diff --git a/.gitignore b/.gitignore index a09c0df..3550b13 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ /target/ -/.disasmer/ -**/.disasmer/ +/.clusterflux/ +**/.clusterflux/ /vscode-extension/node_modules/ /private/*/Cargo.lock /private/*/target/ diff --git a/CLUSTERFLUX_PUBLIC_TREE.json b/CLUSTERFLUX_PUBLIC_TREE.json new file mode 100644 index 0000000..46cd4b0 --- /dev/null +++ b/CLUSTERFLUX_PUBLIC_TREE.json @@ -0,0 +1,22 @@ +{ + "kind": "clusterflux-filtered-public-tree", + "source_commit": "a43e907efd9d1561c23fe73499478e881f868355", + "release_name": "dryrun-a43e907efd9d", + "filtered_out": [ + "private/**", + "internal/**", + "experiments/**", + ".git", + "target", + "git-ignored source paths", + "root/*.md except README.md", + "**/.clusterflux/**", + ".forgejo/**" + ], + "public_export": { + "host_neutral": true, + "include_forgejo_workflows": false + }, + "forgejo_host": "git.michelpaulissen.com", + "default_hosted_coordinator_endpoint": "https://clusterflux.michelpaulissen.com" +} diff --git a/Cargo.lock b/Cargo.lock index e7f5400..bb110d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -197,15 +197,15 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.12.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cc" -version = "1.2.65" +version = "1.2.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" dependencies = [ "find-msvc-tools", "shlex", @@ -274,6 +274,134 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "clusterflux-cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64", + "clap", + "clusterflux-control", + "clusterflux-core", + "serde", + "serde_json", + "sha2 0.10.9", + "tempfile", + "wasmparser 0.245.1", +] + +[[package]] +name = "clusterflux-control" +version = "0.1.0" +dependencies = [ + "serde_json", + "thiserror 1.0.69", + "ureq", +] + +[[package]] +name = "clusterflux-coordinator" +version = "0.1.0" +dependencies = [ + "base64", + "clusterflux-core", + "clusterflux-wasm-runtime", + "postgres", + "serde", + "serde_json", + "sha2 0.10.9", + "tempfile", + "thiserror 1.0.69", + "wasmparser 0.245.1", +] + +[[package]] +name = "clusterflux-core" +version = "0.1.0" +dependencies = [ + "base64", + "ed25519-dalek", + "getrandom 0.3.4", + "hex", + "serde", + "serde_json", + "sha2 0.10.9", + "syn", + "tempfile", + "thiserror 1.0.69", +] + +[[package]] +name = "clusterflux-dap" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64", + "clusterflux-control", + "clusterflux-core", + "clusterflux-node", + "serde_json", + "tempfile", +] + +[[package]] +name = "clusterflux-macros" +version = "0.1.0" +dependencies = [ + "hex", + "proc-macro2", + "quote", + "serde_json", + "sha2 0.10.9", + "syn", +] + +[[package]] +name = "clusterflux-node" +version = "0.1.0" +dependencies = [ + "base64", + "clusterflux-control", + "clusterflux-core", + "clusterflux-wasm-runtime", + "libc", + "quinn", + "rcgen", + "serde", + "serde_json", + "sha2 0.10.9", + "tempfile", + "thiserror 1.0.69", + "tokio", + "wasmparser 0.245.1", + "wat", +] + +[[package]] +name = "clusterflux-sdk" +version = "0.1.0" +dependencies = [ + "base64", + "clusterflux-control", + "clusterflux-core", + "clusterflux-macros", + "futures-executor", + "serde", + "serde_json", +] + +[[package]] +name = "clusterflux-wasm-runtime" +version = "0.1.0" +dependencies = [ + "clusterflux-core", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", + "wasmtime", +] + [[package]] name = "cmov" version = "0.5.4" @@ -589,133 +717,6 @@ dependencies = [ "ctutils", ] -[[package]] -name = "disasmer-cli" -version = "0.1.0" -dependencies = [ - "anyhow", - "base64", - "clap", - "disasmer-control", - "disasmer-core", - "serde", - "serde_json", - "sha2 0.10.9", - "tempfile", - "wasmparser 0.245.1", -] - -[[package]] -name = "disasmer-control" -version = "0.1.0" -dependencies = [ - "serde_json", - "thiserror 1.0.69", - "ureq", -] - -[[package]] -name = "disasmer-coordinator" -version = "0.1.0" -dependencies = [ - "base64", - "disasmer-core", - "disasmer-wasm-runtime", - "postgres", - "serde", - "serde_json", - "sha2 0.10.9", - "tempfile", - "thiserror 1.0.69", - "wasmparser 0.245.1", -] - -[[package]] -name = "disasmer-core" -version = "0.1.0" -dependencies = [ - "base64", - "ed25519-dalek", - "getrandom 0.3.4", - "hex", - "serde", - "serde_json", - "sha2 0.10.9", - "syn", - "tempfile", - "thiserror 1.0.69", -] - -[[package]] -name = "disasmer-dap" -version = "0.1.0" -dependencies = [ - "anyhow", - "base64", - "disasmer-control", - "disasmer-core", - "disasmer-node", - "serde_json", - "tempfile", -] - -[[package]] -name = "disasmer-macros" -version = "0.1.0" -dependencies = [ - "hex", - "proc-macro2", - "quote", - "serde_json", - "sha2 0.10.9", - "syn", -] - -[[package]] -name = "disasmer-node" -version = "0.1.0" -dependencies = [ - "base64", - "disasmer-control", - "disasmer-core", - "disasmer-wasm-runtime", - "libc", - "quinn", - "rcgen", - "serde", - "serde_json", - "sha2 0.10.9", - "tempfile", - "thiserror 1.0.69", - "tokio", - "wasmparser 0.245.1", -] - -[[package]] -name = "disasmer-sdk" -version = "0.1.0" -dependencies = [ - "base64", - "disasmer-control", - "disasmer-core", - "disasmer-macros", - "futures-executor", - "serde", - "serde_json", -] - -[[package]] -name = "disasmer-wasm-runtime" -version = "0.1.0" -dependencies = [ - "disasmer-core", - "serde", - "serde_json", - "thiserror 1.0.69", - "tokio", - "wasmtime", -] - [[package]] name = "displaydoc" version = "0.2.6" @@ -912,11 +913,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi 5.3.0", "wasip2", - "wasm-bindgen", ] [[package]] @@ -926,9 +925,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -1147,7 +1148,7 @@ dependencies = [ name = "launch-build-demo" version = "0.1.0" dependencies = [ - "disasmer-sdk", + "clusterflux-sdk", "futures-executor", "serde", "serde_json", @@ -1179,9 +1180,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.17" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" dependencies = [ "libc", ] @@ -1240,9 +1241,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.2" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memfd" @@ -1261,9 +1262,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "mio" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "wasi 0.11.1+wasi-snapshot-preview1", @@ -1282,9 +1283,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", @@ -1478,7 +1479,7 @@ dependencies = [ "hmac", "md-5", "memchr", - "rand 0.10.1", + "rand", "sha2 0.11.0", "stringprep", ] @@ -1511,15 +1512,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - [[package]] name = "proc-macro2" version = "1.0.106" @@ -1574,14 +1566,15 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.15" +version = "0.11.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" dependencies = [ "bytes", - "getrandom 0.3.4", + "getrandom 0.4.3", "lru-slab", - "rand 0.9.4", + "rand", + "rand_pcg", "ring", "rustc-hash", "rustls", @@ -1595,16 +1588,16 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.14" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" dependencies = [ "cfg_aliases", "libc", "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1630,35 +1623,15 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.9.4" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" -dependencies = [ - "rand_chacha", - "rand_core 0.9.5", -] - -[[package]] -name = "rand" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", "getrandom 0.4.3", "rand_core 0.10.1", ] -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - [[package]] name = "rand_core" version = "0.6.4" @@ -1668,21 +1641,21 @@ dependencies = [ "getrandom 0.2.17", ] -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - [[package]] name = "rand_core" version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "rcgen" version = "0.14.8" @@ -1736,9 +1709,9 @@ dependencies = [ [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc_version" @@ -1773,9 +1746,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.41" +version = "0.23.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" dependencies = [ "log", "once_cell", @@ -1788,9 +1761,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" dependencies = [ "web-time", "zeroize", @@ -1809,9 +1782,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "scopeguard" @@ -1928,9 +1901,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -2067,9 +2040,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.51" +version = "0.3.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" dependencies = [ "deranged", "num-conv", @@ -2087,9 +2060,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.30" +version = "0.2.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" dependencies = [ "num-conv", "time-core", @@ -2107,9 +2080,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" dependencies = [ "tinyvec_macros", ] @@ -2165,7 +2138,7 @@ dependencies = [ "pin-project-lite", "postgres-protocol", "postgres-types", - "rand 0.10.1", + "rand", "socket2", "tokio", "tokio-util", @@ -2390,12 +2363,12 @@ dependencies = [ [[package]] name = "wasm-encoder" -version = "0.252.0" +version = "0.253.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8185ae345fa5687c054626ff9a50e7089797a343d9904d1dc9820eb4c4d3196f" +checksum = "59972d6cd272259de647b7c1f1912e45e289c75ffd4be04e10695507cd7e1b59" dependencies = [ "leb128fmt", - "wasmparser 0.252.0", + "wasmparser 0.253.0", ] [[package]] @@ -2413,9 +2386,9 @@ dependencies = [ [[package]] name = "wasmparser" -version = "0.252.0" +version = "0.253.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3eb099dcadcde5be9eef55e3a337128efd4e44b4c93122487e4d2e4e1c6627c" +checksum = "19db11f87d2486580e1e8b6f494c54df7e0566b87d0b599db843c24019667339" dependencies = [ "bitflags", "indexmap", @@ -2634,22 +2607,22 @@ dependencies = [ [[package]] name = "wast" -version = "252.0.0" +version = "253.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "942a3449d6a593fccc111a6241c8df52bda168af30e40bf9580d4394d7374c65" +checksum = "d3264542f8965c5d84fb1085d924bfba9a6314bb228eff13a2de14d7627664d0" dependencies = [ "bumpalo", "leb128fmt", "memchr", "unicode-width", - "wasm-encoder 0.252.0", + "wasm-encoder 0.253.0", ] [[package]] name = "wat" -version = "1.252.0" +version = "1.253.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c72a4ba7088f7bac94cf516e49882bdf97068904a563768cf249efc839ec42cb" +checksum = "4bfc5ce906144200c972ec617470aa35bd847472e170b26dde3e80541c674055" dependencies = [ "wast", ] @@ -2726,16 +2699,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -2753,31 +2717,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -2786,96 +2733,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "wit-bindgen" version = "0.57.1" @@ -2958,26 +2857,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zerocopy" -version = "0.8.52" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.52" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "zerofrom" version = "0.1.8" @@ -3040,6 +2919,6 @@ dependencies = [ [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 3095f9b..7ee4655 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,22 +1,22 @@ [workspace] resolver = "2" members = [ - "crates/disasmer-cli", - "crates/disasmer-control", - "crates/disasmer-coordinator", - "crates/disasmer-core", - "crates/disasmer-dap", - "crates/disasmer-macros", - "crates/disasmer-node", - "crates/disasmer-sdk", - "crates/disasmer-wasm-runtime", + "crates/clusterflux-cli", + "crates/clusterflux-control", + "crates/clusterflux-coordinator", + "crates/clusterflux-core", + "crates/clusterflux-dap", + "crates/clusterflux-macros", + "crates/clusterflux-node", + "crates/clusterflux-sdk", + "crates/clusterflux-wasm-runtime", "examples/launch-build-demo", ] [workspace.package] edition = "2021" license = "Apache-2.0 OR MIT" -repository = "https://git.michelpaulissen.com/michel/disasmer" +repository = "https://git.michelpaulissen.com/michel/clusterflux" [workspace.dependencies] anyhow = "1.0" diff --git a/DISASMER_PUBLIC_TREE.json b/DISASMER_PUBLIC_TREE.json deleted file mode 100644 index 8c32bc8..0000000 --- a/DISASMER_PUBLIC_TREE.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "kind": "disasmer-filtered-public-tree", - "source_commit": "20b59dc46b72103c2f8a516c692b5cc3d54fab19", - "release_name": "dryrun-20b59dc46b72", - "filtered_out": [ - "private/**", - "experiments/**", - ".git", - "target", - "git-ignored source paths", - "root/*.md except README.md and SECURITY.md", - "**/.disasmer/**", - ".forgejo/**" - ], - "public_export": { - "host_neutral": true, - "include_forgejo_workflows": false - }, - "forgejo_host": "git.michelpaulissen.com", - "default_hosted_coordinator_endpoint": "https://disasmer.michelpaulissen.com" -} diff --git a/LICENSE-MIT b/LICENSE-MIT index aa62566..473078e 100644 --- a/LICENSE-MIT +++ b/LICENSE-MIT @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 Disasmer contributors +Copyright (c) 2026 Clusterflux contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 76475c7..55fe563 100644 --- a/README.md +++ b/README.md @@ -1,421 +1,80 @@ -# Disasmer - -Disasmer is a distributed Wasm runtime for source-debuggable, local-first execution. The flagship workflow is a build written as Rust source code: one virtual process, many virtual threads/tasks, ordinary debugger controls, attached user nodes, and explicit artifact handling. - -The MVP shape is: - -```text -examples/launch-build-demo/envs/linux/Containerfile -examples/launch-build-demo/src/build.rs -``` - -```rust -use disasmer::env; - -let linux = env!("linux"); -``` - -`envs//Containerfile` or `envs//Dockerfile` defines an environment named ``. Source code references that environment by logical name with `env!("name")`, not by runner label or machine name. - -Bundle metadata is inspectable before launch: - -```bash -disasmer bundle inspect --project examples/launch-build-demo -``` - -The inspection output includes discovered environments, selected input digests, default source-provider choices, and the bundle identity. Container images are not embedded by default. - -The development example lives in `examples/launch-build-demo`. Its coordinator -main prepares a node-local source snapshot, compiles a real static executable -with `cc` in the declared rootless-Podman environment, flushes the output file, -and passes its artifact handle to a downstream deterministic packaging task. - -Canonical public documentation covers the [architecture](docs/architecture.md), -[security model](docs/security.md), [task ABI](docs/task-abi.md), -[artifact semantics](docs/artifacts.md), [debugging](docs/debugging.md), and -[self-hosting](docs/self-hosting.md). - -## Quickstart - -Prerequisites for the local MVP path are a Rust toolchain, Node.js for smoke -scripts, rootless Podman for Linux environment materialization, and VS Code when -debugging through the extension. - -Build the public workspace and install the local command binaries: - -```bash -cargo build --workspace -cargo install --path crates/disasmer-cli --bin disasmer -cargo install --path crates/disasmer-node --bin disasmer-node -cargo install --path crates/disasmer-coordinator --bin disasmer-coordinator -cargo install --path crates/disasmer-dap --bin disasmer-debug-dap -``` - -Install or run the VS Code extension from this checkout: - -```bash -code --extensionDevelopmentPath "$(pwd)/vscode-extension" examples/launch-build-demo -``` - -For a persistent local install, copy or symlink `vscode-extension` into the VS -Code user extension directory under a versioned folder such as -`disasmer.disasmer-vscode-0.1.0`, then restart VS Code. - -Inspect and run the flagship project: - -```bash -disasmer bundle inspect --project examples/launch-build-demo -disasmer run --local --project examples/launch-build-demo build -``` - -For explicit process-boundary inspection, run the local services yourself: - -```bash -disasmer-coordinator --listen 127.0.0.1:7999 --allow-local-trusted-loopback -disasmer node attach --coordinator 127.0.0.1:7999 -disasmer run --local --coordinator 127.0.0.1:7999 --project examples/launch-build-demo build -``` - -`disasmer run --local` starts a loopback coordinator and user-attached local -node for the run when no coordinator address is supplied. `disasmer run [entry]` -selects an entrypoint such as `build`; `--project` overrides the project -directory. When the CLI has a hosted login and no local override is selected, -`disasmer run` uses the hosted coordinator. Use `--local` or -`--coordinator ` to force local coordinator mode. The -`--allow-local-trusted-loopback` switch is an explicit single-machine development -compatibility mode: it is rejected on non-loopback listeners and must not be -used for a shared or remotely reachable coordinator. - -The MVP keeps bundles inline in the existing 1 MiB control frame. The CLI -therefore supports raw Wasm modules up to approximately 696 KiB (712704 bytes) -after reserving space for the authenticated request and task metadata. It builds, -resolves, validates, and checks this boundary before creating the virtual -process. Larger bundles fail without leaving an active process; a larger-bundle -transport is explicitly post-MVP. - -In VS Code, open `examples/launch-build-demo`, start the `Disasmer: Launch -Virtual Process` configuration or press F5, inspect the Disasmer nodes, -processes, logs, artifacts, and inspector views, and use the ordinary debugger -controls for breakpoints, continue, pause, and restart. Pause is -participant-acknowledged; source stepping still fails explicitly instead of -simulating success. Restarting a terminal task rebuilds the bundle: a compatible -edit launches only that task under a fresh instance ID while preserving its -validated arguments, handles, environment, and clean VFS boundary. An active -task cannot be silently replaced, and an ABI-incompatible edit clearly requires -a whole-process restart. Artifact download -behavior is exercised by `node scripts/artifact-download-smoke.js`; final export -is explicit and should go through an attached receiver node or user-provided -storage integration rather than hidden coordinator storage. - -Cleanup for the local quickstart is ordinary process and artifact cleanup: stop -the coordinator process, stop attached node processes, remove any temporary -artifacts under `target/acceptance/`, and remove the local VS Code extension copy -or symlink if one was installed. - -## First-Run Diagnostics - -Use these messages as first checks before debugging infrastructure: - -- Missing nodes: attach a node with `disasmer node attach --coordinator ` or check the Disasmer Nodes view. -- Missing environments: add `envs//Containerfile` or `envs//Dockerfile`; the VS Code extension highlights unknown `env!("name")` references. -- Quota limits: hosted/community denials are returned before dispatch with a specific community tier reason. -- Unavailable artifacts: downloads fail before showing a link when retention, size, authorization, quota, or connectivity makes streaming impossible. -- Auth failures: browser login uses the device/browser flow, while agents and nodes use public-key identity and scoped enrollment grants. -- Failed debug freezes: all-stop reports the participant that could not freeze instead of claiming success. -- Source-provider capability gaps: source preparation stays pending until a node reports `SourceGit` or `SourceFilesystem`. - -## Repository Shape - -The public workspace contains the open-source contract layer: - -```text -crates/disasmer-core shared identities, policy traits, scheduling, VFS/artifacts -crates/disasmer-coordinator local coordinator state model -crates/disasmer-dap Debug Adapter Protocol adapter for VS Code/debug clients -crates/disasmer-macros #[disasmer::main] and #[disasmer::task] -crates/disasmer-node node backend interfaces and local node runtime -crates/disasmer-cli CLI command surface -crates/disasmer-sdk user-facing Rust SDK -scripts/verify-public-split.sh -``` - -Hosted-only policy code lives under `private/**`. The public split is verified by copying the repo without `private/**` and running the public workspace tests. - -Deployment names and authority names describe different things. The -**standalone Core coordinator** is the open-source self-hosted server. The -**Hosted coordinator** is the managed deployment that adds hosted policy around -Core. Within either deployment, **Client**, **Node**, **Identity**, and -**Operator** name authority lanes. “Public” and “private” describe source or -release visibility; they do not grant authority. - -## Hosted Coordinator - -Public CLI binaries default to the hosted coordinator at -`https://disasmer.michelpaulissen.com` unless local/self-hosted mode is -selected. The CLI asks the hosted coordinator to create a state-, nonce-, and -PKCE-bound login transaction, opens the returned Authentik authorization URL, -and polls with an opaque transaction secret. Authentik returns to the hosted -`/auth/callback`; provider codes and identity claims never pass through the CLI. -The hosted website for the MVP is deliberately barebones HTML with no CSS; -layout and UX polish are later work. - -The hosted coordinator combines private hosted policy code with the open-source -Core coordinator. Client traffic uses authenticated user sessions, Identity -traffic handles browser/OIDC login, Node traffic is signed by enrolled workers, -and privileged Operator actions require a separate private credential. It does -not give community users arbitrary hosted native commands or hosted containers. -Real work runs on attached nodes. - -Self-hosted users do not need the hosted website. They can run the public -coordinator and public node runtime from this repository, attach their own -nodes, and use the CLI/API for normal project, process, log, artifact, debug, -quota, and admin operations. - -The standalone Core coordinator uses strict Client authority by default. A -self-hosted operator supplies one scoped bootstrap session through protected -service configuration: - -```bash -DISASMER_SELF_HOSTED_SESSION_SECRET="$SELF_HOSTED_SESSION_SECRET" \ -DISASMER_SELF_HOSTED_TENANT=my-team \ -DISASMER_SELF_HOSTED_PROJECT=my-project \ -DISASMER_SELF_HOSTED_USER=me \ -disasmer-coordinator --listen 127.0.0.1:7999 -``` - -From the project directory, connect the CLI without placing the secret in a -process argument: - -```bash -printf '%s\n' "$SELF_HOSTED_SESSION_SECRET" | \ - disasmer auth connect-self-hosted \ - --coordinator 127.0.0.1:7999 \ - --tenant my-team --project-id my-project --user me \ - --session-secret-stdin -``` - -The CLI verifies the scoped session before writing `.disasmer/session.json`; on -Unix the file is mode `0600`. Enrollment-grant creation and ordinary Client -operations then use this authenticated session. Signed Node and Agent requests -remain separate authority lanes. - -The native Core transport is plaintext and therefore refuses non-loopback -listeners. For administration from another machine, create an authenticated SSH -tunnel to the coordinator loopback port and connect the CLI to the tunnel's -local `127.0.0.1` endpoint. Do not expose port 7999 directly. - -Release dry-run mechanics, Forgejo publication, filtered repository preparation, -deployment evidence, and public-release e2e commands are source-side release -operations, not the product quickstart. They live in -`public_release_dryrun.md`. - -## Coordinator State - -The coordinator separates durable project and identity state from live runtime state. Tenants, users, projects, node identities, credentials, source-provider configuration, durable service policy records, and explicit project permissions can be stored in Postgres. Active virtual processes, live virtual threads, scheduler state, debug epochs, ephemeral VFS manifests, and transient artifact locations remain in coordinator memory for the MVP and are not represented in the Postgres schema. - -Process lifecycle is independent of task count. `disasmer process list` and -`disasmer process status` read the live virtual-process registry directly. -`disasmer process cancel --yes` requests cooperative cancellation and leaves the -process visible as `cancelling` until its runtime exits. `disasmer process abort ---yes` is the explicit forced operation: it terminates the coordinator-side -process identity, signals active node work to stop, and immediately releases the -single-process slot. VS Code uses the same live registry for its sidebar and -offers Attach, Restart, Cancel, or Abort when F5 finds an existing process. - -The local workspace and the hosted **Coordinator Project** are distinct. A -Coordinator Project is the identity, authorization, quota, and one-active-process -boundary; a local workspace is the source/bundle launch target. When another -workspace launch is occupying the same Coordinator Project, VS Code avoids -blindly attaching with the wrong source and offers different-project actions. - -## Local-First Builds - -Disasmer is designed so local source checkouts and large outputs stay node-local unless user code or policy explicitly moves bytes. - -`flush()` publishes metadata and visibility information. It makes produced artifacts visible to downstream tasks without implying durable coordinator storage. - -`sync()` is explicit. It may move bytes to another node or user-provided storage according to code or configured policy. - -User-provided storage/export integrations are ordinary project code or external -commands, such as publishing through a CLI. Disasmer records metadata and -coordinates capability, scheduling, and transfer decisions; it does not provide -or manage an explicit artifact-store feature as part of the MVP. - -Artifacts are best-effort retained on nodes by default. If unsynced node-local bytes are garbage collected or the retaining node is lost, the artifact becomes unavailable instead of silently recovering from coordinator storage. - -Artifact downloads are created only when current retention, size, authorization, and community tier accounting allow it. Download links are scoped to the tenant, project, process, artifact, actor, and policy context, expire after a bounded TTL, can be revoked, and streaming usage is charged before and during transfer. - -On Linux nodes, Containerfile and Dockerfile environments are materialized with rootless user Podman. The node runtime has a concrete runner path for `podman build` followed by `podman run`; tests use a recording runner, while real attached nodes can use the `std::process::Command` runner. A local build from an attached local checkout uses a node-local bind mount into the selected environment; the coordinator does not create a full-repo tarball or route compiler file reads. Command output is associated with the virtual thread that started it and can be staged into the VFS artifact namespace. `node scripts/wasmtime-node-smoke.js` builds the launch example for `wasm32-unknown-unknown`, runs its exported task through the node Wasmtime runtime, and verifies a Wasm task can invoke a native command through the versioned `disasmer.command_run_v1` host capability without moving command execution into the hosted coordinator. - -## Nodes And Trust - -Users attach their own nodes for real work. The hosted coordinator is a control plane for identity, rendezvous, scheduling metadata, debug sessions, logs, artifact metadata, and operator state. The community tier does not provide arbitrary hosted native commands or hosted containers. - -Agent and worker automation should use enrolled public-key identity. User OAuth or browser session tokens are not task credentials and should not be passed to nodes. - -```bash -disasmer login -disasmer login --browser -disasmer agent enroll --public-key -DISASMER_AGENT_PRIVATE_KEY= disasmer run --non-interactive build -disasmer run --local --non-interactive build -disasmer node attach --enrollment-grant --public-key -disasmer-node --coordinator https://disasmer.michelpaulissen.com --tenant --project-id --node --public-key --enrollment-grant --worker --emit-ready -``` - -`disasmer login` uses a short-lived human device flow and does not ask users to paste long-lived secrets. `disasmer login --browser` opens the server-provided authorization URL, polls the hosted transaction, and stores only normalized CLI-session metadata in `.disasmer/session.json`; provider authorization codes, OAuth tokens, and identity claims are not accepted or persisted by the CLI. `disasmer login --browser --plan` is the diagnostic JSON login plan path. `--non-interactive` never opens a browser; `disasmer login --browser --non-interactive` and an unauthenticated implicit hosted `disasmer run --non-interactive` fail with an authentication-category report and next actions instead of prompting or guessing. Node attach exchanges a short-lived enrollment grant for a scoped long-lived node identity and then exits; a long-lived `disasmer-node --worker` process must be running for coordinator-side launches and DAP live-services debugging to place real command/container work on that node. - -Hosted agent public keys are project-scoped records: a signed-in user can -register, list, rotate, and revoke an agent key without giving the agent browser -or OAuth credentials. An enrolled agent can run CLI commands non-interactively -with `DISASMER_AGENT_PRIVATE_KEY`; the CLI derives the registered public key, -signs workflow requests, and records the public-key fingerprint in its run plan -instead of starting a browser flow. `DISASMER_AGENT_PUBLIC_KEY` may identify or -cross-check the registered key, but cannot authenticate without the private key. - -`disasmer node attach` auto-detects OS, architecture, command/container, -VFS/artifact, source-provider, and environment capabilities. `--cap ` is -available as an override for unusual local setups, not as required normal -configuration. - -For local process-boundary testing, the public workspace includes a TCP JSON-line coordinator service and a node runtime binary: - -```bash -cargo run -p disasmer-coordinator -- --listen 127.0.0.1:0 -cargo run -p disasmer-node -- --coordinator --worker \ - --project-root examples/launch-build-demo -``` - -`scripts/real-flagship-harness.js` starts those as separate long-lived processes. The real `build` Wasm entrypoint spawns its named Wasm tasks through the coordinator; the Linux task selects `env!("linux")`, runs its command through rootless Podman against the node-local checkout, publishes content-addressed bytes, and reports task and artifact metadata. The download and export smokes reuse that workflow and then retrieve the retained bytes through an authorized reverse stream. `scripts/wasmtime-assignment-smoke.js` separately proves cooperative cancellation observed by task code and forced abort of uncooperative Wasm/native work. - -For self-hosted local clouds, trusted teams, or VPN deployments, run the public -coordinator on the chosen listen address and attach team-owned nodes with their -public keys. This path uses the open-source coordinator, scheduler, node -heartbeat, task metadata, retained-node downloads, and direct node-to-node export -planning without private hosted OIDC or community tier policy modules. -`node scripts/self-hosted-coordinator-smoke.js` exercises that public path with -two trusted Linux nodes. - -## Debugging - -The VS Code extension contributes the normal `disasmer` debug type, refreshes bundle metadata before launch, and starts the DAP adapter. Bundle probe ownership comes from actual `#[disasmer::main]` and `#[disasmer::task]` declarations and their declared names, not function-name heuristics. In the verified local-services path, an executing Wasm probe reports a breakpoint hit, every active participant acknowledges the freeze, and only then does DAP emit an all-stop `stopped` event. Runtime child tasks appear as dynamic virtual threads. Continue requests a real coordinator resume. Stack, task-argument, live task-handle, native-command status, and output data come from node acknowledgements; values the runtime did not report are labeled unavailable instead of being inferred or fabricated. Each dispatched task captures a clean entry checkpoint containing its TaskSpec, bundle, ABI, arguments, environment digest, VFS epoch, and required-artifact manifest. After a terminal failure, `restartFrame` rebuilds the current source and relaunches only a boundary-compatible task with a fresh instance ID; an ABI-incompatible edit requires a whole virtual-process restart without mutating the existing process. Active-task restart and arbitrary source stepping fail explicitly. Disasmer-specific side views expose nodes, virtual processes, logs, artifacts, and inspector state alongside the normal debugger UI. - -Live-services launch now uses the same bundle builder, Wasm entrypoint TaskSpec, -probe plan, worker placement, Debug Epoch observation, continuation, and restart -code as local-services; only ownership of the already-attached worker differs. -That path remains labeled integration-verified until the final revision-bound -hosted E2E is recorded. F5 and DAP acceptance therefore continue to exercise -local services as well as the strict hosted deployment rather than treating -deployment configuration alone as live proof. - -The built-in operator panel preview is rendered by the coordinator from live -process, task, log, and artifact metadata. It uses typed widgets only, keeps -program UI events scoped and rate-limited, and keeps control-plane actions such -as debug, cancel, restart, and artifact download available when program UI events -are disabled. - -## Windows - -Windows node support uses the same node protocol and capability model as Linux. MVP Windows command execution is user-attached development execution, labeled `windows-command-dev`. Production-grade managed Windows sandboxing is behind an explicit backend stub until it is implemented and validated independently. When the acceptance report shows `windows_validation: "not-run"`, Windows support is best-effort and unvalidated for that release; the public smoke only verifies protocol, placement, metadata, and download behavior for a Windows-capable node identity. - -Real Windows validation runs through the manual Forgejo workflow -`.forgejo/workflows/windows-validation.yml` when the intermittent Windows runner is -online. That workflow records `windows_validation: "forgejo-windows-runner"` in -the acceptance report, runs `disasmer node attach` against a coordinator, starts a -Windows node process, executes a simple `windows-command-dev` command, publishes -artifact metadata, checks Windows placement, and verifies the debugger can show a -Windows virtual thread. - -## Source Providers - -Git is an optional source-provider module included by default. The VFS core depends on source-provider manifests and snapshot handles, not on Git internals. Non-Git source providers can implement the public source-provider interface and run snapshot preparation as node work instead of requiring coordinator filesystem access. - -Source preparation is scheduled as node work. The coordinator can return a -pending source-preparation status while waiting for any capable node, then assign -the work after a node reports `SourceGit` or `SourceFilesystem` capability; it -does not need checkout or provider access itself. - -Source-provider manifests are validated as public input and must declare a -local-first transfer policy: local source bytes stay node-local, the coordinator -does not receive source bytes by default, and remote preparation uses required -content or explicit snapshot chunks rather than a full-repo tarball. - -## Verification - -Run the public workspace checks: - -```bash -scripts/acceptance-public.sh -``` - -Run the CLI-first non-e2e gate before any final public-release e2e attempt: - -```bash -scripts/acceptance-cli-first.sh -``` - -This gate composes the CLI/API, local-services, self-hosted, hosted-compat, -debugger, artifact, safety, and public dry-run contract checks without invoking -`public-release-dryrun-e2e.js` or the final evidence verifier. Leave -`DISASMER_PUBLIC_RELEASE_DRYRUN_E2E` and `DISASMER_PUBLIC_RELEASE_DRYRUN_FINAL` -unset until the CLI-first criteria are otherwise implemented to pass. - -Or run the individual public checks: - -```bash -cargo test --workspace -node scripts/acceptance-report-smoke.js -node scripts/public-private-boundary-smoke.js -node scripts/release-blocker-smoke.js -node scripts/docs-smoke.js -node scripts/public-release-dryrun-contract-smoke.js -node scripts/wasmtime-node-smoke.js -node scripts/podman-backend-smoke.js -node scripts/vscode-extension-smoke.js -node scripts/vscode-f5-smoke.js -node scripts/node-attach-smoke.js -node scripts/wasmtime-assignment-smoke.js -node scripts/cli-local-run-smoke.js -node scripts/artifact-download-smoke.js -node scripts/artifact-export-smoke.js -node scripts/operator-panel-smoke.js -node scripts/source-preparation-smoke.js -node scripts/scheduler-placement-smoke.js -node scripts/windows-best-effort-smoke.js -node scripts/quic-smoke.js -node scripts/flagship-demo-smoke.js -node scripts/dap-smoke.js -node scripts/prepare-public-release-dryrun.js -scripts/verify-public-split.sh -``` - -When the Forgejo Windows runner is online, run the manual `Windows validation` -workflow as the release Windows gate. The local equivalent on a Windows machine is: - -```powershell -$env:DISASMER_WINDOWS_VALIDATION = "forgejo-windows-runner" -node scripts/acceptance-report.js windows -cargo fmt --all --check -cargo test -p disasmer-node windows_backend_is_labeled_user_attached_dev_execution -node scripts/windows-runner-smoke.js -``` - -Run private hosted policy checks separately: - -```bash -scripts/acceptance-private.sh -``` - -The private hosted gate includes `hosted-client-compat-smoke.js`, which starts -the hosted dry-run service locally and verifies that the released Client CLI and -signed Node runtime can attach, launch work through coordinator task assignment, -and publish debug/log/artifact metadata through the hosted Client protocol -boundary. - -Both acceptance scripts write an environment report under `target/acceptance/` -with the commit SHA, toolchain versions, OS/kernel, Podman/Postgres discovery, -browser/VS Code harness metadata, and Windows-validation status. The report -records Podman as `available` only when rootless Podman is discoverable; if it is -missing or not rootless, Linux Podman backend behavior is marked `incomplete` -with the reason and the Podman backend smoke blocks acceptance with the same -incomplete reason. +# Clusterflux + +Clusterflux runs a Rust-defined workflow as one distributed virtual process. A +coordinator hosts the async main, while attached nodes execute Wasm tasks, +rootless containers, and native commands. Tasks exchange canonical values and +typed handles instead of sharing host memory. + +Start with [Getting started](docs/getting-started.md). It takes you through +authentication, project setup, node enrollment, a run, debugging, task restart, +and artifact download. + +## What you get + +- One virtual process with distinct task instances and restart attempts. +- Bundle-declared environments resolved by digest. +- Native work only on nodes you attach. +- Metadata-first artifacts whose bytes remain on retaining nodes by default. +- VS Code debugging backed by coordinator task and attempt snapshots. +- Full and partial Debug Epochs with explicit consistency status. +- Human Authentik sessions plus scoped public-key identities for agents and nodes. +- A public coordinator, node runtime, CLI, SDK, and DAP adapter for self-hosting. + +## Install from this checkout + +~~~bash +cargo install --path crates/clusterflux-cli --bin clusterflux +cargo install --path crates/clusterflux-node --bin clusterflux-node +cargo install --path crates/clusterflux-coordinator --bin clusterflux-coordinator +cargo install --path crates/clusterflux-dap --bin clusterflux-debug-dap +~~~ + +Rootless Podman is required on Linux nodes that build or run a declared +Containerfile environment. Install VS Code when you want the graphical debug +workflow. + +## First run + +For the hosted service: + +~~~bash +clusterflux login --browser +clusterflux project init --new-project my-project --name "My Project" --yes +clusterflux node enroll --project-id my-project --json +~~~ + +Use the returned short-lived enrollment grant once with "clusterflux node +attach", then start "clusterflux-node --worker" from the project directory. See +[Nodes](docs/nodes.md) for the complete sequence. + +Choose an entrypoint and run it: + +~~~bash +clusterflux bundle inspect --project examples/launch-build-demo +clusterflux run --project examples/launch-build-demo build +~~~ + +Inspect the result: + +~~~bash +clusterflux process status +clusterflux task list +clusterflux logs +clusterflux artifact list +clusterflux artifact download app.txt --to ./app.txt +~~~ + +To run your own coordinator instead, follow [Self-hosting](docs/self-hosting.md). +The hosted website is not required for self-hosted projects. + +## Documentation + +- [Getting started](docs/getting-started.md) +- [Architecture](docs/architecture.md) +- [Nodes](docs/nodes.md) +- [Environments](docs/environments.md) +- [Artifacts](docs/artifacts.md) +- [Debugging](docs/debugging.md) +- [Task ABI](docs/task-abi.md) +- [Self-hosting](docs/self-hosting.md) +- [Security](docs/security.md) diff --git a/SECURITY.md b/SECURITY.md deleted file mode 100644 index 44df78b..0000000 --- a/SECURITY.md +++ /dev/null @@ -1,26 +0,0 @@ -# Security policy - -Disasmer is pre-release software that executes untrusted Wasm and can delegate -explicit host capabilities to attached nodes. Security reports are welcome even -when the affected behavior has not shipped in a numbered release. - -## Supported versions - -Until the first public release, only the current `main` branch is supported. -After releases begin, this table will identify supported release lines. - -## Reporting a vulnerability - -Do not open a public issue for a suspected vulnerability. Email -`ops@michelpaulissen.com` with: - -- the affected commit or release; -- the relevant deployment mode (hosted, self-hosted, or attached node); -- reproduction steps or a minimal proof of concept; -- the security boundary or tenant scope that was crossed; and -- any known mitigations. - -Please avoid accessing other users' data, disrupting hosted services, or -retaining secrets while testing. We will acknowledge receipt, coordinate a fix -and disclosure, and credit reporters who want to be named. No response-time or -bounty commitment is made before the first public release. diff --git a/crates/disasmer-cli/Cargo.toml b/crates/clusterflux-cli/Cargo.toml similarity index 69% rename from crates/disasmer-cli/Cargo.toml rename to crates/clusterflux-cli/Cargo.toml index 9562125..aec8a0b 100644 --- a/crates/disasmer-cli/Cargo.toml +++ b/crates/clusterflux-cli/Cargo.toml @@ -1,20 +1,20 @@ [package] -name = "disasmer-cli" +name = "clusterflux-cli" version = "0.1.0" edition.workspace = true license.workspace = true repository.workspace = true [[bin]] -name = "disasmer" +name = "clusterflux" path = "src/main.rs" [dependencies] anyhow.workspace = true base64.workspace = true clap.workspace = true -disasmer-core = { path = "../disasmer-core" } -disasmer-control = { path = "../disasmer-control" } +clusterflux-core = { path = "../clusterflux-core" } +clusterflux-control = { path = "../clusterflux-control" } serde.workspace = true serde_json.workspace = true sha2.workspace = true diff --git a/crates/disasmer-cli/src/admin.rs b/crates/clusterflux-cli/src/admin.rs similarity index 82% rename from crates/disasmer-cli/src/admin.rs rename to crates/clusterflux-cli/src/admin.rs index 254d38e..83f19a6 100644 --- a/crates/disasmer-cli/src/admin.rs +++ b/crates/clusterflux-cli/src/admin.rs @@ -1,7 +1,7 @@ use std::path::PathBuf; use anyhow::Result; -use disasmer_core::admin_request_proof; +use clusterflux_core::admin_request_proof; use serde_json::{json, Value}; use crate::client::JsonLineSession; @@ -94,31 +94,31 @@ pub(crate) fn admin_bootstrap_report(args: AdminBootstrapArgs, cwd: PathBuf) -> .unwrap_or(json!(false)), "project_init": project_init, "admin_surfaces": { - "coordinator": "disasmer-coordinator", - "project": "disasmer project init/status/list/select", - "node": "disasmer node enroll/list/status/revoke", - "process": "disasmer run/process status/process restart/process cancel", - "logs": "disasmer logs", - "artifacts": "disasmer artifact list/download/export", - "quota": "disasmer quota status", - "policy": "disasmer admin status/suspend-tenant", + "coordinator": "clusterflux-coordinator", + "project": "clusterflux project init/status/list/select", + "node": "clusterflux node enroll/list/status/revoke", + "process": "clusterflux run/process status/process restart/process cancel", + "logs": "clusterflux logs", + "artifacts": "clusterflux artifact list/download/export", + "quota": "clusterflux quota status", + "policy": "clusterflux admin status/suspend-tenant", }, "bootstrap_sequence": [ { "step": "start_self_hosted_coordinator", - "command": "disasmer-coordinator --listen 127.0.0.1:0", + "command": "clusterflux-coordinator --listen 127.0.0.1:0", "private_website_required": false, }, { "step": "create_or_link_project", - "command": "disasmer project init --yes", + "command": "clusterflux project init --yes", "completed": true, "private_website_required": false, }, { "step": "create_node_enrollment_grant", "command": format!( - "disasmer node enroll --coordinator {coordinator} --tenant {} --project-id {}", + "clusterflux node enroll --coordinator {coordinator} --tenant {} --project-id {}", tenant, project ), "private_website_required": false, @@ -126,7 +126,7 @@ pub(crate) fn admin_bootstrap_report(args: AdminBootstrapArgs, cwd: PathBuf) -> { "step": "attach_worker_node", "command": format!( - "disasmer node attach --coordinator {coordinator} --tenant {} --project-id {} --worker", + "clusterflux node attach --coordinator {coordinator} --tenant {} --project-id {} --worker", tenant, project ), "private_website_required": false, @@ -134,7 +134,7 @@ pub(crate) fn admin_bootstrap_report(args: AdminBootstrapArgs, cwd: PathBuf) -> { "step": "run_process", "command": format!( - "disasmer run --coordinator {coordinator} --tenant {} --project-id {}", + "clusterflux run --coordinator {coordinator} --tenant {} --project-id {}", tenant, project ), "private_website_required": false, @@ -142,17 +142,17 @@ pub(crate) fn admin_bootstrap_report(args: AdminBootstrapArgs, cwd: PathBuf) -> { "step": "inspect_status_logs_artifacts", "commands": [ - "disasmer process status", - "disasmer task list", - "disasmer logs", - "disasmer artifact list", - "disasmer quota status", + "clusterflux process status", + "clusterflux task list", + "clusterflux logs", + "clusterflux artifact list", + "clusterflux quota status", ], "private_website_required": false, }, { "step": "revoke_access", - "command": "disasmer admin revoke-node --node --yes", + "command": "clusterflux admin revoke-node --node --yes", "private_website_required": false, } ], @@ -173,7 +173,7 @@ pub(crate) fn admin_suspend_tenant_report(args: AdminSuspendTenantArgs) -> Resul "actor_user": args.scope.user, "target_tenant": tenant, }), - "disasmer admin suspend-tenant --yes".to_owned(), + "clusterflux admin suspend-tenant --yes".to_owned(), )); } if let Some(coordinator) = &args.scope.coordinator { @@ -226,11 +226,11 @@ pub(crate) fn admin_suspend_tenant_report(args: AdminSuspendTenantArgs) -> Resul fn admin_token_for_request(explicit: Option<&str>) -> Result { explicit .map(str::to_owned) - .or_else(|| std::env::var("DISASMER_ADMIN_TOKEN").ok()) + .or_else(|| std::env::var("CLUSTERFLUX_ADMIN_TOKEN").ok()) .filter(|token| !token.trim().is_empty()) .ok_or_else(|| { anyhow::anyhow!( - "admin command requires --admin-token or DISASMER_ADMIN_TOKEN for coordinator requests" + "admin command requires --admin-token or CLUSTERFLUX_ADMIN_TOKEN for coordinator requests" ) }) } diff --git a/crates/disasmer-cli/src/agent.rs b/crates/clusterflux-cli/src/agent.rs similarity index 94% rename from crates/disasmer-cli/src/agent.rs rename to crates/clusterflux-cli/src/agent.rs index 447f5ec..daf4450 100644 --- a/crates/disasmer-cli/src/agent.rs +++ b/crates/clusterflux-cli/src/agent.rs @@ -1,4 +1,4 @@ -use disasmer_core::Digest; +use clusterflux_core::Digest; use serde::Serialize; use crate::AgentEnrollArgs; diff --git a/crates/disasmer-cli/src/artifact.rs b/crates/clusterflux-cli/src/artifact.rs similarity index 98% rename from crates/disasmer-cli/src/artifact.rs rename to crates/clusterflux-cli/src/artifact.rs index def5132..a0440be 100644 --- a/crates/disasmer-cli/src/artifact.rs +++ b/crates/clusterflux-cli/src/artifact.rs @@ -294,7 +294,7 @@ fn download_link_to_path( .pointer("/link/scoped_token_digest") .cloned() .context("artifact download link did not include a scoped token digest")?; - let expected_digest: disasmer_core::Digest = serde_json::from_value( + let expected_digest: clusterflux_core::Digest = serde_json::from_value( link_response .pointer("/link/artifact_digest") .cloned() @@ -326,7 +326,7 @@ fn download_link_to_path( std::fs::create_dir_all(parent) .with_context(|| format!("failed to create {}", parent.display()))?; let mut temporary = tempfile::Builder::new() - .prefix(".disasmer-download-") + .prefix(".clusterflux-download-") .tempfile_in(parent) .with_context(|| { format!( @@ -433,8 +433,8 @@ fn download_link_to_path( })); } let actual_digest_hex = format!("{:x}", hasher.finalize()); - let actual_digest = - disasmer_core::Digest::from_sha256_hex(&actual_digest_hex).map_err(anyhow::Error::msg)?; + let actual_digest = clusterflux_core::Digest::from_sha256_hex(&actual_digest_hex) + .map_err(anyhow::Error::msg)?; if received_bytes != expected_size || actual_digest != expected_digest { return Ok(json!({ "status": "download_integrity_failed", diff --git a/crates/disasmer-cli/src/auth.rs b/crates/clusterflux-cli/src/auth.rs similarity index 96% rename from crates/disasmer-cli/src/auth.rs rename to crates/clusterflux-cli/src/auth.rs index 87810c8..0e4961a 100644 --- a/crates/disasmer-cli/src/auth.rs +++ b/crates/clusterflux-cli/src/auth.rs @@ -268,7 +268,7 @@ fn coordinator_auth_status_summary( "signup_failure_details_exposed": false, "machine_error": cli_error_summary(&message), "error": message, - "next_actions": ["disasmer doctor", "check coordinator status"], + "next_actions": ["clusterflux doctor", "check coordinator status"], "coordinator_session_requests": 0, }); } @@ -301,7 +301,7 @@ fn coordinator_auth_status_summary( "signup_failure_details_exposed": false, "machine_error": cli_error_summary(&message), "error": message, - "next_actions": ["disasmer login --browser"], + "next_actions": ["clusterflux login --browser"], "coordinator_session_requests": 0, }); } @@ -321,7 +321,7 @@ fn coordinator_auth_status_summary( "signup_failure_details_exposed": false, "machine_error": cli_error_summary(&message), "error": message, - "next_actions": ["disasmer doctor", "check coordinator status"], + "next_actions": ["clusterflux doctor", "check coordinator status"], "coordinator_session_requests": session.requests(), }); } @@ -343,7 +343,7 @@ fn coordinator_auth_status_summary( "signup_failure_details_exposed": false, "machine_error": cli_error_summary(message), "coordinator_response_type": "error", - "next_actions": ["disasmer doctor", "disasmer login --browser"], + "next_actions": ["clusterflux doctor", "clusterflux login --browser"], "coordinator_session_requests": coordinator_session_requests, }); } @@ -416,8 +416,8 @@ pub(crate) fn non_interactive_browser_login_report(args: &LoginArgs) -> Value { "browser login requires an interactive browser, but non-interactive mode is enabled"; let next_actions = vec![ "rerun without --non-interactive to open the browser", - "disasmer login --browser --plan", - "use DISASMER_AGENT_PRIVATE_KEY for automation", + "clusterflux login --browser --plan", + "use CLUSTERFLUX_AGENT_PRIVATE_KEY for automation", ]; json!({ "command": "login", @@ -460,11 +460,11 @@ pub(crate) fn auth_state_value(cwd: &Path) -> Result { })) } CliSession::HumanSession => { - let expires_at = std::env::var("DISASMER_TOKEN_EXPIRES_AT").ok(); + let expires_at = std::env::var("CLUSTERFLUX_TOKEN_EXPIRES_AT").ok(); Ok(json!({ "kind": "human", "authenticated": true, - "source": "DISASMER_TOKEN", + "source": "CLUSTERFLUX_TOKEN", "provider_tokens_exposed_to_nodes": false, "expires_at": expires_at, "token_expiry_posture": if expires_at.is_some() { "expires_at" } else { "unknown_env_token" }, @@ -479,7 +479,7 @@ pub(crate) fn auth_state_value(cwd: &Path) -> Result { "kind": "agent_public_key", "authenticated": true, "agent": agent, - "source": "DISASMER_AGENT_PRIVATE_KEY", + "source": "CLUSTERFLUX_AGENT_PRIVATE_KEY", "public_key_fingerprint": public_key_fingerprint, "browser_interaction_required": browser_interaction_required, "token_expiry_posture": "not_applicable_public_key", @@ -667,7 +667,7 @@ pub(crate) fn execute_interactive_browser_login(args: LoginArgs) -> Result Result<()> { - let mut command = if let Some(command) = std::env::var_os("DISASMER_BROWSER_OPEN_COMMAND") { + let mut command = if let Some(command) = std::env::var_os("CLUSTERFLUX_BROWSER_OPEN_COMMAND") { Command::new(command) } else { platform_browser_command() @@ -751,7 +751,7 @@ fn platform_browser_command() -> Command { } fn browser_login_timeout() -> Duration { - let seconds = std::env::var("DISASMER_BROWSER_LOGIN_TIMEOUT_SECONDS") + let seconds = std::env::var("CLUSTERFLUX_BROWSER_LOGIN_TIMEOUT_SECONDS") .ok() .and_then(|value| value.parse::().ok()) .filter(|seconds| *seconds > 0) diff --git a/crates/disasmer-cli/src/auth_scope.rs b/crates/clusterflux-cli/src/auth_scope.rs similarity index 97% rename from crates/disasmer-cli/src/auth_scope.rs rename to crates/clusterflux-cli/src/auth_scope.rs index 39af42f..7fa4c38 100644 --- a/crates/disasmer-cli/src/auth_scope.rs +++ b/crates/clusterflux-cli/src/auth_scope.rs @@ -62,7 +62,7 @@ pub(crate) fn session_scope_mismatch_status(fields: &[&str]) -> Value { "account_state_known": false, "private_moderation_details_exposed": false, "signup_failure_details_exposed": false, - "next_actions": ["disasmer login --browser"], + "next_actions": ["clusterflux login --browser"], }) } diff --git a/crates/disasmer-cli/src/build.rs b/crates/clusterflux-cli/src/build.rs similarity index 94% rename from crates/disasmer-cli/src/build.rs rename to crates/clusterflux-cli/src/build.rs index 51dd7f3..ab8e19e 100644 --- a/crates/disasmer-cli/src/build.rs +++ b/crates/clusterflux-cli/src/build.rs @@ -2,7 +2,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; use anyhow::{bail, Context, Result}; -use disasmer_core::Digest; +use clusterflux_core::Digest; use serde_json::{json, Value}; use wasmparser::{Parser, Payload}; @@ -47,24 +47,24 @@ pub(crate) fn build_report(args: BuildArgs, cwd: PathBuf) -> Result { let environment_manifest = serde_json::to_vec(&inspection.metadata.environments)?; append_custom_section( &mut wasm.bytes, - "disasmer.environments", + "clusterflux.environments", &environment_manifest, ); let bundle_digest = Digest::sha256(&wasm.bytes); - let task_descriptors = descriptor_records(&wasm.bytes, "disasmer.tasks")?; - let entrypoint_descriptors = descriptor_records(&wasm.bytes, "disasmer.entrypoints")?; + let task_descriptors = descriptor_records(&wasm.bytes, "clusterflux.tasks")?; + let entrypoint_descriptors = descriptor_records(&wasm.bytes, "clusterflux.entrypoints")?; if task_descriptors.is_empty() { bail!( - "compiled Wasm module contains no #[disasmer::task] descriptors; annotate at least one exported task" + "compiled Wasm module contains no #[clusterflux::task] descriptors; annotate at least one exported task" ); } if entrypoint_descriptors.is_empty() { bail!( - "compiled Wasm module contains no #[disasmer::main] descriptors; annotate at least one entrypoint" + "compiled Wasm module contains no #[clusterflux::main] descriptors; annotate at least one entrypoint" ); } let output = args.output.unwrap_or_else(|| { - inspection.project.join(".disasmer/build").join( + inspection.project.join(".clusterflux/build").join( bundle_digest .as_str() .trim_start_matches("sha256:") @@ -171,7 +171,7 @@ fn compile_project_wasm(project: &Path) -> Result { .is_some_and(|types| types.iter().any(|kind| kind == "cdylib")) }) }) - .context("Disasmer project library must include crate-type = [\"cdylib\"]")?; + .context("Clusterflux project library must include crate-type = [\"cdylib\"]")?; let target_name = target["name"] .as_str() .context("cargo target name missing")?; @@ -272,7 +272,7 @@ fn write_bundle( )?; write_json(&debug_path, &json!(inspection.metadata.debug_metadata))?; let manifest = json!({ - "kind": "disasmer-bundle", + "kind": "clusterflux-bundle", "format_version": 1, "package": wasm.package, "target": wasm.target, diff --git a/crates/disasmer-cli/src/bundle.rs b/crates/clusterflux-cli/src/bundle.rs similarity index 97% rename from crates/disasmer-cli/src/bundle.rs rename to crates/clusterflux-cli/src/bundle.rs index 994e00c..9569309 100644 --- a/crates/disasmer-cli/src/bundle.rs +++ b/crates/clusterflux-cli/src/bundle.rs @@ -2,7 +2,7 @@ use std::collections::BTreeSet; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; -use disasmer_core::{ +use clusterflux_core::{ diagnose_environment_references, discover_source_debug_probes, BundleDebugProbe, BundleIdentityInputs, BundleMetadata, Digest, EnvironmentReference, ProjectModel, SelectedInput, SourceProviderKind, SourceProviderManifest, @@ -246,7 +246,7 @@ fn source_provider_manifest(kind: &SourceProviderKind) -> SourceProviderManifest fn environment_diagnostics_for_inputs( project: &Path, selected_inputs: &[SelectedInput], - environments: &[disasmer_core::EnvironmentResource], + environments: &[clusterflux_core::EnvironmentResource], ) -> Result> { let mut diagnostics = Vec::new(); for input in selected_inputs { @@ -287,7 +287,7 @@ fn discover_debug_probes( fn pre_schedule_diagnostics( source_provider_statuses: &[SourceProviderStatus], environment_diagnostics: &[EnvironmentDiagnosticReport], - environments: &[disasmer_core::EnvironmentResource], + environments: &[clusterflux_core::EnvironmentResource], ) -> Vec { let mut diagnostics = Vec::new(); diagnostics.extend( @@ -301,7 +301,7 @@ fn pre_schedule_diagnostics( next_actions: vec![ "create the missing envs//Containerfile or envs//Dockerfile" .to_owned(), - "rerun disasmer inspect".to_owned(), + "rerun clusterflux inspect".to_owned(), ], }), ); @@ -326,7 +326,7 @@ fn pre_schedule_diagnostics( ), next_actions: vec![ "choose an available source provider with --source-provider".to_owned(), - "rerun disasmer inspect --json".to_owned(), + "rerun clusterflux inspect --json".to_owned(), ], }), ); diff --git a/crates/disasmer-cli/src/client.rs b/crates/clusterflux-cli/src/client.rs similarity index 96% rename from crates/disasmer-cli/src/client.rs rename to crates/clusterflux-cli/src/client.rs index 0a809d6..9338ba8 100644 --- a/crates/disasmer-cli/src/client.rs +++ b/crates/clusterflux-cli/src/client.rs @@ -1,6 +1,6 @@ use anyhow::{Context, Result}; -use disasmer_control::{endpoint_identity, endpoint_is_loopback, ControlSession}; -use disasmer_core::coordinator_wire_request; +use clusterflux_control::{endpoint_identity, endpoint_is_loopback, ControlSession}; +use clusterflux_core::coordinator_wire_request; use serde_json::{json, Value}; use crate::config::StoredCliSession; @@ -75,7 +75,7 @@ pub(crate) fn authenticated_or_local_trusted_request( Ok(local_trusted_request) } else { anyhow::bail!( - "no authenticated CLI session matches coordinator {coordinator}; run `disasmer login --browser` from the current project" + "no authenticated CLI session matches coordinator {coordinator}; run `clusterflux login --browser` from the current project" ) } } diff --git a/crates/disasmer-cli/src/config.rs b/crates/clusterflux-cli/src/config.rs similarity index 95% rename from crates/disasmer-cli/src/config.rs rename to crates/clusterflux-cli/src/config.rs index 853de18..db22a3f 100644 --- a/crates/disasmer-cli/src/config.rs +++ b/crates/clusterflux-cli/src/config.rs @@ -5,7 +5,8 @@ use serde::{Deserialize, Serialize}; use crate::CliScopeArgs; -pub(crate) const DEFAULT_HOSTED_COORDINATOR_ENDPOINT: &str = "https://disasmer.michelpaulissen.com"; +pub(crate) const DEFAULT_HOSTED_COORDINATOR_ENDPOINT: &str = + "https://clusterflux.michelpaulissen.com"; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub(crate) struct ProjectConfig { @@ -37,11 +38,11 @@ pub(crate) fn default_hosted_coordinator_endpoint() -> String { } pub(crate) fn project_config_file(project: &Path) -> PathBuf { - project.join(".disasmer").join("project.json") + project.join(".clusterflux").join("project.json") } pub(crate) fn session_config_file(project: &Path) -> PathBuf { - project.join(".disasmer").join("session.json") + project.join(".clusterflux").join("session.json") } pub(crate) fn read_project_config(project: &Path) -> Result> { diff --git a/crates/disasmer-cli/src/confirm.rs b/crates/clusterflux-cli/src/confirm.rs similarity index 100% rename from crates/disasmer-cli/src/confirm.rs rename to crates/clusterflux-cli/src/confirm.rs diff --git a/crates/disasmer-cli/src/debug.rs b/crates/clusterflux-cli/src/debug.rs similarity index 96% rename from crates/disasmer-cli/src/debug.rs rename to crates/clusterflux-cli/src/debug.rs index 897283a..d39c780 100644 --- a/crates/disasmer-cli/src/debug.rs +++ b/crates/clusterflux-cli/src/debug.rs @@ -23,9 +23,9 @@ pub(crate) fn exec_dap(args: DapArgs) -> Result<()> { let status = Command::new(dap_binary_path()?) .args(args.args) .status() - .context("failed to launch disasmer-debug-dap")?; + .context("failed to launch clusterflux-debug-dap")?; if !status.success() { - anyhow::bail!("disasmer-debug-dap exited with {status}"); + anyhow::bail!("clusterflux-debug-dap exited with {status}"); } Ok(()) } diff --git a/crates/disasmer-cli/src/dispatch.rs b/crates/clusterflux-cli/src/dispatch.rs similarity index 100% rename from crates/disasmer-cli/src/dispatch.rs rename to crates/clusterflux-cli/src/dispatch.rs diff --git a/crates/disasmer-cli/src/doctor.rs b/crates/clusterflux-cli/src/doctor.rs similarity index 80% rename from crates/disasmer-cli/src/doctor.rs rename to crates/clusterflux-cli/src/doctor.rs index dce6e87..cfa91ed 100644 --- a/crates/disasmer-cli/src/doctor.rs +++ b/crates/clusterflux-cli/src/doctor.rs @@ -2,8 +2,8 @@ use std::path::PathBuf; use std::time::Duration; use anyhow::{Context, Result}; -use disasmer_control::ControlSession; -use disasmer_core::{coordinator_wire_request, Capability, NodeCapabilities}; +use clusterflux_control::ControlSession; +use clusterflux_core::{coordinator_wire_request, Capability, NodeCapabilities}; use serde_json::{json, Value}; use crate::auth::auth_state_value; @@ -25,9 +25,9 @@ pub(crate) fn doctor_report(args: DoctorArgs, cwd: PathBuf) -> Result { "cargo": command_available("cargo"), "git": command_available("git"), "podman": command_available("podman"), - "disasmer-node": command_available("disasmer-node") || sibling_binary("disasmer-node").is_some(), - "disasmer-coordinator": command_available("disasmer-coordinator") || sibling_binary("disasmer-coordinator").is_some(), - "disasmer-debug-dap": command_available("disasmer-debug-dap") || sibling_binary("disasmer-debug-dap").is_some(), + "clusterflux-node": command_available("clusterflux-node") || sibling_binary("clusterflux-node").is_some(), + "clusterflux-coordinator": command_available("clusterflux-coordinator") || sibling_binary("clusterflux-coordinator").is_some(), + "clusterflux-debug-dap": command_available("clusterflux-debug-dap") || sibling_binary("clusterflux-debug-dap").is_some(), }); let node_readiness = NodeCapabilities::detect_current(); let node_readiness_summary = node_readiness_summary(&node_readiness, &dependencies); @@ -42,10 +42,10 @@ pub(crate) fn doctor_report(args: DoctorArgs, cwd: PathBuf) -> Result { "node_readiness": node_readiness, "node_readiness_summary": node_readiness_summary, "next_actions": [ - "disasmer login --browser", - "disasmer project init", - "disasmer node attach", - "disasmer run" + "clusterflux login --browser", + "clusterflux project init", + "clusterflux node attach", + "clusterflux run" ] })) } @@ -60,7 +60,7 @@ fn node_readiness_summary(capabilities: &NodeCapabilities, dependencies: &Value) .contains(&Capability::SourceFilesystem); let has_container_backend = capabilities .environment_backends - .contains(&disasmer_core::EnvironmentBackend::Container); + .contains(&clusterflux_core::EnvironmentBackend::Container); let podman_available = dependencies .get("podman") .and_then(Value::as_bool) @@ -69,8 +69,8 @@ fn node_readiness_summary(capabilities: &NodeCapabilities, dependencies: &Value) .get("git") .and_then(Value::as_bool) .unwrap_or(false); - let disasmer_node_available = dependencies - .get("disasmer-node") + let clusterflux_node_available = dependencies + .get("clusterflux-node") .and_then(Value::as_bool) .unwrap_or(false); @@ -81,8 +81,8 @@ fn node_readiness_summary(capabilities: &NodeCapabilities, dependencies: &Value) if capabilities.source_providers.contains("git") && !git_available { missing.push("git"); } - if !disasmer_node_available { - missing.push("disasmer-node"); + if !clusterflux_node_available { + missing.push("clusterflux-node"); } let basic_runtime_ready = true; @@ -96,14 +96,14 @@ fn node_readiness_summary(capabilities: &NodeCapabilities, dependencies: &Value) }; let next_actions = if status == "ready_to_attach" { vec![ - "disasmer node enroll", - "disasmer node attach", - "disasmer-node --worker", + "clusterflux node enroll", + "clusterflux node attach", + "clusterflux-node --worker", ] } else { vec![ "install missing local dependencies", - "rerun disasmer doctor", + "rerun clusterflux doctor", ] }; @@ -118,7 +118,7 @@ fn node_readiness_summary(capabilities: &NodeCapabilities, dependencies: &Value) "container_backend_reported": has_container_backend, "podman_binary_available": podman_available, "git_binary_available": git_available, - "node_binary_available": disasmer_node_available, + "node_binary_available": clusterflux_node_available, "missing_local_dependencies": missing, "source_providers": capabilities.source_providers.iter().collect::>(), "next_actions": next_actions, @@ -130,7 +130,7 @@ fn coordinator_reachability(coordinator: Option<&str>) -> Value { return json!({ "checked": false, "status": "not_configured", - "next_action": "run disasmer login --browser or pass --coordinator" + "next_action": "run clusterflux login --browser or pass --coordinator" }); }; diff --git a/crates/disasmer-cli/src/errors.rs b/crates/clusterflux-cli/src/errors.rs similarity index 91% rename from crates/disasmer-cli/src/errors.rs rename to crates/clusterflux-cli/src/errors.rs index 6d74cd6..0dce2be 100644 --- a/crates/disasmer-cli/src/errors.rs +++ b/crates/clusterflux-cli/src/errors.rs @@ -210,45 +210,48 @@ fn cli_error_retryable_after_user_action(category: &str) -> bool { fn cli_error_next_actions(category: &str) -> Vec<&'static str> { match category { - "authentication" => vec!["disasmer login --browser", "disasmer auth status"], + "authentication" => vec!["clusterflux login --browser", "clusterflux auth status"], "authorization" => vec![ - "disasmer auth status", + "clusterflux auth status", "check tenant/project selection", "ask an admin to grant access", ], "quota" => vec![ - "disasmer quota status", + "clusterflux quota status", "reduce concurrent work or wait for usage to fall", ], "policy" => vec![ - "disasmer doctor", + "clusterflux doctor", "check coordinator policy for this action", ], "capability" => vec![ - "disasmer node list", + "clusterflux node list", "attach a node with the required capabilities", "check tenant/project on the attached node", ], "connectivity" => vec![ - "disasmer doctor", + "clusterflux doctor", "check the coordinator endpoint and network reachability", ], "environment" => vec![ - "disasmer inspect", + "clusterflux inspect", "check envs//Containerfile or envs//Dockerfile", ], - "program" => vec!["disasmer logs", "fix the program or task command and rerun"], + "program" => vec![ + "clusterflux logs", + "fix the program or task command and rerun", + ], "active_process" => vec![ - "disasmer process list", - "disasmer process status", - "disasmer debug attach", - "disasmer process restart --yes", - "disasmer process cancel --yes", - "disasmer process abort --yes", + "clusterflux process list", + "clusterflux process status", + "clusterflux debug attach", + "clusterflux process restart --yes", + "clusterflux process cancel --yes", + "clusterflux process abort --yes", "use another Coordinator Project", ], _ => vec![ - "disasmer doctor", + "clusterflux doctor", "rerun with --json for machine-readable details", ], } diff --git a/crates/disasmer-cli/src/key.rs b/crates/clusterflux-cli/src/key.rs similarity index 98% rename from crates/disasmer-cli/src/key.rs rename to crates/clusterflux-cli/src/key.rs index 7a9f0de..46c955b 100644 --- a/crates/disasmer-cli/src/key.rs +++ b/crates/clusterflux-cli/src/key.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use disasmer_core::Digest; +use clusterflux_core::Digest; use serde_json::{json, Value}; use crate::client::{authenticated_or_local_trusted_request, JsonLineSession}; @@ -182,7 +182,7 @@ pub(crate) fn key_revoke_report_with_session( "user": args.scope.user, "agent": args.agent, }), - format!("disasmer key revoke --agent {} --yes", args.agent), + format!("clusterflux key revoke --agent {} --yes", args.agent), )); } let coordinator = effective_coordinator(&args.scope.coordinator, stored_session); diff --git a/crates/disasmer-cli/src/logout.rs b/crates/clusterflux-cli/src/logout.rs similarity index 94% rename from crates/disasmer-cli/src/logout.rs rename to crates/clusterflux-cli/src/logout.rs index 1da7e3a..3dce1e5 100644 --- a/crates/disasmer-cli/src/logout.rs +++ b/crates/clusterflux-cli/src/logout.rs @@ -23,7 +23,7 @@ pub(crate) fn logout_report(args: AuthLogoutArgs, cwd: PathBuf, command: &str) - "session_file": session_file, "node_credentials_untouched": true, }), - format!("disasmer {command} --yes"), + format!("clusterflux {command} --yes"), )); } let stored_session = read_cli_session(&cwd).ok().flatten(); @@ -73,7 +73,7 @@ fn revoke_stored_cli_session_if_possible(stored_session: Option<&StoredCliSessio "coordinator": coordinator, "error": message, "machine_error": cli_error_summary(&message), - "next_actions": ["disasmer login --browser"], + "next_actions": ["clusterflux login --browser"], }); } }; @@ -95,7 +95,7 @@ fn revoke_stored_cli_session_if_possible(stored_session: Option<&StoredCliSessio "error": message, "machine_error": cli_error_summary(&message), "coordinator_session_requests": coordinator_session.requests(), - "next_actions": ["disasmer login --browser"], + "next_actions": ["clusterflux login --browser"], }); } }; @@ -113,7 +113,7 @@ fn revoke_stored_cli_session_if_possible(stored_session: Option<&StoredCliSessio "coordinator_response_type": response.get("type").and_then(Value::as_str).unwrap_or("unknown"), "machine_error": cli_error_summary(message), "coordinator_session_requests": coordinator_session.requests(), - "next_actions": ["disasmer login --browser"], + "next_actions": ["clusterflux login --browser"], }); } json!({ diff --git a/crates/disasmer-cli/src/logs.rs b/crates/clusterflux-cli/src/logs.rs similarity index 100% rename from crates/disasmer-cli/src/logs.rs rename to crates/clusterflux-cli/src/logs.rs diff --git a/crates/disasmer-cli/src/main.rs b/crates/clusterflux-cli/src/main.rs similarity index 95% rename from crates/disasmer-cli/src/main.rs rename to crates/clusterflux-cli/src/main.rs index 06b1fe2..b74070c 100644 --- a/crates/disasmer-cli/src/main.rs +++ b/crates/clusterflux-cli/src/main.rs @@ -8,7 +8,7 @@ use std::path::PathBuf; use clap::{Args, Parser, Subcommand}; #[cfg(test)] -use disasmer_core::{Capability, Digest, ProjectModel, SourceProviderKind}; +use clusterflux_core::{Capability, Digest, ProjectModel, SourceProviderKind}; #[cfg(test)] use serde_json::json; use serde_json::Value; @@ -126,17 +126,17 @@ use tools::dap_binary_path; #[derive(Clone, Debug, Parser)] #[command( - name = "disasmer", + name = "clusterflux", version, arg_required_else_help = true, - about = "Disasmer distributed Wasm runtime CLI.", + about = "Clusterflux distributed Wasm runtime CLI.", after_help = "Primary workflow: - 1. disasmer login --browser - 2. disasmer project init - 3. disasmer node enroll; disasmer node attach; disasmer-node --worker - 4. disasmer run [entry] --project - 5. Debug with VS Code \"Disasmer: Launch Virtual Process\" or disasmer dap - 6. Inspect with disasmer process list, disasmer process status, task list, logs, and artifact list + 1. clusterflux login --browser + 2. clusterflux project init + 3. clusterflux node enroll; clusterflux node attach; clusterflux-node --worker + 4. clusterflux run [entry] --project + 5. Debug with VS Code \"Clusterflux: Launch Virtual Process\" or clusterflux dap + 6. Inspect with clusterflux process list, clusterflux process status, task list, logs, and artifact list 7. Request cooperative shutdown with process cancel; force termination with process abort Use --json on primary commands for scriptable output. Hosted account creation happens in the browser login flow." @@ -322,7 +322,7 @@ struct ProjectInitArgs { scope: CliScopeArgs, #[arg(long, default_value = "project")] new_project: String, - #[arg(long, default_value = "Disasmer Project")] + #[arg(long, default_value = "Clusterflux Project")] name: String, #[arg(long)] yes: bool, @@ -635,7 +635,7 @@ struct AdminStatusArgs { struct AdminBootstrapArgs { #[command(flatten)] scope: CliScopeArgs, - #[arg(long, default_value = "Disasmer Project")] + #[arg(long, default_value = "Clusterflux Project")] name: String, #[arg(long)] yes: bool, diff --git a/crates/disasmer-cli/src/node.rs b/crates/clusterflux-cli/src/node.rs similarity index 98% rename from crates/disasmer-cli/src/node.rs rename to crates/clusterflux-cli/src/node.rs index c8dbb5c..917310d 100644 --- a/crates/disasmer-cli/src/node.rs +++ b/crates/clusterflux-cli/src/node.rs @@ -2,7 +2,7 @@ use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; -use disasmer_core::{ +use clusterflux_core::{ generate_ed25519_private_key, node_ed25519_public_key_from_private_key, sign_node_request, signed_request_payload_digest, Capability, Digest, EnvironmentBackend, NodeCapabilities, NodeId, @@ -60,7 +60,7 @@ pub(crate) struct CapabilityGrantDisclosure { #[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub(crate) struct NodeAttachDetectionEvidence { pub(crate) auto_detected: bool, - pub(crate) os: disasmer_core::Os, + pub(crate) os: clusterflux_core::Os, pub(crate) arch: String, pub(crate) command_backend: String, pub(crate) command_backend_available: bool, @@ -287,7 +287,7 @@ pub(crate) fn node_revoke_report(args: NodeRevokeArgs, cwd: PathBuf) -> Result Result .unwrap_or(derived_public_key.clone()); if public_key != derived_public_key { anyhow::bail!( - "node attach --public-key must match DISASMER_NODE_PRIVATE_KEY or the stored local node credential" + "node attach --public-key must match CLUSTERFLUX_NODE_PRIVATE_KEY or the stored local node credential" ); } let mut plan = attach_plan(args); @@ -662,7 +662,7 @@ pub(crate) fn execute_node_attach(args: AttachArgs) -> Result } fn node_private_key_for_attach(node: &str) -> Result { - if let Ok(private_key) = std::env::var("DISASMER_NODE_PRIVATE_KEY") { + if let Ok(private_key) = std::env::var("CLUSTERFLUX_NODE_PRIVATE_KEY") { return Ok(private_key); } load_or_create_local_node_credential(&std::env::current_dir()?, node) @@ -698,7 +698,7 @@ pub(crate) fn load_or_create_local_node_credential(project: &Path, node: &str) - let public_key = node_ed25519_public_key_from_private_key(&private_key).map_err(anyhow::Error::msg)?; let credential = StoredNodeCredential { - kind: "disasmer_node_credential".to_owned(), + kind: "clusterflux_node_credential".to_owned(), node: node.to_owned(), private_key: private_key.clone(), public_key, @@ -774,7 +774,7 @@ pub(crate) fn local_node_credential_file(project: &Path, node: &str) -> PathBuf let digest = Digest::sha256(node); let file_stem = digest.as_str().trim_start_matches("sha256:"); project - .join(".disasmer") + .join(".clusterflux") .join("nodes") .join(format!("{file_stem}.json")) } @@ -811,7 +811,7 @@ fn signed_node_request_json( } pub(crate) fn default_node_id() -> String { - std::env::var("DISASMER_NODE_ID") + std::env::var("CLUSTERFLUX_NODE_ID") .or_else(|_| std::env::var("HOSTNAME")) .or_else(|_| std::env::var("COMPUTERNAME")) .unwrap_or_else(|_| "node-local".to_owned()) diff --git a/crates/disasmer-cli/src/output.rs b/crates/clusterflux-cli/src/output.rs similarity index 98% rename from crates/disasmer-cli/src/output.rs rename to crates/clusterflux-cli/src/output.rs index 77e725a..d5c2b57 100644 --- a/crates/disasmer-cli/src/output.rs +++ b/crates/clusterflux-cli/src/output.rs @@ -21,25 +21,25 @@ pub(crate) fn human_report(value: &Value) -> String { let title = value .get("command") .and_then(Value::as_str) - .map(|command| format!("Disasmer {command}")) + .map(|command| format!("Clusterflux {command}")) .or_else(|| { if value.get("human_flow").is_some() { - Some("Disasmer login".to_owned()) + Some("Clusterflux login".to_owned()) } else if value.get("metadata").is_some() && value.get("source_provider_manifest").is_some() { - Some("Disasmer bundle inspect".to_owned()) + Some("Clusterflux bundle inspect".to_owned()) } else if value.get("entry").is_some() && value.get("session").is_some() { - Some("Disasmer run".to_owned()) + Some("Clusterflux run".to_owned()) } else if value.get("capabilities").is_some() && value.get("node").is_some() { - Some("Disasmer node attach".to_owned()) + Some("Clusterflux node attach".to_owned()) } else if value.get("public_key_fingerprint").is_some() { - Some("Disasmer agent enroll".to_owned()) + Some("Clusterflux agent enroll".to_owned()) } else { None } }) - .unwrap_or_else(|| "Disasmer report".to_owned()); + .unwrap_or_else(|| "Clusterflux report".to_owned()); lines.push(title); push_string_field(&mut lines, value, "status", "status"); diff --git a/crates/disasmer-cli/src/process.rs b/crates/clusterflux-cli/src/process.rs similarity index 97% rename from crates/disasmer-cli/src/process.rs rename to crates/clusterflux-cli/src/process.rs index cfd7670..e7696b4 100644 --- a/crates/disasmer-cli/src/process.rs +++ b/crates/clusterflux-cli/src/process.rs @@ -157,7 +157,10 @@ pub(crate) fn process_restart_report_with_session( "project": args.scope.project, "process": args.process, }), - format!("disasmer process restart --process {} --yes", args.process), + format!( + "clusterflux process restart --process {} --yes", + args.process + ), )); } if let Some(coordinator) = &args.scope.coordinator { @@ -223,7 +226,10 @@ pub(crate) fn process_cancel_report_with_session( "node": args.node, "task": args.task, }), - format!("disasmer process cancel --process {} --yes", args.process), + format!( + "clusterflux process cancel --process {} --yes", + args.process + ), )); } if let Some(coordinator) = &args.scope.coordinator { @@ -287,7 +293,7 @@ pub(crate) fn process_abort_report_with_session( "project": args.scope.project, "process": args.process, }), - format!("disasmer process abort --process {} --yes", args.process), + format!("clusterflux process abort --process {} --yes", args.process), )); } let Some(coordinator) = &args.scope.coordinator else { diff --git a/crates/disasmer-cli/src/process_events.rs b/crates/clusterflux-cli/src/process_events.rs similarity index 100% rename from crates/disasmer-cli/src/process_events.rs rename to crates/clusterflux-cli/src/process_events.rs diff --git a/crates/disasmer-cli/src/project.rs b/crates/clusterflux-cli/src/project.rs similarity index 98% rename from crates/disasmer-cli/src/project.rs rename to crates/clusterflux-cli/src/project.rs index 551077b..47140db 100644 --- a/crates/disasmer-cli/src/project.rs +++ b/crates/clusterflux-cli/src/project.rs @@ -101,7 +101,7 @@ pub(crate) fn project_init_report(args: ProjectInitArgs, cwd: PathBuf) -> Result "current_directory_link": { "cwd": cwd, "config_file": config_file, - "config_format": "disasmer_project_config_v1", + "config_format": "clusterflux_project_config_v1", "links_current_directory": true, "writes_current_directory_only": true, "private_website_required": false, @@ -113,7 +113,7 @@ pub(crate) fn project_init_report(args: ProjectInitArgs, cwd: PathBuf) -> Result "coordinator": config.coordinator.clone(), "project_name": args.name.clone(), "default_project_id_used": args.new_project == "project", - "default_project_name_used": args.name == "Disasmer Project", + "default_project_name_used": args.name == "Clusterflux Project", "browser_interaction_required": false, "private_website_required": false, }, @@ -147,7 +147,7 @@ pub(crate) fn project_status_report(args: ProjectStatusArgs, cwd: PathBuf) -> Re || session.user != config.user) { anyhow::bail!( - "stored CLI session is for {}/{}/{} but this workspace is configured for {}/{}/{}; run `disasmer login --browser` from this workspace", + "stored CLI session is for {}/{}/{} but this workspace is configured for {}/{}/{}; run `clusterflux login --browser` from this workspace", session.tenant, session.project, session.user, diff --git a/crates/disasmer-cli/src/quota.rs b/crates/clusterflux-cli/src/quota.rs similarity index 100% rename from crates/disasmer-cli/src/quota.rs rename to crates/clusterflux-cli/src/quota.rs diff --git a/crates/disasmer-cli/src/run.rs b/crates/clusterflux-cli/src/run.rs similarity index 78% rename from crates/disasmer-cli/src/run.rs rename to crates/clusterflux-cli/src/run.rs index 048b01f..f6b607b 100644 --- a/crates/disasmer-cli/src/run.rs +++ b/crates/clusterflux-cli/src/run.rs @@ -4,10 +4,10 @@ use std::time::{Duration, Instant}; use anyhow::{Context, Result}; use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; -use disasmer_control::MAX_CONTROL_FRAME_BYTES; -use disasmer_core::{ - agent_ed25519_public_key_from_private_key, sign_agent_workflow_request, - signed_request_payload_digest, AgentWorkflowScope, Digest, +use clusterflux_control::MAX_CONTROL_FRAME_BYTES; +use clusterflux_core::{ + agent_ed25519_public_key_from_private_key, agent_workflow_request_scope_from_payload, + sign_agent_workflow_request, signed_request_payload_digest, Digest, }; use serde::Serialize; use serde_json::{json, Value}; @@ -122,8 +122,8 @@ fn non_interactive_run_requires_auth_report(args: RunArgs, cwd: PathBuf) -> Valu let entry = args.entry.unwrap_or_else(|| "build".to_owned()); let message = "non-interactive run requires an authenticated human or agent session unless --local or --coordinator is explicit"; let next_actions = vec![ - "disasmer login --browser", - "set DISASMER_AGENT_PRIVATE_KEY for automation", + "clusterflux login --browser", + "set CLUSTERFLUX_AGENT_PRIVATE_KEY for automation", "pass --local to run against local services", "pass --coordinator for an explicit self-hosted coordinator", ]; @@ -191,7 +191,7 @@ fn coordinator_run_report(plan: RunPlan) -> Result { && !crate::client::is_loopback_coordinator(&coordinator) { anyhow::bail!( - "no authenticated CLI session matches coordinator {coordinator}; run `disasmer login --browser` from the current project" + "no authenticated CLI session matches coordinator {coordinator}; run `clusterflux login --browser` from the current project" ); } let process = "vp-current".to_owned(); @@ -209,7 +209,17 @@ fn coordinator_run_report(plan: RunPlan) -> Result { &user, human_session_secret.as_deref(), ); - let response = session.request_allow_error(request)?; + let response = request_process_start_with_rollback( + &mut session, + request, + &coordinator, + &plan.session, + &user, + human_session_secret.as_deref(), + &tenant, + &project, + &process, + )?; let run_start = run_start_summary(&response); let status = run_start .get("status") @@ -239,6 +249,7 @@ fn coordinator_run_report(plan: RunPlan) -> Result { "required_artifacts": [], "args": [], "vfs_epoch": response.get("epoch").and_then(Value::as_u64).unwrap_or_default(), + "failure_policy": "fail_fast", "bundle_digest": bundle.digest, }); let mut launch_task_request = json!({ @@ -256,23 +267,37 @@ fn coordinator_run_report(plan: RunPlan) -> Result { &user, human_session_secret.as_deref(), ); - let launch = session.request_allow_error(launch_task_request)?; - if !matches!( - launch.get("type").and_then(Value::as_str), - Some("main_launched") - ) { - rollback_failed_process_launch( - &mut session, - &plan.session, - &user, - human_session_secret.as_deref(), - &tenant, - &project, - &process, - &launch, - )?; + let launch = session + .request_allow_error(launch_task_request) + .and_then(|response| { + if response.get("type").and_then(Value::as_str) == Some("main_launched") { + Ok(response) + } else { + anyhow::bail!("coordinator main launch was not acknowledged: {response}") + } + }); + match launch { + Ok(launch) => launch, + Err(launch_error) => { + let rollback = ProcessLaunchRollback { + cli_session: &plan.session, + fallback_user: &user, + human_session_secret: human_session_secret.as_deref(), + tenant: &tenant, + project: &project, + process: &process, + }; + let cleanup = rollback_failed_process_launch_reconnecting( + &coordinator, + &rollback, + &json!({ "error": launch_error.to_string() }), + ); + if let Err(cleanup_error) = cleanup { + anyhow::bail!("{launch_error}; process cleanup also failed: {cleanup_error}"); + } + return Err(launch_error); + } } - launch } else { Value::Null }; @@ -304,12 +329,62 @@ fn coordinator_run_report(plan: RunPlan) -> Result { })) } +#[allow(clippy::too_many_arguments)] +fn request_process_start_with_rollback( + session: &mut JsonLineSession, + request: Value, + coordinator: &str, + cli_session: &CliSession, + fallback_user: &str, + human_session_secret: Option<&str>, + tenant: &str, + project: &str, + process: &str, +) -> Result { + let rollback = ProcessLaunchRollback { + cli_session, + fallback_user, + human_session_secret, + tenant, + project, + process, + }; + match session.request_allow_error(request) { + Ok(response) => Ok(response), + Err(start_error) => { + let cleanup = rollback_failed_process_launch_reconnecting( + coordinator, + &rollback, + &json!({ "error": start_error.to_string(), "phase": "start_process" }), + ); + if let Err(cleanup_error) = cleanup { + let cleanup_message = cleanup_error.to_string(); + if !cleanup_message.contains("process abort requires an active virtual process") { + anyhow::bail!( + "{start_error}; ambiguous process-start cleanup also failed: {cleanup_error}" + ); + } + } + Err(start_error) + } + } +} + +struct ProcessLaunchRollback<'a> { + cli_session: &'a CliSession, + fallback_user: &'a str, + human_session_secret: Option<&'a str>, + tenant: &'a str, + project: &'a str, + process: &'a str, +} + fn validate_inline_bundle_size(module_size_bytes: usize) -> Result<()> { if module_size_bytes <= MAX_INLINE_WASM_MODULE_BYTES { return Ok(()); } anyhow::bail!( - "built Wasm module is {module_size_bytes} bytes, but the current {}-byte inline control frame supports at most about {} KiB ({} raw bytes); reduce the MVP bundle's dependencies or release optimization footprint. Larger bundle transport is post-MVP. No virtual process was created", + "built Wasm module is {module_size_bytes} bytes, but the current {}-byte inline control frame supports at most about {} KiB ({} raw bytes); reduce the bundle dependency or optimization footprint. Larger bundles require an out-of-band transport. No virtual process was created", MAX_CONTROL_FRAME_BYTES, MAX_INLINE_WASM_MODULE_BYTES / 1024, MAX_INLINE_WASM_MODULE_BYTES, @@ -318,34 +393,40 @@ fn validate_inline_bundle_size(module_size_bytes: usize) -> Result<()> { fn rollback_failed_process_launch( session: &mut JsonLineSession, - cli_session: &CliSession, - fallback_user: &str, - human_session_secret: Option<&str>, - tenant: &str, - project: &str, - process: &str, + context: &ProcessLaunchRollback<'_>, launch_response: &Value, ) -> Result<()> { let rollback = authenticated_human_or_local_trusted_workflow( json!({ "type": "abort_process", - "tenant": tenant, - "project": project, - "process": process, + "tenant": context.tenant, + "project": context.project, + "process": context.process, }), - cli_session, - fallback_user, - human_session_secret, + context.cli_session, + context.fallback_user, + context.human_session_secret, ); let rollback_response = session.request_allow_error(rollback)?; if rollback_response.get("type").and_then(Value::as_str) != Some("process_aborted") { anyhow::bail!( - "coordinator main launch failed ({launch_response}) and rollback was not acknowledged ({rollback_response}); inspect virtual process {process} before retrying" + "coordinator main launch failed ({launch_response}) and rollback was not acknowledged ({rollback_response}); inspect virtual process {} before retrying", + context.process ); } Ok(()) } +fn rollback_failed_process_launch_reconnecting( + coordinator: &str, + context: &ProcessLaunchRollback<'_>, + launch_response: &Value, +) -> Result<()> { + let mut cleanup_session = JsonLineSession::connect(coordinator) + .context("open a fresh coordinator connection for failed-launch cleanup")?; + rollback_failed_process_launch(&mut cleanup_session, context, launch_response) +} + fn build_bundle_for_run(project: &Path, entry: &str) -> Result { let build_report = crate::build::build_report( BuildArgs { @@ -362,7 +443,7 @@ fn build_bundle_for_run(project: &Path, entry: &str) -> Result { .get("diagnostics") .cloned() .unwrap_or(Value::Null); - anyhow::bail!("Disasmer bundle build was blocked before run: {diagnostics}"); + anyhow::bail!("Clusterflux bundle build was blocked before run: {diagnostics}"); } let artifact = build_report .get("bundle_artifact") @@ -403,11 +484,11 @@ fn build_bundle_for_run(project: &Path, entry: &str) -> Result { .filter_map(|descriptor| descriptor.get("name").and_then(Value::as_str)) .collect::>(); format!( - "bundle has no Disasmer entrypoint `{entry}`; available entrypoints: {available:?}" + "bundle has no Clusterflux entrypoint `{entry}`; available entrypoints: {available:?}" ) })?; if descriptor.get("abi_version").and_then(Value::as_u64) != Some(1) { - anyhow::bail!("entrypoint `{entry}` does not use supported Disasmer ABI version 1"); + anyhow::bail!("entrypoint `{entry}` does not use supported Clusterflux ABI version 1"); } let entry_export = descriptor .get("export") @@ -439,7 +520,7 @@ fn human_run_session_secret( if !matches!(plan.session, CliSession::HumanSession) { return Ok(None); } - if let Ok(token) = std::env::var("DISASMER_TOKEN") { + if let Ok(token) = std::env::var("CLUSTERFLUX_TOKEN") { if !token.trim().is_empty() { return Ok(Some(token)); } @@ -505,9 +586,9 @@ pub(crate) fn run_start_summary(response: &Value) -> Value { "restart": false, "single_active_process_boundary": true, "next_actions": [ - "disasmer process status", - "disasmer logs", - "disasmer process cancel" + "clusterflux process status", + "clusterflux logs", + "clusterflux process cancel" ], }); } @@ -543,16 +624,16 @@ pub(crate) fn run_start_summary(response: &Value) -> Value { "safe_failure": true, "next_actions": if active_conflict { json!([ - "disasmer process list", - "disasmer process status", - "disasmer debug attach", - "disasmer process restart --yes", - "disasmer process cancel --yes", - "disasmer process abort --yes", + "clusterflux process list", + "clusterflux process status", + "clusterflux debug attach", + "clusterflux process restart --yes", + "clusterflux process cancel --yes", + "clusterflux process abort --yes", "use another Coordinator Project" ]) } else { - json!(["disasmer doctor", "check coordinator status"]) + json!(["clusterflux doctor", "check coordinator status"]) }, }) } @@ -566,16 +647,16 @@ pub(crate) fn should_execute_local_node(plan: &RunPlan) -> bool { } fn execute_local_node_run(plan: RunPlan) -> Result { - let environments = disasmer_core::discover_environments(&plan.project)?; - let detected = disasmer_core::NodeCapabilities::detect_current(); + let environments = clusterflux_core::discover_environments(&plan.project)?; + let detected = clusterflux_core::NodeCapabilities::detect_current(); if environments.iter().any(|environment| { environment .requirements .capabilities - .contains(&disasmer_core::Capability::RootlessPodman) + .contains(&clusterflux_core::Capability::RootlessPodman) }) && !detected .capabilities - .contains(&disasmer_core::Capability::RootlessPodman) + .contains(&clusterflux_core::Capability::RootlessPodman) { anyhow::bail!( "local project declares a Linux container environment, but rootless Podman is not available; configure rootless Podman or attach a capable user node" @@ -589,7 +670,7 @@ fn execute_local_node_run(plan: RunPlan) -> Result { let coordinator_address = local_coordinator.address.clone(); let mut coordinator_plan = plan.clone(); coordinator_plan.coordinator = - CoordinatorSelection::LocalOverride(format!("disasmer+tcp://{coordinator_address}")); + CoordinatorSelection::LocalOverride(format!("clusterflux+tcp://{coordinator_address}")); let run = coordinator_run_report(coordinator_plan)?; let launch_type = run.pointer("/task_launch/type").and_then(Value::as_str); if launch_type != Some("main_launched") { @@ -722,13 +803,13 @@ fn wait_for_local_main_completion(coordinator: &str, process: &str, task: &str) pub(crate) fn session_from_env() -> Result { if let Some(session) = agent_session_from_keys( - std::env::var("DISASMER_AGENT_ID").unwrap_or_else(|_| "agent".to_owned()), - std::env::var("DISASMER_AGENT_PUBLIC_KEY").ok(), - std::env::var("DISASMER_AGENT_PRIVATE_KEY").ok(), + std::env::var("CLUSTERFLUX_AGENT_ID").unwrap_or_else(|_| "agent".to_owned()), + std::env::var("CLUSTERFLUX_AGENT_PUBLIC_KEY").ok(), + std::env::var("CLUSTERFLUX_AGENT_PRIVATE_KEY").ok(), )? { return Ok(session); } - if std::env::var_os("DISASMER_TOKEN").is_some() { + if std::env::var_os("CLUSTERFLUX_TOKEN").is_some() { return Ok(CliSession::HumanSession); } Ok(CliSession::Anonymous) @@ -742,11 +823,11 @@ pub(crate) fn agent_session_from_keys( if let Some(private_key) = private_key { let derived_public_key = agent_ed25519_public_key_from_private_key(&private_key) .map_err(anyhow::Error::msg) - .context("DISASMER_AGENT_PRIVATE_KEY is not a valid Ed25519 private key")?; + .context("CLUSTERFLUX_AGENT_PRIVATE_KEY is not a valid Ed25519 private key")?; if let Some(configured_public_key) = configured_public_key.as_deref() { if configured_public_key != derived_public_key { anyhow::bail!( - "DISASMER_AGENT_PUBLIC_KEY does not match DISASMER_AGENT_PRIVATE_KEY" + "CLUSTERFLUX_AGENT_PUBLIC_KEY does not match CLUSTERFLUX_AGENT_PRIVATE_KEY" ); } } @@ -760,7 +841,7 @@ pub(crate) fn agent_session_from_keys( } if configured_public_key.is_some() { anyhow::bail!( - "DISASMER_AGENT_PUBLIC_KEY identifies a registered key but cannot authenticate; set DISASMER_AGENT_PRIVATE_KEY to prove key possession" + "CLUSTERFLUX_AGENT_PUBLIC_KEY identifies a registered key but cannot authenticate; set CLUSTERFLUX_AGENT_PRIVATE_KEY to prove key possession" ); } Ok(None) @@ -809,32 +890,15 @@ fn agent_signature_for_request( request: &serde_json::Map, agent: &str, private_key: Option<&str>, -) -> Option { +) -> Option { let private_key = private_key?; - let request_kind = request.get("type")?.as_str()?; - let tenant = request.get("tenant")?.as_str()?; - let project = request.get("project")?.as_str()?; - let task_spec = request.get("task_spec"); - let process = request - .get("process") - .or_else(|| task_spec.and_then(|spec| spec.get("process")))? - .as_str()?; - let task = request - .get("task") - .or_else(|| task_spec.and_then(|spec| spec.get("task"))) - .and_then(Value::as_str) - .map(disasmer_core::TaskInstanceId::from); - let payload_digest = signed_request_payload_digest(&Value::Object(request.clone())); + let payload = Value::Object(request.clone()); + let scope = agent_workflow_request_scope_from_payload(&payload).ok()?; + let agent = clusterflux_core::AgentId::from(agent); + let payload_digest = signed_request_payload_digest(&payload); sign_agent_workflow_request( private_key, - AgentWorkflowScope { - tenant: &disasmer_core::TenantId::from(tenant), - project: &disasmer_core::ProjectId::from(project), - agent: &disasmer_core::AgentId::from(agent), - request_kind, - process: &disasmer_core::ProcessId::from(process), - task: task.as_ref(), - }, + scope.for_agent(&agent), &payload_digest, crate::tools::command_nonce("agent-signature"), crate::tools::unix_timestamp_seconds(), @@ -853,14 +917,14 @@ mod transactional_launch_tests { fn inline_module_limit_is_derived_from_the_control_frame() { assert_eq!(MAX_CONTROL_FRAME_BYTES, 1024 * 1024); assert_eq!(MAX_INLINE_WASM_MODULE_BYTES % 3, 0); - assert!(MAX_INLINE_WASM_MODULE_BYTES >= 690 * 1024); - assert!(MAX_INLINE_WASM_MODULE_BYTES <= 700 * 1024); + let inline_limit = std::hint::black_box(MAX_INLINE_WASM_MODULE_BYTES); + assert!((690 * 1024..=700 * 1024).contains(&inline_limit)); validate_inline_bundle_size(MAX_INLINE_WASM_MODULE_BYTES).unwrap(); let error = validate_inline_bundle_size(MAX_INLINE_WASM_MODULE_BYTES + 1) .unwrap_err() .to_string(); assert!(error.contains("No virtual process was created")); - assert!(error.contains("post-MVP")); + assert!(error.contains("out-of-band transport")); } #[test] @@ -927,17 +991,76 @@ mod transactional_launch_tests { .unwrap(); }); let mut session = JsonLineSession::connect(&address.to_string()).unwrap(); + let rollback = ProcessLaunchRollback { + cli_session: &CliSession::Anonymous, + fallback_user: "user-a", + human_session_secret: None, + tenant: "tenant-a", + project: "project-a", + process: "vp-current", + }; rollback_failed_process_launch( &mut session, + &rollback, + &json!({"type": "error", "message": "main launch failed"}), + ) + .unwrap(); + server.join().unwrap(); + } + + #[test] + fn dropped_start_response_reconnects_and_aborts_ambiguous_process() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap().to_string(); + let server = std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + let mut start_line = String::new(); + std::io::BufReader::new(stream.try_clone().unwrap()) + .read_line(&mut start_line) + .unwrap(); + assert!(start_line.contains("\"type\":\"start_process\"")); + drop(stream); + + let (mut cleanup_stream, _) = listener.accept().unwrap(); + let mut cleanup_line = String::new(); + std::io::BufReader::new(cleanup_stream.try_clone().unwrap()) + .read_line(&mut cleanup_line) + .unwrap(); + assert!(cleanup_line.contains("\"type\":\"abort_process\"")); + writeln!( + cleanup_stream, + "{}", + json!({ + "type": "process_aborted", + "process": "vp-current", + "aborted_tasks": [], + "affected_nodes": [] + }) + ) + .unwrap(); + }); + let mut session = JsonLineSession::connect(&address).unwrap(); + let error = request_process_start_with_rollback( + &mut session, + json!({ + "type": "start_process", + "tenant": "tenant-a", + "project": "project-a", + "actor_user": "user-a", + "process": "vp-current", + "restart": false + }), + &address, &CliSession::Anonymous, "user-a", None, "tenant-a", "project-a", "vp-current", - &json!({"type": "error", "message": "main launch failed"}), ) - .unwrap(); + .unwrap_err() + .to_string(); + assert!(error.contains("closed") || error.contains("response")); server.join().unwrap(); } } diff --git a/crates/disasmer-cli/src/run/local_services.rs b/crates/clusterflux-cli/src/run/local_services.rs similarity index 91% rename from crates/disasmer-cli/src/run/local_services.rs rename to crates/clusterflux-cli/src/run/local_services.rs index d2ca337..474a1b1 100644 --- a/crates/disasmer-cli/src/run/local_services.rs +++ b/crates/clusterflux-cli/src/run/local_services.rs @@ -126,12 +126,12 @@ fn read_coordinator_ready_address(child: &mut Child) -> Result { } fn node_command() -> Result { - if let Some(path) = std::env::var_os("DISASMER_NODE_BIN") { + if let Some(path) = std::env::var_os("CLUSTERFLUX_NODE_BIN") { return Ok(Command::new(path)); } let mut sibling = std::env::current_exe().context("cannot locate current executable")?; - sibling.set_file_name(format!("disasmer-node{}", std::env::consts::EXE_SUFFIX)); + sibling.set_file_name(format!("clusterflux-node{}", std::env::consts::EXE_SUFFIX)); if sibling.is_file() { return Ok(Command::new(sibling)); } @@ -141,22 +141,22 @@ fn node_command() -> Result { "run", "-q", "-p", - "disasmer-node", + "clusterflux-node", "--bin", - "disasmer-node", + "clusterflux-node", "--", ]); Ok(command) } fn coordinator_command() -> Result { - if let Some(path) = std::env::var_os("DISASMER_COORDINATOR_BIN") { + if let Some(path) = std::env::var_os("CLUSTERFLUX_COORDINATOR_BIN") { return Ok(Command::new(path)); } let mut sibling = std::env::current_exe().context("cannot locate current executable")?; sibling.set_file_name(format!( - "disasmer-coordinator{}", + "clusterflux-coordinator{}", std::env::consts::EXE_SUFFIX )); if sibling.is_file() { @@ -168,9 +168,9 @@ fn coordinator_command() -> Result { "run", "-q", "-p", - "disasmer-coordinator", + "clusterflux-coordinator", "--bin", - "disasmer-coordinator", + "clusterflux-coordinator", "--", ]); Ok(command) diff --git a/crates/disasmer-cli/src/task.rs b/crates/clusterflux-cli/src/task.rs similarity index 98% rename from crates/disasmer-cli/src/task.rs rename to crates/clusterflux-cli/src/task.rs index 63913f1..9bdf868 100644 --- a/crates/disasmer-cli/src/task.rs +++ b/crates/clusterflux-cli/src/task.rs @@ -54,7 +54,7 @@ pub(crate) fn task_restart_report_with_session( "task": args.task, }), format!( - "disasmer task restart {} --process {} --yes", + "clusterflux task restart {} --process {} --yes", args.task, args.process ), )); diff --git a/crates/disasmer-cli/src/tests.rs b/crates/clusterflux-cli/src/tests.rs similarity index 95% rename from crates/disasmer-cli/src/tests.rs rename to crates/clusterflux-cli/src/tests.rs index b5e3d9d..e924398 100644 --- a/crates/disasmer-cli/src/tests.rs +++ b/crates/clusterflux-cli/src/tests.rs @@ -12,7 +12,7 @@ fn parse(args: &[&str]) -> Cli { fn write_runnable_wasm_project(project: &Path) { let cli_manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); - let sdk = cli_manifest_dir.parent().unwrap().join("disasmer-sdk"); + let sdk = cli_manifest_dir.parent().unwrap().join("clusterflux-sdk"); let shared_target = cli_manifest_dir .parent() .and_then(Path::parent) @@ -23,7 +23,7 @@ fn write_runnable_wasm_project(project: &Path) { fs::write( project.join("Cargo.toml"), format!( - "[package]\nname = \"cli-run-fixture\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\n[workspace]\n\n[lib]\ncrate-type = [\"cdylib\"]\n\n[dependencies]\ndisasmer = {{ package = \"disasmer-sdk\", path = {} }}\n", + "[package]\nname = \"cli-run-fixture\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\n[workspace]\n\n[lib]\ncrate-type = [\"cdylib\"]\n\n[dependencies]\nclusterflux = {{ package = \"clusterflux-sdk\", path = {} }}\n", serde_json::to_string(&sdk.to_string_lossy()).unwrap() ), ) @@ -39,14 +39,14 @@ fn write_runnable_wasm_project(project: &Path) { fs::write( project.join("src/lib.rs"), r#" -#[disasmer::task] +#[clusterflux::task] #[unsafe(no_mangle)] pub extern "C" fn task_add_one(value: i32) -> i32 { value + 1 } -#[disasmer::main] +#[clusterflux::main] pub fn build_main() -> i32 { 7 } -#[disasmer::main(name = "test")] +#[clusterflux::main(name = "test")] pub fn test_main() -> i32 { 8 } "#, ) @@ -62,7 +62,7 @@ fn cli_error_classifier_distinguishes_mvp_failure_categories() { 20, ), ( - "CLI session credential has expired; run disasmer login --browser again", + "CLI session credential has expired; run clusterflux login --browser again", "authentication", 20, ), @@ -163,7 +163,7 @@ fn command_report_exit_code_marks_command_failures_only() { #[test] fn top_level_version_is_available() { let command = Cli::command(); - assert_eq!(command.get_name(), "disasmer"); + assert_eq!(command.get_name(), "clusterflux"); assert!(command.get_version().is_some()); } @@ -171,7 +171,7 @@ fn top_level_version_is_available() { fn run_defaults_to_current_project_and_build_entry() { let Cli { command: Commands::Run(args), - } = parse(&["disasmer", "run"]) + } = parse(&["clusterflux", "run"]) else { panic!("wrong command"); }; @@ -188,7 +188,7 @@ fn run_defaults_to_current_project_and_build_entry() { fn non_interactive_run_without_session_requires_explicit_auth_or_local() { let Cli { command: Commands::Run(args), - } = parse(&["disasmer", "run", "--non-interactive", "--json"]) + } = parse(&["clusterflux", "run", "--non-interactive", "--json"]) else { panic!("wrong command"); }; @@ -208,8 +208,8 @@ fn non_interactive_run_without_session_requires_explicit_auth_or_local() { .iter() .filter_map(Value::as_str) .collect::>(); - assert!(next_actions.contains(&"disasmer login --browser")); - assert!(next_actions.contains(&"set DISASMER_AGENT_PRIVATE_KEY for automation")); + assert!(next_actions.contains(&"clusterflux login --browser")); + assert!(next_actions.contains(&"set CLUSTERFLUX_AGENT_PRIVATE_KEY for automation")); assert!(next_actions.contains(&"pass --local to run against local services")); } @@ -217,7 +217,7 @@ fn non_interactive_run_without_session_requires_explicit_auth_or_local() { fn run_project_and_named_entry_are_respected() { let Cli { command: Commands::Run(args), - } = parse(&["disasmer", "run", "test", "--project", "/other"]) + } = parse(&["clusterflux", "run", "test", "--project", "/other"]) else { panic!("wrong command"); }; @@ -239,7 +239,7 @@ fn node_attach_detects_and_accepts_capability_overrides() { command: Commands::Node { command: NodeCommands::Attach(args), }, - } = parse(&["disasmer", "node", "attach", "--cap", "quic-direct"]) + } = parse(&["clusterflux", "node", "attach", "--cap", "quic-direct"]) else { panic!("wrong command"); }; @@ -280,7 +280,7 @@ fn node_attach_discloses_dangerous_capability_grants() { command: NodeCommands::Attach(args), }, } = parse(&[ - "disasmer", + "clusterflux", "node", "attach", "--cap", @@ -371,7 +371,8 @@ fn agent_environment_auth_requires_matching_private_key_possession() { assert!(public_only.to_string().contains("cannot authenticate")); let private_key = "ed25519:BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc="; - let public_key = disasmer_core::agent_ed25519_public_key_from_private_key(private_key).unwrap(); + let public_key = + clusterflux_core::agent_ed25519_public_key_from_private_key(private_key).unwrap(); let session = agent_session_from_keys( "agent-ci".to_owned(), Some(public_key.clone()), @@ -415,7 +416,8 @@ fn run_with_agent_public_key_sends_attributable_workflow_actor() { .unwrap(); let private_key = "ed25519:BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc="; - let public_key = disasmer_core::agent_ed25519_public_key_from_private_key(private_key).unwrap(); + let public_key = + clusterflux_core::agent_ed25519_public_key_from_private_key(private_key).unwrap(); let fingerprint = Digest::sha256(&public_key); let expected_fingerprint = fingerprint.to_string(); let listener = TcpListener::bind("127.0.0.1:0").unwrap(); @@ -460,10 +462,14 @@ fn run_with_agent_public_key_sends_attributable_workflow_actor() { launch["payload"]["task_spec"]["task_instance"], "ti:vp-current:main" ); + assert_eq!( + launch["payload"]["task_spec"]["failure_policy"], + "fail_fast" + ); assert!(launch_line.contains(r#""required_capabilities":[]"#)); assert!(launch_line.contains(r#""wasm_module_base64":""#)); assert!(launch_line.contains(r#""kind":"coordinator_node_wasm""#)); - assert!(launch_line.contains(r#""export":"disasmer_entry_v1_"#)); + assert!(launch_line.contains(r#""export":"clusterflux_entry_v1_"#)); assert!(!launch_line.contains(r#""command":""#)); assert!(launch_line.contains(r#""actor_agent":"agent-ci""#)); assert!(launch_line.contains(&format!( @@ -493,7 +499,7 @@ fn run_with_agent_public_key_sends_attributable_workflow_actor() { RunArgs { entry: Some("build".to_owned()), project: Some(temp.path().to_path_buf()), - coordinator: Some(format!("disasmer+tcp://{addr}")), + coordinator: Some(format!("clusterflux+tcp://{addr}")), local: false, non_interactive: true, json: false, @@ -611,7 +617,7 @@ fn run_with_human_session_derives_workflow_scope_from_authenticated_envelope() { fn run_local_flag_overrides_logged_in_hosted_selection() { let Cli { command: Commands::Run(args), - } = parse(&["disasmer", "run", "--local"]) + } = parse(&["clusterflux", "run", "--local"]) else { panic!("wrong command"); }; @@ -677,7 +683,7 @@ fn run_contacts_configured_coordinator_and_reports_active_process_conflicts() { assert!(launch_line.contains(r#""required_capabilities":[]"#)); assert!(launch_line.contains(r#""wasm_module_base64":""#)); assert!(launch_line.contains(r#""kind":"coordinator_node_wasm""#)); - assert!(launch_line.contains(r#""export":"disasmer_entry_v1_"#)); + assert!(launch_line.contains(r#""export":"clusterflux_entry_v1_"#)); assert!(!launch_line.contains(r#""command":""#)); assert!(!launch_line.contains("--manifest-path")); stream @@ -702,7 +708,7 @@ fn run_contacts_configured_coordinator_and_reports_active_process_conflicts() { RunArgs { entry: Some("build".to_owned()), project: Some(temp.path().to_path_buf()), - coordinator: Some(format!("disasmer+tcp://{addr}")), + coordinator: Some(format!("clusterflux+tcp://{addr}")), local: false, non_interactive: false, json: false, @@ -715,7 +721,7 @@ fn run_contacts_configured_coordinator_and_reports_active_process_conflicts() { RunArgs { entry: Some("test".to_owned()), project: Some(temp.path().to_path_buf()), - coordinator: Some(format!("disasmer+tcp://{addr}")), + coordinator: Some(format!("clusterflux+tcp://{addr}")), local: false, non_interactive: false, json: false, @@ -757,7 +763,7 @@ fn run_contacts_configured_coordinator_and_reports_active_process_conflicts() { .as_array() .unwrap() .iter() - .any(|action| action == "disasmer process restart --yes")); + .any(|action| action == "clusterflux process restart --yes")); } #[test] @@ -781,14 +787,14 @@ fn run_rejection_reports_machine_readable_error_category() { .as_array() .unwrap() .iter() - .any(|action| action == "disasmer quota status")); + .any(|action| action == "clusterflux quota status")); } #[test] fn local_only_run_executes_ephemeral_local_services() { let Cli { command: Commands::Run(args), - } = parse(&["disasmer", "run", "--local"]) + } = parse(&["clusterflux", "run", "--local"]) else { panic!("wrong command"); }; @@ -801,7 +807,7 @@ fn local_only_run_executes_ephemeral_local_services() { fn login_defaults_to_the_server_owned_browser_flow() { let Cli { command: Commands::Login(args), - } = parse(&["disasmer", "login"]) + } = parse(&["clusterflux", "login"]) else { panic!("wrong command"); }; @@ -822,7 +828,7 @@ fn login_defaults_to_the_server_owned_browser_flow() { fn browser_login_flow_is_available_for_humans() { let Cli { command: Commands::Login(args), - } = parse(&["disasmer", "login", "--browser"]) + } = parse(&["clusterflux", "login", "--browser"]) else { panic!("wrong command"); }; @@ -853,7 +859,7 @@ fn login_inherits_the_current_project_scope() { .unwrap(); let Cli { command: Commands::Login(args), - } = parse(&["disasmer", "login", "--browser"]) + } = parse(&["clusterflux", "login", "--browser"]) else { panic!("wrong command"); }; @@ -899,7 +905,7 @@ fn auth_status_reports_a_session_that_does_not_match_the_current_project() { command: Commands::Auth { command: AuthCommands::Status(args), }, - } = parse(&["disasmer", "auth", "status"]) + } = parse(&["clusterflux", "auth", "status"]) else { panic!("wrong command"); }; @@ -921,7 +927,7 @@ fn auth_status_reports_a_session_that_does_not_match_the_current_project() { fn browser_login_non_interactive_fails_before_opening_browser() { let Cli { command: Commands::Login(args), - } = parse(&["disasmer", "login", "--browser", "--non-interactive"]) + } = parse(&["clusterflux", "login", "--browser", "--non-interactive"]) else { panic!("wrong command"); }; @@ -942,7 +948,7 @@ fn browser_login_non_interactive_fails_before_opening_browser() { .filter_map(Value::as_str) .collect::>(); assert!(next_actions.contains(&"rerun without --non-interactive to open the browser")); - assert!(next_actions.contains(&"use DISASMER_AGENT_PRIVATE_KEY for automation")); + assert!(next_actions.contains(&"use CLUSTERFLUX_AGENT_PRIVATE_KEY for automation")); } #[test] @@ -973,7 +979,7 @@ fn stored_browser_login_session_omits_provider_token_values() { "project": "project-live", "user": "user-live", "cli_session_credential_kind": "CliDeviceSession", - "cli_session_secret": "disasmer-cli-session-secret", + "cli_session_secret": "clusterflux-cli-session-secret", "expires_at": "2026-07-04T00:00:00Z", "access_token": "provider-secret", "id_token": "provider-id-token", @@ -993,7 +999,7 @@ fn stored_browser_login_session_omits_provider_token_values() { assert_eq!(stored.user, "user-live"); assert_eq!( stored.session_secret.as_deref(), - Some("disasmer-cli-session-secret") + Some("clusterflux-cli-session-secret") ); assert_eq!(stored.token_expiry_posture, "expires_at"); assert!(stored.provider_tokens_exposed_to_cli); @@ -1014,7 +1020,7 @@ fn stored_browser_login_session_accepts_hosted_epoch_expiry() { "project": "project-live", "user": "user-live", "cli_session_credential_kind": "CliDeviceSession", - "cli_session_secret": "disasmer-cli-session-secret", + "cli_session_secret": "clusterflux-cli-session-secret", "expires_at_epoch_seconds": 1_800_000_000_u64, "provider_tokens_sent_to_nodes": false } @@ -1034,7 +1040,13 @@ fn agent_enroll_uses_public_key_without_browser_each_run() { command: Commands::Agent { command: AgentCommands::Enroll(args), }, - } = parse(&["disasmer", "agent", "enroll", "--public-key", "agent-key"]) + } = parse(&[ + "clusterflux", + "agent", + "enroll", + "--public-key", + "agent-key", + ]) else { panic!("wrong command"); }; @@ -1063,7 +1075,7 @@ fn bundle_inspect_discovers_environments_selected_inputs_and_source_providers() fs::write(temp.path().join("Cargo.toml"), "[package]\nname='demo'\n").unwrap(); fs::write( temp.path().join("src/main.rs"), - "fn main() {}\n\n#[disasmer::task]\nfn compile_linux() {}\n\n#[disasmer::main]\nfn build_main() {}\n", + "fn main() {}\n\n#[clusterflux::task]\nfn compile_linux() {}\n\n#[clusterflux::main]\nfn build_main() {}\n", ) .unwrap(); @@ -1072,7 +1084,7 @@ fn bundle_inspect_discovers_environments_selected_inputs_and_source_providers() command: BundleCommands::Inspect(args), }, } = parse(&[ - "disasmer", + "clusterflux", "bundle", "inspect", "--project", @@ -1273,7 +1285,7 @@ fn bundle_inspect_reports_missing_environment_references_before_schedule() { ); assert!(inspection.environment_diagnostics[0] .message - .contains("missing Disasmer environment `linux`")); + .contains("missing Clusterflux environment `linux`")); assert!(inspection .pre_schedule_diagnostics .iter() @@ -1492,7 +1504,7 @@ fn node_attach_can_exchange_enrollment_grant() { command: NodeCommands::Attach(args), }, } = parse(&[ - "disasmer", + "clusterflux", "node", "attach", "--enrollment-grant", @@ -1519,7 +1531,7 @@ fn node_attach_enrollment_uses_default_public_key_when_not_explicit() { command: NodeCommands::Attach(args), }, } = parse(&[ - "disasmer", + "clusterflux", "node", "attach", "--node", @@ -1553,16 +1565,16 @@ fn node_attach_local_credential_is_durable_and_project_scoped() { assert!(credential_file.exists()); assert!( credential_file - .strip_prefix(temp.path().join(".disasmer").join("nodes")) + .strip_prefix(temp.path().join(".clusterflux").join("nodes")) .unwrap() .components() .count() == 1 ); - let public_key = disasmer_core::node_ed25519_public_key_from_private_key(&first).unwrap(); + let public_key = clusterflux_core::node_ed25519_public_key_from_private_key(&first).unwrap(); let bytes = fs::read(credential_file).unwrap(); let stored: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); - assert_eq!(stored["kind"], "disasmer_node_credential"); + assert_eq!(stored["kind"], "clusterflux_node_credential"); assert_eq!(stored["node"], node); assert_eq!(stored["public_key"], public_key); assert_eq!(stored["credential_scope"], "local_project_node_identity"); @@ -1575,7 +1587,7 @@ fn node_attach_local_credential_is_durable_and_project_scoped() { .permissions() .mode() & 0o777; - let directory_mode = fs::metadata(temp.path().join(".disasmer").join("nodes")) + let directory_mode = fs::metadata(temp.path().join(".clusterflux").join("nodes")) .unwrap() .permissions() .mode() @@ -1606,16 +1618,17 @@ fn node_attach_refuses_a_symlink_credential_target() { fn hosted_coordinator_remains_a_real_https_control_endpoint() { assert_eq!( control_endpoint_identity(DEFAULT_HOSTED_COORDINATOR_ENDPOINT).unwrap(), - "https://disasmer.michelpaulissen.com/api/v1/control" + "https://clusterflux.michelpaulissen.com/api/v1/control" ); assert_eq!( - control_endpoint_identity("https://disasmer.michelpaulissen.com/api/v1/control").unwrap(), - "https://disasmer.michelpaulissen.com/api/v1/control" + control_endpoint_identity("https://clusterflux.michelpaulissen.com/api/v1/control") + .unwrap(), + "https://clusterflux.michelpaulissen.com/api/v1/control" ); assert!(control_endpoint_identity("http://operator.example.test").is_err()); assert_eq!( control_endpoint_identity("127.0.0.1:7999").unwrap(), - "disasmer+tcp://127.0.0.1:7999" + "clusterflux+tcp://127.0.0.1:7999" ); } @@ -1723,7 +1736,7 @@ fn auth_status_reads_stored_cli_session_without_provider_tokens() { let mut line = String::new(); reader.read_line(&mut line).unwrap(); assert!(line.contains(r#""type":"authenticated""#)); - assert!(line.contains(r#""session_secret":"disasmer-cli-session-secret""#)); + assert!(line.contains(r#""session_secret":"clusterflux-cli-session-secret""#)); assert!(line.contains(r#""type":"auth_status""#)); assert!(!line.contains(r#""actor_user":"user-session""#)); stream @@ -1742,7 +1755,7 @@ fn auth_status_reads_stored_cli_session_without_provider_tokens() { project: "project-session".to_owned(), user: "user-session".to_owned(), cli_session_credential_kind: "CliDeviceSession".to_owned(), - session_secret: Some("disasmer-cli-session-secret".to_owned()), + session_secret: Some("clusterflux-cli-session-secret".to_owned()), token_expiry_posture: "expires_at".to_owned(), expires_at: Some("2026-07-04T00:00:00Z".to_owned()), provider_tokens_exposed_to_cli: false, @@ -1799,7 +1812,7 @@ fn auth_status_reads_stored_cli_session_without_provider_tokens() { #[test] fn auth_status_reports_expired_or_revoked_cli_session_as_login_required() { for message in [ - "unauthorized coordinator action: CLI session credential has expired; run disasmer login --browser again", + "unauthorized coordinator action: CLI session credential has expired; run clusterflux login --browser again", "unauthorized coordinator action: CLI session credential has been revoked", ] { let temp = tempfile::tempdir().unwrap(); @@ -1867,7 +1880,7 @@ fn auth_status_reports_expired_or_revoked_cli_session_as_login_required() { .as_array() .unwrap() .iter() - .any(|action| action == "disasmer login --browser") + .any(|action| action == "clusterflux login --browser") ); } } @@ -2068,13 +2081,13 @@ fn auth_status_reports_disabled_deleted_and_manual_review_safely() { #[test] fn cli_first_mvp_command_surface_parses() { for args in [ - &["disasmer", "doctor"][..], - &["disasmer", "auth", "status"], - &["disasmer", "logout", "--yes"], - &["disasmer", "auth", "logout", "--yes"], - &["disasmer", "login", "--browser", "--non-interactive"], + &["clusterflux", "doctor"][..], + &["clusterflux", "auth", "status"], + &["clusterflux", "logout", "--yes"], + &["clusterflux", "auth", "logout", "--yes"], + &["clusterflux", "login", "--browser", "--non-interactive"], &[ - "disasmer", + "clusterflux", "key", "add", "--agent", @@ -2082,47 +2095,52 @@ fn cli_first_mvp_command_surface_parses() { "--public-key", "key", ], - &["disasmer", "key", "list"], - &["disasmer", "key", "revoke", "--agent", "agent", "--yes"], - &["disasmer", "project", "init", "--yes"], - &["disasmer", "project", "status"], - &["disasmer", "project", "list"], - &["disasmer", "project", "select", "project"], - &["disasmer", "inspect"], - &["disasmer", "build"], - &["disasmer", "run", "--non-interactive"], - &["disasmer", "node", "enroll"], - &["disasmer", "node", "list"], - &["disasmer", "node", "status"], - &["disasmer", "node", "revoke", "--node", "node", "--yes"], - &["disasmer", "process", "list"], - &["disasmer", "process", "status"], - &["disasmer", "process", "restart", "--yes"], - &["disasmer", "process", "cancel", "--yes"], - &["disasmer", "process", "abort", "--yes"], - &["disasmer", "task", "list"], - &["disasmer", "task", "restart", "compile-linux", "--yes"], - &["disasmer", "logs"], - &["disasmer", "artifact", "list"], - &["disasmer", "artifact", "download", "artifact"], + &["clusterflux", "key", "list"], + &["clusterflux", "key", "revoke", "--agent", "agent", "--yes"], + &["clusterflux", "project", "init", "--yes"], + &["clusterflux", "project", "status"], + &["clusterflux", "project", "list"], + &["clusterflux", "project", "select", "project"], + &["clusterflux", "inspect"], + &["clusterflux", "build"], + &["clusterflux", "run", "--non-interactive"], + &["clusterflux", "node", "enroll"], + &["clusterflux", "node", "list"], + &["clusterflux", "node", "status"], + &["clusterflux", "node", "revoke", "--node", "node", "--yes"], + &["clusterflux", "process", "list"], + &["clusterflux", "process", "status"], + &["clusterflux", "process", "restart", "--yes"], + &["clusterflux", "process", "cancel", "--yes"], + &["clusterflux", "process", "abort", "--yes"], + &["clusterflux", "task", "list"], + &["clusterflux", "task", "restart", "compile-linux", "--yes"], + &["clusterflux", "logs"], + &["clusterflux", "artifact", "list"], + &["clusterflux", "artifact", "download", "artifact"], &[ - "disasmer", "artifact", "export", "artifact", "--to", "/tmp/out", + "clusterflux", + "artifact", + "export", + "artifact", + "--to", + "/tmp/out", ], - &["disasmer", "dap", "--plan"], - &["disasmer", "debug", "attach"], - &["disasmer", "quota", "status"], - &["disasmer", "admin", "status"], - &["disasmer", "admin", "bootstrap", "--yes"], + &["clusterflux", "dap", "--plan"], + &["clusterflux", "debug", "attach"], + &["clusterflux", "quota", "status"], + &["clusterflux", "admin", "status"], + &["clusterflux", "admin", "bootstrap", "--yes"], &[ - "disasmer", + "clusterflux", "admin", "revoke-node", "--node", "node", "--yes", ], - &["disasmer", "admin", "stop-process", "--yes"], - &["disasmer", "admin", "suspend-tenant", "--yes"], + &["clusterflux", "admin", "stop-process", "--yes"], + &["clusterflux", "admin", "suspend-tenant", "--yes"], ] { let _ = parse(args); } @@ -2131,9 +2149,9 @@ fn cli_first_mvp_command_surface_parses() { #[test] fn cli_has_no_direct_hosted_account_creation_command() { for args in [ - &["disasmer", "signup"][..], - &["disasmer", "account", "create"], - &["disasmer", "login", "--create-account"], + &["clusterflux", "signup"][..], + &["clusterflux", "account", "create"], + &["clusterflux", "login", "--create-account"], ] { let error = Cli::try_parse_from(args).unwrap_err().to_string(); assert!( @@ -2145,7 +2163,7 @@ fn cli_has_no_direct_hosted_account_creation_command() { let mut command = Cli::command(); let help = command.render_help().to_string(); assert!(help.contains("Hosted account creation happens in the browser login flow.")); - assert!(!help.contains("disasmer signup")); + assert!(!help.contains("clusterflux signup")); assert!(!help.contains("account create")); } @@ -2181,7 +2199,7 @@ fn admin_bootstrap_reports_self_hosted_cli_only_path() { ); assert_eq!( report["admin_surfaces"]["node"], - "disasmer node enroll/list/status/revoke" + "clusterflux node enroll/list/status/revoke" ); let steps = report["bootstrap_sequence"].as_array().unwrap(); for expected in [ @@ -2208,12 +2226,12 @@ fn admin_bootstrap_reports_self_hosted_cli_only_path() { .get("command") .and_then(Value::as_str) .unwrap_or("") - .contains("disasmer node enroll"))); + .contains("clusterflux node enroll"))); assert!(steps.iter().any(|step| step .get("command") .and_then(Value::as_str) .unwrap_or("") - .contains("disasmer admin revoke-node"))); + .contains("clusterflux admin revoke-node"))); } #[test] @@ -2223,15 +2241,15 @@ fn top_level_help_exposes_primary_workflow_without_auth() { for expected in [ "Primary workflow:", - "disasmer login --browser", - "disasmer project init", - "disasmer node enroll", - "disasmer node attach", - "disasmer-node --worker", - "disasmer run [entry] --project ", - "Disasmer: Launch Virtual Process", - "disasmer dap", - "disasmer process status", + "clusterflux login --browser", + "clusterflux project init", + "clusterflux node enroll", + "clusterflux node attach", + "clusterflux-node --worker", + "clusterflux run [entry] --project ", + "Clusterflux: Launch Virtual Process", + "clusterflux dap", + "clusterflux process status", "task list", "logs", "artifact list", @@ -2437,19 +2455,19 @@ fn mutating_commands_require_yes_before_side_effects() { #[test] fn cli_first_json_mode_parses_for_primary_commands() { for args in [ - &["disasmer", "doctor", "--json"][..], - &["disasmer", "login", "--json"], + &["clusterflux", "doctor", "--json"][..], + &["clusterflux", "login", "--json"], &[ - "disasmer", + "clusterflux", "login", "--browser", "--non-interactive", "--json", ], - &["disasmer", "logout", "--yes", "--json"], - &["disasmer", "auth", "status", "--json"], + &["clusterflux", "logout", "--yes", "--json"], + &["clusterflux", "auth", "status", "--json"], &[ - "disasmer", + "clusterflux", "agent", "enroll", "--public-key", @@ -2457,7 +2475,7 @@ fn cli_first_json_mode_parses_for_primary_commands() { "--json", ], &[ - "disasmer", + "clusterflux", "key", "add", "--agent", @@ -2466,26 +2484,32 @@ fn cli_first_json_mode_parses_for_primary_commands() { "key", "--json", ], - &["disasmer", "project", "init", "--yes", "--json"], - &["disasmer", "inspect", "--json"], - &["disasmer", "build", "--json"], - &["disasmer", "bundle", "inspect", "--json"], - &["disasmer", "run", "--json"], - &["disasmer", "run", "--non-interactive", "--json"], - &["disasmer", "node", "attach", "--json"], - &["disasmer", "node", "enroll", "--json"], - &["disasmer", "process", "status", "--json"], - &["disasmer", "task", "list", "--json"], - &["disasmer", "logs", "--json"], - &["disasmer", "artifact", "list", "--json"], - &["disasmer", "artifact", "download", "artifact", "--json"], + &["clusterflux", "project", "init", "--yes", "--json"], + &["clusterflux", "inspect", "--json"], + &["clusterflux", "build", "--json"], + &["clusterflux", "bundle", "inspect", "--json"], + &["clusterflux", "run", "--json"], + &["clusterflux", "run", "--non-interactive", "--json"], + &["clusterflux", "node", "attach", "--json"], + &["clusterflux", "node", "enroll", "--json"], + &["clusterflux", "process", "status", "--json"], + &["clusterflux", "task", "list", "--json"], + &["clusterflux", "logs", "--json"], + &["clusterflux", "artifact", "list", "--json"], + &["clusterflux", "artifact", "download", "artifact", "--json"], &[ - "disasmer", "artifact", "export", "artifact", "--to", "/tmp/out", "--json", + "clusterflux", + "artifact", + "export", + "artifact", + "--to", + "/tmp/out", + "--json", ], - &["disasmer", "dap", "--plan", "--json"], - &["disasmer", "debug", "attach", "--json"], - &["disasmer", "quota", "status", "--json"], - &["disasmer", "admin", "status", "--json"], + &["clusterflux", "dap", "--plan", "--json"], + &["clusterflux", "debug", "attach", "--json"], + &["clusterflux", "quota", "status", "--json"], + &["clusterflux", "admin", "status", "--json"], ] { let _ = parse(args); } @@ -2780,7 +2804,7 @@ fn admin_status_and_suspend_use_public_coordinator_api() { let issued_at_epoch_seconds = payload["issued_at_epoch_seconds"].as_u64().unwrap(); assert_eq!( payload["admin_proof"], - disasmer_core::admin_request_proof( + clusterflux_core::admin_request_proof( "admin-token", expected, tenant, @@ -2881,7 +2905,7 @@ fn debug_attach_reports_public_authorization() { }, process: "vp".to_owned(), }, - "/tmp/disasmer-debug-dap-test".to_owned(), + "/tmp/clusterflux-debug-dap-test".to_owned(), ) .unwrap(); server.join().unwrap(); @@ -3053,7 +3077,7 @@ fn user_control_commands_use_authenticated_envelope_with_stored_cli_session() { scope: coordinator_scope, process: "vp".to_owned(), }, - "/tmp/disasmer-debug-dap-test".to_owned(), + "/tmp/clusterflux-debug-dap-test".to_owned(), Some(&session), ) .unwrap(); @@ -3066,15 +3090,15 @@ fn human_report_is_text_not_json() { "command": "doctor", "status": "ok", "coordinator": "127.0.0.1:9443", - "next_actions": ["disasmer login --browser", "disasmer project init"], + "next_actions": ["clusterflux login --browser", "clusterflux project init"], }); let human = human_report(&report); assert!(!human.trim_start().starts_with('{')); - assert!(human.contains("Disasmer doctor")); + assert!(human.contains("Clusterflux doctor")); assert!(human.contains("status: ok")); assert!(human.contains("coordinator: 127.0.0.1:9443")); - assert!(human.contains("disasmer login --browser")); + assert!(human.contains("clusterflux login --browser")); } #[test] @@ -3102,7 +3126,7 @@ fn project_init_select_and_status_use_local_project_config() { assert_eq!(init["project_config_written"], true); assert_eq!( init["current_directory_link"]["config_format"], - "disasmer_project_config_v1" + "clusterflux_project_config_v1" ); assert_eq!( init["current_directory_link"]["links_current_directory"], @@ -4403,7 +4427,7 @@ fn build_command_reuses_bundle_inspection_without_full_repo_upload() { assert_eq!(report["content_addressed"], true); assert_eq!(report["contains_full_repository_upload"], false); assert_eq!(report["bundle_artifact"]["task_descriptor_count"], 9); - assert_eq!(report["bundle_artifact"]["entrypoint_count"], 3); + assert_eq!(report["bundle_artifact"]["entrypoint_count"], 4); assert!(output.join("module.wasm").is_file()); assert!(output.join("manifest.json").is_file()); assert!(output.join("task-descriptors.json").is_file()); diff --git a/crates/disasmer-cli/src/tools.rs b/crates/clusterflux-cli/src/tools.rs similarity index 84% rename from crates/disasmer-cli/src/tools.rs rename to crates/clusterflux-cli/src/tools.rs index a18e15f..59c9def 100644 --- a/crates/disasmer-cli/src/tools.rs +++ b/crates/clusterflux-cli/src/tools.rs @@ -39,25 +39,25 @@ pub(crate) fn sibling_binary(name: &str) -> Option { } pub(crate) fn dap_binary_path() -> Result { - if let Some(path) = std::env::var_os("DISASMER_DAP_BIN") { + if let Some(path) = std::env::var_os("CLUSTERFLUX_DAP_BIN") { return Ok(PathBuf::from(path)); } - if let Some(path) = sibling_binary("disasmer-debug-dap") { + if let Some(path) = sibling_binary("clusterflux-debug-dap") { return Ok(path); } let release = PathBuf::from("target/release").join(format!( - "disasmer-debug-dap{}", + "clusterflux-debug-dap{}", std::env::consts::EXE_SUFFIX )); if release.is_file() { return Ok(release); } let debug = PathBuf::from("target/debug").join(format!( - "disasmer-debug-dap{}", + "clusterflux-debug-dap{}", std::env::consts::EXE_SUFFIX )); if debug.is_file() { return Ok(debug); } - anyhow::bail!("could not locate disasmer-debug-dap; set DISASMER_DAP_BIN") + anyhow::bail!("could not locate clusterflux-debug-dap; set CLUSTERFLUX_DAP_BIN") } diff --git a/crates/disasmer-control/Cargo.toml b/crates/clusterflux-control/Cargo.toml similarity index 89% rename from crates/disasmer-control/Cargo.toml rename to crates/clusterflux-control/Cargo.toml index 3231657..aac8fdc 100644 --- a/crates/disasmer-control/Cargo.toml +++ b/crates/clusterflux-control/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "disasmer-control" +name = "clusterflux-control" version = "0.1.0" edition.workspace = true license.workspace = true diff --git a/crates/disasmer-control/src/lib.rs b/crates/clusterflux-control/src/lib.rs similarity index 92% rename from crates/disasmer-control/src/lib.rs rename to crates/clusterflux-control/src/lib.rs index 2e619ad..0b86002 100644 --- a/crates/disasmer-control/src/lib.rs +++ b/crates/clusterflux-control/src/lib.rs @@ -84,7 +84,9 @@ impl ControlSession { }); } - let loopback_address = endpoint.strip_prefix("disasmer+tcp://").unwrap_or(endpoint); + let loopback_address = endpoint + .strip_prefix("clusterflux+tcp://") + .unwrap_or(endpoint); if !endpoint_is_loopback(loopback_address) { return Err(ControlTransportError::InsecureRemote(endpoint.to_owned())); } @@ -185,9 +187,11 @@ pub fn endpoint_identity(endpoint: &str) -> Result bool { .trim() .strip_prefix("https://") .or_else(|| endpoint.trim().strip_prefix("http://")) - .or_else(|| endpoint.trim().strip_prefix("disasmer+tcp://")) + .or_else(|| endpoint.trim().strip_prefix("clusterflux+tcp://")) .unwrap_or(endpoint.trim()) .split('/') .next() @@ -236,19 +240,19 @@ mod tests { #[test] fn hosted_endpoints_are_real_https_api_urls() { assert_eq!( - control_api_url("https://disasmer.example").unwrap(), - "https://disasmer.example/api/v1/control" + control_api_url("https://clusterflux.example").unwrap(), + "https://clusterflux.example/api/v1/control" ); assert_eq!( - endpoint_identity("https://disasmer.example/api/v1/control").unwrap(), - "https://disasmer.example/api/v1/control" + endpoint_identity("https://clusterflux.example/api/v1/control").unwrap(), + "https://clusterflux.example/api/v1/control" ); } #[test] fn plaintext_transport_is_restricted_to_loopback() { assert!(endpoint_is_loopback("127.0.0.1:7999")); - assert!(endpoint_is_loopback("disasmer+tcp://127.0.0.1:7999")); + assert!(endpoint_is_loopback("clusterflux+tcp://127.0.0.1:7999")); assert!(endpoint_is_loopback("http://[::1]:7999")); assert!(matches!( ControlSession::connect("http://example.com:7999"), diff --git a/crates/disasmer-coordinator/Cargo.toml b/crates/clusterflux-coordinator/Cargo.toml similarity index 68% rename from crates/disasmer-coordinator/Cargo.toml rename to crates/clusterflux-coordinator/Cargo.toml index 85293d2..8aee9c4 100644 --- a/crates/disasmer-coordinator/Cargo.toml +++ b/crates/clusterflux-coordinator/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "disasmer-coordinator" +name = "clusterflux-coordinator" version = "0.1.0" edition.workspace = true license.workspace = true @@ -7,8 +7,8 @@ repository.workspace = true [dependencies] base64.workspace = true -disasmer-core = { path = "../disasmer-core" } -disasmer-wasm-runtime = { path = "../disasmer-wasm-runtime" } +clusterflux-core = { path = "../clusterflux-core" } +clusterflux-wasm-runtime = { path = "../clusterflux-wasm-runtime" } postgres.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/disasmer-coordinator/src/agents.rs b/crates/clusterflux-coordinator/src/agents.rs similarity index 99% rename from crates/disasmer-coordinator/src/agents.rs rename to crates/clusterflux-coordinator/src/agents.rs index fe74517..d0bd438 100644 --- a/crates/disasmer-coordinator/src/agents.rs +++ b/crates/clusterflux-coordinator/src/agents.rs @@ -1,4 +1,4 @@ -use disasmer_core::{ +use clusterflux_core::{ verify_agent_workflow_signature, Actor, AgentId, AgentSignedRequest, AgentWorkflowScope, AuthContext, CredentialKind, Digest, ProjectId, TenantId, UserId, }; diff --git a/crates/disasmer-coordinator/src/durable.rs b/crates/clusterflux-coordinator/src/durable.rs similarity index 99% rename from crates/disasmer-coordinator/src/durable.rs rename to crates/clusterflux-coordinator/src/durable.rs index cffeaae..b040245 100644 --- a/crates/disasmer-coordinator/src/durable.rs +++ b/crates/clusterflux-coordinator/src/durable.rs @@ -1,6 +1,6 @@ use std::collections::BTreeMap; -use disasmer_core::{ +use clusterflux_core::{ AgentId, CredentialKind, Digest, NodeId, ProjectId, SourceProviderKind, TenantId, UserId, }; use serde::{Deserialize, Serialize}; diff --git a/crates/disasmer-coordinator/src/lib.rs b/crates/clusterflux-coordinator/src/lib.rs similarity index 98% rename from crates/disasmer-coordinator/src/lib.rs rename to crates/clusterflux-coordinator/src/lib.rs index a2174f4..fc716b1 100644 --- a/crates/disasmer-coordinator/src/lib.rs +++ b/crates/clusterflux-coordinator/src/lib.rs @@ -1,6 +1,6 @@ use std::collections::{BTreeMap, BTreeSet}; -use disasmer_core::{ +use clusterflux_core::{ Actor, AuthContext, Authorization, CredentialKind, Digest, EnrollmentError, EnrollmentGrant, NodeCredential, NodeId, ProcessId, ProjectId, SourceProviderKind, TenantId, UserId, }; @@ -21,10 +21,12 @@ pub use postgres_store::{ PostgresDurableStore, PostgresStoreError, PostgresTable, POSTGRES_DURABLE_TABLES, }; pub use service::{ - CoordinatorAdmission, CoordinatorRequest, CoordinatorResponse, CoordinatorService, - CoordinatorServiceError, DebugAcknowledgementState, DebugAuditEvent, + ArtifactRelayConfiguration, ArtifactRelayError, ArtifactRelayUsage, CoordinatorAdmission, + CoordinatorMainRuntimeConfiguration, CoordinatorRequest, CoordinatorResponse, + CoordinatorService, CoordinatorServiceError, DebugAcknowledgementState, DebugAuditEvent, DebugParticipantAcknowledgement, SourcePreparationDisposition, SourcePreparationStatus, - TaskAssignment, TaskCancellationTarget, TaskCompletionEvent, TaskExecutor, TaskTerminalState, + TaskAssignment, TaskAttemptSnapshot, TaskAttemptState, TaskCancellationTarget, + TaskCompletionEvent, TaskExecutor, TaskFailureResolution, TaskTerminalState, }; #[derive(Clone, Debug, PartialEq, Eq)] @@ -288,7 +290,7 @@ impl Coordinator { Vec::new() } else { vec![ - "disasmer auth status --json".to_owned(), + "clusterflux auth status --json".to_owned(), "contact the hosted operator or use a self-hosted coordinator".to_owned(), ] }; @@ -554,7 +556,7 @@ impl Coordinator { #[cfg(test)] mod tests { - use disasmer_core::AgentId; + use clusterflux_core::AgentId; use super::*; diff --git a/crates/disasmer-coordinator/src/main.rs b/crates/clusterflux-coordinator/src/main.rs similarity index 76% rename from crates/disasmer-coordinator/src/main.rs rename to crates/clusterflux-coordinator/src/main.rs index 0108498..8c9ce7d 100644 --- a/crates/disasmer-coordinator/src/main.rs +++ b/crates/clusterflux-coordinator/src/main.rs @@ -1,12 +1,12 @@ use std::io::Write; -use disasmer_coordinator::{service::bind_listener, CoordinatorService}; -use disasmer_core::{ProjectId, TenantId, UserId}; +use clusterflux_coordinator::{service::bind_listener, CoordinatorService}; +use clusterflux_core::{ProjectId, TenantId, UserId}; use serde_json::json; fn main() -> Result<(), Box> { let mut listen = "127.0.0.1:0".to_owned(); - let mut allow_local_trusted = std::env::var("DISASMER_ALLOW_LOCAL_TRUSTED_LOOPBACK") + let mut allow_local_trusted = std::env::var("CLUSTERFLUX_ALLOW_LOCAL_TRUSTED_LOOPBACK") .ok() .as_deref() == Some("1"); @@ -22,21 +22,21 @@ fn main() -> Result<(), Box> { let (listener, addr) = bind_listener(&listen)?; let database_url = std::env::var("DATABASE_URL").ok(); let mut service = CoordinatorService::new_with_database_url(1, database_url.as_deref())?; - let self_hosted_session_secret = std::env::var("DISASMER_SELF_HOSTED_SESSION_SECRET") + let self_hosted_session_secret = std::env::var("CLUSTERFLUX_SELF_HOSTED_SESSION_SECRET") .ok() .filter(|secret| !secret.trim().is_empty()); if let Some(session_secret) = self_hosted_session_secret.as_deref() { service.issue_cli_session( TenantId::new( - std::env::var("DISASMER_SELF_HOSTED_TENANT") + std::env::var("CLUSTERFLUX_SELF_HOSTED_TENANT") .unwrap_or_else(|_| "tenant".to_owned()), ), ProjectId::new( - std::env::var("DISASMER_SELF_HOSTED_PROJECT") + std::env::var("CLUSTERFLUX_SELF_HOSTED_PROJECT") .unwrap_or_else(|_| "project".to_owned()), ), UserId::new( - std::env::var("DISASMER_SELF_HOSTED_USER").unwrap_or_else(|_| "user".to_owned()), + std::env::var("CLUSTERFLUX_SELF_HOSTED_USER").unwrap_or_else(|_| "user".to_owned()), ), session_secret, None, diff --git a/crates/disasmer-coordinator/src/postgres_store.rs b/crates/clusterflux-coordinator/src/postgres_store.rs similarity index 72% rename from crates/disasmer-coordinator/src/postgres_store.rs rename to crates/clusterflux-coordinator/src/postgres_store.rs index 5bb7e47..ec72fe2 100644 --- a/crates/disasmer-coordinator/src/postgres_store.rs +++ b/crates/clusterflux-coordinator/src/postgres_store.rs @@ -17,52 +17,52 @@ pub struct PostgresTable { pub const POSTGRES_DURABLE_TABLES: &[PostgresTable] = &[ PostgresTable { - name: "disasmer_tenants", + name: "clusterflux_tenants", durable_record: "tenants", restart_surviving: true, }, PostgresTable { - name: "disasmer_users", + name: "clusterflux_users", durable_record: "users", restart_surviving: true, }, PostgresTable { - name: "disasmer_projects", + name: "clusterflux_projects", durable_record: "projects", restart_surviving: true, }, PostgresTable { - name: "disasmer_node_identities", + name: "clusterflux_node_identities", durable_record: "node identities", restart_surviving: true, }, PostgresTable { - name: "disasmer_credentials", + name: "clusterflux_credentials", durable_record: "credentials", restart_surviving: true, }, PostgresTable { - name: "disasmer_cli_sessions", + name: "clusterflux_cli_sessions", durable_record: "CLI sessions", restart_surviving: true, }, PostgresTable { - name: "disasmer_agent_public_keys", + name: "clusterflux_agent_public_keys", durable_record: "agent public keys", restart_surviving: true, }, PostgresTable { - name: "disasmer_source_provider_configs", + name: "clusterflux_source_provider_configs", durable_record: "source-provider configuration", restart_surviving: true, }, PostgresTable { - name: "disasmer_service_policy_records", + name: "clusterflux_service_policy_records", durable_record: "durable service policy records", restart_surviving: true, }, PostgresTable { - name: "disasmer_project_permissions", + name: "clusterflux_project_permissions", durable_record: "explicit project permissions", restart_surviving: true, }, @@ -134,39 +134,39 @@ impl FallibleDurableStore for PostgresDurableStore { let mut state = DurableState::default(); for record in self.query_records::( - "SELECT record FROM disasmer_tenants ORDER BY tenant_id", + "SELECT record FROM clusterflux_tenants ORDER BY tenant_id", )? { state.tenants.insert(record.id.clone(), record); } - for record in - self.query_records::("SELECT record FROM disasmer_users ORDER BY user_id")? + for record in self + .query_records::("SELECT record FROM clusterflux_users ORDER BY user_id")? { state.users.insert(record.id.clone(), record); } for record in self.query_records::( - "SELECT record FROM disasmer_projects ORDER BY project_id", + "SELECT record FROM clusterflux_projects ORDER BY project_id", )? { state.projects.insert(record.id.clone(), record); } for record in self.query_records::( - "SELECT record FROM disasmer_node_identities ORDER BY node_id", + "SELECT record FROM clusterflux_node_identities ORDER BY node_id", )? { state.node_identities.insert(record.id.clone(), record); } for record in self.query_records::( - "SELECT record FROM disasmer_credentials ORDER BY subject", + "SELECT record FROM clusterflux_credentials ORDER BY subject", )? { state.credentials.insert(record.subject.clone(), record); } for record in self.query_records::( - "SELECT record FROM disasmer_cli_sessions ORDER BY session_digest", + "SELECT record FROM clusterflux_cli_sessions ORDER BY session_digest", )? { state .cli_sessions .insert(record.session_digest.clone(), record); } for record in self.query_records::( - "SELECT record FROM disasmer_agent_public_keys ORDER BY tenant_id, project_id, agent_id", + "SELECT record FROM clusterflux_agent_public_keys ORDER BY tenant_id, project_id, agent_id", )? { state.agent_public_keys.insert( ( @@ -178,7 +178,7 @@ impl FallibleDurableStore for PostgresDurableStore { ); } for record in self.query_records::( - "SELECT record FROM disasmer_source_provider_configs ORDER BY tenant_id, project_id, provider_key", + "SELECT record FROM clusterflux_source_provider_configs ORDER BY tenant_id, project_id, provider_key", )? { let provider_key = format!("{:?}", record.provider); state.source_provider_configs.insert( @@ -187,14 +187,14 @@ impl FallibleDurableStore for PostgresDurableStore { ); } for record in self.query_records::( - "SELECT record FROM disasmer_service_policy_records ORDER BY tenant_id, name", + "SELECT record FROM clusterflux_service_policy_records ORDER BY tenant_id, name", )? { state .service_policy_records .insert((record.tenant.clone(), record.name.clone()), record); } for record in self.query_records::( - "SELECT record FROM disasmer_project_permissions ORDER BY tenant_id, project_id, user_id", + "SELECT record FROM clusterflux_project_permissions ORDER BY tenant_id, project_id, user_id", )? { state.project_permissions.insert( ( @@ -213,44 +213,44 @@ impl FallibleDurableStore for PostgresDurableStore { let mut tx = self.client.transaction()?; tx.batch_execute( " - DELETE FROM disasmer_project_permissions; - DELETE FROM disasmer_service_policy_records; - DELETE FROM disasmer_source_provider_configs; - DELETE FROM disasmer_agent_public_keys; - DELETE FROM disasmer_cli_sessions; - DELETE FROM disasmer_credentials; - DELETE FROM disasmer_node_identities; - DELETE FROM disasmer_projects; - DELETE FROM disasmer_users; - DELETE FROM disasmer_tenants; + DELETE FROM clusterflux_project_permissions; + DELETE FROM clusterflux_service_policy_records; + DELETE FROM clusterflux_source_provider_configs; + DELETE FROM clusterflux_agent_public_keys; + DELETE FROM clusterflux_cli_sessions; + DELETE FROM clusterflux_credentials; + DELETE FROM clusterflux_node_identities; + DELETE FROM clusterflux_projects; + DELETE FROM clusterflux_users; + DELETE FROM clusterflux_tenants; ", )?; for record in state.tenants.values() { let value = Self::record_value(record)?; tx.execute( - "INSERT INTO disasmer_tenants (tenant_id, record) VALUES ($1, $2)", + "INSERT INTO clusterflux_tenants (tenant_id, record) VALUES ($1, $2)", &[&record.id.as_str(), &value], )?; } for record in state.users.values() { let value = Self::record_value(record)?; tx.execute( - "INSERT INTO disasmer_users (user_id, tenant_id, record) VALUES ($1, $2, $3)", + "INSERT INTO clusterflux_users (user_id, tenant_id, record) VALUES ($1, $2, $3)", &[&record.id.as_str(), &record.tenant.as_str(), &value], )?; } for record in state.projects.values() { let value = Self::record_value(record)?; tx.execute( - "INSERT INTO disasmer_projects (project_id, tenant_id, record) VALUES ($1, $2, $3)", + "INSERT INTO clusterflux_projects (project_id, tenant_id, record) VALUES ($1, $2, $3)", &[&record.id.as_str(), &record.tenant.as_str(), &value], )?; } for record in state.node_identities.values() { let value = Self::record_value(record)?; tx.execute( - "INSERT INTO disasmer_node_identities (node_id, tenant_id, project_id, record) VALUES ($1, $2, $3, $4)", + "INSERT INTO clusterflux_node_identities (node_id, tenant_id, project_id, record) VALUES ($1, $2, $3, $4)", &[ &record.id.as_str(), &record.tenant.as_str(), @@ -263,14 +263,14 @@ impl FallibleDurableStore for PostgresDurableStore { let value = Self::record_value(record)?; let project_id = record.project.as_ref().map(|project| project.as_str()); tx.execute( - "INSERT INTO disasmer_credentials (subject, tenant_id, project_id, record) VALUES ($1, $2, $3, $4)", + "INSERT INTO clusterflux_credentials (subject, tenant_id, project_id, record) VALUES ($1, $2, $3, $4)", &[&record.subject.as_str(), &record.tenant.as_str(), &project_id, &value], )?; } for record in state.cli_sessions.values() { let value = Self::record_value(record)?; tx.execute( - "INSERT INTO disasmer_cli_sessions (session_digest, tenant_id, project_id, user_id, record) VALUES ($1, $2, $3, $4, $5)", + "INSERT INTO clusterflux_cli_sessions (session_digest, tenant_id, project_id, user_id, record) VALUES ($1, $2, $3, $4, $5)", &[ &record.session_digest.as_str(), &record.tenant.as_str(), @@ -283,7 +283,7 @@ impl FallibleDurableStore for PostgresDurableStore { for record in state.agent_public_keys.values() { let value = Self::record_value(record)?; tx.execute( - "INSERT INTO disasmer_agent_public_keys (tenant_id, project_id, user_id, agent_id, record) VALUES ($1, $2, $3, $4, $5)", + "INSERT INTO clusterflux_agent_public_keys (tenant_id, project_id, user_id, agent_id, record) VALUES ($1, $2, $3, $4, $5)", &[ &record.tenant.as_str(), &record.project.as_str(), @@ -296,7 +296,7 @@ impl FallibleDurableStore for PostgresDurableStore { for ((_, _, provider_key), record) in &state.source_provider_configs { let value = Self::record_value(record)?; tx.execute( - "INSERT INTO disasmer_source_provider_configs (tenant_id, project_id, provider_key, record) VALUES ($1, $2, $3, $4)", + "INSERT INTO clusterflux_source_provider_configs (tenant_id, project_id, provider_key, record) VALUES ($1, $2, $3, $4)", &[ &record.tenant.as_str(), &record.project.as_str(), @@ -308,14 +308,14 @@ impl FallibleDurableStore for PostgresDurableStore { for record in state.service_policy_records.values() { let value = Self::record_value(record)?; tx.execute( - "INSERT INTO disasmer_service_policy_records (tenant_id, name, record) VALUES ($1, $2, $3)", + "INSERT INTO clusterflux_service_policy_records (tenant_id, name, record) VALUES ($1, $2, $3)", &[&record.tenant.as_str(), &record.name.as_str(), &value], )?; } for record in state.project_permissions.values() { let value = Self::record_value(record)?; tx.execute( - "INSERT INTO disasmer_project_permissions (tenant_id, project_id, user_id, record) VALUES ($1, $2, $3, $4)", + "INSERT INTO clusterflux_project_permissions (tenant_id, project_id, user_id, record) VALUES ($1, $2, $3, $4)", &[ &record.tenant.as_str(), &record.project.as_str(), @@ -331,73 +331,73 @@ impl FallibleDurableStore for PostgresDurableStore { } const POSTGRES_SCHEMA_SQL: &str = r#" -CREATE TABLE IF NOT EXISTS disasmer_tenants ( +CREATE TABLE IF NOT EXISTS clusterflux_tenants ( tenant_id TEXT PRIMARY KEY, record JSONB NOT NULL ); -CREATE TABLE IF NOT EXISTS disasmer_users ( +CREATE TABLE IF NOT EXISTS clusterflux_users ( user_id TEXT PRIMARY KEY, - tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE, + tenant_id TEXT NOT NULL REFERENCES clusterflux_tenants(tenant_id) ON DELETE CASCADE, record JSONB NOT NULL ); -CREATE TABLE IF NOT EXISTS disasmer_projects ( +CREATE TABLE IF NOT EXISTS clusterflux_projects ( project_id TEXT PRIMARY KEY, - tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE, + tenant_id TEXT NOT NULL REFERENCES clusterflux_tenants(tenant_id) ON DELETE CASCADE, record JSONB NOT NULL ); -CREATE TABLE IF NOT EXISTS disasmer_node_identities ( +CREATE TABLE IF NOT EXISTS clusterflux_node_identities ( node_id TEXT PRIMARY KEY, - tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE, - project_id TEXT NOT NULL REFERENCES disasmer_projects(project_id) ON DELETE CASCADE, + tenant_id TEXT NOT NULL REFERENCES clusterflux_tenants(tenant_id) ON DELETE CASCADE, + project_id TEXT NOT NULL REFERENCES clusterflux_projects(project_id) ON DELETE CASCADE, record JSONB NOT NULL ); -CREATE TABLE IF NOT EXISTS disasmer_credentials ( +CREATE TABLE IF NOT EXISTS clusterflux_credentials ( subject TEXT PRIMARY KEY, - tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE, - project_id TEXT REFERENCES disasmer_projects(project_id) ON DELETE CASCADE, + tenant_id TEXT NOT NULL REFERENCES clusterflux_tenants(tenant_id) ON DELETE CASCADE, + project_id TEXT REFERENCES clusterflux_projects(project_id) ON DELETE CASCADE, record JSONB NOT NULL ); -CREATE TABLE IF NOT EXISTS disasmer_cli_sessions ( +CREATE TABLE IF NOT EXISTS clusterflux_cli_sessions ( session_digest TEXT PRIMARY KEY, - tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE, - project_id TEXT NOT NULL REFERENCES disasmer_projects(project_id) ON DELETE CASCADE, - user_id TEXT NOT NULL REFERENCES disasmer_users(user_id) ON DELETE CASCADE, + tenant_id TEXT NOT NULL REFERENCES clusterflux_tenants(tenant_id) ON DELETE CASCADE, + project_id TEXT NOT NULL REFERENCES clusterflux_projects(project_id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES clusterflux_users(user_id) ON DELETE CASCADE, record JSONB NOT NULL ); -CREATE TABLE IF NOT EXISTS disasmer_agent_public_keys ( - tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE, - project_id TEXT NOT NULL REFERENCES disasmer_projects(project_id) ON DELETE CASCADE, - user_id TEXT NOT NULL REFERENCES disasmer_users(user_id) ON DELETE CASCADE, +CREATE TABLE IF NOT EXISTS clusterflux_agent_public_keys ( + tenant_id TEXT NOT NULL REFERENCES clusterflux_tenants(tenant_id) ON DELETE CASCADE, + project_id TEXT NOT NULL REFERENCES clusterflux_projects(project_id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES clusterflux_users(user_id) ON DELETE CASCADE, agent_id TEXT NOT NULL, record JSONB NOT NULL, PRIMARY KEY (tenant_id, project_id, agent_id) ); -CREATE TABLE IF NOT EXISTS disasmer_source_provider_configs ( - tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE, - project_id TEXT NOT NULL REFERENCES disasmer_projects(project_id) ON DELETE CASCADE, +CREATE TABLE IF NOT EXISTS clusterflux_source_provider_configs ( + tenant_id TEXT NOT NULL REFERENCES clusterflux_tenants(tenant_id) ON DELETE CASCADE, + project_id TEXT NOT NULL REFERENCES clusterflux_projects(project_id) ON DELETE CASCADE, provider_key TEXT NOT NULL, record JSONB NOT NULL, PRIMARY KEY (tenant_id, project_id, provider_key) ); -CREATE TABLE IF NOT EXISTS disasmer_service_policy_records ( - tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE, +CREATE TABLE IF NOT EXISTS clusterflux_service_policy_records ( + tenant_id TEXT NOT NULL REFERENCES clusterflux_tenants(tenant_id) ON DELETE CASCADE, name TEXT NOT NULL, record JSONB NOT NULL, PRIMARY KEY (tenant_id, name) ); -CREATE TABLE IF NOT EXISTS disasmer_project_permissions ( - tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE, - project_id TEXT NOT NULL REFERENCES disasmer_projects(project_id) ON DELETE CASCADE, - user_id TEXT NOT NULL REFERENCES disasmer_users(user_id) ON DELETE CASCADE, +CREATE TABLE IF NOT EXISTS clusterflux_project_permissions ( + tenant_id TEXT NOT NULL REFERENCES clusterflux_tenants(tenant_id) ON DELETE CASCADE, + project_id TEXT NOT NULL REFERENCES clusterflux_projects(project_id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES clusterflux_users(user_id) ON DELETE CASCADE, record JSONB NOT NULL, PRIMARY KEY (tenant_id, project_id, user_id) ); @@ -405,7 +405,7 @@ CREATE TABLE IF NOT EXISTS disasmer_project_permissions ( #[cfg(test)] mod tests { - use disasmer_core::{ + use clusterflux_core::{ CredentialKind, Digest, NodeId, ProjectId, SourceProviderKind, TenantId, UserId, }; @@ -420,16 +420,16 @@ mod tests { .collect::>(); assert_eq!(names.len(), 10); - assert!(names.contains(&"disasmer_tenants")); - assert!(names.contains(&"disasmer_users")); - assert!(names.contains(&"disasmer_projects")); - assert!(names.contains(&"disasmer_node_identities")); - assert!(names.contains(&"disasmer_credentials")); - assert!(names.contains(&"disasmer_cli_sessions")); - assert!(names.contains(&"disasmer_agent_public_keys")); - assert!(names.contains(&"disasmer_source_provider_configs")); - assert!(names.contains(&"disasmer_service_policy_records")); - assert!(names.contains(&"disasmer_project_permissions")); + assert!(names.contains(&"clusterflux_tenants")); + assert!(names.contains(&"clusterflux_users")); + assert!(names.contains(&"clusterflux_projects")); + assert!(names.contains(&"clusterflux_node_identities")); + assert!(names.contains(&"clusterflux_credentials")); + assert!(names.contains(&"clusterflux_cli_sessions")); + assert!(names.contains(&"clusterflux_agent_public_keys")); + assert!(names.contains(&"clusterflux_source_provider_configs")); + assert!(names.contains(&"clusterflux_service_policy_records")); + assert!(names.contains(&"clusterflux_project_permissions")); assert!(PostgresDurableStore::durable_tables() .iter() .all(|table| table.restart_surviving)); @@ -494,7 +494,7 @@ mod tests { first.start_process( TenantId::from("tenant"), ProjectId::from("project"), - disasmer_core::ProcessId::from("process"), + clusterflux_core::ProcessId::from("process"), ); first.try_persist(&mut store).unwrap(); @@ -507,7 +507,7 @@ mod tests { #[test] fn postgres_round_trip_runs_when_dsn_is_configured() { - let Ok(dsn) = std::env::var("DISASMER_TEST_POSTGRES") else { + let Ok(dsn) = std::env::var("CLUSTERFLUX_TEST_POSTGRES") else { return; }; diff --git a/crates/disasmer-coordinator/src/service.rs b/crates/clusterflux-coordinator/src/service.rs similarity index 82% rename from crates/disasmer-coordinator/src/service.rs rename to crates/clusterflux-coordinator/src/service.rs index 6177d6e..e2797f5 100644 --- a/crates/disasmer-coordinator/src/service.rs +++ b/crates/clusterflux-coordinator/src/service.rs @@ -6,7 +6,7 @@ use std::collections::{BTreeMap, BTreeSet, VecDeque}; use std::time::{SystemTime, UNIX_EPOCH}; -use disasmer_core::{ +use clusterflux_core::{ Actor, AgentId, ArtifactRegistry, CapabilityReportError, CredentialKind, Digest, LimitError, NativeQuicTransport, NodeDescriptor, NodeId, PanelState, Placement, ProcessId, ProjectId, RateLimit, TenantId, TransportError, UserId, @@ -31,6 +31,7 @@ mod process_launch; mod processes; mod protocol; mod quota; +mod relay; mod routing; mod signed_nodes; mod tcp; @@ -45,10 +46,12 @@ pub use protocol::{ ArtifactTransferAssignment, AuthenticatedCoordinatorRequest, CoordinatorRequest, CoordinatorResponse, DebugAcknowledgementState, DebugAuditEvent, DebugParticipantAcknowledgement, SourcePreparationDisposition, SourcePreparationStatus, - TaskAssignment, TaskCancellationTarget, TaskCompletionEvent, TaskExecutor, - TaskReplacementBundle, TaskTerminalState, VirtualProcessStatus, WorkflowActor, + TaskAssignment, TaskAttemptSnapshot, TaskAttemptState, TaskCancellationTarget, + TaskCompletionEvent, TaskExecutor, TaskFailureResolution, TaskReplacementBundle, + TaskTerminalState, VirtualProcessStatus, WorkflowActor, }; pub use quota::CoordinatorQuotaConfiguration; +pub use relay::{ArtifactRelayConfiguration, ArtifactRelayError, ArtifactRelayUsage}; pub use tcp::{bind_listener, ClientAuthorityMode}; pub use wire_protocol::CoordinatorWireRequest; @@ -61,12 +64,36 @@ const MAX_ENROLLMENT_GRANTS_PER_PROJECT: usize = 64; const MAX_TASK_EVENTS_PER_PROCESS: usize = 128; const MAX_DEBUG_AUDIT_EVENTS_PER_PROCESS: usize = 256; const MAX_RESTART_CHECKPOINTS_PER_PROCESS: usize = 128; +const MAX_TASK_EVENTS_TOTAL: usize = 8_192; +const MAX_DEBUG_AUDIT_EVENTS_TOTAL: usize = 8_192; +const MAX_RESTART_CHECKPOINTS_TOTAL: usize = 4_096; +const MAX_TASK_ATTEMPT_HISTORIES: usize = 4_096; const MAX_IN_FLIGHT_TASKS_PER_PROCESS: usize = 256; const MAX_NODE_REPORTED_OBJECTS_PER_KIND: usize = 1_024; +const DEFAULT_NODE_STALE_AFTER_SECONDS: u64 = 30; fn bounded_ttl(requested: u64, maximum: u64) -> u64 { requested.clamp(1, maximum) } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CoordinatorMainRuntimeConfiguration { + pub fuel_units_per_second: u64, + pub fuel_burst_seconds: u64, + pub memory_bytes: usize, + pub nested_join_timeout_ms: u64, +} + +impl Default for CoordinatorMainRuntimeConfiguration { + fn default() -> Self { + Self { + fuel_units_per_second: 10_000_000, + fuel_burst_seconds: 60, + memory_bytes: 256 * 1024 * 1024, + nested_join_timeout_ms: 24 * 60 * 60 * 1_000, + } + } +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct CoordinatorAdmission { pub workflow_placement_allowed: bool, @@ -95,15 +122,15 @@ pub enum CoordinatorServiceError { #[error("coordinator request failed: {0}")] Coordinator(#[from] CoordinatorError), #[error("artifact download request failed: {0}")] - Download(#[from] disasmer_core::DownloadError), + Download(#[from] clusterflux_core::DownloadError), #[error("scheduler placement failed: {0}")] - Scheduler(#[from] disasmer_core::PlacementError), + Scheduler(#[from] clusterflux_core::PlacementError), #[error("transport request failed: {0}")] Transport(#[from] TransportError), #[error("resource limit failed: {0}")] Resource(#[from] LimitError), #[error("operator panel request failed: {0}")] - Panel(#[from] disasmer_core::PanelError), + Panel(#[from] clusterflux_core::PanelError), #[error("invalid node capability report: {0}")] CapabilityReport(#[from] CapabilityReportError), #[error("invalid VFS artifact path reported by node: {0}")] @@ -118,8 +145,12 @@ pub struct CoordinatorService { coordinator: Coordinator, store: RuntimeDurableStore, node_descriptors: BTreeMap, - enrollment_grants: BTreeMap, + node_last_seen_epoch_seconds: BTreeMap, + node_stale_after_seconds: u64, + debug_freeze_timeout: std::time::Duration, + enrollment_grants: BTreeMap, task_events: VecDeque, + process_scope_history: VecDeque, debug_audit_events: VecDeque, debug_epochs: BTreeMap, debug_epoch_runtime: BTreeMap, @@ -128,6 +159,8 @@ pub struct CoordinatorService { task_assignments: BTreeMap>, task_restart_checkpoints: BTreeMap, task_restart_checkpoint_order: VecDeque, + task_attempts: BTreeMap>, + restart_launches: BTreeSet, main_runtime: main_runtime::CoordinatorMainRuntime, pending_task_launches: VecDeque, task_placements: BTreeMap, @@ -144,6 +177,7 @@ pub struct CoordinatorService { artifact_registry: ArtifactRegistry, artifact_reverse_transfers: BTreeMap, artifact_transfer_by_token: BTreeMap, + artifact_relay: relay::ArtifactRelayLedger, transport: NativeQuicTransport, quota: quota::CoordinatorQuota, admission: CoordinatorAdmission, @@ -154,6 +188,27 @@ pub struct CoordinatorService { } impl CoordinatorService { + pub fn configure_artifact_relay( + &mut self, + configuration: ArtifactRelayConfiguration, + ) -> Result<(), ArtifactRelayError> { + self.artifact_relay.configure(configuration) + } + + pub fn artifact_relay_usage(&self) -> ArtifactRelayUsage { + self.artifact_relay.usage() + } + + pub fn set_debug_freeze_timeout(&mut self, timeout: std::time::Duration) { + self.debug_freeze_timeout = timeout.max(std::time::Duration::from_millis(1)); + } + + pub fn configure_coordinator_main_runtime( + &mut self, + configuration: CoordinatorMainRuntimeConfiguration, + ) -> Result<(), CoordinatorServiceError> { + self.main_runtime.configure(configuration) + } pub(super) fn authorize_node_for_process_or_termination( &self, node: &NodeId, @@ -186,7 +241,7 @@ impl CoordinatorService { pub fn new(coordinator_epoch: u64) -> Self { Self::new_with_optional_admin_token_and_admission( coordinator_epoch, - std::env::var("DISASMER_ADMIN_TOKEN").ok(), + std::env::var("CLUSTERFLUX_ADMIN_TOKEN").ok(), CoordinatorAdmission::default(), ) } @@ -202,7 +257,7 @@ impl CoordinatorService { pub fn new_with_admission(coordinator_epoch: u64, admission: CoordinatorAdmission) -> Self { Self::new_with_optional_admin_token_and_admission( coordinator_epoch, - std::env::var("DISASMER_ADMIN_TOKEN").ok(), + std::env::var("CLUSTERFLUX_ADMIN_TOKEN").ok(), admission, ) } @@ -228,7 +283,7 @@ impl CoordinatorService { ) -> Result { Self::try_new_with_optional_admin_token_admission_and_database_url( coordinator_epoch, - std::env::var("DISASMER_ADMIN_TOKEN").ok(), + std::env::var("CLUSTERFLUX_ADMIN_TOKEN").ok(), CoordinatorAdmission::default(), database_url, CoordinatorQuotaConfiguration::default(), @@ -281,8 +336,16 @@ impl CoordinatorService { coordinator, store, node_descriptors: BTreeMap::new(), + node_last_seen_epoch_seconds: BTreeMap::new(), + node_stale_after_seconds: std::env::var("CLUSTERFLUX_NODE_STALE_AFTER_SECONDS") + .ok() + .and_then(|value| value.parse::().ok()) + .filter(|seconds| *seconds > 0) + .unwrap_or(DEFAULT_NODE_STALE_AFTER_SECONDS), + debug_freeze_timeout: std::time::Duration::from_secs(5), enrollment_grants: BTreeMap::new(), task_events: VecDeque::new(), + process_scope_history: VecDeque::new(), debug_audit_events: VecDeque::new(), debug_epochs: BTreeMap::new(), debug_epoch_runtime: BTreeMap::new(), @@ -291,6 +354,8 @@ impl CoordinatorService { task_assignments: BTreeMap::new(), task_restart_checkpoints: BTreeMap::new(), task_restart_checkpoint_order: VecDeque::new(), + task_attempts: BTreeMap::new(), + restart_launches: BTreeSet::new(), main_runtime: main_runtime::CoordinatorMainRuntime::default(), pending_task_launches: VecDeque::new(), task_placements: BTreeMap::new(), @@ -307,6 +372,7 @@ impl CoordinatorService { artifact_registry: ArtifactRegistry::default(), artifact_reverse_transfers: BTreeMap::new(), artifact_transfer_by_token: BTreeMap::new(), + artifact_relay: relay::ArtifactRelayLedger::default(), transport: NativeQuicTransport, quota: quota::CoordinatorQuota::new(quota_configuration), admission, diff --git a/crates/disasmer-coordinator/src/service/admin.rs b/crates/clusterflux-coordinator/src/service/admin.rs similarity index 99% rename from crates/disasmer-coordinator/src/service/admin.rs rename to crates/clusterflux-coordinator/src/service/admin.rs index 8e9866b..fa220a6 100644 --- a/crates/disasmer-coordinator/src/service/admin.rs +++ b/crates/clusterflux-coordinator/src/service/admin.rs @@ -1,6 +1,6 @@ use std::time::{SystemTime, UNIX_EPOCH}; -use disasmer_core::{ +use clusterflux_core::{ admin_request_proof_from_token_digest, CredentialKind, Digest, TenantId, UserId, }; diff --git a/crates/disasmer-coordinator/src/service/artifacts.rs b/crates/clusterflux-coordinator/src/service/artifacts.rs similarity index 77% rename from crates/disasmer-coordinator/src/service/artifacts.rs rename to crates/clusterflux-coordinator/src/service/artifacts.rs index 38430b5..8425808 100644 --- a/crates/disasmer-coordinator/src/service/artifacts.rs +++ b/crates/clusterflux-coordinator/src/service/artifacts.rs @@ -1,7 +1,7 @@ use std::io::{Read, Seek, SeekFrom, Write}; use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; -use disasmer_core::{ +use clusterflux_core::{ generate_opaque_token, Actor, ArtifactId, AuthContext, DataPlaneObject, DataPlaneScope, Digest, DownloadPolicy, NodeEndpoint, NodeId, ProjectId, RendezvousRequest, ResourceLimits, ResourceMeter, StorageLocation, TenantId, UserId, @@ -10,13 +10,13 @@ use sha2::{Digest as _, Sha256}; use crate::CoordinatorError; +use super::relay::RelayFinishReason; use super::{ bounded_ttl, ArtifactTransferAssignment, CoordinatorResponse, CoordinatorService, CoordinatorServiceError, }; pub(super) const MAX_ARTIFACT_REVERSE_CHUNK_BYTES: u64 = 256 * 1024; -const MAX_CONCURRENT_ARTIFACT_REVERSE_TRANSFERS: usize = 4; #[derive(Debug)] pub(super) struct ArtifactReverseTransfer { @@ -57,6 +57,7 @@ impl CoordinatorService { .artifact_registry .downloadable_size(&context, &artifact, &policy)?; let now_epoch_seconds = self.current_epoch_seconds()?; + self.artifact_relay.expire(now_epoch_seconds); self.quota.can_charge_download( &context.tenant, &context.project, @@ -69,14 +70,45 @@ impl CoordinatorService { ttl_seconds, self.admission.max_artifact_download_ttl_seconds, ); - let link = self.artifact_registry.create_download_link( + let expires_at_epoch_seconds = now_epoch_seconds.saturating_add(ttl_seconds); + let account = match &context.actor { + Actor::User(user) => user.clone(), + Actor::Agent(_) | Actor::Node(_) | Actor::Task(_) => { + return Err(CoordinatorServiceError::Protocol( + "artifact relay download requires a user account".to_owned(), + )) + } + }; + self.artifact_relay + .reserve( + token_nonce.clone(), + context.tenant.clone(), + context.project.clone(), + account, + downloadable_size, + MAX_ARTIFACT_REVERSE_CHUNK_BYTES, + expires_at_epoch_seconds, + now_epoch_seconds, + ) + .map_err(|error| CoordinatorServiceError::Protocol(error.to_string()))?; + let link = match self.artifact_registry.create_download_link( &context, &artifact, &policy, &token_nonce, now_epoch_seconds, ttl_seconds, - )?; + ) { + Ok(link) => link, + Err(error) => { + self.artifact_relay + .finish(&token_nonce, RelayFinishReason::Cancelled); + return Err(error.into()); + } + }; + self.artifact_relay + .rekey(&token_nonce, link.scoped_token_digest.as_str().to_owned()) + .map_err(|error| CoordinatorServiceError::Protocol(error.to_string()))?; Ok(CoordinatorResponse::ArtifactDownloadLink { link }) } @@ -94,6 +126,7 @@ impl CoordinatorService { let artifact = ArtifactId::new(artifact); let policy = DownloadPolicy { max_bytes }; let now_epoch_seconds = self.current_epoch_seconds()?; + self.artifact_relay.expire(now_epoch_seconds); self.artifact_registry .expire_download_links(now_epoch_seconds); let downloadable_size = self @@ -102,7 +135,7 @@ impl CoordinatorService { let validation_limits = ResourceLimits::unlimited(); let mut validation_meter = ResourceMeter::default(); let mut stream = self.artifact_registry.open_download_stream( - disasmer_core::DownloadStreamRequest { + clusterflux_core::DownloadStreamRequest { context: &context, artifact: &artifact, policy: &policy, @@ -123,6 +156,8 @@ impl CoordinatorService { { self.artifact_reverse_transfers.remove(&transfer_id); self.artifact_transfer_by_token.remove(&token_digest); + self.artifact_relay + .finish(token_digest.as_str(), RelayFinishReason::Failed); return Err(CoordinatorServiceError::Protocol(format!( "retaining node could not stream artifact: {message}" ))); @@ -141,9 +176,16 @@ impl CoordinatorService { || transfer.artifact != artifact || transfer.token_digest != token_digest { - return Err(disasmer_core::DownloadError::InvalidToken.into()); + return Err(clusterflux_core::DownloadError::InvalidToken.into()); } if transfer.received_bytes != transfer.expected_size_bytes { + self.artifact_relay + .charge_egress( + token_digest.as_str(), + self.artifact_relay.framing_overhead_bytes(), + now_epoch_seconds, + ) + .map_err(|error| CoordinatorServiceError::Protocol(error.to_string()))?; return Ok(CoordinatorResponse::ArtifactDownloadStream { link: stream.link, streamed_bytes: 0, @@ -205,12 +247,23 @@ impl CoordinatorService { streamed_bytes, now_epoch_seconds, )?; + let content_base64 = BASE64_STANDARD.encode(content); + self.artifact_relay + .charge_egress( + token_digest.as_str(), + (content_base64.len() as u64) + .saturating_add(self.artifact_relay.framing_overhead_bytes()), + now_epoch_seconds, + ) + .map_err(|error| CoordinatorServiceError::Protocol(error.to_string()))?; if let Some(transfer) = self.artifact_reverse_transfers.get_mut(&transfer_id) { transfer.delivered_offset = end; } if complete { self.artifact_reverse_transfers.remove(&transfer_id); self.artifact_transfer_by_token.remove(&token_digest); + self.artifact_relay + .finish(token_digest.as_str(), RelayFinishReason::Completed); } return Ok(CoordinatorResponse::ArtifactDownloadStream { link: stream.link, @@ -219,27 +272,22 @@ impl CoordinatorService { content_bytes_available: true, content_offset: Some(content_offset), content_eof: complete, - content_base64: Some(BASE64_STANDARD.encode(content)), + content_base64: Some(content_base64), content_source: Some("retaining_node_reverse_stream".to_owned()), }); } - if self.artifact_reverse_transfers.len() >= MAX_CONCURRENT_ARTIFACT_REVERSE_TRANSFERS { - return Err(CoordinatorServiceError::Protocol(format!( - "artifact reverse transfer concurrency limit of {MAX_CONCURRENT_ARTIFACT_REVERSE_TRANSFERS} reached" - ))); - } let StorageLocation::RetainedNode(source_node) = &stream.link.source else { - return Err(disasmer_core::DownloadError::Unavailable.into()); + return Err(clusterflux_core::DownloadError::Unavailable.into()); }; let metadata = self .artifact_registry .metadata(&artifact) - .ok_or(disasmer_core::DownloadError::NotFound)?; + .ok_or(clusterflux_core::DownloadError::NotFound)?; let transfer_id = generate_opaque_token("artifact_transfer") .map_err(CoordinatorServiceError::Protocol)?; let spool = tempfile::Builder::new() - .prefix("disasmer-artifact-transfer-") + .prefix("clusterflux-artifact-transfer-") .tempfile() .map_err(|error| { CoordinatorServiceError::Protocol(format!( @@ -266,6 +314,13 @@ impl CoordinatorService { .insert(transfer_id.clone(), transfer); self.artifact_transfer_by_token .insert(token_digest, transfer_id); + self.artifact_relay + .charge_egress( + stream.link.scoped_token_digest.as_str(), + self.artifact_relay.framing_overhead_bytes(), + now_epoch_seconds, + ) + .map_err(|error| CoordinatorServiceError::Protocol(error.to_string()))?; Ok(CoordinatorResponse::ArtifactDownloadStream { link: stream.link, streamed_bytes: 0, @@ -302,6 +357,8 @@ impl CoordinatorService { if let Some(transfer_id) = self.artifact_transfer_by_token.remove(&token_digest) { self.artifact_reverse_transfers.remove(&transfer_id); } + self.artifact_relay + .finish(token_digest.as_str(), RelayFinishReason::Cancelled); Ok(CoordinatorResponse::ArtifactDownloadLinkRevoked { link }) } @@ -326,12 +383,12 @@ impl CoordinatorService { }, )?; let StorageLocation::RetainedNode(source_node) = action.source else { - return Err(disasmer_core::DownloadError::Unavailable.into()); + return Err(clusterflux_core::DownloadError::Unavailable.into()); }; let metadata = self .artifact_registry .metadata(&artifact) - .ok_or(disasmer_core::DownloadError::NotFound)?; + .ok_or(clusterflux_core::DownloadError::NotFound)?; let source = self.export_endpoint(&source_node, &context.tenant, &context.project)?; let destination = self.export_endpoint(&receiver_node, &context.tenant, &context.project)?; @@ -407,6 +464,33 @@ impl CoordinatorService { let project = ProjectId::new(project); let node = NodeId::new(node); self.authorize_artifact_transfer_node(&tenant, &project, &node)?; + let token_digest = { + let transfer = self + .artifact_reverse_transfers + .get(&transfer_id) + .ok_or_else(|| { + CoordinatorServiceError::Protocol( + "unknown artifact reverse transfer".to_owned(), + ) + })?; + if transfer.tenant != tenant + || transfer.project != project + || transfer.source_node != node + || transfer.artifact.as_str() != artifact + { + return Err(CoordinatorError::Unauthorized( + "artifact reverse transfer is outside the signed node scope".to_owned(), + ) + .into()); + } + transfer.token_digest.clone() + }; + let now_epoch_seconds = self.current_epoch_seconds()?; + let ingress_wire_bytes = (content_base64.len() as u64) + .saturating_add(self.artifact_relay.framing_overhead_bytes()); + self.artifact_relay + .charge_ingress(token_digest.as_str(), ingress_wire_bytes, now_epoch_seconds) + .map_err(|error| CoordinatorServiceError::Protocol(error.to_string()))?; let transfer = self .artifact_reverse_transfers .get_mut(&transfer_id) @@ -511,6 +595,35 @@ impl CoordinatorService { let project = ProjectId::new(project); let node = NodeId::new(node); self.authorize_artifact_transfer_node(&tenant, &project, &node)?; + let token_digest = { + let transfer = self + .artifact_reverse_transfers + .get(&transfer_id) + .ok_or_else(|| { + CoordinatorServiceError::Protocol( + "unknown artifact reverse transfer".to_owned(), + ) + })?; + if transfer.tenant != tenant + || transfer.project != project + || transfer.source_node != node + || transfer.artifact.as_str() != artifact + { + return Err(CoordinatorError::Unauthorized( + "artifact reverse transfer failure is outside the signed node scope".to_owned(), + ) + .into()); + } + transfer.token_digest.clone() + }; + let now_epoch_seconds = self.current_epoch_seconds()?; + self.artifact_relay + .charge_ingress( + token_digest.as_str(), + (message.len() as u64).saturating_add(self.artifact_relay.framing_overhead_bytes()), + now_epoch_seconds, + ) + .map_err(|error| CoordinatorServiceError::Protocol(error.to_string()))?; let transfer = self .artifact_reverse_transfers .get_mut(&transfer_id) @@ -533,6 +646,8 @@ impl CoordinatorService { } else { message.chars().take(1024).collect() }); + self.artifact_relay + .finish(token_digest.as_str(), RelayFinishReason::Failed); Ok(CoordinatorResponse::ArtifactTransferFailed { transfer_id }) } @@ -566,7 +681,10 @@ impl CoordinatorService { for (id, token) in expired { self.artifact_reverse_transfers.remove(&id); self.artifact_transfer_by_token.remove(&token); + self.artifact_relay + .finish(token.as_str(), RelayFinishReason::Expired); } + self.artifact_relay.expire(now_epoch_seconds); } fn export_endpoint( @@ -586,7 +704,7 @@ impl CoordinatorService { .into()); } let descriptor = self.node_descriptors.get(node).ok_or_else(|| { - disasmer_core::DownloadError::DirectConnectivityUnavailable(format!( + clusterflux_core::DownloadError::DirectConnectivityUnavailable(format!( "node {node} has not reported export connectivity" )) })?; @@ -596,9 +714,9 @@ impl CoordinatorService { ) .into()); } - if !descriptor.online { + if !self.node_is_live(node) { return Err( - disasmer_core::DownloadError::DirectConnectivityUnavailable(format!( + clusterflux_core::DownloadError::DirectConnectivityUnavailable(format!( "node {node} is offline for artifact export" )) .into(), @@ -606,7 +724,7 @@ impl CoordinatorService { } if !descriptor.direct_connectivity { return Err( - disasmer_core::DownloadError::DirectConnectivityUnavailable(format!( + clusterflux_core::DownloadError::DirectConnectivityUnavailable(format!( "direct connectivity unavailable to node {node} for artifact export" )) .into(), @@ -622,19 +740,21 @@ impl CoordinatorService { fn ensure_download_source_connectivity( &self, source: &StorageLocation, - ) -> Result<(), disasmer_core::DownloadError> { + ) -> Result<(), clusterflux_core::DownloadError> { let StorageLocation::RetainedNode(node) = source else { return Ok(()); }; - let descriptor = self.node_descriptors.get(node).ok_or_else(|| { - disasmer_core::DownloadError::DirectConnectivityUnavailable(format!( + let _descriptor = self.node_descriptors.get(node).ok_or_else(|| { + clusterflux_core::DownloadError::DirectConnectivityUnavailable(format!( "retaining node {node} has not reported online status for artifact download" )) })?; - if !descriptor.online { - return Err(disasmer_core::DownloadError::DirectConnectivityUnavailable( - format!("retaining node {node} is offline for artifact download"), - )); + if !self.node_is_live(node) { + return Err( + clusterflux_core::DownloadError::DirectConnectivityUnavailable(format!( + "retaining node {node} is offline for artifact download" + )), + ); } Ok(()) } diff --git a/crates/disasmer-coordinator/src/service/authenticated.rs b/crates/clusterflux-coordinator/src/service/authenticated.rs similarity index 99% rename from crates/disasmer-coordinator/src/service/authenticated.rs rename to crates/clusterflux-coordinator/src/service/authenticated.rs index 389ba2c..f8e21bb 100644 --- a/crates/disasmer-coordinator/src/service/authenticated.rs +++ b/crates/clusterflux-coordinator/src/service/authenticated.rs @@ -1,4 +1,4 @@ -use disasmer_core::{AuthContext, UserId}; +use clusterflux_core::{AuthContext, UserId}; use super::{ AuthenticatedCoordinatorRequest, CoordinatorRequest, CoordinatorResponse, CoordinatorService, diff --git a/crates/disasmer-coordinator/src/service/authorization.rs b/crates/clusterflux-coordinator/src/service/authorization.rs similarity index 94% rename from crates/disasmer-coordinator/src/service/authorization.rs rename to crates/clusterflux-coordinator/src/service/authorization.rs index c1c9dfc..92c1e6d 100644 --- a/crates/disasmer-coordinator/src/service/authorization.rs +++ b/crates/clusterflux-coordinator/src/service/authorization.rs @@ -1,4 +1,4 @@ -use disasmer_core::{Actor, AuthContext, UserId}; +use clusterflux_core::{Actor, AuthContext, UserId}; use crate::CoordinatorError; @@ -26,6 +26,7 @@ pub(super) enum PublicUserOperation { ListProcesses, QuotaStatus, RestartTask, + ResolveTaskFailure, DebugAttach, SetDebugBreakpoints, InspectDebugBreakpoints, @@ -33,6 +34,7 @@ pub(super) enum PublicUserOperation { ResumeDebugEpoch, InspectDebugEpoch, ListTaskEvents, + ListTaskSnapshots, JoinTask, CreateArtifactDownloadLink, OpenArtifactDownloadStream, @@ -63,6 +65,7 @@ impl PublicUserOperation { Self::ListProcesses => "list_processes", Self::QuotaStatus => "quota_status", Self::RestartTask => "restart_task", + Self::ResolveTaskFailure => "resolve_task_failure", Self::DebugAttach => "debug_attach", Self::SetDebugBreakpoints => "set_debug_breakpoints", Self::InspectDebugBreakpoints => "inspect_debug_breakpoints", @@ -70,6 +73,7 @@ impl PublicUserOperation { Self::ResumeDebugEpoch => "resume_debug_epoch", Self::InspectDebugEpoch => "inspect_debug_epoch", Self::ListTaskEvents => "list_task_events", + Self::ListTaskSnapshots => "list_task_snapshots", Self::JoinTask => "join_task", Self::CreateArtifactDownloadLink => "create_artifact_download_link", Self::OpenArtifactDownloadStream => "open_artifact_download_stream", @@ -112,6 +116,7 @@ impl From<&AuthenticatedCoordinatorRequest> for PublicUserOperation { AuthenticatedCoordinatorRequest::ListProcesses => Self::ListProcesses, AuthenticatedCoordinatorRequest::QuotaStatus => Self::QuotaStatus, AuthenticatedCoordinatorRequest::RestartTask { .. } => Self::RestartTask, + AuthenticatedCoordinatorRequest::ResolveTaskFailure { .. } => Self::ResolveTaskFailure, AuthenticatedCoordinatorRequest::DebugAttach { .. } => Self::DebugAttach, AuthenticatedCoordinatorRequest::SetDebugBreakpoints { .. } => { Self::SetDebugBreakpoints @@ -123,6 +128,7 @@ impl From<&AuthenticatedCoordinatorRequest> for PublicUserOperation { AuthenticatedCoordinatorRequest::ResumeDebugEpoch { .. } => Self::ResumeDebugEpoch, AuthenticatedCoordinatorRequest::InspectDebugEpoch { .. } => Self::InspectDebugEpoch, AuthenticatedCoordinatorRequest::ListTaskEvents { .. } => Self::ListTaskEvents, + AuthenticatedCoordinatorRequest::ListTaskSnapshots { .. } => Self::ListTaskSnapshots, AuthenticatedCoordinatorRequest::JoinTask { .. } => Self::JoinTask, AuthenticatedCoordinatorRequest::CreateArtifactDownloadLink { .. } => { Self::CreateArtifactDownloadLink @@ -166,7 +172,7 @@ pub(super) fn authorize_authenticated_user_operation( #[cfg(test)] mod tests { - use disasmer_core::{AgentId, ProjectId, TenantId}; + use clusterflux_core::{AgentId, ProjectId, TenantId}; use super::*; diff --git a/crates/disasmer-coordinator/src/service/debug.rs b/crates/clusterflux-coordinator/src/service/debug.rs similarity index 89% rename from crates/disasmer-coordinator/src/service/debug.rs rename to crates/clusterflux-coordinator/src/service/debug.rs index 89a8d9b..efa1406 100644 --- a/crates/disasmer-coordinator/src/service/debug.rs +++ b/crates/clusterflux-coordinator/src/service/debug.rs @@ -1,6 +1,7 @@ use std::collections::{BTreeMap, BTreeSet}; +use std::time::Instant; -use disasmer_core::{ +use clusterflux_core::{ Actor, NodeId, ProcessId, ProjectId, TaskInstanceId, TenantId, UserId, WasmHostDebugProbeResult, WASM_TASK_ABI_VERSION, }; @@ -28,6 +29,7 @@ pub(super) struct DebugEpochRuntime { pub(super) command: String, pub(super) expected: BTreeSet, pub(super) acknowledgements: BTreeMap, + pub(super) deadline: Instant, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -51,7 +53,7 @@ impl CoordinatorService { let project = ProjectId::new(project); let actor = UserId::new(actor_user); let process = ProcessId::new(process); - let context = disasmer_core::AuthContext { + let context = clusterflux_core::AuthContext { tenant: tenant.clone(), project: project.clone(), actor: Actor::User(actor.clone()), @@ -90,7 +92,7 @@ impl CoordinatorService { let project = ProjectId::new(project); let actor = UserId::new(actor_user); let process = ProcessId::new(process); - let context = disasmer_core::AuthContext { + let context = clusterflux_core::AuthContext { tenant: tenant.clone(), project: project.clone(), actor: Actor::User(actor.clone()), @@ -147,7 +149,7 @@ impl CoordinatorService { let project = ProjectId::new(project); let actor = UserId::new(actor_user); let process = ProcessId::new(process); - let context = disasmer_core::AuthContext { + let context = clusterflux_core::AuthContext { tenant: tenant.clone(), project: project.clone(), actor: Actor::User(actor.clone()), @@ -405,6 +407,14 @@ impl CoordinatorService { ) .into()); } + let attempt_key = task_restart_key(&tenant_id, &project_id, &process_id, &task_id); + if let Some(attempt) = self + .task_attempts + .get_mut(&attempt_key) + .and_then(|attempts| attempts.iter_mut().rev().find(|attempt| attempt.current)) + { + attempt.probe_symbol = Some(probe_symbol.clone()); + } let process_key = process_control_key(&tenant_id, &project_id, &process_id); let Some(plan) = self.debug_breakpoints.get(&process_key).cloned() else { return Ok(CoordinatorResponse::DebugProbeHit { @@ -536,7 +546,7 @@ impl CoordinatorService { let project = ProjectId::new(project); let actor = UserId::new(actor_user); let process = ProcessId::new(process); - let context = disasmer_core::AuthContext { + let context = clusterflux_core::AuthContext { tenant: tenant.clone(), project: project.clone(), actor: Actor::User(actor.clone()), @@ -586,15 +596,32 @@ impl CoordinatorService { && acknowledgements .iter() .all(|ack| ack.state == DebugAcknowledgementState::Frozen); + let frozen_count = acknowledgements + .iter() + .filter(|ack| ack.state == DebugAcknowledgementState::Frozen) + .count(); + let freeze_deadline_elapsed = + runtime.command == "freeze" && Instant::now() >= runtime.deadline; + let partially_frozen = runtime.command == "freeze" + && freeze_deadline_elapsed + && frozen_count > 0 + && !fully_frozen; let fully_resumed = runtime.command == "resume" && all_acknowledged && acknowledgements .iter() .all(|ack| ack.state == DebugAcknowledgementState::Running); + let missing = runtime + .expected + .iter() + .filter(|key| !runtime.acknowledgements.contains_key(*key)) + .cloned() + .collect::>(); let failed = acknowledgements .iter() - .any(|ack| ack.state == DebugAcknowledgementState::Failed); - let failure_messages = acknowledgements + .any(|ack| ack.state == DebugAcknowledgementState::Failed) + || (freeze_deadline_elapsed && !missing.is_empty()); + let mut failure_messages = acknowledgements .iter() .filter(|ack| ack.state == DebugAcknowledgementState::Failed) .map(|ack| { @@ -602,7 +629,15 @@ impl CoordinatorService { .clone() .unwrap_or_else(|| format!("task {} failed debug control", ack.task)) }) - .collect(); + .collect::>(); + if freeze_deadline_elapsed { + failure_messages.extend(missing.iter().map(|(_, _, _, node, task)| { + format!( + "task {task} on node {node} did not acknowledge frozen state within {} ms", + self.debug_freeze_timeout.as_millis() + ) + })); + } let expected_tasks = runtime .expected .into_iter() @@ -620,6 +655,7 @@ impl CoordinatorService { expected_tasks, acknowledgements, fully_frozen, + partially_frozen, fully_resumed, failed, failure_messages, @@ -664,7 +700,7 @@ impl CoordinatorService { let project = ProjectId::new(project); let actor = UserId::new(actor_user); let process = ProcessId::new(process); - let context = disasmer_core::AuthContext { + let context = clusterflux_core::AuthContext { tenant: tenant.clone(), project: project.clone(), actor: Actor::User(actor.clone()), @@ -715,11 +751,18 @@ impl CoordinatorService { "cannot resume debug epoch {expected} for {process}: participant state is unavailable" )) })?; - if runtime.command != "freeze" - || !runtime_all_in_state(runtime, DebugAcknowledgementState::Frozen) - { + let has_frozen = runtime + .acknowledgements + .values() + .any(|ack| ack.state == DebugAcknowledgementState::Frozen); + let settled = runtime + .expected + .iter() + .all(|key| runtime.acknowledgements.contains_key(key)) + || Instant::now() >= runtime.deadline; + if runtime.command != "freeze" || !has_frozen || !settled { return Err(CoordinatorServiceError::Protocol(format!( - "cannot resume debug epoch {expected} for {process}: all expected participants have not acknowledged frozen state" + "cannot resume debug epoch {expected} for {process}: no settled frozen participant set is available" ))); } current @@ -740,6 +783,21 @@ impl CoordinatorService { next } }; + let resumable = if command == "resume" { + self.debug_epoch_runtime + .get(&process_key) + .map(|runtime| { + runtime + .acknowledgements + .iter() + .filter(|(_, ack)| ack.state == DebugAcknowledgementState::Frozen) + .map(|(key, _)| key.clone()) + .collect::>() + }) + .unwrap_or_default() + } else { + BTreeSet::new() + }; let mut affected_tasks = self .enqueue_debug_command_for_active_tasks(&tenant, &project, &process, epoch, command); let mut expected = self @@ -770,6 +828,17 @@ impl CoordinatorService { task: control.task_instance.clone(), }); } + if command == "resume" { + expected.retain(|key| resumable.contains(key)); + affected_tasks.retain(|target| { + resumable + .iter() + .any(|(_, _, _, node, task)| node == &target.node && task == &target.task) + }); + self.debug_commands.retain(|key, pending| { + pending.epoch != epoch || pending.command != "resume" || resumable.contains(key) + }); + } self.debug_epoch_runtime.insert( process_key.clone(), DebugEpochRuntime { @@ -777,6 +846,7 @@ impl CoordinatorService { command: command.to_owned(), expected, acknowledgements: BTreeMap::new(), + deadline: Instant::now() + self.debug_freeze_timeout, }, ); if let Some(control) = self @@ -787,7 +857,13 @@ impl CoordinatorService { { if command == "freeze" { control.debug.request_freeze(epoch); - } else { + } else if resumable.contains(&task_control_key( + &tenant, + &project, + &process, + &NodeId::from("coordinator-main"), + &control.task_instance, + )) { control.debug.request_resume(epoch); } } @@ -876,14 +952,13 @@ impl CoordinatorService { task: control.task_instance.clone(), epoch, state, - stack_frames: vec![control + stack_frames: control .stopped_probe_symbol .as_deref() - .and_then(|symbol| symbol.strip_prefix("disasmer.probe.")) + .and_then(|symbol| symbol.strip_prefix("clusterflux.probe.")) .map(|function| format!("{function}::wasm")) - .unwrap_or_else(|| { - format!("coordinator_main::wasm ({})", control.task_definition) - })], + .into_iter() + .collect(), local_values: Vec::new(), task_args: Vec::new(), handles, @@ -989,6 +1064,9 @@ impl CoordinatorService { }; self.debug_audit_events.remove(index); } + while self.debug_audit_events.len() >= super::MAX_DEBUG_AUDIT_EVENTS_TOTAL { + self.debug_audit_events.pop_front(); + } self.debug_audit_events.push_back(event.clone()); Ok(event) } diff --git a/crates/disasmer-coordinator/src/service/debug/validation.rs b/crates/clusterflux-coordinator/src/service/debug/validation.rs similarity index 100% rename from crates/disasmer-coordinator/src/service/debug/validation.rs rename to crates/clusterflux-coordinator/src/service/debug/validation.rs diff --git a/crates/disasmer-coordinator/src/service/debug_requests.rs b/crates/clusterflux-coordinator/src/service/debug_requests.rs similarity index 76% rename from crates/disasmer-coordinator/src/service/debug_requests.rs rename to crates/clusterflux-coordinator/src/service/debug_requests.rs index 0db48c6..b7ac02e 100644 --- a/crates/disasmer-coordinator/src/service/debug_requests.rs +++ b/crates/clusterflux-coordinator/src/service/debug_requests.rs @@ -1,7 +1,7 @@ use std::collections::BTreeSet; use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; -use disasmer_core::{ +use clusterflux_core::{ Actor, Capability, CredentialKind, Digest, EnvironmentResource, ProcessId, ProjectId, RestartDecision, RestartPolicy, RestartRequest, TaskDispatch, TaskInstanceId, TenantId, UserId, WasmExportAbi, @@ -10,9 +10,10 @@ use disasmer_core::{ use crate::{CoordinatorError, CoordinatorServiceError}; use super::keys::task_restart_key; +use super::protocol::{TaskAttemptState, TaskFailureResolution}; use super::{ AuthenticatedCoordinatorRequest, CoordinatorRequest, CoordinatorResponse, CoordinatorService, - TaskReplacementBundle, WorkflowActor, + TaskReplacementBundle, TaskTerminalState, WorkflowActor, }; impl CoordinatorService { @@ -158,7 +159,7 @@ impl CoordinatorService { let actor = UserId::new(actor_user); let process = ProcessId::new(process); let task = TaskInstanceId::new(task); - let context = disasmer_core::AuthContext { + let context = clusterflux_core::AuthContext { tenant: tenant.clone(), project: project.clone(), actor: Actor::User(actor.clone()), @@ -211,6 +212,7 @@ impl CoordinatorService { let checkpoint = self.task_restart_checkpoints.get(&checkpoint_key).cloned(); let mut accepted = false; let mut restarted_task_instance = None; + let mut restarted_attempt_id = None; let mut clean_boundary_available = false; let mut requires_whole_process_restart = true; let message = if active_main { @@ -272,14 +274,16 @@ impl CoordinatorService { RestartDecision::RestartTask { from_vfs_epoch, .. } => { clean_boundary_available = true; let mut assignment = checkpoint.assignment; - let new_task_instance = disasmer_core::TaskInstanceId::new( - disasmer_core::generate_opaque_token("ti") - .map_err(CoordinatorServiceError::Protocol)?, + assignment.task = task.clone(); + assignment.task_spec.task_instance = task.clone(); + let next_attempt = self + .task_attempts + .get(&checkpoint_key) + .map_or(1, |attempts| attempts.len() + 1); + assignment.artifact_path = format!( + "/vfs/artifacts/{}-attempt-{next_attempt}-result.json", + task.as_str() ); - assignment.task = new_task_instance.clone(); - assignment.task_spec.task_instance = new_task_instance.clone(); - assignment.artifact_path = - format!("/vfs/artifacts/{}-result.json", new_task_instance.as_str()); if let Some(replacement) = replacement { assignment.task_spec.dispatch = TaskDispatch::CoordinatorNodeWasm { export: Some(replacement.export), @@ -296,6 +300,9 @@ impl CoordinatorService { Some(replacement.bundle_digest.clone()); assignment.wasm_module_base64 = replacement.wasm_module_base64; } + let restart_task_spec = assignment.task_spec.clone(); + let restart_artifact_path = assignment.artifact_path.clone(); + self.restart_launches.insert(checkpoint_key.clone()); let launch = self.handle_launch_task_with_actor( tenant.clone(), project.clone(), @@ -312,18 +319,34 @@ impl CoordinatorService { true, assignment.artifact_path, assignment.wasm_module_base64, - )?; + ); + self.restart_launches.remove(&checkpoint_key); + let launch = launch?; + let queued = matches!(launch, CoordinatorResponse::TaskQueued { .. }); accepted = matches!( - launch, + &launch, CoordinatorResponse::TaskLaunched { .. } | CoordinatorResponse::TaskQueued { .. } ); if accepted { - restarted_task_instance = Some(new_task_instance.clone()); + restarted_task_instance = Some(task.clone()); + restarted_attempt_id = self + .task_attempts + .get(&checkpoint_key) + .and_then(|attempts| attempts.last()) + .map(|attempt| attempt.attempt_id.clone()); + if restarted_attempt_id.is_none() { + restarted_attempt_id = Some(self.begin_task_attempt( + &restart_task_spec, + None, + Some(&restart_artifact_path), + queued, + )?); + } } requires_whole_process_restart = !accepted; format!( - "selected task restarted as new instance {new_task_instance} from clean VFS entry boundary epoch {from_vfs_epoch}; unflushed task changes were discarded" + "selected logical task {task} restarted as a new attempt from clean VFS entry boundary epoch {from_vfs_epoch}; unflushed task changes were discarded" ) } RestartDecision::RestartWholeVirtualProcess { message } => message, @@ -346,6 +369,7 @@ impl CoordinatorService { process, task, restarted_task_instance, + restarted_attempt_id, actor, accepted, clean_boundary_available, @@ -358,6 +382,87 @@ impl CoordinatorService { audit_event, }) } + + pub(super) fn handle_resolve_task_failure( + &mut self, + tenant: String, + project: String, + actor_user: String, + process: String, + task: String, + resolution: TaskFailureResolution, + ) -> Result { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let actor = UserId::new(actor_user); + let process = ProcessId::new(process); + let task = TaskInstanceId::new(task); + let context = clusterflux_core::AuthContext { + tenant: tenant.clone(), + project: project.clone(), + actor: Actor::User(actor), + }; + let authorization = self.coordinator.authorize_debug_attach(&context, &process); + if !authorization.allowed { + return Err(CoordinatorError::Unauthorized(format!( + "task failure resolution denied: {}", + authorization.reason + )) + .into()); + } + let key = task_restart_key(&tenant, &project, &process, &task); + let attempt = self + .task_attempts + .get_mut(&key) + .and_then(|attempts| attempts.iter_mut().rev().find(|attempt| attempt.current)) + .filter(|attempt| attempt.state == TaskAttemptState::FailedAwaitingAction) + .ok_or_else(|| { + CoordinatorServiceError::Protocol( + "task is not failed awaiting operator action".to_owned(), + ) + })?; + attempt.state = match resolution { + TaskFailureResolution::AcceptFailure => TaskAttemptState::Failed, + TaskFailureResolution::Cancel => TaskAttemptState::Cancelled, + }; + attempt.command_state = Some( + match resolution { + TaskFailureResolution::AcceptFailure => "failure_accepted", + TaskFailureResolution::Cancel => "cancelled", + } + .to_owned(), + ); + let attempt_id = attempt.attempt_id.clone(); + let mut event = self + .task_events + .iter() + .rev() + .find(|event| { + event.tenant == tenant + && event.project == project + && event.process == process + && event.task == task + && event.attempt_id.as_deref() == Some(attempt_id.as_str()) + }) + .cloned() + .ok_or_else(|| { + CoordinatorServiceError::Protocol( + "failed attempt terminal event is unavailable".to_owned(), + ) + })?; + if resolution == TaskFailureResolution::Cancel { + event.terminal_state = TaskTerminalState::Cancelled; + event.stderr_tail = "operator cancelled task after failure".to_owned(); + } + self.record_task_completion_event(event.clone()); + self.notify_coordinator_main_waiters(&event); + Ok(CoordinatorResponse::TaskFailureResolved { + process, + task, + attempt_id, + resolution, + }) + } } struct ValidatedTaskReplacement { @@ -372,7 +477,7 @@ struct ValidatedTaskReplacement { fn validate_task_replacement( replacement: &TaskReplacementBundle, - task_definition: &disasmer_core::TaskDefinitionId, + task_definition: &clusterflux_core::TaskDefinitionId, environment_id: Option<&str>, ) -> Result { let module = BASE64_STANDARD @@ -398,7 +503,7 @@ fn validate_task_replacement( if descriptor .get("abi_version") .and_then(serde_json::Value::as_u64) - != Some(disasmer_core::WASM_TASK_ABI_VERSION as u64) + != Some(clusterflux_core::WASM_TASK_ABI_VERSION as u64) { return Err(CoordinatorServiceError::Protocol(format!( "replacement task `{task_definition}` uses an unsupported task ABI" @@ -455,7 +560,7 @@ fn validate_task_replacement( required_capabilities.extend(environment.requirements.capabilities.iter().cloned()); } let environment_digest = environment.as_ref().map_or_else( - || Digest::sha256("disasmer.environment.unconstrained.v1"), + || Digest::sha256("clusterflux.environment.unconstrained.v1"), |environment| environment.digest.clone(), ); Ok(ValidatedTaskReplacement { diff --git a/crates/disasmer-coordinator/src/service/durable_runtime.rs b/crates/clusterflux-coordinator/src/service/durable_runtime.rs similarity index 100% rename from crates/disasmer-coordinator/src/service/durable_runtime.rs rename to crates/clusterflux-coordinator/src/service/durable_runtime.rs diff --git a/crates/disasmer-coordinator/src/service/keys.rs b/crates/clusterflux-coordinator/src/service/keys.rs similarity index 94% rename from crates/disasmer-coordinator/src/service/keys.rs rename to crates/clusterflux-coordinator/src/service/keys.rs index 5be2f1e..8cc82c5 100644 --- a/crates/disasmer-coordinator/src/service/keys.rs +++ b/crates/clusterflux-coordinator/src/service/keys.rs @@ -1,4 +1,6 @@ -use disasmer_core::{ArtifactId, NodeId, ProcessId, ProjectId, TaskInstanceId, TenantId, VfsPath}; +use clusterflux_core::{ + ArtifactId, NodeId, ProcessId, ProjectId, TaskInstanceId, TenantId, VfsPath, +}; pub(super) type TaskControlKey = (TenantId, ProjectId, ProcessId, NodeId, TaskInstanceId); pub(super) type TaskRestartKey = (TenantId, ProjectId, ProcessId, TaskInstanceId); diff --git a/crates/disasmer-coordinator/src/service/logs.rs b/crates/clusterflux-coordinator/src/service/logs.rs similarity index 76% rename from crates/disasmer-coordinator/src/service/logs.rs rename to crates/clusterflux-coordinator/src/service/logs.rs index 3324ab9..fec1d2b 100644 --- a/crates/disasmer-coordinator/src/service/logs.rs +++ b/crates/clusterflux-coordinator/src/service/logs.rs @@ -1,11 +1,12 @@ -use disasmer_core::{ +use clusterflux_core::{ ArtifactFlush, ArtifactId, Digest, NodeId, ProcessId, ProjectId, TaskBoundaryValue, TaskInstanceId, TaskJoinResult, TaskJoinState, TenantId, UserId, VfsPath, }; use crate::CoordinatorError; -use super::keys::{process_control_key, task_control_key}; +use super::keys::{process_control_key, task_control_key, task_restart_key}; +use super::protocol::TaskAttemptState; use super::{ artifact_id_from_path, CoordinatorResponse, CoordinatorService, CoordinatorServiceError, TaskCompletionEvent, TaskTerminalState, MAX_TASK_LOG_TAIL_BYTES, @@ -160,6 +161,7 @@ impl CoordinatorService { executor: super::TaskExecutor::Node, task_definition: checkpoint.assignment.task_spec.task_definition.clone(), task, + attempt_id: None, placement: None, terminal_state: terminal_state .unwrap_or_else(|| TaskTerminalState::from_status_code(status_code)), @@ -215,16 +217,25 @@ impl CoordinatorService { self.task_aborts.remove(&task_key); self.debug_commands.remove(&task_key); self.active_tasks.remove(&task_key); - if !self - .active_tasks - .iter() - .any(|(task_tenant, task_project, task_process, _, _)| { - task_tenant == &event.tenant - && task_project == &event.project - && task_process == &event.process - }) - { + let no_active_tasks = + !self + .active_tasks + .iter() + .any(|(task_tenant, task_project, task_process, _, _)| { + task_tenant == &event.tenant + && task_project == &event.project + && task_process == &event.process + }); + if no_active_tasks { self.process_aborts.remove(&process_key); + if self.process_cancellations.remove(&process_key) + && !self.main_runtime.controls.contains_key(&process_key) + { + self.coordinator + .abort_process(&event.tenant, &event.project, &event.process)?; + self.clear_debug_state_for_process(&event.tenant, &event.project, &event.process); + self.clear_operator_panel_state(&event.tenant, &event.project, &event.process); + } } if process_was_aborted { let checkpoint_key = super::keys::task_restart_key( @@ -237,8 +248,11 @@ impl CoordinatorService { self.task_restart_checkpoint_order .retain(|retained| retained != &checkpoint_key); } + let awaiting_operator = self.finish_task_attempt(&mut event); self.record_task_completion_event(event.clone()); - self.notify_coordinator_main_waiters(&event); + if !awaiting_operator { + self.notify_coordinator_main_waiters(&event); + } Ok(CoordinatorResponse::TaskRecorded { process: event.process, task: event.task, @@ -275,6 +289,33 @@ impl CoordinatorService { Ok(CoordinatorResponse::TaskEvents { events }) } + pub(super) fn handle_list_task_snapshots( + &mut self, + tenant: String, + project: String, + actor_user: String, + process: String, + ) -> Result { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let _actor = UserId::new(actor_user); + let process = ProcessId::new(process); + self.authorize_task_event_process_scope(&tenant, &project, &process)?; + let snapshots = self + .task_attempts + .iter() + .filter( + |((attempt_tenant, attempt_project, attempt_process, _), _)| { + attempt_tenant == &tenant + && attempt_project == &project + && attempt_process == &process + }, + ) + .flat_map(|(_, attempts)| attempts.iter().cloned()) + .collect(); + Ok(CoordinatorResponse::TaskSnapshots { snapshots }) + } + fn authorize_task_event_process_scope( &self, tenant: &TenantId, @@ -287,13 +328,25 @@ impl CoordinatorService { .is_some(); let historical_in_scope = self.task_events.iter().any(|event| { event.tenant == *tenant && event.project == *project && event.process == *process - }); + }) || self.process_scope_history.iter().any( + |(historical_tenant, historical_project, historical_process)| { + historical_tenant == tenant + && historical_project == project + && historical_process == process + }, + ); let process_exists_outside_scope = self .coordinator .active_process_exists_outside_scope(tenant, project, process) || self.task_events.iter().any(|event| { event.process == *process && (event.tenant != *tenant || event.project != *project) - }); + }) + || self.process_scope_history.iter().any( + |(historical_tenant, historical_project, historical_process)| { + historical_process == process + && (historical_tenant != tenant || historical_project != project) + }, + ); if !active_in_scope && !historical_in_scope && process_exists_outside_scope { return Err(CoordinatorError::Unauthorized( "task event access is outside the virtual process tenant/project scope".to_owned(), @@ -362,6 +415,31 @@ impl CoordinatorService { process: ProcessId, task: TaskInstanceId, ) -> TaskJoinResult { + let attempt_key = task_restart_key(&tenant, &project, &process, &task); + if self + .task_attempts + .get(&attempt_key) + .is_some_and(|attempts| { + attempts + .iter() + .rev() + .find(|attempt| attempt.current) + .is_some_and(|attempt| { + matches!( + attempt.state, + TaskAttemptState::Queued + | TaskAttemptState::Running + | TaskAttemptState::FailedAwaitingAction + ) + }) + }) + { + return TaskJoinResult::pending( + process, + task, + "logical task is still running or awaiting operator action", + ); + } let event = self.task_events.iter().rev().find(|event| { event.tenant == tenant && event.project == project @@ -396,6 +474,17 @@ impl CoordinatorService { pub(super) fn record_task_completion_event(&mut self, mut event: TaskCompletionEvent) { event.stdout_tail = bounded_log_tail(event.stdout_tail, &mut event.stdout_truncated); event.stderr_tail = bounded_log_tail(event.stderr_tail, &mut event.stderr_truncated); + let process_scope = ( + event.tenant.clone(), + event.project.clone(), + event.process.clone(), + ); + self.process_scope_history + .retain(|retained| retained != &process_scope); + while self.process_scope_history.len() >= super::MAX_TASK_EVENTS_TOTAL { + self.process_scope_history.pop_front(); + } + self.process_scope_history.push_back(process_scope); while self .task_events .iter() @@ -416,9 +505,46 @@ impl CoordinatorService { }; self.task_events.remove(index); } + while self.task_events.len() >= super::MAX_TASK_EVENTS_TOTAL { + self.task_events.pop_front(); + } self.task_events.push_back(event); } + fn finish_task_attempt(&mut self, event: &mut TaskCompletionEvent) -> bool { + let key = task_restart_key(&event.tenant, &event.project, &event.process, &event.task); + let Some(attempt) = self + .task_attempts + .get_mut(&key) + .and_then(|attempts| attempts.iter_mut().rev().find(|attempt| attempt.current)) + else { + return false; + }; + event.attempt_id = Some(attempt.attempt_id.clone()); + attempt.status_code = event.status_code; + attempt.artifact_path = event.artifact_path.clone(); + attempt.artifact_digest = event.artifact_digest.clone(); + attempt.artifact_size_bytes = event.artifact_size_bytes; + attempt.error = (!event.stderr_tail.trim().is_empty()).then(|| event.stderr_tail.clone()); + let awaiting_operator = event.terminal_state == TaskTerminalState::Failed + && attempt.failure_policy == clusterflux_core::TaskFailurePolicy::AwaitOperator; + attempt.state = if awaiting_operator { + TaskAttemptState::FailedAwaitingAction + } else { + match event.terminal_state { + TaskTerminalState::Completed => TaskAttemptState::Completed, + TaskTerminalState::Failed => TaskAttemptState::Failed, + TaskTerminalState::Cancelled => TaskAttemptState::Cancelled, + } + }; + attempt.command_state = Some(if awaiting_operator { + "failed_awaiting_action".to_owned() + } else { + format!("{:?}", event.terminal_state).to_ascii_lowercase() + }); + awaiting_operator + } + fn flush_artifact_metadata( &mut self, flush: ArtifactFlush, diff --git a/crates/disasmer-coordinator/src/service/main_runtime.rs b/crates/clusterflux-coordinator/src/service/main_runtime.rs similarity index 89% rename from crates/disasmer-coordinator/src/service/main_runtime.rs rename to crates/clusterflux-coordinator/src/service/main_runtime.rs index b38be8e..952d827 100644 --- a/crates/disasmer-coordinator/src/service/main_runtime.rs +++ b/crates/clusterflux-coordinator/src/service/main_runtime.rs @@ -1,10 +1,11 @@ use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::mpsc::{self, Receiver, Sender, SyncSender, TryRecvError}; +use std::sync::mpsc::{self, Receiver, Sender, SyncSender}; use std::sync::{Arc, Mutex}; +use std::time::Duration; use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; -use disasmer_core::{ +use clusterflux_core::{ Capability, CredentialKind, Digest, EnvironmentResource, NodeId, ProcessId, ProjectId, TaskBoundaryValue, TaskDefinitionId, TaskDispatch, TaskInstanceId, TaskJoinState, TaskSpec, TenantId, WasmExportAbi, WasmHostCommandRequest, WasmHostCommandResult, @@ -13,12 +14,16 @@ use disasmer_core::{ WasmHostTaskHandle, WasmHostTaskJoinRequest, WasmHostTaskJoinResult, WasmHostTaskStartRequest, WasmHostVfsRequest, WasmHostVfsResult, WasmTaskInvocation, WasmTaskOutcome, WasmTaskResult, }; -use disasmer_wasm_runtime::{WasmDebugControl, WasmTaskHost, WasmtimeTaskRuntime}; +use clusterflux_wasm_runtime::{ + WasmDebugControl, WasmTaskHost, WasmtimeRuntimeLimits, WasmtimeTaskRuntime, +}; use wasmparser::{Parser, Payload}; use crate::{CoordinatorError, CoordinatorServiceError}; -use super::keys::{process_control_key, task_restart_key, ProcessControlKey, TaskRestartKey}; +use super::keys::{ + process_control_key, task_control_key, task_restart_key, ProcessControlKey, TaskRestartKey, +}; use super::{ CoordinatorResponse, CoordinatorService, TaskCompletionEvent, TaskExecutor, TaskTerminalState, WorkflowActor, @@ -39,7 +44,7 @@ enum MainCommand { StartTask { scope: MainScope, handle_id: u64, - task_spec: TaskSpec, + task_spec: Box, wasm_module_base64: String, response: SyncSender>, }, @@ -76,6 +81,8 @@ pub(super) struct CoordinatorMainRuntime { pub(super) controls: BTreeMap, join_waiters: BTreeMap>>>, next_launch_id: u64, + runtime_limits: WasmtimeRuntimeLimits, + nested_join_timeout: Duration, } impl Default for CoordinatorMainRuntime { @@ -87,11 +94,35 @@ impl Default for CoordinatorMainRuntime { controls: BTreeMap::new(), join_waiters: BTreeMap::new(), next_launch_id: 1, + runtime_limits: WasmtimeRuntimeLimits::default(), + nested_join_timeout: Duration::from_secs(24 * 60 * 60), } } } impl CoordinatorMainRuntime { + pub(super) fn configure( + &mut self, + configuration: super::CoordinatorMainRuntimeConfiguration, + ) -> Result<(), CoordinatorServiceError> { + if configuration.nested_join_timeout_ms == 0 { + return Err(CoordinatorServiceError::Protocol( + "coordinator main nested join timeout must be positive".to_owned(), + )); + } + let limits = WasmtimeRuntimeLimits { + fuel_units_per_second: configuration.fuel_units_per_second, + fuel_burst_seconds: configuration.fuel_burst_seconds, + memory_bytes: configuration.memory_bytes, + }; + limits + .validate() + .map_err(|error| CoordinatorServiceError::Protocol(error.to_string()))?; + self.runtime_limits = limits; + self.nested_join_timeout = Duration::from_millis(configuration.nested_join_timeout_ms); + Ok(()) + } + pub(super) fn is_waiting_for_task( &self, tenant: &TenantId, @@ -107,11 +138,8 @@ impl CoordinatorMainRuntime { fn drain_commands(&self) -> Vec { let mut commands = Vec::new(); - loop { - match self.receiver.try_recv() { - Ok(command) => commands.push(command), - Err(TryRecvError::Empty | TryRecvError::Disconnected) => break, - } + while let Ok(command) = self.receiver.try_recv() { + commands.push(command); } commands } @@ -157,8 +185,10 @@ impl CoordinatorMainRuntime { scope.task_instance.clone(), Vec::new(), ); + let runtime_limits = self.runtime_limits.clone(); + let nested_join_timeout = self.nested_join_timeout; std::thread::Builder::new() - .name(format!("disasmer-main-{}", scope.process)) + .name(format!("clusterflux-main-{}", scope.process)) .spawn(move || { let host = CoordinatorMainHost { scope: scope.clone(), @@ -171,8 +201,9 @@ impl CoordinatorMainRuntime { wasm_module_base64, next_handle_id: 1, handles, + nested_join_timeout, }; - let result = WasmtimeTaskRuntime::new() + let result = WasmtimeTaskRuntime::new_with_limits(runtime_limits) .and_then(|runtime| { runtime.run_task_export_verified_with_task_host( &module, @@ -244,6 +275,7 @@ struct CoordinatorMainHost { wasm_module_base64: String, next_handle_id: u64, handles: Arc>>, + nested_join_timeout: Duration, } impl WasmTaskHost for CoordinatorMainHost { @@ -364,6 +396,7 @@ impl WasmTaskHost for CoordinatorMainHost { required_artifacts, args: request.args, vfs_epoch: self.scope.epoch, + failure_policy: request.failure_policy, bundle_digest: Some(self.bundle_digest.clone()), }; let (response, receiver) = mpsc::sync_channel(1); @@ -371,7 +404,7 @@ impl WasmTaskHost for CoordinatorMainHost { .send(MainCommand::StartTask { scope: self.scope.clone(), handle_id, - task_spec: task_spec.clone(), + task_spec: Box::new(task_spec.clone()), wasm_module_base64: self.wasm_module_base64.clone(), response, }) @@ -406,9 +439,18 @@ impl WasmTaskHost for CoordinatorMainHost { response, }) .map_err(|_| "coordinator main command channel closed".to_owned())?; - let joined = receiver - .recv() - .map_err(|_| "coordinator main task-join response channel closed".to_owned())?; + let joined = + receiver + .recv_timeout(self.nested_join_timeout) + .map_err(|error| match error { + mpsc::RecvTimeoutError::Timeout => format!( + "coordinator main nested task join timed out after {} ms", + self.nested_join_timeout.as_millis() + ), + mpsc::RecvTimeoutError::Disconnected => { + "coordinator main task-join response channel closed".to_owned() + } + })?; self.handles .lock() .map_err(|_| "coordinator main handle registry is unavailable".to_owned())? @@ -429,7 +471,7 @@ impl WasmTaskHost for CoordinatorMainHost { ) -> Result { request.validate()?; Ok(WasmHostTaskControlResult { - abi_version: disasmer_core::WASM_TASK_ABI_VERSION, + abi_version: clusterflux_core::WASM_TASK_ABI_VERSION, cancellation_requested: self.abort.load(Ordering::Acquire), }) } @@ -473,7 +515,7 @@ impl CoordinatorService { actor_user: Option, actor_agent: Option, agent_public_key_fingerprint: Option, - agent_signature: Option, + agent_signature: Option, request_payload_digest: Option<&Digest>, task_spec: TaskSpec, wasm_module_base64: String, @@ -602,6 +644,7 @@ impl CoordinatorService { wasm_module_base64, response, } => { + let task_spec = *task_spec; if !self.main_runtime.is_current_scope(&scope) { let _ = response.send(Err( "coordinator main process incarnation was replaced".to_owned(), @@ -630,7 +673,7 @@ impl CoordinatorService { .and_then(|launch| match launch { CoordinatorResponse::TaskLaunched { .. } | CoordinatorResponse::TaskQueued { .. } => Ok(WasmHostTaskHandle { - abi_version: disasmer_core::WASM_TASK_ABI_VERSION, + abi_version: clusterflux_core::WASM_TASK_ABI_VERSION, handle_id, task_spec, }), @@ -666,7 +709,7 @@ impl CoordinatorService { "completed child task omitted its boundary result".to_owned() }) .map(|result| WasmHostTaskJoinResult { - abi_version: disasmer_core::WASM_TASK_ABI_VERSION, + abi_version: clusterflux_core::WASM_TASK_ABI_VERSION, task_instance, result, }); @@ -732,7 +775,7 @@ impl CoordinatorService { .clone() .ok_or_else(|| "completed child task omitted its boundary result".to_owned()) .map(|result| WasmHostTaskJoinResult { - abi_version: disasmer_core::WASM_TASK_ABI_VERSION, + abi_version: clusterflux_core::WASM_TASK_ABI_VERSION, task_instance: event.task.clone(), result, }), @@ -776,6 +819,7 @@ impl CoordinatorService { executor: TaskExecutor::CoordinatorMain, task_definition: scope.task_definition, task: scope.task_instance, + attempt_id: None, placement: None, terminal_state, status_code: if error.is_empty() { Some(0) } else { None }, @@ -802,111 +846,28 @@ impl CoordinatorService { handles.clear(); } } - // Completion keeps the virtual process and its final debug handshake - // inspectable. In particular, the DAP client may still be waiting for - // every participant's resume acknowledgement when the main Wasm task - // exits. A new process incarnation or an explicit abort clears this - // ephemeral state. - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn completed_main_keeps_process_and_debug_handshake_visible_until_explicit_abort() { - let mut service = CoordinatorService::new(7); - let tenant = TenantId::from("tenant"); - let project = ProjectId::from("project"); - let process = ProcessId::from("vp-current"); - let main_task = TaskInstanceId::from("ti:vp-current:main"); - let scope = MainScope { - tenant: tenant.clone(), - project: project.clone(), - process: process.clone(), - task_definition: TaskDefinitionId::from("build"), - task_instance: main_task.clone(), - epoch: 7, - launch_id: 1, - }; - service + let process_key = process_control_key(&scope.tenant, &scope.project, &scope.process); + for (task_tenant, task_project, task_process, node, task) in self.active_tasks.clone() { + if task_tenant == scope.tenant + && task_project == scope.project + && task_process == scope.process + { + self.task_aborts.insert(task_control_key( + &task_tenant, + &task_project, + &task_process, + &node, + &task, + )); + } + } + self.process_aborts.insert(process_key.clone()); + let _ = self .coordinator - .start_process(tenant.clone(), project.clone(), process.clone()); - let process_key = process_control_key(&tenant, &project, &process); - service.main_runtime.controls.insert( - process_key.clone(), - CoordinatorMainControl { - task_definition: scope.task_definition.clone(), - task_instance: main_task.clone(), - abort: Arc::new(AtomicBool::new(false)), - debug: Arc::new(WasmDebugControl::default()), - state: "running".to_owned(), - stopped_probe_symbol: None, - handles: Arc::new(Mutex::new(HashMap::new())), - launch_id: 1, - }, - ); - service.debug_epochs.insert(process_key.clone(), 2); - service.debug_epoch_runtime.insert( - process_key.clone(), - super::super::debug::DebugEpochRuntime { - epoch: 2, - command: "resume".to_owned(), - expected: BTreeSet::new(), - acknowledgements: BTreeMap::new(), - }, - ); - - service.record_coordinator_main_completion( - scope, - Ok(WasmTaskResult::completed( - main_task, - TaskBoundaryValue::SmallJson(serde_json::Value::Null), - )), - ); - - assert!(service - .coordinator - .active_process(&tenant, &project, &process) - .is_some()); - let CoordinatorResponse::ProcessStatuses { processes, .. } = service - .handle_list_processes( - tenant.as_str().to_owned(), - project.as_str().to_owned(), - "user".to_owned(), - ) - .unwrap() - else { - panic!("expected process statuses"); - }; - assert_eq!(processes.len(), 1); - assert_eq!(processes[0].state, "completed"); - assert_eq!(processes[0].main_state.as_deref(), Some("completed")); - assert_eq!(service.debug_epochs.get(&process_key), Some(&2)); - assert_eq!( - service - .debug_epoch_runtime - .get(&process_key) - .map(|runtime| runtime.command.as_str()), - Some("resume") - ); - - service - .handle_abort_process( - tenant.as_str().to_owned(), - project.as_str().to_owned(), - "user".to_owned(), - process.as_str().to_owned(), - ) - .unwrap(); - assert!(service - .coordinator - .active_process(&tenant, &project, &process) - .is_none()); - assert!(!service.main_runtime.controls.contains_key(&process_key)); - assert!(!service.debug_epochs.contains_key(&process_key)); - assert!(!service.debug_epoch_runtime.contains_key(&process_key)); + .abort_process(&scope.tenant, &scope.project, &scope.process); + self.main_runtime.controls.remove(&process_key); + self.clear_debug_state_for_process(&scope.tenant, &scope.project, &scope.process); + self.clear_operator_panel_state(&scope.tenant, &scope.project, &scope.process); } } @@ -921,7 +882,7 @@ pub(super) fn task_descriptors( else { continue; }; - if section.name() != "disasmer.tasks" { + if section.name() != "clusterflux.tasks" { continue; } for record in section @@ -955,7 +916,7 @@ pub(super) fn bundle_environments( else { continue; }; - if section.name() != "disasmer.environments" { + if section.name() != "clusterflux.environments" { continue; } let environments: Vec = serde_json::from_slice(section.data()) @@ -997,3 +958,82 @@ pub(super) fn capability_from_descriptor(capability: &str) -> Result Err(format!("unknown task capability `{other}`")), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn completed_main_releases_process_slot_and_debug_state() { + let mut service = CoordinatorService::new(7); + let tenant = TenantId::from("tenant"); + let project = ProjectId::from("project"); + let process = ProcessId::from("vp-current"); + let main_task = TaskInstanceId::from("ti:vp-current:main"); + let scope = MainScope { + tenant: tenant.clone(), + project: project.clone(), + process: process.clone(), + task_definition: TaskDefinitionId::from("build"), + task_instance: main_task.clone(), + epoch: 7, + launch_id: 1, + }; + service + .coordinator + .start_process(tenant.clone(), project.clone(), process.clone()); + let process_key = process_control_key(&tenant, &project, &process); + service.main_runtime.controls.insert( + process_key.clone(), + CoordinatorMainControl { + task_definition: scope.task_definition.clone(), + task_instance: main_task.clone(), + abort: Arc::new(AtomicBool::new(false)), + debug: Arc::new(WasmDebugControl::default()), + state: "running".to_owned(), + stopped_probe_symbol: None, + handles: Arc::new(Mutex::new(HashMap::new())), + launch_id: 1, + }, + ); + service.debug_epochs.insert(process_key.clone(), 2); + service.debug_epoch_runtime.insert( + process_key.clone(), + super::super::debug::DebugEpochRuntime { + epoch: 2, + command: "resume".to_owned(), + expected: BTreeSet::new(), + acknowledgements: BTreeMap::new(), + deadline: std::time::Instant::now(), + }, + ); + + service.record_coordinator_main_completion( + scope, + Ok(WasmTaskResult::completed( + main_task, + TaskBoundaryValue::SmallJson(serde_json::Value::Null), + )), + ); + + assert!(service + .coordinator + .active_process(&tenant, &project, &process) + .is_none()); + let CoordinatorResponse::ProcessStatuses { processes, .. } = service + .handle_list_processes( + tenant.as_str().to_owned(), + project.as_str().to_owned(), + "user".to_owned(), + ) + .unwrap() + else { + panic!("expected process statuses"); + }; + assert!(processes.is_empty()); + assert!(!service.main_runtime.controls.contains_key(&process_key)); + assert!(!service.debug_epochs.contains_key(&process_key)); + assert!(!service.debug_epoch_runtime.contains_key(&process_key)); + assert!(service.process_aborts.contains(&process_key)); + } +} diff --git a/crates/disasmer-coordinator/src/service/nodes.rs b/crates/clusterflux-coordinator/src/service/nodes.rs similarity index 90% rename from crates/disasmer-coordinator/src/service/nodes.rs rename to crates/clusterflux-coordinator/src/service/nodes.rs index becd733..349ad61 100644 --- a/crates/disasmer-coordinator/src/service/nodes.rs +++ b/crates/clusterflux-coordinator/src/service/nodes.rs @@ -1,7 +1,7 @@ use std::collections::BTreeSet; use std::time::{SystemTime, UNIX_EPOCH}; -use disasmer_core::{ +use clusterflux_core::{ generate_opaque_token, verify_node_request_signature, Actor, ArtifactId, CredentialKind, Digest, NodeCapabilities, NodeDescriptor, NodeId, NodeSignedRequest, ProjectId, SourceProviderKind, TenantId, UserId, @@ -15,6 +15,38 @@ use super::{ }; impl CoordinatorService { + pub fn set_node_stale_after_seconds(&mut self, seconds: u64) { + self.node_stale_after_seconds = seconds.max(1); + } + + pub(super) fn liveness_now_epoch_seconds(&self) -> u64 { + #[cfg(test)] + if let Some(now) = self.server_time_override { + return now; + } + unix_timestamp_seconds() + } + + pub(super) fn node_is_live(&self, node: &NodeId) -> bool { + self.node_last_seen_epoch_seconds + .get(node) + .is_some_and(|last_seen| { + self.liveness_now_epoch_seconds().saturating_sub(*last_seen) + <= self.node_stale_after_seconds + }) + } + + pub(super) fn live_node_descriptors(&self) -> Vec { + self.node_descriptors + .values() + .cloned() + .map(|mut descriptor| { + descriptor.online = self.node_is_live(&descriptor.id); + descriptor + }) + .collect() + } + pub(super) fn handle_attach_node( &mut self, tenant: String, @@ -138,7 +170,7 @@ impl CoordinatorService { self.enrollment_grants .get_mut(&grant_key) .ok_or(CoordinatorError::Enrollment( - disasmer_core::EnrollmentError::Expired, + clusterflux_core::EnrollmentError::Expired, ))?; let credential = self.coordinator.exchange_node_enrollment_grant( grant, @@ -182,7 +214,7 @@ impl CoordinatorService { source_snapshots: Vec, artifact_locations: Vec, direct_connectivity: bool, - online: bool, + _online: bool, ) -> Result { let tenant = TenantId::new(tenant); let project = ProjectId::new(project); @@ -239,6 +271,7 @@ impl CoordinatorService { self.artifact_registry .reconcile_node_retention(&node, &artifact_locations); + let online = self.node_is_live(&node); self.node_descriptors.insert( node.clone(), NodeDescriptor { @@ -270,10 +303,9 @@ impl CoordinatorService { let project = ProjectId::new(project); let actor = UserId::new(actor_user); let descriptors = self - .node_descriptors - .values() + .live_node_descriptors() + .into_iter() .filter(|descriptor| descriptor.tenant == tenant && descriptor.project == project) - .cloned() .collect(); Ok(CoordinatorResponse::NodeDescriptors { descriptors, actor }) } @@ -289,13 +321,14 @@ impl CoordinatorService { let project = ProjectId::new(project); let actor = UserId::new(actor_user); let node = NodeId::new(node); - let context = disasmer_core::AuthContext { + let context = clusterflux_core::AuthContext { tenant: tenant.clone(), project: project.clone(), actor: Actor::User(actor.clone()), }; self.coordinator.revoke_node_credential(&context, &node)?; let descriptor_removed = self.node_descriptors.remove(&node).is_some(); + self.node_last_seen_epoch_seconds.remove(&node); self.artifact_registry.garbage_collect_node(&node); let queued_assignments_removed = self .task_assignments @@ -395,6 +428,12 @@ impl CoordinatorService { } self.node_replay_nonces .insert(replay_key, now_epoch_seconds); + let seen_at = self.liveness_now_epoch_seconds(); + self.node_last_seen_epoch_seconds + .insert(node.clone(), seen_at); + if let Some(descriptor) = self.node_descriptors.get_mut(node) { + descriptor.online = true; + } Ok(()) } } diff --git a/crates/disasmer-coordinator/src/service/panels.rs b/crates/clusterflux-coordinator/src/service/panels.rs similarity index 97% rename from crates/disasmer-coordinator/src/service/panels.rs rename to crates/clusterflux-coordinator/src/service/panels.rs index 9de25bd..4e66042 100644 --- a/crates/disasmer-coordinator/src/service/panels.rs +++ b/crates/clusterflux-coordinator/src/service/panels.rs @@ -1,4 +1,4 @@ -use disasmer_core::{ +use clusterflux_core::{ Actor, DownloadPolicy, PanelEvent, PanelEventKind, PanelState, PanelWidget, PanelWidgetKind, ProcessId, ProjectId, RateLimit, TenantId, UserId, }; @@ -241,11 +241,11 @@ impl CoordinatorService { } let mut actions = vec![ - disasmer_core::ControlPlaneAction::DebugProcess, - disasmer_core::ControlPlaneAction::CancelProcess, + clusterflux_core::ControlPlaneAction::DebugProcess, + clusterflux_core::ControlPlaneAction::CancelProcess, ]; if let Some(task) = last_task.clone() { - actions.push(disasmer_core::ControlPlaneAction::RestartTask(task)); + actions.push(clusterflux_core::ControlPlaneAction::RestartTask(task)); } panel.set_control_plane_actions(actions); @@ -255,7 +255,7 @@ impl CoordinatorService { .find_map(|event| event.artifact_path.as_ref()) { let artifact = artifact_id_from_path(path); - let context = disasmer_core::AuthContext { + let context = clusterflux_core::AuthContext { tenant, project, actor: Actor::User(actor_user), diff --git a/crates/disasmer-coordinator/src/service/process_launch.rs b/crates/clusterflux-coordinator/src/service/process_launch.rs similarity index 76% rename from crates/disasmer-coordinator/src/service/process_launch.rs rename to crates/clusterflux-coordinator/src/service/process_launch.rs index 30cd228..7ec7f0e 100644 --- a/crates/disasmer-coordinator/src/service/process_launch.rs +++ b/crates/clusterflux-coordinator/src/service/process_launch.rs @@ -1,10 +1,11 @@ use std::collections::BTreeMap; use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; -use disasmer_core::{ +use clusterflux_core::{ AgentSignedRequest, ArtifactId, CheckpointBoundary, CredentialKind, DefaultScheduler, Digest, - NodeId, PlacementRequest, ProcessId, ProjectId, Scheduler, TaskCheckpoint, TaskDispatch, - TaskInstanceId, TaskSpec, TenantId, VfsManifest, VfsObject, VfsPath, WasmTaskInvocation, + NodeId, PlacementRequest, ProcessId, ProjectId, Scheduler, TaskBoundaryValue, TaskCheckpoint, + TaskDispatch, TaskInstanceId, TaskSpec, TenantId, VfsManifest, VfsObject, VfsPath, + WasmTaskInvocation, }; use crate::CoordinatorError; @@ -15,6 +16,7 @@ use super::{ }; use super::processes::*; +use super::protocol::{TaskAttemptSnapshot, TaskAttemptState}; impl CoordinatorService { pub(super) fn capture_task_restart_checkpoint( @@ -24,7 +26,7 @@ impl CoordinatorService { let task_spec = &assignment.task_spec; let environment_digest = task_spec.environment_digest.clone().unwrap_or_else(|| { task_spec.environment.as_ref().map_or_else( - || Digest::sha256("disasmer.environment.unconstrained.v1"), + || Digest::sha256("clusterflux.environment.unconstrained.v1"), |environment| { Digest::sha256( serde_json::to_vec(environment) @@ -34,7 +36,7 @@ impl CoordinatorService { ) }); let task_entrypoint = match &task_spec.dispatch { - disasmer_core::TaskDispatch::CoordinatorNodeWasm { export, .. } => export + clusterflux_core::TaskDispatch::CoordinatorNodeWasm { export, .. } => export .clone() .or_else(|| assignment_task_descriptor(assignment)?.get("export")?.as_str().map(str::to_owned)) .ok_or_else(|| { @@ -132,6 +134,11 @@ impl CoordinatorService { self.task_restart_checkpoints.remove(&expired); } } + while self.task_restart_checkpoint_order.len() > super::MAX_RESTART_CHECKPOINTS_TOTAL { + if let Some(expired) = self.task_restart_checkpoint_order.pop_front() { + self.task_restart_checkpoints.remove(&expired); + } + } Ok(()) } @@ -139,9 +146,9 @@ impl CoordinatorService { &mut self, tenant: String, project: String, - environment: Option, + environment: Option, environment_digest: Option, - required_capabilities: Vec, + required_capabilities: Vec, dependency_cache: Option, source_snapshot: Option, required_artifacts: Vec, @@ -155,6 +162,7 @@ impl CoordinatorService { project: project.clone(), environment, environment_digest, + environment_cache_required: false, required_capabilities: required_capabilities.into_iter().collect(), dependency_cache, source_snapshot, @@ -169,7 +177,7 @@ impl CoordinatorService { policy_allowed: self.admission.workflow_placement_allowed, prefer_node: prefer_node.map(NodeId::new), }; - let nodes = self.node_descriptors.values().cloned().collect::>(); + let nodes = self.live_node_descriptors(); let placement = DefaultScheduler.place(&nodes, &request)?; Ok(CoordinatorResponse::TaskPlacement { placement }) } @@ -191,7 +199,7 @@ impl CoordinatorService { if matches!( &task_spec.dispatch, TaskDispatch::CoordinatorNodeWasm { - abi: disasmer_core::WasmExportAbi::EntrypointV1, + abi: clusterflux_core::WasmExportAbi::EntrypointV1, .. } ) { @@ -324,6 +332,7 @@ impl CoordinatorService { let active = self .coordinator .active_process(&tenant, &project, &process) + .cloned() .ok_or_else(|| { CoordinatorError::Unauthorized( "task launch requires an active coordinator-side virtual process".to_owned(), @@ -435,6 +444,8 @@ impl CoordinatorService { project: project.clone(), environment: task_spec.environment.clone(), environment_digest: task_spec.environment_digest.clone(), + environment_cache_required: task_spec.environment_id.is_some() + && task_spec.environment.is_none(), required_capabilities: task_spec.required_capabilities.clone(), dependency_cache: task_spec.dependency_cache.clone(), source_snapshot: task_spec.source_snapshot.clone(), @@ -446,7 +457,7 @@ impl CoordinatorService { policy_allowed: self.admission.workflow_placement_allowed, prefer_node: None, }; - let nodes = self.node_descriptors.values().cloned().collect::>(); + let nodes = self.live_node_descriptors(); let placement = match DefaultScheduler.place(&nodes, &request) { Ok(placement) => placement, Err(err) if wait_for_node => { @@ -458,6 +469,7 @@ impl CoordinatorService { let charged_spawns = self.quota .charge_workflow_spawn(&tenant, &project, now_epoch_seconds)?; + self.begin_task_attempt(&task_spec, None, Some(&artifact_path), true)?; self.pending_task_launches.push_back(PendingTaskLaunch { tenant: tenant.clone(), project: project.clone(), @@ -494,6 +506,12 @@ impl CoordinatorService { task_spec, wasm_module_base64, }; + self.begin_task_attempt( + &assignment.task_spec, + Some(assignment.node.clone()), + Some(&assignment.artifact_path), + false, + )?; self.capture_task_restart_checkpoint(&assignment)?; let task_key = task_control_key(&tenant, &project, &process, &placement.node, &task); self.task_placements @@ -518,7 +536,7 @@ impl CoordinatorService { tenant: &TenantId, project: &ProjectId, process: &ProcessId, - task_instance: &disasmer_core::TaskInstanceId, + task_instance: &clusterflux_core::TaskInstanceId, ) -> bool { self.active_tasks.iter().any( |(task_tenant, task_project, task_process, _, existing_instance)| { @@ -532,12 +550,137 @@ impl CoordinatorService { && &pending.project == project && &pending.process == process && &pending.task == task_instance - }) || self.task_events.iter().any(|event| { + }) || (!self.restart_launches.contains(&task_restart_key( + tenant, + project, + process, + task_instance, + )) && self.task_events.iter().any(|event| { &event.tenant == tenant && &event.project == project && &event.process == process && &event.task == task_instance - }) + })) + } + + pub(super) fn begin_task_attempt( + &mut self, + task_spec: &TaskSpec, + node: Option, + artifact_path: Option<&str>, + queued: bool, + ) -> Result { + let key = task_restart_key( + &task_spec.tenant, + &task_spec.project, + &task_spec.process, + &task_spec.task_instance, + ); + while !self.task_attempts.contains_key(&key) + && self.task_attempts.len() >= super::MAX_TASK_ATTEMPT_HISTORIES + { + let removable = self.task_attempts.iter().find_map(|(candidate, attempts)| { + attempts + .iter() + .all(|attempt| { + !matches!( + &attempt.state, + TaskAttemptState::Queued + | TaskAttemptState::Running + | TaskAttemptState::FailedAwaitingAction + ) + }) + .then(|| candidate.clone()) + }); + let Some(removable) = removable else { + return Err(CoordinatorServiceError::Protocol( + "task attempt history capacity is exhausted by active attempts".to_owned(), + )); + }; + self.task_attempts.remove(&removable); + } + let attempts = self.task_attempts.entry(key).or_default(); + for attempt in attempts.iter_mut() { + attempt.current = false; + } + let attempt_id = clusterflux_core::generate_opaque_token("ta") + .map_err(CoordinatorServiceError::Protocol)?; + let attempt_number = u32::try_from(attempts.len() + 1).unwrap_or(u32::MAX); + let mut argument_summary = task_spec + .args + .iter() + .map(|argument| { + let mut value = serde_json::to_string(argument) + .unwrap_or_else(|_| "".to_owned()); + value.truncate(value.len().min(1024)); + value + }) + .collect::>(); + argument_summary.truncate(64); + let mut handle_summary = task_spec + .required_artifacts + .iter() + .map(|artifact| format!("artifact:{artifact}")) + .collect::>(); + for argument in &task_spec.args { + if let TaskBoundaryValue::Structured(boundary) = argument { + handle_summary.extend(boundary.handles.iter().map(|handle| format!("{handle:?}"))); + } + } + handle_summary.truncate(256); + attempts.push(TaskAttemptSnapshot { + process: task_spec.process.clone(), + task: task_spec.task_instance.clone(), + attempt_id: attempt_id.clone(), + attempt_number, + task_definition: task_spec.task_definition.clone(), + display_name: task_spec.task_definition.as_str().replace(['_', '-'], " "), + state: if queued { + TaskAttemptState::Queued + } else { + TaskAttemptState::Running + }, + current: true, + node, + environment_id: task_spec.environment_id.clone(), + environment_digest: task_spec.environment_digest.clone(), + argument_summary, + handle_summary, + command_state: Some(if queued { "queued" } else { "running" }.to_owned()), + vfs_checkpoint: format!("vfs-epoch:{}", task_spec.vfs_epoch), + probe_symbol: None, + source_path: None, + source_line: None, + restart_compatible: true, + failure_policy: task_spec.failure_policy, + artifact_path: artifact_path.and_then(|path| VfsPath::new(path).ok()), + artifact_digest: None, + artifact_size_bytes: None, + status_code: None, + error: None, + }); + if attempts.len() > 128 { + attempts.remove(0); + } + Ok(attempt_id) + } + + pub(super) fn assign_task_attempt(&mut self, task_spec: &TaskSpec, node: NodeId) { + let key = task_restart_key( + &task_spec.tenant, + &task_spec.project, + &task_spec.process, + &task_spec.task_instance, + ); + if let Some(attempt) = self + .task_attempts + .get_mut(&key) + .and_then(|attempts| attempts.iter_mut().rev().find(|attempt| attempt.current)) + { + attempt.node = Some(node); + attempt.state = TaskAttemptState::Running; + attempt.command_state = Some("running".to_owned()); + } } } diff --git a/crates/disasmer-coordinator/src/service/processes.rs b/crates/clusterflux-coordinator/src/service/processes.rs similarity index 94% rename from crates/disasmer-coordinator/src/service/processes.rs rename to crates/clusterflux-coordinator/src/service/processes.rs index ecfe973..1fdc5f2 100644 --- a/crates/disasmer-coordinator/src/service/processes.rs +++ b/crates/clusterflux-coordinator/src/service/processes.rs @@ -1,7 +1,7 @@ use std::collections::{BTreeSet, VecDeque}; use std::time::{SystemTime, UNIX_EPOCH}; -use disasmer_core::{ +use clusterflux_core::{ AgentId, AgentSignedRequest, CredentialKind, DefaultScheduler, Digest, NodeId, PlacementRequest, ProcessId, ProjectId, RendezvousRequest, Scheduler, SourcePreparation, TaskCheckpoint, TaskInstanceId, TaskSpec, TenantId, UserId, @@ -129,6 +129,7 @@ impl CoordinatorService { task_spec: pending.task_spec, wasm_module_base64: pending.wasm_module_base64, }; + self.assign_task_attempt(&assignment.task_spec, assignment.node.clone()); self.capture_task_restart_checkpoint(&assignment)?; let task_key = task_control_key( &pending.tenant, @@ -148,9 +149,9 @@ impl CoordinatorService { pub(super) fn handle_request_rendezvous( &mut self, - scope: disasmer_core::DataPlaneScope, - source: disasmer_core::NodeEndpoint, - destination: disasmer_core::NodeEndpoint, + scope: clusterflux_core::DataPlaneScope, + source: clusterflux_core::NodeEndpoint, + destination: clusterflux_core::NodeEndpoint, direct_connectivity: bool, failure_reason: String, ) -> Result { @@ -179,7 +180,7 @@ impl CoordinatorService { &mut self, tenant: String, project: String, - provider: disasmer_core::SourceProviderKind, + provider: clusterflux_core::SourceProviderKind, ) -> Result { let tenant = TenantId::new(tenant); let project = ProjectId::new(project); @@ -189,6 +190,7 @@ impl CoordinatorService { project, environment: None, environment_digest: None, + environment_cache_required: false, required_capabilities: preparation.required_capabilities.clone(), dependency_cache: None, source_snapshot: None, @@ -197,7 +199,7 @@ impl CoordinatorService { policy_allowed: true, prefer_node: None, }; - let nodes = self.node_descriptors.values().cloned().collect::>(); + let nodes = self.live_node_descriptors(); let disposition = match DefaultScheduler.place(&nodes, &request) { Ok(placement) => SourcePreparationDisposition::Assigned { node: placement.node, @@ -223,7 +225,7 @@ impl CoordinatorService { tenant: String, project: String, node: String, - provider: disasmer_core::SourceProviderKind, + provider: clusterflux_core::SourceProviderKind, source_snapshot: Digest, ) -> Result { let tenant = TenantId::new(tenant); @@ -369,6 +371,18 @@ impl CoordinatorService { self.task_events.retain(|event| { event.tenant != tenant || event.project != project || event.process != process }); + self.task_attempts + .retain(|(attempt_tenant, attempt_project, attempt_process, _), _| { + attempt_tenant != &tenant + || attempt_project != &project + || attempt_process != &process + }); + self.restart_launches + .retain(|(attempt_tenant, attempt_project, attempt_process, _)| { + attempt_tenant != &tenant + || attempt_project != &project + || attempt_process != &process + }); self.debug_audit_events.retain(|event| { event.tenant != tenant || event.project != project || event.process != process }); @@ -480,6 +494,12 @@ impl CoordinatorService { }); } } + let process_key = process_control_key(&tenant, &project, &process); + if cancelled_tasks.is_empty() && !self.main_runtime.controls.contains_key(&process_key) { + self.coordinator + .abort_process(&tenant, &project, &process)?; + self.clear_operator_panel_state(&tenant, &project, &process); + } Ok(CoordinatorResponse::ProcessCancellationRequested { process, cancelled_tasks, @@ -723,15 +743,16 @@ impl CoordinatorService { ) .into()); } + let canonical_scope = clusterflux_core::AgentWorkflowRequestScope::new( + tenant.clone(), + project.clone(), + request_kind, + process.clone(), + task.cloned(), + ) + .map_err(CoordinatorError::Unauthorized)?; let record = self.coordinator.authorize_agent_project_run( - disasmer_core::AgentWorkflowScope { - tenant, - project, - agent: &agent, - request_kind, - process, - task, - }, + canonical_scope.for_agent(&agent), agent_public_key_fingerprint.as_ref(), request_payload_digest, &signature, diff --git a/crates/disasmer-coordinator/src/service/protocol.rs b/crates/clusterflux-coordinator/src/service/protocol.rs similarity index 95% rename from crates/disasmer-coordinator/src/service/protocol.rs rename to crates/clusterflux-coordinator/src/service/protocol.rs index ac9fdf2..3a3204e 100644 --- a/crates/disasmer-coordinator/src/service/protocol.rs +++ b/crates/clusterflux-coordinator/src/service/protocol.rs @@ -1,6 +1,6 @@ use std::collections::BTreeMap; -use disasmer_core::{ +use clusterflux_core::{ AgentId, AgentSignedRequest, ArtifactId, Authorization, Capability, CredentialKind, DataPlaneScope, Digest, DirectBulkTransferPlan, DownloadLink, EnvironmentRequirements, LimitKind, NodeCapabilities, NodeDescriptor, NodeEndpoint, NodeId, NodeSignedRequest, @@ -22,6 +22,13 @@ pub struct TaskReplacementBundle { pub wasm_module_base64: String, } +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TaskFailureResolution { + AcceptFailure, + Cancel, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] pub enum CoordinatorRequest { @@ -306,6 +313,14 @@ pub enum CoordinatorRequest { #[serde(default)] replacement_bundle: Option, }, + ResolveTaskFailure { + tenant: String, + project: String, + actor_user: String, + process: String, + task: String, + resolution: TaskFailureResolution, + }, DebugAttach { tenant: String, project: String, @@ -444,6 +459,12 @@ pub enum CoordinatorRequest { #[serde(default)] process: Option, }, + ListTaskSnapshots { + tenant: String, + project: String, + actor_user: String, + process: String, + }, JoinTask { tenant: String, project: String, @@ -507,7 +528,7 @@ impl CoordinatorRequest { pub fn operation(&self) -> Result { serde_json::to_value(self) .map_err(|err| format!("failed to encode coordinator request operation: {err}")) - .map(|value| disasmer_core::coordinator_payload_operation(&value)) + .map(|value| clusterflux_core::coordinator_payload_operation(&value)) } } @@ -580,6 +601,11 @@ pub enum AuthenticatedCoordinatorRequest { #[serde(default)] replacement_bundle: Option, }, + ResolveTaskFailure { + process: String, + task: String, + resolution: TaskFailureResolution, + }, DebugAttach { process: String, }, @@ -607,6 +633,9 @@ pub enum AuthenticatedCoordinatorRequest { #[serde(default)] process: Option, }, + ListTaskSnapshots { + process: String, + }, JoinTask { process: String, task: String, diff --git a/crates/disasmer-coordinator/src/service/protocol/responses.rs b/crates/clusterflux-coordinator/src/service/protocol/responses.rs similarity index 86% rename from crates/disasmer-coordinator/src/service/protocol/responses.rs rename to crates/clusterflux-coordinator/src/service/protocol/responses.rs index a107abc..3fba996 100644 --- a/crates/disasmer-coordinator/src/service/protocol/responses.rs +++ b/crates/clusterflux-coordinator/src/service/protocol/responses.rs @@ -31,9 +31,11 @@ pub struct TaskCompletionEvent { pub process: ProcessId, pub node: NodeId, pub executor: TaskExecutor, - pub task_definition: disasmer_core::TaskDefinitionId, + pub task_definition: clusterflux_core::TaskDefinitionId, pub task: TaskInstanceId, #[serde(default, skip_serializing_if = "Option::is_none")] + pub attempt_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub placement: Option, pub terminal_state: TaskTerminalState, pub status_code: Option, @@ -49,6 +51,46 @@ pub struct TaskCompletionEvent { pub result: Option, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TaskAttemptState { + Queued, + Running, + FailedAwaitingAction, + Completed, + Failed, + Cancelled, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct TaskAttemptSnapshot { + pub process: ProcessId, + pub task: TaskInstanceId, + pub attempt_id: String, + pub attempt_number: u32, + pub task_definition: clusterflux_core::TaskDefinitionId, + pub display_name: String, + pub state: TaskAttemptState, + pub current: bool, + pub node: Option, + pub environment_id: Option, + pub environment_digest: Option, + pub argument_summary: Vec, + pub handle_summary: Vec, + pub command_state: Option, + pub vfs_checkpoint: String, + pub probe_symbol: Option, + pub source_path: Option, + pub source_line: Option, + pub restart_compatible: bool, + pub failure_policy: clusterflux_core::TaskFailurePolicy, + pub artifact_path: Option, + pub artifact_digest: Option, + pub artifact_size_bytes: Option, + pub status_code: Option, + pub error: Option, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct DebugAuditEvent { pub tenant: TenantId, @@ -115,7 +157,7 @@ pub enum DebugAcknowledgementState { #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct DebugParticipantAcknowledgement { pub node: NodeId, - pub task_definition: disasmer_core::TaskDefinitionId, + pub task_definition: clusterflux_core::TaskDefinitionId, pub task: TaskInstanceId, pub epoch: u64, pub state: DebugAcknowledgementState, @@ -132,7 +174,7 @@ pub struct DebugParticipantAcknowledgement { pub struct VirtualProcessStatus { pub process: ProcessId, pub state: String, - pub main_task_definition: Option, + pub main_task_definition: Option, pub main_task_instance: Option, pub main_state: Option, pub main_wait_state: Option, @@ -226,7 +268,7 @@ pub enum CoordinatorResponse { node: NodeId, tenant: TenantId, project: ProjectId, - credential: disasmer_core::NodeCredential, + credential: clusterflux_core::NodeCredential, }, NodeHeartbeat { node: NodeId, @@ -261,7 +303,7 @@ pub enum CoordinatorResponse { }, MainLaunched { process: ProcessId, - task_definition: disasmer_core::TaskDefinitionId, + task_definition: clusterflux_core::TaskDefinitionId, task_instance: TaskInstanceId, actor: WorkflowActor, state: String, @@ -349,7 +391,8 @@ pub enum CoordinatorResponse { TaskRestart { process: ProcessId, task: TaskInstanceId, - restarted_task_instance: Option, + restarted_task_instance: Option, + restarted_attempt_id: Option, actor: UserId, accepted: bool, clean_boundary_available: bool, @@ -420,6 +463,7 @@ pub enum CoordinatorResponse { expected_tasks: Vec, acknowledgements: Vec, fully_frozen: bool, + partially_frozen: bool, fully_resumed: bool, failed: bool, failure_messages: Vec, @@ -450,6 +494,15 @@ pub enum CoordinatorResponse { TaskEvents { events: Vec, }, + TaskSnapshots { + snapshots: Vec, + }, + TaskFailureResolved { + process: ProcessId, + task: TaskInstanceId, + attempt_id: String, + resolution: TaskFailureResolution, + }, TaskJoined { join: TaskJoinResult, }, diff --git a/crates/disasmer-coordinator/src/service/quota.rs b/crates/clusterflux-coordinator/src/service/quota.rs similarity index 99% rename from crates/disasmer-coordinator/src/service/quota.rs rename to crates/clusterflux-coordinator/src/service/quota.rs index a265dd3..fb1b4a5 100644 --- a/crates/disasmer-coordinator/src/service/quota.rs +++ b/crates/clusterflux-coordinator/src/service/quota.rs @@ -1,6 +1,6 @@ use std::collections::BTreeMap; -use disasmer_core::{LimitError, LimitKind, ProjectId, ResourceLimits, ResourceMeter, TenantId}; +use clusterflux_core::{LimitError, LimitKind, ProjectId, ResourceLimits, ResourceMeter, TenantId}; #[derive(Clone, Debug, PartialEq, Eq)] pub struct CoordinatorQuotaConfiguration { diff --git a/crates/clusterflux-coordinator/src/service/relay.rs b/crates/clusterflux-coordinator/src/service/relay.rs new file mode 100644 index 0000000..33f2fbc --- /dev/null +++ b/crates/clusterflux-coordinator/src/service/relay.rs @@ -0,0 +1,486 @@ +use std::collections::BTreeMap; + +use clusterflux_core::{ProjectId, TenantId, UserId}; +use thiserror::Error; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ArtifactRelayConfiguration { + pub enabled: bool, + pub max_artifact_bytes: u64, + pub max_active_per_project: usize, + pub max_active_per_tenant: usize, + pub max_active_per_account: usize, + pub max_active_global: usize, + pub max_period_bytes_per_project: u64, + pub max_period_bytes_per_tenant: u64, + pub max_period_bytes_per_account: u64, + pub max_period_bytes_global: u64, + pub period_seconds: u64, + pub framing_overhead_bytes: u64, + pub max_tracked_scopes: usize, +} + +impl ArtifactRelayConfiguration { + pub fn unlimited() -> Self { + Self { + enabled: true, + max_artifact_bytes: u64::MAX, + max_active_per_project: usize::MAX, + max_active_per_tenant: usize::MAX, + max_active_per_account: usize::MAX, + max_active_global: usize::MAX, + max_period_bytes_per_project: u64::MAX, + max_period_bytes_per_tenant: u64::MAX, + max_period_bytes_per_account: u64::MAX, + max_period_bytes_global: u64::MAX, + period_seconds: u64::MAX, + framing_overhead_bytes: 512, + max_tracked_scopes: usize::MAX, + } + } + + pub fn validate(&self) -> Result<(), ArtifactRelayError> { + if self.period_seconds == 0 + || self.max_active_per_project == 0 + || self.max_active_per_tenant == 0 + || self.max_active_per_account == 0 + || self.max_active_global == 0 + || self.max_tracked_scopes == 0 + { + return Err(ArtifactRelayError::InvalidConfiguration); + } + Ok(()) + } +} + +impl Default for ArtifactRelayConfiguration { + fn default() -> Self { + Self::unlimited() + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ArtifactRelayUsage { + pub active_transfers: usize, + pub ingress_bytes: u64, + pub egress_bytes: u64, + pub abandoned_or_failed_bytes: u64, + pub reserved_bytes: u64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum RelayFinishReason { + Completed, + Failed, + Cancelled, + Expired, +} + +#[derive(Clone, Debug, Error, PartialEq, Eq)] +pub enum ArtifactRelayError { + #[error("artifact relay is disabled by hosted policy")] + Disabled, + #[error("artifact exceeds the configured relay size limit")] + ArtifactTooLarge, + #[error("artifact relay concurrency limit reached for {0}")] + Concurrency(&'static str), + #[error("artifact relay period byte budget exhausted for {0}")] + PeriodBudget(&'static str), + #[error("artifact relay retained scope state limit reached")] + StateLimit, + #[error("artifact relay reservation is missing or expired")] + MissingReservation, + #[error("artifact relay configuration contains a zero bound")] + InvalidConfiguration, +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +struct RelayScope { + tenant: TenantId, + project: ProjectId, + account: UserId, +} + +#[derive(Clone, Debug)] +struct RelayReservation { + scope: RelayScope, + remaining_reserved_bytes: u64, + ingress_bytes: u64, + egress_bytes: u64, + expires_at_epoch_seconds: u64, +} + +#[derive(Clone, Debug)] +pub(super) struct ArtifactRelayLedger { + configuration: ArtifactRelayConfiguration, + reservations: BTreeMap, + period: u64, + project_used: BTreeMap<(TenantId, ProjectId), u64>, + tenant_used: BTreeMap, + account_used: BTreeMap<(TenantId, UserId), u64>, + global_used: u64, + ingress_used: u64, + egress_used: u64, + abandoned_or_failed_used: u64, +} + +impl Default for ArtifactRelayLedger { + fn default() -> Self { + Self::new(ArtifactRelayConfiguration::default()) + } +} + +impl ArtifactRelayLedger { + pub(super) fn new(configuration: ArtifactRelayConfiguration) -> Self { + Self { + configuration, + reservations: BTreeMap::new(), + period: 0, + project_used: BTreeMap::new(), + tenant_used: BTreeMap::new(), + account_used: BTreeMap::new(), + global_used: 0, + ingress_used: 0, + egress_used: 0, + abandoned_or_failed_used: 0, + } + } + + pub(super) fn configure( + &mut self, + configuration: ArtifactRelayConfiguration, + ) -> Result<(), ArtifactRelayError> { + configuration.validate()?; + if !self.reservations.is_empty() { + return Err(ArtifactRelayError::Concurrency("configuration change")); + } + *self = Self::new(configuration); + Ok(()) + } + + fn prepare_period(&mut self, now_epoch_seconds: u64) { + let period = now_epoch_seconds / self.configuration.period_seconds.max(1); + if self.period != period { + self.period = period; + self.project_used.clear(); + self.tenant_used.clear(); + self.account_used.clear(); + self.global_used = 0; + self.ingress_used = 0; + self.egress_used = 0; + self.abandoned_or_failed_used = 0; + } + } + + pub(super) fn estimated_wire_bytes(&self, artifact_bytes: u64, chunk_bytes: u64) -> u64 { + let encoded = artifact_bytes + .saturating_add(2) + .saturating_div(3) + .saturating_mul(4); + let chunks = artifact_bytes + .saturating_add(chunk_bytes.saturating_sub(1)) + .saturating_div(chunk_bytes.max(1)) + .max(1); + encoded.saturating_mul(2).saturating_add( + chunks + .saturating_mul(2) + .saturating_mul(self.configuration.framing_overhead_bytes), + ) + } + + pub(super) fn framing_overhead_bytes(&self) -> u64 { + self.configuration.framing_overhead_bytes + } + + pub(super) fn reserve( + &mut self, + key: String, + tenant: TenantId, + project: ProjectId, + account: UserId, + artifact_bytes: u64, + chunk_bytes: u64, + expires_at_epoch_seconds: u64, + now_epoch_seconds: u64, + ) -> Result<(), ArtifactRelayError> { + self.expire(now_epoch_seconds); + self.prepare_period(now_epoch_seconds); + if !self.configuration.enabled { + return Err(ArtifactRelayError::Disabled); + } + if artifact_bytes > self.configuration.max_artifact_bytes { + return Err(ArtifactRelayError::ArtifactTooLarge); + } + let scope = RelayScope { + tenant, + project, + account, + }; + self.check_concurrency(&scope)?; + self.check_tracked_scope_capacity(&scope)?; + let reserved = self.estimated_wire_bytes(artifact_bytes, chunk_bytes); + self.check_budget(&scope, reserved)?; + self.reservations.insert( + key, + RelayReservation { + scope, + remaining_reserved_bytes: reserved, + ingress_bytes: 0, + egress_bytes: 0, + expires_at_epoch_seconds, + }, + ); + Ok(()) + } + + pub(super) fn rekey(&mut self, from: &str, to: String) -> Result<(), ArtifactRelayError> { + let reservation = self + .reservations + .remove(from) + .ok_or(ArtifactRelayError::MissingReservation)?; + self.reservations.insert(to, reservation); + Ok(()) + } + + fn check_concurrency(&self, scope: &RelayScope) -> Result<(), ArtifactRelayError> { + if self.reservations.len() >= self.configuration.max_active_global { + return Err(ArtifactRelayError::Concurrency("global")); + } + let project = self + .reservations + .values() + .filter(|reservation| { + reservation.scope.tenant == scope.tenant + && reservation.scope.project == scope.project + }) + .count(); + if project >= self.configuration.max_active_per_project { + return Err(ArtifactRelayError::Concurrency("project")); + } + let tenant = self + .reservations + .values() + .filter(|reservation| reservation.scope.tenant == scope.tenant) + .count(); + if tenant >= self.configuration.max_active_per_tenant { + return Err(ArtifactRelayError::Concurrency("tenant")); + } + let account = self + .reservations + .values() + .filter(|reservation| { + reservation.scope.tenant == scope.tenant + && reservation.scope.account == scope.account + }) + .count(); + if account >= self.configuration.max_active_per_account { + return Err(ArtifactRelayError::Concurrency("account")); + } + Ok(()) + } + + fn check_tracked_scope_capacity(&self, scope: &RelayScope) -> Result<(), ArtifactRelayError> { + let project_key = (scope.tenant.clone(), scope.project.clone()); + let account_key = (scope.tenant.clone(), scope.account.clone()); + let new_scopes = usize::from(!self.project_used.contains_key(&project_key)) + + usize::from(!self.tenant_used.contains_key(&scope.tenant)) + + usize::from(!self.account_used.contains_key(&account_key)); + let tracked = self.project_used.len() + self.tenant_used.len() + self.account_used.len(); + if tracked.saturating_add(new_scopes) > self.configuration.max_tracked_scopes { + return Err(ArtifactRelayError::StateLimit); + } + Ok(()) + } + + fn reserved_for(&self, scope: &RelayScope) -> (u64, u64, u64, u64) { + let mut project = 0_u64; + let mut tenant = 0_u64; + let mut account = 0_u64; + let mut global = 0_u64; + for reservation in self.reservations.values() { + let bytes = reservation.remaining_reserved_bytes; + global = global.saturating_add(bytes); + if reservation.scope.tenant == scope.tenant { + tenant = tenant.saturating_add(bytes); + if reservation.scope.project == scope.project { + project = project.saturating_add(bytes); + } + if reservation.scope.account == scope.account { + account = account.saturating_add(bytes); + } + } + } + (project, tenant, account, global) + } + + fn check_budget(&self, scope: &RelayScope, additional: u64) -> Result<(), ArtifactRelayError> { + let (project_reserved, tenant_reserved, account_reserved, global_reserved) = + self.reserved_for(scope); + let project_used = self + .project_used + .get(&(scope.tenant.clone(), scope.project.clone())) + .copied() + .unwrap_or(0); + let tenant_used = self.tenant_used.get(&scope.tenant).copied().unwrap_or(0); + let account_used = self + .account_used + .get(&(scope.tenant.clone(), scope.account.clone())) + .copied() + .unwrap_or(0); + for (used, reserved, limit, label) in [ + ( + project_used, + project_reserved, + self.configuration.max_period_bytes_per_project, + "project", + ), + ( + tenant_used, + tenant_reserved, + self.configuration.max_period_bytes_per_tenant, + "tenant", + ), + ( + account_used, + account_reserved, + self.configuration.max_period_bytes_per_account, + "account", + ), + ( + self.global_used, + global_reserved, + self.configuration.max_period_bytes_global, + "global", + ), + ] { + if used.saturating_add(reserved).saturating_add(additional) > limit { + return Err(ArtifactRelayError::PeriodBudget(label)); + } + } + Ok(()) + } + + fn charge( + &mut self, + key: &str, + bytes: u64, + ingress: bool, + now_epoch_seconds: u64, + ) -> Result<(), ArtifactRelayError> { + self.prepare_period(now_epoch_seconds); + let reservation = self + .reservations + .get(key) + .cloned() + .ok_or(ArtifactRelayError::MissingReservation)?; + let additional = bytes.saturating_sub(reservation.remaining_reserved_bytes); + if additional > 0 { + self.check_budget(&reservation.scope, additional)?; + } + let reservation = self + .reservations + .get_mut(key) + .ok_or(ArtifactRelayError::MissingReservation)?; + reservation.remaining_reserved_bytes = + reservation.remaining_reserved_bytes.saturating_sub(bytes); + if ingress { + reservation.ingress_bytes = reservation.ingress_bytes.saturating_add(bytes); + self.ingress_used = self.ingress_used.saturating_add(bytes); + } else { + reservation.egress_bytes = reservation.egress_bytes.saturating_add(bytes); + self.egress_used = self.egress_used.saturating_add(bytes); + } + let project_key = ( + reservation.scope.tenant.clone(), + reservation.scope.project.clone(), + ); + let account_key = ( + reservation.scope.tenant.clone(), + reservation.scope.account.clone(), + ); + *self.project_used.entry(project_key).or_default() = self + .project_used + .get(&( + reservation.scope.tenant.clone(), + reservation.scope.project.clone(), + )) + .copied() + .unwrap_or(0) + .saturating_add(bytes); + *self + .tenant_used + .entry(reservation.scope.tenant.clone()) + .or_default() = self + .tenant_used + .get(&reservation.scope.tenant) + .copied() + .unwrap_or(0) + .saturating_add(bytes); + *self.account_used.entry(account_key).or_default() = self + .account_used + .get(&( + reservation.scope.tenant.clone(), + reservation.scope.account.clone(), + )) + .copied() + .unwrap_or(0) + .saturating_add(bytes); + self.global_used = self.global_used.saturating_add(bytes); + Ok(()) + } + + pub(super) fn charge_ingress( + &mut self, + key: &str, + bytes: u64, + now_epoch_seconds: u64, + ) -> Result<(), ArtifactRelayError> { + self.charge(key, bytes, true, now_epoch_seconds) + } + + pub(super) fn charge_egress( + &mut self, + key: &str, + bytes: u64, + now_epoch_seconds: u64, + ) -> Result<(), ArtifactRelayError> { + self.charge(key, bytes, false, now_epoch_seconds) + } + + pub(super) fn finish(&mut self, key: &str, reason: RelayFinishReason) { + if let Some(reservation) = self.reservations.remove(key) { + if reason != RelayFinishReason::Completed { + self.abandoned_or_failed_used = self + .abandoned_or_failed_used + .saturating_add(reservation.ingress_bytes) + .saturating_add(reservation.egress_bytes); + } + } + } + + pub(super) fn expire(&mut self, now_epoch_seconds: u64) { + let expired = self + .reservations + .iter() + .filter(|(_, reservation)| reservation.expires_at_epoch_seconds < now_epoch_seconds) + .map(|(key, _)| key.clone()) + .collect::>(); + for key in expired { + self.finish(&key, RelayFinishReason::Expired); + } + } + + pub(super) fn usage(&self) -> ArtifactRelayUsage { + ArtifactRelayUsage { + active_transfers: self.reservations.len(), + ingress_bytes: self.ingress_used, + egress_bytes: self.egress_used, + abandoned_or_failed_bytes: self.abandoned_or_failed_used, + reserved_bytes: self + .reservations + .values() + .map(|reservation| reservation.remaining_reserved_bytes) + .fold(0_u64, u64::saturating_add), + } + } +} diff --git a/crates/disasmer-coordinator/src/service/routing.rs b/crates/clusterflux-coordinator/src/service/routing.rs similarity index 95% rename from crates/disasmer-coordinator/src/service/routing.rs rename to crates/clusterflux-coordinator/src/service/routing.rs index 52da137..ca5da0c 100644 --- a/crates/disasmer-coordinator/src/service/routing.rs +++ b/crates/clusterflux-coordinator/src/service/routing.rs @@ -11,7 +11,8 @@ impl CoordinatorService { "failed to canonicalize coordinator request for authentication: {error}" )) })?; - let request_payload_digest = disasmer_core::signed_request_payload_digest(&request_payload); + let request_payload_digest = + clusterflux_core::signed_request_payload_digest(&request_payload); match request { CoordinatorRequest::Ping => Ok(CoordinatorResponse::Pong { epoch: self.coordinator.coordinator_epoch(), @@ -143,7 +144,7 @@ impl CoordinatorService { CoordinatorRequest::ListProjects { tenant, actor_user } => { let tenant = TenantId::new(tenant); let actor = UserId::new(actor_user); - let context = disasmer_core::AuthContext { + let context = clusterflux_core::AuthContext { tenant: tenant.clone(), project: ProjectId::from("__project_listing__"), actor: Actor::User(actor.clone()), @@ -209,7 +210,7 @@ impl CoordinatorService { let tenant = TenantId::new(tenant); let project = ProjectId::new(project); let actor = UserId::new(user); - let context = disasmer_core::AuthContext { + let context = clusterflux_core::AuthContext { tenant, project, actor: Actor::User(actor.clone()), @@ -229,7 +230,7 @@ impl CoordinatorService { let project = ProjectId::new(project); let actor = UserId::new(user); let agent = AgentId::new(agent); - let context = disasmer_core::AuthContext { + let context = clusterflux_core::AuthContext { tenant, project, actor: Actor::User(actor.clone()), @@ -426,6 +427,16 @@ impl CoordinatorService { task, replacement_bundle, ), + CoordinatorRequest::ResolveTaskFailure { + tenant, + project, + actor_user, + process, + task, + resolution, + } => self.handle_resolve_task_failure( + tenant, project, actor_user, process, task, resolution, + ), request @ (CoordinatorRequest::DebugAttach { .. } | CoordinatorRequest::SetDebugBreakpoints { .. } | CoordinatorRequest::InspectDebugBreakpoints { .. } @@ -444,6 +455,12 @@ impl CoordinatorService { actor_user, process, } => self.handle_list_task_events(tenant, project, actor_user, process), + CoordinatorRequest::ListTaskSnapshots { + tenant, + project, + actor_user, + process, + } => self.handle_list_task_snapshots(tenant, project, actor_user, process), CoordinatorRequest::JoinTask { tenant, project, @@ -708,6 +725,18 @@ impl CoordinatorService { task, replacement_bundle, ), + AuthenticatedCoordinatorRequest::ResolveTaskFailure { + process, + task, + resolution, + } => self.handle_resolve_task_failure( + context.tenant.as_str().to_owned(), + context.project.as_str().to_owned(), + actor.as_str().to_owned(), + process, + task, + resolution, + ), request @ (AuthenticatedCoordinatorRequest::DebugAttach { .. } | AuthenticatedCoordinatorRequest::SetDebugBreakpoints { .. } | AuthenticatedCoordinatorRequest::InspectDebugBreakpoints { .. } @@ -727,6 +756,13 @@ impl CoordinatorService { actor.as_str().to_owned(), process, ), + AuthenticatedCoordinatorRequest::ListTaskSnapshots { process } => self + .handle_list_task_snapshots( + context.tenant.as_str().to_owned(), + context.project.as_str().to_owned(), + actor.as_str().to_owned(), + process, + ), AuthenticatedCoordinatorRequest::JoinTask { process, task } => self.handle_join_task( context.tenant.as_str().to_owned(), context.project.as_str().to_owned(), diff --git a/crates/disasmer-coordinator/src/service/signed_nodes.rs b/crates/clusterflux-coordinator/src/service/signed_nodes.rs similarity index 98% rename from crates/disasmer-coordinator/src/service/signed_nodes.rs rename to crates/clusterflux-coordinator/src/service/signed_nodes.rs index c81f91b..e6e3878 100644 --- a/crates/disasmer-coordinator/src/service/signed_nodes.rs +++ b/crates/clusterflux-coordinator/src/service/signed_nodes.rs @@ -1,4 +1,4 @@ -use disasmer_core::{NodeId, NodeSignedRequest}; +use clusterflux_core::{NodeId, NodeSignedRequest}; use crate::CoordinatorError; @@ -18,7 +18,7 @@ impl CoordinatorService { "failed to canonicalize signed node request: {error}" )) })?; - let payload_digest = disasmer_core::signed_request_payload_digest(&request_payload); + let payload_digest = clusterflux_core::signed_request_payload_digest(&request_payload); let signed_node = NodeId::new(signed_node); if request_node != signed_node { return Err(CoordinatorError::Unauthorized( diff --git a/crates/disasmer-coordinator/src/service/tcp.rs b/crates/clusterflux-coordinator/src/service/tcp.rs similarity index 100% rename from crates/disasmer-coordinator/src/service/tcp.rs rename to crates/clusterflux-coordinator/src/service/tcp.rs diff --git a/crates/disasmer-coordinator/src/service/tests.rs b/crates/clusterflux-coordinator/src/service/tests.rs similarity index 97% rename from crates/disasmer-coordinator/src/service/tests.rs rename to crates/clusterflux-coordinator/src/service/tests.rs index 9c226a3..8b8c77f 100644 --- a/crates/disasmer-coordinator/src/service/tests.rs +++ b/crates/clusterflux-coordinator/src/service/tests.rs @@ -5,7 +5,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; -use disasmer_core::{ +use clusterflux_core::{ admin_request_proof, agent_ed25519_public_key_from_private_key, coordinator_wire_request, derive_ed25519_private_key_from_seed, node_ed25519_public_key_from_private_key, sign_agent_workflow_request, sign_node_request, signed_request_payload_digest, @@ -64,7 +64,7 @@ fn runtime_service_uses_memory_only_when_database_url_is_absent() { #[test] fn postgres_backed_runtime_service_survives_restart_without_live_process_state() { - let Ok(database_url) = std::env::var("DISASMER_TEST_POSTGRES_SERVICE") else { + let Ok(database_url) = std::env::var("CLUSTERFLUX_TEST_POSTGRES_SERVICE") else { return; }; let mut clean_store = crate::PostgresDurableStore::connect(&database_url).unwrap(); @@ -202,7 +202,7 @@ fn assert_agent_workflow_actor(actor: &WorkflowActor, fingerprint: &Digest) { const TEST_AGENT_PRIVATE_KEY: &str = "ed25519:BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc="; static TEST_NODE_REQUEST_NONCE: AtomicU64 = AtomicU64::new(1); -const TEST_WASM_MODULE: &[u8] = b"disasmer-test-wasm-module"; +const TEST_WASM_MODULE: &[u8] = b"clusterflux-test-wasm-module"; fn test_wasm_module_base64() -> String { BASE64_STANDARD.encode(TEST_WASM_MODULE) @@ -243,13 +243,13 @@ fn test_edited_task_bundle(compatibility: &Digest, edit_marker: &str) -> (String "result_schema": "u32", "required_capabilities": [], "restart_compatibility_hash": compatibility, - "abi_version": disasmer_core::WASM_TASK_ABI_VERSION, - "probe_symbol": "disasmer.probe.compile", + "abi_version": clusterflux_core::WASM_TASK_ABI_VERSION, + "probe_symbol": "clusterflux.probe.compile", })) .unwrap(); let mut module = b"\0asm\x01\0\0\0".to_vec(); - append_test_custom_section(&mut module, "disasmer.tasks", &descriptor); - append_test_custom_section(&mut module, "disasmer.edit", edit_marker.as_bytes()); + append_test_custom_section(&mut module, "clusterflux.tasks", &descriptor); + append_test_custom_section(&mut module, "clusterflux.edit", edit_marker.as_bytes()); let digest = Digest::sha256(&module); (BASE64_STANDARD.encode(module), digest) } @@ -286,8 +286,8 @@ fn test_task_spec_instance( tenant: TenantId::from(tenant), project: ProjectId::from(project), process: ProcessId::from(process), - task_definition: disasmer_core::TaskDefinitionId::from(task_definition), - task_instance: disasmer_core::TaskInstanceId::from(task_instance), + task_definition: clusterflux_core::TaskDefinitionId::from(task_definition), + task_instance: clusterflux_core::TaskInstanceId::from(task_instance), dispatch: TaskDispatch::CoordinatorNodeWasm { export: Some(task_definition.to_owned()), abi: WasmExportAbi::TaskV1, @@ -301,6 +301,7 @@ fn test_task_spec_instance( required_artifacts: Vec::new(), args: Vec::new(), vfs_epoch: epoch, + failure_policy: Default::default(), bundle_digest: Some(Digest::sha256(TEST_WASM_MODULE)), } } @@ -2640,10 +2641,10 @@ fn debug_epoch_commands_are_polled_by_signed_active_task_nodes() { &ProcessId::from("process"), ), super::main_runtime::CoordinatorMainControl { - task_definition: disasmer_core::TaskDefinitionId::from("completed-main"), + task_definition: clusterflux_core::TaskDefinitionId::from("completed-main"), task_instance: TaskInstanceId::from("ti:process:main"), abort: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), - debug: std::sync::Arc::new(disasmer_wasm_runtime::WasmDebugControl::default()), + debug: std::sync::Arc::new(clusterflux_wasm_runtime::WasmDebugControl::default()), state: "completed".to_owned(), stopped_probe_symbol: None, handles: std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())), @@ -2660,13 +2661,13 @@ fn debug_epoch_commands_are_polled_by_signed_active_task_nodes() { project: "project".to_owned(), actor_user: "user".to_owned(), process: "process".to_owned(), - probe_symbols: vec!["disasmer.probe.compile_linux".to_owned()], + probe_symbols: vec!["clusterflux.probe.compile_linux".to_owned()], }) .unwrap() else { panic!("expected debug breakpoints response"); }; - assert_eq!(probe_symbols, ["disasmer.probe.compile_linux"]); + assert_eq!(probe_symbols, ["clusterflux.probe.compile_linux"]); assert_eq!(hit_epoch, None); let CoordinatorResponse::DebugProbeHit { @@ -2681,7 +2682,7 @@ fn debug_epoch_commands_are_polled_by_signed_active_task_nodes() { process: "process".to_owned(), node: "worker".to_owned(), task: "compile-linux".to_owned(), - probe_symbol: "disasmer.probe.compile_linux".to_owned(), + probe_symbol: "clusterflux.probe.compile_linux".to_owned(), }) .unwrap() else { @@ -2689,7 +2690,7 @@ fn debug_epoch_commands_are_polled_by_signed_active_task_nodes() { }; assert!(breakpoint_matched); assert_eq!(debug_epoch, Some(1)); - assert_eq!(probe_symbol, "disasmer.probe.compile_linux"); + assert_eq!(probe_symbol, "clusterflux.probe.compile_linux"); let CoordinatorResponse::DebugBreakpoints { hit_epoch, @@ -2711,7 +2712,7 @@ fn debug_epoch_commands_are_polled_by_signed_active_task_nodes() { assert_eq!(hit_task, Some(TaskInstanceId::from("compile-linux"))); assert_eq!( hit_probe_symbol.as_deref(), - Some("disasmer.probe.compile_linux") + Some("clusterflux.probe.compile_linux") ); let CoordinatorResponse::DebugCommand { @@ -2780,7 +2781,7 @@ fn debug_epoch_commands_are_polled_by_signed_active_task_nodes() { .unwrap_err(); assert!(early_resume .to_string() - .contains("have not acknowledged frozen state")); + .contains("no settled frozen participant set")); service .handle_signed_node_request_auto(CoordinatorRequest::ReportDebugState { @@ -3111,6 +3112,7 @@ fn service_reports_task_restart_boundary_through_public_api() { completed_event_observed, requires_whole_process_restart, restarted_task_instance, + restarted_attempt_id, message, audit_event, used_debug_read_bytes, @@ -3133,7 +3135,9 @@ fn service_reports_task_restart_boundary_through_public_api() { assert!(!active_task); assert!(completed_event_observed); assert!(!requires_whole_process_restart); - let restarted_task_instance = restarted_task_instance.expect("restart must return new id"); + let restarted_task_instance = restarted_task_instance.expect("restart returns logical id"); + let restarted_attempt_id = restarted_attempt_id.expect("restart returns a new attempt id"); + assert!(restarted_attempt_id.starts_with("ta_")); assert!(message.contains("from clean VFS entry boundary epoch 7")); let CoordinatorResponse::TaskAssignment { assignment: Some(restarted_assignment), @@ -3148,7 +3152,7 @@ fn service_reports_task_restart_boundary_through_public_api() { panic!("expected restarted task assignment"); }; assert_eq!(restarted_assignment.task, restarted_task_instance); - assert_ne!(restarted_assignment.task, TaskInstanceId::from("task")); + assert_eq!(restarted_assignment.task, TaskInstanceId::from("task")); let mut expected_task_spec = initial_assignment.task_spec.clone(); expected_task_spec.task_instance = restarted_task_instance; assert_eq!(restarted_assignment.task_spec, expected_task_spec); @@ -3404,10 +3408,10 @@ fn service_rejects_second_active_process_unless_restarting_same_process() { service.main_runtime.controls.insert( process_key.clone(), super::main_runtime::CoordinatorMainControl { - task_definition: disasmer_core::TaskDefinitionId::from("build"), + task_definition: clusterflux_core::TaskDefinitionId::from("build"), task_instance: TaskInstanceId::from("ti:process-a:main"), abort: std::sync::Arc::clone(&retired_main_abort), - debug: std::sync::Arc::new(disasmer_wasm_runtime::WasmDebugControl::default()), + debug: std::sync::Arc::new(clusterflux_wasm_runtime::WasmDebugControl::default()), state: "running".to_owned(), stopped_probe_symbol: None, handles: std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())), @@ -3448,7 +3452,7 @@ fn service_rejects_second_active_process_unless_restarting_same_process() { } #[test] -fn cooperative_cancel_keeps_process_identity_until_explicit_abort_releases_slot() { +fn quiescent_cooperative_cancel_releases_slot_immediately() { let mut service = CoordinatorService::new(17); service .handle_request(CoordinatorRequest::StartProcess { @@ -3468,8 +3472,9 @@ fn cooperative_cancel_keeps_process_identity_until_explicit_abort_releases_slot( process: ProcessId::from("process-a"), node: NodeId::from("node"), executor: TaskExecutor::Node, - task_definition: disasmer_core::TaskDefinitionId::from("old-task"), + task_definition: clusterflux_core::TaskDefinitionId::from("old-task"), task: TaskInstanceId::from("ti:process-a:old"), + attempt_id: None, placement: None, terminal_state: TaskTerminalState::Completed, status_code: Some(0), @@ -3506,49 +3511,6 @@ fn cooperative_cancel_keeps_process_identity_until_explicit_abort_releases_slot( }) .unwrap(); - let CoordinatorResponse::ProcessStatuses { processes, .. } = service - .handle_request(CoordinatorRequest::ListProcesses { - tenant: "tenant".to_owned(), - project: "project".to_owned(), - actor_user: "user".to_owned(), - }) - .unwrap() - else { - panic!("expected live process statuses"); - }; - assert_eq!(processes.len(), 1); - assert_eq!(processes[0].process, ProcessId::from("process-a")); - assert_eq!(processes[0].state, "cancelling"); - - let blocked = service - .handle_request(CoordinatorRequest::StartProcess { - tenant: "tenant".to_owned(), - project: "project".to_owned(), - actor_user: None, - actor_agent: None, - agent_public_key_fingerprint: None, - agent_signature: None, - process: "process-b".to_owned(), - restart: false, - }) - .unwrap_err(); - assert!(blocked - .to_string() - .contains("already has active virtual process process-a")); - - let CoordinatorResponse::ProcessAborted { process, .. } = service - .handle_request(CoordinatorRequest::AbortProcess { - tenant: "tenant".to_owned(), - project: "project".to_owned(), - actor_user: "user".to_owned(), - process: "process-a".to_owned(), - }) - .unwrap() - else { - panic!("expected forced process abort"); - }; - assert_eq!(process, ProcessId::from("process-a")); - let CoordinatorResponse::ProcessStatuses { processes, .. } = service .handle_request(CoordinatorRequest::ListProcesses { tenant: "tenant".to_owned(), @@ -3674,7 +3636,7 @@ fn aborted_process_accepts_signed_terminal_event_for_issued_task() { ); assert_eq!( service.task_events[0].task_definition, - disasmer_core::TaskDefinitionId::from("compile") + clusterflux_core::TaskDefinitionId::from("compile") ); assert!(service.task_restart_checkpoints.is_empty()); } @@ -3987,20 +3949,6 @@ fn service_download_links_are_scoped_and_streaming_is_metered() { .unwrap_err(); assert!(cross_actor_open.to_string().contains("token is invalid")); - service.set_server_time(71); - let expired = service - .handle_request(CoordinatorRequest::OpenArtifactDownloadStream { - tenant: "tenant".to_owned(), - project: "project".to_owned(), - actor_user: "user".to_owned(), - artifact: "app.txt".to_owned(), - max_bytes: 64, - token_digest: link.scoped_token_digest.clone(), - chunk_bytes: 1, - }) - .unwrap_err(); - assert!(expired.to_string().contains("expired")); - service.set_server_time(11); upload_pending_artifact_transfer(&mut service, "node", artifact_bytes); let CoordinatorResponse::ArtifactDownloadStream { @@ -4032,6 +3980,36 @@ fn service_download_links_are_scoped_and_streaming_is_metered() { ); assert_eq!(content_source, "retaining_node_reverse_stream"); + let CoordinatorResponse::ArtifactDownloadLink { + link: expiring_link, + } = service + .handle_request(CoordinatorRequest::CreateArtifactDownloadLink { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + artifact: "app.txt".to_owned(), + max_bytes: 64, + ttl_seconds: 1, + }) + .unwrap() + else { + panic!("expected expiring download link"); + }; + service.set_server_time(13); + let expired = service + .handle_request(CoordinatorRequest::OpenArtifactDownloadStream { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + artifact: "app.txt".to_owned(), + max_bytes: 64, + token_digest: expiring_link.scoped_token_digest, + chunk_bytes: 1, + }) + .unwrap_err(); + assert!(expired.to_string().contains("expired")); + service.set_server_time(11); + let cross_actor_revoke = service .handle_request(CoordinatorRequest::RevokeArtifactDownloadLink { tenant: "tenant".to_owned(), @@ -4732,8 +4710,8 @@ fn coordinator_side_task_launch_queues_worker_assignment() { tenant: TenantId::from("tenant"), project: ProjectId::from("project"), process: ProcessId::from("vp-control"), - task_definition: disasmer_core::TaskDefinitionId::from("compile-linux"), - task_instance: disasmer_core::TaskInstanceId::from("compile-linux-1"), + task_definition: clusterflux_core::TaskDefinitionId::from("compile-linux"), + task_instance: clusterflux_core::TaskInstanceId::from("compile-linux-1"), dispatch: TaskDispatch::CoordinatorNodeWasm { export: Some("compile-linux".to_owned()), abi: WasmExportAbi::TaskV1, @@ -4747,6 +4725,7 @@ fn coordinator_side_task_launch_queues_worker_assignment() { required_artifacts: vec![ArtifactId::from("bootstrap-artifact")], args: vec![TaskBoundaryValue::SmallJson(serde_json::json!("test"))], vfs_epoch: epoch, + failure_policy: Default::default(), bundle_digest: Some(Digest::sha256(TEST_WASM_MODULE)), }; @@ -4843,7 +4822,6 @@ fn coordinator_side_task_launch_queues_worker_assignment() { }; assert_eq!(join.state, TaskJoinState::Pending); assert!(!join.remote_completion_observed); - assert!(join.message.contains("waiting for signed node")); let CoordinatorResponse::TaskAssignment { assignment } = service .handle_signed_node_request_auto(CoordinatorRequest::PollTaskAssignment { @@ -5067,11 +5045,11 @@ fn same_definition_instances_join_correctly_when_they_complete_in_reverse_order( assert_eq!(task, TaskInstanceId::from(instance)); assert_eq!( assignment.task_spec.task_definition, - disasmer_core::TaskDefinitionId::from("compile") + clusterflux_core::TaskDefinitionId::from("compile") ); assert_eq!( assignment.task_spec.task_instance, - disasmer_core::TaskInstanceId::from(instance) + clusterflux_core::TaskInstanceId::from(instance) ); } for instance in ["compile-1", "compile-2"] { @@ -5159,7 +5137,7 @@ fn same_definition_instances_join_correctly_when_they_complete_in_reverse_order( }; assert_eq!( join.task_instance, - disasmer_core::TaskInstanceId::from(instance) + clusterflux_core::TaskInstanceId::from(instance) ); assert_eq!( join.result, @@ -5197,6 +5175,7 @@ fn same_definition_instances_join_correctly_when_they_complete_in_reverse_order( let CoordinatorResponse::TaskRestart { accepted, restarted_task_instance, + restarted_attempt_id, .. } = service .handle_request(CoordinatorRequest::RestartTask { @@ -5215,8 +5194,10 @@ fn same_definition_instances_join_correctly_when_they_complete_in_reverse_order( panic!("expected task restart response"); }; assert!(accepted); - let restarted = restarted_task_instance.expect("restart creates a new instance"); - assert_ne!(restarted, TaskInstanceId::from("compile-1")); + let restarted = restarted_task_instance.expect("restart returns the logical instance"); + let restarted_attempt_id = restarted_attempt_id.expect("restart creates a new attempt"); + assert!(restarted_attempt_id.starts_with("ta_")); + assert_eq!(restarted, TaskInstanceId::from("compile-1")); assert_ne!(restarted, TaskInstanceId::from("compile-2")); let CoordinatorResponse::TaskAssignment { assignment: Some(restarted_assignment), @@ -5233,7 +5214,7 @@ fn same_definition_instances_join_correctly_when_they_complete_in_reverse_order( assert_eq!(restarted_assignment.task, restarted); assert_eq!( restarted_assignment.task_spec.task_definition, - disasmer_core::TaskDefinitionId::from("compile") + clusterflux_core::TaskDefinitionId::from("compile") ); assert_eq!( restarted_assignment.task_spec.args, @@ -5432,8 +5413,9 @@ fn long_lived_process_state_reaches_a_scoped_bounded_steady_state() { process: ProcessId::from("vp-other"), node: NodeId::from("worker-other"), executor: TaskExecutor::Node, - task_definition: disasmer_core::TaskDefinitionId::from("other"), + task_definition: clusterflux_core::TaskDefinitionId::from("other"), task: TaskInstanceId::new(format!("other-{index}")), + attempt_id: None, placement: None, terminal_state: TaskTerminalState::Completed, status_code: Some(0), @@ -5490,7 +5472,7 @@ fn long_lived_process_state_reaches_a_scoped_bounded_steady_state() { assert!( super::MAX_NODE_REPLAY_NONCES_PER_AUTHORITY >= super::NODE_SIGNATURE_WINDOW_SECONDS as usize * 2 * 1_000 - / disasmer_core::MIN_SIGNED_NODE_POLL_INTERVAL_MS as usize + / clusterflux_core::MIN_SIGNED_NODE_POLL_INTERVAL_MS as usize + 64, "the bounded node replay window must sustain artifact and assignment polls at the protocol minimum with control-message headroom" ); @@ -5616,6 +5598,82 @@ fn signed_active_wasm_task_can_spawn_and_join_child_in_its_process_only() { assert_eq!(join.state, TaskJoinState::Pending); } +#[test] +fn coordinator_rejects_named_environment_without_requirements() { + let mut service = CoordinatorService::new(10); + service + .handle_request(CoordinatorRequest::AttachNode { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "uncached-worker".to_owned(), + public_key: test_node_public_key("uncached-worker"), + }) + .unwrap(); + service + .handle_signed_node_request_auto(CoordinatorRequest::ReportNodeCapabilities { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "uncached-worker".to_owned(), + capabilities: linux_capabilities(), + cached_environment_digests: Vec::new(), + dependency_cache_digests: Vec::new(), + source_snapshots: Vec::new(), + artifact_locations: Vec::new(), + direct_connectivity: true, + online: true, + }) + .unwrap(); + let CoordinatorResponse::ProcessStarted { epoch, .. } = service + .handle_request(CoordinatorRequest::StartProcess { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: None, + actor_agent: None, + agent_public_key_fingerprint: None, + agent_signature: None, + process: "vp-environment".to_owned(), + restart: false, + }) + .unwrap() + else { + panic!("expected process start"); + }; + service + .handle_signed_node_request_auto(CoordinatorRequest::ReconnectNode { + node: "uncached-worker".to_owned(), + process: "vp-environment".to_owned(), + epoch, + }) + .unwrap(); + let mut task_spec = test_task_spec( + "tenant", + "project", + "vp-environment", + "compile-linux", + epoch, + [], + ); + task_spec.environment_id = Some("missing-environment".to_owned()); + task_spec.environment_digest = Some(Digest::sha256("missing-environment")); + + let error = service + .handle_request(CoordinatorRequest::LaunchTask { + task_spec, + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: Some("user".to_owned()), + actor_agent: None, + agent_public_key_fingerprint: None, + agent_signature: None, + wait_for_node: false, + artifact_path: "/vfs/artifacts/environment-output.txt".to_owned(), + wasm_module_base64: test_wasm_module_base64(), + }) + .unwrap_err(); + + assert!(error.to_string().contains("named environment cache")); +} + #[test] fn coordinator_side_task_launch_fails_cleanly_without_capable_worker() { let mut service = CoordinatorService::new(10); @@ -5918,6 +5976,29 @@ fn service_rejects_task_completion_outside_node_scope() { assert!(events.is_empty()); } +#[test] +fn service_rejects_task_event_access_using_retained_process_scope() { + let mut service = CoordinatorService::new(1); + service.process_scope_history.push_back(( + TenantId::from("tenant-a"), + ProjectId::from("project-a"), + ProcessId::from("process"), + )); + + let error = service + .handle_request(CoordinatorRequest::ListTaskEvents { + tenant: "tenant-b".to_owned(), + project: "project-b".to_owned(), + actor_user: "user".to_owned(), + process: Some("process".to_owned()), + }) + .unwrap_err(); + + assert!(error + .to_string() + .contains("outside the virtual process tenant/project scope")); +} + #[test] fn service_rejects_node_capability_report_outside_enrollment_scope() { let mut service = CoordinatorService::new(1); diff --git a/crates/disasmer-coordinator/src/service/wire_protocol.rs b/crates/clusterflux-coordinator/src/service/wire_protocol.rs similarity index 95% rename from crates/disasmer-coordinator/src/service/wire_protocol.rs rename to crates/clusterflux-coordinator/src/service/wire_protocol.rs index 2d871fa..4884415 100644 --- a/crates/disasmer-coordinator/src/service/wire_protocol.rs +++ b/crates/clusterflux-coordinator/src/service/wire_protocol.rs @@ -1,4 +1,4 @@ -use disasmer_core::{COORDINATOR_PROTOCOL_VERSION, COORDINATOR_WIRE_REQUEST_TYPE}; +use clusterflux_core::{COORDINATOR_PROTOCOL_VERSION, COORDINATOR_WIRE_REQUEST_TYPE}; use serde::{Deserialize, Serialize}; use serde_json::Value; diff --git a/crates/disasmer-coordinator/src/sessions.rs b/crates/clusterflux-coordinator/src/sessions.rs similarity index 95% rename from crates/disasmer-coordinator/src/sessions.rs rename to crates/clusterflux-coordinator/src/sessions.rs index 097667d..b29e5e1 100644 --- a/crates/disasmer-coordinator/src/sessions.rs +++ b/crates/clusterflux-coordinator/src/sessions.rs @@ -1,6 +1,6 @@ use std::time::{SystemTime, UNIX_EPOCH}; -use disasmer_core::{Actor, AuthContext, CredentialKind, Digest, ProjectId, TenantId, UserId}; +use clusterflux_core::{Actor, AuthContext, CredentialKind, Digest, ProjectId, TenantId, UserId}; use crate::{CliSessionRecord, Coordinator, CoordinatorError, CredentialRecord}; @@ -83,7 +83,8 @@ impl Coordinator { .is_some_and(|expires_at| expires_at <= now_epoch_seconds) { return Err(CoordinatorError::Unauthorized( - "CLI session credential has expired; run disasmer login --browser again".to_owned(), + "CLI session credential has expired; run clusterflux login --browser again" + .to_owned(), )); } if record.credential_kind != CredentialKind::CliDeviceSession { diff --git a/crates/disasmer-core/Cargo.toml b/crates/clusterflux-core/Cargo.toml similarity index 94% rename from crates/disasmer-core/Cargo.toml rename to crates/clusterflux-core/Cargo.toml index 4dbbfa8..ffad4a3 100644 --- a/crates/disasmer-core/Cargo.toml +++ b/crates/clusterflux-core/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "disasmer-core" +name = "clusterflux-core" version = "0.1.0" edition.workspace = true license.workspace = true diff --git a/crates/disasmer-core/src/artifact.rs b/crates/clusterflux-core/src/artifact.rs similarity index 100% rename from crates/disasmer-core/src/artifact.rs rename to crates/clusterflux-core/src/artifact.rs diff --git a/crates/disasmer-core/src/auth.rs b/crates/clusterflux-core/src/auth.rs similarity index 85% rename from crates/disasmer-core/src/auth.rs rename to crates/clusterflux-core/src/auth.rs index 33bb2b1..61e99fb 100644 --- a/crates/disasmer-core/src/auth.rs +++ b/crates/clusterflux-core/src/auth.rs @@ -40,7 +40,7 @@ pub fn admin_request_proof_from_token_digest( ) -> Digest { let issued_at_epoch_seconds = issued_at_epoch_seconds.to_string(); Digest::from_parts([ - b"disasmer-admin-request-proof:v1".as_slice(), + b"clusterflux-admin-request-proof:v1".as_slice(), admin_token_digest.as_str().as_bytes(), operation.as_bytes(), tenant.as_bytes(), @@ -272,6 +272,115 @@ pub struct AgentWorkflowScope<'a> { pub task: Option<&'a TaskInstanceId>, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AgentWorkflowRequestScope { + pub tenant: TenantId, + pub project: ProjectId, + pub request_kind: String, + pub process: ProcessId, + pub task: Option, +} + +impl AgentWorkflowRequestScope { + pub fn new( + tenant: TenantId, + project: ProjectId, + request_kind: impl Into, + process: ProcessId, + task: Option, + ) -> Result { + let request_kind = request_kind.into(); + match request_kind.as_str() { + "start_process" if task.is_some() => { + return Err("start_process agent scope must not contain a task instance".to_owned()) + } + "launch_task" if task.is_none() => { + return Err("launch_task agent scope requires a task instance".to_owned()) + } + "start_process" | "launch_task" => {} + _ => { + return Err(format!( + "request kind `{request_kind}` is not an agent workflow operation" + )) + } + } + Ok(Self { + tenant, + project, + request_kind, + process, + task, + }) + } + + pub fn for_agent<'a>(&'a self, agent: &'a AgentId) -> AgentWorkflowScope<'a> { + AgentWorkflowScope { + tenant: &self.tenant, + project: &self.project, + agent, + request_kind: &self.request_kind, + process: &self.process, + task: self.task.as_ref(), + } + } +} + +pub fn agent_workflow_request_scope_from_payload( + payload: &Value, +) -> Result { + let object = payload + .as_object() + .ok_or_else(|| "agent workflow request payload must be an object".to_owned())?; + let request_kind = object + .get("type") + .and_then(Value::as_str) + .ok_or_else(|| "agent workflow request type is missing".to_owned())?; + let tenant = object + .get("tenant") + .and_then(Value::as_str) + .ok_or_else(|| "agent workflow tenant is missing".to_owned())?; + let project = object + .get("project") + .and_then(Value::as_str) + .ok_or_else(|| "agent workflow project is missing".to_owned())?; + let (process, task) = match request_kind { + "start_process" => ( + object + .get("process") + .and_then(Value::as_str) + .ok_or_else(|| "start_process agent scope is missing process".to_owned())?, + None, + ), + "launch_task" => { + let task_spec = object + .get("task_spec") + .and_then(Value::as_object) + .ok_or_else(|| "launch_task agent scope is missing task_spec".to_owned())?; + let process = task_spec + .get("process") + .and_then(Value::as_str) + .ok_or_else(|| "launch_task agent scope is missing process".to_owned())?; + let task = task_spec + .get("task_instance") + .and_then(Value::as_str) + .ok_or_else(|| "launch_task agent scope is missing task_instance".to_owned())?; + (process, Some(TaskInstanceId::from(task))) + } + _ => { + return Err(format!( + "request kind `{request_kind}` is not an agent workflow operation" + )) + } + }; + AgentWorkflowRequestScope::new( + TenantId::from(tenant), + ProjectId::from(project), + request_kind, + ProcessId::from(process), + task, + ) +} + pub fn sign_agent_workflow_request( private_key: &str, scope: AgentWorkflowScope<'_>, @@ -402,7 +511,7 @@ fn agent_workflow_signature_message( let issued_at = issued_at_epoch_seconds.to_string(); let task = scope.task.map(TaskInstanceId::as_str).unwrap_or(""); let parts = [ - "disasmer-agent-workflow-signature:v2", + "clusterflux-agent-workflow-signature:v2", scope.tenant.as_str(), scope.project.as_str(), scope.agent.as_str(), @@ -432,7 +541,7 @@ fn node_request_signature_message( ) -> Vec { let issued_at = issued_at_epoch_seconds.to_string(); let parts = [ - "disasmer-node-request-signature:v2", + "clusterflux-node-request-signature:v2", node.as_str(), request_kind, payload_digest.as_str(), diff --git a/crates/disasmer-core/src/bundle.rs b/crates/clusterflux-core/src/bundle.rs similarity index 97% rename from crates/disasmer-core/src/bundle.rs rename to crates/clusterflux-core/src/bundle.rs index 6351c50..0bea366 100644 --- a/crates/disasmer-core/src/bundle.rs +++ b/crates/clusterflux-core/src/bundle.rs @@ -136,7 +136,7 @@ impl BundleIdentityInputs { task_abi: self.task_abi.clone(), entrypoints, default_entrypoint: self.default_entrypoint.clone(), - boundary: "disasmer_task_exports".to_owned(), + boundary: "clusterflux_task_exports".to_owned(), }, source_metadata: BundleSourceMetadata { source_provider_manifest: self.source_provider_manifest.clone(), @@ -202,7 +202,7 @@ pub fn discover_source_debug_probes( return None; }; let function_name = function.sig.ident.to_string(); - let task = disasmer_probe_task(function)?; + let task = clusterflux_probe_task(function)?; let (function_index, (line_index, _)) = function_starts .iter() .enumerate() @@ -261,7 +261,7 @@ fn parse_rust_function_name(line: &str) -> Option { (!name.is_empty()).then_some(name) } -fn disasmer_probe_task(function: &syn::ItemFn) -> Option { +fn clusterflux_probe_task(function: &syn::ItemFn) -> Option { for attribute in &function.attrs { let mut segments = attribute.path().segments.iter(); let Some(namespace) = segments.next() else { @@ -270,7 +270,7 @@ fn disasmer_probe_task(function: &syn::ItemFn) -> Option { let Some(kind) = segments.next() else { continue; }; - if namespace.ident != "disasmer" || segments.next().is_some() { + if namespace.ident != "clusterflux" || segments.next().is_some() { continue; } let function_name = function.sig.ident.to_string(); @@ -425,19 +425,19 @@ mod tests { fn source_debug_probe_metadata_maps_function_ranges_to_tasks() { let probes = discover_source_debug_probes( "src/build.rs", - r#"#[disasmer::main] + r#"#[clusterflux::main] fn build_main() { let linux = compile_linux(); } -#[disasmer::task] +#[clusterflux::task] fn compile_linux() { println!("linux"); } fn helper_without_runtime_probe() {} -#[disasmer::task(name = "release")] +#[clusterflux::task(name = "release")] fn package_release() { println!("package"); } diff --git a/crates/disasmer-core/src/capability.rs b/crates/clusterflux-core/src/capability.rs similarity index 100% rename from crates/disasmer-core/src/capability.rs rename to crates/clusterflux-core/src/capability.rs diff --git a/crates/disasmer-core/src/checkpoint.rs b/crates/clusterflux-core/src/checkpoint.rs similarity index 100% rename from crates/disasmer-core/src/checkpoint.rs rename to crates/clusterflux-core/src/checkpoint.rs diff --git a/crates/disasmer-core/src/debug.rs b/crates/clusterflux-core/src/debug.rs similarity index 73% rename from crates/disasmer-core/src/debug.rs rename to crates/clusterflux-core/src/debug.rs index a5da275..49f808e 100644 --- a/crates/disasmer-core/src/debug.rs +++ b/crates/clusterflux-core/src/debug.rs @@ -62,10 +62,12 @@ pub struct DebugEpoch { #[derive(Clone, Debug, Error, PartialEq, Eq)] pub enum DebugEpochError { - #[error("participant `{task}` cannot freeze, so all-stop failed")] + #[error("no participant could freeze; `{task}` rejected the freeze request")] CannotFreeze { task: TaskInstanceId }, #[error("participant `{0}` is not part of this debug epoch")] UnknownParticipant(TaskInstanceId), + #[error("participant `{0}` did not acknowledge frozen state in this debug epoch")] + ParticipantNotFrozen(TaskInstanceId), } impl DebugEpoch { @@ -75,28 +77,38 @@ impl DebugEpoch { reason: DebugStopReason, participants: Vec, ) -> Result { - for participant in &participants { - if matches!( - participant.kind, - DebugParticipantKind::WasmTask | DebugParticipantKind::ControlledNativeCommand - ) && !participant.can_freeze - { - return Err(DebugEpochError::CannotFreeze { - task: participant.task.clone(), - }); - } - } - - let participants = participants + let first_rejected = participants + .iter() + .find(|participant| { + matches!(participant.state, DebugRuntimeState::Running) && !participant.can_freeze + }) + .map(|participant| participant.task.clone()); + let participants: BTreeMap<_, _> = participants .into_iter() .map(|mut participant| { if matches!(participant.state, DebugRuntimeState::Running) { - participant.state = DebugRuntimeState::Frozen; + participant.state = if participant.can_freeze { + DebugRuntimeState::Frozen + } else { + DebugRuntimeState::Failed( + "participant did not acknowledge frozen state before the debug deadline" + .to_owned(), + ) + }; } (participant.task.clone(), participant) }) .collect(); + if !participants + .values() + .any(|participant| participant.state == DebugRuntimeState::Frozen) + { + return Err(DebugEpochError::CannotFreeze { + task: first_rejected.unwrap_or_else(|| TaskInstanceId::from("debug-epoch")), + }); + } + Ok(Self { process, epoch, @@ -126,6 +138,9 @@ impl DebugEpoch { .participants .get(task) .ok_or_else(|| DebugEpochError::UnknownParticipant(task.clone()))?; + if participant.state != DebugRuntimeState::Frozen { + return Err(DebugEpochError::ParticipantNotFrozen(task.clone())); + } Ok(ThreadInspection { task: participant.task.clone(), name: participant.name.clone(), @@ -150,6 +165,22 @@ impl DebugEpoch { .map(|participant| participant.name.clone()) .collect() } + + pub fn all_threads_stopped(&self) -> bool { + !self.participants.is_empty() + && self + .participants + .values() + .all(|participant| participant.state == DebugRuntimeState::Frozen) + } + + pub fn partially_frozen(&self) -> bool { + !self.all_threads_stopped() + && self + .participants + .values() + .any(|participant| participant.state == DebugRuntimeState::Frozen) + } } #[cfg(test)] @@ -203,7 +234,7 @@ mod tests { } #[test] - fn debug_epoch_reports_freeze_failure_instead_of_claiming_all_stop() { + fn debug_epoch_reports_failure_when_no_participant_can_freeze() { let error = DebugEpoch::pause( ProcessId::from("process"), 1, @@ -218,6 +249,34 @@ mod tests { assert!(matches!(error, DebugEpochError::CannotFreeze { .. })); } + #[test] + fn debug_epoch_keeps_frozen_participants_when_another_participant_fails() { + let epoch = DebugEpoch::pause( + ProcessId::from("process"), + 1, + vec![ + participant("main", DebugParticipantKind::WasmTask, true), + participant( + "native", + DebugParticipantKind::ControlledNativeCommand, + false, + ), + ], + ) + .unwrap(); + + assert!(epoch.partially_frozen()); + assert!(!epoch.all_threads_stopped()); + assert!(matches!( + epoch.participant_state(&TaskInstanceId::from("native")), + Some(DebugRuntimeState::Failed(_)) + )); + assert!(matches!( + epoch.inspection(&TaskInstanceId::from("native")), + Err(DebugEpochError::ParticipantNotFrozen(_)) + )); + } + #[test] fn continue_resumes_every_frozen_participant() { let mut epoch = DebugEpoch::pause( diff --git a/crates/disasmer-core/src/digest.rs b/crates/clusterflux-core/src/digest.rs similarity index 100% rename from crates/disasmer-core/src/digest.rs rename to crates/clusterflux-core/src/digest.rs diff --git a/crates/disasmer-core/src/environment.rs b/crates/clusterflux-core/src/environment.rs similarity index 98% rename from crates/disasmer-core/src/environment.rs rename to crates/clusterflux-core/src/environment.rs index ea2aee5..83b35fb 100644 --- a/crates/disasmer-core/src/environment.rs +++ b/crates/clusterflux-core/src/environment.rs @@ -129,7 +129,7 @@ pub fn diagnose_environment_references( .filter(|reference| !known.contains(reference.name.as_str())) .map(|reference| EnvironmentDiagnostic { message: format!( - "missing Disasmer environment `{}`; expected envs/{}/Containerfile or envs/{}/Dockerfile", + "missing Clusterflux environment `{}`; expected envs/{}/Containerfile or envs/{}/Dockerfile", reference.name, reference.name, reference.name ), reference, diff --git a/crates/disasmer-core/src/execution.rs b/crates/clusterflux-core/src/execution.rs similarity index 72% rename from crates/disasmer-core/src/execution.rs rename to crates/clusterflux-core/src/execution.rs index 0ab9c53..d68e960 100644 --- a/crates/disasmer-core/src/execution.rs +++ b/crates/clusterflux-core/src/execution.rs @@ -78,7 +78,7 @@ pub enum TaskBoundaryValue { pub enum TaskBoundaryHandle { SourceSnapshot(crate::Digest), Blob(crate::Digest), - Artifact(crate::ArtifactId), + Artifact(crate::ArtifactHandle), VfsManifest(crate::Digest), } @@ -89,6 +89,168 @@ pub struct StructuredTaskBoundary { pub handles: Vec, } +const TASK_HANDLE_PLACEHOLDER_KEY: &str = "$task_handle"; + +impl TaskBoundaryHandle { + fn kind(&self) -> &'static str { + match self { + Self::SourceSnapshot(_) => "source_snapshot", + Self::Blob(_) => "blob", + Self::Artifact(_) => "artifact", + Self::VfsManifest(_) => "vfs_manifest", + } + } + + fn validate(&self) -> Result<(), String> { + match self { + Self::SourceSnapshot(digest) | Self::Blob(digest) | Self::VfsManifest(digest) => { + if !digest.is_valid_sha256() { + return Err(format!("{} handle has an invalid digest", self.kind())); + } + } + Self::Artifact(artifact) => { + if artifact.id.as_str().trim().is_empty() || artifact.id.as_str().len() > 256 { + return Err("artifact handle has an invalid ID".to_owned()); + } + if !artifact.digest.is_valid_sha256() { + return Err("artifact handle has an invalid digest".to_owned()); + } + } + } + Ok(()) + } + + fn materialized_value(&self) -> serde_json::Value { + match self { + Self::SourceSnapshot(digest) | Self::Blob(digest) | Self::VfsManifest(digest) => { + serde_json::json!({ "digest": digest }) + } + Self::Artifact(artifact) => serde_json::json!({ + "id": artifact.id, + "digest": artifact.digest, + "size_bytes": artifact.size_bytes, + }), + } + } +} + +impl StructuredTaskBoundary { + pub fn validate(&self) -> Result<(), String> { + if self.handles.len() > 256 { + return Err("structured task argument exceeds 256 handles".to_owned()); + } + for handle in &self.handles { + handle.validate()?; + } + let mut used = vec![false; self.handles.len()]; + validate_task_handle_placeholders(&self.value, &self.handles, &mut used)?; + if let Some(index) = used.iter().position(|used| !used) { + return Err(format!( + "structured task argument contains unused handle-table entry {index}" + )); + } + Ok(()) + } + + pub fn materialize(&self) -> Result { + self.validate()?; + let mut value = self.value.clone(); + materialize_task_handle_placeholders(&mut value, &self.handles)?; + Ok(value) + } +} + +fn validate_task_handle_placeholders( + value: &serde_json::Value, + handles: &[TaskBoundaryHandle], + used: &mut [bool], +) -> Result<(), String> { + match value { + serde_json::Value::Array(values) => { + for value in values { + validate_task_handle_placeholders(value, handles, used)?; + } + } + serde_json::Value::Object(object) => { + if let Some(placeholder) = object.get(TASK_HANDLE_PLACEHOLDER_KEY) { + if object.len() != 1 { + return Err("task handle placeholder must be the only object field".to_owned()); + } + let placeholder = placeholder.as_object().ok_or_else(|| { + "task handle placeholder payload must be an object".to_owned() + })?; + if placeholder.len() != 2 + || !placeholder.contains_key("index") + || !placeholder.contains_key("kind") + { + return Err( + "task handle placeholder must contain exactly index and kind".to_owned(), + ); + } + let index = placeholder["index"] + .as_u64() + .and_then(|index| usize::try_from(index).ok()) + .ok_or_else(|| "task handle placeholder index is invalid".to_owned())?; + let kind = placeholder["kind"] + .as_str() + .ok_or_else(|| "task handle placeholder kind is invalid".to_owned())?; + let handle = handles.get(index).ok_or_else(|| { + format!("task handle placeholder index {index} is out of range") + })?; + if handle.kind() != kind { + return Err(format!( + "task handle placeholder {index} expects kind {kind}, but the table contains {}", + handle.kind() + )); + } + if std::mem::replace(&mut used[index], true) { + return Err(format!( + "task handle placeholder index {index} is used more than once" + )); + } + } else { + for value in object.values() { + validate_task_handle_placeholders(value, handles, used)?; + } + } + } + _ => {} + } + Ok(()) +} + +fn materialize_task_handle_placeholders( + value: &mut serde_json::Value, + handles: &[TaskBoundaryHandle], +) -> Result<(), String> { + match value { + serde_json::Value::Array(values) => { + for value in values { + materialize_task_handle_placeholders(value, handles)?; + } + } + serde_json::Value::Object(object) => { + if let Some(placeholder) = object.get(TASK_HANDLE_PLACEHOLDER_KEY) { + let index = placeholder + .get("index") + .and_then(serde_json::Value::as_u64) + .and_then(|index| usize::try_from(index).ok()) + .ok_or_else(|| "validated task handle placeholder lost its index".to_owned())?; + *value = handles + .get(index) + .ok_or_else(|| "validated task handle placeholder became invalid".to_owned())? + .materialized_value(); + } else { + for value in object.values_mut() { + materialize_task_handle_placeholders(value, handles)?; + } + } + } + _ => {} + } + Ok(()) +} + impl TaskBoundaryValue { pub fn required_artifacts(&self) -> Vec { match self { @@ -97,7 +259,7 @@ impl TaskBoundaryValue { .handles .iter() .filter_map(|handle| match handle { - TaskBoundaryHandle::Artifact(artifact) => Some(artifact.clone()), + TaskBoundaryHandle::Artifact(artifact) => Some(artifact.id.clone()), _ => None, }) .collect(), @@ -131,6 +293,8 @@ pub struct WasmHostTaskStartRequest { pub task_definition: TaskDefinitionId, pub environment_id: Option, pub args: Vec, + #[serde(default)] + pub failure_policy: TaskFailurePolicy, } impl WasmHostTaskStartRequest { @@ -241,8 +405,8 @@ impl WasmHostCommandRequest { fn validate_command_working_directory(path: &str) -> Result<(), String> { let permitted_root = path == "/workspace" || path.starts_with("/workspace/") - || path == "/disasmer/output" - || path.starts_with("/disasmer/output/"); + || path == "/clusterflux/output" + || path.starts_with("/clusterflux/output/"); if !permitted_root || path.len() > 4096 || path.contains('\0') @@ -250,7 +414,7 @@ fn validate_command_working_directory(path: &str) -> Result<(), String> { || path.split('/').any(|component| component == "..") { return Err( - "Wasm command working directory must stay under /workspace or /disasmer/output" + "Wasm command working directory must stay under /workspace or /clusterflux/output" .to_owned(), ); } @@ -470,6 +634,9 @@ impl WasmTaskInvocation { if self.task_instance.as_str().len() > 192 { return Err("Wasm task invocation has an invalid task-instance id".to_owned()); } + for argument in &self.args { + argument.validate()?; + } let encoded = serde_json::to_vec(self).map_err(|error| error.to_string())?; if encoded.len() > MAX_WASM_TASK_ENVELOPE_BYTES { return Err(format!( @@ -542,6 +709,17 @@ impl WasmTaskResult { } impl TaskBoundaryValue { + pub fn validate(&self) -> Result<(), String> { + match self { + Self::SmallJson(_) => Ok(()), + Self::Structured(structured) => structured.validate(), + Self::SourceSnapshot(digest) | Self::Blob(digest) | Self::VfsManifest(digest) => { + TaskBoundaryHandle::SourceSnapshot(digest.clone()).validate() + } + Self::Artifact(artifact) => TaskBoundaryHandle::Artifact(artifact.clone()).validate(), + } + } + pub fn reject_host_only(type_name: &str) -> Result { Err(format!( "task boundary value `{type_name}` is host-only; use small serialized data or handles" @@ -589,9 +767,19 @@ pub struct TaskSpec { pub required_artifacts: Vec, pub args: Vec, pub vfs_epoch: u64, + #[serde(default)] + pub failure_policy: TaskFailurePolicy, pub bundle_digest: Option, } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TaskFailurePolicy { + #[default] + FailFast, + AwaitOperator, +} + impl TaskSpec { pub fn product_mode_uses_remote_dispatch(&self) -> bool { self.dispatch.is_product_remote_dispatch() @@ -683,6 +871,55 @@ mod tests { assert!(error.contains("host-only")); } + #[test] + fn structured_handle_placeholders_are_strict_and_table_authoritative() { + let artifact = ArtifactHandle { + id: ArtifactId::from("real.bin"), + digest: Digest::sha256("real bytes"), + size_bytes: 10, + }; + let boundary = StructuredTaskBoundary { + value: serde_json::json!({ + "label": "release", + "artifact": { "$task_handle": { "index": 0, "kind": "artifact" } } + }), + handles: vec![TaskBoundaryHandle::Artifact(artifact.clone())], + }; + + boundary.validate().unwrap(); + assert_eq!( + boundary.materialize().unwrap()["artifact"]["id"], + "real.bin" + ); + assert!(!boundary.value.to_string().contains("real.bin")); + + let mut wrong_kind = boundary.clone(); + wrong_kind.value["artifact"]["$task_handle"]["kind"] = + serde_json::Value::String("blob".to_owned()); + assert!(wrong_kind.validate().unwrap_err().contains("expects kind")); + + let mut out_of_range = boundary.clone(); + out_of_range.value["artifact"]["$task_handle"]["index"] = serde_json::json!(1); + assert!(out_of_range + .validate() + .unwrap_err() + .contains("out of range")); + + let unused = StructuredTaskBoundary { + value: serde_json::json!({ "label": "release" }), + handles: boundary.handles.clone(), + }; + assert!(unused.validate().unwrap_err().contains("unused")); + + let malformed = StructuredTaskBoundary { + value: serde_json::json!({ + "$task_handle": { "index": 0, "kind": "artifact", "id": "forged.bin" } + }), + handles: boundary.handles, + }; + assert!(malformed.validate().unwrap_err().contains("exactly")); + } + #[test] fn product_task_spec_has_no_local_function_dispatch_variant() { let spec = TaskSpec { @@ -704,6 +941,7 @@ mod tests { required_artifacts: Vec::new(), args: vec![TaskBoundaryValue::SmallJson(serde_json::json!("src"))], vfs_epoch: 7, + failure_policy: Default::default(), bundle_digest: Some(Digest::sha256("bundle")), }; diff --git a/crates/disasmer-core/src/ids.rs b/crates/clusterflux-core/src/ids.rs similarity index 100% rename from crates/disasmer-core/src/ids.rs rename to crates/clusterflux-core/src/ids.rs diff --git a/crates/disasmer-core/src/lib.rs b/crates/clusterflux-core/src/lib.rs similarity index 73% rename from crates/disasmer-core/src/lib.rs rename to crates/clusterflux-core/src/lib.rs index 4aa4c77..38b3e15 100644 --- a/crates/disasmer-core/src/lib.rs +++ b/crates/clusterflux-core/src/lib.rs @@ -25,13 +25,13 @@ pub use artifact::{ }; pub use auth::{ admin_request_proof, admin_request_proof_from_token_digest, - agent_ed25519_public_key_from_private_key, derive_ed25519_private_key_from_seed, - node_capability_policy_digest, node_ed25519_public_key_from_private_key, - sign_agent_workflow_request, sign_node_request, signed_request_payload_digest, - verify_agent_workflow_signature, verify_node_request_signature, Action, Actor, - AgentSignedRequest, AgentWorkflowScope, AuthContext, Authorization, BrowserLoginFlow, - CredentialKind, EnrollmentError, EnrollmentGrant, IdentityKind, NodeCredential, - NodeSignedRequest, PublicKeyIdentity, Scope, + agent_ed25519_public_key_from_private_key, agent_workflow_request_scope_from_payload, + derive_ed25519_private_key_from_seed, node_capability_policy_digest, + node_ed25519_public_key_from_private_key, sign_agent_workflow_request, sign_node_request, + signed_request_payload_digest, verify_agent_workflow_signature, verify_node_request_signature, + Action, Actor, AgentSignedRequest, AgentWorkflowRequestScope, AgentWorkflowScope, AuthContext, + Authorization, BrowserLoginFlow, CredentialKind, EnrollmentError, EnrollmentGrant, + IdentityKind, NodeCredential, NodeSignedRequest, PublicKeyIdentity, Scope, }; #[cfg(not(target_arch = "wasm32"))] pub use auth::{generate_ed25519_private_key, generate_opaque_token}; @@ -57,13 +57,13 @@ pub use environment::{ pub use execution::{ CommandBackendKind, CommandInvocation, CommandNetworkPolicy, CommandPlan, GuestRuntimeKind, NativeCommandPolicy, StructuredTaskBoundary, TaskBoundaryHandle, TaskBoundaryValue, - TaskDispatch, TaskJoinResult, TaskJoinState, TaskSpec, WasmExportAbi, WasmHostCommandRequest, - WasmHostCommandResult, WasmHostDebugProbeRequest, WasmHostDebugProbeResult, - WasmHostSourceSnapshotRequest, WasmHostSourceSnapshotResult, WasmHostTaskControlRequest, - WasmHostTaskControlResult, WasmHostTaskHandle, WasmHostTaskJoinRequest, WasmHostTaskJoinResult, - WasmHostTaskStartRequest, WasmHostVfsOperation, WasmHostVfsRequest, WasmHostVfsResult, - WasmTaskInvocation, WasmTaskOutcome, WasmTaskResult, MAX_WASM_TASK_ENVELOPE_BYTES, - WASM_TASK_ABI_VERSION, + TaskDispatch, TaskFailurePolicy, TaskJoinResult, TaskJoinState, TaskSpec, WasmExportAbi, + WasmHostCommandRequest, WasmHostCommandResult, WasmHostDebugProbeRequest, + WasmHostDebugProbeResult, WasmHostSourceSnapshotRequest, WasmHostSourceSnapshotResult, + WasmHostTaskControlRequest, WasmHostTaskControlResult, WasmHostTaskHandle, + WasmHostTaskJoinRequest, WasmHostTaskJoinResult, WasmHostTaskStartRequest, + WasmHostVfsOperation, WasmHostVfsRequest, WasmHostVfsResult, WasmTaskInvocation, + WasmTaskOutcome, WasmTaskResult, MAX_WASM_TASK_ENVELOPE_BYTES, WASM_TASK_ABI_VERSION, }; pub use ids::{ AgentId, ArtifactId, NodeId, ProcessId, ProjectId, TaskDefinitionId, TaskInstanceId, TenantId, diff --git a/crates/disasmer-core/src/limits.rs b/crates/clusterflux-core/src/limits.rs similarity index 100% rename from crates/disasmer-core/src/limits.rs rename to crates/clusterflux-core/src/limits.rs diff --git a/crates/disasmer-core/src/operator_panel.rs b/crates/clusterflux-core/src/operator_panel.rs similarity index 100% rename from crates/disasmer-core/src/operator_panel.rs rename to crates/clusterflux-core/src/operator_panel.rs diff --git a/crates/disasmer-core/src/policy.rs b/crates/clusterflux-core/src/policy.rs similarity index 100% rename from crates/disasmer-core/src/policy.rs rename to crates/clusterflux-core/src/policy.rs diff --git a/crates/disasmer-core/src/project.rs b/crates/clusterflux-core/src/project.rs similarity index 94% rename from crates/disasmer-core/src/project.rs rename to crates/clusterflux-core/src/project.rs index 167d871..3b3bd34 100644 --- a/crates/disasmer-core/src/project.rs +++ b/crates/clusterflux-core/src/project.rs @@ -27,13 +27,13 @@ pub struct ProjectModel { pub enum ProjectModelError { #[error("environment discovery failed: {0}")] Environment(String), - #[error("Disasmer entrypoint discovery failed: {0}")] + #[error("Clusterflux entrypoint discovery failed: {0}")] EntrypointDiscovery(String), #[error( - "no Disasmer entrypoint is declared; add `#[disasmer::main]` to a function under src/" + "no Clusterflux entrypoint is declared; add `#[clusterflux::main]` to a function under src/" )] NoEntrypoints, - #[error("unknown Disasmer entrypoint `{name}`; available entrypoints: {available:?}")] + #[error("unknown Clusterflux entrypoint `{name}`; available entrypoints: {available:?}")] UnknownEntrypoint { name: String, available: Vec, @@ -155,7 +155,7 @@ fn collect_entrypoint_items( .iter() .map(|segment| segment.ident.to_string()) .collect::>(); - segments.as_slice() == ["disasmer", "main"] + segments.as_slice() == ["clusterflux", "main"] }) else { continue; }; @@ -233,7 +233,7 @@ mod tests { .unwrap(); fs::write( temp.path().join("src/main.rs"), - "#[disasmer::main]\npub fn build_main() {}\n", + "#[clusterflux::main]\npub fn build_main() {}\n", ) .unwrap(); @@ -250,12 +250,12 @@ mod tests { fs::create_dir_all(temp.path().join("src/nested")).unwrap(); fs::write( temp.path().join("src/lib.rs"), - "#[disasmer::main(name = \"check\")]\npub fn test_main() {}\n", + "#[clusterflux::main(name = \"check\")]\npub fn test_main() {}\n", ) .unwrap(); fs::write( temp.path().join("src/nested/release.rs"), - "#[disasmer::main]\npub fn release_main() {}\n", + "#[clusterflux::main]\npub fn release_main() {}\n", ) .unwrap(); let model = ProjectModel::discover_without_config(temp.path()).unwrap(); @@ -276,7 +276,7 @@ mod tests { fs::create_dir_all(temp.path().join("src")).unwrap(); fs::write( temp.path().join("src/main.rs"), - "#[disasmer::main]\npub fn build_main() {}\n", + "#[clusterflux::main]\npub fn build_main() {}\n", ) .unwrap(); let model = ProjectModel::discover_without_config(temp.path()).unwrap(); diff --git a/crates/disasmer-core/src/scheduler.rs b/crates/clusterflux-core/src/scheduler.rs similarity index 89% rename from crates/disasmer-core/src/scheduler.rs rename to crates/clusterflux-core/src/scheduler.rs index 0cf2e5e..b489519 100644 --- a/crates/disasmer-core/src/scheduler.rs +++ b/crates/clusterflux-core/src/scheduler.rs @@ -28,6 +28,8 @@ pub struct PlacementRequest { pub project: ProjectId, pub environment: Option, pub environment_digest: Option, + #[serde(default)] + pub environment_cache_required: bool, pub required_capabilities: BTreeSet, pub dependency_cache: Option, pub source_snapshot: Option, @@ -139,6 +141,17 @@ fn compatibility( } } } + if request.environment_cache_required { + match request.environment_digest.as_ref() { + Some(digest) if !node.cached_environments.contains(digest) => { + reasons.push(format!( + "required named environment cache {digest} is unavailable" + )); + } + None => reasons.push("required named environment cache digest is missing".to_owned()), + Some(_) => {} + } + } let source_transfer_required = request .source_snapshot .as_ref() @@ -259,6 +272,7 @@ mod tests { project: ProjectId::from("project"), environment: Some(EnvironmentRequirements::linux_container()), environment_digest: Some(Digest::sha256("env")), + environment_cache_required: false, required_capabilities: BTreeSet::from([Capability::Command]), dependency_cache: Some(Digest::sha256("deps")), source_snapshot: Some(Digest::sha256("source")), @@ -283,6 +297,30 @@ mod tests { .any(|reason| reason.contains("dependency"))); } + #[test] + fn scheduler_requires_requested_named_environment_cache() { + let request = PlacementRequest { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + environment: None, + environment_digest: Some(Digest::sha256("missing-environment")), + environment_cache_required: true, + required_capabilities: BTreeSet::new(), + dependency_cache: None, + source_snapshot: None, + required_artifacts: BTreeSet::new(), + quota_available: true, + policy_allowed: true, + prefer_node: None, + }; + + let error = DefaultScheduler + .place(&[node("uncached", false)], &request) + .unwrap_err(); + + assert!(error.message.contains("named environment cache")); + } + #[test] fn scheduler_failure_names_missing_constraint() { let mut request = PlacementRequest { @@ -290,6 +328,7 @@ mod tests { project: ProjectId::from("project"), environment: None, environment_digest: None, + environment_cache_required: false, required_capabilities: BTreeSet::from([Capability::WindowsCommandDev]), dependency_cache: None, source_snapshot: None, @@ -314,6 +353,7 @@ mod tests { project: ProjectId::from("project"), environment: Some(EnvironmentRequirements::windows_command_dev()), environment_digest: None, + environment_cache_required: false, required_capabilities: BTreeSet::new(), dependency_cache: None, source_snapshot: None, @@ -344,6 +384,7 @@ mod tests { project: ProjectId::from("project"), environment: None, environment_digest: None, + environment_cache_required: false, required_capabilities: BTreeSet::from([Capability::Command]), dependency_cache: None, source_snapshot: Some(Digest::sha256("source")), @@ -378,6 +419,7 @@ mod tests { project: ProjectId::from("project"), environment: None, environment_digest: None, + environment_cache_required: false, required_capabilities: BTreeSet::from([Capability::Command]), dependency_cache: None, source_snapshot: None, @@ -403,6 +445,7 @@ mod tests { project: ProjectId::from("project"), environment: None, environment_digest: None, + environment_cache_required: false, required_capabilities: BTreeSet::from([Capability::Command]), dependency_cache: None, source_snapshot: None, diff --git a/crates/disasmer-core/src/source.rs b/crates/clusterflux-core/src/source.rs similarity index 100% rename from crates/disasmer-core/src/source.rs rename to crates/clusterflux-core/src/source.rs diff --git a/crates/disasmer-core/src/transport.rs b/crates/clusterflux-core/src/transport.rs similarity index 100% rename from crates/disasmer-core/src/transport.rs rename to crates/clusterflux-core/src/transport.rs diff --git a/crates/disasmer-core/src/vfs.rs b/crates/clusterflux-core/src/vfs.rs similarity index 100% rename from crates/disasmer-core/src/vfs.rs rename to crates/clusterflux-core/src/vfs.rs diff --git a/crates/disasmer-core/src/wire.rs b/crates/clusterflux-core/src/wire.rs similarity index 100% rename from crates/disasmer-core/src/wire.rs rename to crates/clusterflux-core/src/wire.rs diff --git a/crates/disasmer-dap/Cargo.toml b/crates/clusterflux-dap/Cargo.toml similarity index 55% rename from crates/disasmer-dap/Cargo.toml rename to crates/clusterflux-dap/Cargo.toml index d389edf..d68235d 100644 --- a/crates/disasmer-dap/Cargo.toml +++ b/crates/clusterflux-dap/Cargo.toml @@ -1,20 +1,20 @@ [package] -name = "disasmer-dap" +name = "clusterflux-dap" version = "0.1.0" edition.workspace = true license.workspace = true repository.workspace = true [[bin]] -name = "disasmer-debug-dap" +name = "clusterflux-debug-dap" path = "src/main.rs" [dependencies] anyhow.workspace = true base64.workspace = true -disasmer-core = { path = "../disasmer-core" } -disasmer-control = { path = "../disasmer-control" } -disasmer-node = { path = "../disasmer-node" } +clusterflux-core = { path = "../clusterflux-core" } +clusterflux-control = { path = "../clusterflux-control" } +clusterflux-node = { path = "../clusterflux-node" } serde_json.workspace = true [dev-dependencies] diff --git a/crates/disasmer-dap/src/adapter.rs b/crates/clusterflux-dap/src/adapter.rs similarity index 87% rename from crates/disasmer-dap/src/adapter.rs rename to crates/clusterflux-dap/src/adapter.rs index c2c632f..a9d1345 100644 --- a/crates/disasmer-dap/src/adapter.rs +++ b/crates/clusterflux-dap/src/adapter.rs @@ -1,7 +1,7 @@ use std::io::{self, BufReader}; use anyhow::Result; -use disasmer_core::DebugRuntimeState; +use clusterflux_core::DebugRuntimeState; use serde_json::{json, Value}; use crate::breakpoints::{ @@ -83,7 +83,7 @@ pub(crate) fn run_adapter() -> Result<()> { .and_then(Value::as_bool) .unwrap_or(false); if let Some(process_id) = args.get("processId").and_then(Value::as_str) { - state.process = disasmer_core::ProcessId::new(process_id); + state.process = clusterflux_core::ProcessId::new(process_id); } if command == "attach" { state.session_mode = DapSessionMode::Attach; @@ -178,12 +178,12 @@ pub(crate) fn run_adapter() -> Result<()> { writer.response(&request, true, json!({}))?; let session_message = if state.session_mode == DapSessionMode::Attach { format!( - "Attached to Disasmer virtual process {} with {:?} runtime\n", + "Attached to Clusterflux virtual process {} with {:?} runtime\n", state.process, state.runtime_backend ) } else { format!( - "Disasmer bundle refreshed for entry `{}`; starting virtual process {} with {:?} runtime\n", + "Clusterflux bundle refreshed for entry `{}`; starting virtual process {} with {:?} runtime\n", state.entry, state.process, state.runtime_backend ) }; @@ -200,9 +200,9 @@ pub(crate) fn run_adapter() -> Result<()> { "stopped", json!({ "reason": "breakpoint", - "description": "Disasmer Debug Epoch all-stop confirmed by every active participant", + "description": "Clusterflux Debug Epoch all-stop confirmed by every active participant", "threadId": stopped_thread, - "allThreadsStopped": true, + "allThreadsStopped": state.debug_all_threads_stopped, }), )?; continue; @@ -223,9 +223,9 @@ pub(crate) fn run_adapter() -> Result<()> { "stopped", json!({ "reason": "breakpoint", - "description": "Disasmer Debug Epoch all-stop", + "description": "Clusterflux Debug Epoch all-stop", "threadId": stopped_thread, - "allThreadsStopped": true, + "allThreadsStopped": state.debug_all_threads_stopped, }), )?; } @@ -255,27 +255,35 @@ pub(crate) fn run_adapter() -> Result<()> { "stackTrace" => { let thread = request_thread(&request, &state); let source_path = crate::source::stack_source_path(&state); - let frame_name = thread - .runtime_stack_frames - .first() - .cloned() - .unwrap_or_else(|| format!("{}::run", thread.name)); + let stack_frames = if state.runtime_backend != RuntimeBackend::Simulated + && thread.runtime_stack_frames.is_empty() + { + Vec::new() + } else { + let frame_name = thread + .runtime_stack_frames + .first() + .cloned() + .unwrap_or_else(|| format!("{}::run", thread.name)); + vec![json!({ + "id": thread.frame_id, + "name": frame_name, + "line": thread.line, + "column": 1, + "source": { + "name": crate::source::source_name(&source_path), + "path": source_path, + "presentationHint": "normal" + } + })] + }; + let total_frames = stack_frames.len(); writer.response( &request, true, json!({ - "stackFrames": [{ - "id": thread.frame_id, - "name": frame_name, - "line": thread.line, - "column": 1, - "source": { - "name": crate::source::source_name(&source_path), - "path": source_path, - "presentationHint": "normal" - } - }], - "totalFrames": 1 + "stackFrames": stack_frames, + "totalFrames": total_frames }), )?; } @@ -319,7 +327,7 @@ pub(crate) fn run_adapter() -> Result<()> { "presentationHint": "arguments" }, { - "name": "Disasmer Runtime", + "name": "Clusterflux Runtime", "variablesReference": thread.runtime_ref, "expensive": false }, @@ -379,9 +387,9 @@ pub(crate) fn run_adapter() -> Result<()> { "stopped", json!({ "reason": "pause", - "description": "Disasmer Debug Epoch pause", + "description": "Clusterflux Debug Epoch pause", "threadId": stopped_thread, - "allThreadsStopped": true, + "allThreadsStopped": state.debug_all_threads_stopped, }), )?; } @@ -429,9 +437,23 @@ pub(crate) fn run_adapter() -> Result<()> { "stopped", json!({ "reason": "breakpoint", - "description": "Disasmer Debug Epoch all-stop confirmed by every active participant", + "description": "Clusterflux Debug Epoch all-stop confirmed by every active participant", "threadId": stopped_thread, - "allThreadsStopped": true, + "allThreadsStopped": state.debug_all_threads_stopped, + }), + )?; + } + Ok(RuntimeContinuationOutcome::Exception(record)) => { + state.apply_runtime_record(record); + let stopped_thread = stopped_thread_for_breakpoint(&state); + writer.output("stderr", format!("{}\n", state.command_status))?; + writer.event( + "stopped", + json!({ + "reason": "exception", + "description": "Task failed and is awaiting operator action", + "threadId": stopped_thread, + "allThreadsStopped": false, }), )?; } @@ -475,9 +497,9 @@ pub(crate) fn run_adapter() -> Result<()> { "stopped", json!({ "reason": "breakpoint", - "description": "Disasmer Debug Epoch all-stop", + "description": "Clusterflux Debug Epoch all-stop", "threadId": stopped_thread, - "allThreadsStopped": true, + "allThreadsStopped": state.debug_all_threads_stopped, }), )?; } @@ -512,9 +534,9 @@ pub(crate) fn run_adapter() -> Result<()> { "stopped", json!({ "reason": "step", - "description": format!("Disasmer Debug Epoch {description}"), + "description": format!("Clusterflux Debug Epoch {description}"), "threadId": thread_id, - "allThreadsStopped": true, + "allThreadsStopped": state.debug_all_threads_stopped, }), )?; } @@ -565,7 +587,7 @@ pub(crate) fn run_adapter() -> Result<()> { "reason": "breakpoint", "description": "Restarted main from the rebuilt bundle; every active participant confirmed all-stop", "threadId": stopped_thread, - "allThreadsStopped": true, + "allThreadsStopped": state.debug_all_threads_stopped, }), )?; } @@ -610,7 +632,20 @@ pub(crate) fn run_adapter() -> Result<()> { "reason": "breakpoint", "description": "Restarted task reached a Wasm probe and every active participant confirmed all-stop", "threadId": stopped_thread, - "allThreadsStopped": true, + "allThreadsStopped": state.debug_all_threads_stopped, + }), + )?; + } + Ok(RuntimeContinuationOutcome::Exception(record)) => { + state.apply_runtime_record(record); + let stopped_thread = stopped_thread_for_breakpoint(&state); + writer.event( + "stopped", + json!({ + "reason": "exception", + "description": "Replacement attempt failed and is awaiting operator action", + "threadId": stopped_thread, + "allThreadsStopped": false, }), )?; } @@ -712,15 +747,19 @@ fn restart_task_through_coordinator(state: &mut AdapterState, thread_id: i64) -> )); } let restarted_task = record.restarted_task_instance.ok_or_else(|| { - anyhow::anyhow!("coordinator accepted task restart without a new task-instance ID") + anyhow::anyhow!("coordinator accepted task restart without its stable task-instance ID") })?; if let Some(thread) = state.threads.get_mut(&thread_id) { thread.task = restarted_task.clone(); + thread.attempt_id = record.restarted_attempt_id.clone().ok_or_else(|| { + anyhow::anyhow!("coordinator accepted task restart without a new attempt ID") + })?; } state.debug_probes = crate::breakpoints::load_bundle_debug_probes(&state.project, &state.source_path); Ok(format!( - "Coordinator restarted task `{task}` as `{restarted_task}` from the rebuilt bundle and a clean runtime checkpoint boundary" + "Coordinator restarted logical task `{restarted_task}` as attempt `{}` from the rebuilt bundle and a clean runtime checkpoint boundary", + record.restarted_attempt_id.as_deref().unwrap_or("unknown") )) } @@ -739,6 +778,7 @@ fn record_coordinator_debug_epoch( .unwrap_or_else(|| state.threads[&default_thread_id(state)].task.clone()); let record = create_debug_epoch(state, &stopped_task, reason)?; let status = wait_for_debug_epoch_frozen(state, record.epoch)?; + state.debug_all_threads_stopped = status.fully_frozen; state.epoch = record.epoch; state.coordinator_debug_epoch = Some(record.epoch); let previous_status = state.command_status.clone(); @@ -808,7 +848,7 @@ pub(crate) fn runtime_backend_from_launch_arg( Some("live-services") => Ok(RuntimeBackend::LiveServices), Some(value) if is_explicit_demo_backend(value) => Ok(RuntimeBackend::Simulated), Some(value) => Err(format!( - "unsupported Disasmer runtimeBackend `{value}`; use local-services, live-services, or explicit demo" + "unsupported Clusterflux runtimeBackend `{value}`; use local-services, live-services, or explicit demo" )), } } diff --git a/crates/disasmer-dap/src/breakpoints.rs b/crates/clusterflux-dap/src/breakpoints.rs similarity index 97% rename from crates/disasmer-dap/src/breakpoints.rs rename to crates/clusterflux-dap/src/breakpoints.rs index 17bbf30..caebbb3 100644 --- a/crates/disasmer-dap/src/breakpoints.rs +++ b/crates/clusterflux-dap/src/breakpoints.rs @@ -1,6 +1,6 @@ use std::fs; -use disasmer_core::{ +use clusterflux_core::{ discover_source_debug_probes, BundleDebugProbe, DebugRuntimeState, TaskInstanceId, }; use serde_json::{json, Value}; @@ -63,11 +63,11 @@ pub(crate) fn resolve_breakpoints( && source_function_name_at_line(state, line).is_some()); let message = match probe { Some(probe) => format!( - "Mapped to Disasmer debug probe {} for task {}", + "Mapped to Clusterflux debug probe {} for task {}", probe.id, probe.task ), - None if verified => "Mapped to Disasmer virtual source location".to_owned(), - None => "No Disasmer debug probe metadata covers this source line".to_owned(), + None if verified => "Mapped to Clusterflux virtual source location".to_owned(), + None => "No Clusterflux debug probe metadata covers this source line".to_owned(), }; ResolvedBreakpoint { id: index + 1, diff --git a/crates/disasmer-dap/src/dap_protocol.rs b/crates/clusterflux-dap/src/dap_protocol.rs similarity index 100% rename from crates/disasmer-dap/src/dap_protocol.rs rename to crates/clusterflux-dap/src/dap_protocol.rs diff --git a/crates/disasmer-dap/src/demo_backend.rs b/crates/clusterflux-dap/src/demo_backend.rs similarity index 83% rename from crates/disasmer-dap/src/demo_backend.rs rename to crates/clusterflux-dap/src/demo_backend.rs index a8101aa..dacfcf7 100644 --- a/crates/disasmer-dap/src/demo_backend.rs +++ b/crates/clusterflux-dap/src/demo_backend.rs @@ -1,6 +1,6 @@ use std::collections::BTreeMap; -use disasmer_core::{DebugRuntimeState, TaskInstanceId}; +use clusterflux_core::{DebugRuntimeState, TaskInstanceId}; use crate::virtual_model::{AdapterState, VirtualThread}; @@ -42,7 +42,8 @@ fn thread(id: i64, task: &str, name: &str, line: i64) -> VirtualThread { vfs_ref: 7000 + id, command_ref: 8000 + id, task: TaskInstanceId::from(task), - task_definition: disasmer_core::TaskDefinitionId::from(task), + attempt_id: format!("demo-attempt-{id}"), + task_definition: clusterflux_core::TaskDefinitionId::from(task), name: name.to_owned(), line, state: DebugRuntimeState::Running, @@ -59,5 +60,9 @@ fn thread(id: i64, task: &str, name: &str, line: i64) -> VirtualThread { runtime_task_args: Vec::new(), runtime_handles: Vec::new(), runtime_command_status: None, + runtime_node: Some("demo-node".to_owned()), + runtime_environment: Some("demo".to_owned()), + runtime_vfs_checkpoint: Some("demo-vfs".to_owned()), + restart_compatible: Some(true), } } diff --git a/crates/disasmer-dap/src/main.rs b/crates/clusterflux-dap/src/main.rs similarity index 92% rename from crates/disasmer-dap/src/main.rs rename to crates/clusterflux-dap/src/main.rs index 727532d..32336b2 100644 --- a/crates/disasmer-dap/src/main.rs +++ b/crates/clusterflux-dap/src/main.rs @@ -29,9 +29,9 @@ use variables::variables_response; use virtual_model::{AdapterState, RuntimeLaunchRecord}; #[cfg(test)] -use demo_backend::{LINUX_THREAD, MAIN_THREAD, PACKAGE_THREAD, WINDOWS_THREAD}; +use clusterflux_core::{BundleDebugProbe, DebugRuntimeState, TaskInstanceId}; #[cfg(test)] -use disasmer_core::{BundleDebugProbe, DebugRuntimeState, TaskInstanceId}; +use demo_backend::{LINUX_THREAD, MAIN_THREAD, PACKAGE_THREAD, WINDOWS_THREAD}; #[cfg(test)] use virtual_model::{process_id, RuntimeBackend}; diff --git a/crates/disasmer-dap/src/runtime_client.rs b/crates/clusterflux-dap/src/runtime_client.rs similarity index 78% rename from crates/disasmer-dap/src/runtime_client.rs rename to crates/clusterflux-dap/src/runtime_client.rs index ee304a8..4013a4f 100644 --- a/crates/disasmer-dap/src/runtime_client.rs +++ b/crates/clusterflux-dap/src/runtime_client.rs @@ -5,7 +5,7 @@ use std::time::{Duration, Instant}; use anyhow::{anyhow, Result}; use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; -use disasmer_core::TaskInstanceId; +use clusterflux_core::TaskInstanceId; use serde_json::{json, Value}; use crate::virtual_model::{AdapterState, RuntimeLaunchRecord}; @@ -83,6 +83,7 @@ pub(crate) struct DebugEpochStatusRecord { pub(crate) expected_tasks: usize, pub(crate) acknowledgements: Vec, pub(crate) fully_frozen: bool, + pub(crate) partially_frozen: bool, pub(crate) fully_resumed: bool, pub(crate) failed: bool, pub(crate) failure_messages: Vec, @@ -92,6 +93,7 @@ pub(crate) struct DebugEpochStatusRecord { pub(crate) struct TaskRestartRecord { pub(crate) accepted: bool, pub(crate) restarted_task_instance: Option, + pub(crate) restarted_attempt_id: Option, pub(crate) clean_boundary_available: bool, pub(crate) requires_whole_process_restart: bool, pub(crate) active_task: bool, @@ -101,6 +103,7 @@ pub(crate) struct TaskRestartRecord { pub(crate) enum RuntimeContinuationOutcome { Breakpoint(RuntimeLaunchRecord), + Exception(RuntimeLaunchRecord), Terminal(RuntimeLaunchRecord), } @@ -109,9 +112,9 @@ pub(crate) fn run_local_services_runtime( ) -> Result<(RuntimeLaunchRecord, LocalRuntimeSession)> { let repo = std::env::current_dir()?; let mut coordinator_command = local_tool_command( - "DISASMER_COORDINATOR_BIN", - "disasmer-coordinator", - "disasmer-coordinator", + "CLUSTERFLUX_COORDINATOR_BIN", + "clusterflux-coordinator", + "clusterflux-coordinator", &repo, ); let mut coordinator = coordinator_command @@ -159,8 +162,12 @@ pub(crate) fn run_local_services_runtime( .ok_or_else(|| anyhow!("local coordinator omitted the node enrollment grant"))? .to_owned(); - let mut worker_command = - local_tool_command("DISASMER_NODE_BIN", "disasmer-node", "disasmer-node", &repo); + let mut worker_command = local_tool_command( + "CLUSTERFLUX_NODE_BIN", + "clusterflux-node", + "clusterflux-node", + &repo, + ); let mut worker = ChildGuard::new( worker_command .args([ @@ -207,14 +214,19 @@ pub(crate) fn run_local_services_runtime( } fn build_debug_bundle(state: &AdapterState, repo: &Path) -> Result { - let mut command = local_tool_command("DISASMER_CLI_BIN", "disasmer", "disasmer-cli", repo); + let mut command = local_tool_command( + "CLUSTERFLUX_CLI_BIN", + "clusterflux", + "clusterflux-cli", + repo, + ); let output = command .args(["build", "--project", &state.project, "--json"]) .current_dir(repo) .output()?; if !output.status.success() { return Err(anyhow!( - "Disasmer bundle build failed before debug launch: {}", + "Clusterflux bundle build failed before debug launch: {}", String::from_utf8_lossy(&output.stderr).trim() )); } @@ -298,7 +310,7 @@ fn launch_services_debug_entrypoint( let probe_symbols = state.requested_probe_symbols(); if probe_symbols.is_empty() { return Err(anyhow!( - "no executable Disasmer probe corresponds to the configured source breakpoints" + "no executable Clusterflux probe corresponds to the configured source breakpoints" )); } coordinator_request( @@ -364,6 +376,14 @@ fn launch_services_debug_entrypoint( .and_then(Value::as_u64) .ok_or_else(|| anyhow!("breakpoint status omitted the hit debug epoch"))?; let frozen = wait_for_debug_epoch_state_at(coordinator, state, debug_epoch, true)?; + let fully_frozen = frozen + .get("fully_frozen") + .and_then(Value::as_bool) + .unwrap_or(false); + let partially_frozen = frozen + .get("partially_frozen") + .and_then(Value::as_bool) + .unwrap_or(false); let expected = frozen .get("expected_tasks") .and_then(Value::as_array) @@ -374,11 +394,12 @@ fn launch_services_debug_entrypoint( .and_then(Value::as_array) .map(Vec::len) .unwrap_or(0); - if expected == 0 || acknowledged != expected { + if expected == 0 || (!fully_frozen && !partially_frozen) { return Err(anyhow!( - "debug epoch {debug_epoch} claimed frozen without every active participant" + "debug epoch {debug_epoch} settled without a frozen participant" )); } + let task_snapshots = fetch_task_snapshots(coordinator, state)?; let process_statuses = coordinator_request( coordinator, client_user_request( @@ -418,6 +439,7 @@ fn launch_services_debug_entrypoint( "debug_epoch": frozen, "process_status": process_status, "process_statuses": process_statuses, + "task_snapshots": task_snapshots, }), task_events: json!({ "events": [] }), placed_task_launched: true, @@ -439,7 +461,7 @@ fn launch_services_debug_entrypoint( .get("hit_probe_symbol") .and_then(Value::as_str) .map(str::to_owned), - all_participants_frozen: true, + all_participants_frozen: fully_frozen && acknowledged == expected, }) } @@ -453,7 +475,7 @@ fn wait_for_local_node( loop { if let Some(status) = worker.try_wait()? { return Err(anyhow!( - "local Disasmer worker `{node}` exited before attaching ({status}){}", + "local Clusterflux worker `{node}` exited before attaching ({status}){}", child_stderr_suffix(worker) )); } @@ -481,7 +503,7 @@ fn wait_for_local_node( let _ = worker.kill(); let _ = worker.wait(); return Err(anyhow!( - "local Disasmer worker `{node}` did not attach within 30 seconds{}", + "local Clusterflux worker `{node}` did not attach within 30 seconds{}", child_stderr_suffix(worker) )); } @@ -539,6 +561,17 @@ fn wait_for_debug_epoch_state_at( }), ), )?; + let partially_frozen = frozen + && response + .get("partially_frozen") + .and_then(Value::as_bool) + .unwrap_or(false); + if response.get("fully_frozen").and_then(Value::as_bool) == Some(true) + || partially_frozen + || (!frozen && response.get("fully_resumed").and_then(Value::as_bool) == Some(true)) + { + return Ok(response); + } if response.get("failed").and_then(Value::as_bool) == Some(true) { return Err(anyhow!( "debug epoch {epoch} participant failed: {}", @@ -557,9 +590,6 @@ fn wait_for_debug_epoch_state_at( } else { "fully_resumed" }; - if response.get(ready_field).and_then(Value::as_bool) == Some(true) { - return Ok(response); - } if Instant::now() >= deadline { return Err(anyhow!( "debug epoch {epoch} did not reach {ready_field} within 60 seconds" @@ -576,6 +606,50 @@ pub(crate) fn run_live_services_runtime(state: &AdapterState) -> Result Result { + coordinator_request( + coordinator, + client_user_request( + state, + json!({ + "type": "list_task_snapshots", + "tenant": state.tenant, + "project": state.project_id, + "actor_user": state.actor_user, + "process": state.process.as_str(), + }), + ), + ) +} + +fn fetch_current_process_status( + coordinator: &str, + state: &AdapterState, +) -> Result<(Value, Option)> { + let statuses = coordinator_request( + coordinator, + client_user_request( + state, + json!({ + "type": "list_processes", + "tenant": state.tenant, + "project": state.project_id, + "actor_user": state.actor_user, + }), + ), + )?; + let current = statuses + .get("processes") + .and_then(Value::as_array) + .and_then(|processes| { + processes.iter().find(|process| { + process.get("process").and_then(Value::as_str) == Some(state.process.as_str()) + }) + }) + .cloned(); + Ok((statuses, current)) +} + pub(crate) fn relaunch_services_main_runtime(state: &AdapterState) -> Result { let coordinator = crate::view_state::normalize_coordinator_endpoint(&state.coordinator_endpoint); @@ -632,6 +706,23 @@ pub(crate) fn attach_services_runtime(state: &AdapterState) -> Result Result 0, + placed_task_launched: active_main || active_snapshot_count > 0, status_code: None, stdout_bytes: 0, stderr_bytes: 0, @@ -775,6 +867,85 @@ pub(crate) fn wait_for_services_runtime_outcome( if let Some(outcome) = terminal_runtime_outcome(&coordinator, state, &events) { return Ok(outcome); } + let task_snapshots = fetch_task_snapshots(&coordinator, state)?; + if let Some((failed_task, _attempt_id)) = failed_awaiting_action_snapshot(&task_snapshots) { + let failed_task = failed_task.to_owned(); + let failed_event = events + .get("events") + .and_then(Value::as_array) + .into_iter() + .flatten() + .rev() + .find(|event| { + event.get("task").and_then(Value::as_str) == Some(failed_task.as_str()) + && event.get("terminal_state").and_then(Value::as_str) == Some("failed") + }) + .cloned() + .unwrap_or_else(|| json!({})); + let event_count = events + .get("events") + .and_then(Value::as_array) + .map(Vec::len) + .unwrap_or(state.runtime_event_count); + let (process_statuses, process_status) = + fetch_current_process_status(&coordinator, state)?; + return Ok(RuntimeContinuationOutcome::Exception(RuntimeLaunchRecord { + coordinator, + node: failed_event + .get("node") + .and_then(Value::as_str) + .unwrap_or("unknown") + .to_owned(), + node_report: json!({ + "task_snapshots": task_snapshots, + "process_status": process_status, + "process_statuses": process_statuses, + "failed_awaiting_action": failed_task, + }), + task_events: events, + placed_task_launched: true, + status_code: failed_event + .get("status_code") + .and_then(Value::as_i64) + .map(|status| status as i32) + .or(Some(1)), + stdout_bytes: failed_event + .get("stdout_bytes") + .and_then(Value::as_u64) + .unwrap_or(0), + stderr_bytes: failed_event + .get("stderr_bytes") + .and_then(Value::as_u64) + .unwrap_or(0), + stdout_tail: failed_event + .get("stdout_tail") + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(), + stderr_tail: failed_event + .get("stderr_tail") + .and_then(Value::as_str) + .unwrap_or("task failed awaiting operator action") + .to_owned(), + stdout_truncated: failed_event + .get("stdout_truncated") + .and_then(Value::as_bool) + .unwrap_or(false), + stderr_truncated: failed_event + .get("stderr_truncated") + .and_then(Value::as_bool) + .unwrap_or(false), + artifact_path: failed_event + .get("artifact_path") + .and_then(Value::as_str) + .map(str::to_owned), + event_count, + debug_epoch: None, + stopped_task: Some(failed_task), + stopped_probe_symbol: None, + all_participants_frozen: false, + })); + } let breakpoint = match coordinator_request( &coordinator, @@ -826,6 +997,13 @@ pub(crate) fn wait_for_services_runtime_outcome( .and_then(Value::as_str) .unwrap_or("unknown") .to_owned(); + let task_snapshots = fetch_task_snapshots(&coordinator, state)?; + let (process_statuses, process_status) = + fetch_current_process_status(&coordinator, state)?; + let fully_frozen = frozen + .get("fully_frozen") + .and_then(Value::as_bool) + .unwrap_or(false); return Ok(RuntimeContinuationOutcome::Breakpoint( RuntimeLaunchRecord { coordinator, @@ -833,6 +1011,9 @@ pub(crate) fn wait_for_services_runtime_outcome( node_report: json!({ "breakpoint": breakpoint, "debug_epoch": frozen, + "task_snapshots": task_snapshots, + "process_status": process_status, + "process_statuses": process_statuses, }), task_events: json!({ "events": [] }), placed_task_launched: true, @@ -854,7 +1035,7 @@ pub(crate) fn wait_for_services_runtime_outcome( .get("hit_probe_symbol") .and_then(Value::as_str) .map(str::to_owned), - all_participants_frozen: true, + all_participants_frozen: fully_frozen, }, )); } @@ -870,6 +1051,23 @@ pub(crate) fn wait_for_services_runtime_outcome( } } +pub(crate) fn failed_awaiting_action_snapshot(task_snapshots: &Value) -> Option<(&str, &str)> { + task_snapshots + .get("snapshots")? + .as_array()? + .iter() + .find(|snapshot| { + snapshot.get("current").and_then(Value::as_bool) == Some(true) + && snapshot.get("state").and_then(Value::as_str) == Some("failed_awaiting_action") + }) + .and_then(|snapshot| { + Some(( + snapshot.get("task")?.as_str()?, + snapshot.get("attempt_id")?.as_str()?, + )) + }) +} + fn terminal_runtime_outcome( coordinator: &str, state: &AdapterState, diff --git a/crates/disasmer-dap/src/runtime_client/debug_protocol.rs b/crates/clusterflux-dap/src/runtime_client/debug_protocol.rs similarity index 71% rename from crates/disasmer-dap/src/runtime_client/debug_protocol.rs rename to crates/clusterflux-dap/src/runtime_client/debug_protocol.rs index c00b2b1..38dbc2f 100644 --- a/crates/disasmer-dap/src/runtime_client/debug_protocol.rs +++ b/crates/clusterflux-dap/src/runtime_client/debug_protocol.rs @@ -1,7 +1,7 @@ use std::time::{Duration, Instant}; use anyhow::{anyhow, Result}; -use disasmer_core::TaskInstanceId; +use clusterflux_core::TaskInstanceId; use serde_json::{json, Value}; use crate::virtual_model::AdapterState; @@ -49,7 +49,7 @@ pub(super) fn wait_for_debug_epoch_state( ) -> Result { let deadline = Instant::now() + Duration::from_secs(60); loop { - let response = coordinator_debug_epoch_request( + let response = match coordinator_debug_epoch_request( state, client_user_request( state, @@ -62,15 +62,34 @@ pub(super) fn wait_for_debug_epoch_state( "epoch": epoch, }), ), - )?; + ) { + Ok(response) => response, + Err(error) if !frozen && debug_epoch_was_released(&error, state, epoch) => { + return Ok(DebugEpochStatusRecord { + epoch, + command: "resume".to_owned(), + expected_tasks: 0, + acknowledgements: Vec::new(), + fully_frozen: false, + partially_frozen: false, + fully_resumed: true, + failed: false, + failure_messages: Vec::new(), + }); + } + Err(error) => return Err(error), + }; let status = parse_debug_epoch_status(response)?; + if frozen && (status.fully_frozen || status.partially_frozen) { + return Ok(status); + } if status.failed { return Err(anyhow!( "debug epoch {epoch} participant failed: {}", status.failure_messages.join("; ") )); } - if (frozen && status.fully_frozen) || (!frozen && status.fully_resumed) { + if !frozen && status.fully_resumed { return Ok(status); } if Instant::now() >= deadline { @@ -85,6 +104,13 @@ pub(super) fn wait_for_debug_epoch_state( } } +fn debug_epoch_was_released(error: &anyhow::Error, state: &AdapterState, epoch: u64) -> bool { + format!("{error:#}").contains(&format!( + "debug epoch {epoch} is not active for {}", + state.process + )) +} + fn parse_debug_epoch_status(response: Value) -> Result { let epoch = response .get("epoch") @@ -122,6 +148,10 @@ fn parse_debug_epoch_status(response: Value) -> Result { .get("fully_frozen") .and_then(Value::as_bool) .unwrap_or(false), + partially_frozen: response + .get("partially_frozen") + .and_then(Value::as_bool) + .unwrap_or(false), fully_resumed: response .get("fully_resumed") .and_then(Value::as_bool) @@ -144,6 +174,10 @@ pub(crate) fn parse_task_restart_response(response: Value) -> Result Result Result<(), disasmer::TaskArgError> { +async fn build_release() -> Result<(), clusterflux::TaskArgError> { let source = prepare_source_snapshot(); - let compile = disasmer::spawn::task_with_arg(source.clone(), compile_linux) + let compile = clusterflux::spawn::task_with_arg(source.clone(), compile_linux) .name("compile linux") .env(linux_command_env()) .start() @@ -689,7 +701,7 @@ async fn build_release() -> Result<(), disasmer::TaskArgError> { let compile_thread = compile.virtual_thread_id(); let linux_artifact = compile.join().await; - let package = disasmer::spawn::task_with_arg(vec![linux_artifact.clone()], package_release) + let package = clusterflux::spawn::task_with_arg(vec![linux_artifact.clone()], package_release) .name("package release") .env(coordinator_env()) .start() @@ -707,11 +719,11 @@ fn prepare_source_snapshot() -> SourceSnapshot { } fn linux_command_env() -> EnvRef { - disasmer::env!("linux-command") + clusterflux::env!("linux-command") } fn coordinator_env() -> EnvRef { - disasmer::env!("coordinator") + clusterflux::env!("coordinator") } fn compile_linux(source: SourceSnapshot) -> Artifact { @@ -815,3 +827,28 @@ fn wasm_frame_locals_expose_only_values_from_the_node_snapshot() { .iter() .any(|variable| variable["name"] == "wasm-local-diagnostic")); } + +#[test] +fn detects_current_failed_attempt_awaiting_operator_action() { + let snapshots = json!({ + "snapshots": [ + { + "task": "task-stale", + "attempt_id": "attempt-stale", + "state": "failed_awaiting_action", + "current": false + }, + { + "task": "task-current", + "attempt_id": "attempt-current", + "state": "failed_awaiting_action", + "current": true + } + ] + }); + + assert_eq!( + runtime_client::failed_awaiting_action_snapshot(&snapshots), + Some(("task-current", "attempt-current")) + ); +} diff --git a/crates/disasmer-dap/src/variables.rs b/crates/clusterflux-dap/src/variables.rs similarity index 91% rename from crates/disasmer-dap/src/variables.rs rename to crates/clusterflux-dap/src/variables.rs index 57fc58c..97483d8 100644 --- a/crates/disasmer-dap/src/variables.rs +++ b/crates/clusterflux-dap/src/variables.rs @@ -1,7 +1,7 @@ use std::collections::BTreeMap; use std::fs; -use disasmer_core::{Digest, TaskInstanceId}; +use clusterflux_core::{Digest, TaskInstanceId}; use serde_json::{json, Value}; use crate::breakpoints::parse_rust_function_name; @@ -72,9 +72,14 @@ pub(crate) fn variables_response(state: &AdapterState, reference: i64) -> Value "value": thread.task.to_string(), "variablesReference": 0 }, + { + "name": "attempt", + "value": thread.attempt_id, + "variablesReference": 0 + }, { "name": "environment", - "value": if runtime_backed { "not reported by the frozen node participant" } else { task_environment(thread) }, + "value": if runtime_backed { thread.runtime_environment.as_deref().unwrap_or("unknown") } else { task_environment(thread) }, "variablesReference": 0 }, { @@ -95,12 +100,9 @@ pub(crate) fn variables_response(state: &AdapterState, reference: i64) -> Value if state.runtime_backend != crate::virtual_model::RuntimeBackend::Simulated { return json!({ "variables": [ - { - "name": "runtime-vfs-diagnostic", - "value": state.runtime_artifact_path.as_deref().map_or( - "the frozen node participant did not report a VFS or artifact handle snapshot", - |_| "the runtime reported a terminal artifact path; no broader VFS snapshot was reported" - ), + { + "name": "runtime-vfs-diagnostic", + "value": thread.runtime_vfs_checkpoint.as_deref().unwrap_or("the coordinator has no current VFS checkpoint identity"), "variablesReference": 0 }, { @@ -150,6 +152,21 @@ pub(crate) fn variables_response(state: &AdapterState, reference: i64) -> Value "value": thread.name, "variablesReference": 0 }, + { + "name": "task_attempt_id", + "value": thread.attempt_id, + "variablesReference": 0 + }, + { + "name": "node", + "value": thread.runtime_node.as_deref().unwrap_or("unknown"), + "variablesReference": 0 + }, + { + "name": "restart_compatible", + "value": thread.restart_compatible.map_or("unknown".to_owned(), |value| value.to_string()), + "variablesReference": 0 + }, { "name": "debug_epoch", "value": state.epoch, @@ -288,7 +305,7 @@ fn source_locals_variables(state: &AdapterState, thread: &VirtualThread) -> Vec< json!({ "name": local.name, "value": value, - "type": if has_value { "disasmer-source-local" } else { "unavailable-local" }, + "type": if has_value { "clusterflux-source-local" } else { "unavailable-local" }, "variablesReference": 0 }) }) @@ -329,6 +346,14 @@ fn source_locals_for_thread( state: &AdapterState, thread: &VirtualThread, ) -> (Vec, String) { + if state.runtime_backend != crate::virtual_model::RuntimeBackend::Simulated && thread.line <= 0 + { + return ( + Vec::new(), + "cannot be inspected: the coordinator has no current source location for this attempt" + .to_owned(), + ); + } let source_path = crate::source::resolve_source_path(&state.project, &state.source_path); let Ok(source) = fs::read_to_string(&source_path) else { return ( @@ -373,7 +398,8 @@ fn source_locals_for_thread( statement.push_str(lines[index]); } - let runtime_value = infer_disasmer_source_local_value(state, &statement, &runtime_values); + let runtime_value = + infer_clusterflux_source_local_value(state, &statement, &runtime_values); if let Some(value) = runtime_value.clone() { runtime_values.insert(name.clone(), value); } @@ -388,7 +414,7 @@ fn source_locals_for_thread( "cannot be inspected: no source-level `let` locals are in scope for this frame; task args and handles remain visible" .to_owned() } else if locals.iter().any(|local| local.value.is_some()) { - "selected source-level Disasmer API locals are populated from virtual-thread runtime state; non-Disasmer locals remain best-effort until DWARF value snapshots are available" + "selected source-level Clusterflux API locals are populated from virtual-thread runtime state; non-Clusterflux locals remain best-effort until DWARF value snapshots are available" .to_owned() } else { "cannot be inspected: selected source-level local names are best-effort until DWARF/runtime value snapshots are available" @@ -446,7 +472,7 @@ impl SourceLocalRuntimeValue { } } -fn infer_disasmer_source_local_value( +fn infer_clusterflux_source_local_value( state: &AdapterState, statement: &str, runtime_values: &BTreeMap, @@ -499,14 +525,17 @@ fn infer_disasmer_source_local_value( } fn extract_spawn_task_function(statement: &str) -> Option { - for marker in ["disasmer::spawn::async_task(", "disasmer::spawn::task("] { + for marker in [ + "clusterflux::spawn::async_task(", + "clusterflux::spawn::task(", + ] { if let Some(args) = extract_call_arguments(statement, marker) { return args.first().cloned(); } } for marker in [ - "disasmer::spawn::async_task_with_arg(", - "disasmer::spawn::task_with_arg(", + "clusterflux::spawn::async_task_with_arg(", + "clusterflux::spawn::task_with_arg(", ] { if let Some(args) = extract_call_arguments(statement, marker) { return args.get(1).cloned(); diff --git a/crates/disasmer-dap/src/view_state.rs b/crates/clusterflux-dap/src/view_state.rs similarity index 97% rename from crates/disasmer-dap/src/view_state.rs rename to crates/clusterflux-dap/src/view_state.rs index dc92487..0bb7879 100644 --- a/crates/disasmer-dap/src/view_state.rs +++ b/crates/clusterflux-dap/src/view_state.rs @@ -11,7 +11,7 @@ pub(crate) fn normalize_coordinator_endpoint(endpoint: &str) -> String { } pub(crate) fn write_view_state(state: &AdapterState, record: &RuntimeLaunchRecord) -> Result<()> { - let dir = Path::new(&state.project).join(".disasmer"); + let dir = Path::new(&state.project).join(".clusterflux"); fs::create_dir_all(&dir)?; let node_status = if record.placed_task_launched { if record.status_code == Some(0) { diff --git a/crates/disasmer-dap/src/virtual_model.rs b/crates/clusterflux-dap/src/virtual_model.rs similarity index 72% rename from crates/disasmer-dap/src/virtual_model.rs rename to crates/clusterflux-dap/src/virtual_model.rs index a90b3fd..ee4a25b 100644 --- a/crates/disasmer-dap/src/virtual_model.rs +++ b/crates/clusterflux-dap/src/virtual_model.rs @@ -2,7 +2,7 @@ use std::collections::BTreeMap; use std::path::Path; use anyhow::{anyhow, Result}; -use disasmer_core::{ +use clusterflux_core::{ BundleDebugProbe, DebugRuntimeState, Digest, ProcessId, ProjectId, TaskDefinitionId, TaskInstanceId, TenantId, UserId, }; @@ -23,6 +23,7 @@ pub(crate) struct VirtualThread { pub(crate) vfs_ref: i64, pub(crate) command_ref: i64, pub(crate) task: TaskInstanceId, + pub(crate) attempt_id: String, pub(crate) task_definition: TaskDefinitionId, pub(crate) name: String, pub(crate) line: i64, @@ -40,6 +41,10 @@ pub(crate) struct VirtualThread { pub(crate) runtime_task_args: Vec<(String, String)>, pub(crate) runtime_handles: Vec<(String, String)>, pub(crate) runtime_command_status: Option, + pub(crate) runtime_node: Option, + pub(crate) runtime_environment: Option, + pub(crate) runtime_vfs_checkpoint: Option, + pub(crate) restart_compatible: Option, } #[derive(Clone)] @@ -62,6 +67,7 @@ pub(crate) struct AdapterState { pub(crate) runtime_artifact_path: Option, pub(crate) runtime_event_count: usize, pub(crate) coordinator_debug_epoch: Option, + pub(crate) debug_all_threads_stopped: bool, pub(crate) stopped_task: Option, pub(crate) debug_probes: Vec, pub(crate) breakpoints: Vec, @@ -103,7 +109,7 @@ impl Default for AdapterState { project, source_path: "src/build.rs".to_owned(), runtime_backend: RuntimeBackend::Simulated, - coordinator_endpoint: "https://disasmer.michelpaulissen.com".to_owned(), + coordinator_endpoint: "https://clusterflux.michelpaulissen.com".to_owned(), tenant: TenantId::from("tenant"), project_id: ProjectId::from("project"), actor_user: UserId::from("dap"), @@ -115,6 +121,7 @@ impl Default for AdapterState { runtime_artifact_path: None, runtime_event_count: 0, coordinator_debug_epoch: None, + debug_all_threads_stopped: true, stopped_task: None, debug_probes: Vec::new(), breakpoints: Vec::new(), @@ -134,7 +141,7 @@ impl AdapterState { && *line <= i64::from(probe.line_end) }) }) - .map(|probe| format!("disasmer.probe.{}", probe.function)) + .map(|probe| format!("clusterflux.probe.{}", probe.function)) .collect::>(); symbols.sort(); symbols.dedup(); @@ -159,7 +166,7 @@ impl AdapterState { if self.runtime_backend == RuntimeBackend::LiveServices { let session = load_client_session(&self.project).ok_or_else(|| { anyhow!( - "no authenticated CLI session was found for this workspace; run `disasmer login --browser` from {}", + "no authenticated CLI session was found for this workspace; run `clusterflux login --browser` from {}", self.project ) })?; @@ -167,7 +174,7 @@ impl AdapterState { let mismatches = scope_mismatches(configured, &session); if !mismatches.is_empty() { return Err(anyhow!( - "the stored CLI session does not match this workspace ({mismatches}); run `disasmer login --browser` from {}", + "the stored CLI session does not match this workspace ({mismatches}); run `clusterflux login --browser` from {}", self.project )); } @@ -205,24 +212,31 @@ impl AdapterState { self.runtime_artifact_path = None; self.runtime_event_count = 0; self.coordinator_debug_epoch = None; + self.debug_all_threads_stopped = true; self.stopped_task = None; self.debug_probes = crate::breakpoints::load_bundle_debug_probes(&self.project, &self.source_path); self.epoch = 0; self.breakpoints.clear(); - self.threads = launch_threads(&self.entry); - if self.runtime_backend != RuntimeBackend::Simulated { - self.threads.retain(|id, _| *id == MAIN_THREAD); - if let Some(main) = self.threads.get_mut(&MAIN_THREAD) { - main.task = TaskInstanceId::new(self.entry.clone()); - main.name = format!("{} virtual process", self.entry); - } - } + self.threads = if self.runtime_backend == RuntimeBackend::Simulated { + launch_threads(&self.entry) + } else { + BTreeMap::new() + }; Ok(()) } pub(crate) fn apply_runtime_record(&mut self, record: RuntimeLaunchRecord) { self.coordinator_endpoint = record.coordinator.clone(); + if self.runtime_backend != RuntimeBackend::Simulated { + if let Some(snapshots) = record.node_report.get("task_snapshots") { + self.threads = coordinator_threads_from_snapshots( + &self.entry, + snapshots, + record.node_report.get("process_status"), + ); + } + } if !record.placed_task_launched { self.command_status = format!( "virtual process started through {}; no runtime task event observed yet", @@ -245,9 +259,14 @@ impl AdapterState { } let task_failed = record.status_code.is_some_and(|status| status != 0); - self.command_status = if record.all_participants_frozen { + self.command_status = if record.debug_epoch.is_some() { format!( - "frozen through {} at executing Wasm probe {} with debug epoch {}", + "{} through {} at executing Wasm probe {} with debug epoch {}", + if record.all_participants_frozen { + "fully frozen" + } else { + "partially frozen" + }, self.runtime_backend.description(), record.stopped_probe_symbol.as_deref().unwrap_or("unknown"), record.debug_epoch.unwrap_or_default() @@ -264,7 +283,8 @@ impl AdapterState { self.last_task_failed = task_failed; self.runtime_artifact_path = record.artifact_path.clone(); self.runtime_event_count = record.event_count; - if record.all_participants_frozen { + if record.debug_epoch.is_some() { + self.debug_all_threads_stopped = record.all_participants_frozen; self.epoch = record.debug_epoch.unwrap_or(self.epoch); self.coordinator_debug_epoch = record.debug_epoch; self.stopped_task = record.stopped_task.as_deref().map(TaskInstanceId::new); @@ -324,11 +344,17 @@ impl AdapterState { .get("command_status") .and_then(Value::as_str) .map(str::to_owned); - } - } - for thread in self.threads.values_mut() { - if thread.state == DebugRuntimeState::Running { - thread.state = DebugRuntimeState::Frozen; + thread.state = match acknowledgement.get("state").and_then(Value::as_str) { + Some("frozen") => DebugRuntimeState::Frozen, + Some("failed") => DebugRuntimeState::Failed( + acknowledgement + .get("message") + .and_then(Value::as_str) + .unwrap_or("participant failed to freeze") + .to_owned(), + ), + _ => thread.state.clone(), + }; } } if let Some(thread) = self.threads.get_mut(&MAIN_THREAD) { @@ -430,6 +456,7 @@ impl AdapterState { vfs_ref: 7000 + id, command_ref: 8000 + id, task: TaskInstanceId::new(task), + attempt_id: "unknown-attempt".to_owned(), task_definition: TaskDefinitionId::new(task_definition), name, line, @@ -447,6 +474,10 @@ impl AdapterState { runtime_task_args: Vec::new(), runtime_handles: Vec::new(), runtime_command_status: None, + runtime_node: None, + runtime_environment: None, + runtime_vfs_checkpoint: None, + restart_compatible: None, }, ); id @@ -460,11 +491,14 @@ impl AdapterState { record.event_count ); self.last_task_failed = false; - self.runtime_artifact_path = first_event_string(&record.task_events, "artifact_path"); + self.runtime_artifact_path = None; self.runtime_event_count = record.event_count; - self.threads = coordinator_threads_from_events( + self.threads = coordinator_threads_from_snapshots( &self.entry, - &record.task_events, + record + .node_report + .get("task_snapshots") + .unwrap_or(&Value::Null), record.node_report.get("process_status"), ); if let Some(thread) = self.threads.get_mut(&MAIN_THREAD) { @@ -515,7 +549,7 @@ struct ClientSession { fn load_project_scope(project: &str) -> Option { for ancestor in Path::new(project).ancestors() { - let file = ancestor.join(".disasmer/project.json"); + let file = ancestor.join(".clusterflux/project.json"); let Ok(bytes) = std::fs::read(file) else { continue; }; @@ -537,7 +571,7 @@ fn load_project_scope(project: &str) -> Option { fn load_client_session(project: &str) -> Option { for ancestor in Path::new(project).ancestors() { - let file = ancestor.join(".disasmer/session.json"); + let file = ancestor.join(".clusterflux/session.json"); let Ok(bytes) = std::fs::read(file) else { continue; }; @@ -616,6 +650,46 @@ pub(crate) fn process_id(project: &str, entry: &str) -> ProcessId { ProcessId::new(format!("vp-{suffix}")) } +fn coordinator_main_thread(entry: &str, task: &str, task_definition: &str) -> VirtualThread { + VirtualThread { + id: MAIN_THREAD, + frame_id: 1_001, + locals_ref: 2_001, + wasm_locals_ref: 2_501, + args_ref: 3_001, + runtime_ref: 3_501, + output_ref: 4_001, + target_ref: 4_501, + vfs_ref: 5_001, + command_ref: 5_501, + task: TaskInstanceId::from(task), + attempt_id: "main:unknown".to_owned(), + task_definition: TaskDefinitionId::from(task_definition), + name: format!("{entry} coordinator main ({task})"), + line: 0, + state: DebugRuntimeState::Running, + freeze_supported: true, + recent_output: Vec::new(), + stdout_bytes: 0, + stderr_bytes: 0, + stdout_tail: String::new(), + stderr_tail: String::new(), + stdout_truncated: false, + stderr_truncated: false, + runtime_stack_frames: Vec::new(), + wasm_local_values: Vec::new(), + runtime_task_args: Vec::new(), + runtime_handles: Vec::new(), + runtime_command_status: None, + runtime_node: Some("coordinator-main".to_owned()), + runtime_environment: None, + runtime_vfs_checkpoint: None, + restart_compatible: None, + } +} + +#[cfg(test)] +#[allow(dead_code)] fn coordinator_threads_from_events( entry: &str, task_events: &Value, @@ -680,7 +754,169 @@ fn coordinator_threads_from_events( threads } -fn coordinator_event_thread(id: i64, event_index: usize, event: &Value) -> Option { +fn coordinator_threads_from_snapshots( + entry: &str, + task_snapshots: &Value, + process_status: Option<&Value>, +) -> BTreeMap { + let mut threads = BTreeMap::new(); + if let Some(status) = process_status { + if let Some(task) = status.get("main_task_instance").and_then(Value::as_str) { + let task_definition = status + .get("main_task_definition") + .and_then(Value::as_str) + .unwrap_or(entry); + let mut main = coordinator_main_thread(entry, task, task_definition); + main.attempt_id = format!( + "main:{}", + status + .get("coordinator_epoch") + .and_then(Value::as_u64) + .unwrap_or_default() + ); + main.line = 0; + main.runtime_stack_frames.clear(); + main.state = if status + .get("main_debug_epoch") + .and_then(Value::as_u64) + .is_some() + { + DebugRuntimeState::Frozen + } else { + DebugRuntimeState::Running + }; + main.runtime_node = Some("coordinator-main".to_owned()); + main.runtime_environment = Some("coordinator-capless".to_owned()); + main.runtime_vfs_checkpoint = status + .get("coordinator_epoch") + .and_then(Value::as_u64) + .map(|epoch| format!("vfs-epoch:{epoch}")); + main.restart_compatible = Some(false); + main.recent_output.clear(); + threads.insert(MAIN_THREAD, main); + } + } + + let Some(snapshots) = task_snapshots.get("snapshots").and_then(Value::as_array) else { + return threads; + }; + let mut next_id = if threads.contains_key(&MAIN_THREAD) { + LINUX_THREAD + } else { + MAIN_THREAD + }; + for snapshot in snapshots { + if snapshot.get("current").and_then(Value::as_bool) != Some(true) + || !matches!( + snapshot.get("state").and_then(Value::as_str), + Some("queued" | "running" | "failed_awaiting_action") + ) + { + continue; + } + let Some(task) = snapshot.get("task").and_then(Value::as_str) else { + continue; + }; + let Some(task_definition) = snapshot.get("task_definition").and_then(Value::as_str) else { + continue; + }; + let attempt_id = snapshot + .get("attempt_id") + .and_then(Value::as_str) + .unwrap_or("unknown-attempt") + .to_owned(); + let state = match snapshot.get("state").and_then(Value::as_str) { + Some("queued" | "running") => DebugRuntimeState::Running, + Some("failed_awaiting_action") => { + DebugRuntimeState::Failed("failed awaiting operator action".to_owned()) + } + _ => continue, + }; + let argument_summary = snapshot + .get("argument_summary") + .and_then(Value::as_array) + .into_iter() + .flatten() + .enumerate() + .filter_map(|(index, value)| Some((format!("arg_{index}"), value.as_str()?.to_owned()))) + .collect(); + let handle_summary = snapshot + .get("handle_summary") + .and_then(Value::as_array) + .into_iter() + .flatten() + .enumerate() + .filter_map(|(index, value)| { + Some((format!("handle_{index}"), value.as_str()?.to_owned())) + }) + .collect(); + let display_name = snapshot + .get("display_name") + .and_then(Value::as_str) + .unwrap_or(task_definition); + let short_attempt = attempt_id.chars().take(16).collect::(); + let id = next_id; + next_id += 1; + threads.insert( + id, + VirtualThread { + id, + frame_id: 1000 + id, + locals_ref: 5000 + id, + wasm_locals_ref: 9000 + id, + args_ref: 2000 + id, + runtime_ref: 3000 + id, + output_ref: 4000 + id, + target_ref: 6000 + id, + vfs_ref: 7000 + id, + command_ref: 8000 + id, + task: TaskInstanceId::from(task), + attempt_id, + task_definition: TaskDefinitionId::from(task_definition), + name: format!("{display_name} ({task}, attempt {short_attempt})"), + line: snapshot + .get("source_line") + .and_then(Value::as_i64) + .unwrap_or(0), + state, + freeze_supported: true, + recent_output: Vec::new(), + stdout_bytes: 0, + stderr_bytes: 0, + stdout_tail: String::new(), + stderr_tail: String::new(), + stdout_truncated: false, + stderr_truncated: false, + runtime_stack_frames: Vec::new(), + wasm_local_values: Vec::new(), + runtime_task_args: argument_summary, + runtime_handles: handle_summary, + runtime_command_status: snapshot + .get("command_state") + .and_then(Value::as_str) + .map(str::to_owned), + runtime_node: snapshot + .get("node") + .and_then(Value::as_str) + .map(str::to_owned), + runtime_environment: snapshot + .get("environment_id") + .and_then(Value::as_str) + .map(str::to_owned), + runtime_vfs_checkpoint: snapshot + .get("vfs_checkpoint") + .and_then(Value::as_str) + .map(str::to_owned), + restart_compatible: snapshot.get("restart_compatible").and_then(Value::as_bool), + }, + ); + } + threads +} + +#[cfg(test)] +#[allow(dead_code)] +fn coordinator_event_thread(id: i64, _event_index: usize, event: &Value) -> Option { let task = event.get("task").and_then(Value::as_str)?; let task_definition = event.get("task_definition").and_then(Value::as_str)?; let definition_name = task_definition.replace(['_', '-'], " "); @@ -729,9 +965,14 @@ fn coordinator_event_thread(id: i64, event_index: usize, event: &Value) -> Optio vfs_ref: 7000 + id, command_ref: 8000 + id, task: TaskInstanceId::from(task), + attempt_id: event + .get("attempt_id") + .and_then(Value::as_str) + .unwrap_or("unknown-attempt") + .to_owned(), task_definition: TaskDefinitionId::from(task_definition), name, - line: 42 + event_index as i64, + line: 0, state, freeze_supported: true, recent_output: vec![format!( @@ -762,14 +1003,9 @@ fn coordinator_event_thread(id: i64, event_index: usize, event: &Value) -> Optio runtime_task_args: Vec::new(), runtime_handles: Vec::new(), runtime_command_status: None, + runtime_node: event.get("node").and_then(Value::as_str).map(str::to_owned), + runtime_environment: None, + runtime_vfs_checkpoint: None, + restart_compatible: None, }) } - -fn first_event_string(task_events: &Value, field: &str) -> Option { - task_events - .get("events") - .and_then(Value::as_array) - .and_then(|events| events.iter().find_map(|event| event.get(field))) - .and_then(Value::as_str) - .map(str::to_owned) -} diff --git a/crates/disasmer-macros/Cargo.toml b/crates/clusterflux-macros/Cargo.toml similarity index 91% rename from crates/disasmer-macros/Cargo.toml rename to crates/clusterflux-macros/Cargo.toml index 4fc912e..2ec4801 100644 --- a/crates/disasmer-macros/Cargo.toml +++ b/crates/clusterflux-macros/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "disasmer-macros" +name = "clusterflux-macros" version = "0.1.0" edition.workspace = true license.workspace = true diff --git a/crates/disasmer-macros/src/lib.rs b/crates/clusterflux-macros/src/lib.rs similarity index 62% rename from crates/disasmer-macros/src/lib.rs rename to crates/clusterflux-macros/src/lib.rs index f35e2b2..52bb7e6 100644 --- a/crates/disasmer-macros/src/lib.rs +++ b/crates/clusterflux-macros/src/lib.rs @@ -2,8 +2,8 @@ use proc_macro::TokenStream; use quote::{format_ident, quote, ToTokens}; use sha2::{Digest as _, Sha256}; use syn::{ - parse::Parser, parse_macro_input, Data, DeriveInput, Expr, Fields, FnArg, ItemFn, Lit, - LitByteStr, Meta, ReturnType, Token, Type, + parse::Parser, parse_macro_input, Data, DeriveInput, Expr, FnArg, ItemFn, Lit, LitByteStr, + Meta, ReturnType, Token, Type, }; #[proc_macro_derive(TaskArg)] @@ -11,134 +11,47 @@ pub fn derive_task_arg(item: TokenStream) -> TokenStream { let input = parse_macro_input!(item as DeriveInput); let name = &input.ident; let (impl_generics, type_generics, where_clause) = input.generics.split_for_impl(); - let collect = - match &input.data { - Data::Struct(data) => collect_struct_fields(&data.fields), - Data::Enum(data) => { - let arms = - data.variants.iter().map(|variant| { - let variant_name = &variant.ident; - match &variant.fields { - Fields::Named(fields) => { - let field_names = fields - .named - .iter() - .map(|field| field.ident.as_ref().expect("named field")) - .collect::>(); - let bindings = field_names - .iter() - .map(|field| format_ident!("__disasmer_{field}")) - .collect::>(); - let visits = bindings.iter().map(|binding| quote! { - ::disasmer::__private::CollectTaskHandles::collect_task_handles( - #binding, - handles, - )?; - }); - quote! { - Self::#variant_name { #(#field_names: #bindings),* } => { - #(#visits)* - } - } - } - Fields::Unnamed(fields) => { - let bindings = (0..fields.unnamed.len()) - .map(|index| format_ident!("__disasmer_field_{index}")) - .collect::>(); - let visits = bindings.iter().map(|binding| quote! { - ::disasmer::__private::CollectTaskHandles::collect_task_handles( - #binding, - handles, - )?; - }); - quote! { - Self::#variant_name(#(#bindings),*) => { - #(#visits)* - } - } - } - Fields::Unit => quote! { Self::#variant_name => {} }, - } - }); - quote! { match self { #(#arms),* } } - } - Data::Union(union) => { - return syn::Error::new_spanned( - union.union_token, - "TaskArg cannot be derived for unions", - ) - .into_compile_error() - .into(); - } - }; - quote! { - impl #impl_generics ::disasmer::__private::CollectTaskHandles - for #name #type_generics #where_clause - { - fn collect_task_handles( - &self, - handles: &mut ::std::vec::Vec<::disasmer::core::TaskBoundaryHandle>, - ) -> ::std::result::Result<(), ::disasmer::TaskArgError> { - #collect - Ok(()) - } + match &input.data { + Data::Struct(_) | Data::Enum(_) => {} + Data::Union(union) => { + return syn::Error::new_spanned( + union.union_token, + "TaskArg cannot be derived for unions", + ) + .into_compile_error() + .into(); } + }; + quote! { + impl #impl_generics ::clusterflux::__private::CollectTaskHandles + for #name #type_generics #where_clause + {} - impl #impl_generics ::disasmer::TaskArg for #name #type_generics #where_clause { - fn task_arg_kind(&self) -> ::disasmer::TaskArgKind { - ::disasmer::TaskArgKind::Structured + impl #impl_generics ::clusterflux::TaskArg for #name #type_generics #where_clause { + fn task_arg_kind(&self) -> ::clusterflux::TaskArgKind { + ::clusterflux::TaskArgKind::Structured } fn task_boundary_value( &self, ) -> ::std::result::Result< - ::disasmer::core::TaskBoundaryValue, - ::disasmer::TaskArgError, + ::clusterflux::core::TaskBoundaryValue, + ::clusterflux::TaskArgError, > { - ::disasmer::__private::structured_task_boundary(self) + ::clusterflux::__private::structured_task_boundary(self) } } } .into() } -fn collect_struct_fields(fields: &Fields) -> proc_macro2::TokenStream { - match fields { - Fields::Named(fields) => { - let visits = fields.named.iter().map(|field| { - let field = field.ident.as_ref().expect("named field"); - quote! { - ::disasmer::__private::CollectTaskHandles::collect_task_handles( - &self.#field, - handles, - )?; - } - }); - quote! { #(#visits)* } - } - Fields::Unnamed(fields) => { - let visits = fields.unnamed.iter().enumerate().map(|(index, _)| { - let index = syn::Index::from(index); - quote! { - ::disasmer::__private::CollectTaskHandles::collect_task_handles( - &self.#index, - handles, - )?; - } - }); - quote! { #(#visits)* } - } - Fields::Unit => quote! {}, - } -} - #[proc_macro_attribute] pub fn main(attr: TokenStream, item: TokenStream) -> TokenStream { let function = parse_macro_input!(item as ItemFn); if !function.sig.inputs.is_empty() { return syn::Error::new_spanned( &function.sig, - "#[disasmer::main] requires a function with no arguments", + "#[clusterflux::main] requires a function with no arguments", ) .into_compile_error() .into(); @@ -151,13 +64,13 @@ pub fn main(attr: TokenStream, item: TokenStream) -> TokenStream { .unwrap_or(&function_name), ); let descriptor = format_ident!( - "__DISASMER_ENTRYPOINT_{}", + "__CLUSTERFLUX_ENTRYPOINT_{}", function_name.to_ascii_uppercase() ); let argument_schema = argument_schema(&function); let result_schema = result_schema(&function); let stable_id = stable_digest( - "disasmer-entrypoint-id:v1", + "clusterflux-entrypoint-id:v1", &[ &entrypoint_name, &function_name, @@ -166,15 +79,15 @@ pub fn main(attr: TokenStream, item: TokenStream) -> TokenStream { ], ); let export_name = format!( - "disasmer_entry_v1_{}", + "clusterflux_entry_v1_{}", stable_id.trim_start_matches("sha256:") ); let export_wrapper = format_ident!( - "__disasmer_entry_export_{}", + "__clusterflux_entry_export_{}", function_name.to_ascii_lowercase() ); let export_invocation = invocation_helper(&function, true, quote! { #stable_id }); - let probe_symbol = format!("disasmer.probe.{function_name}"); + let probe_symbol = format!("clusterflux.probe.{function_name}"); let manifest = serde_json::json!({ "kind": "entrypoint", "name": entrypoint_name, @@ -188,7 +101,7 @@ pub fn main(attr: TokenStream, item: TokenStream) -> TokenStream { }); let manifest = manifest_record(manifest); let manifest_static = format_ident!( - "__DISASMER_ENTRYPOINT_MANIFEST_{}", + "__CLUSTERFLUX_ENTRYPOINT_MANIFEST_{}", function_name.to_ascii_uppercase() ); @@ -196,7 +109,7 @@ pub fn main(attr: TokenStream, item: TokenStream) -> TokenStream { #function #[doc(hidden)] - pub const #descriptor: ::disasmer::EntrypointDescriptor = ::disasmer::EntrypointDescriptor { + pub const #descriptor: ::clusterflux::EntrypointDescriptor = ::clusterflux::EntrypointDescriptor { name: #entrypoint_name, function: #function_name, export: #export_name, @@ -209,15 +122,15 @@ pub fn main(attr: TokenStream, item: TokenStream) -> TokenStream { #[doc(hidden)] #[used] - #[cfg_attr(target_arch = "wasm32", unsafe(link_section = "disasmer.entrypoints"))] + #[cfg_attr(target_arch = "wasm32", unsafe(link_section = "clusterflux.entrypoints"))] pub static #manifest_static: [u8; #manifest.len()] = *#manifest; #[doc(hidden)] #[cfg(target_arch = "wasm32")] #[unsafe(export_name = #export_name)] pub extern "C" fn #export_wrapper(input_pointer: u32, input_length: u32) -> u64 { - ::disasmer::debug::probe(#probe_symbol) - .expect("Disasmer entrypoint debug probe host call failed"); + ::clusterflux::debug::probe(#probe_symbol) + .expect("Clusterflux entrypoint debug probe host call failed"); #export_invocation } } @@ -230,23 +143,23 @@ pub fn task(attr: TokenStream, item: TokenStream) -> TokenStream { if function.sig.inputs.len() > 1 { return syn::Error::new_spanned( &function.sig, - "#[disasmer::task] requires a function with zero or one owned argument", + "#[clusterflux::task] requires a function with zero or one owned argument", ) .into_compile_error() .into(); } let function_name = function.sig.ident.to_string(); let (task_name, required_capabilities) = descriptor_options(attr, &function_name); - let descriptor = format_ident!("__DISASMER_TASK_{}", function_name.to_ascii_uppercase()); + let descriptor = format_ident!("__CLUSTERFLUX_TASK_{}", function_name.to_ascii_uppercase()); let argument_schema = argument_schema(&function); let result_schema = result_schema(&function); let capability_schema = required_capabilities.join(","); let stable_id = stable_digest( - "disasmer-task-id:v1", + "clusterflux-task-id:v1", &[&task_name, &function_name, &argument_schema, &result_schema], ); let restart_compatibility_hash = stable_digest( - "disasmer-task-restart:v1", + "clusterflux-task-restart:v1", &[ &task_name, &argument_schema, @@ -256,15 +169,15 @@ pub fn task(attr: TokenStream, item: TokenStream) -> TokenStream { ], ); let export_name = format!( - "disasmer_task_v1_{}", + "clusterflux_task_v1_{}", stable_id.trim_start_matches("sha256:") ); let export_wrapper = format_ident!( - "__disasmer_task_export_{}", + "__clusterflux_task_export_{}", function_name.to_ascii_lowercase() ); let export_invocation = invocation_helper(&function, false, quote! { #task_name }); - let probe_symbol = format!("disasmer.probe.{function_name}"); + let probe_symbol = format!("clusterflux.probe.{function_name}"); let manifest = serde_json::json!({ "kind": "task", "name": task_name, @@ -280,7 +193,7 @@ pub fn task(attr: TokenStream, item: TokenStream) -> TokenStream { }); let manifest = manifest_record(manifest); let manifest_static = format_ident!( - "__DISASMER_TASK_MANIFEST_{}", + "__CLUSTERFLUX_TASK_MANIFEST_{}", function_name.to_ascii_uppercase() ); @@ -288,7 +201,7 @@ pub fn task(attr: TokenStream, item: TokenStream) -> TokenStream { #function #[doc(hidden)] - pub const #descriptor: ::disasmer::TaskDescriptor = ::disasmer::TaskDescriptor { + pub const #descriptor: ::clusterflux::TaskDescriptor = ::clusterflux::TaskDescriptor { name: #task_name, function: #function_name, export: #export_name, @@ -307,15 +220,15 @@ pub fn task(attr: TokenStream, item: TokenStream) -> TokenStream { #[doc(hidden)] #[used] - #[cfg_attr(target_arch = "wasm32", unsafe(link_section = "disasmer.tasks"))] + #[cfg_attr(target_arch = "wasm32", unsafe(link_section = "clusterflux.tasks"))] pub static #manifest_static: [u8; #manifest.len()] = *#manifest; #[doc(hidden)] #[cfg(target_arch = "wasm32")] #[unsafe(export_name = #export_name)] pub extern "C" fn #export_wrapper(input_pointer: u32, input_length: u32) -> u64 { - ::disasmer::debug::probe(#probe_symbol) - .expect("Disasmer task debug probe host call failed"); + ::clusterflux::debug::probe(#probe_symbol) + .expect("Clusterflux task debug probe host call failed"); #export_invocation } } @@ -343,7 +256,7 @@ fn invocation_helper( }; match function.sig.inputs.first() { None => quote! { - ::disasmer::__private::#helper( + ::clusterflux::__private::#helper( #expected_definition, input_pointer, input_length, @@ -353,7 +266,7 @@ fn invocation_helper( Some(FnArg::Typed(argument)) if !is_entrypoint => { let argument_type = &argument.ty; quote! { - ::disasmer::__private::#helper( + ::clusterflux::__private::#helper( #expected_definition, input_pointer, input_length, @@ -362,10 +275,11 @@ fn invocation_helper( } } Some(FnArg::Typed(_)) => unreachable!("entrypoint arguments were rejected"), - Some(FnArg::Receiver(receiver)) => { - syn::Error::new_spanned(receiver, "#[disasmer::task] cannot be applied to a method") - .into_compile_error() - } + Some(FnArg::Receiver(receiver)) => syn::Error::new_spanned( + receiver, + "#[clusterflux::task] cannot be applied to a method", + ) + .into_compile_error(), } } diff --git a/crates/disasmer-node/Cargo.toml b/crates/clusterflux-node/Cargo.toml similarity index 61% rename from crates/disasmer-node/Cargo.toml rename to crates/clusterflux-node/Cargo.toml index f65c4d2..75d142c 100644 --- a/crates/disasmer-node/Cargo.toml +++ b/crates/clusterflux-node/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "disasmer-node" +name = "clusterflux-node" version = "0.1.0" edition.workspace = true license.workspace = true @@ -7,9 +7,9 @@ repository.workspace = true [dependencies] base64.workspace = true -disasmer-core = { path = "../disasmer-core" } -disasmer-control = { path = "../disasmer-control" } -disasmer-wasm-runtime = { path = "../disasmer-wasm-runtime" } +clusterflux-core = { path = "../clusterflux-core" } +clusterflux-control = { path = "../clusterflux-control" } +clusterflux-wasm-runtime = { path = "../clusterflux-wasm-runtime" } libc.workspace = true quinn.workspace = true rcgen.workspace = true @@ -20,3 +20,6 @@ tempfile.workspace = true thiserror.workspace = true tokio.workspace = true wasmparser.workspace = true + +[dev-dependencies] +wat = "=1.253.0" diff --git a/crates/disasmer-node/src/assignment_runner.rs b/crates/clusterflux-node/src/assignment_runner.rs similarity index 89% rename from crates/disasmer-node/src/assignment_runner.rs rename to crates/clusterflux-node/src/assignment_runner.rs index 3ec434f..2950844 100644 --- a/crates/disasmer-node/src/assignment_runner.rs +++ b/crates/clusterflux-node/src/assignment_runner.rs @@ -1,4 +1,4 @@ -use std::collections::{BTreeSet, HashMap}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::io::Read; use std::path::PathBuf; use std::process::Stdio; @@ -12,16 +12,16 @@ use std::os::unix::process::CommandExt; use crate::coordinator_session::CoordinatorSession; use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; -use disasmer_core::{ - Capability, CommandInvocation, Digest, EnvironmentRequirements, NodeId, ProcessId, ProjectId, - TaskBoundaryValue, TaskDispatch, TaskInstanceId, TaskJoinResult, TaskJoinState, TaskSpec, - TenantId, VfsManifest, WasmExportAbi, WasmHostDebugProbeRequest, WasmHostDebugProbeResult, +use clusterflux_core::{ + Capability, CommandInvocation, Digest, NodeId, ProcessId, ProjectId, TaskBoundaryValue, + TaskDispatch, TaskInstanceId, TaskJoinResult, TaskJoinState, TaskSpec, TenantId, VfsManifest, + WasmExportAbi, WasmHostDebugProbeRequest, WasmHostDebugProbeResult, WasmHostSourceSnapshotRequest, WasmHostSourceSnapshotResult, WasmHostTaskHandle, WasmHostTaskJoinRequest, WasmHostTaskJoinResult, WasmHostTaskStartRequest, WasmHostVfsOperation, WasmHostVfsRequest, WasmHostVfsResult, WasmTaskInvocation, WasmTaskOutcome, }; -use disasmer_node::{ +use clusterflux_node::{ BackendError, CommandOutput, LinuxRootlessPodmanBackend, LocalCheckoutTaskRequest, LocalSourceCheckout, PodmanCommand, ProcessOutput, ProcessRunner, WasmDebugControl, WasmTaskHost, WasmtimeTaskRuntime, @@ -41,9 +41,9 @@ mod control_watcher; use control_watcher::{spawn_task_control_watcher, spawn_worker_shutdown_watcher}; mod validation; use validation::{ - authorize_command_network, capability_from_descriptor, is_secret_environment_name, - redact_configured_values, require_command_environment, resolve_task_export, task_descriptors, - verify_environment_digest, verify_source_snapshot, + authorize_command_network, bundle_environments, capability_from_descriptor, + is_secret_environment_name, redact_configured_values, require_command_environment, + resolve_task_export, task_descriptors, verify_environment_digest, verify_source_snapshot, }; pub(crate) fn run_verified_wasmtime_assignment( @@ -51,9 +51,9 @@ pub(crate) fn run_verified_wasmtime_assignment( task: &RuntimeTask, node_private_key: &str, ) -> Result<(CommandOutput, VfsManifest, Option), Box> { - if std::env::var_os("DISASMER_DEBUG_CONTROL_TRACE").is_some() { + if std::env::var_os("CLUSTERFLUX_DEBUG_CONTROL_TRACE").is_some() { eprintln!( - "disasmer debug control: starting Wasm assignment for task {}", + "clusterflux debug control: starting Wasm assignment for task {}", task.task ); } @@ -68,6 +68,13 @@ pub(crate) fn run_verified_wasmtime_assignment( .as_ref() .expect("caller checked wasm_module_base64"); let module = BASE64_STANDARD.decode(module_base64)?; + let actual_bundle_digest = Digest::sha256(&module); + if &actual_bundle_digest != expected_bundle_digest { + return Err(format!( + "bundle digest mismatch: expected {expected_bundle_digest}, received {actual_bundle_digest}" + ) + .into()); + } let task_spec = task.task_spec.as_ref().ok_or_else(|| { std::io::Error::new( std::io::ErrorKind::InvalidInput, @@ -107,9 +114,9 @@ pub(crate) fn run_verified_wasmtime_assignment( &module, )?), )?; - if std::env::var_os("DISASMER_DEBUG_CONTROL_TRACE").is_some() { + if std::env::var_os("CLUSTERFLUX_DEBUG_CONTROL_TRACE").is_some() { eprintln!( - "disasmer debug control: Wasm assignment returned for task {}", + "clusterflux debug control: Wasm assignment returned for task {}", task.task ); } @@ -154,7 +161,7 @@ struct CoordinatorWasmTaskHost { process: String, parent_task: String, epoch: u64, - bundle_digest: disasmer_core::Digest, + bundle_digest: clusterflux_core::Digest, wasm_module_base64: String, node_private_key: String, allow_command: bool, @@ -165,6 +172,7 @@ struct CoordinatorWasmTaskHost { source_snapshot: Option, output_root: PathBuf, task_descriptors: HashMap, + environments: BTreeMap, next_handle_id: u64, handles: Arc>>, command_status: Arc>>, @@ -233,19 +241,19 @@ impl CoordinatorWasmTaskHost { allow_command: task_spec .required_capabilities .contains(&Capability::Command) - && disasmer_core::NodeCapabilities::detect_current() + && clusterflux_core::NodeCapabilities::detect_current() .capabilities .contains(&Capability::Command), allow_network: task_spec .required_capabilities .contains(&Capability::Network) - && disasmer_core::NodeCapabilities::detect_current() + && clusterflux_core::NodeCapabilities::detect_current() .capabilities .contains(&Capability::Network), allow_source_snapshot: task_spec .required_capabilities .contains(&Capability::SourceFilesystem) - && disasmer_core::NodeCapabilities::detect_current() + && clusterflux_core::NodeCapabilities::detect_current() .capabilities .contains(&Capability::SourceFilesystem), environment_id: task_spec.environment_id.clone(), @@ -256,6 +264,7 @@ impl CoordinatorWasmTaskHost { // discovery is required only if the guest actually invokes task_start_v1; module // compilation/digest verification remains authoritative for malformed input. task_descriptors: task_descriptors(module).unwrap_or_default(), + environments: bundle_environments(module)?, next_handle_id: 1, handles, command_status, @@ -425,24 +434,18 @@ impl WasmTaskHost for CoordinatorWasmTaskHost { ) }) .collect::, _>>()?; - let resolved_environment = request.environment_id.as_deref().and_then(|name| { - self.args.project_root.as_ref().and_then(|root| { - disasmer_core::discover_environments(root) - .ok()? - .into_iter() - .find(|environment| environment.name == name) + let resolved_environment = request + .environment_id + .as_deref() + .map(|name| { + self.environments.get(name).cloned().ok_or_else(|| { + format!("bundle environment manifest has no environment `{name}`") + }) }) - }); + .transpose()?; let environment = resolved_environment .as_ref() - .map(|environment| environment.requirements.clone()) - .or_else(|| match request.environment_id.as_deref() { - Some(environment) if environment.eq_ignore_ascii_case("windows") => { - Some(EnvironmentRequirements::windows_command_dev()) - } - Some(_) => Some(EnvironmentRequirements::linux_container()), - None => Some(EnvironmentRequirements::unconstrained()), - }); + .map(|environment| environment.requirements.clone()); let environment_digest = resolved_environment .as_ref() .map(|environment| environment.digest.clone()); @@ -450,8 +453,10 @@ impl WasmTaskHost for CoordinatorWasmTaskHost { required_capabilities.extend(environment.capabilities.iter().cloned()); } let handle_id = self.next_handle_id; - let task_instance = - disasmer_core::TaskInstanceId::new(format!("{}:child:{}", self.parent_task, handle_id)); + let task_instance = clusterflux_core::TaskInstanceId::new(format!( + "{}:child:{}", + self.parent_task, handle_id + )); let required_artifacts = request .args .iter() @@ -501,6 +506,7 @@ impl WasmTaskHost for CoordinatorWasmTaskHost { required_artifacts, args: request.args, vfs_epoch: self.epoch, + failure_policy: request.failure_policy, bundle_digest: Some(self.bundle_digest.clone()), }; let mut session = self.session()?; @@ -531,7 +537,7 @@ impl WasmTaskHost for CoordinatorWasmTaskHost { .map_err(|_| "Wasm task handle registry lock was poisoned".to_owned())? .insert(handle_id, spec.clone()); Ok(WasmHostTaskHandle { - abi_version: disasmer_core::WASM_TASK_ABI_VERSION, + abi_version: clusterflux_core::WASM_TASK_ABI_VERSION, handle_id, task_spec: spec, }) @@ -541,7 +547,7 @@ impl WasmTaskHost for CoordinatorWasmTaskHost { &mut self, request: WasmHostTaskJoinRequest, ) -> Result { - if request.abi_version != disasmer_core::WASM_TASK_ABI_VERSION { + if request.abi_version != clusterflux_core::WASM_TASK_ABI_VERSION { return Err(format!( "unsupported Wasm task ABI version {}", request.abi_version @@ -588,7 +594,7 @@ impl WasmTaskHost for CoordinatorWasmTaskHost { .map_err(|_| "Wasm task handle registry lock was poisoned".to_owned())? .remove(&request.handle_id); return Ok(WasmHostTaskJoinResult { - abi_version: disasmer_core::WASM_TASK_ABI_VERSION, + abi_version: clusterflux_core::WASM_TASK_ABI_VERSION, task_instance: spec.task_instance, result, }); @@ -617,8 +623,8 @@ impl WasmTaskHost for CoordinatorWasmTaskHost { fn run_command( &mut self, - request: disasmer_core::WasmHostCommandRequest, - ) -> Result { + request: clusterflux_core::WasmHostCommandRequest, + ) -> Result { request.validate()?; if !self.allow_command { return Err( @@ -685,36 +691,36 @@ impl WasmTaskHost for CoordinatorWasmTaskHost { artifacts.overlay_mut(), ) .map_err(|error| { - if std::env::var_os("DISASMER_DEBUG_CONTROL_TRACE").is_some() { - eprintln!("disasmer command host failed: {error}"); + if std::env::var_os("CLUSTERFLUX_DEBUG_CONTROL_TRACE").is_some() { + eprintln!("clusterflux command host failed: {error}"); } error.to_string() })?; let stdout = redact_configured_values(output.stdout, &configured_secrets); let stderr = redact_configured_values(output.stderr, &configured_secrets); - if std::env::var_os("DISASMER_DEBUG_CONTROL_TRACE").is_some() { + if std::env::var_os("CLUSTERFLUX_DEBUG_CONTROL_TRACE").is_some() { eprintln!( - "disasmer command host completed: status={:?} stdout={:?} stderr={:?}", + "clusterflux command host completed: status={:?} stdout={:?} stderr={:?}", output.status_code, stdout, stderr ); } - return Ok(disasmer_core::WasmHostCommandResult { - abi_version: disasmer_core::WASM_TASK_ABI_VERSION, + Ok(clusterflux_core::WasmHostCommandResult { + abi_version: clusterflux_core::WASM_TASK_ABI_VERSION, status_code: output.status_code, stdout, stderr, stdout_truncated: output.stdout_truncated, stderr_truncated: output.stderr_truncated, - }); + }) } fn poll_task_control( &mut self, - request: disasmer_core::WasmHostTaskControlRequest, - ) -> Result { + request: clusterflux_core::WasmHostTaskControlRequest, + ) -> Result { request.validate()?; - Ok(disasmer_core::WasmHostTaskControlResult { - abi_version: disasmer_core::WASM_TASK_ABI_VERSION, + Ok(clusterflux_core::WasmHostTaskControlResult { + abi_version: clusterflux_core::WASM_TASK_ABI_VERSION, cancellation_requested: self.cancellation_requested.load(Ordering::Acquire), }) } @@ -745,7 +751,7 @@ impl WasmTaskHost for CoordinatorWasmTaskHost { )); } let result = WasmHostDebugProbeResult { - abi_version: disasmer_core::WASM_TASK_ABI_VERSION, + abi_version: clusterflux_core::WASM_TASK_ABI_VERSION, breakpoint_matched: response .get("breakpoint_matched") .and_then(serde_json::Value::as_bool) @@ -783,8 +789,8 @@ impl WasmTaskHost for CoordinatorWasmTaskHost { } }; Ok(WasmHostVfsResult { - abi_version: disasmer_core::WASM_TASK_ABI_VERSION, - artifact: disasmer_core::ArtifactHandle { + abi_version: clusterflux_core::WASM_TASK_ABI_VERSION, + artifact: clusterflux_core::ArtifactHandle { id: retained.id, digest: retained.digest, size_bytes: retained.size_bytes, @@ -810,7 +816,7 @@ impl WasmTaskHost for CoordinatorWasmTaskHost { })?; let snapshot = snapshot_project(project_root)?; Ok(WasmHostSourceSnapshotResult { - abi_version: disasmer_core::WASM_TASK_ABI_VERSION, + abi_version: clusterflux_core::WASM_TASK_ABI_VERSION, snapshot: snapshot.digest, }) } diff --git a/crates/disasmer-node/src/assignment_runner/control_watcher.rs b/crates/clusterflux-node/src/assignment_runner/control_watcher.rs similarity index 88% rename from crates/disasmer-node/src/assignment_runner/control_watcher.rs rename to crates/clusterflux-node/src/assignment_runner/control_watcher.rs index 3503006..51edab4 100644 --- a/crates/disasmer-node/src/assignment_runner/control_watcher.rs +++ b/crates/clusterflux-node/src/assignment_runner/control_watcher.rs @@ -4,8 +4,8 @@ use std::sync::{Arc, Mutex}; use std::thread; use std::time::Duration; -use disasmer_core::TaskSpec; -use disasmer_node::WasmDebugControl; +use clusterflux_core::TaskSpec; +use clusterflux_node::WasmDebugControl; use crate::coordinator_session::CoordinatorSession; use crate::daemon::{worker_shutdown_requested, Args}; @@ -117,21 +117,21 @@ pub(super) fn spawn_task_control_watcher( ) { let (state, message) = match command { "freeze" => { - if std::env::var_os("DISASMER_DEBUG_CONTROL_TRACE").is_some() { + if std::env::var_os("CLUSTERFLUX_DEBUG_CONTROL_TRACE").is_some() { eprintln!( - "disasmer debug control: node received freeze for epoch {epoch} task {task} (debug={:?})", + "clusterflux debug control: node received freeze for epoch {epoch} task {task} (debug={:?})", Arc::as_ptr(&debug_control) ); } debug_control.request_freeze(epoch); - if debug_control.wait_until_frozen(epoch, Duration::from_secs(60)) { + if debug_control.wait_until_frozen(epoch, Duration::from_secs(5)) { ("frozen", None) } else { debug_control.request_resume(epoch); ( "failed", Some( - "node execution did not reach a freezeable Wasm safepoint or native process-group boundary within 60 seconds" + "node execution did not reach a freezeable Wasm safepoint or native process-group boundary within 5 seconds" .to_owned(), ), ) @@ -139,13 +139,13 @@ pub(super) fn spawn_task_control_watcher( } "resume" => { debug_control.request_resume(epoch); - if debug_control.wait_until_running(epoch, Duration::from_secs(60)) { + if debug_control.wait_until_running(epoch, Duration::from_secs(5)) { ("running", None) } else { ( "failed", Some( - "node execution did not leave frozen state within 60 seconds" + "node execution did not leave frozen state within 5 seconds" .to_owned(), ), ) @@ -169,7 +169,17 @@ pub(super) fn spawn_task_control_watcher( "task": &task, "epoch": epoch, "state": state, - "stack_frames": [format!("{}::wasm", task_definition)], + "stack_frames": if state == "frozen" { + let mut frames = debug_control.stack_frames(); + if let Some(frame) = frames.first_mut() { + *frame = format!("{task_definition}::wasm / {frame}"); + } else { + frames.push(format!("{task_definition}::wasm")); + } + frames + } else { + Vec::new() + }, "local_values": [], "task_args": &task_args, "handles": debug_handle_snapshot(&handles), diff --git a/crates/disasmer-node/src/assignment_runner/process_runner.rs b/crates/clusterflux-node/src/assignment_runner/process_runner.rs similarity index 100% rename from crates/disasmer-node/src/assignment_runner/process_runner.rs rename to crates/clusterflux-node/src/assignment_runner/process_runner.rs diff --git a/crates/disasmer-node/src/assignment_runner/tests.rs b/crates/clusterflux-node/src/assignment_runner/tests.rs similarity index 94% rename from crates/disasmer-node/src/assignment_runner/tests.rs rename to crates/clusterflux-node/src/assignment_runner/tests.rs index 4593342..4d2d864 100644 --- a/crates/disasmer-node/src/assignment_runner/tests.rs +++ b/crates/clusterflux-node/src/assignment_runner/tests.rs @@ -43,7 +43,7 @@ fn test_controlled_runner( }, process: "vp".to_owned(), task: "task".to_owned(), - node_private_key: disasmer_core::derive_ed25519_private_key_from_seed( + node_private_key: clusterflux_core::derive_ed25519_private_key_from_seed( "controlled-runner-test", ), debug_control: Arc::new(WasmDebugControl::default()), @@ -84,7 +84,7 @@ fn controlled_process_runner_kills_running_group_when_abort_is_polled() { }, process: "vp".to_owned(), task: "task".to_owned(), - node_private_key: disasmer_core::derive_ed25519_private_key_from_seed( + node_private_key: clusterflux_core::derive_ed25519_private_key_from_seed( "controlled-runner-test", ), debug_control: Arc::new(WasmDebugControl::default()), @@ -159,7 +159,7 @@ fn command_environment_verification_rejects_a_changed_recipe() { "FROM docker.io/library/alpine:3.20\n", ) .unwrap(); - let expected = disasmer_core::discover_environments(checkout.path()) + let expected = clusterflux_core::discover_environments(checkout.path()) .unwrap() .into_iter() .find(|environment| environment.name == "linux") @@ -198,8 +198,8 @@ fn native_command_requires_environment_and_network_grant() { assert!(error.contains("explicit command-capable environment")); assert_eq!(require_command_environment(Some("linux")).unwrap(), "linux"); - authorize_command_network(&disasmer_core::CommandNetworkPolicy::Disabled, false).unwrap(); - let error = authorize_command_network(&disasmer_core::CommandNetworkPolicy::Enabled, false) + authorize_command_network(&clusterflux_core::CommandNetworkPolicy::Disabled, false).unwrap(); + let error = authorize_command_network(&clusterflux_core::CommandNetworkPolicy::Enabled, false) .unwrap_err(); assert!(error.contains("Network capability")); } diff --git a/crates/disasmer-node/src/assignment_runner/validation.rs b/crates/clusterflux-node/src/assignment_runner/validation.rs similarity index 78% rename from crates/disasmer-node/src/assignment_runner/validation.rs rename to crates/clusterflux-node/src/assignment_runner/validation.rs index aec1c78..0699d5c 100644 --- a/crates/disasmer-node/src/assignment_runner/validation.rs +++ b/crates/clusterflux-node/src/assignment_runner/validation.rs @@ -1,7 +1,7 @@ -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::path::Path; -use disasmer_core::{Capability, Digest}; +use clusterflux_core::{Capability, Digest}; use wasmparser::{Parser, Payload}; use crate::source_snapshot::{snapshot_project, SourceSnapshotInventory}; @@ -21,10 +21,10 @@ pub(super) fn verify_source_snapshot( } pub(super) fn authorize_command_network( - policy: &disasmer_core::CommandNetworkPolicy, + policy: &clusterflux_core::CommandNetworkPolicy, allow_network: bool, ) -> Result<(), String> { - if policy == &disasmer_core::CommandNetworkPolicy::Enabled && !allow_network { + if policy == &clusterflux_core::CommandNetworkPolicy::Enabled && !allow_network { return Err( "Wasm task requested network access without an explicit granted Network capability" .to_owned(), @@ -35,7 +35,7 @@ pub(super) fn authorize_command_network( pub(super) fn require_command_environment(environment_id: Option<&str>) -> Result<&str, String> { environment_id.ok_or_else(|| { - "native commands require an explicit command-capable environment; attach one with .env(disasmer::env!(\"name\"))" + "native commands require an explicit command-capable environment; attach one with .env(clusterflux::env!(\"name\"))" .to_owned() }) } @@ -44,8 +44,8 @@ pub(super) fn verify_environment_digest( project_root: &Path, environment_id: &str, expected: &Digest, -) -> Result { - let environment = disasmer_core::discover_environments(project_root) +) -> Result { + let environment = clusterflux_core::discover_environments(project_root) .map_err(|error| error.to_string())? .into_iter() .find(|environment| environment.name == environment_id) @@ -105,7 +105,7 @@ pub(super) fn task_descriptors( let Payload::CustomSection(section) = payload? else { continue; }; - if section.name() != "disasmer.tasks" { + if section.name() != "clusterflux.tasks" { continue; } for record in section @@ -125,6 +125,32 @@ pub(super) fn task_descriptors( Ok(descriptors) } +pub(super) fn bundle_environments( + module: &[u8], +) -> Result, Box> { + for payload in Parser::new(0).parse_all(module) { + let Payload::CustomSection(section) = payload? else { + continue; + }; + if section.name() != "clusterflux.environments" { + continue; + } + let environments: Vec = + serde_json::from_slice(section.data())?; + let mut by_name = BTreeMap::new(); + for environment in environments { + if by_name + .insert(environment.name.clone(), environment) + .is_some() + { + return Err("bundle environment manifest contains duplicate names".into()); + } + } + return Ok(by_name); + } + Ok(BTreeMap::new()) +} + pub(super) fn resolve_task_export( module: &[u8], task: &str, @@ -133,7 +159,7 @@ pub(super) fn resolve_task_export( let Payload::CustomSection(section) = payload? else { continue; }; - if section.name() != "disasmer.tasks" { + if section.name() != "clusterflux.tasks" { continue; } for record in section @@ -148,7 +174,7 @@ pub(super) fn resolve_task_export( if descriptor .get("abi_version") .and_then(serde_json::Value::as_u64) - != Some(disasmer_core::WASM_TASK_ABI_VERSION as u64) + != Some(clusterflux_core::WASM_TASK_ABI_VERSION as u64) { return Err(format!("task `{task}` uses an unsupported Wasm ABI version").into()); } diff --git a/crates/disasmer-node/src/bin/disasmer-podman-smoke.rs b/crates/clusterflux-node/src/bin/clusterflux-podman-smoke.rs similarity index 95% rename from crates/disasmer-node/src/bin/disasmer-podman-smoke.rs rename to crates/clusterflux-node/src/bin/clusterflux-podman-smoke.rs index 09f9f04..5757d08 100644 --- a/crates/disasmer-node/src/bin/disasmer-podman-smoke.rs +++ b/crates/clusterflux-node/src/bin/clusterflux-podman-smoke.rs @@ -2,11 +2,11 @@ use std::fs; use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; -use disasmer_core::{ +use clusterflux_core::{ CommandInvocation, Digest, EnvironmentKind, EnvironmentRequirements, EnvironmentResource, NodeId, ProcessId, TaskInstanceId, VfsOverlay, VfsPath, }; -use disasmer_node::{ +use clusterflux_node::{ LinuxRootlessPodmanBackend, LocalCheckoutTaskRequest, LocalSourceCheckout, StdProcessRunner, }; use serde_json::json; @@ -38,7 +38,7 @@ fn main() -> Result<(), Box> { working_directory: "/workspace".to_owned(), environment_variables: Default::default(), timeout_ms: 60_000, - network: disasmer_core::CommandNetworkPolicy::Disabled, + network: clusterflux_core::CommandNetworkPolicy::Disabled, env: Some(env), }; let checkout = LocalSourceCheckout { @@ -87,7 +87,7 @@ fn create_workspace() -> Result { .map(|duration| duration.as_nanos()) .unwrap_or_default(); let workspace = std::env::temp_dir().join(format!( - "disasmer-podman-smoke-{}-{nanos}", + "clusterflux-podman-smoke-{}-{nanos}", std::process::id() )); fs::create_dir_all(&workspace)?; diff --git a/crates/disasmer-node/src/bin/disasmer-quic-smoke.rs b/crates/clusterflux-node/src/bin/clusterflux-quic-smoke.rs similarity index 98% rename from crates/disasmer-node/src/bin/disasmer-quic-smoke.rs rename to crates/clusterflux-node/src/bin/clusterflux-quic-smoke.rs index 1a9f4b7..5ab83ba 100644 --- a/crates/disasmer-node/src/bin/disasmer-quic-smoke.rs +++ b/crates/clusterflux-node/src/bin/clusterflux-quic-smoke.rs @@ -1,6 +1,6 @@ use std::{net::SocketAddr, sync::Arc}; -use disasmer_core::{ +use clusterflux_core::{ ArtifactId, DataPlaneObject, DataPlaneScope, Digest, NativeQuicTransport, NodeEndpoint, NodeId, ProcessId, ProjectId, RendezvousRequest, TenantId, Transport, }; @@ -101,7 +101,7 @@ async fn main() -> Result<(), Box> { println!( "{}", json!({ - "kind": "disasmer_quic_smoke", + "kind": "clusterflux_quic_smoke", "transport": format!("{:?}", transport.kind()), "rust_native_quic": true, "authenticated_direct_connection": transport.authenticated_direct_connections(), diff --git a/crates/disasmer-node/src/bin/disasmer-wasmtime-smoke.rs b/crates/clusterflux-node/src/bin/clusterflux-wasmtime-smoke.rs similarity index 87% rename from crates/disasmer-node/src/bin/disasmer-wasmtime-smoke.rs rename to crates/clusterflux-node/src/bin/clusterflux-wasmtime-smoke.rs index 4b6d8cb..9330423 100644 --- a/crates/disasmer-node/src/bin/disasmer-wasmtime-smoke.rs +++ b/crates/clusterflux-node/src/bin/clusterflux-wasmtime-smoke.rs @@ -2,7 +2,7 @@ use std::fs; use std::path::PathBuf; use std::sync::{Arc, Mutex}; -use disasmer_core::{ +use clusterflux_core::{ ArtifactHandle, ArtifactId, Digest, StructuredTaskBoundary, TaskBoundaryHandle, TaskBoundaryValue, WasmHostCommandRequest, WasmHostCommandResult, WasmHostSourceSnapshotRequest, WasmHostSourceSnapshotResult, WasmHostTaskControlRequest, @@ -10,7 +10,7 @@ use disasmer_core::{ WasmHostTaskStartRequest, WasmHostVfsOperation, WasmHostVfsRequest, WasmHostVfsResult, WasmTaskInvocation, WasmTaskOutcome, }; -use disasmer_node::{WasmTaskHost, WasmtimeTaskRuntime}; +use clusterflux_node::{WasmTaskHost, WasmtimeTaskRuntime}; use serde_json::json; use wasmparser::{Parser, Payload}; @@ -30,7 +30,7 @@ fn main() -> Result<(), Box> { } if args.len() != 5 { return Err( - "usage: disasmer-wasmtime-smoke | --host-command | --debug-freeze-resume | --task-abi | --task-artifact ".into(), + "usage: clusterflux-wasmtime-smoke | --host-command | --debug-freeze-resume | --task-abi | --task-artifact ".into(), ); } @@ -73,14 +73,20 @@ fn run_task_artifact_smoke(module: &str, task: &str) -> Result<(), Box Result<(), Box Result<(), Box Result<(), Box Result { request.validate()?; Ok(WasmHostTaskControlResult { - abi_version: disasmer_core::WASM_TASK_ABI_VERSION, + abi_version: clusterflux_core::WASM_TASK_ABI_VERSION, cancellation_requested: false, }) } @@ -216,7 +222,7 @@ impl WasmTaskHost for ArtifactRecordingHost { } => (artifact, relative_path), }; Ok(WasmHostVfsResult { - abi_version: disasmer_core::WASM_TASK_ABI_VERSION, + abi_version: clusterflux_core::WASM_TASK_ABI_VERSION, artifact, relative_path, }) @@ -228,7 +234,7 @@ impl WasmTaskHost for ArtifactRecordingHost { ) -> Result { request.validate()?; Ok(WasmHostSourceSnapshotResult { - abi_version: disasmer_core::WASM_TASK_ABI_VERSION, + abi_version: clusterflux_core::WASM_TASK_ABI_VERSION, snapshot: Digest::sha256("standalone-source-snapshot"), }) } @@ -239,7 +245,7 @@ fn task_export(wasm: &[u8], task: &str) -> Result = serde_json::from_str(args)?; let invocation = WasmTaskInvocation::new( - disasmer_core::TaskDefinitionId::from(task), - disasmer_core::TaskInstanceId::new(format!("{task}-1")), + clusterflux_core::TaskDefinitionId::from(task), + clusterflux_core::TaskInstanceId::new(format!("{task}-1")), args, ); let result = WasmtimeTaskRuntime::new()?.run_task_export_verified( @@ -346,8 +352,8 @@ fn run_host_command_smoke(module: &str, task: &str) -> Result<(), Box Result<(), Box Result<(), Box Result<(), Box> { if !args.worker { return Err( - "one-shot native command mode was removed; run `disasmer-node --worker` and launch a bundled Wasm task through the coordinator" + "one-shot native command mode was removed; run `clusterflux-node --worker` and launch a bundled Wasm task through the coordinator" .into(), ); } @@ -236,7 +236,7 @@ fn report_node_capabilities( .into_iter() .map(|snapshot| snapshot.digest) .collect::>(); - Ok(session.request(signed_node_request_json( + session.request(signed_node_request_json( args, node_private_key, "report_node_capabilities", @@ -253,7 +253,7 @@ fn report_node_capabilities( "direct_connectivity": false, "online": online, }), - )?)?) + )?) } fn service_pending_artifact_transfer( @@ -586,13 +586,13 @@ mod tests { use std::collections::BTreeSet; use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; - use disasmer_core::{ + use clusterflux_core::{ Digest, ProcessId, ProjectId, TaskBoundaryValue, TaskDispatch, TaskInstanceId, TaskSpec, TenantId, WasmExportAbi, WasmTaskResult, }; use crate::assignment_runner::run_verified_wasmtime_assignment; - use disasmer_core::MIN_SIGNED_NODE_POLL_INTERVAL_MS; + use clusterflux_core::MIN_SIGNED_NODE_POLL_INTERVAL_MS; use super::{ control_endpoint_identity, load_or_create_local_node_credential, @@ -609,24 +609,24 @@ mod tests { #[test] fn hosted_url_remains_an_https_control_endpoint() { assert_eq!( - control_endpoint_identity("https://disasmer.michelpaulissen.com").unwrap(), - "https://disasmer.michelpaulissen.com/api/v1/control" + control_endpoint_identity("https://clusterflux.michelpaulissen.com").unwrap(), + "https://clusterflux.michelpaulissen.com/api/v1/control" ); assert_eq!( - control_endpoint_identity("https://disasmer.michelpaulissen.com/api/v1/control") + control_endpoint_identity("https://clusterflux.michelpaulissen.com/api/v1/control") .unwrap(), - "https://disasmer.michelpaulissen.com/api/v1/control" + "https://clusterflux.michelpaulissen.com/api/v1/control" ); assert_eq!( control_endpoint_identity("127.0.0.1:7999").unwrap(), - "disasmer+tcp://127.0.0.1:7999" + "clusterflux+tcp://127.0.0.1:7999" ); } #[test] fn daemon_local_node_credential_is_durable_between_runs() { let temp = std::env::temp_dir().join(format!( - "disasmer-node-credential-test-{}-{}", + "clusterflux-node-credential-test-{}-{}", std::process::id(), super::unix_timestamp_nanos() )); @@ -635,7 +635,7 @@ mod tests { let second = load_or_create_local_node_credential(&temp, "daemon-node").unwrap(); assert_eq!(first, second); - assert!(temp.join(".disasmer").join("nodes").exists()); + assert!(temp.join(".clusterflux").join("nodes").exists()); std::fs::remove_dir_all(&temp).unwrap(); } @@ -653,15 +653,16 @@ mod tests { let wat_result = result.replace('\\', "\\\\").replace('"', "\\\""); let result_length = result.len(); let packed = ((result_length as u64) << 32) | 2048; - let wasm = format!( + let wasm = wat::parse_str(format!( r#"(module (memory (export "memory") 1) (data (i32.const 2048) "{wat_result}") - (func (export "disasmer_alloc_v1") (param i32) (result i32) + (func (export "clusterflux_alloc_v1") (param i32) (result i32) i32.const 1024) (func (export "task_add_one") (param i32 i32) (result i64) i64.const {packed}))"# - ); + )) + .unwrap(); let task = RuntimeTask { process: "vp".to_owned(), task: task_instance.as_str().to_owned(), @@ -670,7 +671,7 @@ mod tests { tenant: TenantId::from("tenant"), project: ProjectId::from("project"), process: ProcessId::from("vp"), - task_definition: disasmer_core::TaskDefinitionId::from("task_add_one"), + task_definition: clusterflux_core::TaskDefinitionId::from("task_add_one"), task_instance, dispatch: TaskDispatch::CoordinatorNodeWasm { export: Some("task_add_one".to_owned()), @@ -685,6 +686,7 @@ mod tests { required_artifacts: Vec::new(), args: Vec::new(), vfs_epoch: 7, + failure_policy: Default::default(), bundle_digest: Some(Digest::sha256(&wasm)), }), bundle_digest: Some(Digest::sha256(&wasm)), diff --git a/crates/disasmer-node/src/debug_agent.rs b/crates/clusterflux-node/src/debug_agent.rs similarity index 100% rename from crates/disasmer-node/src/debug_agent.rs rename to crates/clusterflux-node/src/debug_agent.rs diff --git a/crates/disasmer-node/src/lib.rs b/crates/clusterflux-node/src/lib.rs similarity index 95% rename from crates/disasmer-node/src/lib.rs rename to crates/clusterflux-node/src/lib.rs index a45beae..228fa81 100644 --- a/crates/disasmer-node/src/lib.rs +++ b/crates/clusterflux-node/src/lib.rs @@ -1,6 +1,6 @@ use std::path::PathBuf; -use disasmer_core::{ +use clusterflux_core::{ Capability, CommandBackendKind, CommandInvocation, CommandPlan, Digest, EnvironmentKind, GuestRuntimeKind, ProcessId, TaskInstanceId, VfsObject, VfsOverlay, VfsPath, }; @@ -9,14 +9,14 @@ use thiserror::Error; mod command_runner; mod windows_dev; +pub use clusterflux_wasm_runtime::{ + WasmDebugControl, WasmTaskError, WasmTaskHost, WasmtimeDebugProbe, WasmtimeTaskRuntime, +}; use command_runner::capture_command_logs; pub use command_runner::{ authorize_node_command, CapturedCommandLogs, CommandOutput, LocalCommandExecutor, VirtualThreadCommand, DEFAULT_COMMAND_LOG_LIMIT_BYTES, }; -pub use disasmer_wasm_runtime::{ - WasmDebugControl, WasmTaskError, WasmTaskHost, WasmtimeDebugProbe, WasmtimeTaskRuntime, -}; pub use windows_dev::{WindowsCommandDevBackend, WindowsSandboxStubBackend}; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -224,7 +224,7 @@ pub struct LinuxCommandTaskOutput { impl LinuxRootlessPodmanBackend { pub fn materialize_environment( &self, - env: &disasmer_core::EnvironmentResource, + env: &clusterflux_core::EnvironmentResource, ) -> Result { match env.kind { EnvironmentKind::Containerfile | EnvironmentKind::Dockerfile => {} @@ -239,7 +239,7 @@ impl LinuxRootlessPodmanBackend { program: "podman".to_owned(), args: vec![ "build".to_owned(), - "--pull=never".to_owned(), + "--pull=missing".to_owned(), "--tag".to_owned(), image_tag, "--file".to_owned(), @@ -274,8 +274,8 @@ impl LinuxRootlessPodmanBackend { }; let lifecycle = LinuxTaskLifecycle::new(process.clone(), virtual_thread.clone()); let network = match invocation.network { - disasmer_core::CommandNetworkPolicy::Disabled => "none", - disasmer_core::CommandNetworkPolicy::Enabled => "slirp4netns", + clusterflux_core::CommandNetworkPolicy::Disabled => "none", + clusterflux_core::CommandNetworkPolicy::Enabled => "slirp4netns", }; let mut args = vec![ "run".to_owned(), @@ -295,9 +295,9 @@ impl LinuxRootlessPodmanBackend { "--volume".to_owned(), format!("{}:/workspace:ro,Z", checkout.host_path.to_string_lossy()), "--volume".to_owned(), - format!("{}:/disasmer/output:rw,Z", output_root.to_string_lossy()), + format!("{}:/clusterflux/output:rw,Z", output_root.to_string_lossy()), "--env".to_owned(), - "CARGO_TARGET_DIR=/disasmer/output/target".to_owned(), + "CARGO_TARGET_DIR=/clusterflux/output/target".to_owned(), "--workdir".to_owned(), invocation.working_directory.clone(), ]; @@ -328,7 +328,7 @@ impl LinuxRootlessPodmanBackend { pub fn execute_environment_materialization( &self, - env: &disasmer_core::EnvironmentResource, + env: &clusterflux_core::EnvironmentResource, runner: &mut impl ProcessRunner, ) -> Result { let materialization = self.materialize_environment(env)?; @@ -426,7 +426,7 @@ impl LinuxRootlessPodmanBackend { self.execute_run_plan(plan, runner, overlay) } - fn image_tag(&self, env: &disasmer_core::EnvironmentResource) -> String { + fn image_tag(&self, env: &clusterflux_core::EnvironmentResource) -> String { let name = env .name .chars() @@ -451,7 +451,7 @@ impl LinuxRootlessPodmanBackend { }) .take(24) .collect::(); - format!("disasmer-env/{name}:{digest}") + format!("clusterflux-env/{name}:{digest}") } } @@ -482,7 +482,7 @@ mod tests { use std::collections::VecDeque; use std::path::PathBuf; - use disasmer_core::{ + use clusterflux_core::{ DebugRuntimeState, Digest, EnvironmentRequirements, EnvironmentResource, NodeId, ProjectId, TenantId, }; @@ -540,7 +540,7 @@ mod tests { working_directory: "/workspace".to_owned(), environment_variables: Default::default(), timeout_ms: 60_000, - network: disasmer_core::CommandNetworkPolicy::Disabled, + network: clusterflux_core::CommandNetworkPolicy::Disabled, env: Some(container_env()), }; let plan = LinuxRootlessPodmanBackend.plan(&invocation).unwrap(); @@ -558,7 +558,9 @@ mod tests { .unwrap(); assert_eq!(materialization.environment, "linux"); - assert!(materialization.image_tag.starts_with("disasmer-env/linux:")); + assert!(materialization + .image_tag + .starts_with("clusterflux-env/linux:")); assert_eq!(materialization.image_tag.matches(':').count(), 1); assert_eq!(materialization.build.program, "podman"); assert!(materialization.rootless_user_podman); @@ -567,7 +569,7 @@ mod tests { assert!(materialization .build .args - .contains(&"--pull=never".to_owned())); + .contains(&"--pull=missing".to_owned())); assert!(materialization .build .args @@ -589,7 +591,7 @@ mod tests { "release".to_owned(), )]), timeout_ms: 60_000, - network: disasmer_core::CommandNetworkPolicy::Disabled, + network: clusterflux_core::CommandNetworkPolicy::Disabled, env: Some(container_env()), }; let plan = LinuxRootlessPodmanBackend @@ -663,7 +665,7 @@ mod tests { working_directory: "/workspace".to_owned(), environment_variables: Default::default(), timeout_ms: 60_000, - network: disasmer_core::CommandNetworkPolicy::Disabled, + network: clusterflux_core::CommandNetworkPolicy::Disabled, env: Some(container_env()), }; let mut runner = @@ -716,7 +718,7 @@ mod tests { working_directory: "/workspace".to_owned(), environment_variables: Default::default(), timeout_ms: 60_000, - network: disasmer_core::CommandNetworkPolicy::Disabled, + network: clusterflux_core::CommandNetworkPolicy::Disabled, env: Some(container_env()), }; let mut runner = @@ -756,7 +758,7 @@ mod tests { working_directory: "/workspace".to_owned(), environment_variables: Default::default(), timeout_ms: 60_000, - network: disasmer_core::CommandNetworkPolicy::Disabled, + network: clusterflux_core::CommandNetworkPolicy::Disabled, env: Some(container_env()), }; let mut runner = RecordingRunner::with_outputs([ProcessOutput { @@ -821,7 +823,7 @@ mod tests { working_directory: "/workspace".to_owned(), environment_variables: Default::default(), timeout_ms: 60_000, - network: disasmer_core::CommandNetworkPolicy::Disabled, + network: clusterflux_core::CommandNetworkPolicy::Disabled, env: None, }; let plan = WindowsCommandDevBackend.plan(&invocation).unwrap(); @@ -919,7 +921,7 @@ mod tests { #[test] fn native_command_output_is_associated_with_virtual_thread_and_staged_to_vfs() { let executor = LocalCommandExecutor { - node: disasmer_core::NodeId::from("node"), + node: clusterflux_core::NodeId::from("node"), hosted_control_plane: false, has_command_capability: true, }; @@ -938,7 +940,7 @@ mod tests { working_directory: "/workspace".to_owned(), environment_variables: Default::default(), timeout_ms: 60_000, - network: disasmer_core::CommandNetworkPolicy::Disabled, + network: clusterflux_core::CommandNetworkPolicy::Disabled, env: None, }, stage_stdout_as: Some(VfsPath::new("/vfs/artifacts/app.txt").unwrap()), @@ -962,7 +964,7 @@ mod tests { #[test] fn local_command_executor_caps_logs_and_reports_backpressure_by_virtual_thread() { let executor = LocalCommandExecutor { - node: disasmer_core::NodeId::from("node"), + node: clusterflux_core::NodeId::from("node"), hosted_control_plane: false, has_command_capability: true, }; @@ -978,7 +980,7 @@ mod tests { working_directory: "/workspace".to_owned(), environment_variables: Default::default(), timeout_ms: 60_000, - network: disasmer_core::CommandNetworkPolicy::Disabled, + network: clusterflux_core::CommandNetworkPolicy::Disabled, env: None, }, stage_stdout_as: None, diff --git a/crates/disasmer-node/src/main.rs b/crates/clusterflux-node/src/main.rs similarity index 100% rename from crates/disasmer-node/src/main.rs rename to crates/clusterflux-node/src/main.rs diff --git a/crates/disasmer-node/src/node_identity.rs b/crates/clusterflux-node/src/node_identity.rs similarity index 95% rename from crates/disasmer-node/src/node_identity.rs rename to crates/clusterflux-node/src/node_identity.rs index 75edb1b..ee8e6bc 100644 --- a/crates/disasmer-node/src/node_identity.rs +++ b/crates/clusterflux-node/src/node_identity.rs @@ -1,6 +1,6 @@ use std::path::{Path, PathBuf}; -use disasmer_core::{ +use clusterflux_core::{ generate_ed25519_private_key, node_ed25519_public_key_from_private_key, sign_node_request, signed_request_payload_digest, NodeId, }; @@ -32,7 +32,7 @@ pub(crate) fn establish_node_identity( .unwrap_or(derived_public_key.clone()); if public_key != derived_public_key { return Err( - "node --public-key must match DISASMER_NODE_PRIVATE_KEY or the stored local node credential" + "node --public-key must match CLUSTERFLUX_NODE_PRIVATE_KEY or the stored local node credential" .into(), ); } @@ -58,7 +58,7 @@ pub(crate) fn establish_node_identity( pub(crate) fn node_private_key_for_runtime( node: &str, ) -> Result> { - if let Ok(private_key) = std::env::var("DISASMER_NODE_PRIVATE_KEY") { + if let Ok(private_key) = std::env::var("CLUSTERFLUX_NODE_PRIVATE_KEY") { return Ok(private_key); } load_or_create_local_node_credential(&std::env::current_dir()?, node) @@ -97,7 +97,7 @@ pub(crate) fn load_or_create_local_node_credential( let public_key = node_ed25519_public_key_from_private_key(&private_key).map_err(invalid_key_error)?; let credential = StoredNodeCredential { - kind: "disasmer_node_credential".to_owned(), + kind: "clusterflux_node_credential".to_owned(), node: node.to_owned(), private_key: private_key.clone(), public_key, @@ -169,10 +169,10 @@ fn persist_node_credential( } pub(crate) fn local_node_credential_file(project: &Path, node: &str) -> PathBuf { - let digest = disasmer_core::Digest::sha256(node); + let digest = clusterflux_core::Digest::sha256(node); let file_stem = digest.as_str().trim_start_matches("sha256:"); project - .join(".disasmer") + .join(".clusterflux") .join("nodes") .join(format!("{file_stem}.json")) } diff --git a/crates/disasmer-node/src/source_snapshot.rs b/crates/clusterflux-node/src/source_snapshot.rs similarity index 97% rename from crates/disasmer-node/src/source_snapshot.rs rename to crates/clusterflux-node/src/source_snapshot.rs index 5d05cfd..82ccf0e 100644 --- a/crates/disasmer-node/src/source_snapshot.rs +++ b/crates/clusterflux-node/src/source_snapshot.rs @@ -3,7 +3,7 @@ use std::io::Read; use std::path::{Path, PathBuf}; use std::process::Command; -use disasmer_core::Digest; +use clusterflux_core::Digest; use sha2::{Digest as _, Sha256}; const MAX_SNAPSHOT_FILES: usize = 20_000; @@ -222,7 +222,7 @@ fn snapshot_file(absolute: &Path, relative: &str) -> Result Result { Ok(Self { max_bytes: environment_u64( - "DISASMER_NODE_ARTIFACT_MAX_BYTES", + "CLUSTERFLUX_NODE_ARTIFACT_MAX_BYTES", DEFAULT_MAX_NODE_ARTIFACT_STORE_BYTES, )?, max_count: usize::try_from(environment_u64( - "DISASMER_NODE_ARTIFACT_MAX_COUNT", + "CLUSTERFLUX_NODE_ARTIFACT_MAX_COUNT", DEFAULT_MAX_NODE_ARTIFACT_COUNT as u64, )?) - .map_err(|_| "DISASMER_NODE_ARTIFACT_MAX_COUNT does not fit usize".to_owned())?, + .map_err(|_| "CLUSTERFLUX_NODE_ARTIFACT_MAX_COUNT does not fit usize".to_owned())?, max_age_seconds: environment_u64( - "DISASMER_NODE_ARTIFACT_MAX_AGE_SECONDS", + "CLUSTERFLUX_NODE_ARTIFACT_MAX_AGE_SECONDS", DEFAULT_MAX_NODE_ARTIFACT_AGE_SECONDS, )?, restart_pin_seconds: environment_u64( - "DISASMER_NODE_ARTIFACT_RESTART_PIN_SECONDS", + "CLUSTERFLUX_NODE_ARTIFACT_RESTART_PIN_SECONDS", DEFAULT_NODE_ARTIFACT_RESTART_PIN_SECONDS, )?, }) @@ -94,7 +94,7 @@ impl NodeArtifactStore { } }) .collect::(); - let root = base.join(".disasmer").join("node-artifacts").join(node); + let root = base.join(".clusterflux").join("node-artifacts").join(node); if fs::symlink_metadata(&root) .map(|metadata| metadata.file_type().is_symlink()) .unwrap_or(false) @@ -171,7 +171,7 @@ impl NodeArtifactStore { pub(crate) fn materialize_into_output( &self, - artifact: &disasmer_core::ArtifactHandle, + artifact: &clusterflux_core::ArtifactHandle, output_root: &Path, relative_path: &str, ) -> Result { @@ -415,9 +415,9 @@ struct StoredArtifactEntry { pub(crate) fn retained_result_artifact( project_root: Option<&Path>, node: &str, - result: Option<&disasmer_core::TaskBoundaryValue>, + result: Option<&clusterflux_core::TaskBoundaryValue>, ) -> Result, String> { - let Some(disasmer_core::TaskBoundaryValue::Artifact(handle)) = result else { + let Some(clusterflux_core::TaskBoundaryValue::Artifact(handle)) = result else { return Ok(None); }; NodeArtifactStore::for_runtime(project_root, node)? @@ -459,7 +459,10 @@ pub(crate) fn task_output_root( }) .collect::() }; - let parent = base.join(".disasmer").join("task-outputs").join(safe(node)); + let parent = base + .join(".clusterflux") + .join("task-outputs") + .join(safe(node)); fs::create_dir_all(&parent).map_err(|error| error.to_string())?; let now_nanos = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -499,7 +502,7 @@ pub(crate) fn clean_stale_task_output_roots( } }) .collect::(); - let parent = base.join(".disasmer").join("task-outputs").join(node); + let parent = base.join(".clusterflux").join("task-outputs").join(node); let parent_metadata = match fs::symlink_metadata(&parent) { Ok(metadata) => metadata, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0), @@ -837,7 +840,7 @@ mod tests { let retained = retain_fixture(&store, temp.path(), "build/app", b"real compiler output"); let consumer = temp.path().join("consumer"); fs::create_dir_all(&consumer).unwrap(); - let handle = disasmer_core::ArtifactHandle { + let handle = clusterflux_core::ArtifactHandle { id: retained.id, digest: retained.digest, size_bytes: retained.size_bytes, @@ -858,7 +861,7 @@ mod tests { let retained = retain_fixture(&store, temp.path(), "app", b"artifact"); let consumer = temp.path().join("consumer"); fs::create_dir_all(&consumer).unwrap(); - let handle = disasmer_core::ArtifactHandle { + let handle = clusterflux_core::ArtifactHandle { id: retained.id, digest: Digest::sha256("forged"), size_bytes: retained.size_bytes, @@ -891,7 +894,7 @@ mod tests { .is_err()); let retained = retain_fixture(&store, temp.path(), "safe", b"safe"); - let handle = disasmer_core::ArtifactHandle { + let handle = clusterflux_core::ArtifactHandle { id: retained.id, digest: retained.digest, size_bytes: retained.size_bytes, diff --git a/crates/disasmer-node/src/task_reports.rs b/crates/clusterflux-node/src/task_reports.rs similarity index 99% rename from crates/disasmer-node/src/task_reports.rs rename to crates/clusterflux-node/src/task_reports.rs index c347e5e..a700e76 100644 --- a/crates/disasmer-node/src/task_reports.rs +++ b/crates/clusterflux-node/src/task_reports.rs @@ -1,5 +1,5 @@ -use disasmer_core::{TaskBoundaryValue, VfsManifest}; -use disasmer_node::CommandOutput; +use clusterflux_core::{TaskBoundaryValue, VfsManifest}; +use clusterflux_node::CommandOutput; use serde_json::{json, Value}; use crate::daemon::RuntimeTask; diff --git a/crates/disasmer-node/src/windows_dev.rs b/crates/clusterflux-node/src/windows_dev.rs similarity index 98% rename from crates/disasmer-node/src/windows_dev.rs rename to crates/clusterflux-node/src/windows_dev.rs index f90c1ba..523e163 100644 --- a/crates/disasmer-node/src/windows_dev.rs +++ b/crates/clusterflux-node/src/windows_dev.rs @@ -1,4 +1,4 @@ -use disasmer_core::{ +use clusterflux_core::{ Capability, CommandBackendKind, CommandInvocation, CommandPlan, GuestRuntimeKind, }; diff --git a/crates/disasmer-sdk/Cargo.toml b/crates/clusterflux-sdk/Cargo.toml similarity index 52% rename from crates/disasmer-sdk/Cargo.toml rename to crates/clusterflux-sdk/Cargo.toml index cabccc9..18bf7d9 100644 --- a/crates/disasmer-sdk/Cargo.toml +++ b/crates/clusterflux-sdk/Cargo.toml @@ -1,18 +1,18 @@ [package] -name = "disasmer-sdk" +name = "clusterflux-sdk" version = "0.1.0" edition.workspace = true license.workspace = true repository.workspace = true [lib] -name = "disasmer" +name = "clusterflux" [dependencies] base64.workspace = true -disasmer-core = { path = "../disasmer-core" } -disasmer-control = { path = "../disasmer-control" } -disasmer-macros = { path = "../disasmer-macros" } +clusterflux-core = { path = "../clusterflux-core" } +clusterflux-control = { path = "../clusterflux-control" } +clusterflux-macros = { path = "../clusterflux-macros" } futures-executor.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/disasmer-sdk/src/__private.rs b/crates/clusterflux-sdk/src/__private.rs similarity index 66% rename from crates/disasmer-sdk/src/__private.rs rename to crates/clusterflux-sdk/src/__private.rs index 74a555a..7df628d 100644 --- a/crates/disasmer-sdk/src/__private.rs +++ b/crates/clusterflux-sdk/src/__private.rs @@ -6,8 +6,8 @@ use crate::TaskArg; #[cfg(target_arch = "wasm32")] #[unsafe(no_mangle)] -pub extern "C" fn disasmer_alloc_v1(length: u32) -> u32 { - if length as usize > disasmer_core::MAX_WASM_TASK_ENVELOPE_BYTES { +pub extern "C" fn clusterflux_alloc_v1(length: u32) -> u32 { + if length as usize > clusterflux_core::MAX_WASM_TASK_ENVELOPE_BYTES { return 0; } let mut bytes = Vec::::with_capacity(length as usize); @@ -114,14 +114,14 @@ where let result = match function() { Ok(output) => match output.task_boundary_value() { Ok(result) => { - disasmer_core::WasmTaskResult::completed(invocation.task_instance, result) + clusterflux_core::WasmTaskResult::completed(invocation.task_instance, result) } - Err(error) => disasmer_core::WasmTaskResult::failed( + Err(error) => clusterflux_core::WasmTaskResult::failed( invocation.task_instance, bounded_task_error(error.to_string()), ), }, - Err(error) => disasmer_core::WasmTaskResult::failed( + Err(error) => clusterflux_core::WasmTaskResult::failed( invocation.task_instance, bounded_task_error(error), ), @@ -229,7 +229,7 @@ where let argument = match decode_boundary_value(argument.clone()) { Ok(argument) => argument, Err(error) => { - return encode_result(disasmer_core::WasmTaskResult::failed( + return encode_result(clusterflux_core::WasmTaskResult::failed( invocation.task_instance, error, )) @@ -238,14 +238,14 @@ where let result = match function(argument) { Ok(output) => match output.task_boundary_value() { Ok(result) => { - disasmer_core::WasmTaskResult::completed(invocation.task_instance, result) + clusterflux_core::WasmTaskResult::completed(invocation.task_instance, result) } - Err(error) => disasmer_core::WasmTaskResult::failed( + Err(error) => clusterflux_core::WasmTaskResult::failed( invocation.task_instance, bounded_task_error(error.to_string()), ), }, - Err(error) => disasmer_core::WasmTaskResult::failed( + Err(error) => clusterflux_core::WasmTaskResult::failed( invocation.task_instance, bounded_task_error(error), ), @@ -274,18 +274,18 @@ fn decode_invocation( expected_task: &str, pointer: u32, length: u32, -) -> Result { - if length as usize > disasmer_core::MAX_WASM_TASK_ENVELOPE_BYTES { +) -> Result { + if length as usize > clusterflux_core::MAX_WASM_TASK_ENVELOPE_BYTES { return Err(format!( "task invocation exceeds {} byte ABI limit", - disasmer_core::MAX_WASM_TASK_ENVELOPE_BYTES + clusterflux_core::MAX_WASM_TASK_ENVELOPE_BYTES )); } if pointer == 0 && length != 0 { return Err("task invocation pointer is null".to_owned()); } let bytes = unsafe { std::slice::from_raw_parts(pointer as *const u8, length as usize) }; - let invocation: disasmer_core::WasmTaskInvocation = + let invocation: clusterflux_core::WasmTaskInvocation = serde_json::from_slice(bytes).map_err(|error| error.to_string())?; invocation.validate()?; if invocation.task_definition.as_str() != expected_task { @@ -298,19 +298,19 @@ fn decode_invocation( } #[cfg(target_arch = "wasm32")] -fn decode_boundary_value(value: disasmer_core::TaskBoundaryValue) -> Result +fn decode_boundary_value(value: clusterflux_core::TaskBoundaryValue) -> Result where T: DeserializeOwned, { let value = match value { - disasmer_core::TaskBoundaryValue::SmallJson(value) => value, - disasmer_core::TaskBoundaryValue::Structured(structured) => structured.value, - disasmer_core::TaskBoundaryValue::SourceSnapshot(digest) - | disasmer_core::TaskBoundaryValue::Blob(digest) - | disasmer_core::TaskBoundaryValue::VfsManifest(digest) => { + clusterflux_core::TaskBoundaryValue::SmallJson(value) => value, + clusterflux_core::TaskBoundaryValue::Structured(structured) => structured.materialize()?, + clusterflux_core::TaskBoundaryValue::SourceSnapshot(digest) + | clusterflux_core::TaskBoundaryValue::Blob(digest) + | clusterflux_core::TaskBoundaryValue::VfsManifest(digest) => { serde_json::json!({ "digest": digest }) } - disasmer_core::TaskBoundaryValue::Artifact(artifact) => { + clusterflux_core::TaskBoundaryValue::Artifact(artifact) => { serde_json::json!({ "id": artifact.id, "digest": artifact.digest, @@ -325,38 +325,38 @@ where fn failed_result( task_instance: impl Into, error: impl Into, -) -> disasmer_core::WasmTaskResult { - disasmer_core::WasmTaskResult::failed(task_instance.into().0, error) +) -> clusterflux_core::WasmTaskResult { + clusterflux_core::WasmTaskResult::failed(task_instance.into().0, error) } #[cfg(target_arch = "wasm32")] -struct FailedTaskInstance(disasmer_core::TaskInstanceId); +struct FailedTaskInstance(clusterflux_core::TaskInstanceId); #[cfg(target_arch = "wasm32")] impl From<&str> for FailedTaskInstance { fn from(task_definition: &str) -> Self { - Self(disasmer_core::TaskInstanceId::new(format!( + Self(clusterflux_core::TaskInstanceId::new(format!( "invalid-invocation:{task_definition}" ))) } } #[cfg(target_arch = "wasm32")] -impl From for FailedTaskInstance { - fn from(task_instance: disasmer_core::TaskInstanceId) -> Self { +impl From for FailedTaskInstance { + fn from(task_instance: clusterflux_core::TaskInstanceId) -> Self { Self(task_instance) } } #[cfg(target_arch = "wasm32")] -fn encode_result(mut result: disasmer_core::WasmTaskResult) -> u64 { +fn encode_result(mut result: clusterflux_core::WasmTaskResult) -> u64 { let mut bytes = serde_json::to_vec(&result).unwrap_or_default(); - if bytes.len() > disasmer_core::MAX_WASM_TASK_ENVELOPE_BYTES { - result = disasmer_core::WasmTaskResult::failed( + if bytes.len() > clusterflux_core::MAX_WASM_TASK_ENVELOPE_BYTES { + result = clusterflux_core::WasmTaskResult::failed( result.task_instance, format!( "task result exceeds {} byte ABI limit", - disasmer_core::MAX_WASM_TASK_ENVELOPE_BYTES + clusterflux_core::MAX_WASM_TASK_ENVELOPE_BYTES ), ); bytes = serde_json::to_vec(&result).unwrap_or_default(); @@ -367,44 +367,83 @@ fn encode_result(mut result: disasmer_core::WasmTaskResult) -> u64 { std::mem::forget(bytes); ((length as u64) << 32) | pointer as u64 } +std::thread_local! { + static TASK_HANDLE_ENCODING: std::cell::RefCell>> = + const { std::cell::RefCell::new(None) }; +} + #[doc(hidden)] -pub trait CollectTaskHandles { - fn collect_task_handles( - &self, - handles: &mut Vec, - ) -> Result<(), crate::TaskArgError>; +pub trait CollectTaskHandles {} + +#[doc(hidden)] +pub fn serialize_task_handle( + handle: clusterflux_core::TaskBoundaryHandle, + serializer: S, + serialize_plain: F, +) -> Result +where + S: serde::Serializer, + F: FnOnce(S) -> Result, +{ + TASK_HANDLE_ENCODING.with(|encoding| { + let mut encoding = encoding.borrow_mut(); + let Some(handles) = encoding.as_mut() else { + drop(encoding); + return serialize_plain(serializer); + }; + let index = handles.len(); + let kind = match &handle { + clusterflux_core::TaskBoundaryHandle::SourceSnapshot(_) => "source_snapshot", + clusterflux_core::TaskBoundaryHandle::Blob(_) => "blob", + clusterflux_core::TaskBoundaryHandle::Artifact(_) => "artifact", + clusterflux_core::TaskBoundaryHandle::VfsManifest(_) => "vfs_manifest", + }; + handles.push(handle); + serde::Serialize::serialize( + &serde_json::json!({ + "$task_handle": { + "index": index, + "kind": kind, + } + }), + serializer, + ) + }) } #[doc(hidden)] pub fn structured_task_boundary( value: &T, -) -> Result +) -> Result where T: serde::Serialize + CollectTaskHandles + ?Sized, { - let serialized = serde_json::to_value(value) - .map_err(|error| crate::TaskArgError::Serialization(error.to_string()))?; - let mut handles = Vec::new(); - CollectTaskHandles::collect_task_handles(value, &mut handles)?; - Ok(disasmer_core::TaskBoundaryValue::Structured( - disasmer_core::StructuredTaskBoundary { + TASK_HANDLE_ENCODING.with(|encoding| { + if encoding.borrow().is_some() { + return Err(crate::TaskArgError::Serialization( + "nested structured task argument encoding is not allowed".to_owned(), + )); + } + *encoding.borrow_mut() = Some(Vec::new()); + let serialized = serde_json::to_value(value) + .map_err(|error| crate::TaskArgError::Serialization(error.to_string())); + let handles = encoding.borrow_mut().take().unwrap_or_default(); + let serialized = serialized?; + let boundary = clusterflux_core::StructuredTaskBoundary { value: serialized, handles, - }, - )) + }; + boundary + .validate() + .map_err(crate::TaskArgError::Serialization)?; + Ok(clusterflux_core::TaskBoundaryValue::Structured(boundary)) + }) } macro_rules! no_task_handles { ($($ty:ty),+ $(,)?) => { $( - impl CollectTaskHandles for $ty { - fn collect_task_handles( - &self, - _handles: &mut Vec, - ) -> Result<(), crate::TaskArgError> { - Ok(()) - } - } + impl CollectTaskHandles for $ty {} )+ }; } @@ -428,73 +467,11 @@ no_task_handles!( f64, ); -impl CollectTaskHandles for crate::SourceSnapshot { - fn collect_task_handles( - &self, - handles: &mut Vec, - ) -> Result<(), crate::TaskArgError> { - handles.push(disasmer_core::TaskBoundaryHandle::SourceSnapshot( - crate::task_args::parse_handle_digest(&self.digest)?, - )); - Ok(()) - } -} +impl CollectTaskHandles for crate::SourceSnapshot {} +impl CollectTaskHandles for crate::Blob {} +impl CollectTaskHandles for crate::Artifact {} +impl CollectTaskHandles for crate::Vfs {} -impl CollectTaskHandles for crate::Blob { - fn collect_task_handles( - &self, - handles: &mut Vec, - ) -> Result<(), crate::TaskArgError> { - handles.push(disasmer_core::TaskBoundaryHandle::Blob( - crate::task_args::parse_handle_digest(&self.digest)?, - )); - Ok(()) - } -} +impl CollectTaskHandles for Option where T: CollectTaskHandles {} -impl CollectTaskHandles for crate::Artifact { - fn collect_task_handles( - &self, - handles: &mut Vec, - ) -> Result<(), crate::TaskArgError> { - if self.id.trim().is_empty() || self.id.len() > 256 { - return Err(crate::TaskArgError::Serialization( - "artifact handle ID must be non-empty and at most 256 bytes".to_owned(), - )); - } - handles.push(disasmer_core::TaskBoundaryHandle::Artifact( - disasmer_core::ArtifactId::from(self.id.as_str()), - )); - Ok(()) - } -} - -impl CollectTaskHandles for Option -where - T: CollectTaskHandles, -{ - fn collect_task_handles( - &self, - handles: &mut Vec, - ) -> Result<(), crate::TaskArgError> { - if let Some(value) = self { - value.collect_task_handles(handles)?; - } - Ok(()) - } -} - -impl CollectTaskHandles for Vec -where - T: CollectTaskHandles, -{ - fn collect_task_handles( - &self, - handles: &mut Vec, - ) -> Result<(), crate::TaskArgError> { - for value in self { - value.collect_task_handles(handles)?; - } - Ok(()) - } -} +impl CollectTaskHandles for Vec where T: CollectTaskHandles {} diff --git a/crates/disasmer-sdk/src/lib.rs b/crates/clusterflux-sdk/src/lib.rs similarity index 88% rename from crates/disasmer-sdk/src/lib.rs rename to crates/clusterflux-sdk/src/lib.rs index 72f540c..7aabad9 100644 --- a/crates/disasmer-sdk/src/lib.rs +++ b/crates/clusterflux-sdk/src/lib.rs @@ -1,13 +1,13 @@ use serde::Serialize; -pub use disasmer_core as core; -pub use disasmer_macros::{main, task, TaskArg}; +pub use clusterflux_core as core; +pub use clusterflux_macros::{main, task, TaskArg}; mod sdk_runtime; mod task_args; pub use task_args::{ reject_host_only_task_arg, validate_task_arg, Artifact, Blob, EnvRef, SourceSnapshot, TaskArg, - TaskArgBudget, TaskArgError, TaskArgKind, TaskArgValidation, + TaskArgBudget, TaskArgError, TaskArgKind, TaskArgValidation, Vfs, }; #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] @@ -66,6 +66,7 @@ pub mod spawn { use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Mutex, OnceLock}; + pub use clusterflux_core::TaskFailurePolicy; use serde::de::DeserializeOwned; #[cfg(target_arch = "wasm32")] @@ -124,6 +125,7 @@ pub mod spawn { env: None, name: "task", task_id: None, + failure_policy: TaskFailurePolicy::FailFast, } } @@ -140,6 +142,7 @@ pub mod spawn { name: "task", task_id: None, arg_budget: TaskArgBudget::default(), + failure_policy: TaskFailurePolicy::FailFast, } } @@ -154,6 +157,7 @@ pub mod spawn { env: None, name: "task", task_id: None, + failure_policy: TaskFailurePolicy::FailFast, marker: std::marker::PhantomData, } } @@ -175,6 +179,7 @@ pub mod spawn { name: "task", task_id: None, arg_budget: TaskArgBudget::default(), + failure_policy: TaskFailurePolicy::FailFast, marker: std::marker::PhantomData, } } @@ -190,6 +195,7 @@ pub mod spawn { env: Option, name: &'static str, task_id: Option, + failure_policy: TaskFailurePolicy, marker: std::marker::PhantomData (Fut, R)>, } @@ -214,6 +220,11 @@ pub mod spawn { self } + pub fn failure_policy(mut self, failure_policy: TaskFailurePolicy) -> Self { + self.failure_policy = failure_policy; + self + } + pub async fn start(self) -> Result, TaskRuntimeError> { let id = NEXT_THREAD_ID.fetch_add(1, Ordering::SeqCst); let runtime_event = register_runtime_thread(id, self.name, self.env); @@ -221,9 +232,10 @@ pub mod spawn { { let task_id = product_task_id::(self.task_id)?; let remote = start_guest_host_task( - disasmer_core::TaskDefinitionId::from(task_id.as_str()), + clusterflux_core::TaskDefinitionId::from(task_id.as_str()), self.env, Vec::new(), + self.failure_policy, )?; return Ok(TaskHandle { virtual_thread_id: id, @@ -238,7 +250,13 @@ pub mod spawn { { if let Some(config) = ProductRuntimeConfig::from_env()? { let task_id = product_task_id::(self.task_id)?; - let remote = start_remote_task(config, task_id, self.env, Vec::new())?; + let remote = start_remote_task( + config, + task_id, + self.env, + Vec::new(), + self.failure_policy, + )?; return Ok(TaskHandle { virtual_thread_id: id, name: self.name, @@ -275,6 +293,7 @@ pub mod spawn { name: &'static str, task_id: Option, arg_budget: TaskArgBudget, + failure_policy: TaskFailurePolicy, marker: std::marker::PhantomData (Fut, R)>, } @@ -305,6 +324,11 @@ pub mod spawn { self } + pub fn failure_policy(mut self, failure_policy: TaskFailurePolicy) -> Self { + self.failure_policy = failure_policy; + self + } + pub async fn start(self) -> Result, TaskRuntimeError> { let arg = self.arg.expect("task argument used once"); validate_task_arg(&arg, self.arg_budget) @@ -318,9 +342,10 @@ pub mod spawn { .task_boundary_value() .map_err(|error| TaskRuntimeError::Argument(error.to_string()))?; let remote = start_guest_host_task( - disasmer_core::TaskDefinitionId::from(task_id.as_str()), + clusterflux_core::TaskDefinitionId::from(task_id.as_str()), self.env, vec![boundary], + self.failure_policy, )?; return Ok(TaskHandle { virtual_thread_id: id, @@ -338,7 +363,13 @@ pub mod spawn { let boundary = arg .task_boundary_value() .map_err(|error| TaskRuntimeError::Argument(error.to_string()))?; - let remote = start_remote_task(config, task_id, self.env, vec![boundary])?; + let remote = start_remote_task( + config, + task_id, + self.env, + vec![boundary], + self.failure_policy, + )?; return Ok(TaskHandle { virtual_thread_id: id, name: self.name, @@ -371,6 +402,7 @@ pub mod spawn { env: Option, name: &'static str, task_id: Option, + failure_policy: TaskFailurePolicy, } impl TaskBuilder @@ -393,6 +425,11 @@ pub mod spawn { self } + pub fn failure_policy(mut self, failure_policy: TaskFailurePolicy) -> Self { + self.failure_policy = failure_policy; + self + } + pub async fn start(self) -> Result, TaskRuntimeError> { let id = NEXT_THREAD_ID.fetch_add(1, Ordering::SeqCst); let runtime_event = register_runtime_thread(id, self.name, self.env); @@ -400,9 +437,10 @@ pub mod spawn { { let task_id = product_task_id::(self.task_id)?; let remote = start_guest_host_task( - disasmer_core::TaskDefinitionId::from(task_id.as_str()), + clusterflux_core::TaskDefinitionId::from(task_id.as_str()), self.env, Vec::new(), + self.failure_policy, )?; return Ok(TaskHandle { virtual_thread_id: id, @@ -417,7 +455,13 @@ pub mod spawn { { if let Some(config) = ProductRuntimeConfig::from_env()? { let task_id = product_task_id::(self.task_id)?; - let remote = start_remote_task(config, task_id, self.env, Vec::new())?; + let remote = start_remote_task( + config, + task_id, + self.env, + Vec::new(), + self.failure_policy, + )?; return Ok(TaskHandle { virtual_thread_id: id, name: self.name, @@ -453,6 +497,7 @@ pub mod spawn { name: &'static str, task_id: Option, arg_budget: TaskArgBudget, + failure_policy: TaskFailurePolicy, } impl TaskWithArgBuilder @@ -481,6 +526,11 @@ pub mod spawn { self } + pub fn failure_policy(mut self, failure_policy: TaskFailurePolicy) -> Self { + self.failure_policy = failure_policy; + self + } + pub async fn start(self) -> Result, TaskRuntimeError> { let arg = self.arg.expect("task argument used once"); validate_task_arg(&arg, self.arg_budget) @@ -494,9 +544,10 @@ pub mod spawn { .task_boundary_value() .map_err(|error| TaskRuntimeError::Argument(error.to_string()))?; let remote = start_guest_host_task( - disasmer_core::TaskDefinitionId::from(task_id.as_str()), + clusterflux_core::TaskDefinitionId::from(task_id.as_str()), self.env, vec![boundary], + self.failure_policy, )?; return Ok(TaskHandle { virtual_thread_id: id, @@ -514,7 +565,13 @@ pub mod spawn { let boundary = arg .task_boundary_value() .map_err(|error| TaskRuntimeError::Argument(error.to_string()))?; - let remote = start_remote_task(config, task_id, self.env, vec![boundary])?; + let remote = start_remote_task( + config, + task_id, + self.env, + vec![boundary], + self.failure_policy, + )?; return Ok(TaskHandle { virtual_thread_id: id, name: self.name, @@ -566,7 +623,7 @@ pub mod spawn { self.debugger_visible } - pub fn task_spec(&self) -> Option<&disasmer_core::TaskSpec> { + pub fn task_spec(&self) -> Option<&clusterflux_core::TaskSpec> { self.remote.as_ref().map(|remote| &remote.spec) } @@ -620,7 +677,7 @@ pub mod command { working_directory: String, environment_variables: BTreeMap, timeout: Duration, - network: disasmer_core::CommandNetworkPolicy, + network: clusterflux_core::CommandNetworkPolicy, } impl Command { @@ -631,7 +688,7 @@ pub mod command { working_directory: "/workspace".to_owned(), environment_variables: BTreeMap::new(), timeout: Duration::from_secs(15 * 60), - network: disasmer_core::CommandNetworkPolicy::Disabled, + network: clusterflux_core::CommandNetworkPolicy::Disabled, } } @@ -665,12 +722,12 @@ pub mod command { } pub fn network_disabled(mut self) -> Self { - self.network = disasmer_core::CommandNetworkPolicy::Disabled; + self.network = clusterflux_core::CommandNetworkPolicy::Disabled; self } pub fn network_enabled(mut self) -> Self { - self.network = disasmer_core::CommandNetworkPolicy::Enabled; + self.network = clusterflux_core::CommandNetworkPolicy::Enabled; self } @@ -700,7 +757,7 @@ pub mod command { } #[cfg(not(target_arch = "wasm32"))] Err(TaskRuntimeError::Configuration( - "Disasmer commands execute only as a granted host capability of a running Wasm task" + "Clusterflux commands execute only as a granted host capability of a running Wasm task" .to_owned(), )) } @@ -722,7 +779,7 @@ pub mod source { } #[cfg(not(target_arch = "wasm32"))] Err(TaskRuntimeError::Configuration( - "source snapshots are produced only by a source-capable Disasmer node".to_owned(), + "source snapshots are produced only by a source-capable Clusterflux node".to_owned(), )) } } @@ -730,7 +787,7 @@ pub mod source { pub mod fs { use crate::{spawn::TaskRuntimeError, Artifact}; - pub const OUTPUT_ROOT: &str = "/disasmer/output"; + pub const OUTPUT_ROOT: &str = "/clusterflux/output"; #[derive(Clone, Debug, PartialEq, Eq)] pub struct OutputPath { @@ -761,7 +818,7 @@ pub mod fs { #[cfg(target_arch = "wasm32")] { let retained = crate::sdk_runtime::guest_vfs_operation( - disasmer_core::WasmHostVfsOperation::FlushOutput { + clusterflux_core::WasmHostVfsOperation::FlushOutput { relative_path: path.relative.clone(), }, )?; @@ -775,7 +832,7 @@ pub mod fs { { let _ = path; Err(TaskRuntimeError::Configuration( - "Disasmer output files are flushed only by a running Wasm task on a retaining node" + "Clusterflux output files are flushed only by a running Wasm task on a retaining node" .to_owned(), )) } @@ -791,9 +848,9 @@ pub mod fs { let digest = crate::task_args::parse_handle_digest(&artifact.digest) .map_err(|error| TaskRuntimeError::Argument(error.to_string()))?; crate::sdk_runtime::guest_vfs_operation( - disasmer_core::WasmHostVfsOperation::MaterializeArtifact { - artifact: disasmer_core::ArtifactHandle { - id: disasmer_core::ArtifactId::from(artifact.id.as_str()), + clusterflux_core::WasmHostVfsOperation::MaterializeArtifact { + artifact: clusterflux_core::ArtifactHandle { + id: clusterflux_core::ArtifactId::from(artifact.id.as_str()), digest, size_bytes: artifact.size_bytes, }, @@ -806,7 +863,7 @@ pub mod fs { { let _ = (&output, artifact); Err(TaskRuntimeError::Configuration( - "Disasmer artifacts are materialized only by a running Wasm task on a retaining node" + "Clusterflux artifacts are materialized only by a running Wasm task on a retaining node" .to_owned(), )) } @@ -921,7 +978,7 @@ mod tests { fn task_arg_validation_allows_handles_and_rejects_oversized_inline_values() { let artifact = crate::Artifact { id: "artifact://build/app".to_owned(), - digest: disasmer_core::Digest::sha256("app").as_str().to_owned(), + digest: clusterflux_core::Digest::sha256("app").as_str().to_owned(), size_bytes: 3, }; let artifact_validation = crate::validate_task_arg( @@ -970,7 +1027,7 @@ mod tests { fn spawn_task_with_arg_allows_runtime_handles_under_inline_budget() { let artifact = crate::Artifact { id: "artifact://build/app".to_owned(), - digest: disasmer_core::Digest::sha256("app").as_str().to_owned(), + digest: clusterflux_core::Digest::sha256("app").as_str().to_owned(), size_bytes: 3, }; let result = block_on(async { diff --git a/crates/disasmer-sdk/src/sdk_runtime.rs b/crates/clusterflux-sdk/src/sdk_runtime.rs similarity index 87% rename from crates/disasmer-sdk/src/sdk_runtime.rs rename to crates/clusterflux-sdk/src/sdk_runtime.rs index f9a281d..a51cf37 100644 --- a/crates/disasmer-sdk/src/sdk_runtime.rs +++ b/crates/clusterflux-sdk/src/sdk_runtime.rs @@ -6,13 +6,13 @@ use std::thread; use std::time::{Duration, Instant}; use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; -use disasmer_control::ControlSession; -use disasmer_core::{ - coordinator_wire_request, Digest, TaskBoundaryValue, TaskDefinitionId, TaskJoinResult, - TaskJoinState, TaskSpec, +use clusterflux_control::ControlSession; +use clusterflux_core::{ + coordinator_wire_request, Digest, TaskBoundaryValue, TaskDefinitionId, TaskFailurePolicy, + TaskJoinResult, TaskJoinState, TaskSpec, }; #[cfg(not(target_arch = "wasm32"))] -use disasmer_core::{ +use clusterflux_core::{ EnvironmentRequirements, ProcessId, ProjectId, TaskDispatch, TenantId, WasmExportAbi, }; use serde::de::DeserializeOwned; @@ -20,16 +20,16 @@ use serde_json::{json, Value}; use crate::EnvRef; -const COORDINATOR_ENV: &str = "DISASMER_SDK_COORDINATOR"; -const TENANT_ENV: &str = "DISASMER_SDK_TENANT"; -const PROJECT_ENV: &str = "DISASMER_SDK_PROJECT"; -const PROCESS_ENV: &str = "DISASMER_SDK_PROCESS"; -const USER_ENV: &str = "DISASMER_SDK_USER"; -const SESSION_SECRET_ENV: &str = "DISASMER_SDK_SESSION_SECRET"; -const BUNDLE_DIGEST_ENV: &str = "DISASMER_SDK_BUNDLE_DIGEST"; -const WASM_MODULE_ENV: &str = "DISASMER_SDK_WASM_MODULE_BASE64"; -const WASM_MODULE_PATH_ENV: &str = "DISASMER_SDK_WASM_MODULE_PATH"; -const VFS_EPOCH_ENV: &str = "DISASMER_SDK_VFS_EPOCH"; +const COORDINATOR_ENV: &str = "CLUSTERFLUX_SDK_COORDINATOR"; +const TENANT_ENV: &str = "CLUSTERFLUX_SDK_TENANT"; +const PROJECT_ENV: &str = "CLUSTERFLUX_SDK_PROJECT"; +const PROCESS_ENV: &str = "CLUSTERFLUX_SDK_PROCESS"; +const USER_ENV: &str = "CLUSTERFLUX_SDK_USER"; +const SESSION_SECRET_ENV: &str = "CLUSTERFLUX_SDK_SESSION_SECRET"; +const BUNDLE_DIGEST_ENV: &str = "CLUSTERFLUX_SDK_BUNDLE_DIGEST"; +const WASM_MODULE_ENV: &str = "CLUSTERFLUX_SDK_WASM_MODULE_BASE64"; +const WASM_MODULE_PATH_ENV: &str = "CLUSTERFLUX_SDK_WASM_MODULE_PATH"; +const VFS_EPOCH_ENV: &str = "CLUSTERFLUX_SDK_VFS_EPOCH"; #[cfg(not(target_arch = "wasm32"))] static NEXT_PRODUCT_TASK_INSTANCE: AtomicU64 = AtomicU64::new(1); @@ -128,6 +128,7 @@ pub(crate) fn start_remote_task( task_definition: String, environment: Option, args: Vec, + failure_policy: TaskFailurePolicy, ) -> Result { if task_definition.trim().is_empty() || task_definition.len() > 128 { return Err(TaskRuntimeError::Configuration( @@ -155,7 +156,7 @@ pub(crate) fn start_remote_task( )); } let source_snapshot = source_snapshots.into_iter().next(); - let task_instance = disasmer_core::TaskInstanceId::new(format!( + let task_instance = clusterflux_core::TaskInstanceId::new(format!( "ti:{}:{}", config.process, NEXT_PRODUCT_TASK_INSTANCE.fetch_add(1, Ordering::SeqCst) @@ -179,6 +180,7 @@ pub(crate) fn start_remote_task( required_artifacts, args, vfs_epoch: config.vfs_epoch, + failure_policy, bundle_digest: Some(config.bundle_digest.clone()), }; let payload = json!({ @@ -232,9 +234,9 @@ where { #[cfg(target_arch = "wasm32")] if let Some(handle_id) = handle.host_handle_id { - let response: Result = guest_host_call( - &disasmer_core::WasmHostTaskJoinRequest { - abi_version: disasmer_core::WASM_TASK_ABI_VERSION, + let response: Result = guest_host_call( + &clusterflux_core::WasmHostTaskJoinRequest { + abi_version: clusterflux_core::WASM_TASK_ABI_VERSION, handle_id, }, GuestHostCall::Join, @@ -308,15 +310,17 @@ pub(crate) fn start_guest_host_task( task_definition: TaskDefinitionId, environment: Option, args: Vec, + failure_policy: TaskFailurePolicy, ) -> Result { - let request = disasmer_core::WasmHostTaskStartRequest { - abi_version: disasmer_core::WASM_TASK_ABI_VERSION, + let request = clusterflux_core::WasmHostTaskStartRequest { + abi_version: clusterflux_core::WASM_TASK_ABI_VERSION, task_definition, environment_id: environment.map(|environment| environment.name.to_owned()), args, + failure_policy, }; request.validate().map_err(TaskRuntimeError::Argument)?; - let response: Result = + let response: Result = guest_host_call(&request, GuestHostCall::Start)?; let response = response.map_err(TaskRuntimeError::RemoteTask)?; Ok(RemoteTaskHandle { @@ -344,10 +348,10 @@ pub(crate) fn run_guest_host_command( working_directory: String, environment_variables: std::collections::BTreeMap, timeout_ms: u64, - network: disasmer_core::CommandNetworkPolicy, -) -> Result { - let request = disasmer_core::WasmHostCommandRequest { - abi_version: disasmer_core::WASM_TASK_ABI_VERSION, + network: clusterflux_core::CommandNetworkPolicy, +) -> Result { + let request = clusterflux_core::WasmHostCommandRequest { + abi_version: clusterflux_core::WASM_TASK_ABI_VERSION, program, args, working_directory, @@ -356,18 +360,18 @@ pub(crate) fn run_guest_host_command( network, }; request.validate().map_err(TaskRuntimeError::Argument)?; - let response: Result = + let response: Result = guest_host_call(&request, GuestHostCall::Command)?; response.map_err(TaskRuntimeError::RemoteTask) } #[cfg(target_arch = "wasm32")] pub(crate) fn guest_cancellation_requested() -> Result { - let request = disasmer_core::WasmHostTaskControlRequest { - abi_version: disasmer_core::WASM_TASK_ABI_VERSION, + let request = clusterflux_core::WasmHostTaskControlRequest { + abi_version: clusterflux_core::WASM_TASK_ABI_VERSION, }; request.validate().map_err(TaskRuntimeError::Argument)?; - let response: Result = + let response: Result = guest_host_call(&request, GuestHostCall::TaskControl)?; response .map(|result| result.cancellation_requested) @@ -377,39 +381,39 @@ pub(crate) fn guest_cancellation_requested() -> Result { #[cfg(target_arch = "wasm32")] pub(crate) fn guest_debug_probe( symbol: String, -) -> Result { - let request = disasmer_core::WasmHostDebugProbeRequest { - abi_version: disasmer_core::WASM_TASK_ABI_VERSION, +) -> Result { + let request = clusterflux_core::WasmHostDebugProbeRequest { + abi_version: clusterflux_core::WASM_TASK_ABI_VERSION, symbol, }; request.validate().map_err(TaskRuntimeError::Argument)?; - let response: Result = + let response: Result = guest_host_call(&request, GuestHostCall::DebugProbe)?; response.map_err(TaskRuntimeError::RemoteTask) } #[cfg(target_arch = "wasm32")] pub(crate) fn guest_vfs_operation( - operation: disasmer_core::WasmHostVfsOperation, -) -> Result { - let request = disasmer_core::WasmHostVfsRequest { - abi_version: disasmer_core::WASM_TASK_ABI_VERSION, + operation: clusterflux_core::WasmHostVfsOperation, +) -> Result { + let request = clusterflux_core::WasmHostVfsRequest { + abi_version: clusterflux_core::WASM_TASK_ABI_VERSION, operation, }; request.validate().map_err(TaskRuntimeError::Argument)?; - let response: Result = + let response: Result = guest_host_call(&request, GuestHostCall::Vfs)?; response.map_err(TaskRuntimeError::RemoteTask) } #[cfg(target_arch = "wasm32")] pub(crate) fn guest_source_snapshot( -) -> Result { - let request = disasmer_core::WasmHostSourceSnapshotRequest { - abi_version: disasmer_core::WASM_TASK_ABI_VERSION, +) -> Result { + let request = clusterflux_core::WasmHostSourceSnapshotRequest { + abi_version: clusterflux_core::WASM_TASK_ABI_VERSION, }; request.validate().map_err(TaskRuntimeError::Argument)?; - let response: Result = + let response: Result = guest_host_call(&request, GuestHostCall::SourceSnapshot)?; response.map_err(TaskRuntimeError::RemoteTask) } @@ -420,7 +424,7 @@ where I: serde::Serialize, O: DeserializeOwned, { - #[link(wasm_import_module = "disasmer")] + #[link(wasm_import_module = "clusterflux")] unsafe extern "C" { #[link_name = "task_start_v1"] fn task_start_v1( @@ -475,12 +479,12 @@ where let input = serde_json::to_vec(input).map_err(|error| TaskRuntimeError::Protocol(error.to_string()))?; - if input.len() > disasmer_core::MAX_WASM_TASK_ENVELOPE_BYTES { + if input.len() > clusterflux_core::MAX_WASM_TASK_ENVELOPE_BYTES { return Err(TaskRuntimeError::Argument( "Wasm host-call request exceeds the task ABI limit".to_owned(), )); } - let mut output = vec![0_u8; disasmer_core::MAX_WASM_TASK_ENVELOPE_BYTES]; + let mut output = vec![0_u8; clusterflux_core::MAX_WASM_TASK_ENVELOPE_BYTES]; let written = unsafe { match call { GuestHostCall::Start => task_start_v1( diff --git a/crates/clusterflux-sdk/src/task_args.rs b/crates/clusterflux-sdk/src/task_args.rs new file mode 100644 index 0000000..b2a104b --- /dev/null +++ b/crates/clusterflux-sdk/src/task_args.rs @@ -0,0 +1,352 @@ +use serde::{ser::SerializeStruct, Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +pub struct SourceSnapshot { + pub digest: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +pub struct Blob { + pub digest: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +pub struct Artifact { + pub id: String, + pub digest: String, + pub size_bytes: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +pub struct Vfs { + pub digest: String, +} + +impl Serialize for SourceSnapshot { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + crate::__private::serialize_task_handle( + clusterflux_core::TaskBoundaryHandle::SourceSnapshot( + parse_handle_digest(&self.digest).map_err(serde::ser::Error::custom)?, + ), + serializer, + |serializer| serialize_digest_struct("SourceSnapshot", &self.digest, serializer), + ) + } +} + +impl Serialize for Blob { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + crate::__private::serialize_task_handle( + clusterflux_core::TaskBoundaryHandle::Blob( + parse_handle_digest(&self.digest).map_err(serde::ser::Error::custom)?, + ), + serializer, + |serializer| serialize_digest_struct("Blob", &self.digest, serializer), + ) + } +} + +impl Serialize for Vfs { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + crate::__private::serialize_task_handle( + clusterflux_core::TaskBoundaryHandle::VfsManifest( + parse_handle_digest(&self.digest).map_err(serde::ser::Error::custom)?, + ), + serializer, + |serializer| serialize_digest_struct("Vfs", &self.digest, serializer), + ) + } +} + +impl Serialize for Artifact { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + if self.id.trim().is_empty() || self.id.len() > 256 { + return Err(serde::ser::Error::custom( + "artifact handle ID must be non-empty and at most 256 bytes", + )); + } + let handle = clusterflux_core::ArtifactHandle { + id: clusterflux_core::ArtifactId::from(self.id.as_str()), + digest: parse_handle_digest(&self.digest).map_err(serde::ser::Error::custom)?, + size_bytes: self.size_bytes, + }; + crate::__private::serialize_task_handle( + clusterflux_core::TaskBoundaryHandle::Artifact(handle), + serializer, + |serializer| { + let mut state = serializer.serialize_struct("Artifact", 3)?; + state.serialize_field("id", &self.id)?; + state.serialize_field("digest", &self.digest)?; + state.serialize_field("size_bytes", &self.size_bytes)?; + state.end() + }, + ) + } +} + +fn serialize_digest_struct( + name: &'static str, + digest: &str, + serializer: S, +) -> Result +where + S: serde::Serializer, +{ + let mut state = serializer.serialize_struct(name, 1)?; + state.serialize_field("digest", digest)?; + state.end() +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct EnvRef { + pub name: &'static str, +} + +impl EnvRef { + pub const fn new_static(name: &'static str) -> Self { + Self { name } + } +} + +#[macro_export] +macro_rules! env { + ($name:literal) => { + $crate::EnvRef::new_static($name) + }; +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TaskArgKind { + SmallSerialized, + Structured, + Handle, +} + +/// A value allowed to cross a Clusterflux task boundary. +/// +/// Implementations are deliberately limited to small owned values and explicit +/// Clusterflux handles. Host-only values fail at compile time because they do not +/// implement `TaskArg`: +/// +/// ```compile_fail +/// let borrowed = String::from("host-owned"); +/// let _ = clusterflux::spawn::task_with_arg(borrowed.as_str(), |_| 1_u32); +/// ``` +/// +/// ```compile_fail +/// let pointer = std::ptr::null::(); +/// let _ = clusterflux::spawn::task_with_arg(pointer, |_| 1_u32); +/// ``` +/// +/// ```compile_fail +/// let file = std::fs::File::open("Cargo.toml").unwrap(); +/// let _ = clusterflux::spawn::task_with_arg(file, |_| 1_u32); +/// ``` +/// +/// ```compile_fail +/// let lock = std::sync::Mutex::new(1_u32); +/// let guard = lock.lock().unwrap(); +/// let _ = clusterflux::spawn::task_with_arg(guard, |_| 1_u32); +/// ``` +pub trait TaskArg: Serialize { + fn task_arg_kind(&self) -> TaskArgKind { + TaskArgKind::SmallSerialized + } + + fn task_boundary_value(&self) -> Result { + serde_json::to_value(self) + .map(clusterflux_core::TaskBoundaryValue::SmallJson) + .map_err(|error| TaskArgError::Serialization(error.to_string())) + } +} + +macro_rules! small_task_arg { + ($($ty:ty),+ $(,)?) => { + $( + impl TaskArg for $ty {} + )+ + }; +} + +small_task_arg!( + (), + bool, + char, + String, + i8, + i16, + i32, + i64, + isize, + u8, + u16, + u32, + u64, + usize, + f32, + f64, +); + +impl TaskArg for SourceSnapshot { + fn task_arg_kind(&self) -> TaskArgKind { + TaskArgKind::Handle + } + + fn task_boundary_value(&self) -> Result { + parse_handle_digest(&self.digest).map(clusterflux_core::TaskBoundaryValue::SourceSnapshot) + } +} + +impl TaskArg for Blob { + fn task_arg_kind(&self) -> TaskArgKind { + TaskArgKind::Handle + } + + fn task_boundary_value(&self) -> Result { + parse_handle_digest(&self.digest).map(clusterflux_core::TaskBoundaryValue::Blob) + } +} + +impl TaskArg for Artifact { + fn task_arg_kind(&self) -> TaskArgKind { + TaskArgKind::Handle + } + + fn task_boundary_value(&self) -> Result { + Ok(clusterflux_core::TaskBoundaryValue::Artifact( + clusterflux_core::ArtifactHandle { + id: clusterflux_core::ArtifactId::from(self.id.as_str()), + digest: parse_handle_digest(&self.digest)?, + size_bytes: self.size_bytes, + }, + )) + } +} + +impl TaskArg for Vfs { + fn task_arg_kind(&self) -> TaskArgKind { + TaskArgKind::Handle + } + + fn task_boundary_value(&self) -> Result { + parse_handle_digest(&self.digest).map(clusterflux_core::TaskBoundaryValue::VfsManifest) + } +} + +impl TaskArg for Option +where + T: TaskArg + crate::__private::CollectTaskHandles, +{ + fn task_arg_kind(&self) -> TaskArgKind { + TaskArgKind::Structured + } + + fn task_boundary_value(&self) -> Result { + crate::__private::structured_task_boundary(self) + } +} + +impl TaskArg for Vec +where + T: TaskArg + crate::__private::CollectTaskHandles, +{ + fn task_arg_kind(&self) -> TaskArgKind { + TaskArgKind::Structured + } + + fn task_boundary_value(&self) -> Result { + crate::__private::structured_task_boundary(self) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct TaskArgBudget { + pub max_inline_bytes: usize, +} + +impl Default for TaskArgBudget { + fn default() -> Self { + Self { + max_inline_bytes: 64 * 1024, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TaskArgValidation { + pub inline_bytes: usize, + pub kind: TaskArgKind, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum TaskArgError { + Serialization(String), + TooLarge { size: usize, limit: usize }, + HostOnly { type_name: &'static str }, +} + +impl std::fmt::Display for TaskArgError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Serialization(error) => write!(f, "task argument could not serialize: {error}"), + Self::TooLarge { size, limit } => write!( + f, + "task argument is {size} bytes; inline task arguments are limited to {limit} bytes, use SourceSnapshot, Blob, Artifact, or VFS handles" + ), + Self::HostOnly { type_name } => write!( + f, + "task boundary value `{type_name}` is host-only; use small serialized data or handles" + ), + } + } +} + +impl std::error::Error for TaskArgError {} + +pub fn validate_task_arg( + value: &T, + budget: TaskArgBudget, +) -> Result +where + T: TaskArg + ?Sized, +{ + let bytes = serde_json::to_vec(value) + .map_err(|error| TaskArgError::Serialization(error.to_string()))?; + let kind = value.task_arg_kind(); + if kind != TaskArgKind::Handle && bytes.len() > budget.max_inline_bytes { + return Err(TaskArgError::TooLarge { + size: bytes.len(), + limit: budget.max_inline_bytes, + }); + } + Ok(TaskArgValidation { + inline_bytes: bytes.len(), + kind, + }) +} + +pub fn reject_host_only_task_arg() -> TaskArgError { + TaskArgError::HostOnly { + type_name: std::any::type_name::(), + } +} + +pub(crate) fn parse_handle_digest(value: &str) -> Result { + serde_json::from_value(serde_json::Value::String(value.to_owned())).map_err(|_| { + TaskArgError::Serialization( + "handle digest must be a serialized sha256 digest issued by Clusterflux".to_owned(), + ) + }) +} diff --git a/crates/disasmer-sdk/tests/macros.rs b/crates/clusterflux-sdk/tests/macros.rs similarity index 54% rename from crates/disasmer-sdk/tests/macros.rs rename to crates/clusterflux-sdk/tests/macros.rs index 1a5edc5..659e4be 100644 --- a/crates/disasmer-sdk/tests/macros.rs +++ b/crates/clusterflux-sdk/tests/macros.rs @@ -1,32 +1,34 @@ use futures_executor::block_on; use serde::{Deserialize, Serialize}; -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, disasmer::TaskArg)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, clusterflux::TaskArg)] struct CompoundBoundary { label: String, - source: disasmer::SourceSnapshot, - optional_artifact: Option, - artifacts: Vec, + source: clusterflux::SourceSnapshot, + optional_artifact: Option, + artifacts: Vec, + blobs: Vec>, + vfs: clusterflux::Vfs, } -#[disasmer::main] +#[clusterflux::main] fn build_main() -> u32 { 1 } -#[disasmer::main(name = "release")] +#[clusterflux::main(name = "release")] fn release_main() -> u32 { 2 } -#[disasmer::task(name = "compile-linux", capabilities = "Command")] +#[clusterflux::task(name = "compile-linux", capabilities = "Command")] fn compile_linux() -> u32 { 41 } -#[disasmer::task] -fn package_release() -> disasmer::Artifact { - disasmer::Artifact { +#[clusterflux::task] +fn package_release() -> clusterflux::Artifact { + clusterflux::Artifact { id: "artifact://package/release.tar.zst".to_owned(), digest: "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" .to_owned(), @@ -34,31 +36,31 @@ fn package_release() -> disasmer::Artifact { } } -#[disasmer::main(name = "async-build")] +#[clusterflux::main(name = "async-build")] async fn async_build_main() -> Result<(), &'static str> { Ok(()) } -#[disasmer::task(name = "async-compile")] +#[clusterflux::task(name = "async-compile")] async fn async_compile(input: u32) -> Result { Ok(input + 1) } -#[disasmer::task(name = "roundtrip-compound")] +#[clusterflux::task(name = "roundtrip-compound")] async fn roundtrip_compound(input: CompoundBoundary) -> CompoundBoundary { input } #[test] -fn disasmer_attributes_and_spawn_api_compile_for_rust_build_workflow() { - let program = disasmer::RegisteredProgram { +fn clusterflux_attributes_and_spawn_api_compile_for_rust_build_workflow() { + let program = clusterflux::RegisteredProgram { entrypoints: &[ - __DISASMER_ENTRYPOINT_BUILD_MAIN, - __DISASMER_ENTRYPOINT_RELEASE_MAIN, + __CLUSTERFLUX_ENTRYPOINT_BUILD_MAIN, + __CLUSTERFLUX_ENTRYPOINT_RELEASE_MAIN, ], tasks: &[ - __DISASMER_TASK_COMPILE_LINUX, - __DISASMER_TASK_PACKAGE_RELEASE, + __CLUSTERFLUX_TASK_COMPILE_LINUX, + __CLUSTERFLUX_TASK_PACKAGE_RELEASE, ], }; assert_eq!( @@ -82,7 +84,7 @@ fn disasmer_attributes_and_spawn_api_compile_for_rust_build_workflow() { assert_eq!(task.abi_version, 1); assert!(task.source_file.ends_with("macros.rs")); assert!(task.source_line > 0); - assert_eq!(task.probe_symbol, "disasmer.probe.compile_linux"); + assert_eq!(task.probe_symbol, "clusterflux.probe.compile_linux"); assert!(task.bundle_manifest_entry); assert!(task.remotely_startable); let entrypoint = program.select_entrypoint("build").unwrap(); @@ -92,9 +94,9 @@ fn disasmer_attributes_and_spawn_api_compile_for_rust_build_workflow() { assert!(entrypoint.bundle_manifest_entry); let result = block_on(async { - let handle = disasmer::spawn::task(|| compile_linux() + build_main()) + let handle = clusterflux::spawn::task(|| compile_linux() + build_main()) .name("compile linux") - .env(disasmer::env!("linux")) + .env(clusterflux::env!("linux")) .start() .await .unwrap(); @@ -109,26 +111,30 @@ fn disasmer_attributes_and_spawn_api_compile_for_rust_build_workflow() { #[test] fn task_boundaries_accept_small_values_and_runtime_handles() { - let small = disasmer::validate_task_arg(&7_u32, disasmer::TaskArgBudget::default()).unwrap(); - assert_eq!(small.kind, disasmer::TaskArgKind::SmallSerialized); + let small = + clusterflux::validate_task_arg(&7_u32, clusterflux::TaskArgBudget::default()).unwrap(); + assert_eq!(small.kind, clusterflux::TaskArgKind::SmallSerialized); let artifact = package_release(); - let artifact = disasmer::validate_task_arg( + let artifact = clusterflux::validate_task_arg( &artifact, - disasmer::TaskArgBudget { + clusterflux::TaskArgBudget { max_inline_bytes: 4, }, ) .unwrap(); - assert_eq!(artifact.kind, disasmer::TaskArgKind::Handle); + assert_eq!(artifact.kind, clusterflux::TaskArgKind::Handle); } #[test] fn async_main_and_task_attributes_preserve_real_rust_futures_and_results() { assert_eq!(block_on(async_build_main()), Ok(())); assert_eq!(block_on(async_compile(41)), Ok(42)); - assert_eq!(__DISASMER_ENTRYPOINT_ASYNC_BUILD_MAIN.name, "async-build"); - assert_eq!(__DISASMER_TASK_ASYNC_COMPILE.name, "async-compile"); + assert_eq!( + __CLUSTERFLUX_ENTRYPOINT_ASYNC_BUILD_MAIN.name, + "async-build" + ); + assert_eq!(__CLUSTERFLUX_TASK_ASYNC_COMPILE.name, "async-compile"); } #[test] @@ -136,45 +142,55 @@ fn derived_compound_boundary_keeps_nested_handles_out_of_opaque_json() { let source = "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; let value = CompoundBoundary { label: "release".to_owned(), - source: disasmer::SourceSnapshot { + source: clusterflux::SourceSnapshot { digest: source.to_owned(), }, - optional_artifact: Some(disasmer::Artifact { + optional_artifact: Some(clusterflux::Artifact { id: "optional.bin".to_owned(), digest: source.to_owned(), size_bytes: 0, }), artifacts: vec![ - disasmer::Artifact { + clusterflux::Artifact { id: "one.bin".to_owned(), digest: source.to_owned(), size_bytes: 0, }, - disasmer::Artifact { + clusterflux::Artifact { id: "two.bin".to_owned(), digest: source.to_owned(), size_bytes: 0, }, ], + blobs: vec![Some(clusterflux::Blob { + digest: source.to_owned(), + })], + vfs: clusterflux::Vfs { + digest: source.to_owned(), + }, }; assert_eq!(block_on(roundtrip_compound(value.clone())), value); - let boundary = disasmer::TaskArg::task_boundary_value(&value).unwrap(); - let disasmer::core::TaskBoundaryValue::Structured(structured) = boundary else { + let boundary = clusterflux::TaskArg::task_boundary_value(&value).unwrap(); + let clusterflux::core::TaskBoundaryValue::Structured(structured) = boundary else { panic!("derived boundary must be structured"); }; - assert_eq!(structured.handles.len(), 4); + assert_eq!(structured.handles.len(), 6); + let inline = structured.value.to_string(); + assert!(!inline.contains(source)); + assert!(!inline.contains("optional.bin")); assert_eq!( - disasmer::core::TaskBoundaryValue::Structured(structured.clone()).source_snapshots(), - vec![disasmer::core::Digest::sha256([])] + clusterflux::core::TaskBoundaryValue::Structured(structured.clone()).source_snapshots(), + vec![clusterflux::core::Digest::sha256([])] ); assert_eq!( - disasmer::core::TaskBoundaryValue::Structured(structured.clone()).required_artifacts(), + clusterflux::core::TaskBoundaryValue::Structured(structured.clone()).required_artifacts(), vec![ - disasmer::core::ArtifactId::from("optional.bin"), - disasmer::core::ArtifactId::from("one.bin"), - disasmer::core::ArtifactId::from("two.bin"), + clusterflux::core::ArtifactId::from("optional.bin"), + clusterflux::core::ArtifactId::from("one.bin"), + clusterflux::core::ArtifactId::from("two.bin"), ] ); - let decoded: CompoundBoundary = serde_json::from_value(structured.value).unwrap(); + let decoded: CompoundBoundary = + serde_json::from_value(structured.materialize().unwrap()).unwrap(); assert_eq!(decoded, value); } diff --git a/crates/disasmer-wasm-runtime/Cargo.toml b/crates/clusterflux-wasm-runtime/Cargo.toml similarity index 74% rename from crates/disasmer-wasm-runtime/Cargo.toml rename to crates/clusterflux-wasm-runtime/Cargo.toml index 778c463..f792d09 100644 --- a/crates/disasmer-wasm-runtime/Cargo.toml +++ b/crates/clusterflux-wasm-runtime/Cargo.toml @@ -1,12 +1,12 @@ [package] -name = "disasmer-wasm-runtime" +name = "clusterflux-wasm-runtime" version = "0.1.0" edition.workspace = true license.workspace = true repository.workspace = true [dependencies] -disasmer-core = { path = "../disasmer-core" } +clusterflux-core = { path = "../clusterflux-core" } serde.workspace = true serde_json.workspace = true thiserror.workspace = true diff --git a/crates/disasmer-wasm-runtime/src/lib.rs b/crates/clusterflux-wasm-runtime/src/lib.rs similarity index 84% rename from crates/disasmer-wasm-runtime/src/lib.rs rename to crates/clusterflux-wasm-runtime/src/lib.rs index f15e582..9732e3f 100644 --- a/crates/disasmer-wasm-runtime/src/lib.rs +++ b/crates/clusterflux-wasm-runtime/src/lib.rs @@ -1,4 +1,4 @@ -use disasmer_core::{ +use clusterflux_core::{ DebugEpoch, DebugParticipant, DebugParticipantKind, DebugRuntimeState, Digest, ProcessId, TaskInstanceId, WasmHostCommandRequest, WasmHostCommandResult, WasmHostDebugProbeRequest, WasmHostDebugProbeResult, WasmHostSourceSnapshotRequest, WasmHostSourceSnapshotResult, @@ -10,24 +10,66 @@ use serde::{Deserialize, Serialize}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Condvar, Mutex}; use std::thread; -use std::time::Duration; +use std::time::{Duration, Instant}; use thiserror::Error; use wasmtime::{ Caller, Config, DebugEvent, DebugHandler, Engine, Linker, Module, OptLevel, Store, - StoreContextMut, StoreLimits, StoreLimitsBuilder, UpdateDeadline, + StoreContextMut, StoreLimits, StoreLimitsBuilder, UpdateDeadline, WasmBacktrace, }; mod task_host_linker; use task_host_linker::{task_host_linker, task_host_stub_linker}; const INACTIVE_EPOCH_DEADLINE_TICKS: u64 = u64::MAX / 2; -const MAX_WASM_MEMORY_BYTES: usize = 256 * 1024 * 1024; +const DEFAULT_MAX_WASM_MEMORY_BYTES: usize = 256 * 1024 * 1024; const MAX_WASM_TABLE_ELEMENTS: usize = 100_000; -const MAX_WASM_FUEL_PER_INVOCATION: u64 = 1_000_000_000; +const DEFAULT_WASM_FUEL_UNITS_PER_SECOND: u64 = 10_000_000; +const DEFAULT_WASM_FUEL_BURST_SECONDS: u64 = 60; -fn task_store_limits() -> StoreLimits { +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WasmtimeRuntimeLimits { + pub fuel_units_per_second: u64, + pub fuel_burst_seconds: u64, + pub memory_bytes: usize, +} + +impl WasmtimeRuntimeLimits { + pub fn validate(&self) -> Result<(), WasmTaskError> { + if self.fuel_units_per_second == 0 || self.fuel_burst_seconds == 0 { + return Err(WasmTaskError::Runtime( + "Wasm fuel rate and burst duration must be positive".to_owned(), + )); + } + if self.memory_bytes == 0 { + return Err(WasmTaskError::Runtime( + "Wasm memory limit must be positive".to_owned(), + )); + } + self.fuel_capacity().ok_or_else(|| { + WasmTaskError::Runtime("Wasm fuel burst capacity overflowed u64".to_owned()) + })?; + Ok(()) + } + + fn fuel_capacity(&self) -> Option { + self.fuel_units_per_second + .checked_mul(self.fuel_burst_seconds) + } +} + +impl Default for WasmtimeRuntimeLimits { + fn default() -> Self { + Self { + fuel_units_per_second: DEFAULT_WASM_FUEL_UNITS_PER_SECOND, + fuel_burst_seconds: DEFAULT_WASM_FUEL_BURST_SECONDS, + memory_bytes: DEFAULT_MAX_WASM_MEMORY_BYTES, + } + } +} + +fn task_store_limits(runtime: &WasmtimeRuntimeLimits) -> StoreLimits { StoreLimitsBuilder::new() - .memory_size(MAX_WASM_MEMORY_BYTES) + .memory_size(runtime.memory_bytes) .table_elements(MAX_WASM_TABLE_ELEMENTS) .instances(8) .tables(8) @@ -37,14 +79,15 @@ fn task_store_limits() -> StoreLimits { } fn debug_control_trace(message: impl std::fmt::Display) { - if std::env::var_os("DISASMER_DEBUG_CONTROL_TRACE").is_some() { - eprintln!("disasmer debug control: {message}"); + if std::env::var_os("CLUSTERFLUX_DEBUG_CONTROL_TRACE").is_some() { + eprintln!("clusterflux debug control: {message}"); } } #[derive(Clone)] pub struct WasmtimeTaskRuntime { engine: Engine, + runtime_limits: WasmtimeRuntimeLimits, } pub trait WasmTaskHost { @@ -78,7 +121,7 @@ pub trait WasmTaskHost { ) -> Result { request.validate()?; Ok(WasmHostDebugProbeResult { - abi_version: disasmer_core::WASM_TASK_ABI_VERSION, + abi_version: clusterflux_core::WASM_TASK_ABI_VERSION, breakpoint_matched: false, debug_epoch: None, }) @@ -95,8 +138,10 @@ struct WasmDebugControlState { requested_epoch: Option, frozen_epoch: Option, resumed_through_epoch: u64, + execution_armed: bool, quiescent_host_boundary_depth: usize, frozen_at_host_boundary: bool, + stack_frames: Vec, } #[derive(Debug, Default)] @@ -113,7 +158,9 @@ impl WasmDebugControl { .is_none_or(|requested| epoch >= requested) { state.requested_epoch = Some(epoch); - if state.quiescent_host_boundary_depth > 0 && state.resumed_through_epoch < epoch { + if (!state.execution_armed || state.quiescent_host_boundary_depth > 0) + && state.resumed_through_epoch < epoch + { state.frozen_epoch = Some(epoch); state.frozen_at_host_boundary = true; } @@ -187,6 +234,36 @@ impl WasmDebugControl { state.frozen_epoch != Some(epoch) } + pub fn stack_frames(&self) -> Vec { + self.state + .lock() + .expect("debug control lock poisoned") + .stack_frames + .clone() + } + + fn record_stack_frames(&self, stack_frames: Vec) { + self.state + .lock() + .expect("debug control lock poisoned") + .stack_frames = stack_frames; + } + + fn arm_execution(&self, abort: &AtomicBool) { + let mut state = self.state.lock().expect("debug control lock poisoned"); + state.execution_armed = true; + while state.frozen_at_host_boundary + && state.frozen_epoch.is_some() + && !abort.load(Ordering::Acquire) + { + state = self + .changed + .wait_timeout(state, Duration::from_millis(50)) + .expect("debug control lock poisoned at Wasm startup safepoint") + .0; + } + } + fn pause_at_requested_epoch(&self, abort: &AtomicBool) { let mut state = self.state.lock().expect("debug control lock poisoned"); let Some(epoch) = state.requested_epoch else { @@ -258,12 +335,49 @@ struct WasmtimeTaskHostState { host: Box, fatal_host_error: Option, limits: StoreLimits, + fuel_budget: FuelTokenBucket, } struct BasicStoreState { limits: StoreLimits, } +struct FuelTokenBucket { + fuel_units_per_second: u64, + capacity: u64, + last_refill: Instant, +} + +impl FuelTokenBucket { + fn new(limits: &WasmtimeRuntimeLimits) -> Self { + Self { + fuel_units_per_second: limits.fuel_units_per_second, + capacity: limits + .fuel_capacity() + .expect("validated Wasm fuel capacity"), + last_refill: Instant::now(), + } + } + + fn refill(&mut self, current_fuel: u64) -> u64 { + let now = Instant::now(); + let elapsed_nanos = now.duration_since(self.last_refill).as_nanos(); + self.last_refill = now; + let refill = elapsed_nanos + .saturating_mul(self.fuel_units_per_second as u128) + .checked_div(1_000_000_000) + .unwrap_or(0) + .min(u64::MAX as u128) as u64; + current_fuel.saturating_add(refill).min(self.capacity) + } +} + +impl WasmtimeTaskHostState { + pub(crate) fn refill_fuel_after_host_call(&mut self, current_fuel: u64) -> u64 { + self.fuel_budget.refill(current_fuel) + } +} + struct EpochControlGuard { stop: Arc, watcher: Option>, @@ -298,6 +412,9 @@ impl EpochControlGuard { Ok(UpdateDeadline::Continue(1)) }); store.set_epoch_deadline(1); + if let Some(debug) = &debug { + debug.arm_execution(&abort); + } let engine = engine.clone(); let stop = Arc::new(AtomicBool::new(false)); let watcher_stop = Arc::clone(&stop); @@ -406,7 +523,7 @@ impl Default for WasmtimeDebugState { fn default() -> Self { Self { snapshot: None, - limits: task_store_limits(), + limits: task_store_limits(&WasmtimeRuntimeLimits::default()), } } } @@ -471,12 +588,20 @@ impl DebugHandler for WasmtimeLocalSnapshotHandler { impl WasmtimeTaskRuntime { pub fn new() -> Result { + Self::new_with_limits(WasmtimeRuntimeLimits::default()) + } + + pub fn new_with_limits(runtime_limits: WasmtimeRuntimeLimits) -> Result { + runtime_limits.validate()?; let mut config = Config::new(); config.wasm_backtrace_details(wasmtime::WasmBacktraceDetails::Enable); config.epoch_interruption(true); config.consume_fuel(true); let engine = Engine::new(&config).map_err(wasmtime_error)?; - Ok(Self { engine }) + Ok(Self { + engine, + runtime_limits, + }) } fn debug_engine() -> Result { @@ -526,12 +651,17 @@ impl WasmtimeTaskRuntime { WasmtimeTaskHostState { host, fatal_host_error: None, - limits: task_store_limits(), + limits: task_store_limits(&self.runtime_limits), + fuel_budget: FuelTokenBucket::new(&self.runtime_limits), }, ); store.limiter(|state| &mut state.limits); store - .set_fuel(MAX_WASM_FUEL_PER_INVOCATION) + .set_fuel( + self.runtime_limits + .fuel_capacity() + .expect("validated fuel capacity"), + ) .map_err(wasmtime_error)?; defer_epoch_interruption(&mut store); let instance = linker @@ -565,12 +695,16 @@ impl WasmtimeTaskRuntime { let mut store = Store::new( &self.engine, BasicStoreState { - limits: task_store_limits(), + limits: task_store_limits(&self.runtime_limits), }, ); store.limiter(|state| &mut state.limits); store - .set_fuel(MAX_WASM_FUEL_PER_INVOCATION) + .set_fuel( + self.runtime_limits + .fuel_capacity() + .expect("validated fuel capacity"), + ) .map_err(wasmtime_error)?; defer_epoch_interruption(&mut store); let instance = linker @@ -580,7 +714,7 @@ impl WasmtimeTaskRuntime { .get_memory(&mut store, "memory") .ok_or_else(|| WasmTaskError::TaskAbi("guest module exports no memory".to_owned()))?; let allocate = instance - .get_typed_func::(&mut store, "disasmer_alloc_v1") + .get_typed_func::(&mut store, "clusterflux_alloc_v1") .map_err(wasmtime_error)?; let input_length = u32::try_from(encoded.len()) .map_err(|_| WasmTaskError::TaskAbi("task invocation is too large".to_owned()))?; @@ -647,12 +781,17 @@ impl WasmtimeTaskRuntime { WasmtimeTaskHostState { host, fatal_host_error: None, - limits: task_store_limits(), + limits: task_store_limits(&self.runtime_limits), + fuel_budget: FuelTokenBucket::new(&self.runtime_limits), }, ); store.limiter(|state| &mut state.limits); store - .set_fuel(MAX_WASM_FUEL_PER_INVOCATION) + .set_fuel( + self.runtime_limits + .fuel_capacity() + .expect("validated fuel capacity"), + ) .map_err(wasmtime_error)?; defer_epoch_interruption(&mut store); let instance = linker @@ -662,7 +801,7 @@ impl WasmtimeTaskRuntime { .get_memory(&mut store, "memory") .ok_or_else(|| WasmTaskError::TaskAbi("guest module exports no memory".to_owned()))?; let allocate = instance - .get_typed_func::(&mut store, "disasmer_alloc_v1") + .get_typed_func::(&mut store, "clusterflux_alloc_v1") .map_err(wasmtime_error)?; let input_length = u32::try_from(encoded.len()) .map_err(|_| WasmTaskError::TaskAbi("task invocation is too large".to_owned()))?; @@ -702,12 +841,16 @@ impl WasmtimeTaskRuntime { let mut store = Store::new( &self.engine, BasicStoreState { - limits: task_store_limits(), + limits: task_store_limits(&self.runtime_limits), }, ); store.limiter(|state| &mut state.limits); store - .set_fuel(MAX_WASM_FUEL_PER_INVOCATION) + .set_fuel( + self.runtime_limits + .fuel_capacity() + .expect("validated fuel capacity"), + ) .map_err(wasmtime_error)?; defer_epoch_interruption(&mut store); let instance = linker diff --git a/crates/disasmer-wasm-runtime/src/task_host_linker.rs b/crates/clusterflux-wasm-runtime/src/task_host_linker.rs similarity index 89% rename from crates/disasmer-wasm-runtime/src/task_host_linker.rs rename to crates/clusterflux-wasm-runtime/src/task_host_linker.rs index 464b557..127ccb0 100644 --- a/crates/disasmer-wasm-runtime/src/task_host_linker.rs +++ b/crates/clusterflux-wasm-runtime/src/task_host_linker.rs @@ -6,7 +6,7 @@ pub(super) fn task_host_linker( let mut linker = Linker::new(engine); linker .func_wrap( - "disasmer", + "clusterflux", "task_start_v1", |mut caller: Caller<'_, WasmtimeTaskHostState>, input_pointer: u32, @@ -27,7 +27,7 @@ pub(super) fn task_host_linker( .map_err(wasmtime_error)?; linker .func_wrap( - "disasmer", + "clusterflux", "source_snapshot_v1", |mut caller: Caller<'_, WasmtimeTaskHostState>, input_pointer: u32, @@ -48,7 +48,7 @@ pub(super) fn task_host_linker( .map_err(wasmtime_error)?; linker .func_wrap( - "disasmer", + "clusterflux", "vfs_operation_v1", |mut caller: Caller<'_, WasmtimeTaskHostState>, input_pointer: u32, @@ -69,7 +69,7 @@ pub(super) fn task_host_linker( .map_err(wasmtime_error)?; linker .func_wrap( - "disasmer", + "clusterflux", "command_run_v1", |mut caller: Caller<'_, WasmtimeTaskHostState>, input_pointer: u32, @@ -90,7 +90,7 @@ pub(super) fn task_host_linker( .map_err(wasmtime_error)?; linker .func_wrap( - "disasmer", + "clusterflux", "task_control_v1", |mut caller: Caller<'_, WasmtimeTaskHostState>, input_pointer: u32, @@ -111,7 +111,7 @@ pub(super) fn task_host_linker( .map_err(wasmtime_error)?; linker .func_wrap( - "disasmer", + "clusterflux", "debug_probe_v1", |mut caller: Caller<'_, WasmtimeTaskHostState>, input_pointer: u32, @@ -132,7 +132,7 @@ pub(super) fn task_host_linker( .map_err(wasmtime_error)?; linker .func_wrap( - "disasmer", + "clusterflux", "task_join_v1", |mut caller: Caller<'_, WasmtimeTaskHostState>, input_pointer: u32, @@ -168,7 +168,7 @@ pub(super) fn task_host_stub_linker( ] { linker .func_wrap( - "disasmer", + "clusterflux", import, |_input_pointer: u32, _input_length: u32, @@ -180,7 +180,7 @@ pub(super) fn task_host_stub_linker( } linker .func_wrap( - "disasmer", + "clusterflux", "debug_probe_v1", |mut caller: Caller<'_, T>, input_pointer: u32, @@ -224,7 +224,7 @@ fn debug_probe_stub_call( let response: Result = match serde_json::from_slice::(&input) { Ok(request) => request.validate().map(|()| WasmHostDebugProbeResult { - abi_version: disasmer_core::WASM_TASK_ABI_VERSION, + abi_version: clusterflux_core::WASM_TASK_ABI_VERSION, breakpoint_matched: false, debug_epoch: None, }), @@ -281,6 +281,20 @@ fn task_host_call( { return -3; } + if let Some(debug) = caller.data().host.debug_control() { + let stack_frames = WasmBacktrace::force_capture(&*caller) + .frames() + .iter() + .take(16) + .map(|frame| { + frame + .func_name() + .map(str::to_owned) + .unwrap_or_else(|| format!("wasm_function_{}", frame.func_index())) + }) + .collect(); + debug.record_stack_frames(stack_frames); + } let encoded = match operation { TaskHostOperation::Start => { let debug = caller.data().host.debug_control(); @@ -306,17 +320,19 @@ fn task_host_call( if let Some(debug) = &debug { debug.enter_quiescent_host_boundary(abort.as_deref()); } - let response: Result = - match serde_json::from_slice::(&input) { - Ok(request) if request.abi_version == disasmer_core::WASM_TASK_ABI_VERSION => { - caller.data_mut().host.join_task(request) - } - Ok(request) => Err(format!( - "unsupported Wasm task ABI version {}", - request.abi_version - )), - Err(error) => Err(error.to_string()), - }; + let response: Result = match serde_json::from_slice::< + WasmHostTaskJoinRequest, + >(&input) + { + Ok(request) if request.abi_version == clusterflux_core::WASM_TASK_ABI_VERSION => { + caller.data_mut().host.join_task(request) + } + Ok(request) => Err(format!( + "unsupported Wasm task ABI version {}", + request.abi_version + )), + Err(error) => Err(error.to_string()), + }; if let Some(debug) = &debug { debug.leave_quiescent_host_boundary(abort.as_deref()); } @@ -393,6 +409,11 @@ fn task_host_call( let Ok(encoded) = encoded else { return -4; }; + let current_fuel = caller.get_fuel().unwrap_or(0); + let refilled_fuel = caller.data_mut().refill_fuel_after_host_call(current_fuel); + if caller.set_fuel(refilled_fuel).is_err() { + return -8; + } if encoded.is_empty() || encoded.len() > output_capacity as usize { return -5; } diff --git a/crates/disasmer-wasm-runtime/src/tests.rs b/crates/clusterflux-wasm-runtime/src/tests.rs similarity index 91% rename from crates/disasmer-wasm-runtime/src/tests.rs rename to crates/clusterflux-wasm-runtime/src/tests.rs index 44f5305..aa2c495 100644 --- a/crates/disasmer-wasm-runtime/src/tests.rs +++ b/crates/clusterflux-wasm-runtime/src/tests.rs @@ -1,5 +1,24 @@ use super::*; +#[test] +fn fuel_token_bucket_refills_after_idle_and_never_exceeds_burst() { + let limits = WasmtimeRuntimeLimits { + fuel_units_per_second: 100, + fuel_burst_seconds: 2, + memory_bytes: 1024 * 1024, + }; + let mut bucket = FuelTokenBucket::new(&limits); + bucket.last_refill = Instant::now() - Duration::from_millis(1_500); + let refilled = bucket.refill(0); + assert!((150..=151).contains(&refilled)); + + bucket.last_refill = Instant::now() - Duration::from_secs(10); + assert_eq!(bucket.refill(refilled), 200); + for _ in 0..10_000 { + assert!(bucket.refill(200) <= 200); + } +} + struct AbortSignalHost { abort: Arc, debug: Option>, @@ -41,7 +60,7 @@ impl WasmTaskHost for AbortSignalHost { ) -> Result { request.validate()?; Ok(WasmHostTaskControlResult { - abi_version: disasmer_core::WASM_TASK_ABI_VERSION, + abi_version: clusterflux_core::WASM_TASK_ABI_VERSION, cancellation_requested: false, }) } diff --git a/crates/disasmer-sdk/src/task_args.rs b/crates/disasmer-sdk/src/task_args.rs deleted file mode 100644 index 1f0f29a..0000000 --- a/crates/disasmer-sdk/src/task_args.rs +++ /dev/null @@ -1,250 +0,0 @@ -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct SourceSnapshot { - pub digest: String, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct Blob { - pub digest: String, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct Artifact { - pub id: String, - pub digest: String, - pub size_bytes: u64, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct EnvRef { - pub name: &'static str, -} - -impl EnvRef { - pub const fn new_static(name: &'static str) -> Self { - Self { name } - } -} - -#[macro_export] -macro_rules! env { - ($name:literal) => { - $crate::EnvRef::new_static($name) - }; -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum TaskArgKind { - SmallSerialized, - Structured, - Handle, -} - -/// A value allowed to cross a Disasmer task boundary. -/// -/// Implementations are deliberately limited to small owned values and explicit -/// Disasmer handles. Host-only values fail at compile time because they do not -/// implement `TaskArg`: -/// -/// ```compile_fail -/// let borrowed = String::from("host-owned"); -/// let _ = disasmer::spawn::task_with_arg(borrowed.as_str(), |_| 1_u32); -/// ``` -/// -/// ```compile_fail -/// let pointer = std::ptr::null::(); -/// let _ = disasmer::spawn::task_with_arg(pointer, |_| 1_u32); -/// ``` -/// -/// ```compile_fail -/// let file = std::fs::File::open("Cargo.toml").unwrap(); -/// let _ = disasmer::spawn::task_with_arg(file, |_| 1_u32); -/// ``` -/// -/// ```compile_fail -/// let lock = std::sync::Mutex::new(1_u32); -/// let guard = lock.lock().unwrap(); -/// let _ = disasmer::spawn::task_with_arg(guard, |_| 1_u32); -/// ``` -pub trait TaskArg: Serialize { - fn task_arg_kind(&self) -> TaskArgKind { - TaskArgKind::SmallSerialized - } - - fn task_boundary_value(&self) -> Result { - serde_json::to_value(self) - .map(disasmer_core::TaskBoundaryValue::SmallJson) - .map_err(|error| TaskArgError::Serialization(error.to_string())) - } -} - -macro_rules! small_task_arg { - ($($ty:ty),+ $(,)?) => { - $( - impl TaskArg for $ty {} - )+ - }; -} - -small_task_arg!( - (), - bool, - char, - String, - i8, - i16, - i32, - i64, - isize, - u8, - u16, - u32, - u64, - usize, - f32, - f64, -); - -impl TaskArg for SourceSnapshot { - fn task_arg_kind(&self) -> TaskArgKind { - TaskArgKind::Handle - } - - fn task_boundary_value(&self) -> Result { - parse_handle_digest(&self.digest).map(disasmer_core::TaskBoundaryValue::SourceSnapshot) - } -} - -impl TaskArg for Blob { - fn task_arg_kind(&self) -> TaskArgKind { - TaskArgKind::Handle - } - - fn task_boundary_value(&self) -> Result { - parse_handle_digest(&self.digest).map(disasmer_core::TaskBoundaryValue::Blob) - } -} - -impl TaskArg for Artifact { - fn task_arg_kind(&self) -> TaskArgKind { - TaskArgKind::Handle - } - - fn task_boundary_value(&self) -> Result { - Ok(disasmer_core::TaskBoundaryValue::Artifact( - disasmer_core::ArtifactHandle { - id: disasmer_core::ArtifactId::from(self.id.as_str()), - digest: parse_handle_digest(&self.digest)?, - size_bytes: self.size_bytes, - }, - )) - } -} - -impl TaskArg for Option -where - T: TaskArg + crate::__private::CollectTaskHandles, -{ - fn task_arg_kind(&self) -> TaskArgKind { - TaskArgKind::Structured - } - - fn task_boundary_value(&self) -> Result { - crate::__private::structured_task_boundary(self) - } -} - -impl TaskArg for Vec -where - T: TaskArg + crate::__private::CollectTaskHandles, -{ - fn task_arg_kind(&self) -> TaskArgKind { - TaskArgKind::Structured - } - - fn task_boundary_value(&self) -> Result { - crate::__private::structured_task_boundary(self) - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct TaskArgBudget { - pub max_inline_bytes: usize, -} - -impl Default for TaskArgBudget { - fn default() -> Self { - Self { - max_inline_bytes: 64 * 1024, - } - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct TaskArgValidation { - pub inline_bytes: usize, - pub kind: TaskArgKind, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum TaskArgError { - Serialization(String), - TooLarge { size: usize, limit: usize }, - HostOnly { type_name: &'static str }, -} - -impl std::fmt::Display for TaskArgError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Serialization(error) => write!(f, "task argument could not serialize: {error}"), - Self::TooLarge { size, limit } => write!( - f, - "task argument is {size} bytes; inline task arguments are limited to {limit} bytes, use SourceSnapshot, Blob, Artifact, or VFS handles" - ), - Self::HostOnly { type_name } => write!( - f, - "task boundary value `{type_name}` is host-only; use small serialized data or handles" - ), - } - } -} - -impl std::error::Error for TaskArgError {} - -pub fn validate_task_arg( - value: &T, - budget: TaskArgBudget, -) -> Result -where - T: TaskArg + ?Sized, -{ - let bytes = serde_json::to_vec(value) - .map_err(|error| TaskArgError::Serialization(error.to_string()))?; - let kind = value.task_arg_kind(); - if kind != TaskArgKind::Handle && bytes.len() > budget.max_inline_bytes { - return Err(TaskArgError::TooLarge { - size: bytes.len(), - limit: budget.max_inline_bytes, - }); - } - Ok(TaskArgValidation { - inline_bytes: bytes.len(), - kind, - }) -} - -pub fn reject_host_only_task_arg() -> TaskArgError { - TaskArgError::HostOnly { - type_name: std::any::type_name::(), - } -} - -pub(crate) fn parse_handle_digest(value: &str) -> Result { - serde_json::from_value(serde_json::Value::String(value.to_owned())).map_err(|_| { - TaskArgError::Serialization( - "handle digest must be a serialized sha256 digest issued by Disasmer".to_owned(), - ) - }) -} diff --git a/docs/architecture.md b/docs/architecture.md index 90eb40c..2b3062d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,46 +1,55 @@ # Architecture -Disasmer presents a distributed workflow as one virtual process. The workflow -program and its tasks are WebAssembly modules; the coordinator, attached nodes, -CLI, and debugger are separate host processes. +Clusterflux separates a control plane from execution nodes. -## Components +## Coordinator -- The SDK and macros turn Rust workflow functions into versioned Wasm exports. -- The CLI builds and validates a bounded bundle, creates a virtual process, and - launches its selected entrypoint as the coordinator main. -- The coordinator owns identity, authorization, scheduling, process state, and - debug epochs. It executes only the capless coordinator-main Wasm: that main - may spawn/join tasks, wait, log bounded state, and participate in debugging, - but cannot run commands, read source or host files, use sockets, or obtain - secrets. The coordinator does not retain artifact bytes. -- Attached nodes verify assignments and execute Wasm with Wasmtime. Host - operations are available only through imports backed by granted capabilities. -- The DAP adapter maps a virtual process and its tasks to ordinary debugger - processes and threads without pretending that source-level stepping exists. +The coordinator owns identity scope, one-active-process admission, task +placement, attempt history, Debug Epoch coordination, VFS metadata, artifact +metadata, quotas, and secure relay reservations. Its async main may park and wake +without consuming node compute. -The standalone Core coordinator is the public self-hosted service. The Hosted -coordinator wraps the same Core service with private identity-provider, -community-policy, operator, and deployment code. Public/private is a source -distribution boundary, not an authority boundary. +The coordinator does not execute arbitrary native commands or hosted +containers. It also does not become a durable artifact store merely because it +coordinates a download. -## State +## Nodes -Durable Postgres state contains tenants, users, projects, credentials, -permissions, source-provider configuration, and service policy records. Active -virtual processes, assignments, debug epochs, transient VFS views, and artifact -locations are intentionally ephemeral for the MVP. Restarting the coordinator -therefore preserves identity and project configuration but ends active work. +A node is an enrolled public-key identity. It reports capabilities and periodic +heartbeats. The coordinator derives whether it is live from the last accepted +heartbeat; a client-supplied "online" field is not placement authority. -## Execution and placement +Nodes resolve bundle environments, execute Wasm tasks, run native commands, and +retain artifact bytes. A node must prove the tenant, project, process, task, and +key scope on every signed request. -Wasm is the execution substrate, not a capability. The coordinator main uses a -small fixed capless import set. A node task's `TaskSpec` declares the additional -host operations and environment it needs, and a node is eligible only when its -reported capabilities satisfy those requirements. For example, a Wasm task may -request command execution in a declared Linux environment; a user-attached node -with rootless Podman can provide that operation, while hosted community -infrastructure denies arbitrary native commands and hosted containers. +## Virtual process and tasks -The build-system workflow is the MVP wedge. General distributed application -runtime features are deliberately outside the initial release. +A Coordinator Project admits one active virtual process. The process contains: + +- one coordinator-hosted async main; +- logical task instances created by that main; +- one or more attempts for each logical task; +- joins tied to the logical task, not to an individual attempt. + +"FailFast" makes a terminal failure visible to the join immediately. +"AwaitOperator" keeps the join pending while an operator accepts, cancels, or +restarts the failed task. Restart creates a distinct attempt but preserves the +logical task identity. + +A terminal process releases the active slot automatically. A task parked for an +operator is not terminal and continues to occupy the slot. + +## Durable and live state + +Projects, identities, credentials, permissions, and hosted policy records may +be stored in Postgres. Active processes, task placement, Debug Epochs, transient +VFS state, relay reservations, and live node state are bounded in memory and are +not reconstructed as running after a coordinator restart. + +## Protocol lanes + +Human clients use scoped sessions. Agents and nodes use separate signed +public-key identities. Operator actions use a separate administrative +credential. Authority comes from the authenticated lane and server-side scope, +never from identity fields in an untrusted request body. diff --git a/docs/artifacts.md b/docs/artifacts.md index 825da0a..cdc7ca8 100644 --- a/docs/artifacts.md +++ b/docs/artifacts.md @@ -1,37 +1,36 @@ # Artifacts -Artifacts are named, content-addressed results retained by the node that -produced or received their bytes. The coordinator stores authorization and -location metadata, not an implicit copy of every output. +Clusterflux separates artifact metadata from artifact bytes. -## Publish, flush, and sync +## Publish metadata -- Publishing from a Wasm task transfers bytes to its retaining node and records - the digest and size. -- `flush()` publishes metadata so downstream tasks can refer to the artifact. It - does not imply coordinator storage or durable replication. -- `sync()` is an explicit movement decision to another node or user-provided - storage integration. +"flush()" makes a VFS artifact visible to downstream tasks and records its +digest, size, producer task and attempt, and retaining locations. It does not +upload bytes to the coordinator by default. -Large bytes should travel node-to-node or node-to-receiver. Task arguments carry -artifact handles rather than embedding output data. +Your node retains artifact bytes on a best-effort basis. If every retaining node +is lost, stale, revoked, or garbage-collects the bytes, the artifact is +unavailable. Clusterflux reports that state instead of inventing durable +storage. -## Download and export +## Move bytes explicitly -`disasmer artifact download ` asks the coordinator for an authorized, -scoped stream and retrieves the real bytes from a retaining node. Authorization -is bound to tenant, project, process, artifact, actor, retention state, expiry, -and quota. Missing, expired, revoked, oversized, or unreachable artifacts fail -explicitly before success is reported. +Use "sync()" or an explicit export when you need another node or your own storage +system to hold the bytes. Same-node reuse avoids a coordinator transfer. -Final export is also explicit. A project can attach a receiver node or invoke a -user-provided storage tool from an allowed environment. The MVP intentionally -does not advertise a managed artifact-store product. +## Download -Node retention is best effort unless project code synchronizes the artifact to -a durable destination. Coordinator restart does not manufacture or preserve -artifact bytes. Each node enforces configured byte, object-count, and age -bounds. Artifacts required by active tasks, live downloads, or a retained -restart boundary are pinned; garbage collection evicts eligible older objects -and reports unavailable bytes honestly. Coordinator metadata and download -session histories are independently count- and time-bounded. +~~~bash +clusterflux artifact list --process +clusterflux artifact download --to ./output.bin --max-bytes 67108864 +~~~ + +Before returning a link, the service checks authorization, current retention, +source-node liveness, live size, project quota, and relay capacity. It reserves +the affordable ingress and egress budget atomically. The scoped link is +unguessable, expires, can be revoked, and is bound to the actor, tenant, project, +process, artifact, and policy context. + +The reverse stream counts framing, base64 expansion, failed bytes, and abandoned +transfers. The CLI verifies the final digest. Hosted policy may impose stricter +size, concurrency, and period limits or disable relay traffic globally. diff --git a/docs/debugging.md b/docs/debugging.md index c72bbce..4558251 100644 --- a/docs/debugging.md +++ b/docs/debugging.md @@ -1,30 +1,42 @@ # Debugging -Disasmer exposes the distributed workflow as one virtual process with virtual -threads for tasks. The VS Code extension launches or attaches through the DAP -adapter and reads the same live process registry used by the CLI. +The VS Code extension uses the Debug Adapter Protocol and the coordinator's +authoritative process, task, attempt, and Debug Epoch APIs. -## Supported controls +## Launch -- Launch and attach to a real coordinator process. -- Inspect processes, tasks, logs, artifacts, nodes, scopes, and variables. -- Set function/source breakpoints supported by the compiled Wasm mapping. -- Continue and pause through participant-acknowledged debug epochs. -- Rebuild and restart a terminal task from its captured clean VFS entry boundary - under a fresh task-instance ID when the edited task ABI and boundary remain - compatible. Incompatible edits require a whole-process restart without - altering the running process. -- Cooperatively cancel or explicitly abort the whole virtual process. +Open your project and start "Clusterflux: Launch Virtual Process". The adapter +builds the bundle, launches the selected entrypoint, and replaces its thread +view with coordinator task snapshots. Product mode does not infer threads from +completion events or demo fixtures. -Source-level stepping is not in the MVP. Step requests fail explicitly instead -of returning a synthetic success. All-stop succeeds only after every active -participant acknowledges the requested epoch; a missing participant is reported -as a freeze failure. +Each task view includes its logical task, attempt ID, definition, state, node, +environment, arguments, typed handles, command status, VFS checkpoint, probe +source when known, and restart compatibility. Unknown source remains unknown. -When F5 finds an existing process in the same Coordinator Project, the extension -offers attach, restart, cancel, or abort. If the process came from another local -workspace, it does not silently attach with the wrong source mapping. Processes -remain visible independent of whether they currently contain active tasks. +## Breakpoints and Debug Epochs -The DAP demo backend is an isolated adapter test fixture. Product launches use -the coordinator/node runtime path and do not fall back to the demo model. +When a breakpoint is hit, the coordinator asks every active participant to +freeze. A fully frozen epoch is a consistent all-participant view. + +If one or more participants cannot freeze within five seconds, the epoch becomes +partially frozen when at least one participant did freeze. The adapter warns +that running and frozen tasks do not form a consistent global snapshot. You may +inspect the acknowledged participants and resume them cleanly. Missing or failed +participants remain explicit. + +Stack frames, Wasm locals, task arguments, handles, command status, and output +come from runtime acknowledgements. No frame or value is fabricated when the +runtime did not report it. + +## Continue and restart + +Continue resumes only participants that acknowledged the freeze. + +Restarting a terminal task rebuilds the bundle and checks its clean entry +checkpoint. A compatible restart creates a new attempt under the same logical +task identity. Earlier attempt history remains inspectable, and the logical join +continues. An active task or an incompatible boundary requires a whole-process +restart. + +Source stepping is not simulated. Unsupported stepping fails explicitly. diff --git a/docs/environments.md b/docs/environments.md new file mode 100644 index 0000000..7ceb3bc --- /dev/null +++ b/docs/environments.md @@ -0,0 +1,34 @@ +# Environments + +Declare environments in the bundle under: + +~~~text +envs//Containerfile +envs//Dockerfile +~~~ + +Reference one by logical name: + +~~~rust +use clusterflux::env; + +let linux = env!("linux"); +~~~ + +Bundle inspection reports every discovered environment and its digest: + +~~~bash +clusterflux bundle inspect --project . +~~~ + +The bundle definition is authoritative for every spawn. The coordinator passes +the declared environment identity and digest in the TaskSpec, and the node +resolves that exact definition. A same-named local recipe with different bytes +is rejected rather than substituted. + +On Linux, container-backed environments use rootless Podman. Clusterflux does +not enable privileged containers by default. + +A task may use a bind-mounted local checkout for speed. That source path is +non-hermetic. Choose a source snapshot when you need a reproducible input +identity independent of the current working tree. diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..779f1f8 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,110 @@ +# Getting started + +This guide uses the hosted coordinator. For your own coordinator, complete +[Self-hosting](self-hosting.md) first and then return to the node and run steps. + +## 1. Install the commands + +~~~bash +cargo install --path crates/clusterflux-cli --bin clusterflux +cargo install --path crates/clusterflux-node --bin clusterflux-node +cargo install --path crates/clusterflux-dap --bin clusterflux-debug-dap +~~~ + +Install rootless Podman on each Linux node that will execute container-backed +environments. + +## 2. Sign in and create a project + +~~~bash +clusterflux login --browser +clusterflux auth status +clusterflux project init --new-project my-project --name "My Project" --yes +clusterflux project select my-project +~~~ + +The browser flow is owned by the configured Authentik identity provider. The CLI +stores an opaque Clusterflux session, not provider authorization codes or +provider tokens. + +## 3. Enroll and start a node + +Create a short-lived grant: + +~~~bash +clusterflux node enroll --project-id my-project --json +~~~ + +Generate a node key pair using your normal secret-management tooling. Exchange +the grant once: + +~~~bash +clusterflux node attach --project-id my-project --node workstation --enrollment-grant "$ENROLLMENT_GRANT" --public-key "$NODE_PUBLIC_KEY" --json +~~~ + +Start the worker from the project directory with its private key supplied +through protected environment or credential storage: + +~~~bash +CLUSTERFLUX_NODE_PRIVATE_KEY="$NODE_PRIVATE_KEY" clusterflux-node --coordinator https://clusterflux.michelpaulissen.com --tenant "$TENANT" --project-id my-project --node workstation --public-key "$NODE_PUBLIC_KEY" --project-root "$PWD" --worker --emit-ready +~~~ + +The enrollment grant is not needed again. Stop and restart the worker with the +same key identity. Check server-derived liveness with: + +~~~bash +clusterflux node list +clusterflux node status workstation +~~~ + +## 4. Inspect and run a bundle + +A project contains "clusterflux.toml", Rust workflow source, and any declared +environments under "envs/". + +~~~bash +clusterflux bundle inspect --project . +clusterflux run --project . build +~~~ + +Choose another entrypoint by replacing "build". Clusterflux rejects an +oversized or invalid bundle before it creates the virtual process. + +## 5. Inspect tasks and output + +~~~bash +clusterflux process list +clusterflux process status +clusterflux task list +clusterflux logs +clusterflux artifact list +~~~ + +A failed task configured with "AwaitOperator" remains visible as awaiting +action. Restart it as a new attempt under the same logical task identity: + +~~~bash +clusterflux task restart --process --yes +~~~ + +## 6. Debug in VS Code + +Open the project in VS Code and start "Clusterflux: Launch Virtual Process". +Set a breakpoint on a generated probe location and use the normal Threads, +Stack, Variables, Continue, Pause, and Restart controls. + +A fully frozen Debug Epoch gives a consistent all-participant view. If a +participant cannot freeze within five seconds, the adapter reports a partial +epoch. You may inspect frozen participants, but values across running and frozen +tasks are not a consistent global snapshot. + +## 7. Download an artifact + +~~~bash +clusterflux artifact list --process +clusterflux artifact download --to ./output.bin --max-bytes 67108864 +~~~ + +The command opens a scoped, expiring download and verifies the artifact digest. +It fails if the retaining node is stale, the bytes were garbage collected, the +digest or size changed, or policy cannot reserve the transfer. diff --git a/docs/nodes.md b/docs/nodes.md new file mode 100644 index 0000000..e0db3cc --- /dev/null +++ b/docs/nodes.md @@ -0,0 +1,53 @@ +# Nodes + +Your node runs real commands and retains real output bytes. Enroll it once, then +restart it with the same public-key identity. + +## Enroll + +~~~bash +clusterflux node enroll --project-id my-project --json +clusterflux node attach --project-id my-project --node workstation --enrollment-grant "$ENROLLMENT_GRANT" --public-key "$NODE_PUBLIC_KEY" +~~~ + +Treat the enrollment grant as a short-lived secret. It is exchanged once and is +not a worker credential. + +## Run the worker + +~~~bash +CLUSTERFLUX_NODE_PRIVATE_KEY="$NODE_PRIVATE_KEY" clusterflux-node --coordinator https://clusterflux.michelpaulissen.com --tenant "$TENANT" --project-id my-project --node workstation --public-key "$NODE_PUBLIC_KEY" --project-root "$PWD" --worker --emit-ready +~~~ + +Start the worker from the project directory when tasks need a local checkout. +The worker reports detected command, container, source, VFS, environment, OS, +and architecture capabilities. Use "clusterflux node attach --cap " +only when detection needs an explicit override. + +## Liveness + +~~~bash +clusterflux node list +clusterflux node status workstation +~~~ + +The coordinator marks a node stale after its accepted heartbeat age exceeds the +configured threshold. Stale nodes are excluded before placement and before a +retained-node download link is created. + +## Local source + +A bind-mounted local checkout is fast and avoids transferring the repository, +but it is non-hermetic: uncommitted changes, ignored files, and concurrent +editor writes can affect the command. Use a content-addressed source snapshot +when reproducibility matters. + +## Revoke + +~~~bash +clusterflux node revoke workstation +~~~ + +Revocation removes the node identity and its live descriptor. Subsequent signed +requests fail. Artifacts retained only by that node become unavailable unless +you explicitly synchronized them elsewhere. diff --git a/docs/security.md b/docs/security.md index fee0a4b..c5fe468 100644 --- a/docs/security.md +++ b/docs/security.md @@ -1,54 +1,48 @@ -# Security model +# Security -Disasmer runs untrusted project code as WebAssembly on attached nodes. Wasm is -the isolation substrate; access to host operations is the capability boundary. -Running a Wasm module alone does not grant commands, containers, source access, -artifact access, networking, or coordinator authority. +## Authority -## Authority lanes +Clusterflux separates four authority lanes: -- Client requests use an authenticated session scoped to one user, tenant, and - project. -- Nodes and non-interactive agents use Ed25519 request signatures, bounded - timestamps, unique nonces, and explicitly scoped enrollment grants. -- Identity requests create and poll browser-login transactions. The hosted - callback exchanges the authorization code with Authentik and obtains identity - from its authenticated user-info endpoint; the CLI cannot supply trusted - identity claims. -- Hosted Operator requests use a separate private credential and replay-safe - proof. They are not Client protocol variants authorized by convention. +- Client sessions for people. +- Agent public-key signatures for non-interactive workflows. +- Node public-key signatures for enrolled workers. +- Operator credentials for hosted administrative actions. -The coordinator uses its own clock for expiry, quota windows, and policy -decisions. Quota meters are keyed by tenant, project, resource kind, and window. -Client-provided actor or scope strings are not authority. Replay windows, -login/enrollment transactions, task and debug histories, downloads, and -artifact metadata all have explicit expiry or count bounds scoped to their -authority or project/process object. +The server derives tenant, project, identity, and permission scope from the +authenticated lane. Request-body identity fields are not authority. -## Transport and secrets +## Sessions and keys -Remote control traffic must use HTTPS and follows normal certificate -validation. Plain JSON-lines TCP and HTTP are restricted to loopback and require -an explicit local-development mode on the coordinator. The hosted deployment -terminates TLS at its reverse proxy and forwards only to a loopback service. +Human login uses the configured identity provider and an opaque, nonce- and +PKCE-bound transaction. The CLI never accepts provider claims or authorization +codes as a session. -Node keys are generated from operating-system randomness. Local credential -directories and files use Unix modes `0700` and `0600`, reject symbolic-link -paths, and are written atomically without overwriting an existing credential. -Environment-provided keys remain the operator's storage responsibility. +Enrollment grants are short-lived and single-use. Node and agent signatures bind +the canonical request body, timestamp, nonce, key fingerprint, and scope. +Forged, replayed, expired, revoked, cross-tenant, and body-modified requests +fail. -## Trust boundary +Keep ".clusterflux/session.json", node private keys, agent private keys, and +operator credentials out of source control. Use protected environment files or +your secret manager. -Rootless Podman is a useful additional containment and environment mechanism on -Linux nodes, but it does not turn a denied host operation into an allowed one. -Native commands require an explicit environment, cwd, bounded environment map, -wall-clock timeout, output mount, and network policy. Linux container commands -run with resource and process-count limits, no-new-privileges, and dropped -capabilities; Wasmtime stores independently enforce memory/table/instance and -fuel bounds. A limit failure is a bounded task failure and does not terminate -the node daemon. -Node operators decide which projects they trust and which host capabilities a -node advertises. The community hosted service does not provide arbitrary hosted -native execution; users attach their own nodes for real work. +## Execution -Report vulnerabilities according to [SECURITY.md](../SECURITY.md). +The coordinator does not run arbitrary native user commands. Nodes execute +commands within their reported capabilities. Linux container environments use +rootless Podman and avoid privileged defaults. + +Bundle size, canonical argument size, handle count, logs, task history, Debug +Epoch state, relay state, and coordinator collections are bounded. Resource and +relay reservations happen before unaffordable work or transfer begins. + +## Artifacts + +Artifact links are scoped, expiring, revocable, and bound to live metadata. +Digest, size, producer, task attempt, and retaining source are checked. The +coordinator retains metadata and bounded relay spool state, not durable artifact +bytes by default. + +Report security issues privately to the repository owner rather than opening a +public issue with credentials, keys, or exploit details. diff --git a/docs/self-hosting.md b/docs/self-hosting.md index 9675d68..58a6736 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -1,49 +1,38 @@ # Self-hosting -The public workspace contains the standalone Core coordinator, node runtime, -CLI, SDK, DAP adapter, and VS Code extension. Self-hosting does not require the -private hosted website or Authentik integration. +Run the public coordinator and node runtime without the hosted website. -Build the workspace, then start a coordinator with a protected bootstrap -session: +## Start a strict local coordinator -```bash -cargo build --workspace -DISASMER_SELF_HOSTED_SESSION_SECRET="$SELF_HOSTED_SESSION_SECRET" \ -DISASMER_SELF_HOSTED_TENANT=my-team \ -DISASMER_SELF_HOSTED_PROJECT=my-project \ -DISASMER_SELF_HOSTED_USER=me \ - target/debug/disasmer-coordinator --listen 127.0.0.1:7999 -``` +Supply one scoped bootstrap session through protected service configuration: -The coordinator's native JSON-lines transport is deliberately loopback-only and -the server refuses a non-loopback listener. For administration from another -machine, use an authenticated SSH tunnel and connect to its local endpoint: +~~~bash +CLUSTERFLUX_SELF_HOSTED_SESSION_SECRET="$SELF_HOSTED_SESSION_SECRET" CLUSTERFLUX_SELF_HOSTED_TENANT=my-team CLUSTERFLUX_SELF_HOSTED_PROJECT=my-project CLUSTERFLUX_SELF_HOSTED_USER=me clusterflux-coordinator --listen 127.0.0.1:7999 +~~~ -```bash -ssh -N -L 7999:127.0.0.1:7999 coordinator.example -``` +Connect the CLI without placing the secret in a process argument: -Do not expose plaintext port 7999 to a network. The hosted service is the -reference HTTPS deployment shape: TLS reverse proxy to a loopback HTTP -application endpoint. +~~~bash +printf '%s\n' "$SELF_HOSTED_SESSION_SECRET" | clusterflux auth connect-self-hosted --coordinator 127.0.0.1:7999 --tenant my-team --project-id my-project --user me --session-secret-stdin +~~~ -Connect a CLI without putting the secret in its process arguments: +The CLI verifies the scope before writing ".clusterflux/session.json". On Unix, +the session file uses mode "0600". -```bash -printf '%s\n' "$SELF_HOSTED_SESSION_SECRET" | \ - disasmer auth connect-self-hosted \ - --coordinator 127.0.0.1:7999 \ - --tenant my-team --project-id my-project --user me \ - --session-secret-stdin -``` +## Attach nodes -Create a scoped node enrollment grant, then attach nodes that advertise only the -host capabilities you intend to provide. Linux command/container environments -require rootless Podman on those nodes. Wasmtime execution itself is always part -of the runtime and is not an optional capability. +Use the same enrollment and worker flow described in [Nodes](nodes.md), with +"--coordinator 127.0.0.1:7999". -Configure `DATABASE_URL` for durable identity and project state. Active -processes and tasks are ephemeral in the MVP and end when the coordinator -restarts. Back up Postgres and any user-selected artifact destination; node-local -artifact retention is best effort. +## Network boundary + +The native coordinator transport is plaintext and refuses non-loopback +listeners. For another machine, keep the coordinator on loopback and use an +authenticated SSH tunnel or deploy a trusted TLS reverse proxy that enforces the +same client boundary. Do not expose the native port directly. + +## Administration + +Project, node, process, task, log, artifact, debug, quota, and self-hosted admin +operations remain available through the public CLI/API. Authentik is one hosted +identity deployment, not a requirement for your coordinator. diff --git a/docs/task-abi.md b/docs/task-abi.md index 0bd23b7..20f536f 100644 --- a/docs/task-abi.md +++ b/docs/task-abi.md @@ -1,45 +1,49 @@ # Task ABI -The MVP task ABI is a small, versioned contract between a compiled workflow -module and the coordinator-main or node Wasmtime host. It is intentionally -narrower than WASI and should grow only when the flagship workflow needs -another host operation. +Clusterflux tasks cross a canonical, versioned Wasm boundary. -## Assignment +## Values -Each assignment carries a `TaskSpec` containing the virtual process and task -identity, bundle module, declared Wasm export and ABI version, serialized -arguments, required capabilities and artifacts, selected environment, and VFS -epoch. The node rejects a missing or incompatible spec rather than guessing an -entrypoint. +Small arguments and results use canonical JSON-compatible values. Large or +capability-bearing values use typed handles. A structured boundary value carries +both its JSON structure and the complete typed handle table required to +materialize it. -SDK task macros generate exports that decode versioned boundary values, invoke -the Rust function, and encode the result. Values crossing a task boundary are -limited to serializable data and Disasmer handles; host-only objects fail at -compile time. +Supported handles include VFS and artifact identities with full tenant, project, +process, producer, digest, size, and path provenance. The SDK encodes handles as +explicit placeholders and the runtime replaces only placeholders that match the +declared type and metadata. -## Host imports +A malformed, missing, extra, cross-scope, or type-mismatched handle fails closed. +The runtime does not reconstruct authority from a string path or caller-supplied +display field. -The current guest imports cover task spawn/join, command execution, -cancellation observation, source snapshots, VFS file operations, artifact -publication, and debug probes. Every import validates memory ranges and payload -sizes. The coordinator main receives only spawn/join, waiting, bounded state, -and debug controls; node hosts check the assignment's declared capabilities -before performing source, command, network, or artifact operations. +## Tasks -`disasmer.command_run_v1` runs a command through the selected environment -backend. On Linux, a task assigned to an environment requiring rootless Podman -uses the normal Podman assignment path. The hosted community policy does not -grant this on managed infrastructure; a user node may grant it for its own -machine. +Use the macros and SDK types: -## Lifecycle +~~~rust +#[clusterflux::task] +async fn compile(input: clusterflux::Artifact) -> Result { + // Task body +} -Cooperative cancellation is visible to guest code at a host boundary. Forced -abort arms Wasmtime epoch interruption and also terminates active environment -work. Debug freeze and resume synchronize at acknowledged host or Wasmtime epoch -boundaries. These controls do not claim instruction-accurate source stepping. -Each task assignment captures a restart-compatibility hash and clean VFS entry -boundary. DAP task restart rebuilds the bundle and accepts edited code only when -the task ABI, declared capabilities, environment, arguments, handles, and VFS -boundary remain compatible. +#[clusterflux::main] +async fn build() -> Result<(), String> { + // Spawn and join tasks +} +~~~ + +Each spawn produces a TaskSpec containing the logical task instance, +definition, environment identity and digest, canonical arguments, handles, VFS +epoch, bundle identity, dispatch ABI, and failure policy. + +Use distinct logical task instances when you spawn the same definition twice. +A restart preserves that logical identity and creates a distinct attempt. + +## Failure policy + +"FailFast" is the default. "AwaitOperator" leaves the logical join pending after +failure so you can inspect, accept, cancel, or restart the attempt. Side effects +outside the VFS boundary are at-least-once across retries; make them idempotent +or add your own deduplication key. diff --git a/examples/launch-build-demo/Cargo.toml b/examples/launch-build-demo/Cargo.toml index 8879be0..2f6e4c0 100644 --- a/examples/launch-build-demo/Cargo.toml +++ b/examples/launch-build-demo/Cargo.toml @@ -10,7 +10,7 @@ path = "src/build.rs" crate-type = ["rlib", "cdylib"] [dependencies] -disasmer = { package = "disasmer-sdk", path = "../../crates/disasmer-sdk" } +clusterflux = { package = "clusterflux-sdk", path = "../../crates/clusterflux-sdk" } futures-executor.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/examples/launch-build-demo/README.md b/examples/launch-build-demo/README.md index 499e611..7aeff95 100644 --- a/examples/launch-build-demo/README.md +++ b/examples/launch-build-demo/README.md @@ -7,5 +7,5 @@ The Linux environment is defined by `envs/linux/Containerfile`. The Windows envi Inspect the bundle metadata: ```bash -disasmer bundle inspect --project examples/launch-build-demo +clusterflux bundle inspect --project examples/launch-build-demo ``` diff --git a/examples/launch-build-demo/fixture/hello-disasmer.c b/examples/launch-build-demo/fixture/hello-clusterflux.c similarity index 51% rename from examples/launch-build-demo/fixture/hello-disasmer.c rename to examples/launch-build-demo/fixture/hello-clusterflux.c index 6a1c9c4..79b67fd 100644 --- a/examples/launch-build-demo/fixture/hello-disasmer.c +++ b/examples/launch-build-demo/fixture/hello-clusterflux.c @@ -1,6 +1,6 @@ #include int main(void) { - puts("hello from a real Disasmer build"); + puts("hello from a real Clusterflux build"); return 0; } diff --git a/examples/launch-build-demo/src/bin/sdk-product-runtime.rs b/examples/launch-build-demo/src/bin/sdk-product-runtime.rs index f0b39be..018b9e0 100644 --- a/examples/launch-build-demo/src/bin/sdk-product-runtime.rs +++ b/examples/launch-build-demo/src/bin/sdk-product-runtime.rs @@ -4,25 +4,25 @@ use futures_executor::block_on; fn main() -> Result<(), Box> { let handle = block_on( - disasmer::spawn::task_with_arg(41_i32, |_| -> i32 { + clusterflux::spawn::task_with_arg(41_i32, |_| -> i32 { panic!("product-mode spawn invoked its local Rust closure") }) .task_id("task_add_one") .name("SDK product Wasmtime task") - .env(disasmer::env!("linux")) + .env(clusterflux::env!("linux")) .start(), )?; let task_spec = handle .task_spec() .cloned() - .ok_or("DISASMER_SDK_COORDINATOR did not enable product runtime")?; + .ok_or("CLUSTERFLUX_SDK_COORDINATOR did not enable product runtime")?; let virtual_thread_id = handle.virtual_thread_id(); let result = block_on(handle.join())?; println!( "{}", serde_json::to_string(&serde_json::json!({ - "kind": "disasmer-sdk-product-runtime", + "kind": "clusterflux-sdk-product-runtime", "virtual_thread_id": virtual_thread_id, "task_spec": task_spec, "remote_result": result, diff --git a/examples/launch-build-demo/src/build.rs b/examples/launch-build-demo/src/build.rs index e18315b..86bc8ae 100644 --- a/examples/launch-build-demo/src/build.rs +++ b/examples/launch-build-demo/src/build.rs @@ -1,16 +1,17 @@ -use disasmer::{Artifact, EnvRef, SourceSnapshot}; +use clusterflux::{Artifact, EnvRef, SourceSnapshot}; use serde::{Deserialize, Serialize}; -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, disasmer::TaskArg)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, clusterflux::TaskArg)] pub struct BuildReport { pub linux_thread: u64, + pub linux_parallel_thread: u64, pub package_thread: u64, pub linux_artifact: Artifact, pub package_artifact: Artifact, pub source: SourceSnapshot, } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, disasmer::TaskArg)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, clusterflux::TaskArg)] pub struct PackageInput { pub release_name: String, pub source: SourceSnapshot, @@ -19,18 +20,18 @@ pub struct PackageInput { } pub fn linux_env() -> EnvRef { - disasmer::env!("linux") + clusterflux::env!("linux") } pub fn windows_env() -> EnvRef { - disasmer::env!("windows") + clusterflux::env!("windows") } -#[disasmer::task(capabilities = "source_filesystem")] +#[clusterflux::task(capabilities = "source_filesystem")] pub async fn prepare_source() -> SourceSnapshot { #[cfg(target_arch = "wasm32")] { - return disasmer::source::snapshot() + return clusterflux::source::snapshot() .await .expect("the source task should snapshot the node checkout it was placed on"); } @@ -41,19 +42,19 @@ pub async fn prepare_source() -> SourceSnapshot { } } -#[disasmer::task(capabilities = "command")] +#[clusterflux::task(capabilities = "command")] pub async fn compile_linux(source: SourceSnapshot) -> Artifact { let _ = &source; #[cfg(target_arch = "wasm32")] { - let executable = disasmer::fs::output_path("hello-disasmer") + let executable = clusterflux::fs::output_path("hello-clusterflux") .expect("the executable output path should be task-local"); - let output = disasmer::command::Command::new("cc") + let output = clusterflux::command::Command::new("cc") .args([ "-Os", "-static", "-s", - "fixture/hello-disasmer.c", + "fixture/hello-clusterflux.c", "-o", executable.as_str(), ]) @@ -65,26 +66,26 @@ pub async fn compile_linux(source: SourceSnapshot) -> Artifact { .await .expect("the declared Linux environment should provide a real C compiler"); assert_eq!(output.status_code, Some(0)); - return disasmer::fs::flush(&executable) + return clusterflux::fs::flush(&executable) .await .expect("the command-created executable should flush to node artifact storage"); } #[cfg(not(target_arch = "wasm32"))] Artifact { - id: "hello-disasmer-native-test".to_owned(), + id: "hello-clusterflux-native-test".to_owned(), digest: "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" .to_owned(), size_bytes: 0, } } -#[disasmer::task] +#[clusterflux::task] #[unsafe(no_mangle)] pub extern "C" fn task_add_one(input: i32) -> i32 { input + 1 } -#[disasmer::task] +#[clusterflux::task] #[unsafe(no_mangle)] pub extern "C" fn task_trap(_input: i32) -> i32 { #[cfg(target_arch = "wasm32")] @@ -93,7 +94,7 @@ pub extern "C" fn task_trap(_input: i32) -> i32 { panic!("intentional task trap") } -#[disasmer::task(capabilities = "command")] +#[clusterflux::task(capabilities = "command")] pub async fn package_release(input: PackageInput) -> Artifact { #[cfg(target_arch = "wasm32")] { @@ -103,12 +104,12 @@ pub async fn package_release(input: PackageInput) -> Artifact { .as_ref() .expect("the package input should contain the compiler artifact"); assert!(input.inputs.iter().any(|artifact| artifact == executable)); - let executable = disasmer::fs::materialize(&executable, "package/hello-disasmer") + let executable = clusterflux::fs::materialize(&executable, "package/hello-clusterflux") .await .expect("the retaining node should materialize the compiler artifact locally"); - let release = disasmer::fs::output_path("release.tar") + let release = clusterflux::fs::output_path("release.tar") .expect("the release output path should be task-local"); - let output = disasmer::command::Command::new("tar") + let output = clusterflux::command::Command::new("tar") .args([ "--sort=name", "--mtime=@0", @@ -119,8 +120,8 @@ pub async fn package_release(input: PackageInput) -> Artifact { "-cf", release.as_str(), "-C", - "/disasmer/output/package", - "hello-disasmer", + "/clusterflux/output/package", + "hello-clusterflux", ]) .current_dir("/workspace") .env("SOURCE_DATE_EPOCH", "0") @@ -132,9 +133,9 @@ pub async fn package_release(input: PackageInput) -> Artifact { assert_eq!(output.status_code, Some(0)); assert_eq!( executable.as_str(), - "/disasmer/output/package/hello-disasmer" + "/clusterflux/output/package/hello-clusterflux" ); - return disasmer::fs::flush(&release) + return clusterflux::fs::flush(&release) .await .expect("the command-created release archive should flush as the final artifact"); } @@ -150,11 +151,11 @@ pub async fn package_release(input: PackageInput) -> Artifact { } } -#[disasmer::task(capabilities = "command")] +#[clusterflux::task(capabilities = "command")] pub async fn abort_probe() -> i32 { #[cfg(target_arch = "wasm32")] { - let output = disasmer::command::Command::new("sh") + let output = clusterflux::command::Command::new("sh") .args(["-c", "sleep 30"]) .output() .await @@ -165,11 +166,11 @@ pub async fn abort_probe() -> i32 { 0 } -#[disasmer::task] +#[clusterflux::task] pub fn cooperative_cancellation_probe() -> i32 { #[cfg(target_arch = "wasm32")] loop { - if disasmer::process::cancellation_requested() + if clusterflux::process::cancellation_requested() .expect("task control should remain available while the Wasm task is running") { // Cancellation is a request observed and handled by task code. Returning @@ -181,11 +182,11 @@ pub fn cooperative_cancellation_probe() -> i32 { 17 } -#[disasmer::task] +#[clusterflux::task] pub fn debug_child_probe() -> i32 { #[cfg(target_arch = "wasm32")] loop { - if disasmer::process::cancellation_requested() + if clusterflux::process::cancellation_requested() .expect("child debug participant should retain task control") { return 23; @@ -195,11 +196,11 @@ pub fn debug_child_probe() -> i32 { 23 } -#[disasmer::task] +#[clusterflux::task] pub async fn debug_parent_probe() -> i32 { #[cfg(target_arch = "wasm32")] { - let child = disasmer::spawn::task(debug_child_probe) + let child = clusterflux::spawn::task(debug_child_probe) .name("debug child participant") .start() .await @@ -213,16 +214,16 @@ pub async fn debug_parent_probe() -> i32 { 23 } -#[disasmer::main] +#[clusterflux::main] pub async fn build_main() -> BuildReport { run_build_workflow().await } -#[disasmer::main(name = "fail")] +#[clusterflux::main(name = "fail")] pub async fn fail_main() -> Result { #[cfg(target_arch = "wasm32")] { - let child = disasmer::spawn::task_with_arg(0_i32, |value| task_trap(value)) + let child = clusterflux::spawn::task_with_arg(0_i32, |value| task_trap(value)) .task_id("task_trap") .name("intentional failing child") .start() @@ -237,13 +238,14 @@ pub async fn fail_main() -> Result { Err("intentional child failure".to_owned()) } -#[disasmer::main(name = "restart")] +#[clusterflux::main(name = "restart")] pub async fn restart_main() -> i32 { #[cfg(target_arch = "wasm32")] { - return disasmer::spawn::task_with_arg(41_i32, |value| task_add_one(value)) - .task_id("task_add_one") + return clusterflux::spawn::task_with_arg(0_i32, |value| task_trap(value)) + .task_id("task_trap") .name("edited restart probe") + .failure_policy(clusterflux::spawn::TaskFailurePolicy::AwaitOperator) .start() .await .expect("restart probe should launch its child") @@ -255,8 +257,31 @@ pub async fn restart_main() -> i32 { task_add_one(41) } +#[clusterflux::main(name = "park-wake")] +pub async fn park_wake_main() -> i32 { + #[cfg(target_arch = "wasm32")] + { + let mut value = 0_i32; + for _ in 0..16 { + let next = clusterflux::spawn::task_with_arg(value, |input| task_add_one(input)) + .task_id("task_add_one") + .name("park/wake fuel refill probe") + .start() + .await + .expect("park/wake probe should launch") + .join() + .await + .expect("park/wake probe should complete"); + value = i32::try_from(next).expect("park/wake result should remain in i32 range"); + } + return value; + } + #[cfg(not(target_arch = "wasm32"))] + 16 +} + pub async fn run_build_workflow() -> BuildReport { - let source = disasmer::spawn::async_task(prepare_source) + let source = clusterflux::spawn::async_task(prepare_source) .name("prepare source snapshot") .start() .await @@ -264,15 +289,24 @@ pub async fn run_build_workflow() -> BuildReport { .join() .await .unwrap(); - let linux = disasmer::spawn::async_task_with_arg(source.clone(), compile_linux) + let linux = clusterflux::spawn::async_task_with_arg(source.clone(), compile_linux) .name("compile linux") .env(linux_env()) .start() .await .unwrap(); + let linux_parallel = clusterflux::spawn::async_task_with_arg(source.clone(), compile_linux) + .name("compile linux in parallel") + .env(linux_env()) + .start() + .await + .unwrap(); let linux_thread = linux.virtual_thread_id(); + let linux_parallel_thread = linux_parallel.virtual_thread_id(); let linux_artifact = linux.join().await.unwrap(); - let package = disasmer::spawn::async_task_with_arg( + let linux_parallel_artifact = linux_parallel.join().await.unwrap(); + assert_eq!(linux_artifact.digest, linux_parallel_artifact.digest); + let package = clusterflux::spawn::async_task_with_arg( PackageInput { release_name: "release.tar".to_owned(), source: source.clone(), @@ -292,6 +326,7 @@ pub async fn run_build_workflow() -> BuildReport { BuildReport { linux_thread, + linux_parallel_thread, package_thread, linux_artifact, package_artifact, @@ -308,16 +343,21 @@ mod tests { #[test] fn flagship_workflow_is_rust_source_with_spawned_virtual_tasks() { let main_report = block_on(build_main()); - assert_eq!(main_report.linux_artifact.id, "hello-disasmer-native-test"); + assert_eq!( + main_report.linux_artifact.id, + "hello-clusterflux-native-test" + ); assert_eq!(task_add_one(41), 42); assert_eq!(block_on(restart_main()), 42); + assert_eq!(block_on(park_wake_main()), 16); assert_eq!(linux_env().name, "linux"); assert_eq!(windows_env().name, "windows"); let report = block_on(run_build_workflow()); assert_ne!(report.linux_thread, report.package_thread); - assert_eq!(report.linux_artifact.id, "hello-disasmer-native-test"); + assert_ne!(report.linux_thread, report.linux_parallel_thread); + assert_eq!(report.linux_artifact.id, "hello-clusterflux-native-test"); assert_eq!(report.package_artifact.id, "release-native-test"); assert_eq!( report.source.digest, diff --git a/flake.nix b/flake.nix index c70ccbc..f99377a 100644 --- a/flake.nix +++ b/flake.nix @@ -1,5 +1,5 @@ { - description = "Disasmer development and verification environment"; + description = "Clusterflux development and verification environment"; inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; @@ -27,7 +27,7 @@ zip ]; shellHook = '' - echo "Disasmer shell: $(rustc --version), $(node --version), $(podman --version)" + echo "Clusterflux shell: $(rustc --version), $(node --version), $(podman --version)" ''; }; }); diff --git a/scripts/acceptance-cli-first.sh b/scripts/acceptance-cli-first.sh deleted file mode 100755 index f49e007..0000000 --- a/scripts/acceptance-cli-first.sh +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -cd "$repo" - -if [[ "${DISASMER_PUBLIC_RELEASE_DRYRUN_E2E:-}" == "1" ]]; then - echo "CLI-first acceptance does not run final public-release e2e; unset DISASMER_PUBLIC_RELEASE_DRYRUN_E2E" >&2 - exit 1 -fi - -if [[ "${DISASMER_PUBLIC_RELEASE_DRYRUN_FINAL:-}" == "1" ]]; then - echo "CLI-first acceptance does not run final public-release evidence; unset DISASMER_PUBLIC_RELEASE_DRYRUN_FINAL" >&2 - exit 1 -fi - -node scripts/acceptance-report.js cli-first -node scripts/cli-first-contract-smoke.js -node scripts/acceptance-report-smoke.js -node scripts/acceptance-doc-contract-smoke.js -node scripts/acceptance-environment-contract-smoke.js -node scripts/acceptance-evidence-contract-smoke.js -node scripts/code-size-guard.js -node scripts/public-private-boundary-smoke.js -node scripts/release-blocker-smoke.js -node scripts/resource-metering-contract-smoke.js -node scripts/hostile-input-contract-smoke.js -node scripts/tenant-isolation-contract-smoke.js -node scripts/public-story-contract-smoke.js - -if [[ "${DISASMER_CLI_FIRST_PREPARE_PUBLIC_RELEASE:-1}" == "1" ]]; then - node scripts/prepare-public-release-dryrun.js -fi - -node scripts/public-release-dryrun-contract-smoke.js -node scripts/public-browser-login-contract-smoke.js -node scripts/self-hosted-coordinator-smoke.js -if [[ "${DISASMER_CLI_HAPPY_PATH_LIVE:-}" == "1" ]]; then - node scripts/cli-happy-path-live-smoke.js -fi -node scripts/public-local-demo-matrix-smoke.js -scripts/release-source-scan.sh - -cargo fmt --all --check -cargo test --workspace -cargo build --workspace --bins - -node scripts/docs-smoke.js -node scripts/cli-output-mode-smoke.js -node scripts/cli-login-smoke.js -node scripts/cli-error-exit-smoke.js -node scripts/cli-browser-login-flow-smoke.js -node scripts/cli-install-smoke.js -node scripts/user-session-token-boundary-smoke.js -node scripts/sdk-spawn-runtime-smoke.js -node scripts/node-lifecycle-contract-smoke.js -node scripts/wasmtime-node-smoke.js -node scripts/wasmtime-assignment-smoke.js -if command -v podman >/dev/null 2>&1; then - node scripts/podman-backend-smoke.js -elif command -v nix >/dev/null 2>&1; then - nix shell nixpkgs#podman --command node scripts/podman-backend-smoke.js -else - node scripts/podman-backend-smoke.js -fi -node scripts/vscode-extension-smoke.js -node scripts/vscode-f5-smoke.js -node scripts/node-attach-smoke.js -node scripts/cli-local-run-smoke.js -node scripts/artifact-download-smoke.js -node scripts/artifact-export-smoke.js -node scripts/operator-panel-smoke.js -node scripts/source-preparation-smoke.js -node scripts/scheduler-placement-smoke.js -node scripts/windows-best-effort-smoke.js -node scripts/windows-validation-contract-smoke.js -node scripts/quic-smoke.js -node scripts/dap-smoke.js -node scripts/flagship-demo-smoke.js - -if [[ "${DISASMER_CLI_FIRST_INCLUDE_PRIVATE:-0}" == "1" ]]; then - scripts/acceptance-private.sh -fi diff --git a/scripts/acceptance-doc-contract-smoke.js b/scripts/acceptance-doc-contract-smoke.js deleted file mode 100755 index ae9857e..0000000 --- a/scripts/acceptance-doc-contract-smoke.js +++ /dev/null @@ -1,205 +0,0 @@ -#!/usr/bin/env node - -const assert = require("assert"); -const fs = require("fs"); -const path = require("path"); -const { - assertPreFinalOrFinalLedger, -} = require("./phase3-ledger"); - -const repo = path.resolve(__dirname, ".."); -if ( - fs.existsSync(path.join(repo, "DISASMER_PUBLIC_TREE.json")) && - !fs.existsSync(path.join(repo, "acceptance_criteria.md")) -) { - console.log( - "Acceptance doc contract smoke skipped: root acceptance markdown is filtered from this public tree" - ); - process.exit(0); -} - -function read(relativePath) { - return fs.readFileSync(path.join(repo, relativePath), "utf8"); -} - -function criterionLines(source) { - return source - .split(/\r?\n/) - .filter((line) => /^- \[[ x]\] \*\*/.test(line)); -} - -function assertEveryCriterionHasStatus(source, name) { - const lines = criterionLines(source); - assert(lines.length > 0, `${name} must contain acceptance criteria`); - for (const line of lines) { - assert.match( - line, - /^- \[[ x]\] \*\*(Passed|Partial|Open|Postponed)(?: \([^)]+\))?:\*\*/, - `${name} criterion lacks an explicit status prefix: ${line}` - ); - } -} - -function assertNoOpenCriteria(source, name) { - const open = criterionLines(source).filter((line) => /\*\*Open(?::| \()/.test(line)); - assert.deepStrictEqual(open, [], `${name} still has Open criteria`); -} - -function assertPhase3CriterionHeadingsHaveStatus(source, name) { - const headings = source - .split(/\r?\n/) - .filter((line) => /^## /.test(line) && /P3-[A-Z]+-\d{3}:/.test(line)); - assert(headings.length > 0, `${name} must contain Phase 3 criteria`); - for (const line of headings) { - assert.match( - line, - /^## \*\*(Passed|Partial|Open|Postponed):\*\* P3-[A-Z]+-\d{3}:/, - `${name} criterion heading lacks an explicit status prefix: ${line}` - ); - } - assertPreFinalOrFinalLedger(source); -} - -const phase2 = read("acceptance_criteria_phase2.md"); -const base = read("acceptance_criteria.md"); -const cliFirst = read("cli_acceptance_criteria.md"); -const website = read("website_mvp_inventory.md"); -const phase3 = read("phase_3_acceptance_criteria.md"); -const docsSmoke = read("scripts/docs-smoke.js"); -const releaseBlockerSmoke = read("scripts/release-blocker-smoke.js"); -const publicAcceptance = read("scripts/acceptance-public.sh"); -const privateAcceptance = read("scripts/acceptance-private.sh"); -const cliFirstAcceptance = read("scripts/acceptance-cli-first.sh"); -const publicSplit = read("scripts/verify-public-split.sh"); - -assert.match( - phase2, - /phase 2 superset of `acceptance_criteria\.md`/, - "phase 2 criteria must declare that they are a superset of the base criteria" -); -assert.match( - phase2, - /Existing `acceptance_criteria\.md` remains required unless it conflicts with this stricter release document; this document wins in conflicts/, - "phase 2 criteria must keep base acceptance criteria required unless stricter phase 2 criteria conflict" -); -assert.match( - phase2, - /- \[x\] \*\*Passed:\*\* Existing `acceptance_criteria\.md` remains required unless it conflicts with this stricter release document; this document wins in conflicts\./, - "phase 2 cross-document requirement must be marked passed only when this guard is wired" -); - -assert.match( - phase3, - /Canonical Phase 3 gate:[\s\S]*active Phase 3 acceptance document/, - "phase 3 criteria must declare itself as the canonical current gate" -); -assert.match( - phase3, - /Important reading note:[\s\S]*not necessarily always mean adding code/, - "phase 3 criteria must keep the no-automatic-code-work disclaimer at the top" -); -assert.match( - phase3, - /Every criterion heading below is prefixed with \*\*Passed\*\*, \*\*Partial\*\*, or \*\*Open\*\*/, - "phase 3 criteria must explain the explicit status-prefix convention" -); -assertPhase3CriterionHeadingsHaveStatus(phase3, "phase_3_acceptance_criteria.md"); - -for (const [source, name] of [ - [base, "acceptance_criteria.md"], - [phase2, "acceptance_criteria_phase2.md"], - [cliFirst, "cli_acceptance_criteria.md"], -]) { - assertEveryCriterionHasStatus(source, name); - assertNoOpenCriteria(source, name); - assert.match( - source, - /Design-document references to billing, paid plans, or plan flags are future metadata placeholders; they do not require MVP/, - `${name} must keep billing/paid-plan placeholders outside MVP implementation scope` - ); - assert.match( - source, - /not the canonical Phase 3 release gate; current Phase 3 status and release verification live in `phase_3_acceptance_criteria\.md`/, - `${name} must point current Phase 3 status to phase_3_acceptance_criteria.md` - ); -} - -assertEveryCriterionHasStatus(website, "website_mvp_inventory.md"); -assert.match( - website, - /private hosted website addendum to `acceptance_criteria\.md`, `acceptance_criteria_phase2\.md`, and `cli_acceptance_criteria\.md`/, - "website acceptance criteria must declare itself as the private hosted website addendum" -); -assert.match( - website, - /barebones functional HTML with no CSS/, - "website acceptance criteria must keep the MVP website barebones with no CSS" -); -assert.match( - website, - /Billing is not part of the MVP[\s\S]*Design-document references to billing, paid plans, or plan flags are future metadata placeholders; they do not require MVP website routes/, - "website acceptance criteria must keep billing and paid-plan placeholders outside MVP website scope" -); -assert.match( - website, - /- \[ \] \*\*Open:\*\* The private hosted website acceptance gate exists and exercises the barebones website through `disasmer\.michelpaulissen\.com`/, - "website acceptance criteria must leave the private website deployment gate explicit and open" -); -assert.match( - website, - /not the canonical Phase 3 release gate; current Phase 3 status and release verification live in `phase_3_acceptance_criteria\.md`/, - "website acceptance criteria must point current Phase 3 status to phase_3_acceptance_criteria.md" -); - -for (const file of [ - "MVP.md", - "acceptance_criteria.md", - "acceptance_criteria_phase2.md", - "cli_acceptance_criteria.md", - "website_mvp_inventory.md", - "phase_3_acceptance_criteria.md", -]) { - assert( - docsSmoke.includes(`"${file}"`), - `docs smoke must include ${file} as user-facing acceptance context` - ); -} - -assert( - releaseBlockerSmoke.includes('const phase2 = read("acceptance_criteria_phase2.md")'), - "release-blocker smoke must read phase 2 acceptance criteria" -); -assert( - releaseBlockerSmoke.includes('const base = read("acceptance_criteria.md")'), - "release-blocker smoke must read base acceptance criteria" -); - -for (const [source, name] of [ - [base, "acceptance_criteria.md"], - [phase2, "acceptance_criteria_phase2.md"], -]) { - for (const [label, pattern] of [ - ["MVP selected locals", /selected (?:top-level )?locals|selected real source locals/], - ["MVP task args", /task arguments|task args/], - ["MVP handle inspection", /Artifact.*SourceSnapshot.*Blob|Disasmer handles/], - ["MVP stdout stderr", /stdout\/stderr/], - ["MVP unavailable locals", /cannot be inspected|unavailable-local/], - ["MVP required DAP surface", /initialize[\s\S]*launch.*attach[\s\S]*setBreakpoints[\s\S]*configurationDone[\s\S]*threads[\s\S]*stackTrace[\s\S]*scopes[\s\S]*variables[\s\S]*continue[\s\S]*pause/], - ]) { - assert.match(source, pattern, `${name} must include MVP debugging criterion: ${label}`); - } -} - -for (const [scriptName, script] of [ - ["public acceptance", publicAcceptance], - ["private acceptance", privateAcceptance], - ["CLI-first acceptance", cliFirstAcceptance], - ["public split", publicSplit], -]) { - assert( - script.includes("node scripts/acceptance-doc-contract-smoke.js"), - `${scriptName} must run acceptance-doc-contract-smoke.js` - ); -} - -console.log("Acceptance doc contract smoke passed"); diff --git a/scripts/acceptance-environment-contract-smoke.js b/scripts/acceptance-environment-contract-smoke.js deleted file mode 100755 index b0de87b..0000000 --- a/scripts/acceptance-environment-contract-smoke.js +++ /dev/null @@ -1,262 +0,0 @@ -#!/usr/bin/env node - -const assert = require("assert"); -const fs = require("fs"); -const path = require("path"); - -const repo = path.resolve(__dirname, ".."); - -function read(relativePath) { - return fs.readFileSync(path.join(repo, relativePath), "utf8"); -} - -function maybeRead(relativePath) { - const absolute = path.join(repo, relativePath); - if (!fs.existsSync(absolute)) return null; - return fs.readFileSync(absolute, "utf8"); -} - -function expect(source, name, pattern) { - assert.match(source, pattern, `missing acceptance environment evidence: ${name}`); -} - -function expectIncludes(source, name, text) { - assert(source.includes(text), `missing acceptance environment evidence: ${name}`); -} - -const publicAcceptance = read("scripts/acceptance-public.sh"); -const privateAcceptance = read("scripts/acceptance-private.sh"); -const publicSplit = read("scripts/verify-public-split.sh"); -const acceptanceReport = read("scripts/acceptance-report.js"); -const acceptanceReportSmoke = read("scripts/acceptance-report-smoke.js"); -const readme = read("README.md"); -const windowsWorkflow = read(".forgejo/workflows/windows-validation.yml"); -const publicDryrunServiceSmoke = maybeRead("private/hosted-policy/scripts/public-release-dryrun-service-smoke.js"); -const publicDryrunDeployPrep = maybeRead("private/hosted-policy/scripts/prepare-public-release-dryrun-deployment.js"); -const publicDryrunSystemd = maybeRead("private/hosted-policy/deploy/disasmer-public-release-dryrun.service"); -const publicDryrunRunbook = maybeRead("private/hosted-policy/deploy/README.md"); -const hostedClientCompatSmoke = maybeRead("private/hosted-policy/scripts/hosted-client-compat-smoke.js"); -const hostedService = maybeRead("private/hosted-policy/src/bin/disasmer-hosted-service.rs"); -const hostedStartup = maybeRead( - "private/hosted-policy/src/bin/disasmer-hosted-service/startup.rs" -); -const hostedOperatorAuth = maybeRead( - "private/hosted-policy/src/bin/disasmer-hosted-service/operator_auth.rs" -); -const publicDryrunE2e = read("scripts/public-release-dryrun-e2e.js"); -const finalDryrunEvidence = read("scripts/public-release-dryrun-final-evidence.js"); - -for (const [name, script] of [ - ["public acceptance", publicAcceptance], - ["private acceptance", privateAcceptance], -]) { - expect(script, `${name} writes acceptance environment report first`, /node scripts\/acceptance-report\.js (public|private)[\s\S]*node scripts\/acceptance-report-smoke\.js/); - expectIncludes( - script, - `${name} runs acceptance environment contract`, - "node scripts/acceptance-environment-contract-smoke.js" - ); -} - -for (const [name, script] of [ - ["public acceptance", publicAcceptance], - ["public split", publicSplit], -]) { - for (const smoke of [ - "scripts/wasmtime-assignment-smoke.js", - "scripts/node-attach-smoke.js", - "scripts/cli-local-run-smoke.js", - "scripts/vscode-f5-smoke.js", - "scripts/dap-smoke.js", - "scripts/artifact-download-smoke.js", - "scripts/artifact-export-smoke.js", - "scripts/public-local-demo-matrix-smoke.js", - ]) { - assert(script.includes(`node ${smoke}`), `${name} must run ${smoke}`); - } -} - -expectIncludes(publicAcceptance, "public gate runs rootless Podman backend smoke", "node scripts/podman-backend-smoke.js"); -expectIncludes(publicAcceptance, "public gate runs Wasmtime node smoke", "node scripts/wasmtime-node-smoke.js"); -expectIncludes(privateAcceptance, "private gate runs hosted deployment smoke", "node private/hosted-policy/scripts/hosted-deployment-smoke.js"); -expectIncludes(privateAcceptance, "private gate prepares public dry-run deployment bundle", "node private/hosted-policy/scripts/prepare-public-release-dryrun-deployment.js"); -expectIncludes(privateAcceptance, "private gate runs hosted Client compatibility smoke", "node private/hosted-policy/scripts/hosted-client-compat-smoke.js"); -expectIncludes(privateAcceptance, "private gate runs standalone Core coordinator smoke", "node scripts/self-hosted-coordinator-smoke.js"); -expectIncludes(privateAcceptance, "private gate runs Postgres durable smoke", "node private/hosted-policy/scripts/postgres-durable-smoke.js"); -expectIncludes(privateAcceptance, "private gate can run public release dry-run service smoke", "node private/hosted-policy/scripts/public-release-dryrun-service-smoke.js"); -expectIncludes(privateAcceptance, "public release dry-run service smoke is env gated", "DISASMER_PUBLIC_RELEASE_DRYRUN_SERVICE_ADDR"); -expectIncludes(privateAcceptance, "private gate runs hosted policy cargo tests", "cargo test --manifest-path private/hosted-policy/Cargo.toml"); -expectIncludes(publicAcceptance, "public gate can run final dry-run evidence verifier", "node scripts/public-release-dryrun-final-evidence.js"); -expectIncludes(publicAcceptance, "public gate can run public release dry-run e2e", "node scripts/public-release-dryrun-e2e.js"); -expectIncludes(publicAcceptance, "public release dry-run e2e is env gated", "DISASMER_PUBLIC_RELEASE_DRYRUN_E2E"); -expectIncludes(privateAcceptance, "private gate can run final dry-run evidence verifier", "node scripts/public-release-dryrun-final-evidence.js"); -expectIncludes(publicAcceptance, "public final dry-run verifier is env gated", "DISASMER_PUBLIC_RELEASE_DRYRUN_FINAL"); -expectIncludes(privateAcceptance, "private final dry-run verifier is env gated", "DISASMER_PUBLIC_RELEASE_DRYRUN_FINAL"); - -expect(publicSplit, "public split excludes private modules", /--exclude='\.\/private'/); -expect(publicSplit, "public split excludes experiments", /--exclude='\.\/experiments'/); -expect(publicSplit, "public split tests copied workspace", /cargo test --workspace --manifest-path "\$tmp_dir\/Cargo\.toml"/); -expect(publicSplit, "public split builds copied workspace binaries", /cargo build --workspace --bins --manifest-path "\$tmp_dir\/Cargo\.toml"/); -expectIncludes( - publicSplit, - "public split runs acceptance environment contract from copied tree", - '(cd "$tmp_dir" && node scripts/acceptance-environment-contract-smoke.js)' -); - -for (const [name, pattern] of [ - ["commit SHA fallback", /process\.env\.DISASMER_ACCEPTANCE_COMMIT \|\| commandOutput\("git", \["rev-parse", "HEAD"\]\)/], - ["tree status", /tree_status: \(commandOutput\("git", \["status", "--short"\]\) \|\| ""\)[\s\S]*\.filter\(Boolean\)/], - ["OS report", /platform: os\.platform\(\)[\s\S]*kernel: os\.release\(\)/], - ["Rust report", /rustc: commandOutput\("rustc", \["--version"\]\)/], - ["Node report", /version: process\.version/], - ["Podman report", /function podmanReport\(\)/], - ["Postgres report", /postgres: \{[\s\S]*commandOutput\("postgres", \["--version"\]\) \|\|[\s\S]*commandOutput\("psql", \["--version"\]\)/], - ["browser harness report", /browser_harness:/], - ["VS Code harness report", /vscode_harness:/], - ["Windows validation report", /windows_validation: process\.env\.DISASMER_WINDOWS_VALIDATION \|\| "not-run"/], -]) { - expect(acceptanceReport, name, pattern); -} - -for (const [name, pattern] of [ - ["acceptance report validates Podman incomplete state", /assertPodmanReport/], - ["acceptance report validates Windows not-run", /assertReport\(runReport\(mode\), mode, "not-run"\)/], - ["acceptance report validates Windows runner mode", /DISASMER_WINDOWS_VALIDATION: "forgejo-windows-runner"/], -]) { - expect(acceptanceReportSmoke, name, pattern); -} - -expect(readme, "README documents public acceptance script", /scripts\/acceptance-public\.sh/); -expect(readme, "README documents private acceptance script", /scripts\/acceptance-private\.sh/); -expect(readme, "README documents rootless Podman incomplete handling", /Podman backend behavior is marked `incomplete`/); -expect(readme, "README documents Postgres discovery in environment report", /Podman\/Postgres discovery/); -expect(readme, "README documents manual Windows validation", /manual `Windows validation`\s+workflow/); - -expect(windowsWorkflow, "Windows workflow is manual", /workflow_dispatch/); -expect(windowsWorkflow, "Windows workflow uses intermittent Windows runner", /runs-on:\s*windows/); -expect(windowsWorkflow, "Windows workflow writes acceptance report", /node scripts\/acceptance-report\.js windows/); - -if (publicDryrunServiceSmoke && publicDryrunDeployPrep && publicDryrunSystemd && publicDryrunRunbook) { - for (const [name, pattern] of [ - ["service smoke requires external service address", /DISASMER_PUBLIC_RELEASE_DRYRUN_SERVICE_ADDR is required/], - ["service smoke requires browser test driver", /DISASMER_PUBLIC_RELEASE_DRYRUN_BROWSER_OPEN_COMMAND/], - ["service smoke loads release manifest", /public-release-manifest\.json/], - ["service smoke rejects stale release manifest", /manifest\.source_commit[\s\S]*expectedCommit/], - ["service smoke records source commit", /source_commit: release\.sourceCommit/], - ["service smoke records release name", /release_name: release\.releaseName/], - ["service smoke connects through public domain", /addr\.host[\s\S]*serviceHost/], - ["service smoke verifies DNS state", /\["not-published", "published"\]\.includes\(dnsPublicationState\)/], - ["service smoke begins a server-owned OIDC login", /type: "begin_oidc_browser_login"/], - ["service smoke polls with opaque credentials", /type: "poll_oidc_browser_login"[\s\S]*transaction_id[\s\S]*polling_secret/], - ["service smoke rejects missing hosted callback completion", /timed out waiting for the hosted OIDC callback/], - ["service smoke reads server-created project", /type: "list_projects"/], - ["service smoke enrolls signed node", /type: "create_node_enrollment_grant"[\s\S]*exchange_node_enrollment_grant/], - ["service smoke starts session-authorized process", /type: "start_process"/], - ["service smoke reports signed node capabilities", /signedNodeRequest[\s\S]*report_node_capabilities/], - ["service smoke reads authorized debug state", /type: "debug_attach"/], - ["service smoke aborts its probe process", /type: "abort_process"/], - ["service smoke records private hosted coordinator", /coordinator_implementation:[\s\S]*"hosted-policy-coordinator"/], - ["service smoke writes evidence report", /public-release-dryrun-service\.json/], - ]) { - expect(publicDryrunServiceSmoke, name, pattern); - } - - for (const [name, source, pattern] of [ - ["deployment prep builds hosted service release", publicDryrunDeployPrep, /cargo"[\s\S]*"build"[\s\S]*"--release"[\s\S]*"private\/hosted-policy\/Cargo\.toml"[\s\S]*"disasmer-hosted-service"/], - ["deployment prep stages systemd unit", publicDryrunDeployPrep, /disasmer-public-release-dryrun\.service/], - ["deployment prep writes manifest", publicDryrunDeployPrep, /deployment-manifest\.json/], - ["deployment prep records private hosted coordinator", publicDryrunDeployPrep, /coordinator_implementation:[\s\S]*"hosted-policy-coordinator"/], - ["deployment prep records service address", publicDryrunDeployPrep, /service_addr:[\s\S]*`\$\{serviceHost\}:\$\{servicePort\}`/], - ["deployment prep records DNS state", publicDryrunDeployPrep, /dns_publication_state: dnsPublicationState/], - ["deployment prep records service smoke command", publicDryrunDeployPrep, /public-release-dryrun-service-smoke\.js/], - ["systemd keeps hosted HTTP upstream on loopback", publicDryrunSystemd, /--listen 127\.0\.0\.1:9080/], - ["systemd loads root-owned operator credentials", publicDryrunSystemd, /EnvironmentFile=\/etc\/disasmer-public-release-dryrun\/operator\.env/], - ["systemd avoids privileged port capability", publicDryrunSystemd, /NoNewPrivileges=true/], - ["systemd uses dedicated user", publicDryrunSystemd, /User=disasmer[\s\S]*Group=disasmer/], - ["runbook says externally reachable", publicDryrunRunbook, /externally reachable host/], - ["runbook documents DNS pending fallback", publicDryrunRunbook, /Until the `disasmer\.michelpaulissen\.com` DNS record is deployed/], - ["runbook gives hosts entry", publicDryrunRunbook, / disasmer\.michelpaulissen\.com/], - ["runbook separates authority vocabulary", publicDryrunRunbook, /Client and signed Node protocols[\s\S]*Identity boundary[\s\S]*Operator boundary/], - ["runbook documents operator credential", publicDryrunRunbook, /DISASMER_HOSTED_OPERATOR_TOKEN/], - ]) { - expect(source, name, pattern); - } -} - -if (hostedClientCompatSmoke && hostedService && hostedStartup && hostedOperatorAuth) { - const hostedServiceSecurity = `${hostedService}\n${hostedStartup}\n${hostedOperatorAuth}`; - for (const [name, pattern] of [ - ["hosted service embeds core coordinator runtime", /core_coordinator: CoordinatorService/], - ["hosted service parses the unified identity, operator, and client protocol", /decode_incoming_request/], - ["hosted service delegates client requests", /handle_client_request/], - ["hosted service routes decoded client requests to Core", /IncomingRequest::Client\(request\)/], - ["hosted service requires replay-resistant operator envelope proofs", /hosted_operator_request[\s\S]*HostedOperatorAuth[\s\S]*replay_nonces[\s\S]*verify_request[\s\S]*hosted_operator_request_proof_from_token_digest/], - ["hosted service issues the authenticated Core CLI session", /core_coordinator[\s\S]*\.issue_cli_session/], - ["hosted service creates the authenticated default project through Core", /AuthenticatedCoordinatorRequest::CreateProject/], - ]) { - expect(hostedServiceSecurity, name, pattern); - } - - for (const [name, pattern] of [ - ["compat smoke starts hosted service", /disasmer-hosted-service/], - ["compat smoke creates hosted project", /type: "create_project"/], - ["compat smoke creates session-authorized enrollment grant", /type: "create_node_enrollment_grant"/], - ["compat smoke runs public CLI attach", /"disasmer-cli"[\s\S]*"node"[\s\S]*"attach"/], - ["compat smoke verifies public enrollment exchange", /node_enrollment_exchanged/], - ["compat smoke runs public CLI browser login", /"login"[\s\S]*"--browser"[\s\S]*DISASMER_BROWSER_OPEN_COMMAND/], - ["compat smoke derives hosted scope", /const session = login\.coordinator_response\.session/], - ["compat smoke rejects forged identity", /forged_unsigned_project/], - ["compat smoke rejects an actually expired hosted session", /DISASMER_HOSTED_CLI_SESSION_TTL_SECONDS[\s\S]*expired; run disasmer login --browser again/], - ["compat smoke denies cross-tenant task-event reads", /crossTenantTaskEventsDenied[\s\S]*type: "list_task_events"[\s\S]*vp-victim/], - ["compat smoke writes evidence report", /hosted-client-compat\.json/], - ]) { - expect(hostedClientCompatSmoke, name, pattern); - } -} - -for (const [name, pattern] of [ - ["e2e runner requires explicit opt-in", /DISASMER_PUBLIC_RELEASE_DRYRUN_E2E/], - ["e2e runner requires the honestly reset pre-final ledger", /assertPreFinalLedger\(readPhase3Ledger\(repo\)\)/], - ["e2e runner requires public domain service address", /serviceAddr[\s\S]*serviceHost/], - ["e2e runner downloads release assets", /downloadReleaseAssets/], - ["e2e runner verifies release checksums", /verifyChecksums/], - ["e2e runner clones public repo", /git"[\s\S]*"clone"[\s\S]*publicRepositoryUrl/], - ["e2e runner uses default hosted coordinator", /defaultLoginPlan\.coordinator[\s\S]*serviceEndpoint/], - ["e2e runner uses server-owned browser login", /DISASMER_PUBLIC_RELEASE_DRYRUN_BROWSER_OPEN_COMMAND[\s\S]*DISASMER_BROWSER_OPEN_COMMAND/], - ["e2e runner attaches user node", /node"[\s\S]*"attach"/], - ["e2e runner starts public worker runtime", /workerArgs[\s\S]*"--worker"[\s\S]*cp\.spawn\(disasmerNode/], - ["e2e runner launches released product through CLI", /"run"[\s\S]*"build"[\s\S]*runReport\.status[\s\S]*main_launched/], - ["standalone Core proof launches a real Wasm TaskSpec", /type: "launch_task"[\s\S]*task_spec:[\s\S]*kind: "coordinator_node_wasm"[\s\S]*bundle_digest: manifest\.bundle_digest/], - ["e2e runner verifies public assignment polling", /worker_assignment_poll_protocol/], - ["e2e runner validates standalone Core coordinator", /validateStandaloneCoreCoordinator/], - ["e2e runner records standalone Core coordinator", /core_coordinator_implementation/], - ["e2e runner verifies task events", /list_task_events/], - ["e2e runner creates artifact download link", /create_artifact_download_link/], - ["e2e runner verifies VS Code debugger", /vscode-f5-smoke\.js/], - ["e2e runner writes e2e report", /public-release-dryrun-e2e\.json/], -]) { - expect(publicDryrunE2e, name, pattern); -} - -for (const [name, pattern] of [ - ["final verifier requires public release manifest", /public-release-manifest\.json/], - ["final verifier requires Forgejo release evidence", /public-release-dryrun-forgejo-release\.json/], - ["final verifier requires deployment manifest", /deployment-manifest\.json/], - ["final verifier requires service smoke evidence", /public-release-dryrun-service\.json/], - ["final verifier requires all 191 Phase 3 criteria passed", /assertFinalLedger\(readPhase3Ledger\(repo\)\)/], - ["final verifier requires current service smoke source", /service\.source_commit[\s\S]*manifest\.source_commit/], - ["final verifier requires current service smoke release", /service\.release_name[\s\S]*manifest\.release_name/], - ["final verifier requires hosted Client compatibility evidence", /hosted-client-compat\.json/], - ["final verifier requires current Client compatibility source", /compat\.source_commit[\s\S]*manifest\.source_commit/], - ["final verifier requires current Client compatibility release", /compat\.release_name[\s\S]*manifest\.release_name/], - ["final verifier requires Core coordinator compatibility evidence", /core-coordinator-compat\.json/], - ["final verifier requires current public coordinator source", /coreCoordinator\.source_commit[\s\S]*manifest\.source_commit/], - ["final verifier requires current public coordinator release", /coreCoordinator\.release_name[\s\S]*manifest\.release_name/], - ["final verifier requires public e2e evidence", /public-release-dryrun-e2e\.json/], - ["final verifier records both coordinator validations", /coordinator_validation/], - ["final verifier writes final evidence", /public-release-dryrun-final\.json/], -]) { - expect(finalDryrunEvidence, name, pattern); -} - -console.log("Acceptance environment contract smoke passed"); diff --git a/scripts/acceptance-evidence-contract-smoke.js b/scripts/acceptance-evidence-contract-smoke.js deleted file mode 100755 index c9008c5..0000000 --- a/scripts/acceptance-evidence-contract-smoke.js +++ /dev/null @@ -1,1080 +0,0 @@ -#!/usr/bin/env node - -const assert = require("assert"); -const fs = require("fs"); -const path = require("path"); - -const repo = path.resolve(__dirname, ".."); - -function read(relativePath) { - return fs.readFileSync(path.join(repo, relativePath), "utf8"); -} - -function maybeRead(relativePath) { - const fullPath = path.join(repo, relativePath); - return fs.existsSync(fullPath) ? fs.readFileSync(fullPath, "utf8") : null; -} - -function expect(source, name, pattern) { - if (process.env.DISASMER_ACCEPTANCE_CONTRACT_TRACE) { - process.stderr.write(`checking acceptance evidence: ${name}\n`); - } - assert.match(source, pattern, `missing acceptance evidence guard: ${name}`); -} - -function expectGate(script, gateName) { - assert( - script.includes("node scripts/acceptance-evidence-contract-smoke.js"), - `${gateName} must run acceptance-evidence-contract-smoke.js` - ); -} - -const phase2 = read("acceptance_criteria_phase2.md"); -const publicAcceptance = read("scripts/acceptance-public.sh"); -const privateAcceptance = read("scripts/acceptance-private.sh"); -const cliFirstAcceptance = read("scripts/acceptance-cli-first.sh"); -const publicSplit = read("scripts/verify-public-split.sh"); -const codeSizeGuard = read("scripts/code-size-guard.js"); -const phase3Ledger = read("scripts/phase3-ledger.js"); -const coreAuth = read("crates/disasmer-core/src/auth.rs"); -const coreArtifact = read("crates/disasmer-core/src/artifact.rs"); -const coreBundle = read("crates/disasmer-core/src/bundle.rs"); -const coreCheckpoint = read("crates/disasmer-core/src/checkpoint.rs"); -const coreExecution = read("crates/disasmer-core/src/execution.rs"); -const coreProject = read("crates/disasmer-core/src/project.rs"); -const coreWire = read("crates/disasmer-core/src/wire.rs"); -const sdkLib = read("crates/disasmer-sdk/src/lib.rs"); -const sdkTaskArgs = read("crates/disasmer-sdk/src/task_args.rs"); -const coordinatorDurable = read("crates/disasmer-coordinator/src/durable.rs"); -const coordinatorPostgres = read("crates/disasmer-coordinator/src/postgres_store.rs"); -const coordinatorRuntimeDurable = read("crates/disasmer-coordinator/src/service/durable_runtime.rs"); -const coordinatorSessions = read("crates/disasmer-coordinator/src/sessions.rs"); -const coordinatorAgents = read("crates/disasmer-coordinator/src/agents.rs"); -const coordinatorProtocol = `${read("crates/disasmer-coordinator/src/service/protocol.rs")}\n${read("crates/disasmer-coordinator/src/service/protocol/responses.rs")}\n${read("crates/disasmer-coordinator/src/service/wire_protocol.rs")}`; -const coordinatorService = `${read("crates/disasmer-coordinator/src/service.rs")}\n${read("crates/disasmer-coordinator/src/service/routing.rs")}`; -const coordinatorAdmin = read("crates/disasmer-coordinator/src/service/admin.rs"); -const coordinatorArtifacts = read("crates/disasmer-coordinator/src/service/artifacts.rs"); -const coordinatorQuota = read("crates/disasmer-coordinator/src/service/quota.rs"); -const coordinatorAuthenticated = read("crates/disasmer-coordinator/src/service/authenticated.rs"); -const coordinatorAuthorization = read("crates/disasmer-coordinator/src/service/authorization.rs"); -const coordinatorTcp = read("crates/disasmer-coordinator/src/service/tcp.rs"); -const coordinatorMain = read("crates/disasmer-coordinator/src/main.rs"); -const coordinatorNodes = read("crates/disasmer-coordinator/src/service/nodes.rs"); -const coordinatorSignedNodes = read("crates/disasmer-coordinator/src/service/signed_nodes.rs"); -const coordinatorSignedNodeSurface = `${coordinatorService}\n${coordinatorSignedNodes}`; -const coordinatorProcesses = `${read("crates/disasmer-coordinator/src/service/process_launch.rs")}\n${read("crates/disasmer-coordinator/src/service/processes.rs")}`; -const coordinatorMainRuntime = read("crates/disasmer-coordinator/src/service/main_runtime.rs"); -const coordinatorDebug = `${read("crates/disasmer-coordinator/src/service/debug_requests.rs")}\n${read("crates/disasmer-coordinator/src/service/debug.rs")}\n${read("crates/disasmer-coordinator/src/service/debug/validation.rs")}`; -const coordinatorLogs = read("crates/disasmer-coordinator/src/service/logs.rs"); -const coordinatorServiceTests = read("crates/disasmer-coordinator/src/service/tests.rs"); -const cliConfig = read("crates/disasmer-cli/src/config.rs"); -const cliClient = read("crates/disasmer-cli/src/client.rs"); -const cliAuth = read("crates/disasmer-cli/src/auth.rs"); -const cliLogout = read("crates/disasmer-cli/src/logout.rs"); -const cliAdmin = read("crates/disasmer-cli/src/admin.rs"); -const cliNode = read("crates/disasmer-cli/src/node.rs"); -const cliProject = read("crates/disasmer-cli/src/project.rs"); -const cliRun = read("crates/disasmer-cli/src/run.rs"); -const cliRunLocalServices = read("crates/disasmer-cli/src/run/local_services.rs"); -const cliBundle = read("crates/disasmer-cli/src/bundle.rs"); -const cliTests = read("crates/disasmer-cli/src/tests.rs"); -const nodeLib = read("crates/disasmer-node/src/lib.rs"); -const nodeDaemon = read("crates/disasmer-node/src/daemon.rs"); -const nodeAssignmentRunner = `${read("crates/disasmer-node/src/assignment_runner.rs")}\n${read("crates/disasmer-node/src/assignment_runner/control_watcher.rs")}\n${read("crates/disasmer-node/src/assignment_runner/process_runner.rs")}\n${read("crates/disasmer-node/src/assignment_runner/validation.rs")}`; -const sharedWasmtimeRuntime = `${read("crates/disasmer-wasm-runtime/src/lib.rs")}\n${read("crates/disasmer-wasm-runtime/src/task_host_linker.rs")}`; -const nodeIdentity = read("crates/disasmer-node/src/node_identity.rs"); -const nodeCoordinatorSession = read("crates/disasmer-node/src/coordinator_session.rs"); -const nodeTaskArtifacts = read("crates/disasmer-node/src/task_artifacts.rs"); -const nodeTaskReports = read("crates/disasmer-node/src/task_reports.rs"); -const nodeDebugAgent = read("crates/disasmer-node/src/debug_agent.rs"); -const hostedPolicyRoot = maybeRead("private/hosted-policy/src/lib.rs"); -const hostedObservability = maybeRead("private/hosted-policy/src/observability.rs"); -const hostedPolicy = hostedPolicyRoot - ? `${hostedPolicyRoot}\n${hostedObservability || ""}` - : null; -const hostedPostgresSmoke = maybeRead( - "private/hosted-policy/src/bin/disasmer-postgres-durable-smoke.rs" -); -const dapAdapter = read("crates/disasmer-dap/src/adapter.rs"); -const dapBreakpoints = read("crates/disasmer-dap/src/breakpoints.rs"); -const dapRuntimeClient = `${read("crates/disasmer-dap/src/runtime_client.rs")}\n${read("crates/disasmer-dap/src/runtime_client/transport.rs")}\n${read("crates/disasmer-dap/src/runtime_client/debug_protocol.rs")}`; -const dapVirtualModel = read("crates/disasmer-dap/src/virtual_model.rs"); -const dapTests = read("crates/disasmer-dap/src/tests.rs"); -const nodeAttachSmoke = read("scripts/node-attach-smoke.js"); -const schedulerPlacementSmoke = read("scripts/scheduler-placement-smoke.js"); -const selfHostedCoordinatorSmokeSource = read("scripts/self-hosted-coordinator-smoke.js"); -const cliLocalRunSmoke = read("scripts/cli-local-run-smoke.js"); -const vscodeF5Smoke = read("scripts/vscode-f5-smoke.js"); -const dapSmoke = read("scripts/dap-smoke.js"); -const artifactDownloadSmoke = read("scripts/artifact-download-smoke.js"); -const artifactExportSmoke = read("scripts/artifact-export-smoke.js"); -const realFlagshipHarness = read("scripts/real-flagship-harness.js"); -const selfHostedCoordinatorSmoke = read("scripts/self-hosted-coordinator-smoke.js"); -const wasmtimeAssignmentSmoke = read("scripts/wasmtime-assignment-smoke.js"); -const publicLocalDemoMatrix = read("scripts/public-local-demo-matrix-smoke.js"); -const hostedClientCompatSmoke = fs.existsSync( - path.join(repo, "private/hosted-policy/scripts/hosted-client-compat-smoke.js") -) - ? read("private/hosted-policy/scripts/hosted-client-compat-smoke.js") - : null; -const publicDryrunServiceSmoke = fs.existsSync( - path.join(repo, "private/hosted-policy/scripts/public-release-dryrun-service-smoke.js") -) - ? read("private/hosted-policy/scripts/public-release-dryrun-service-smoke.js") - : null; -const hostedService = maybeRead("private/hosted-policy/src/bin/disasmer-hosted-service.rs"); -const hostedStartup = maybeRead( - "private/hosted-policy/src/bin/disasmer-hosted-service/startup.rs" -); -const hostedOperatorAuth = maybeRead( - "private/hosted-policy/src/bin/disasmer-hosted-service/operator_auth.rs" -); -const publicDryrunE2e = read("scripts/public-release-dryrun-e2e.js"); -const finalDryrunEvidence = read("scripts/public-release-dryrun-final-evidence.js"); -const mvpCompletionReview = maybeRead("MVP_COMPLETION_REVIEW.md"); -const mvpCompletionReport = maybeRead("MVP_COMPLETION_REPORT.md"); - -if (mvpCompletionReview || mvpCompletionReport) { - assert( - mvpCompletionReview && mvpCompletionReport, - "MVP completion review and report must be present together" - ); - const reviewedCriteria = [ - ...new Set(mvpCompletionReview.match(/AC-\d{2}\.\d{2}/g) || []), - ]; - const reportedRows = mvpCompletionReport - .split(/\r?\n/) - .map((line) => line.match(/^\| (AC-\d{2}\.\d{2}) \| ([^|]+) \|/)) - .filter(Boolean); - const reportedCriteria = reportedRows.map((match) => match[1]); - assert.strictEqual( - reviewedCriteria.length, - 108, - "MVP completion review criterion inventory changed unexpectedly" - ); - assert.deepStrictEqual( - [...reportedCriteria].sort(), - [...reviewedCriteria].sort(), - "MVP completion report must contain exactly one table row for every reviewed criterion" - ); - assert.strictEqual( - new Set(reportedCriteria).size, - reportedCriteria.length, - "MVP completion report contains duplicate criterion rows" - ); - const allowedLevels = new Set([ - "Specified", - "Contract-verified", - "Integration-verified", - "Live-verified", - "Open", - ]); - for (const [, criterion, rawLevel] of reportedRows) { - assert( - allowedLevels.has(rawLevel.trim()), - `${criterion} uses an unknown verification level: ${rawLevel.trim()}` - ); - } - const openCriteria = reportedRows - .filter(([, , rawLevel]) => rawLevel.trim() === "Open") - .map(([, criterion]) => criterion); - assert( - openCriteria.length === 0 || - (openCriteria.length === 1 && openCriteria[0] === "AC-14.14"), - "the completion report may leave only immutable release provenance open" - ); - for (let index = 1; index <= 13; index += 1) { - const criterion = `AC-14.${String(index).padStart(2, "0")}`; - const row = reportedRows.find(([, candidate]) => candidate === criterion); - assert.strictEqual( - row?.[2].trim(), - "Live-verified", - `${criterion} must retain strict hosted evidence` - ); - } - for (const requiredSection of [ - "## Verification snapshot", - "## Authoritative verification runs", - "## Exact verification commands", - "## Candidate provenance (not final immutable evidence)", - "## Change accounting and replaced shortcuts", - "## Clean filtered-tree quickstart transcript", - "## Strict hosted transcript", - "## Known Windows-only issues", - "## Acceptance-criterion audit", - "## Remaining release sequence", - ]) { - assert( - mvpCompletionReport.includes(requiredSection), - `MVP completion report omitted required section: ${requiredSection}` - ); - } - if (openCriteria.length === 1) { - expect( - mvpCompletionReport, - "completed strict transcript keeps dirty provenance explicit", - /harness verdict is `partial` only[\s\S]*`source_tree_clean`[\s\S]*configuration `clean` are false/ - ); - } - expect( - mvpCompletionReport, - "exact strict command requires hosted authority and VPS restart proof", - /DISASMER_STRICT_FULL_RELEASE=1[\s\S]*DISASMER_STRICT_VPS_RESTART=1[\s\S]*DISASMER_SECOND_TENANT_(?:SESSION|EVIDENCE)_FILE=[\s\S]*DISASMER_EXPIRED_USER_SESSION_FILE=/ - ); -} - -expect( - phase2, - "phase 2 rejects type-only acceptance", - /- \[x\] \*\*Passed:\*\* A criterion cannot be accepted solely because a type, trait, schema, mock, or unit-level model exists\./ -); - -for (const [gateName, script] of [ - ["public acceptance", publicAcceptance], - ["private acceptance", privateAcceptance], - ["CLI-first acceptance", cliFirstAcceptance], - ["public split", publicSplit], -]) { - expectGate(script, gateName); -} - -expect(codeSizeGuard, "code-size guard enforces 1000-line production file limit", /maxProductionLines = 1000/); -expect( - codeSizeGuard, - "code-size guard excludes test-only Rust modules from business-logic threshold", - /isTestRustFile/ -); -expect( - phase3Ledger, - "Phase 3 ledger defines the exact pre-final and final states", - /Phase 3 ledger must contain 191 criteria[\s\S]*Passed: 0, Partial: 181, Open: 10[\s\S]*P3-GATE-[\s\S]*Passed: 191, Partial: 0, Open: 0/ -); -expect( - publicDryrunE2e, - "final public-release E2E requires the pre-final Phase 3 ledger", - /assertPreFinalLedger\(readPhase3Ledger\(repo\)\)/ -); -expect( - finalDryrunEvidence, - "final evidence requires every Phase 3 criterion to be Passed", - /assertFinalLedger\(readPhase3Ledger\(repo\)\)/ -); -expect( - dapVirtualModel, - "DAP launch state uses shared core identity and debug model types", - /use disasmer_core::\{[\s\S]*DebugRuntimeState[\s\S]*ProcessId[\s\S]*ProjectId[\s\S]*TaskDefinitionId[\s\S]*TaskInstanceId[\s\S]*TenantId[\s\S]*UserId[\s\S]*\}[\s\S]*pub\(crate\) tenant: TenantId[\s\S]*pub\(crate\) project_id: ProjectId[\s\S]*pub\(crate\) actor_user: UserId/ -); -expect( - coreBundle, - "bundle debug metadata contains source probe records", - /pub struct BundleDebugMetadata[\s\S]*pub probes: Vec[\s\S]*pub struct BundleDebugProbe[\s\S]*source_path[\s\S]*line_start[\s\S]*line_end[\s\S]*function[\s\S]*task: TaskDefinitionId[\s\S]*pub fn discover_source_debug_probes[\s\S]*disasmer_probe_task[\s\S]*namespace\.ident != "disasmer"[\s\S]*"main"[\s\S]*strip_suffix\("_main"\)[\s\S]*"task"/ -); -assert.doesNotMatch( - coreBundle, - /debug_probe_task_for_function|"file_scope"/, - "bundle debug metadata must not guess runtime task ownership from function names or invent a file-scope probe" -); -expect( - `${cliBundle}\n${cliTests}`, - "CLI bundle inspection populates generated debug probes from selected source inputs", - /discover_debug_probes\(&project, &selected_inputs\)[\s\S]*metadata\.debug_metadata\.probes = debug_probes[\s\S]*discover_source_debug_probes\(&input\.path, &source\)[\s\S]*bundle_inspect_discovers_environments_selected_inputs_and_source_providers[\s\S]*debug_metadata[\s\S]*probes[\s\S]*compile_linux/ -); -expect( - `${coreProject}\n${cliTests}`, - "project inspection derives real Disasmer entrypoints and never invents launch surfaces", - /NoEntrypoints[\s\S]*discover_entrypoints[\s\S]*segments\.as_slice\(\) == \["disasmer", "main"\][\s\S]*entrypoint_name[\s\S]*project_without_declared_entrypoint_does_not_invent_product_surfaces[\s\S]*entrypoint == "build"[\s\S]*entrypoint == "release"/ -); -expect( - sdkTaskArgs, - "SDK task boundary compile-fail examples reject host-only values", - /```compile_fail[\s\S]*borrowed\.as_str\(\)[\s\S]*```compile_fail[\s\S]*std::ptr::null[\s\S]*```compile_fail[\s\S]*std::fs::File[\s\S]*```compile_fail[\s\S]*Mutex::new/ -); -expect( - dapVirtualModel, - "DAP state stores bundle debug probe metadata", - /debug_probes: Vec[\s\S]*load_bundle_debug_probes\(&self\.project, &self\.source_path\)/ -); -expect( - dapBreakpoints, - "DAP breakpoint resolver uses bundle debug probes", - /load_bundle_debug_probes[\s\S]*discover_source_debug_probes\(source_path, &source\)[\s\S]*resolve_breakpoints[\s\S]*debug_probe_for_line[\s\S]*Mapped to Disasmer debug probe[\s\S]*No Disasmer debug probe metadata covers this source line/ -); -expect( - dapAdapter, - "DAP setBreakpoints calls probe-aware resolver", - /let requested_source_path = request[\s\S]*let requested_lines = request[\s\S]*resolve_breakpoints_for_source\([\s\S]*requested_source_path,[\s\S]*requested_lines[\s\S]*breakpoint\.to_dap\(\)/ -); -expect( - `${dapTests}\n${dapSmoke}`, - "DAP tests and smoke cover probe-backed breakpoint mapping", - /breakpoint_resolution_uses_bundle_debug_probe_metadata[\s\S]*buildMainLine[\s\S]*setBreakpoints[\s\S]*breakpointResponse\.body\.breakpoints\[0\]\.verified, true/ -); -expect( - dapRuntimeClient, - "DAP runtime client calls coordinator restart_task and parses boundary decision", - /pub\(crate\) fn restart_task\([\s\S]*state: &AdapterState,[\s\S]*task: &TaskInstanceId,[\s\S]*"type": "restart_task"[\s\S]*parse_task_restart_response[\s\S]*accepted[\s\S]*clean_boundary_available[\s\S]*requires_whole_process_restart/ -); -expect( - dapRuntimeClient, - "local and live DAP launch share the real Wasm entrypoint path", - /run_local_services_runtime[\s\S]*launch_services_debug_entrypoint\(&listen, state, &repo\)[\s\S]*fn launch_services_debug_entrypoint[\s\S]*build_debug_bundle[\s\S]*"set_debug_breakpoints"[\s\S]*"kind": "coordinator_node_wasm"[\s\S]*"wasm_module_base64"[\s\S]*run_live_services_runtime[\s\S]*launch_services_debug_entrypoint\(&coordinator, state, &repo\)/ -); -assert.doesNotMatch( - dapRuntimeClient, - /launch_live_placed_task|"command": "cargo"|"task": "compile-linux"/, - "live DAP must not launch a separate synthetic cargo task" -); -expect( - dapAdapter, - "DAP services restartFrame uses coordinator restart decision", - /state\.runtime_backend != RuntimeBackend::Simulated[\s\S]*restart_task_through_coordinator[\s\S]*restart_task\(state, &task\)[\s\S]*coordinator refused task restart[\s\S]*whole virtual-process restart required/ -); -expect( - dapRuntimeClient, - "DAP failed-main restart explicitly replaces the existing virtual process", - /relaunch_services_main_runtime[\s\S]*restart_state\.restart_existing = true[\s\S]*launch_services_debug_entrypoint/ -); -expect( - `${coordinatorProcesses}\n${coordinatorMainRuntime}`, - "process replacement retires the old main and rejects late incarnation commands", - /replacing_existing[\s\S]*interrupt_process[\s\S]*controls[\s\S]*remove[\s\S]*next_launch_id[\s\S]*is_current_scope[\s\S]*coordinator main process incarnation was replaced/ -); -expect( - `${dapTests}\n${dapSmoke}`, - "DAP tests and smoke cover coordinator restart boundary refusal", - /task_restart_response_preserves_coordinator_boundary_decision[\s\S]*client\.send\("restartFrame"[\s\S]*restartFailure[\s\S]*checkpoint boundary\|still active/ -); -expect( - dapSmoke, - "DAP restarts a terminal failed task and waits for its new acknowledged probe stop", - /failMainLine[\s\S]*restartClient\.send\("continue"[\s\S]*message\.event === "terminated"[\s\S]*send\("restartFrame"[\s\S]*response\(restartRequest, "restartFrame"\)[\s\S]*Restarted main from the rebuilt bundle[\s\S]*allThreadsStopped, true/ -); -expect( - `${sharedWasmtimeRuntime}\n${nodeAssignmentRunner}\n${dapVirtualModel}\n${dapSmoke}`, - "DAP proves real dynamic child threads, arguments, handles, and probe-boundary all-stop", - /enter_quiescent_host_boundary[\s\S]*leave_quiescent_host_boundary[\s\S]*TaskHostOperation::DebugProbe[\s\S]*debug_handle_snapshot[\s\S]*task_handle_\{handle_id\}[\s\S]*runtime_task_args[\s\S]*insert_runtime_thread[\s\S]*taskTrapLine[\s\S]*childThread[\s\S]*arg_0[\s\S]*task_handle_[\s\S]*definition=task_trap instance=ti:.*:child:\\d\+ state=active/ -); -expect( - `${dapAdapter}\n${dapRuntimeClient}\n${dapVirtualModel}\n${dapTests}`, - "DAP attach product mode reads coordinator debug/task state", - /attach_services_runtime[\s\S]*RuntimeBackend::LocalServices \| RuntimeBackend::LiveServices[\s\S]*apply_attach_record[\s\S]*"type": "debug_attach"[\s\S]*authorization[\s\S]*allowed[\s\S]*"type": "list_task_events"[\s\S]*coordinator_threads_from_events[\s\S]*attach_record_replaces_demo_threads_with_coordinator_task_events/ -); -expect( - coordinatorProtocol, - "coordinator protocol exposes probe hits and acknowledged Debug Epoch state", - /SetDebugBreakpoints \{[\s\S]*InspectDebugBreakpoints \{[\s\S]*CreateDebugEpoch \{[\s\S]*ResumeDebugEpoch \{[\s\S]*InspectDebugEpoch \{[\s\S]*ReportDebugState \{[\s\S]*ReportDebugProbeHit \{[\s\S]*DebugEpochStatus \{/ -); -expect( - `${coordinatorMain}\n${coordinatorRuntimeDurable}\n${coordinatorPostgres}\n${coordinatorServiceTests}\n${hostedStartup || ""}`, - "actual standalone and hosted services load and persist durable state through configured Postgres", - /DATABASE_URL[\s\S]*new_with_database_url[\s\S]*RuntimeDurableStore[\s\S]*PostgresDurableStore::connect[\s\S]*disasmer_cli_sessions[\s\S]*postgres_backed_runtime_service_survives_restart_without_live_process_state[\s\S]*new_with_admin_token_database_url_and_quota/ -); -if (hostedPostgresSmoke) { - expect( - hostedPostgresSmoke, - "private Postgres restart evidence authenticates the same durable CLI session after reboot", - /issue_cli_session[\s\S]*try_persist[\s\S]*Coordinator::try_boot[\s\S]*authenticate_cli_session_at[\s\S]*durable_cli_session_present/ - ); -} -expect( - coordinatorService, - "coordinator stores transient Debug Epoch maps", - /debug_epochs: BTreeMap[\s\S]*debug_epoch_runtime: BTreeMap[\s\S]*debug_breakpoints: BTreeMap[\s\S]*debug_commands: BTreeMap/ -); -expect( - coordinatorDebug, - "executing Wasm probe hits create a freeze Debug Epoch", - /handle_report_debug_probe_hit[\s\S]*wasm_debug_probe_hit[\s\S]*"freeze"[\s\S]*executing Wasm reached probe/ -); -expect( - coordinatorDebug, - "coordinator exposes freeze, resume, poll, and signed state handlers", - /handle_create_debug_epoch[\s\S]*"freeze"[\s\S]*handle_resume_debug_epoch[\s\S]*"resume"[\s\S]*handle_poll_debug_command[\s\S]*handle_report_debug_state/ -); -expect( - coordinatorDebug, - "coordinator derives all-stop and resumed state from participant acknowledgements", - /all_acknowledged[\s\S]*fully_frozen[\s\S]*DebugAcknowledgementState::Frozen[\s\S]*fully_resumed[\s\S]*DebugAcknowledgementState::Running/ -); -expect( - `${dapAdapter}\n${dapRuntimeClient}\n${dapVirtualModel}\n${dapSmoke}`, - "DAP services mode waits for coordinator Debug Epoch acknowledgement", - /run_local_services_runtime[\s\S]*set_debug_breakpoints[\s\S]*wait_for_breakpoint_hit[\s\S]*wait_for_debug_epoch_state_at[\s\S]*all_participants_frozen[\s\S]*resume_debug_epoch\(state[\s\S]*wait_for_debug_epoch_resumed/ -); -expect( - coordinatorServiceTests, - "coordinator test proves signed node freeze/resume debug commands", - /debug_epoch_commands_are_polled_by_signed_active_task_nodes[\s\S]*SetDebugBreakpoints[\s\S]*ReportDebugProbeHit[\s\S]*PollDebugCommand[\s\S]*ReportDebugState[\s\S]*fully_frozen[\s\S]*ResumeDebugEpoch[\s\S]*fully_resumed/ -); -expect( - coreCheckpoint, - "core models the exact clean task entry and VFS boundary", - /CheckpointBoundary[\s\S]*serialized_args[\s\S]*environment_digest[\s\S]*vfs_epoch[\s\S]*task_abi/ -); -expect( - coordinatorProcesses, - "coordinator captures that boundary before dispatch", - /capture_task_restart_checkpoint[\s\S]*required_artifacts[\s\S]*VfsManifest[\s\S]*task_restart_checkpoints\.insert[\s\S]*TaskRestartCheckpoint/ -); -expect( - `${coordinatorDebug}\n${coordinatorServiceTests}\n${wasmtimeAssignmentSmoke}`, - "failed task restart uses the captured boundary and relaunches the real assignment", - /RestartPolicy\.decide[\s\S]*handle_launch_task_with_actor[\s\S]*selected task restarted as new instance[\s\S]*from clean VFS entry boundary[\s\S]*service_reports_task_restart_boundary_through_public_api[\s\S]*restarted_assignment[\s\S]*trapRestart[\s\S]*clean_boundary_available, true[\s\S]*restartedTrapNodeRun\.node_status, "failed"/ -); -expect( - coordinatorProtocol, - "launch and assignment protocol carry the TaskSpec and exact Wasm module bytes", - /LaunchTask \{[\s\S]*task_spec: TaskSpec[\s\S]*wasm_module_base64: String[\s\S]*pub struct TaskAssignment \{[\s\S]*pub task_spec: TaskSpec[\s\S]*pub wasm_module_base64: String/ -); -expect( - coordinatorProcesses, - "coordinator placement preserves the TaskSpec and Wasm module in the assignment", - /let assignment = TaskAssignment \{[\s\S]*task_spec: pending\.task_spec[\s\S]*wasm_module_base64: pending\.wasm_module_base64/ -); -expect( - sharedWasmtimeRuntime, - "shared Wasmtime runtime verifies the module digest before compilation", - /fn verified_module_bytes[\s\S]*let actual = Digest::sha256\(bytes\)[\s\S]*if &actual != expected_bundle_digest[\s\S]*BundleDigestMismatch/ -); -expect( - `${nodeAssignmentRunner}\n${nodeDaemon}`, - "node assignment runner requires the bundle digest and executes the versioned task export through the verified runtime", - /run_verified_wasmtime_assignment[\s\S]*bundle_digest\.as_ref\(\)[\s\S]*run_task_export_verified_with_task_host[\s\S]*daemon_wasm_task_assignment_uses_abi_version_and_verifies_bundle_digest/ -); -expect( - nodeLib, - "node-facing runtime test rejects a digest mismatch before Wasm parsing", - /wasmtime_runtime_rejects_bundle_digest_mismatch_before_compilation[\s\S]*WasmTaskError::BundleDigestMismatch[\s\S]*failed to parse/ -); -expect( - `${coreExecution}\n${coordinatorProtocol}\n${coordinatorProcesses}\n${coordinatorServiceTests}`, - "product-mode spawn uses one Wasm TaskSpec and signed parent-scoped child launch", - /pub enum TaskDispatch[\s\S]*CoordinatorNodeWasm[\s\S]*pub struct TaskSpec[\s\S]*product_mode_uses_remote_dispatch[\s\S]*LaunchChildTask \{[\s\S]*parent_task: String[\s\S]*TaskAssignment \{[\s\S]*task_spec: TaskSpec[\s\S]*handle_launch_child_task[\s\S]*TaskDispatch::CoordinatorNodeWasm[\s\S]*signed_active_wasm_task_can_spawn_and_join_child_in_its_process_only/ -); -expect( - `${coreExecution}\n${coordinatorProtocol}\n${coordinatorLogs}\n${coordinatorService}\n${coordinatorServiceTests}`, - "join_task returns event-derived remote task results", - /pub struct TaskJoinResult[\s\S]*result: Option[\s\S]*remote_completion_observed[\s\S]*from_remote_completion[\s\S]*JoinTask \{[\s\S]*TaskJoined \{[\s\S]*handle_join_task[\s\S]*event\.result\.clone\(\)[\s\S]*waiting for signed node task_completed event[\s\S]*TaskJoinState::Pending[\s\S]*SmallJson/ -); -expect( - `${coordinatorProtocol}\n${coordinatorService}\n${coordinatorProcesses}\n${coordinatorServiceTests}`, - "launch_task can explicitly wait for a later capable node", - /LaunchTask \{[\s\S]*wait_for_node: bool[\s\S]*TaskQueued \{[\s\S]*pending_task_launches: VecDeque[\s\S]*Err\(err\) if wait_for_node[\s\S]*pending_task_launches[\s\S]*push_back[\s\S]*CoordinatorResponse::TaskQueued[\s\S]*assign_pending_task_to_node[\s\S]*DefaultScheduler\.place\(std::slice::from_ref\(&descriptor\)[\s\S]*coordinator_side_task_launch_can_wait_for_capable_worker[\s\S]*wait_for_node: true[\s\S]*late worker should receive pending assignment/ -); -expect( - schedulerPlacementSmoke, - "scheduler smoke proves queued placement through process boundary and signed node poll", - /type: "start_process"[\s\S]*type: "launch_task"[\s\S]*task_definition: "prepare_source"[\s\S]*abi: "task_v1"[\s\S]*required_capabilities: \["SourceFilesystem", "SourceGit"\][\s\S]*wait_for_node: true[\s\S]*task_queued[\s\S]*gitCapabilities[\s\S]*type: "poll_task_assignment"[\s\S]*late capable node should receive queued assignment/ -); -expect( - coreAuth, - "core agent workflow auth uses Ed25519 signatures over scoped request data", - /pub struct AgentSignedRequest[\s\S]*pub fn sign_agent_workflow_request[\s\S]*SigningKey::from_bytes[\s\S]*pub fn verify_agent_workflow_signature[\s\S]*VerifyingKey::from_bytes[\s\S]*agent_workflow_signature_message/ -); -expect( - coreAuth, - "core node request auth uses Ed25519 signatures over scoped node request data", - /pub struct NodeSignedRequest[\s\S]*pub fn sign_node_request[\s\S]*SigningKey::from_bytes[\s\S]*pub fn verify_node_request_signature[\s\S]*VerifyingKey::from_bytes[\s\S]*node_request_signature_message/ -); -expect( - coordinatorProtocol, - "coordinator protocol supports a versioned wire request envelope", - /pub enum CoordinatorWireRequest[\s\S]*Envelope\(CoordinatorRequestEnvelope\)[\s\S]*pub struct CoordinatorRequestEnvelope[\s\S]*protocol_version: u64[\s\S]*request_id: String[\s\S]*operation: String[\s\S]*authentication: Option[\s\S]*payload: CoordinatorRequest/ -); -expect( - coordinatorProtocol, - "coordinator TCP protocol accepts only versioned wire requests", - /pub enum CoordinatorWireRequest\s*\{\s*Envelope\(CoordinatorRequestEnvelope\),?\s*\}/ -); -expect( - coreWire, - "shared coordinator wire envelope includes protocol version, request id, operation, auth metadata, and payload", - /COORDINATOR_PROTOCOL_VERSION: u64 = 1[\s\S]*COORDINATOR_WIRE_REQUEST_TYPE[\s\S]*pub fn coordinator_wire_request[\s\S]*"protocol_version"[\s\S]*"request_id"[\s\S]*"operation"[\s\S]*"authentication"[\s\S]*"payload"/ -); -expect( - coordinatorProtocol, - "coordinator validates wire envelope version and operation before dispatch", - /if self\.protocol_version != COORDINATOR_PROTOCOL_VERSION[\s\S]*request_id\.trim\(\)\.is_empty\(\)[\s\S]*operation .*does not match payload operation/ -); -expect( - coordinatorTcp, - "coordinator TCP framing decodes versioned wire requests", - /decode_wire_request[\s\S]*serde_json::from_str::[\s\S]*into_request\(\)/ -); -expect( - coordinatorTcp, - "standalone Core coordinator TCP authority is strict by default", - /pub enum ClientAuthorityMode[\s\S]*Strict[\s\S]*LocalTrustedLoopback[\s\S]*pub fn serve_tcp[\s\S]*ClientAuthorityMode::Strict/ -); -expect( - coordinatorTcp, - "local trusted request authority is explicitly restricted to loopback development", - /serve_tcp_local_trusted[\s\S]*local_addr\(\)\?[\s\S]*ip\(\)\.is_loopback\(\)[\s\S]*local trusted request mode is restricted to a loopback listener/ -); -expect( - coordinatorTcp, - "strict Core Client authority rejects request-body identity", - /authorize_client_request[\s\S]*CoordinatorRequest::Authenticated[\s\S]*CoordinatorRequest::SignedNode[\s\S]*request-body identity fields are not authority/ -); -expect( - `${coordinatorMain}\n${cliRun}\n${cliRunLocalServices}`, - "local trusted loopback authority requires an explicit development opt-in", - /--allow-local-trusted-loopback[\s\S]*serve_tcp_local_trusted[\s\S]*--allow-local-trusted-loopback/ -); -expect( - dapRuntimeClient, - "DAP local-services coordinator explicitly opts into loopback development authority", - /run_local_services_runtime[\s\S]*"--listen"[\s\S]*"127\.0\.0\.1:0"[\s\S]*"--allow-local-trusted-loopback"/ -); -expect( - `${cliClient}\n${nodeDaemon}\n${dapRuntimeClient}`, - "CLI, node, and DAP transports send versioned coordinator wire requests", - /JsonLineSession[\s\S]*coordinator_wire_request[\s\S]*CoordinatorSession[\s\S]*coordinator_wire_request[\s\S]*fn coordinator_request[\s\S]*coordinator_wire_request/ -); -expect( - `${coordinatorServiceTests}\n${cliTests}`, - "focused tests prove versioned coordinator wire envelope behavior", - /service_stream_rejects_invalid_versioned_envelope_metadata[\s\S]*operation attach_node does not match payload operation ping[\s\S]*doctor_pings_configured_coordinator[\s\S]*"coordinator_request"[\s\S]*"protocol_version"/ -); -expect( - coordinatorServiceTests, - "strict TCP test proves body-authority denial and CLI-session success", - /strict_service_stream_rejects_body_authority_and_accepts_cli_session[\s\S]*victim-tenant[\s\S]*request-body identity fields are not authority[\s\S]*strict-stream-session[\s\S]*vp-authenticated/ -); -expect( - `${coordinatorProtocol}\n${coordinatorService}\n${coordinatorAuthenticated}`, - "authenticated scheduling derives scope from the CLI session", - /AuthenticatedCoordinatorRequest[\s\S]*ScheduleTask[\s\S]*AuthenticatedCoordinatorRequest::ScheduleTask[\s\S]*context\.tenant[\s\S]*context\.project/ -); -expect( - `${coreArtifact}\n${coordinatorNodes}\n${artifactDownloadSmoke}`, - "signed node retention inventory removes garbage-collected artifact availability", - /reconcile_node_retention[\s\S]*retaining_nodes\.remove\(node\)[\s\S]*reconcile_node_retention\(&node, &artifact_locations\)[\s\S]*artifact_locations: \[\][\s\S]*const collectedLink[\s\S]*unavailable from current retention/ -); -expect( - nodeTaskArtifacts, - "node artifact store reads retained chunks only after digest and size verification", - /NodeArtifactStore[\s\S]*read_verified_chunk[\s\S]*expected_digest[\s\S]*expected_size_bytes/ -); -expect( - `${coordinatorProtocol}\n${coordinatorArtifacts}`, - "coordinator reverse-transfer protocol polls and verifies uploaded artifact chunks", - /PollArtifactTransfer[\s\S]*UploadArtifactTransferChunk[\s\S]*artifact reverse transfer chunk digest mismatch/ -); -expect( - artifactDownloadSmoke, - "explicit artifact download waits for the retaining-node reverse stream", - /downloadRetainedBytes[\s\S]*retaining_node_reverse_stream_pending[\s\S]*retaining_node_reverse_stream/ -); -expect( - artifactDownloadSmoke, - "downloaded retained bytes are checked against coordinator artifact size and digest", - /downloadRetainedBytes\([\s\S]*packageEvent\.artifact_size_bytes[\s\S]*createHash\("sha256"\)[\s\S]*downloaded\.content[\s\S]*downloadedDigest, packageEvent\.artifact_digest/ -); -expect( - `${realFlagshipHarness}\n${artifactDownloadSmoke}\n${artifactExportSmoke}`, - "one real CLI flagship path runs Wasm children in rootless Podman and transfers retained bytes", - /ensureRootlessPodman[\s\S]*runFlagshipWorker[\s\S]*create_node_enrollment_grant[\s\S]*--enrollment-grant[\s\S]*"run", "build"[\s\S]*disasmer\+tcp:\/\/[\s\S]*worker_placement_requested[\s\S]*compile_linux[\s\S]*package_release[\s\S]*downloadRetainedBytes[\s\S]*launchFlagship[\s\S]*artifact_size_bytes/ -); -expect( - coordinatorProtocol, - "node heartbeat carries signed enrolled-key proof", - /NodeHeartbeat \{[\s\S]*node: String[\s\S]*node_signature: Option/ -); -expect( - coordinatorProtocol, - "node-originated public requests can be wrapped in signed node envelope", - /SignedNode \{[\s\S]*node: String[\s\S]*node_signature: NodeSignedRequest[\s\S]*request: Box/ -); -expect( - coordinatorNodes, - "coordinator verifies signed node heartbeat freshness and rejects replay", - /handle_node_heartbeat[\s\S]*authenticate_node_request[\s\S]*requires a signed proof of enrolled private-key possession[\s\S]*issued_at_epoch_seconds[\s\S]*abs_diff\(now_epoch_seconds\)[\s\S]*node_replay_nonces\.contains_key[\s\S]*verify_node_request_signature/ -); -expect( - coordinatorSignedNodeSurface, - "coordinator requires signed node envelope for node-originated requests", - /CoordinatorRequest::SignedNode[\s\S]*handle_signed_node_request[\s\S]*signed_node_request_kind[\s\S]*signed_node_request_node[\s\S]*reject_unsigned_node_request[\s\S]*node-originated request requires signed_node envelope proof/ -); -expect( - cliNode, - "CLI node attach derives and uses a node private key", - /let node_private_key = node_private_key_for_attach[\s\S]*node_ed25519_public_key_from_private_key[\s\S]*sign_node_request[\s\S]*signed_node_request_json[\s\S]*fn node_private_key_for_attach[\s\S]*DISASMER_NODE_PRIVATE_KEY/ -); -expect( - cliNode, - "CLI node attach persists fallback node credentials under project .disasmer", - /StoredNodeCredential[\s\S]*load_or_create_local_node_credential[\s\S]*"disasmer_node_credential"[\s\S]*local_node_credential_file[\s\S]*"\.disasmer"[\s\S]*"nodes"/ -); -expect( - cliNode, - "CLI node attach wraps node-originated requests in signed_node envelope", - /fn signed_node_request_json[\s\S]*sign_node_request[\s\S]*"type": "signed_node"[\s\S]*"node_signature": node_signature[\s\S]*"request": request/ -); -expect( - `${nodeDaemon}\n${nodeIdentity}`, - "node daemon derives and uses a node private key", - /let node_private_key = node_private_key_for_runtime[\s\S]*sign_node_request[\s\S]*signed_node_request_json[\s\S]*node_ed25519_public_key_from_private_key[\s\S]*fn node_private_key_for_runtime[\s\S]*DISASMER_NODE_PRIVATE_KEY/ -); -expect( - nodeIdentity, - "node daemon persists fallback node credentials under project .disasmer", - /StoredNodeCredential[\s\S]*load_or_create_local_node_credential[\s\S]*"disasmer_node_credential"[\s\S]*local_node_credential_file[\s\S]*"\.disasmer"[\s\S]*"nodes"/ -); -expect( - `${nodeDaemon}\n${nodeIdentity}`, - "node daemon wraps node-originated requests in signed_node envelope", - /fn signed_node_request_json[\s\S]*sign_node_request[\s\S]*"type": "signed_node"[\s\S]*"node_signature": node_signature[\s\S]*"request": request/ -); -expect( - `${nodeIdentity}\n${nodeCoordinatorSession}\n${sharedWasmtimeRuntime}\n${nodeAssignmentRunner}\n${nodeTaskArtifacts}\n${nodeTaskReports}\n${nodeDebugAgent}`, - "node responsibilities are split into identity, coordinator session, versioned Wasm host capabilities, artifact/VFS, logs, and debug modules", - /struct StoredNodeCredential[\s\S]*struct CoordinatorSession[\s\S]*struct WasmtimeTaskRuntime[\s\S]*command_run_v1[\s\S]*run_verified_wasmtime_assignment[\s\S]*struct TaskArtifactStore[\s\S]*record_completed_task[\s\S]*poll_task_cancellation/ -); -expect( - coordinatorAgents, - "coordinator agent authorization requires signed request freshness and registered public key verification", - /authorize_agent_project_run[\s\S]*AgentSignedRequest[\s\S]*issued_at_epoch_seconds[\s\S]*abs_diff\(now_epoch_seconds\)[\s\S]*verify_agent_workflow_signature/ -); -expect( - coordinatorProtocol, - "agent workflow requests carry signed request proof", - /LaunchTask \{[\s\S]*agent_public_key_fingerprint: Option[\s\S]*agent_signature: Option[\s\S]*StartProcess \{[\s\S]*agent_public_key_fingerprint: Option[\s\S]*agent_signature: Option/ -); -expect( - coordinatorProcesses, - "coordinator rejects fingerprint-only agent workflow dispatch and tracks nonce replay", - /requires a signed request proving private-key possession[\s\S]*agent_replay_nonces\.contains[\s\S]*agent signed request nonce has already been used[\s\S]*authorize_agent_project_run/ -); -expect( - cliRun, - "CLI signs agent workflow requests when private key identity is used", - /DISASMER_AGENT_PRIVATE_KEY[\s\S]*"agent_signature"[\s\S]*fn agent_signature_for_request[\s\S]*sign_agent_workflow_request/ -); -expect( - coordinatorDurable, - "durable state records scoped CLI session credentials", - /pub struct CliSessionRecord[\s\S]*pub session_digest: Digest[\s\S]*pub tenant: TenantId[\s\S]*pub project: ProjectId[\s\S]*pub user: UserId[\s\S]*pub revoked: bool[\s\S]*pub cli_sessions: BTreeMap/ -); -expect( - coordinatorSessions, - "coordinator issues and authenticates CLI sessions by digest", - /pub fn issue_cli_session[\s\S]*Digest::sha256\(session_secret\)[\s\S]*pub fn authenticate_cli_session[\s\S]*cli_sessions[\s\S]*AuthContext/ -); -expect( - coordinatorSessions, - "coordinator rejects expired and revoked CLI sessions", - /expires_at_epoch_seconds[\s\S]*expires_at <= now_epoch_seconds[\s\S]*CLI session credential has expired[\s\S]*pub fn revoke_cli_session[\s\S]*record\.revoked = true/ -); -expect( - coordinatorProtocol, - "coordinator has authenticated request envelope without trusted actor fields", - /Authenticated \{[\s\S]*session_secret: String[\s\S]*request: AuthenticatedCoordinatorRequest[\s\S]*pub enum AuthenticatedCoordinatorRequest[\s\S]*AuthStatus[\s\S]*ListProjects/ -); -expect( - coordinatorProtocol, - "authenticated request protocol covers node enrollment, descriptor listing, and revocation", - /AuthenticatedCoordinatorRequest[\s\S]*CreateNodeEnrollmentGrant \{[\s\S]*ListNodeDescriptors[\s\S]*RevokeNodeCredential \{[\s\S]*node: String/ -); -expect( - coordinatorProtocol, - "authenticated request protocol covers user process, task, debug, and artifact operations", - /AuthenticatedCoordinatorRequest[\s\S]*StartProcess \{[\s\S]*CancelProcess \{[\s\S]*RestartTask \{[\s\S]*DebugAttach \{[\s\S]*ListTaskEvents \{[\s\S]*CreateArtifactDownloadLink \{[\s\S]*OpenArtifactDownloadStream \{[\s\S]*RevokeArtifactDownloadLink \{[\s\S]*ExportArtifactToNode \{/ -); -expect( - coordinatorProtocol, - "authenticated request protocol can revoke the current CLI session", - /AuthenticatedCoordinatorRequest[\s\S]*RevokeCliSession[\s\S]*CliSessionRevoked/ -); -expect( - coordinatorProtocol, - "admin protocol requests carry nonce-bound proof fields instead of bearer tokens", - /AdminStatus \{[\s\S]*actor_user: String[\s\S]*admin_proof: Digest[\s\S]*admin_nonce: String[\s\S]*issued_at_epoch_seconds: u64[\s\S]*SuspendTenant \{[\s\S]*target_tenant: String[\s\S]*admin_proof: Digest/ -); -expect( - coordinatorService, - "coordinator derives authenticated public request context from session secret", - /CoordinatorRequest::Authenticated[\s\S]*handle_authenticated_request[\s\S]*authenticate_cli_session\(&session_secret\)[\s\S]*AuthenticatedCoordinatorRequest::AuthStatus/ -); -expect( - coordinatorAuthorization, - "coordinator has centralized authorization helper for authenticated public user requests", - /pub\(super\) enum PublicUserOperation[\s\S]*AuthStatus[\s\S]*StartProcess[\s\S]*DebugAttach[\s\S]*ExportArtifactToNode[\s\S]*pub\(super\) fn authorize_authenticated_user_operation[\s\S]*authenticated .* request requires a user CLI session/ -); -expect( - coordinatorService, - "authenticated public dispatcher calls centralized authorization before handling", - /let context = self\.coordinator\.authenticate_cli_session\(&session_secret\)\?;[\s\S]*authorize_authenticated_user_operation\(&context, &request\)\?;[\s\S]*match request/ -); -expect( - coordinatorAuthorization, - "coordinator tests prove centralized authenticated public authorization rejection", - /authenticated_public_authorization_requires_user_context_and_names_operation[\s\S]*authenticated debug_attach request requires a user CLI session/ -); -expect( - `${coordinatorService}\n${coordinatorAuthenticated}\n${coordinatorDebug}`, - "coordinator routes user process/task/debug/artifact authenticated requests through session-derived context", - /AuthenticatedCoordinatorRequest::StartProcess[\s\S]*handle_start_process\([\s\S]*context\.tenant[\s\S]*Some\(actor\.as_str\(\)\.to_owned\(\)\)[\s\S]*AuthenticatedCoordinatorRequest::CancelProcess[\s\S]*handle_cancel_process[\s\S]*AuthenticatedCoordinatorRequest::RestartTask[\s\S]*handle_restart_task[\s\S]*AuthenticatedCoordinatorRequest::DebugAttach[\s\S]*handle_authenticated_debug_request[\s\S]*AuthenticatedCoordinatorRequest::ListTaskEvents[\s\S]*handle_list_task_events[\s\S]*AuthenticatedCoordinatorRequest::CreateArtifactDownloadLink[\s\S]*handle_create_artifact_download_link[\s\S]*AuthenticatedCoordinatorRequest::ExportArtifactToNode[\s\S]*handle_export_artifact_to_node[\s\S]*handle_debug_attach/ -); -expect( - `${coreAuth}\n${coordinatorService}\n${coordinatorAdmin}\n${coordinatorServiceTests}`, - "coordinator verifies scoped self-hosted admin proof freshness and replay resistance", - /admin_request_proof_from_token_digest[\s\S]*new_with_admin_token[\s\S]*admin_token_digest[\s\S]*verify_admin_request[\s\S]*ADMIN_REQUEST_MAX_CLOCK_SKEW_SECONDS[\s\S]*admin request proof is invalid[\s\S]*admin_replay_nonces\.contains_key[\s\S]*nonce was already used[\s\S]*MAX_REPLAY_NONCES_PER_AUTHORITY[\s\S]*admin_replay_nonces[\s\S]*\.insert[\s\S]*replayed_admin_status/ -); -expect( - `${coordinatorService}\n${coordinatorQuota}`, - "coordinator keeps quota state and pre-work charging behind a focused quota module", - /mod quota;[\s\S]*quota: quota::CoordinatorQuota[\s\S]*pub\(super\) struct CoordinatorQuota[\s\S]*can_charge_workflow_spawn[\s\S]*charge_workflow_spawn[\s\S]*charge_rendezvous_attempt[\s\S]*can_charge_download[\s\S]*charge_debug_read/ -); -expect( - coordinatorService, - "coordinator revokes authenticated CLI sessions through session-derived context", - /AuthenticatedCoordinatorRequest::RevokeCliSession[\s\S]*revoke_cli_session\(&session_secret\)[\s\S]*CoordinatorResponse::CliSessionRevoked/ -); -expect( - cliConfig, - "stored CLI session can carry scoped coordinator credential without provider token fields", - /pub\(crate\) session_secret: Option/ -); -expect( - cliAuth, - "auth status uses authenticated envelope when a CLI session secret exists", - /let request = match authenticated_or_local_trusted_request\([\s\S]*coordinator,[\s\S]*stored_session,[\s\S]*"type": "auth_status"/ -); -expect( - cliClient, - "stored Client credentials are bound to their issuing coordinator endpoint", - /stored_session_for_coordinator[\s\S]*control_endpoint_identity\(&session\.coordinator\)[\s\S]*control_endpoint_identity\(coordinator\)/ -); -expect( - cliLogout, - "logout revokes stored CLI sessions through authenticated envelope before local removal", - /revoke_stored_cli_session_if_possible[\s\S]*"type": "authenticated"[\s\S]*"type": "revoke_cli_session"[\s\S]*"cli_session_revoked"/ -); -expect( - cliAdmin, - "admin CLI derives nonce-bound proofs and does not send its bearer token", - /admin_request_proof[\s\S]*command_nonce\("admin-status"\)[\s\S]*"admin_proof": admin_proof[\s\S]*"admin_nonce": admin_nonce[\s\S]*"issued_at_epoch_seconds": issued_at_epoch_seconds[\s\S]*fn admin_token_for_request[\s\S]*DISASMER_ADMIN_TOKEN[\s\S]*--admin-token/ -); -expect( - cliTests, - "CLI test proves auth status envelope omits trusted actor body field", - /assert!\(line\.contains\(r#""type":"authenticated""#\)\)[\s\S]*assert!\(line\.contains\(r#""session_secret":"disasmer-cli-session-secret""#\)\)[\s\S]*assert!\(!line\.contains\(r#""actor_user":"user-session""#\)\)/ -); -expect( - cliProject, - "project commands use authenticated envelope for signed-in CLI sessions", - /authenticated_or_local_trusted_request[\s\S]*"type": "create_project"[\s\S]*authenticated_or_local_trusted_request[\s\S]*"type": "list_projects"[\s\S]*authenticated_or_local_trusted_request[\s\S]*"type": "select_project"/ -); -expect( - cliNode, - "node commands use authenticated envelope for signed-in CLI sessions", - /node_enroll_report[\s\S]*authenticated_or_local_trusted_request[\s\S]*"type": "create_node_enrollment_grant"[\s\S]*node_descriptors_report[\s\S]*authenticated_or_local_trusted_request[\s\S]*"type": "list_node_descriptors"[\s\S]*node_revoke_report[\s\S]*authenticated_or_local_trusted_request[\s\S]*"type": "revoke_node_credential"/ -); -expect( - cliProject, - "project command reports use stored CLI session scope when a session secret exists", - /fn session_or_effective_scope_value[\s\S]*session\.session_secret\.is_some\(\)[\s\S]*session_value\(session\)/ -); -expect( - cliTests, - "CLI test proves project list envelope omits trusted actor body field", - /project_list_uses_authenticated_envelope_with_stored_cli_session[\s\S]*assert!\(line\.contains\(r#""type":"authenticated""#\)\)[\s\S]*assert!\(line\.contains\(r#""session_secret":"project-list-session-secret""#\)\)[\s\S]*assert!\(!line\.contains\(r#""actor_user":"user-session""#\)\)/ -); -expect( - cliTests, - "CLI test proves node command envelopes omit trusted actor body fields", - /node_commands_use_authenticated_envelope_with_stored_cli_session[\s\S]*"create_node_enrollment_grant"[\s\S]*"list_node_descriptors"[\s\S]*"revoke_node_credential"[\s\S]*assert!\(!line\.contains\(r#""actor_user":"ignored-user""#\)\)[\s\S]*assert!\(!line\.contains\(r#""tenant":"ignored-tenant""#\)\)[\s\S]*assert!\(!line\.contains\(r#""project":"ignored-project""#\)\)/ -); -expect( - `${cliTests}\n${coordinatorServiceTests}`, - "CLI and coordinator tests prove user control/artifact/debug requests use session-derived authenticated scope", - /user_control_commands_use_authenticated_envelope_with_stored_cli_session[\s\S]*"list_task_events"[\s\S]*"start_process"[\s\S]*"cancel_process"[\s\S]*"restart_task"[\s\S]*"create_artifact_download_link"[\s\S]*"debug_attach"[\s\S]*assert!\(!line\.contains\("ignored-tenant"\)\)[\s\S]*authenticated_envelope_derives_user_scope_from_cli_session[\s\S]*AuthenticatedCoordinatorRequest::StartProcess[\s\S]*AuthenticatedCoordinatorRequest::ListTaskEvents[\s\S]*AuthenticatedCoordinatorRequest::CreateArtifactDownloadLink[\s\S]*AuthenticatedCoordinatorRequest::CancelProcess[\s\S]*AuthenticatedCoordinatorRequest::DebugAttach/ -); -expect( - cliTests, - "CLI tests prove admin coordinator requests require a token but send only a scoped proof", - /admin_status_and_suspend_use_public_coordinator_api[\s\S]*!line\.contains\(r#""admin_token""#[\s\S]*admin_request_proof[\s\S]*admin_commands_require_explicit_admin_token_for_coordinator_requests[\s\S]*--admin-token/ -); -expect( - cliTests, - "CLI tests prove expired/revoked sessions ask for login and logout revokes server session", - /auth_status_reports_expired_or_revoked_cli_session_as_login_required[\s\S]*machine_error"\]\["category"\][\s\S]*"authentication"[\s\S]*logout_revokes_stored_cli_session_on_coordinator_before_local_removal[\s\S]*"revoke_cli_session"/ -); -expect( - cliTests, - "CLI test proves signed agent workflow requests are sent", - /run_with_agent_public_key_sends_attributable_workflow_actor[\s\S]*"agent_signature"[\s\S]*"signature":"ed25519:/ -); -expect( - coordinatorServiceTests, - "coordinator test proves signed agent workflow and fingerprint-only rejection", - /service_runs_agent_workflows_with_scoped_key_attribution[\s\S]*fingerprint_only[\s\S]*signed request[\s\S]*wrong_fingerprint[\s\S]*nonce/ -); -expect( - coordinatorServiceTests, - "coordinator test proves authenticated node operations derive scope from CLI session", - /authenticated_envelope_derives_user_scope_from_cli_session[\s\S]*AuthenticatedCoordinatorRequest::ListNodeDescriptors[\s\S]*AuthenticatedCoordinatorRequest::RevokeNodeCredential[\s\S]*tenant-a[\s\S]*project-a[\s\S]*user-a/ -); -expect( - coordinatorServiceTests, - "coordinator test proves admin operations fail closed without configured or valid proof", - /service_reports_and_enforces_public_admin_tenant_suspension[\s\S]*new_with_admin_token[\s\S]*admin credential is not configured[\s\S]*admin request proof is invalid/ -); -if (hostedService && hostedStartup && hostedOperatorAuth) { - expect( - `${hostedService}\n${hostedStartup}\n${hostedOperatorAuth}`, - "hosted client path rejects operator operations while private Operator requests require replay-resistant proofs", - /HOSTED_OPERATOR_TOKEN_ENV[\s\S]*HostedOperatorAuth[\s\S]*hosted operator operation requires an authenticated hosted_operator_request envelope[\s\S]*CoordinatorService::new_with_admin_token[\s\S]*replay_nonces[\s\S]*verify_request[\s\S]*hosted_operator_request_proof_from_token_digest/ - ); -} -expect( - coordinatorServiceTests, - "coordinator test proves node heartbeat requires signed enrolled key and rejects replay", - /service_requires_signed_node_heartbeat_from_enrolled_key[\s\S]*signed proof[\s\S]*wrong_signature[\s\S]*fresh-node-heartbeat[\s\S]*nonce/ -); -expect( - coordinatorServiceTests, - "coordinator test proves raw node-originated requests require signed envelope", - /service_rejects_raw_node_originated_requests_without_signed_envelope[\s\S]*ReportNodeCapabilities[\s\S]*signed_node envelope proof/ -); -expect( - nodeAttachSmoke, - "node attach smoke signs direct public coordinator heartbeat", - /DISASMER_NODE_PRIVATE_KEY[\s\S]*node_signature: signedNodeHeartbeat/ -); -expect( - selfHostedCoordinatorSmokeSource, - "self-hosted coordinator smoke signs public node heartbeat", - /public_key: identity\.publicKey[\s\S]*node_signature: signedNodeHeartbeat/ -); -expect( - selfHostedCoordinatorSmokeSource, - "self-hosted coordinator smoke proves strict authenticated Client authority", - /DISASMER_SELF_HOSTED_SESSION_SECRET[\s\S]*client_authority, "strict"[\s\S]*forgedBodyAuthority[\s\S]*wrongSession[\s\S]*connect-self-hosted[\s\S]*session-secret-stdin[\s\S]*used_cli_session_credential[\s\S]*authenticated\(\{[\s\S]*type: "schedule_task"/ -); -expect( - wasmtimeAssignmentSmoke, - "real SDK and Wasmtime smoke runs through strict authenticated Client authority", - /DISASMER_SELF_HOSTED_SESSION_SECRET[\s\S]*client_authority, "strict"[\s\S]*DISASMER_SDK_SESSION_SECRET[\s\S]*authenticated\(\{[\s\S]*type: "launch_task"[\s\S]*type: "join_task"/ -); -if (publicDryrunServiceSmoke) { - expect( - publicDryrunServiceSmoke, - "public release dryrun public coordinator heartbeat is signed", - /identity = nodeIdentity\("public-release-service-smoke", node\)[\s\S]*public_key: identity\.publicKey[\s\S]*node_signature: signedNodeHeartbeat/ - ); -} -if (hostedPolicy) { - expect( - hostedPolicy, - "hosted identity broker mints a non-provider CLI session secret and records only its digest in the login summary", - /generate_opaque_token\("cli_session"\)[\s\S]*cli_session_secret_digest: Digest::sha256\(&cli_session_secret\)[\s\S]*cli_session_secret/ - ); -} -if (hostedService) { - expect( - hostedService, - "hosted service delegates CLI session authority and project creation to the public coordinator", - /identity_broker\.complete_trusted_oidc_browser_login[\s\S]*core_coordinator[\s\S]*issue_cli_session[\s\S]*CoordinatorRequest::Authenticated[\s\S]*CreateProject/ - ); -} - -for (const [gateName, script] of [ - ["public acceptance", publicAcceptance], - ["private acceptance", privateAcceptance], - ["CLI-first acceptance", cliFirstAcceptance], - ["public split", publicSplit], -]) { - assert( - script.includes("node scripts/code-size-guard.js"), - `${gateName} must run code-size-guard.js` - ); -} - -expect(publicAcceptance, "public gate includes unit coverage", /cargo test --workspace/); -expect(publicAcceptance, "public gate includes binary build coverage", /cargo build --workspace --bins/); -expect(privateAcceptance, "private gate includes hosted unit coverage", /cargo test --manifest-path private\/hosted-policy\/Cargo\.toml/); -expect(publicSplit, "public split includes copied-tree unit coverage", /cargo test --workspace --manifest-path "\$tmp_dir\/Cargo\.toml"/); -expect(publicSplit, "public split includes copied-tree binary build coverage", /cargo build --workspace --bins --manifest-path "\$tmp_dir\/Cargo\.toml"/); - -for (const [gateName, script] of [ - ["public acceptance", publicAcceptance], - ["public split", publicSplit], -]) { - for (const smoke of [ - "scripts/wasmtime-assignment-smoke.js", - "scripts/node-attach-smoke.js", - "scripts/cli-local-run-smoke.js", - "scripts/vscode-f5-smoke.js", - "scripts/dap-smoke.js", - "scripts/artifact-download-smoke.js", - "scripts/artifact-export-smoke.js", - "scripts/public-local-demo-matrix-smoke.js", - ]) { - assert(script.includes(`node ${smoke}`), `${gateName} must run boundary smoke ${smoke}`); - } -} - -for (const smoke of [ - "private/hosted-policy/scripts/hosted-deployment-smoke.js", - "private/hosted-policy/scripts/hosted-client-compat-smoke.js", - "private/hosted-policy/scripts/postgres-durable-smoke.js", -]) { - assert( - privateAcceptance.includes(`node ${smoke}`), - `private acceptance must run hosted boundary smoke ${smoke}` - ); -} -assert( - privateAcceptance.includes("node scripts/self-hosted-coordinator-smoke.js"), - "private acceptance must run standalone Core coordinator smoke" -); - -for (const [name, source, patterns] of [ - [ - "real coordinator Wasm assignment", - wasmtimeAssignmentSmoke, - [/cp\.spawn/, /disasmer-coordinator/, /type: "create_node_enrollment_grant"/, /disasmer-node/], - ], - [ - "node attach", - nodeAttachSmoke, - [/cp\.spawn/, /"node"[\s\S]*"attach"/, /used_enrollment_exchange/, /signedNodeHeartbeat/], - ], - [ - "CLI local run", - cliLocalRunSmoke, - [/cp\.spawn/, /disasmer[\s\S]*run/, /cli_process_started_node_process[\s\S]*true/], - ], - [ - "VS Code F5", - vscodeF5Smoke, - [ - /runtimeBackend[\s\S]*local-services/, - /buildMainLine[\s\S]*setBreakpoints[\s\S]*verified, true/, - /allThreadsStopped, true/, - /coordinator_task_events[\s\S]*value === 0/, - /Source Locals/, - /Task Args and Handles/, - /unavailable-local-diagnostic/, - /command_status[\s\S]*frozen through local services at executing Wasm probe/, - /state[\s\S]*Frozen/, - /command_spec/, - /stdout_tail/, - /stderr_tail/, - ], - ], - [ - "DAP", - dapSmoke, - [ - /runtimeBackend: "local-services"/, - /buildMainLine[\s\S]*setBreakpoints[\s\S]*verified, true/, - /allThreadsStopped, true[\s\S]*confirmed by every active participant/, - /threads[\s\S]*build virtual process/, - /build_main::wasm/, - /Source Locals/, - /Wasm Frame Locals/, - /unavailable-local-diagnostic/, - /runtime-boundary-diagnostic/, - /command_status[\s\S]*frozen through local services at executing Wasm probe/, - /taskTrapLine[\s\S]*childThread[\s\S]*arg_0[\s\S]*task_handle_/, - /source stepping is not yet available/, - /checkpoint boundary|still active/, - ], - ], - [ - "artifact download", - artifactDownloadSmoke, - [/create_artifact_download_link/, /open_artifact_download_stream/, /crossTenantOpen/], - ], - [ - "artifact export", - artifactExportSmoke, - [/export_artifact_to_node/, /node-export-receiver/, /coordinator_bulk_relay_allowed[\s\S]*false/], - ], - [ - "standalone Core coordinator", - selfHostedCoordinatorSmoke, - [/disasmer-coordinator/, /standalone-core-coordinator/, /core-coordinator-compat\.json/], - ], - [ - "public local demo matrix", - publicLocalDemoMatrix, - [/scripts\/cli-install-smoke\.js/, /scripts\/node-attach-smoke\.js/, /scripts\/vscode-f5-smoke\.js/], - ], -]) { - for (const pattern of patterns) { - expect(source, name, pattern); - } -} - -if (hostedClientCompatSmoke) { - for (const pattern of [ - /disasmer-hosted-service/, - /completeHostedBrowserLogin/, - /DISASMER_BROWSER_OPEN_COMMAND/, - /"disasmer-cli"[\s\S]*"node"[\s\S]*"attach"/, - /node_enrollment_exchanged/, - /DISASMER_HOSTED_CLI_SESSION_TTL_SECONDS/, - /expired; run disasmer login --browser again/, - /crossTenantTaskEventsDenied[\s\S]*list_task_events[\s\S]*vp-victim/, - /hosted-client-compat\.json/, - ]) { - expect(hostedClientCompatSmoke, "hosted Client compatibility", pattern); - } -} - -for (const pattern of [ - /DISASMER_PUBLIC_RELEASE_DRYRUN_E2E/, - /downloadReleaseAssets/, - /verifyChecksums/, - /git"[\s\S]*"clone"/, - /defaultLoginPlan\.coordinator/, - /DISASMER_PUBLIC_RELEASE_DRYRUN_BROWSER_OPEN_COMMAND/, - /DISASMER_BROWSER_OPEN_COMMAND: browserOpenCommand/, - /node_enrollment_exchanged/, - /disasmerNode/, - /launch_task/, - /worker_assignment_poll_verified/, - /validateStandaloneCoreCoordinator/, - /core_coordinator_validated/, - /standalone-core-coordinator/, - /DISASMER_SELF_HOSTED_SESSION_SECRET/, - /client_authority, "strict"/, - /forged_body_authority_denied/, - /wrong_session_denied/, - /preTaskMainLine/, - /compileLinuxLine/, - /task_recorded/, - /list_task_events/, - /create_artifact_download_link/, - /vscode-f5-smoke\.js/, - /downloaded_release_assets: true/, - /vscode_debugger_verified: true/, - /artifact_download_or_export_verified: true/, - /public-release-dryrun-e2e\.json/, -]) { - expect(publicDryrunE2e, "public release e2e evidence", pattern); -} - -for (const pattern of [ - /public-release-dryrun-forgejo-release\.json/, - /deployment-manifest\.json/, - /public-release-dryrun-service\.json/, - /hosted-client-compat\.json/, - /core-coordinator-compat\.json/, - /public-release-dryrun-e2e\.json/, - /coordinator_validation/, - /core_coordinator_client_authority/, - /core_coordinator_forged_body_authority_denied/, - /downloaded_release_assets/, - /vscode_debugger_verified/, - /artifact_download_or_export_verified/, - /public-release-dryrun-final\.json/, -]) { - expect(finalDryrunEvidence, "public release final evidence", pattern); -} - -console.log("Acceptance evidence contract smoke passed"); diff --git a/scripts/acceptance-private.sh b/scripts/acceptance-private.sh index c729f3a..de09c41 100755 --- a/scripts/acceptance-private.sh +++ b/scripts/acceptance-private.sh @@ -4,26 +4,15 @@ set -euo pipefail repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$repo" -node scripts/acceptance-report.js private -node scripts/acceptance-report-smoke.js -node scripts/acceptance-doc-contract-smoke.js -node scripts/acceptance-environment-contract-smoke.js -node scripts/acceptance-evidence-contract-smoke.js -node private/hosted-policy/scripts/phase3-live-infra-contract-smoke.js -node scripts/code-size-guard.js -node scripts/public-private-boundary-smoke.js -node scripts/release-blocker-smoke.js -node scripts/resource-metering-contract-smoke.js -node scripts/hostile-input-contract-smoke.js -node scripts/tenant-isolation-contract-smoke.js -node scripts/cli-error-exit-smoke.js -scripts/release-source-scan.sh -cargo test --manifest-path private/hosted-policy/Cargo.toml -node private/hosted-policy/scripts/hosted-signup-contract-smoke.js +scripts/check-old-name.sh +node scripts/check-docs.js +scripts/check-code-size.sh +cargo fmt --all --manifest-path private/hosted-policy/Cargo.toml --check +cargo clippy --manifest-path private/hosted-policy/Cargo.toml --all-targets -- -D warnings +cargo test --manifest-path private/hosted-policy/Cargo.toml --all-targets node private/hosted-policy/scripts/prepare-public-release-dryrun-deployment.js node private/hosted-policy/scripts/hosted-deployment-smoke.js node private/hosted-policy/scripts/hosted-client-compat-smoke.js -node scripts/self-hosted-coordinator-smoke.js if command -v podman >/dev/null 2>&1; then node private/hosted-policy/scripts/postgres-durable-smoke.js elif command -v nix >/dev/null 2>&1; then @@ -31,9 +20,6 @@ elif command -v nix >/dev/null 2>&1; then else node private/hosted-policy/scripts/postgres-durable-smoke.js fi -if [[ -n "${DISASMER_PUBLIC_RELEASE_DRYRUN_SERVICE_ADDR:-}" ]]; then +if [[ -n "${CLUSTERFLUX_PUBLIC_RELEASE_DRYRUN_SERVICE_ADDR:-}" ]]; then node private/hosted-policy/scripts/public-release-dryrun-service-smoke.js fi -if [[ "${DISASMER_PUBLIC_RELEASE_DRYRUN_FINAL:-}" == "1" ]]; then - node scripts/public-release-dryrun-final-evidence.js -fi diff --git a/scripts/acceptance-public.sh b/scripts/acceptance-public.sh index b849a25..d5969ad 100755 --- a/scripts/acceptance-public.sh +++ b/scripts/acceptance-public.sh @@ -4,40 +4,20 @@ set -euo pipefail repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$repo" -node scripts/acceptance-report.js public -node scripts/acceptance-report-smoke.js -node scripts/acceptance-doc-contract-smoke.js -node scripts/acceptance-environment-contract-smoke.js -node scripts/acceptance-evidence-contract-smoke.js -node scripts/code-size-guard.js -node scripts/public-private-boundary-smoke.js -node scripts/release-blocker-smoke.js +scripts/check-old-name.sh +node scripts/check-docs.js +scripts/check-code-size.sh +scripts/release-source-scan.sh +cargo fmt --all --check +cargo clippy --workspace --all-targets -- -D warnings +cargo test --workspace --all-targets +cargo build --workspace --all-targets +cargo build -p launch-build-demo --target wasm32-unknown-unknown node scripts/resource-metering-contract-smoke.js node scripts/hostile-input-contract-smoke.js node scripts/tenant-isolation-contract-smoke.js -node scripts/public-story-contract-smoke.js -node scripts/public-release-dryrun-contract-smoke.js -node scripts/public-browser-login-contract-smoke.js node scripts/self-hosted-coordinator-smoke.js node scripts/public-local-demo-matrix-smoke.js -scripts/release-source-scan.sh -node scripts/prepare-public-release-dryrun.js -if [[ "${DISASMER_PUBLIC_RELEASE_PREFLIGHT:-}" == "1" ]]; then - node scripts/public-release-dryrun-preflight.js -fi -if [[ -n "${DISASMER_FORGEJO_TOKEN:-}" ]]; then - node scripts/publish-public-release-dryrun.js -fi -if [[ "${DISASMER_PUBLIC_RELEASE_DRYRUN_E2E:-}" == "1" ]]; then - node scripts/public-release-dryrun-e2e.js -fi -if [[ "${DISASMER_PUBLIC_RELEASE_DRYRUN_FINAL:-}" == "1" ]]; then - node scripts/public-release-dryrun-final-evidence.js -fi -cargo fmt --all --check -cargo test --workspace -cargo build --workspace --bins -node scripts/docs-smoke.js node scripts/cli-output-mode-smoke.js node scripts/cli-login-smoke.js node scripts/cli-error-exit-smoke.js @@ -65,7 +45,6 @@ node scripts/operator-panel-smoke.js node scripts/source-preparation-smoke.js node scripts/scheduler-placement-smoke.js node scripts/windows-best-effort-smoke.js -node scripts/windows-validation-contract-smoke.js node scripts/quic-smoke.js node scripts/dap-smoke.js node scripts/flagship-demo-smoke.js diff --git a/scripts/acceptance-report-smoke.js b/scripts/acceptance-report-smoke.js deleted file mode 100644 index 2220639..0000000 --- a/scripts/acceptance-report-smoke.js +++ /dev/null @@ -1,113 +0,0 @@ -#!/usr/bin/env node - -const assert = require("assert"); -const cp = require("child_process"); -const fs = require("fs"); -const path = require("path"); - -const repo = path.resolve(__dirname, ".."); - -function runReport(mode, env = {}) { - const output = cp.execFileSync( - "node", - ["scripts/acceptance-report.js", mode], - { - cwd: repo, - encoding: "utf8", - env: { ...process.env, ...env }, - } - ); - const report = JSON.parse(output); - const persistedPath = path.join( - repo, - "target", - "acceptance", - `${mode}-environment.json` - ); - const persisted = JSON.parse(fs.readFileSync(persistedPath, "utf8")); - assert.deepStrictEqual(persisted, report, `${mode} report was not persisted`); - return report; -} - -function assertString(value, name) { - assert.strictEqual(typeof value, "string", `${name} must be a string`); - assert(value.length > 0, `${name} must not be empty`); -} - -function assertNullableString(value, name) { - if (value === null) return; - assertString(value, name); -} - -function assertPodmanReport(podman) { - assert(podman && typeof podman === "object", "podman report must be an object"); - assert( - ["available", "incomplete"].includes(podman.status), - "podman.status must be available or incomplete" - ); - assertNullableString(podman.version, "podman.version"); - assertNullableString(podman.rootless, "podman.rootless"); - assertNullableString(podman.incomplete_reason, "podman.incomplete_reason"); - if (podman.status === "available") { - assertString(podman.version, "podman.version"); - assert.strictEqual(podman.rootless, "true"); - assert.strictEqual(podman.incomplete_reason, null); - } else { - assertString(podman.incomplete_reason, "podman.incomplete_reason"); - } -} - -function assertReport(report, mode, expectedWindowsValidation) { - assert.strictEqual(report.kind, "disasmer_acceptance_environment"); - assert.strictEqual(report.mode, mode); - assert.match(report.generated_at, /^\d{4}-\d{2}-\d{2}T/); - assert.match(report.commit, /^[0-9a-f]{40}$/); - assert(Array.isArray(report.tree_status), "tree_status must be an array"); - - assertString(report.os.platform, "os.platform"); - assertString(report.os.release, "os.release"); - assertString(report.os.kernel, "os.kernel"); - assertString(report.os.arch, "os.arch"); - - assertString(report.rust.rustc, "rust.rustc"); - assertString(report.rust.cargo, "rust.cargo"); - assertString(report.node.version, "node.version"); - assert(report.node.version.startsWith("v"), "node.version must be Node.js style"); - - assertPodmanReport(report.podman); - assertNullableString(report.postgres.version, "postgres.version"); - assertNullableString(report.browser_harness.version, "browser_harness.version"); - assertNullableString(report.browser_harness.command, "browser_harness.command"); - assertString(report.browser_harness.configured, "browser_harness.configured"); - - assert(Array.isArray(report.vscode_harness.smokes), "vscode smokes must be listed"); - assert( - report.vscode_harness.smokes.includes("scripts/vscode-extension-smoke.js"), - "VS Code extension smoke must be recorded" - ); - assert( - report.vscode_harness.smokes.includes("scripts/vscode-f5-smoke.js"), - "VS Code F5 smoke must be recorded" - ); - assertString(report.vscode_harness.engine, "vscode_harness.engine"); - assertString( - report.vscode_harness.extension_version, - "vscode_harness.extension_version" - ); - - assert.strictEqual(report.windows_validation, expectedWindowsValidation); -} - -for (const mode of ["public", "private"]) { - assertReport(runReport(mode), mode, "not-run"); -} - -assertReport( - runReport("windows", { - DISASMER_WINDOWS_VALIDATION: "forgejo-windows-runner", - }), - "windows", - "forgejo-windows-runner" -); - -console.log("Acceptance report smoke passed"); diff --git a/scripts/acceptance-report.js b/scripts/acceptance-report.js deleted file mode 100755 index dfe6b8d..0000000 --- a/scripts/acceptance-report.js +++ /dev/null @@ -1,139 +0,0 @@ -#!/usr/bin/env node - -const cp = require("child_process"); -const fs = require("fs"); -const os = require("os"); -const path = require("path"); - -const repo = path.resolve(__dirname, ".."); -const mode = process.argv[2] || "public"; - -function commandOutput(command, args = []) { - try { - return cp - .execFileSync(command, args, { - cwd: repo, - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"] - }) - .trim(); - } catch (_) { - return null; - } -} - -function packageJson() { - return JSON.parse( - fs.readFileSync(path.join(repo, "vscode-extension/package.json"), "utf8") - ); -} - -function firstCommandOutput(candidates) { - for (const [command, args] of candidates) { - const output = commandOutput(command, args); - if (output) { - return { command, version: output }; - } - } - return null; -} - -function podmanReport() { - const version = commandOutput("podman", ["--version"]); - if (!version) { - return { - status: "incomplete", - version: null, - rootless: null, - incomplete_reason: "podman command is unavailable" - }; - } - const rootless = commandOutput("podman", [ - "info", - "--format", - "{{.Host.Security.Rootless}}" - ]); - if (!rootless) { - return { - status: "incomplete", - version, - rootless: null, - incomplete_reason: "podman info did not report rootless status" - }; - } - if (rootless !== "true") { - return { - status: "incomplete", - version, - rootless, - incomplete_reason: "podman is not running in rootless mode" - }; - } - return { - status: "available", - version, - rootless, - incomplete_reason: null - }; -} - -const extensionPackage = packageJson(); -const sourceCommit = - process.env.DISASMER_ACCEPTANCE_COMMIT || commandOutput("git", ["rev-parse", "HEAD"]); -const browserVersion = firstCommandOutput([ - ["chromium", ["--version"]], - ["chromium-browser", ["--version"]], - ["google-chrome", ["--version"]], - ["firefox", ["--version"]] -]); -const report = { - kind: "disasmer_acceptance_environment", - mode, - commit: sourceCommit, - tree_status: (commandOutput("git", ["status", "--short"]) || "") - .split("\n") - .filter(Boolean), - generated_at: new Date().toISOString(), - os: { - platform: os.platform(), - release: os.release(), - kernel: os.release(), - arch: os.arch() - }, - rust: { - rustc: commandOutput("rustc", ["--version"]), - cargo: commandOutput("cargo", ["--version"]) - }, - node: { - version: process.version - }, - podman: podmanReport(), - postgres: { - version: - commandOutput("postgres", ["--version"]) || - commandOutput("psql", ["--version"]) - }, - browser_harness: { - version: browserVersion && browserVersion.version, - command: browserVersion && browserVersion.command, - configured: process.env.DISASMER_BROWSER_HARNESS || "not-configured" - }, - vscode_harness: { - smokes: [ - "scripts/vscode-extension-smoke.js", - "scripts/vscode-f5-smoke.js" - ], - engine: extensionPackage.engines && extensionPackage.engines.vscode, - extension_version: extensionPackage.version - }, - windows_validation: process.env.DISASMER_WINDOWS_VALIDATION || "not-run" -}; - -const outDir = path.join(repo, "target/acceptance"); -fs.mkdirSync(outDir, { recursive: true }); -fs.writeFileSync( - path.join(outDir, `${mode}-environment.json`), - `${JSON.stringify(report, null, 2)}\n` -); - -console.log(JSON.stringify(report, null, 2)); diff --git a/scripts/agent-signing.js b/scripts/agent-signing.js index 88ab34a..71c913d 100644 --- a/scripts/agent-signing.js +++ b/scripts/agent-signing.js @@ -25,7 +25,7 @@ function agentWorkflowSignatureMessage({ issuedAtEpochSeconds, }) { const parts = [ - "disasmer-agent-workflow-signature:v2", + "clusterflux-agent-workflow-signature:v2", tenant, project, agent, @@ -53,6 +53,10 @@ function signedAgentWorkflowProof(identity, request, options = {}) { .toString("hex")}`; const issuedAtEpochSeconds = options.issuedAtEpochSeconds || Math.floor(Date.now() / 1000); + const processId = + request.type === "launch_task" ? request.task_spec?.process : request.process; + const task = + request.type === "launch_task" ? request.task_spec?.task_instance : request.task || ""; const signature = crypto.sign( null, agentWorkflowSignatureMessage({ @@ -60,8 +64,8 @@ function signedAgentWorkflowProof(identity, request, options = {}) { project: request.project, agent: request.actor_agent, requestKind: request.type, - process: request.process, - task: request.task || "", + process: processId, + task, payloadDigest: signedRequestPayloadDigest(request), nonce, issuedAtEpochSeconds, diff --git a/scripts/artifact-download-smoke.js b/scripts/artifact-download-smoke.js index 7af084d..2f6a44a 100755 --- a/scripts/artifact-download-smoke.js +++ b/scripts/artifact-download-smoke.js @@ -69,9 +69,9 @@ function downloadNodeCapabilities() { "run", "-q", "-p", - "disasmer-coordinator", + "clusterflux-coordinator", "--bin", - "disasmer-coordinator", + "clusterflux-coordinator", "--", "--listen", "127.0.0.1:0", @@ -268,23 +268,23 @@ function downloadNodeCapabilities() { .update(downloaded.content) .digest("hex")}`; assert.strictEqual(downloadedDigest, packageEvent.artifact_digest); - const inspect = fs.mkdtempSync(path.join(os.tmpdir(), "disasmer-release-")); + const inspect = fs.mkdtempSync(path.join(os.tmpdir(), "clusterflux-release-")); try { const archive = path.join(inspect, "release.tar"); fs.writeFileSync(archive, downloaded.content); const listing = cp.execFileSync("tar", ["-tf", archive], { encoding: "utf8" }); - assert.strictEqual(listing.trim(), "hello-disasmer"); + assert.strictEqual(listing.trim(), "hello-clusterflux"); cp.execFileSync("tar", ["-xf", archive, "-C", inspect]); - fs.chmodSync(path.join(inspect, "hello-disasmer"), 0o755); + fs.chmodSync(path.join(inspect, "hello-clusterflux"), 0o755); assert.strictEqual( - cp.execFileSync(path.join(inspect, "hello-disasmer"), { encoding: "utf8" }), - "hello from a real Disasmer build\n" + cp.execFileSync(path.join(inspect, "hello-clusterflux"), { encoding: "utf8" }), + "hello from a real Clusterflux build\n" ); } finally { fs.rmSync(inspect, { recursive: true, force: true }); } const cliDownloadDirectory = fs.mkdtempSync( - path.join(os.tmpdir(), "disasmer-cli-download-") + path.join(os.tmpdir(), "clusterflux-cli-download-") ); try { const cliDownloadPath = path.join(cliDownloadDirectory, "release.tar"); @@ -292,11 +292,11 @@ function downloadNodeCapabilities() { cp.execFileSync( "cargo", [ - "run", "-q", "-p", "disasmer-cli", "--bin", "disasmer", "--", + "run", "-q", "-p", "clusterflux-cli", "--bin", "clusterflux", "--", "artifact", "download", artifact, "--to", cliDownloadPath, "--max-bytes", "1048576", - "--coordinator", `disasmer+tcp://${addr.host}:${addr.port}`, + "--coordinator", `clusterflux+tcp://${addr.host}:${addr.port}`, "--tenant", "tenant", "--project-id", "project", "--user", "user", diff --git a/scripts/artifact-export-smoke.js b/scripts/artifact-export-smoke.js index d90c4f3..97f1669 100644 --- a/scripts/artifact-export-smoke.js +++ b/scripts/artifact-export-smoke.js @@ -84,15 +84,18 @@ async function reportNode( "run", "-q", "-p", - "disasmer-coordinator", + "clusterflux-coordinator", "--bin", - "disasmer-coordinator", + "clusterflux-coordinator", "--", "--listen", "127.0.0.1:0", "--allow-local-trusted-loopback", ], - { cwd: repo } + { + cwd: repo, + env: { ...process.env, CLUSTERFLUX_NODE_STALE_AFTER_SECONDS: "1" }, + } ); let worker; @@ -121,7 +124,7 @@ async function reportNode( assert.strictEqual(compileEvent.status_code, 0); assert.match( compileEvent.artifact_path, - /^\/vfs\/artifacts\/hello-disasmer-[0-9a-f]{64}$/ + /^\/vfs\/artifacts\/hello-clusterflux-[0-9a-f]{64}$/ ); const artifact = compileEvent.artifact_path.slice("/vfs/artifacts/".length); @@ -165,15 +168,15 @@ async function reportNode( assert.match(exportPlan.plan.authorization_digest, /^sha256:[0-9a-f]{64}$/); assert.strictEqual(exportPlan.artifact_size_bytes, compileEvent.artifact_size_bytes); - const temp = fs.mkdtempSync(path.join(os.tmpdir(), "disasmer-artifact-export-")); - const exportPath = path.join(temp, "hello-disasmer"); + const temp = fs.mkdtempSync(path.join(os.tmpdir(), "clusterflux-artifact-export-")); + const exportPath = path.join(temp, "hello-clusterflux"); const cliExport = await runJson("cargo", [ "run", "-q", "-p", - "disasmer-cli", + "clusterflux-cli", "--bin", - "disasmer", + "clusterflux", "--", "artifact", "export", @@ -207,7 +210,7 @@ async function reportNode( fs.chmodSync(exportPath, 0o755); assert.strictEqual( cp.execFileSync(exportPath, { encoding: "utf8" }), - "hello from a real Disasmer build\n" + "hello from a real Clusterflux build\n" ); const crossTenant = await send(addr, { @@ -223,6 +226,7 @@ async function reportNode( assert.strictEqual(crossTenant.type, "error"); assert.match(crossTenant.message, /tenant mismatch/); + await reportNode(addr, sourceNode, sourceIdentity, { artifacts: [artifact] }); const failedDirect = await send(addr, { type: "export_artifact_to_node", tenant: "tenant", @@ -238,6 +242,8 @@ async function reportNode( assert.match(failedDirect.message, /coordinator bulk relay is disabled/); await reportNode(addr, "node-export-receiver", receiverIdentity, { online: false }); + await new Promise((resolve) => setTimeout(resolve, 2100)); + await reportNode(addr, sourceNode, sourceIdentity, { artifacts: [artifact] }); const offlineReceiver = await send(addr, { type: "export_artifact_to_node", tenant: "tenant", diff --git a/scripts/check-code-size.sh b/scripts/check-code-size.sh new file mode 100755 index 0000000..e740b44 --- /dev/null +++ b/scripts/check-code-size.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo_root" + +maximum_lines=3000 +failed=0 +while IFS= read -r -d '' file; do + case "$file" in + */tests.rs|*/tests/*) continue ;; + esac + lines="$(wc -l < "$file")" + if ((lines > maximum_lines)); then + printf '%s has %s lines; production file limit is %s\n' "$file" "$lines" "$maximum_lines" >&2 + failed=1 + fi +done < <(find crates private/hosted-policy/src -type f -name '*.rs' -print0) + +if ((failed)); then + exit 1 +fi +printf 'production source file size guard passed (%s lines maximum)\n' "$maximum_lines" diff --git a/scripts/check-docs.js b/scripts/check-docs.js new file mode 100644 index 0000000..1830bdc --- /dev/null +++ b/scripts/check-docs.js @@ -0,0 +1,113 @@ +#!/usr/bin/env node +const fs = require("node:fs"); +const path = require("node:path"); + +const root = path.resolve(__dirname, ".."); +const publicDocs = [ + "README.md", + "docs/getting-started.md", + "docs/architecture.md", + "docs/nodes.md", + "docs/environments.md", + "docs/artifacts.md", + "docs/debugging.md", + "docs/task-abi.md", + "docs/self-hosting.md", + "docs/security.md", +]; +const privateDocs = [ + "private/docs/hosted-deployment.md", + "private/docs/authentik.md", + "private/docs/community-policy.md", + "private/docs/bandwidth-and-cost-controls.md", + "private/docs/moderation.md", + "private/docs/publishing.md", +]; +const internalDocs = ["internal/finish_mvp.md", "internal/release-status.md"]; +const filteredPublicTree = + process.env.CLUSTERFLUX_FILTERED_PUBLIC_TREE === "1" || + fs.existsSync(path.join(root, "CLUSTERFLUX_PUBLIC_TREE.json")); + +const failures = []; +const expectExactMarkdownSet = (directory, expected) => { + const actual = fs + .readdirSync(path.join(root, directory), { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith(".md")) + .map((entry) => path.posix.join(directory, entry.name)) + .sort(); + const wanted = [...expected].sort(); + if (JSON.stringify(actual) !== JSON.stringify(wanted)) { + failures.push(`${directory} markdown set is ${actual.join(", ")}; expected ${wanted.join(", ")}`); + } +}; + +const requiredDocs = filteredPublicTree + ? publicDocs + : [...publicDocs, ...privateDocs, ...internalDocs]; +for (const file of requiredDocs) { + if (!fs.existsSync(path.join(root, file))) failures.push(`missing canonical document: ${file}`); +} +expectExactMarkdownSet("docs", publicDocs.filter((file) => file.startsWith("docs/"))); +if (filteredPublicTree) { + for (const directory of ["private", "internal"]) { + if (fs.existsSync(path.join(root, directory))) { + failures.push(`${directory}/ must not exist in the filtered public tree`); + } + } +} else { + expectExactMarkdownSet("private/docs", privateDocs); + expectExactMarkdownSet("internal", internalDocs); +} + +const forbidden = [ + [/\bmvp\b/i, "internal milestone term"], + [/acceptance criteria/i, "internal gate language"], + [/release verification/i, "internal release language"], + [/public\/private source split/i, "source split narrative"], + [/founder|business decisions/i, "business planning narrative"], + [/hacker news|\bHN\b/, "launch-channel narrative"], + [/\busers can\b/i, "indirect reader wording"], + [/node\s+scripts\//i, "developer script instruction"], + [/scripts\/[^\s)]*smoke/i, "smoke script instruction"], + [/internal\/[^\s)]*/i, "internal tooling reference"], +]; +const topLevelCommands = new Set([ + "doctor", "login", "logout", "auth", "agent", "key", "project", "inspect", + "build", "bundle", "run", "node", "process", "task", "logs", "artifact", + "dap", "debug", "quota", "admin", +]); + +for (const file of publicDocs) { + const absolute = path.join(root, file); + const content = fs.readFileSync(absolute, "utf8"); + for (const [pattern, description] of forbidden) { + if (pattern.test(content)) failures.push(`${file}: contains ${description}`); + } + for (const match of content.matchAll(/\]\(([^)#]+)(?:#[^)]+)?\)/g)) { + const target = match[1]; + if (/^(?:https?:|mailto:)/.test(target)) continue; + const resolved = path.resolve(path.dirname(absolute), target); + if (!fs.existsSync(resolved)) failures.push(`${file}: broken link ${target}`); + } + for (const line of content.split(/\r?\n/)) { + const command = line.trim().match(/^clusterflux\s+([a-z][a-z-]*)\b/); + if (command && !topLevelCommands.has(command[1])) { + failures.push(`${file}: unknown top-level CLI command ${command[1]}`); + } + } +} + +const rootMarkdown = fs + .readdirSync(root, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith(".md")) + .map((entry) => entry.name) + .sort(); +if (JSON.stringify(rootMarkdown) !== JSON.stringify(["README.md"])) { + failures.push(`top-level markdown set is ${rootMarkdown.join(", ")}; expected README.md`); +} + +if (failures.length) { + for (const failure of failures) console.error(failure); + process.exit(1); +} +console.log("documentation checks passed"); diff --git a/scripts/check-old-name.sh b/scripts/check-old-name.sh new file mode 100755 index 0000000..e38b995 --- /dev/null +++ b/scripts/check-old-name.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo_root" + +legacy_lower="$(printf '%s%s' 'disa' 'smer')" +legacy_title="$(printf '%s%s' 'Disa' 'smer')" +legacy_upper="$(printf '%s%s' 'DISA' 'SMER')" +pattern="${legacy_lower}|${legacy_title}|${legacy_upper}" + +allowed_content='^\./(internal/finish_mvp\.md|scripts/rename-to-clusterflux\.sh|scripts/migrate-clusterflux-state\.sh):' +matches="$( + rg -n --hidden \ + --glob '!**/.git/**' \ + --glob '!**/target/**' \ + --glob '!**/node_modules/**' \ + --glob '!**/vendor/**' \ + --glob '!**/.direnv/**' \ + --glob '!**/.cache/**' \ + --glob '!**/dist/**' \ + --glob '!**/out/**' \ + "$pattern" . 2>/dev/null | rg -v "$allowed_content" || true +)" + +paths="$( + find . \ + \( -type d \( -name .git -o -name target -o -name node_modules -o -name vendor -o -name .direnv -o -name .cache -o -name dist -o -name out \) -prune \) -o \ + -iname "*${legacy_lower}*" -print | sort || true +)" + +if [[ -n "$matches" || -n "$paths" ]]; then + [[ -z "$matches" ]] || printf '%s\n' "$matches" >&2 + [[ -z "$paths" ]] || printf '%s\n' "$paths" >&2 + printf 'unexpected legacy product name remains\n' >&2 + exit 1 +fi + +printf 'old-name guard passed\n' diff --git a/scripts/cli-browser-login-flow-smoke.js b/scripts/cli-browser-login-flow-smoke.js index 65f9b35..e2da809 100644 --- a/scripts/cli-browser-login-flow-smoke.js +++ b/scripts/cli-browser-login-flow-smoke.js @@ -69,7 +69,7 @@ function startCoordinator() { transaction_id: "login-transaction", polling_secret: "opaque-polling-secret", authorization_url: - "https://auth.michelpaulissen.com/application/o/authorize/?state=server-state&nonce=server-nonce&code_challenge=server-pkce&code_challenge_method=S256&redirect_uri=https%3A%2F%2Fdisasmer.michelpaulissen.com%2Fauth%2Fcallback", + "https://auth.michelpaulissen.com/application/o/authorize/?state=server-state&nonce=server-nonce&code_challenge=server-pkce&code_challenge_method=S256&redirect_uri=https%3A%2F%2Fclusterflux.michelpaulissen.com%2Fauth%2Fcallback", expires_at_epoch_seconds: 1800000000, })}\n` ); @@ -117,7 +117,7 @@ function startCoordinator() { }); } -function runDisasmer(args, env) { +function runClusterflux(args, env) { return new Promise((resolve, reject) => { const child = cp.spawn( "cargo", @@ -127,9 +127,9 @@ function runDisasmer(args, env) { "--manifest-path", path.join(repo, "Cargo.toml"), "-p", - "disasmer-cli", + "clusterflux-cli", "--bin", - "disasmer", + "clusterflux", "--", ...args, ], @@ -142,7 +142,7 @@ function runDisasmer(args, env) { child.once("error", reject); child.once("close", (code) => { if (code === 0) resolve(stdout); - else reject(new Error(`disasmer exited ${code}\n${stderr}\n${stdout}`)); + else reject(new Error(`clusterflux exited ${code}\n${stderr}\n${stdout}`)); }); }); } @@ -153,7 +153,7 @@ function runDisasmer(args, env) { try { const loginStarted = Date.now(); const report = JSON.parse( - await runDisasmer( + await runClusterflux( [ "login", "--browser", @@ -165,8 +165,8 @@ function runDisasmer(args, env) { ], { ...process.env, - DISASMER_BROWSER_OPEN_COMMAND: opener, - DISASMER_BROWSER_LOGIN_TIMEOUT_SECONDS: "5", + CLUSTERFLUX_BROWSER_OPEN_COMMAND: opener, + CLUSTERFLUX_BROWSER_LOGIN_TIMEOUT_SECONDS: "5", } ) ); @@ -184,7 +184,7 @@ function runDisasmer(args, env) { assert.strictEqual(report.boundary.coordinator_session_requests, 2); assert.strictEqual(coordinator.requests.length, 2); - const sessionFile = path.join(project, ".disasmer", "session.json"); + const sessionFile = path.join(project, ".clusterflux", "session.json"); const sessionText = fs.readFileSync(sessionFile, "utf8"); const session = JSON.parse(sessionText); assert.strictEqual(session.kind, "human"); @@ -203,7 +203,7 @@ function runDisasmer(args, env) { ); const authStatus = JSON.parse( - await runDisasmer(["auth", "status", "--json"], process.env) + await runClusterflux(["auth", "status", "--json"], process.env) ); assert.strictEqual(authStatus.active_coordinator, coordinator.url); assert.strictEqual(authStatus.principal, "user-smoke"); diff --git a/scripts/cli-error-exit-smoke.js b/scripts/cli-error-exit-smoke.js index 1d8b733..76763bf 100755 --- a/scripts/cli-error-exit-smoke.js +++ b/scripts/cli-error-exit-smoke.js @@ -9,13 +9,13 @@ const path = require("path"); const repo = path.resolve(__dirname, ".."); const project = path.join(repo, "examples/launch-build-demo"); -const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "disasmer-cli-error-")); +const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "clusterflux-cli-error-")); -function runDisasmer(args) { +function runClusterflux(args) { return new Promise((resolve) => { const child = cp.spawn( "cargo", - ["run", "-q", "-p", "disasmer-cli", "--bin", "disasmer", "--", ...args], + ["run", "-q", "-p", "clusterflux-cli", "--bin", "clusterflux", "--", ...args], { cwd: repo, stdio: ["ignore", "pipe", "pipe"], @@ -55,7 +55,7 @@ async function runWithOneCoordinatorResponse(buildArgs, response) { server.listen(0, "127.0.0.1", () => resolve(server.address())); }); const coordinator = `http://${address.address}:${address.port}`; - const result = await runDisasmer(buildArgs(coordinator)); + const result = await runClusterflux(buildArgs(coordinator)); return { request, result }; } @@ -70,7 +70,7 @@ async function main() { path.join(environmentProject, "src", "main.rs"), "fn main() { let _target = env!(\"linux\"); }\n" ); - const environmentFailure = await runDisasmer([ + const environmentFailure = await runClusterflux([ "build", "--project", environmentProject, @@ -87,11 +87,11 @@ async function main() { true ); assert( - environmentReport.machine_error.next_actions.includes("disasmer inspect") + environmentReport.machine_error.next_actions.includes("clusterflux inspect") ); assert.strictEqual(environmentReport.diagnostics[0].code, "missing_environment"); - const nonInteractive = await runDisasmer([ + const nonInteractive = await runClusterflux([ "run", "build", "--project", @@ -101,7 +101,7 @@ async function main() { ]); assert.strictEqual(nonInteractive.signal, null, nonInteractive.stderr); assert.strictEqual(nonInteractive.code, 20, nonInteractive.stderr); - assert.doesNotMatch(nonInteractive.stderr, /Opening Disasmer browser login/); + assert.doesNotMatch(nonInteractive.stderr, /Opening Clusterflux browser login/); const nonInteractiveReport = JSON.parse(nonInteractive.stdout); assert.strictEqual(nonInteractiveReport.status, "authentication_required"); assert.strictEqual(nonInteractiveReport.non_interactive, true); @@ -141,7 +141,7 @@ async function main() { }); const coordinator = `http://${address.address}:${address.port}`; - const result = await runDisasmer([ + const result = await runClusterflux([ "run", "build", "--project", @@ -174,7 +174,7 @@ async function main() { true ); assert( - report.run_start.machine_error.next_actions.includes("disasmer quota status") + report.run_start.machine_error.next_actions.includes("clusterflux quota status") ); const capabilityFailure = await runWithOneCoordinatorResponse( @@ -274,7 +274,7 @@ async function main() { assert.strictEqual(programReport.tasks[0].machine_error.category, "program"); assert.strictEqual(programReport.tasks[0].machine_error.stable_exit_code, 27); assert( - programReport.tasks[0].machine_error.next_actions.includes("disasmer logs") + programReport.tasks[0].machine_error.next_actions.includes("clusterflux logs") ); const artifactDownload = await runWithOneCoordinatorResponse( @@ -333,7 +333,7 @@ async function main() { true ); - const confirmation = await runDisasmer([ + const confirmation = await runClusterflux([ "process", "cancel", "--coordinator", diff --git a/scripts/cli-first-contract-smoke.js b/scripts/cli-first-contract-smoke.js deleted file mode 100644 index eaf92fa..0000000 --- a/scripts/cli-first-contract-smoke.js +++ /dev/null @@ -1,773 +0,0 @@ -#!/usr/bin/env node - -const assert = require("assert"); -const fs = require("fs"); -const path = require("path"); - -const repo = path.resolve(__dirname, ".."); - -function read(relativePath) { - const fullPath = path.join(repo, relativePath); - if (!fs.existsSync(fullPath)) { - if (fs.existsSync(path.join(repo, "DISASMER_PUBLIC_TREE.json"))) { - console.log( - `CLI-first contract smoke skipped: ${relativePath} is filtered from this public tree` - ); - process.exit(0); - } - throw new Error(`${relativePath} is missing`); - } - return fs.readFileSync(fullPath, "utf8"); -} - -function readRustTree(relativePath) { - const root = path.join(repo, relativePath); - const sourcePaths = []; - - function visit(directory) { - for (const entry of fs - .readdirSync(directory, { withFileTypes: true }) - .sort((left, right) => left.name.localeCompare(right.name))) { - const fullPath = path.join(directory, entry.name); - if (entry.isDirectory()) { - visit(fullPath); - } else if (entry.isFile() && entry.name.endsWith(".rs")) { - sourcePaths.push(fullPath); - } - } - } - - visit(root); - const priority = (filePath) => { - const name = path.basename(filePath); - if (name === "main.rs" || name === "service.rs") return 0; - if (name === "lib.rs") return 1; - if (name === "protocol.rs") return 2; - if (name === "tests.rs") return 100; - return 10; - }; - sourcePaths.sort( - (left, right) => - priority(left) - priority(right) || left.localeCompare(right) - ); - return sourcePaths - .map((filePath) => fs.readFileSync(filePath, "utf8")) - .join("\n"); -} - -function criterionLines(source) { - return source - .split(/\r?\n/) - .filter((line) => /^- \[[ x]\] \*\*/.test(line)); -} - -function expect(source, name, pattern) { - assert.match(source, pattern, `missing CLI-first contract evidence: ${name}`); -} - -const criteria = read("cli_acceptance_criteria.md"); -// Phase 3 deliberately split the former CLI and coordinator mega-files into -// focused modules. Contract evidence therefore follows the complete source -// trees instead of assuming every implementation and test still lives in one -// entrypoint file. -const cli = readRustTree("crates/disasmer-cli/src"); -const coordinator = readRustTree("crates/disasmer-coordinator/src"); -const coordinatorLib = read("crates/disasmer-coordinator/src/lib.rs"); -const cliFirstAcceptance = read("scripts/acceptance-cli-first.sh"); -const outputModeSmoke = read("scripts/cli-output-mode-smoke.js"); -const errorExitSmoke = read("scripts/cli-error-exit-smoke.js"); -const browserLoginFlowSmoke = read("scripts/cli-browser-login-flow-smoke.js"); -const nodeAttachSmoke = read("scripts/node-attach-smoke.js"); -const dapSmoke = read("scripts/dap-smoke.js"); -const cliHappyPathSmoke = read("scripts/cli-happy-path-live-smoke.js"); - -expect(criteria, "header", /^# Disasmer CLI-First MVP Acceptance Criteria/m); -expect( - criteria, - "addendum status", - /\*\*Status:\*\* CLI-first addendum to `acceptance_criteria\.md` and `acceptance_criteria_phase2\.md`/ -); -expect( - criteria, - "website exception", - /hosted account creation as the only intentional private website exception/ -); -expect( - criteria, - "no duplicate work note", - /does not automatically mean new product code, a new feature, or even actual implementation work is required/ -); -expect( - criteria, - "future hosted business non-goal", - /billing, paid-plan checkout, team\/org management, provider setup wizards, secret-manager UI, full support tooling, broad moderation consoles, and durable account\/business-process management are intentionally outside this CLI-first MVP slice/ -); -expect( - criteria, - "billing plan flags are placeholders", - /Design-document references to billing, paid plans, or plan flags are future metadata placeholders; they do not require MVP CLI commands, coordinator routes, schemas, migrations, website controls, or service logic/ -); -expect( - criteria, - "hosted signup remains private Authentik-backed flow", - /Hosted account creation is available only through the private hosted identity flow, backed by Authentik[\s\S]*first-login account\/project creation stays inside/ -); -expect( - criteria, - "hosted signup provider and password policy criteria", - /approved external identity-provider requirement[\s\S]*disasmer_native_password_signup_allowed: false/ -); -expect( - criteria, - "hosted provider normalization criteria", - /Google sign-in may be treated as OIDC; GitHub sign-in is OAuth2 social login[\s\S]*normalizes Google as `google_oidc` and GitHub as `github_oauth2` from an Authentik-issued OIDC session[\s\S]*does not consume provider-specific tokens directly/ -); -expect( - criteria, - "hosted CLI cannot create accounts directly", - /The CLI cannot create a hosted account directly[\s\S]*parser coverage rejects `disasmer signup`, `disasmer account create`, and `disasmer login --create-account`/ -); -expect( - criteria, - "hosted safe claim failure criteria", - /If identity is ambiguous, missing required claims, unverified where required, or blocked by policy[\s\S]*exactly one approved external identity provider[\s\S]*stable subject[\s\S]*verified email/ -); -expect( - criteria, - "hosted signup sanitized failure criteria", - /Signup failures do not disclose moderation, abuse-scoring, or allow\/deny internals[\s\S]*stripping issuer, allowlist, abuse-score, moderation-note, and provider-secret details/ -); -expect( - criteria, - "hosted account state privacy criteria", - /Account suspension, deletion, manual review, and abuse handling are private hosted-admin concerns[\s\S]*public CLI displays clear suspended, disabled, deleted, and manual-review status[\s\S]*strips private moderation\/signup details/ -); - -const lines = criterionLines(criteria); -assert(lines.length > 0, "CLI-first criteria must contain criteria lines"); -for (const line of lines) { - assert.match( - line, - /^- \[[ x]\] \*\*(Passed|Partial|Open|Postponed)(?: \([^)]+\))?:\*\*/, - `CLI-first criterion lacks an explicit status prefix: ${line}` - ); -} - -const openCriteria = lines.filter((line) => /\*\*Open(?::| \()/.test(line)); -assert.deepStrictEqual( - openCriteria, - [], - "CLI-first criteria should not leave Open items once the non-e2e gate exists; remaining unfinished facts stay Partial" -); - -expect( - criteria, - "CLI-first non-e2e gate wording", - /scripts\/acceptance-cli-first\.sh[\s\S]*scripts\/cli-happy-path-live-smoke\.js[\s\S]*live hosted coordinator/ -); -expect( - cliFirstAcceptance, - "CLI-first gate can run live happy path when explicitly enabled", - /DISASMER_CLI_HAPPY_PATH_LIVE[\s\S]*cli-happy-path-live-smoke\.js/ -); -for (const [name, pattern] of [ - ["happy path uses released binaries", /disasmer-public-binaries-/], - ["happy path completes server-owned browser login", /DISASMER_PUBLIC_RELEASE_DRYRUN_BROWSER_OPEN_COMMAND[\s\S]*DISASMER_BROWSER_OPEN_COMMAND/], - ["happy path initializes project", /"project"[\s\S]*"init"/], - ["happy path inspects project", /"inspect"[\s\S]*"--project"/], - ["happy path enrolls node", /"node"[\s\S]*"enroll"/], - ["happy path attaches node", /"node"[\s\S]*"attach"/], - ["happy path starts worker", /--worker/], - ["happy path runs build", /"run"[\s\S]*"build"/], - ["happy path checks logs", /"logs"/], - ["happy path checks artifacts", /"artifact"[\s\S]*"list"/], - ["happy path downloads artifact", /"artifact"[\s\S]*"download"/], - ["happy path restarts process", /"process"[\s\S]*"restart"/], - ["happy path cancels process", /"process"[\s\S]*"cancel"/], - ["happy path writes evidence", /cli-happy-path-live\.json/], -]) { - expect(cliHappyPathSmoke, name, pattern); -} -expect( - criteria, - "artifact export explicit local byte write", - /artifact export --to ` writes bytes[\s\S]*explicit bounded download stream[\s\S]*complete staged content is available/ -); -expect( - criteria, - "artifact download grant disclosure criteria", - /artifact download ` creates a secure[\s\S]*explicit artifact-download grant disclosure[\s\S]*Download links or sessions are not guessable public URLs[\s\S]*guessable_public_url: false[\s\S]*cross-tenant no-reuse[\s\S]*unauthorized-project no-reuse[\s\S]*Every command that grants[\s\S]*artifact download ability[\s\S]*grant_disclosures/ -); -expect( - criteria, - "locality failure safe guidance", - /If direct transfer or locality assumptions fail[\s\S]*connectivity-category safe failures[\s\S]*coordinator bulk relay was not used/ -); -expect( - criteria, - "mutating commands require confirmation", - /Mutating or dangerous commands support `--yes`[\s\S]*confirmation-required safe failure[\s\S]*do not send coordinator requests/ -); -expect( - criteria, - "log redaction criteria", - /Logs are capped, truncated honestly[\s\S]*preserve byte counts and truncation flags[\s\S]*Secret-like values are redacted[\s\S]*common token\/password\/bearer patterns/ -); -expect( - criteria, - "doctor node readiness criteria", - /`disasmer doctor` reports missing local dependencies[\s\S]*explicit node readiness summary[\s\S]*missing local dependencies[\s\S]*node next actions/ -); -expect( - criteria, - "admin bootstrap self-hosted sequence criteria", - /admin bootstrap now reports a CLI-only self-hosted sequence[\s\S]*node enrollment\/attach[\s\S]*quota status[\s\S]*node revoke/ -); -expect( - criteria, - "quota resource-category criteria", - /Hitting a quota produces a clear error[\s\S]*resource category[\s\S]*private abuse heuristics[\s\S]*quota machine errors now extract the resource category/ -); -expect( - criteria, - "quota before work criteria", - /Quotas are checked before expensive work starts[\s\S]*workflow spawns now charge `Spawn` before coordinator-side process\/task state is created or queued[\s\S]*debug, artifact-download, and rendezvous metering/ -); -expect( - criteria, - "community tier CLI wording criteria", - /Community tier language is used instead of "free[ -]tier" in user-facing CLI output/ -); -expect( - criteria, - "DAP launch-critical surface criteria", - /launch-critical DAP surface required by the MVP[\s\S]*`initialize`[\s\S]*`source`[\s\S]*`next`\/step-over[\s\S]*`restartFrame`/ -); -expect( - criteria, - "DAP variables experience criteria", - /normal debugger variables path exposes the MVP debugging experience[\s\S]*selected source locals around Disasmer API calls[\s\S]*runtime-captured Wasmtime frame locals/ -); -expect( - cliFirstAcceptance, - "CLI-first acceptance report", - /node scripts\/acceptance-report\.js cli-first/ -); -expect( - cliFirstAcceptance, - "CLI-first acceptance refuses final e2e", - /DISASMER_PUBLIC_RELEASE_DRYRUN_E2E[\s\S]*does not run final public-release e2e/ -); -expect( - cliFirstAcceptance, - "CLI-first acceptance refuses final evidence", - /DISASMER_PUBLIC_RELEASE_DRYRUN_FINAL[\s\S]*does not run final public-release evidence/ -); -expect( - cliFirstAcceptance, - "CLI-first acceptance composes public API contracts", - /node scripts\/cli-output-mode-smoke\.js[\s\S]*node scripts\/cli-login-smoke\.js[\s\S]*node scripts\/cli-error-exit-smoke\.js[\s\S]*node scripts\/cli-browser-login-flow-smoke\.js/ -); -expect( - cliFirstAcceptance, - "CLI-first acceptance composes service boundary checks", - /node scripts\/wasmtime-assignment-smoke\.js[\s\S]*node scripts\/cli-local-run-smoke\.js/ -); -expect( - cliFirstAcceptance, - "CLI-first acceptance composes self-hosted checks", - /node scripts\/self-hosted-coordinator-smoke\.js/ -); -expect( - cliFirstAcceptance, - "CLI-first acceptance composes debug and artifact checks", - /node scripts\/vscode-f5-smoke\.js[\s\S]*node scripts\/artifact-download-smoke\.js[\s\S]*node scripts\/artifact-export-smoke\.js/ -); -expect( - cliFirstAcceptance, - "CLI-first acceptance composes DAP checks", - /node scripts\/dap-smoke\.js/ -); -expect( - cliFirstAcceptance, - "CLI-first acceptance can include private hosted gate", - /DISASMER_CLI_FIRST_INCLUDE_PRIVATE[\s\S]*scripts\/acceptance-private\.sh/ -); -assert.doesNotMatch( - cliFirstAcceptance, - /node scripts\/public-release-dryrun-e2e\.js|node scripts\/public-release-dryrun-final-evidence\.js/, - "CLI-first non-e2e gate must not invoke final public release e2e or final evidence verifier" -); - -for (const [name, pattern] of [ - ["top-level version metadata", /#\[command\([\s\S]*name = "disasmer"[\s\S]*version[\s\S]*arg_required_else_help = true[\s\S]*\)\]/], - ["top-level primary workflow help", /after_help = "Primary workflow:[\s\S]*disasmer login --browser[\s\S]*disasmer node attach; disasmer-node --worker[\s\S]*Disasmer: Launch Virtual Process[\s\S]*Hosted account creation happens in the browser login flow\."/], - ["top-level logout command", /enum Commands[\s\S]*Logout\(AuthLogoutArgs\)[\s\S]*Auth \{/], - ["login non-interactive flag", /struct LoginArgs[\s\S]*non_interactive: bool/], - ["run non-interactive flag", /struct RunArgs[\s\S]*non_interactive: bool/], - ["stored CLI session model", /struct StoredCliSession[\s\S]*cli_session_credential_kind[\s\S]*provider_tokens_exposed_to_cli[\s\S]*provider_tokens_sent_to_nodes/], - ["read CLI session helper", /fn read_cli_session\(project: &Path\) -> Result>/], - ["write CLI session helper", /fn write_cli_session\(project: &Path, session: &StoredCliSession\) -> Result/], - ["session source fallback", /fn session_from_sources\(project: &Path\) -> Result[\s\S]*read_cli_session\(project\)\?\.is_some\(\)/], - ["browser login writes local CLI session", /local_cli_session_file_written[\s\S]*write_cli_session\(&cwd, &stored_session\)\?/], - ["browser login does not persist provider tokens", /provider_tokens_persisted_locally:\s*false/], - ["non-interactive auth report", /fn non_interactive_auth_machine_error[\s\S]*browser_opened[\s\S]*false/], - ["human report renderer", /fn human_report\(value: &Value\) -> String/], - ["shared report emitter", /fn emit_report\(report: &T, json_output: bool\) -> Result<\(\)>/], - ["doctor command", /Doctor\(DoctorArgs\)/], - ["doctor node readiness summary", /fn node_readiness_summary[\s\S]*ready_to_attach[\s\S]*explicit_attach_required[\s\S]*missing_local_dependencies[\s\S]*next_actions/], - ["auth status command", /enum AuthCommands[\s\S]*Status\(AuthStatusArgs\)/], - ["auth logout command", /enum AuthCommands[\s\S]*Logout\(AuthLogoutArgs\)/], - ["key lifecycle commands", /enum KeyCommands[\s\S]*Add\(KeyAddArgs\)[\s\S]*List\(KeyListArgs\)[\s\S]*Revoke\(KeyRevokeArgs\)/], - ["project commands", /enum ProjectCommands[\s\S]*Init\(ProjectInitArgs\)[\s\S]*Status\(ProjectStatusArgs\)[\s\S]*List\(ProjectListArgs\)[\s\S]*Select\(ProjectSelectArgs\)/], - ["inspect command", /Inspect\(BundleInspectArgs\)/], - ["build command", /Build\(BuildArgs\)/], - ["node lifecycle commands", /enum NodeCommands[\s\S]*Attach\(AttachArgs\)[\s\S]*Enroll\(NodeEnrollArgs\)[\s\S]*List\(NodeListArgs\)[\s\S]*Status\(NodeStatusArgs\)[\s\S]*Revoke\(NodeRevokeArgs\)/], - ["process commands", /enum ProcessCommands[\s\S]*Status\(ProcessStatusArgs\)[\s\S]*Restart\(ProcessRestartArgs\)[\s\S]*Cancel\(ProcessCancelArgs\)/], - ["task lifecycle commands", /enum TaskCommands[\s\S]*List\(TaskListArgs\)[\s\S]*Restart\(TaskRestartArgs\)/], - ["logs command", /Logs\(LogsArgs\)/], - ["artifact commands", /enum ArtifactCommands[\s\S]*List\(ArtifactListArgs\)[\s\S]*Download\(ArtifactDownloadArgs\)[\s\S]*Export\(ArtifactExportArgs\)/], - ["DAP command", /Dap\(DapArgs\)/], - ["debug attach command", /enum DebugCommands[\s\S]*Attach\(DebugAttachArgs\)/], - ["quota command", /enum QuotaCommands[\s\S]*Status\(QuotaStatusArgs\)/], - ["admin commands", /enum AdminCommands[\s\S]*Status\(AdminStatusArgs\)[\s\S]*Bootstrap\(AdminBootstrapArgs\)[\s\S]*RevokeNode\(NodeRevokeArgs\)[\s\S]*StopProcess\(ProcessCancelArgs\)[\s\S]*SuspendTenant\(AdminSuspendTenantArgs\)/], -]) { - expect(cli, name, pattern); -} - -for (const [name, pattern] of [ - ["CLI parse coverage", /fn cli_first_mvp_command_surface_parses\(\)/], - ["CLI primary workflow help coverage", /fn top_level_help_exposes_primary_workflow_without_auth\(\)/], - ["CLI non-interactive run auth coverage", /fn non_interactive_run_without_session_requires_explicit_auth_or_local\(\)/], - ["CLI non-interactive browser login coverage", /fn browser_login_non_interactive_fails_before_opening_browser\(\)/], - ["CLI stored browser session coverage", /fn stored_browser_login_session_omits_provider_token_values\(\)/], - ["CLI auth status session-file coverage", /fn auth_status_reads_stored_cli_session_without_provider_tokens\(\)/], - ["CLI auth status account privacy coverage", /fn auth_status_queries_coordinator_account_state_without_private_moderation_details\(\)/], - ["CLI version coverage", /fn top_level_version_is_available\(\)/], - ["CLI JSON parse coverage", /fn cli_first_json_mode_parses_for_primary_commands\(\)/], - ["CLI human output coverage", /fn human_report_is_text_not_json\(\)/], - ["CLI key lifecycle coverage", /fn key_lifecycle_reports_project_scoped_agent_credentials\(\)/], - ["CLI agent workflow actor coverage", /fn run_with_agent_public_key_sends_attributable_workflow_actor\(\)/], - ["CLI node revoke coverage", /fn node_revoke_reports_scoped_credential_revocation\(\)/], - ["CLI admin public API coverage", /fn admin_status_and_suspend_use_public_coordinator_api\(\)/], - ["CLI admin bootstrap coverage", /fn admin_bootstrap_reports_self_hosted_cli_only_path\(\)/], - ["CLI debug attach coverage", /fn debug_attach_reports_public_authorization\(\)/], - ["doctor unchecked reachability coverage", /fn doctor_reports_unchecked_coordinator_reachability_without_config\(\)/], - ["doctor ping reachability coverage", /fn doctor_pings_configured_coordinator\(\)/], - ["project local config coverage", /fn project_init_select_and_status_use_local_project_config\(\)/], - ["project init public create coverage", /fn project_init_uses_public_create_before_writing_local_config\(\)/], - ["project public API list/select coverage", /fn project_list_and_select_use_public_api_without_website\(\)/], - ["project coordinator status coverage", /fn project_status_queries_public_coordinator_state\(\)/], - ["run coordinator active-process coverage", /fn run_contacts_configured_coordinator_and_reports_active_process_conflicts\(\)/], - ["CLI error classifier coverage", /fn cli_error_classifier_distinguishes_mvp_failure_categories\(\)/], - ["CLI command exit-code coverage", /fn command_report_exit_code_marks_command_failures_only\(\)/], - ["CLI top-level logout alias coverage", /fn top_level_logout_alias_removes_only_cli_session_state\(\)/], - ["CLI confirmation gate helper", /fn confirmation_required_report[\s\S]*coordinator_request_sent[\s\S]*confirmation_required/], - ["CLI mutating confirmation coverage", /fn mutating_commands_require_yes_before_side_effects\(\)/], - ["CLI run rejection category coverage", /fn run_rejection_reports_machine_readable_error_category\(\)/], - ["CLI locality failure classifier", /fn classify_cli_error_message\(message: &str\)[\s\S]*message_mentions_locality_failure\(&message\)[\s\S]*return "connectivity"/], - ["CLI locality failure report helper", /fn task_locality_failure_from_reason\(reason: &Value\) -> Value[\s\S]*coordinator_bulk_relay_used[\s\S]*safe_next_actions/], - ["CLI locality failure human output", /fn push_task_locality_failures\(lines: &mut Vec, tasks: &\[Value\]\)[\s\S]*locality \{task_name\}/], - ["node attach auto-detection coverage", /fn node_attach_detects_and_accepts_capability_overrides\(\)[\s\S]*detection\.auto_detected[\s\S]*command_backend[\s\S]*source_provider_backends/], - ["node attach grant disclosure coverage", /fn node_attach_discloses_dangerous_capability_grants\(\)/], - ["node enroll public API grant coverage", /fn node_enroll_reports_short_lived_public_api_grant\(\)/], - ["quota local status coverage", /fn quota_status_uses_project_config_and_generic_public_limits\(\)/], - ["quota coordinator usage coverage", /fn quota_status_queries_public_coordinator_usage\(\)/], - ["task event summary coverage", /fn process_task_log_and_artifact_reports_summarize_task_events\(\)/], - ["task locality failure summary coverage", /fn process_task_log_and_artifact_reports_summarize_task_events\(\)[\s\S]*source snapshot unavailable and direct connectivity unavailable[\s\S]*locality_failure/], - ["log secret redaction coverage", /fn log_and_task_reports_redact_secret_like_values\(\)/], - ["artifact download/export report coverage", /fn artifact_download_and_export_reports_expose_safe_session_boundaries\(\)/], - ["process control report coverage", /fn process_restart_cancel_and_abort_reports_expose_control_boundaries\(\)/], - ["task restart report coverage", /fn task_restart_reports_clean_boundary_requirements\(\)/], - ["build no full repo upload coverage", /fn build_command_reuses_bundle_inspection_without_full_repo_upload\(\)/], - ["bundle rebuild restart compatibility coverage", /fn bundle_rebuild_after_source_edit_keeps_restart_compatibility_contract\(\)/], - ["inspect missing environment coverage", /fn bundle_inspect_reports_missing_environment_references_before_schedule\(\)/], - ["inspect source provider override coverage", /fn bundle_inspect_reports_source_provider_overrides_before_schedule\(\)/], - ["build missing environment gate coverage", /fn build_blocks_before_schedule_on_missing_environment_reference\(\)/], - ["safe coordinator-required plans", /fn node_enroll_and_process_commands_have_safe_plan_without_coordinator\(\)/], -]) { - expect(cli, name, pattern); -} - -expect( - browserLoginFlowSmoke, - "browser login smoke checks session file", - /provider_tokens_persisted_locally[\s\S]*path\.join\(project, "\.disasmer", "session\.json"\)[\s\S]*\["auth", "status", "--json"\]/ -); -expect( - browserLoginFlowSmoke, - "browser login smoke rejects provider token persistence", - /assert\.doesNotMatch\([\s\S]*access_token\|refresh_token\|id_token/ -); -expect( - nodeAttachSmoke, - "node attach smoke verifies auto-detection evidence", - /plan\.detection\.auto_detected[\s\S]*plan\.detection\.command_backend[\s\S]*source_provider_backends/ -); -expect( - nodeAttachSmoke, - "node attach smoke verifies policy-limited grant disclosures", - /grant_disclosures\.length > 0[\s\S]*coordinator_policy_limited === true[\s\S]*native_command_execution[\s\S]*source_access/ -); -for (const [name, pattern] of [ - ["DAP smoke verifies source request", /client\.send\("source"[\s\S]*assert\.match\(source\.content, \/build_main\//], - ["DAP smoke rejects synthetic source stepping", /client\.send\("next"[\s\S]*client\.failure\(step, "next"\)[\s\S]*source stepping is not yet available[\s\S]*synthetic step/], - ["DAP smoke verifies DAP variables scopes", /Source Locals[\s\S]*Wasm Frame Locals[\s\S]*Task Args and Handles[\s\S]*Disasmer Runtime/], - ["DAP smoke reports unavailable source locals truthfully", /unavailable-local-diagnostic[\s\S]*cannot be inspected/], - ["DAP smoke reports unavailable Wasm frame locals truthfully", /wasm-local-diagnostic[\s\S]*did not report inspectable Wasm frame locals/], - ["DAP smoke verifies real local-services all-stop", /runtimeBackend: "local-services"[\s\S]*allThreadsStopped, true[\s\S]*confirmed by every active participant/], - ["DAP smoke verifies coordinator checkpoint refusal", /client\.send\("restartFrame"[\s\S]*checkpoint boundary\|still active[\s\S]*whole virtual-process restart/], -]) { - expect(dapSmoke, name, pattern); -} - -expect( - coordinator, - "coordinator whole-process cancellation coverage", - /fn service_cancels_whole_process_and_blocks_new_task_launches\(\)/ -); -expect( - coordinator, - "coordinator single active process coverage", - /fn service_rejects_second_active_process_unless_restarting_same_process\(\)/ -); -expect( - coordinator, - "coordinator agent key lifecycle coverage", - /fn service_manages_project_scoped_agent_public_keys\(\)/ -); -expect( - coordinator, - "coordinator agent workflow dispatch coverage", - /fn service_runs_agent_workflows_with_scoped_key_attribution\(\)/ -); -expect( - coordinator, - "coordinator agent workflow authorization", - /authorize_agent_project_run\([\s\S]*project:run/ -); -expect( - coordinator, - "coordinator agent workflow actor fields", - /pub struct WorkflowActor[\s\S]*agent: Option[\s\S]*public_key_fingerprint: Option[\s\S]*authenticated_without_browser/ -); -expect( - coordinator, - "coordinator node revoke coverage", - /fn service_revokes_node_credentials_and_live_descriptors\(\)/ -); -expect( - coordinator, - "coordinator public admin suspension coverage", - /fn service_reports_and_enforces_public_admin_tenant_suspension\(\)/ -); -expect( - coordinator, - "coordinator debug attach coverage", - /fn service_authorizes_debug_attach_through_public_api\(\)/ -); -expect( - coordinator, - "coordinator task restart boundary coverage", - /fn service_reports_task_restart_boundary_through_public_api\(\)/ -); -for (const [name, pattern] of [ - ["task completion placement field", /pub struct TaskCompletionEvent[\s\S]*placement: Option/], - ["task placement state", /task_placements/], - ["task completion retains placement", /event\.placement = self\.task_placements\.remove/], -]) { - expect(coordinator, `coordinator task events retain placement reasons: ${name}`, pattern); -} -expect( - coordinator, - "coordinator workflow spawn metering", - /struct MeterKey[\s\S]*kind: LimitKind[\s\S]*window: u64[\s\S]*fn can_charge_workflow_spawn[\s\S]*self\.can_charge\(tenant, project, LimitKind::Spawn, 1, now_epoch_seconds\)[\s\S]*fn charge_workflow_spawn[\s\S]*self\.charge\(tenant, project, LimitKind::Spawn, 1, now_epoch_seconds\)[\s\S]*charged_spawns/ -); -expect( - coordinator, - "coordinator spawn quota before work coverage", - /fn service_checks_spawn_quota_before_process_or_task_work_starts\(\)[\s\S]*quota\.set_workflow_limits\(ResourceLimits[\s\S]*LimitKind::Spawn[\s\S]*compile-linux-denied[\s\S]*active_tasks\.contains[\s\S]*other-project[\s\S]*fn project_quota_resets_at_the_configured_window_boundary\(\)[\s\S]*set_server_time\(59\)[\s\S]*set_server_time\(60\)/ -); -expect( - coordinator, - "public debug operation audit event", - /pub struct DebugAuditEvent[\s\S]*charged_debug_read_bytes[\s\S]*used_debug_read_bytes/ -); -expect( - coordinator, - "public debug operation metering", - /record_debug_audit_event\([\s\S]*charge_debug_read\([\s\S]*&tenant[\s\S]*&project[\s\S]*DEBUG_CONTROL_READ_BYTES[\s\S]*used_debug_read_bytes\(&tenant, &project, now_epoch_seconds\)/ -); -expect( - cli, - "CLI surfaces debug audit and quota fields", - /debug_reads_quota_limited[\s\S]*charged_debug_read_bytes[\s\S]*used_debug_read_bytes/ -); -expect( - cli, - "CLI sends agent workflow actor fields", - /fn add_workflow_actor_fields[\s\S]*actor_agent[\s\S]*agent_public_key_fingerprint/ -); -expect( - cli, - "CLI exposes node attach detection evidence", - /struct NodeAttachDetectionEvidence[\s\S]*command_backend[\s\S]*container_backend[\s\S]*source_provider_backends[\s\S]*manual_capability_overrides[\s\S]*os_arch_capabilities_require_manual_flags/ -); -expect( - cli, - "CLI renders node attach detection evidence", - /fn push_node_attach_detection[\s\S]*command backend[\s\S]*container backend[\s\S]*source providers[\s\S]*capability overrides/ -); -expect( - cli, - "CLI exposes node attach grant disclosures", - /struct CapabilityGrantDisclosure[\s\S]*coordinator_policy_limited[\s\S]*fn capability_grant_disclosures/ -); -expect( - cli, - "CLI exposes artifact download grant disclosures", - /fn artifact_download_grant_disclosures[\s\S]*"grant": "artifact_download"[\s\S]*"coordinator_policy_limited": true[\s\S]*"authorization_required": true[\s\S]*"guessable_public_url": false[\s\S]*"cross_tenant_reuse_allowed": false[\s\S]*"unauthorized_project_reuse_allowed": false/ -); -expect( - cli, - "CLI exposes normalized node enrollment grants", - /fn node_enroll_report[\s\S]*create_node_enrollment_grant[\s\S]*enrollment_grant[\s\S]*private_website_required[\s\S]*fn node_enrollment_grant_summary[\s\S]*short_lived[\s\S]*node_credentials_separate_from_user_session/ -); -expect( - cli, - "CLI exposes task placement reasons", - /fn task_summaries[\s\S]*node_placement[\s\S]*reasons[\s\S]*explanation_available/ -); -expect( - cli, - "CLI project init report current-directory link", - /fn project_init_report[\s\S]*current_directory_link[\s\S]*links_current_directory[\s\S]*safe_defaults[\s\S]*coordinator_create_before_local_write/ -); -expect( - cli, - "CLI project init writes config after public create", - /let coordinator_response = if let Some\(coordinator\)[\s\S]*"type": "create_project"[\s\S]*coordinator_session_requests = session\.requests\(\)[\s\S]*write_project_config\(&cwd, &config\)\?/ -); -expect( - cli, - "CLI project init renders current-directory link", - /current_directory_link[\s\S]*current directory linked: true[\s\S]*current directory config/ -); -expect( - cli, - "CLI project list/select report public API boundary", - /fn project_list_report[\s\S]*public_coordinator_api[\s\S]*private_website_required[\s\S]*fn project_select_report[\s\S]*project_config_written[\s\S]*private_website_required/ -); -expect( - cli, - "CLI project select writes config after coordinator response", - /fn project_select_report[\s\S]*let coordinator_response = if let Some\(coordinator\)[\s\S]*let request = authenticated_or_local_trusted_request\([\s\S]*"type": "select_project"[\s\S]*Some\(session\.request\(request\)\?\)[\s\S]*write_project_config\(&cwd, &config\)\?/ -); -expect( - cli, - "CLI renders task placement reasons", - /fn push_task_placement_reasons[\s\S]*placement \{task_name\}: \{node\}/ -); -expect( - cli, - "CLI redacts secret-like log values", - /fn log_entries[\s\S]*redact_secret_like_text[\s\S]*secret_like_values_redacted[\s\S]*redacted_fields[\s\S]*fn redact_secret_like_text[\s\S]*access_token=[\s\S]*password=[\s\S]*bearer / -); -expect( - cli, - "CLI classifies machine-readable error categories", - /fn classify_cli_error_message[\s\S]*"authentication"[\s\S]*"authorization"[\s\S]*"quota"[\s\S]*"policy"[\s\S]*"capability"[\s\S]*"connectivity"[\s\S]*"environment"[\s\S]*"program"/ -); -expect( - cli, - "CLI exposes quota machine-error posture", - /fn cli_error_summary_for_category[\s\S]*resource_category[\s\S]*quota_error_resource_category[\s\S]*community_tier_language[\s\S]*community_tier_label[\s\S]*private_abuse_heuristics_exposed[\s\S]*fn quota_error_resource_category[\s\S]*resource limit exceeded for / -); -expect( - coordinator, - "coordinator exposes sanitized auth status API", - /AuthStatus \{[\s\S]*tenant: String[\s\S]*project: String[\s\S]*actor_user: String[\s\S]*AuthStatus \{[\s\S]*account_status: String[\s\S]*suspended: bool[\s\S]*disabled: bool[\s\S]*private_moderation_details_exposed: bool[\s\S]*signup_failure_details_exposed: bool/ -); -expect( - cli, - "CLI auth status queries public account state", - /fn coordinator_auth_status_summary[\s\S]*"type": "auth_status"[\s\S]*"private_moderation_details_exposed": false[\s\S]*"signup_failure_details_exposed": false/ -); -expect( - cli, - "CLI auth status does not expose raw private moderation response fields", - /fn auth_status_queries_coordinator_account_state_without_private_moderation_details[\s\S]*abuse_score[\s\S]*moderation_notes[\s\S]*!serialized\.contains\("abuse_score"\)[\s\S]*!serialized\.contains\("moderation_notes"\)/ -); -expect( - cli, - "CLI auth status reports inactive account states safely", - /fn auth_status_reports_disabled_deleted_and_manual_review_safely[\s\S]*account_status[\s\S]*disabled[\s\S]*deleted[\s\S]*manual_review[\s\S]*!serialized\.contains\("signup_policy_trace"\)/ -); -expect( - coordinatorLib, - "coordinator summarizes private account policy state safely", - /fn tenant_disabled\(&self, tenant: &TenantId\) -> bool[\s\S]*tenant:disabled[\s\S]*fn tenant_deleted\(&self, tenant: &TenantId\) -> bool[\s\S]*tenant:deleted[\s\S]*fn tenant_manual_review\(&self, tenant: &TenantId\) -> bool[\s\S]*tenant:manual_review[\s\S]*fn account_policy_state\(&self, tenant: &TenantId\) -> AccountPolicyState[\s\S]*"deleted"[\s\S]*"disabled"[\s\S]*"manual_review"[\s\S]*"account or tenant is pending hosted review"/ -); -expect( - cli, - "CLI renders sanitized account status", - /coordinator_account_status[\s\S]*account status checked: \{checked\}[\s\S]*account status[\s\S]*private moderation details exposed/ -); -expect( - cli, - "CLI renders community tier wording", - /push_string_field\(&mut lines, value, "quota_tier", "quota tier"\)[\s\S]*community_tier_label[\s\S]*quota tier: \{tier\}/ -); -assert.doesNotMatch(cli, /free[- ]tier/i, "CLI source should use community tier wording"); -expect( - cli, - "CLI reports stable error-code contract", - /fn cli_error_exit_code[\s\S]*"authentication" => 20[\s\S]*"authorization" => 21[\s\S]*"quota" => 22[\s\S]*"policy" => 23[\s\S]*"capability" => 24[\s\S]*"connectivity" => 25[\s\S]*"environment" => 26[\s\S]*"program" => 27/ -); -expect( - cli, - "CLI attaches machine errors to run and task reports", - /run_start_summary[\s\S]*"machine_error": machine_error/ -); -expect( - cli, - "CLI attaches machine errors to task failures", - /task_failure_machine_error[\s\S]*cli_error_summary_with_default/ -); -expect( - cli, - "CLI applies process exit code after printing command failure report", - /fn emit_report[\s\S]*apply_command_report_exit_code[\s\S]*std::process::exit\(exit_code\)[\s\S]*fn human_report/ -); -expect( - cli, - "CLI applies artifact nested failure exit codes", - /fn apply_command_report_exit_code[\s\S]*"\/download_session\/machine_error"[\s\S]*"\/export_plan\/machine_error"[\s\S]*"\/local_export\/machine_error"[\s\S]*"\/local_export\/download_session\/machine_error"[\s\S]*"\/local_export\/stream\/machine_error"/ -); -expect( - cli, - "CLI wraps fallible main with classified exit", - /fn main\(\)[\s\S]*cli_error_summary\(&message\)[\s\S]*std::process::exit\(exit_code\)/ -); -expect( - cli, - "CLI parses dangerous capability overrides", - /"host-filesystem"[\s\S]*Capability::HostFilesystem[\s\S]*"network"[\s\S]*Capability::Network[\s\S]*"secrets"[\s\S]*Capability::Secrets/ -); -expect( - cli, - "CLI exposes source provider diagnostics", - /source_provider_statuses[\s\S]*source_provider_selection[\s\S]*source_provider_unsupported/ -); -expect( - cli, - "CLI exposes environment pre-schedule diagnostics", - /environment_diagnostics_for_inputs[\s\S]*diagnose_environment_references[\s\S]*missing_environment/ -); -expect( - cli, - "CLI bundle metadata exposes MVP build facets", - /BundleIdentityInputs[\s\S]*entrypoints[\s\S]*default_entrypoint[\s\S]*source_transfer_policy[\s\S]*wasm_source_proxy_digest[\s\S]*task_abi_digest/ -); -expect( - cli, - "CLI bundle coverage verifies Dockerfile and debug metadata", - /fn bundle_inspect_discovers_environments_selected_inputs_and_source_providers\(\)[\s\S]*envs\/docker\/Dockerfile[\s\S]*task_metadata[\s\S]*source_metadata[\s\S]*debug_metadata/ -); -expect( - cli, - "CLI build coverage verifies inspectable metadata", - /fn build_command_reuses_bundle_inspection_without_full_repo_upload\(\)[\s\S]*wasm_code[\s\S]*task_metadata[\s\S]*source_metadata[\s\S]*debug_metadata/ -); -expect( - cli, - "CLI build coverage verifies large input handle posture", - /large_input_policy[\s\S]*selected_inputs_are_content_digests[\s\S]*selected_input_bytes_included[\s\S]*silent_task_argument_serialization[\s\S]*supported_handle_types/ -); -expect( - cli, - "CLI bundle coverage verifies restart compatibility metadata", - /restart_compatibility[\s\S]*source_edits_can_restart_from_clean_task_boundary[\s\S]*requires_clean_checkpoint_boundary[\s\S]*compares_task_abi[\s\S]*incompatible_changes_require_whole_process_restart/ -); -expect( - cli, - "CLI blocks build before scheduling unsafe inputs", - /blocked_before_schedule[\s\S]*scheduled_work[\s\S]*false/ -); -expect( - cli, - "CLI artifact export writes explicit local bytes from stream content", - /fn artifact_export_local_write_followup[\s\S]*open_artifact_download_stream[\s\S]*content_base64[\s\S]*std::fs::write/ -); -expect( - cli, - "CLI artifact export keeps content out of reports", - /fn artifact_stream_summary[\s\S]*content_material_returned_in_report[\s\S]*false/ -); -expect( - cli, - "CLI artifact export carries download grant disclosure", - /fn artifact_export_local_write_followup[\s\S]*artifact_download_grant_disclosures[\s\S]*"grant_disclosures": grant_disclosures/ -); -expect( - cli, - "CLI exposes admin bootstrap self-hosted path", - /fn admin_bootstrap_report[\s\S]*self_hosted_cli_only[\s\S]*bootstrap_sequence[\s\S]*create_node_enrollment_grant[\s\S]*attach_worker_node[\s\S]*inspect_status_logs_artifacts[\s\S]*revoke_access/ -); -expect( - coordinator, - "coordinator reverse-streams verified retained artifact bytes through a bounded spool", - /struct ArtifactReverseTransfer[\s\S]*spool: tempfile::NamedTempFile[\s\S]*received_bytes: u64[\s\S]*delivered_offset: u64[\s\S]*handle_open_artifact_download_stream[\s\S]*read_exact\(&mut content\)[\s\S]*charge_download\([\s\S]*delivered_offset = end[\s\S]*BASE64_STANDARD\.encode\(content\)/ -); - -for (const [name, pattern] of [ - ["agent --json flag", /struct AgentEnrollArgs[\s\S]*#\[arg\(long\)\]\s*json: bool/], - ["bundle inspect --json flag", /struct BundleInspectArgs[\s\S]*#\[arg\(long\)\]\s*json: bool/], - ["build --json flag", /struct BuildArgs[\s\S]*#\[arg\(long\)\]\s*json: bool/], - ["run --json flag", /struct RunArgs[\s\S]*#\[arg\(long\)\]\s*json: bool/], - ["node attach --json flag", /struct AttachArgs[\s\S]*#\[arg\(long\)\]\s*json: bool/], - ["DAP plan --json flag", /struct DapArgs[\s\S]*#\[arg\(long\)\]\s*json: bool/], - ["shared scope --json flag", /struct CliScopeArgs[\s\S]*#\[arg\(long\)\]\s*json: bool/], -]) { - expect(cli, name, pattern); -} - -for (const [name, pattern] of [ - ["human default assertion", /default output should be human-readable text, not JSON/], - [ - "login plan JSON mode", - /"login",\s*"--plan",\s*"--coordinator",\s*"https:\/\/coord\.example\.test",\s*"--json"/, - ], - ["doctor human mode", /\["doctor"\]/], - ["doctor reachability JSON mode", /doctorJson\.coordinator_reachability\.status/], - ["doctor node readiness JSON mode", /doctorJson\.node_readiness_summary\.status[\s\S]*explicit_attach_required[\s\S]*missing_local_dependencies/], - ["bundle inspect JSON mode", /\["bundle", "inspect", "--project", project, "--json"\]/], - ["bundle inspect large input JSON mode", /large_input_policy[\s\S]*SourceSnapshot/], - ["bundle inspect restart compatibility JSON mode", /restart_compatibility[\s\S]*source_edits_can_restart_from_clean_task_boundary/], - ["auth expiry posture", /token_expiry_posture[\s\S]*expires_at/], -]) { - expect(outputModeSmoke, name, pattern); -} - -for (const [name, pattern] of [ - ["environment build rejection exit code", /environmentFailure[\s\S]*assert\.strictEqual\(environmentFailure\.code, 26/], - ["environment rejection machine category", /environmentReport\.machine_error\.category, "environment"/], - ["fake coordinator quota rejection", /quota unavailable: resource limit exceeded for api_calls/], - ["run uses JSON mode", /"run"[\s\S]*"--json"/], - ["actual quota exit code", /assert\.strictEqual\(result\.code, 22/], - ["actual quota resource-category assertion", /machine_error\.resource_category, "api_calls"/], - ["actual quota community-tier label assertion", /machine_error\.community_tier_label,[\s\S]*"community tier"/], - ["actual quota abuse-heuristics assertion", /machine_error\.private_abuse_heuristics_exposed,[\s\S]*false/], - ["capability rejection exit code", /capabilityFailure[\s\S]*assert\.strictEqual\(capabilityFailure\.result\.code, 24/], - ["capability rejection next action", /attach a node with the required capabilities/], - ["node policy rejection exit code", /nodePolicyFailure[\s\S]*assert\.strictEqual\(nodePolicyFailure\.result\.code, 23/], - ["node policy rejection next action", /check coordinator policy for this action/], - ["program task failure category", /programReport\.tasks\[0\]\.machine_error\.category, "program"/], - ["artifact download rejection exit code", /artifactDownload[\s\S]*assert\.strictEqual\(artifactDownload\.result\.code, 21/], - ["artifact export rejection exit code", /artifactExport[\s\S]*assert\.strictEqual\(artifactExport\.result\.code, 25/], - ["exit-code application in JSON", /process_exit_code_applied[\s\S]*true/], -]) { - expect(errorExitSmoke, name, pattern); -} - -console.log("CLI-first contract smoke passed"); diff --git a/scripts/cli-happy-path-live-smoke.js b/scripts/cli-happy-path-live-smoke.js index afd42df..1120839 100644 --- a/scripts/cli-happy-path-live-smoke.js +++ b/scripts/cli-happy-path-live-smoke.js @@ -21,7 +21,7 @@ const { configurePodmanTestEnvironment } = require("./podman-test-env"); const repo = path.resolve(__dirname, ".."); configurePodmanTestEnvironment(repo); if ( - !process.env.DISASMER_PODMAN_NIX_SHELL && + !process.env.CLUSTERFLUX_PODMAN_NIX_SHELL && cp.spawnSync("podman", ["--version"], { stdio: "ignore" }).status !== 0 && cp.spawnSync("nix", ["--version"], { stdio: "ignore" }).status === 0 ) { @@ -30,7 +30,7 @@ if ( ["shell", "nixpkgs#podman", "--command", "node", __filename], { cwd: repo, - env: { ...process.env, DISASMER_PODMAN_NIX_SHELL: "1" }, + env: { ...process.env, CLUSTERFLUX_PODMAN_NIX_SHELL: "1" }, stdio: "inherit", } ); @@ -38,64 +38,66 @@ if ( } const releaseRoot = path.resolve( - process.env.DISASMER_PUBLIC_RELEASE_DIR || + process.env.CLUSTERFLUX_PUBLIC_RELEASE_DIR || path.join(repo, "target/public-release-dryrun") ); const acceptanceRoot = path.join(repo, "target/acceptance"); const manifestPath = path.join(releaseRoot, "public-release-manifest.json"); const reportPath = path.join(acceptanceRoot, "cli-happy-path-live.json"); -const serviceEndpoint = "https://disasmer.michelpaulissen.com"; +const serviceEndpoint = "https://clusterflux.michelpaulissen.com"; const serviceAddr = - process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_SERVICE_ADDR || - "disasmer.michelpaulissen.com:443"; + process.env.CLUSTERFLUX_PUBLIC_RELEASE_DRYRUN_SERVICE_ADDR || + "clusterflux.michelpaulissen.com:443"; const browserOpenCommand = - process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_BROWSER_OPEN_COMMAND; -const enabled = process.env.DISASMER_CLI_HAPPY_PATH_LIVE === "1"; -const strictFullRelease = process.env.DISASMER_STRICT_FULL_RELEASE === "1"; -const strictVpsRestart = process.env.DISASMER_STRICT_VPS_RESTART === "1"; -const strictVpsHost = process.env.DISASMER_STRICT_VPS_HOST; -const strictVpsIdentity = process.env.DISASMER_STRICT_VPS_IDENTITY; + process.env.CLUSTERFLUX_PUBLIC_RELEASE_DRYRUN_BROWSER_OPEN_COMMAND; +const reuseSessionFile = + process.env.CLUSTERFLUX_CLI_HAPPY_PATH_REUSE_SESSION_FILE; +const enabled = process.env.CLUSTERFLUX_CLI_HAPPY_PATH_LIVE === "1"; +const strictFullRelease = process.env.CLUSTERFLUX_STRICT_FULL_RELEASE === "1"; +const strictVpsRestart = process.env.CLUSTERFLUX_STRICT_VPS_RESTART === "1"; +const strictVpsHost = process.env.CLUSTERFLUX_STRICT_VPS_HOST; +const strictVpsIdentity = process.env.CLUSTERFLUX_STRICT_VPS_IDENTITY; const strictServiceUnit = - process.env.DISASMER_STRICT_SERVICE_UNIT || - "disasmer-public-release-dryrun.service"; + process.env.CLUSTERFLUX_STRICT_SERVICE_UNIT || + "clusterflux-public-release-dryrun.service"; const strictSoakSeconds = Number( - process.env.DISASMER_STRICT_SOAK_SECONDS || "300" + process.env.CLUSTERFLUX_STRICT_SOAK_SECONDS || "300" ); const commands = []; function requireEnabled() { if (!enabled) { throw new Error( - "DISASMER_CLI_HAPPY_PATH_LIVE=1 is required because this runs the live CLI happy path" + "CLUSTERFLUX_CLI_HAPPY_PATH_LIVE=1 is required because this runs the live CLI happy path" ); } - if (!browserOpenCommand) { + if (!browserOpenCommand && !reuseSessionFile) { throw new Error( - "DISASMER_PUBLIC_RELEASE_DRYRUN_BROWSER_OPEN_COMMAND is required to complete the server-owned hosted browser login" + "CLUSTERFLUX_PUBLIC_RELEASE_DRYRUN_BROWSER_OPEN_COMMAND or CLUSTERFLUX_CLI_HAPPY_PATH_REUSE_SESSION_FILE is required" ); } if ( strictFullRelease && - !process.env.DISASMER_SECOND_TENANT_SESSION_FILE && - !process.env.DISASMER_SECOND_TENANT_EVIDENCE_FILE + !process.env.CLUSTERFLUX_SECOND_TENANT_SESSION_FILE && + !process.env.CLUSTERFLUX_SECOND_TENANT_EVIDENCE_FILE ) { throw new Error( - "DISASMER_STRICT_FULL_RELEASE=1 requires DISASMER_SECOND_TENANT_SESSION_FILE or DISASMER_SECOND_TENANT_EVIDENCE_FILE for the isolation proof" + "CLUSTERFLUX_STRICT_FULL_RELEASE=1 requires CLUSTERFLUX_SECOND_TENANT_SESSION_FILE or CLUSTERFLUX_SECOND_TENANT_EVIDENCE_FILE for the isolation proof" ); } - if (strictFullRelease && !process.env.DISASMER_EXPIRED_USER_SESSION_FILE) { + if (strictFullRelease && !process.env.CLUSTERFLUX_EXPIRED_USER_SESSION_FILE) { throw new Error( - "DISASMER_STRICT_FULL_RELEASE=1 requires DISASMER_EXPIRED_USER_SESSION_FILE" + "CLUSTERFLUX_STRICT_FULL_RELEASE=1 requires CLUSTERFLUX_EXPIRED_USER_SESSION_FILE" ); } if (strictFullRelease && !strictVpsRestart) { throw new Error( - "DISASMER_STRICT_FULL_RELEASE=1 requires DISASMER_STRICT_VPS_RESTART=1" + "CLUSTERFLUX_STRICT_FULL_RELEASE=1 requires CLUSTERFLUX_STRICT_VPS_RESTART=1" ); } if (strictVpsRestart && (!strictVpsHost || !strictVpsIdentity)) { throw new Error( - "strict VPS restart requires DISASMER_STRICT_VPS_HOST and DISASMER_STRICT_VPS_IDENTITY" + "strict VPS restart requires CLUSTERFLUX_STRICT_VPS_HOST and CLUSTERFLUX_STRICT_VPS_IDENTITY" ); } if ( @@ -103,7 +105,7 @@ function requireEnabled() { strictSoakSeconds < 120 || strictSoakSeconds > 1800 ) { - throw new Error("DISASMER_STRICT_SOAK_SECONDS must be an integer from 120 to 1800"); + throw new Error("CLUSTERFLUX_STRICT_SOAK_SECONDS must be an integer from 120 to 1800"); } } @@ -242,6 +244,40 @@ function sendHostedControl(payload) { }); } +function sendHostedControlDroppingResponse(payload) { + const body = Buffer.from( + JSON.stringify(coordinatorWireRequest(payload, "strict-live-dropped-response")) + ); + const url = new URL("/api/v1/control", serviceEndpoint); + return new Promise((resolve, reject) => { + let settled = false; + const request = https.request( + url, + { + method: "POST", + headers: { + "content-type": "application/json", + "content-length": body.length, + }, + timeout: 30_000, + }, + (response) => { + settled = true; + const statusCode = response.statusCode; + response.destroy(); + resolve({ status_code: statusCode, response_body_consumed: false }); + } + ); + request.on("timeout", () => + request.destroy(new Error("hosted dropped-response request timed out")) + ); + request.on("error", (error) => { + if (!settled) reject(error); + }); + request.end(body); + }); +} + function assertDenied(response, pattern, label) { assert.strictEqual(response.type, "error", `${label}: ${JSON.stringify(response)}`); assert.match(response.message, pattern, `${label}: ${JSON.stringify(response)}`); @@ -253,7 +289,7 @@ function assertDenied(response, pattern, label) { } function readNodeCredential(projectDir, node) { - const directory = path.join(projectDir, ".disasmer", "nodes"); + const directory = path.join(projectDir, ".clusterflux", "nodes"); for (const file of fs.readdirSync(directory)) { if (!file.endsWith(".json")) continue; const absolute = path.join(directory, file); @@ -312,7 +348,7 @@ function strictInfrastructureSample(projectDir) { const serviceDisk = Number( run( "ssh", - sshArgs("du", "-sb", "/var/lib/disasmer-public-release-dryrun") + sshArgs("du", "-sb", "/var/lib/clusterflux-public-release-dryrun") ) .trim() .split(/\s+/)[0] @@ -326,12 +362,12 @@ function strictInfrastructureSample(projectDir) { "--", "psql", "-d", - "disasmer", + "clusterflux", "-AtF," ), { input: - "SELECT pg_database_size('disasmer'), (SELECT count(*) FROM disasmer_tenants), (SELECT count(*) FROM disasmer_projects), (SELECT count(*) FROM disasmer_node_identities), (SELECT count(*) FROM disasmer_cli_sessions);\n", + "SELECT pg_database_size('clusterflux'), (SELECT count(*) FROM clusterflux_tenants), (SELECT count(*) FROM clusterflux_projects), (SELECT count(*) FROM clusterflux_node_identities), (SELECT count(*) FROM clusterflux_cli_sessions);\n", stdio: ["pipe", "pipe", "pipe"], } ) @@ -353,7 +389,7 @@ function strictInfrastructureSample(projectDir) { node_identities: database[3], cli_sessions: database[4], }, - local_node_state: directoryStats(path.join(projectDir, ".disasmer")), + local_node_state: directoryStats(path.join(projectDir, ".clusterflux")), }; } @@ -365,7 +401,7 @@ function binaryArchive(manifest) { const platform = `${os.platform()}-${os.arch()}`; const asset = manifest.assets.find( (candidate) => - candidate.name.startsWith("disasmer-public-binaries-") && + candidate.name.startsWith("clusterflux-public-binaries-") && candidate.name.includes(platform) ); assert(asset, `missing released binary archive for ${platform}`); @@ -375,8 +411,8 @@ function binaryArchive(manifest) { function publicRepoUrl(manifest) { const url = - process.env.DISASMER_PUBLIC_REPO_URL || - process.env.DISASMER_PUBLIC_REPO_REMOTE || + process.env.CLUSTERFLUX_PUBLIC_REPO_URL || + process.env.CLUSTERFLUX_PUBLIC_REPO_REMOTE || manifest.public_repo_url || manifest.public_repo_remote; assert(url, "public repository URL is required"); @@ -385,7 +421,7 @@ function publicRepoUrl(manifest) { } function stagePublicCheckout(manifest, checkout) { - const candidateTree = process.env.DISASMER_LIVE_PUBLIC_TREE; + const candidateTree = process.env.CLUSTERFLUX_LIVE_PUBLIC_TREE; if (candidateTree) { const source = path.resolve(candidateTree); assert.strictEqual( @@ -512,6 +548,12 @@ function rawTaskEvents(report) { return report?.events?.response?.events || []; } +function nodeDescriptor(status, node) { + return status?.response?.descriptors?.find( + (descriptor) => descriptor.id === node || descriptor.node === node + ); +} + function completedFlagship(events) { const completedDefinitions = new Set( events @@ -541,7 +583,7 @@ function contentAddressedArtifactId(event, logicalName) { } async function prepareLiveNodeCredentialSecurity({ - disasmer, + clusterflux, projectDir, scope, tenant, @@ -549,11 +591,11 @@ async function prepareLiveNodeCredentialSecurity({ suffix, }) { const node = `security-node-${suffix}`; - const grant = runJson(disasmer, ["node", "enroll", ...scope], { + const grant = runJson(clusterflux, ["node", "enroll", ...scope], { cwd: projectDir, }); const attach = runJson( - disasmer, + clusterflux, [ "node", "attach", @@ -667,18 +709,20 @@ async function prepareLiveNodeCredentialSecurity({ } async function runLiveAgentCredentialSecurity({ - disasmer, + clusterflux, projectDir, scope, sessionSecret, tenant, project, suffix, + bundle, + securityNode, }) { const agent = `security-agent-${suffix}`; const identity = agentIdentity("strict-live-agent", agent); const added = runJson( - disasmer, + clusterflux, ["key", "add", ...scope, "--agent", agent, "--public-key", identity.publicKey], { cwd: projectDir } ); @@ -747,15 +791,232 @@ async function runLiveAgentCredentialSecurity({ "forged agent credential" ); - const aborted = await sendHostedControl( - authenticatedRequest(sessionSecret, { - type: "abort_process", + const fakeEnvironmentDigest = sha256( + Buffer.from(`strict-debug-missing-participant-${suffix}`) + ); + const fakeCapabilityBody = { + ...securityNode.capability_body, + cached_environment_digests: [fakeEnvironmentDigest], + online: true, + }; + const fakeCapabilities = await sendHostedControl( + signedNodeRequest( + securityNode.node, + securityNode.identity, + "report_node_capabilities", + fakeCapabilityBody, + { nonce: `strict-agent-fake-capabilities-${suffix}` } + ) + ); + assert.strictEqual( + fakeCapabilities.type, + "node_capabilities_recorded", + JSON.stringify(fakeCapabilities) + ); + + const fakeTask = `missing-debug-participant-${suffix}`; + const fakeLaunchBody = { + type: "launch_task", + tenant, + project, + actor_agent: agent, + task_spec: { + tenant, + project, process: processId, + task_definition: "task_add_one", + task_instance: fakeTask, + dispatch: { + kind: "coordinator_node_wasm", + export: null, + abi: "task_v1", + }, + environment_id: "strict-debug-missing-participant", + environment: null, + environment_digest: fakeEnvironmentDigest, + required_capabilities: [], + dependency_cache: null, + source_snapshot: null, + required_artifacts: [], + args: [{ SmallJson: 41 }], + vfs_epoch: valid.epoch, + bundle_digest: bundle.digest, + }, + wait_for_node: false, + artifact_path: `/vfs/artifacts/${fakeTask}.txt`, + wasm_module_base64: bundle.moduleBase64, + }; + const fakeLaunch = await sendHostedControl( + signedAgentWorkflowRequest(identity, fakeLaunchBody, { + nonce: `strict-agent-fake-task-${suffix}`, }) ); - assert.strictEqual(aborted.type, "process_aborted", JSON.stringify(aborted)); + assert.strictEqual(fakeLaunch.type, "task_launched", JSON.stringify(fakeLaunch)); + assert.strictEqual(fakeLaunch.placement.node, securityNode.node); + + const mainTask = `ti:${processId}:main`; + const mainLaunchBody = { + type: "launch_task", + tenant, + project, + actor_agent: agent, + task_spec: { + tenant, + project, + process: processId, + task_definition: bundle.entryStableId, + task_instance: mainTask, + dispatch: { + kind: "coordinator_node_wasm", + export: bundle.entryExport, + abi: "entrypoint_v1", + }, + environment_id: null, + environment: null, + environment_digest: null, + required_capabilities: [], + dependency_cache: null, + source_snapshot: null, + required_artifacts: [], + args: [], + vfs_epoch: valid.epoch, + bundle_digest: bundle.digest, + }, + wait_for_node: true, + artifact_path: `/vfs/artifacts/${mainTask}-output.txt`, + wasm_module_base64: bundle.moduleBase64, + }; + const mainLaunch = await sendHostedControl( + signedAgentWorkflowRequest(identity, mainLaunchBody, { + nonce: `strict-agent-main-${suffix}`, + }) + ); + assert.strictEqual(mainLaunch.type, "main_launched", JSON.stringify(mainLaunch)); + assert.strictEqual(mainLaunch.actor.kind, "agent"); + assert.strictEqual(mainLaunch.actor.authenticated_without_browser, true); + + const freezeStartedAt = Date.now(); + const freeze = await sendHostedControl( + authenticatedRequest(sessionSecret, { + type: "create_debug_epoch", + process: processId, + stopped_task: fakeTask, + reason: "strict live missing debug participant injection", + }) + ); + assert.strictEqual(freeze.type, "debug_epoch", JSON.stringify(freeze)); + await delay(5500); + const partialFreeze = await sendHostedControl( + authenticatedRequest(sessionSecret, { + type: "inspect_debug_epoch", + process: processId, + epoch: freeze.epoch, + }) + ); + assert.strictEqual(partialFreeze.type, "debug_epoch_status"); + assert.strictEqual(partialFreeze.partially_frozen, true, JSON.stringify(partialFreeze)); + assert.strictEqual(partialFreeze.fully_frozen, false); + assert.strictEqual(partialFreeze.failed, true); + assert.match( + partialFreeze.failure_messages.join("; "), + /did not acknowledge frozen state within \d+ ms/ + ); + + const mismatchedDigest = sha256(Buffer.from(`strict-nested-mismatch-${suffix}`)); + const mismatchedChild = await sendHostedControl( + signedNodeRequest( + securityNode.node, + securityNode.identity, + "launch_child_task", + { + type: "launch_child_task", + tenant, + project, + process: processId, + node: securityNode.node, + parent_task: fakeTask, + task_spec: { + tenant, + project, + process: processId, + task_definition: "task_add_one", + task_instance: `${fakeTask}:child:mismatched-environment`, + dispatch: { + kind: "coordinator_node_wasm", + export: null, + abi: "task_v1", + }, + environment_id: "strict-nested-mismatch", + environment: null, + environment_digest: mismatchedDigest, + required_capabilities: [], + dependency_cache: null, + source_snapshot: null, + required_artifacts: [], + args: [{ SmallJson: 41 }], + vfs_epoch: valid.epoch, + bundle_digest: bundle.digest, + }, + wait_for_node: false, + artifact_path: `/vfs/artifacts/${fakeTask}-mismatched-child.txt`, + wasm_module_base64: bundle.moduleBase64, + }, + { nonce: `strict-nested-mismatch-${suffix}` } + ) + ); + const nestedEnvironmentMismatch = assertDenied( + mismatchedChild, + /environment|digest|compatible|placement|no node/i, + "nested environment mismatch" + ); + + const resume = await sendHostedControl( + authenticatedRequest(sessionSecret, { + type: "resume_debug_epoch", + process: processId, + epoch: freeze.epoch, + }) + ); + assert.strictEqual(resume.type, "debug_epoch", JSON.stringify(resume)); + await delay(1000); + const resumed = await sendHostedControl( + authenticatedRequest(sessionSecret, { + type: "inspect_debug_epoch", + process: processId, + epoch: freeze.epoch, + }) + ); + assert.strictEqual(resumed.type, "debug_epoch_status"); + const resumedAcknowledgements = resumed.acknowledgements.filter( + (acknowledgement) => acknowledgement.state === "running" + ); + assert(resumedAcknowledgements.length > 0, JSON.stringify(resumed)); + + const completed = await waitForCli( + "agent-authenticated coordinator main and child workflow completion", + () => + runJson(clusterflux, ["task", "list", ...scope, "--process", processId], { + cwd: projectDir, + }), + (tasks) => completedFlagship(rawTaskEvents(tasks)), + 120000 + ); + const completedEvents = rawTaskEvents(completed); + const concurrentCompileTasks = [ + ...new Set( + completedEvents + .filter( + (event) => + event.task_definition === "compile_linux" && + event.terminal_state === "completed" + ) + .map((event) => event.task) + ), + ]; + assert(concurrentCompileTasks.length >= 2, JSON.stringify(completedEvents)); + const revoked = runJson( - disasmer, + clusterflux, ["key", "revoke", ...scope, "--agent", agent, "--yes"], { cwd: projectDir } ); @@ -779,6 +1040,25 @@ async function runLiveAgentCredentialSecurity({ forged, body_modified: bodyModified, revoked: revokedUse, + workflow: { + process: processId, + main_launch: mainLaunch.type, + actor_kind: mainLaunch.actor.kind, + authenticated_without_browser: mainLaunch.actor.authenticated_without_browser, + flagship_completed: completedFlagship(completedEvents), + concurrent_compile_tasks: concurrentCompileTasks, + }, + partial_freeze: { + epoch: freeze.epoch, + elapsed_ms: Date.now() - freezeStartedAt, + partially_frozen: partialFreeze.partially_frozen, + fully_frozen: partialFreeze.fully_frozen, + warning: partialFreeze.failure_messages, + resumed_participants: resumedAcknowledgements.map( + (acknowledgement) => acknowledgement.task + ), + }, + nested_environment_mismatch: nestedEnvironmentMismatch, }; } @@ -790,8 +1070,8 @@ async function runSecondTenantIsolation({ firstArtifact, manifest, }) { - const file = process.env.DISASMER_SECOND_TENANT_SESSION_FILE; - const evidenceFile = process.env.DISASMER_SECOND_TENANT_EVIDENCE_FILE; + const file = process.env.CLUSTERFLUX_SECOND_TENANT_SESSION_FILE; + const evidenceFile = process.env.CLUSTERFLUX_SECOND_TENANT_EVIDENCE_FILE; if (!file && evidenceFile) { const evidenceBytes = fs.readFileSync(path.resolve(evidenceFile)); const previous = JSON.parse(evidenceBytes); @@ -908,7 +1188,7 @@ async function runSecondTenantIsolation({ } async function runLiveSoak({ - disasmer, + clusterflux, projectDir, scope, securityNode, @@ -917,7 +1197,7 @@ async function runLiveSoak({ return { executed: false, reason: "strict VPS measurement access not supplied" }; } const runReport = runJson( - disasmer, + clusterflux, ["run", "build", "--project", ".", "--json"], { cwd: projectDir } ); @@ -927,16 +1207,52 @@ async function runLiveSoak({ "soak coordinator main to park without a capable node", () => runJson( - disasmer, + clusterflux, ["process", "status", ...scope, "--process", processId], { cwd: projectDir } ), (status) => status.live_process?.main_state === "running" && - status.live_process?.main_wait_state === "waiting_for_node", + status.live_process?.main_wait_state === "waiting_for_task" && + status.current_task_count === 0 && + status.live_process?.connected_nodes?.length === 0, 120000 ); + const pollSecurityNode = (nonce) => + sendHostedControl( + signedNodeRequest( + securityNode.node, + securityNode.identity, + "poll_task_assignment", + { + type: "poll_task_assignment", + tenant: securityNode.capability_body.tenant, + project: securityNode.capability_body.project, + node: securityNode.node, + }, + { nonce } + ) + ); + const drainedPriorAssignments = []; + for (let drainIndex = 0; drainIndex < 16; drainIndex += 1) { + const poll = await pollSecurityNode( + `strict-soak-drain-${drainIndex}-${Date.now()}` + ); + assert.strictEqual(poll.type, "task_assignment", JSON.stringify(poll)); + if (poll.assignment === null) break; + assert.notStrictEqual( + poll.assignment.process, + processId, + "soak process unexpectedly received a task assignment" + ); + drainedPriorAssignments.push({ + process: poll.assignment.process, + task: poll.assignment.task, + }); + assert(drainIndex < 15, "pre-soak assignment drain did not quiesce"); + } + const startedAt = Date.now(); const deadline = startedAt + strictSoakSeconds * 1000; const samples = []; @@ -956,28 +1272,17 @@ async function runLiveSoak({ "node_capabilities_recorded", JSON.stringify(capabilities) ); - const poll = await sendHostedControl( - signedNodeRequest( - securityNode.node, - securityNode.identity, - "poll_task_assignment", - { - type: "poll_task_assignment", - tenant: securityNode.capability_body.tenant, - project: securityNode.capability_body.project, - node: securityNode.node, - }, - { nonce: `strict-soak-poll-${sampleIndex}-${Date.now()}` } - ) + const poll = await pollSecurityNode( + `strict-soak-poll-${sampleIndex}-${Date.now()}` ); assert.strictEqual(poll.type, "task_assignment", JSON.stringify(poll)); assert.strictEqual(poll.assignment, null); const status = runJson( - disasmer, + clusterflux, ["process", "status", ...scope, "--process", processId], { cwd: projectDir } ); - assert.strictEqual(status.live_process.main_wait_state, "waiting_for_node"); + assert.strictEqual(status.live_process.main_wait_state, "waiting_for_task"); samples.push(strictInfrastructureSample(projectDir)); sampleIndex += 1; if (Date.now() >= deadline) break; @@ -1001,7 +1306,7 @@ async function runLiveSoak({ "durable object counts grew during the parked-process soak" ); const aborted = runJson( - disasmer, + clusterflux, ["process", "abort", ...scope, "--process", processId, "--yes"], { cwd: projectDir } ); @@ -1016,6 +1321,7 @@ async function runLiveSoak({ main_state: parked.live_process.main_state, main_wait_state: parked.live_process.main_wait_state, }, + drained_prior_assignments: drainedPriorAssignments, signed_node_poll_cycles: samples.length, memory_spread_bytes: spread(currentMemory), service_disk_spread_bytes: spread(serviceDisk), @@ -1026,13 +1332,13 @@ async function runLiveSoak({ } async function revokeSecurityNode({ - disasmer, + clusterflux, projectDir, scope, securityNode, }) { const revoked = runJson( - disasmer, + clusterflux, ["node", "revoke", ...scope, "--node", securityNode.node, "--yes"], { cwd: projectDir } ); @@ -1054,10 +1360,41 @@ async function revokeSecurityNode({ } async function runLiveQuotaPreallocation({ sessionSecret, suffix }) { + const readSpawnQuota = async () => { + const status = await sendHostedControl( + authenticatedRequest(sessionSecret, { type: "quota_status" }) + ); + assert.strictEqual(status.type, "quota_status", JSON.stringify(status)); + const limit = status.limits?.limits?.Spawn; + const usage = status.usage?.Spawn; + const windowSeconds = status.window_seconds?.Spawn; + const windowStarted = status.window_started_epoch_seconds?.Spawn; + assert(Number.isInteger(limit) && limit > 0, JSON.stringify(status)); + assert(Number.isInteger(usage) && usage >= 0, JSON.stringify(status)); + assert( + Number.isInteger(windowSeconds) && windowSeconds > 0, + JSON.stringify(status) + ); + assert(Number.isInteger(windowStarted), JSON.stringify(status)); + return { limit, usage, windowSeconds, windowStarted }; + }; + + let spawnQuota = await readSpawnQuota(); + const windowElapsed = Math.max( + 0, + Math.floor(Date.now() / 1000) - spawnQuota.windowStarted + ); + if (spawnQuota.usage > 0 || windowElapsed > 1) { + await delay( + Math.max(1000, (spawnQuota.windowSeconds - windowElapsed + 1) * 1000) + ); + spawnQuota = await readSpawnQuota(); + } let denial; let deniedProcess; let acceptedStarts = 0; - for (let index = 0; index < 96; index += 1) { + const maxAttempts = spawnQuota.limit - spawnQuota.usage + 2; + for (let index = 0; index < maxAttempts; index += 1) { const processId = `vp-quota-${suffix}-${index}`; const started = await sendHostedControl( authenticatedRequest(sessionSecret, { @@ -1082,7 +1419,10 @@ async function runLiveQuotaPreallocation({ sessionSecret, suffix }) { ); assert.strictEqual(aborted.type, "process_aborted", JSON.stringify(aborted)); } - assert(denial, "live community-tier spawn quota was not reached in 96 attempts"); + assert( + denial, + `live community-tier spawn quota was not reached in ${maxAttempts} attempts` + ); const processes = await sendHostedControl( authenticatedRequest(sessionSecret, { type: "list_processes" }) ); @@ -1096,6 +1436,9 @@ async function runLiveQuotaPreallocation({ sessionSecret, suffix }) { "quota-denied process was allocated before denial" ); return { + limit: spawnQuota.limit, + initial_usage: spawnQuota.usage, + window_seconds: spawnQuota.windowSeconds, accepted_starts_before_denial: acceptedStarts, denied_process: deniedProcess, denial: { @@ -1107,18 +1450,183 @@ async function runLiveQuotaPreallocation({ sessionSecret, suffix }) { }; } +async function runRepeatedParkWakeProof({ clusterflux, projectDir, scope }) { + const runReport = runJson( + clusterflux, + ["run", "park-wake", "--project", ".", "--json"], + { cwd: projectDir } + ); + assert.strictEqual(runReport.status, "main_launched"); + const terminal = await waitForCli( + "repeated coordinator-main park/wake completion", + () => + runJson( + clusterflux, + ["task", "list", ...scope, "--process", runReport.process], + { cwd: projectDir } + ), + (tasks) => { + const completed = new Set( + rawTaskEvents(tasks) + .filter( + (event) => + event.task_definition === "task_add_one" && + event.terminal_state === "completed" + ) + .map((event) => event.task) + ); + return completed.size >= 16; + }, + 120000 + ); + const completedTasks = [ + ...new Set( + rawTaskEvents(terminal) + .filter( + (event) => + event.task_definition === "task_add_one" && + event.terminal_state === "completed" + ) + .map((event) => event.task) + ), + ]; + const released = await waitForCli( + "park/wake process automatic slot release", + () => + runJson( + clusterflux, + ["process", "status", ...scope, "--process", runReport.process], + { cwd: projectDir } + ), + (status) => status.state === "not_active", + 120000 + ); + return { + process: runReport.process, + completed_cycles: completedTasks.length, + task_instances: completedTasks, + terminal_state: released.state, + }; +} + +async function runDroppedConnectionRollback({ + clusterflux, + projectDir, + scope, + sessionSecret, + suffix, +}) { + const processId = `vp-dropped-response-${suffix}`; + const dropped = await sendHostedControlDroppingResponse( + authenticatedRequest(sessionSecret, { + type: "start_process", + process: processId, + restart: false, + }) + ); + assert.strictEqual(dropped.response_body_consumed, false); + const committed = await waitForCli( + "process creation committed after dropped response", + () => + runJson( + clusterflux, + ["process", "status", ...scope, "--process", processId], + { cwd: projectDir } + ), + (status) => status.state !== "not_active" && Boolean(status.live_process), + 30000 + ); + const rollback = await sendHostedControl( + authenticatedRequest(sessionSecret, { + type: "abort_process", + process: processId, + }) + ); + assert.strictEqual(rollback.type, "process_aborted", JSON.stringify(rollback)); + const released = await waitForCli( + "dropped-response process rollback release", + () => + runJson( + clusterflux, + ["process", "status", ...scope, "--process", processId], + { cwd: projectDir } + ), + (status) => status.state === "not_active", + 30000 + ); + return { + process: processId, + response_body_consumed: dropped.response_body_consumed, + committed_state: committed.state, + rollback: rollback.type, + terminal_state: released.state, + }; +} + +async function runLiveBandwidthPreallocation({ + sessionSecret, + artifact, + maxBytes, +}) { + let denial; + let acceptedReservations = 0; + for (let index = 0; index < 64; index += 1) { + const response = await sendHostedControl( + authenticatedRequest(sessionSecret, { + type: "create_artifact_download_link", + artifact, + max_bytes: maxBytes, + ttl_seconds: 300, + }) + ); + if (response.type === "error") { + assert.match( + response.message, + /artifact relay.*(?:concurrency|period byte budget)|bandwidth|reservation/i + ); + denial = response; + break; + } + assert.strictEqual(response.type, "artifact_download_link", JSON.stringify(response)); + acceptedReservations += 1; + } + assert(denial, "artifact relay preallocation was not denied in 64 reservations"); + return { + artifact, + accepted_reservations_before_denial: acceptedReservations, + bytes_served_before_denial: 0, + denial: { + type: denial.type, + category: denial.error?.category || null, + message: denial.message, + }, + }; +} + function deploymentProvenance() { if (!strictVpsRestart) return null; const systemGeneration = run( "ssh", sshArgs("readlink", "-f", "/run/current-system") ).trim(); - const binarySha256 = run( + const mainPid = run( "ssh", sshArgs( - "sha256sum", - "/opt/disasmer-public-release-dryrun/bin/disasmer-hosted-service" + "systemctl", + "show", + strictServiceUnit, + "--property=MainPID", + "--value" ) + ).trim(); + assert.match(mainPid, /^[1-9][0-9]*$/, "hosted service has no live MainPID"); + const executable = run( + "ssh", + sshArgs("readlink", "-f", `/proc/${mainPid}/exe`) + ).trim(); + const binarySha256 = run( + "ssh", + sshArgs("sha256sum", `/proc/${mainPid}/exe`) ) .trim() .split(/\s+/)[0]; @@ -1134,6 +1642,7 @@ function deploymentProvenance() { .split(/\s+/)[0]; return { system_generation: systemGeneration, + hosted_service_executable: executable, hosted_service_sha256: `sha256:${binarySha256}`, service_unit: fragment, service_unit_sha256: `sha256:${serviceUnitSha256}`, @@ -1142,7 +1651,7 @@ function deploymentProvenance() { function configurationProvenance() { const configRepo = path.resolve( - process.env.DISASMER_DEPLOYMENT_CONFIG_REPO || + process.env.CLUSTERFLUX_DEPLOYMENT_CONFIG_REPO || path.join(repo, "..", "michelpaulissen.com") ); return { @@ -1154,8 +1663,8 @@ function configurationProvenance() { } async function restartHostedServiceAndResume({ - disasmer, - disasmerNode, + clusterflux, + clusterfluxNode, projectDir, scope, workerArgs, @@ -1196,12 +1705,12 @@ async function restartHostedServiceAndResume({ before.hosted_service_sha256 ); - const projects = runJson(disasmer, ["project", "list", ...scope], { + const projects = runJson(clusterflux, ["project", "list", ...scope], { cwd: projectDir, }); assert(projects.project_count >= 1, "CLI session/project did not survive restart"); const ephemeralRun = runJson( - disasmer, + clusterflux, ["run", "build", "--project", ".", "--json"], { cwd: projectDir } ); @@ -1211,7 +1720,7 @@ async function restartHostedServiceAndResume({ "pre-restart ephemeral main to park", () => runJson( - disasmer, + clusterflux, ["process", "status", ...scope, "--process", ephemeralProcess], { cwd: projectDir } ), @@ -1243,14 +1752,14 @@ async function restartHostedServiceAndResume({ assert.strictEqual(after.system_generation, before.system_generation); assert.strictEqual(after.hosted_service_sha256, before.hosted_service_sha256); const oldStatus = runJson( - disasmer, + clusterflux, ["process", "status", ...scope, "--process", ephemeralProcess], { cwd: projectDir } ); assert.strictEqual(oldStatus.state, "not_active"); assert.strictEqual(sha256(fs.readFileSync(credentialPath)), credentialDigest); - const worker = spawnJsonLines(disasmerNode, workerArgs, { + const worker = spawnJsonLines(clusterfluxNode, workerArgs, { cwd: projectDir, env: { ...process.env }, }); @@ -1262,8 +1771,8 @@ async function restartHostedServiceAndResume({ assert.strictEqual(sha256(fs.readFileSync(credentialPath)), credentialDigest); const restartedRun = runJson( - disasmer, - ["run", "restart", "--project", ".", "--json"], + clusterflux, + ["run", "build", "--project", ".", "--json"], { cwd: projectDir } ); assert.strictEqual(restartedRun.status, "main_launched"); @@ -1272,28 +1781,19 @@ async function restartHostedServiceAndResume({ "new process completion after hosted service restart", () => runJson( - disasmer, + clusterflux, ["task", "list", ...scope, "--process", restartedProcess], { cwd: projectDir } ), - (tasks) => - rawTaskEvents(tasks).some( - (event) => - event.task_definition === "task_add_one" && - event.terminal_state === "completed" && - JSON.stringify(event.result) === JSON.stringify({ SmallJson: 42 }) - ), + (tasks) => completedFlagship(rawTaskEvents(tasks)), 5 * 60 * 1000 ); const completedEvent = rawTaskEvents(completed).find( - (event) => event.task_definition === "task_add_one" + (event) => + event.executor === "coordinator_main" && + event.terminal_state === "completed" ); - const aborted = runJson( - disasmer, - ["process", "abort", ...scope, "--process", restartedProcess, "--yes"], - { cwd: projectDir } - ); - assert.strictEqual(aborted.abort_request.process_slot_released, true); + assert(completedEvent, "post-restart flagship omitted its completed main event"); return { executed: true, account_project_session_preserved: true, @@ -1310,7 +1810,7 @@ async function restartHostedServiceAndResume({ } async function finishUserCredentialSecurity({ - disasmer, + clusterflux, projectDir, scope, sessionSecret, @@ -1327,7 +1827,7 @@ async function finishUserCredentialSecurity({ ); let expired = null; - const expiredFile = process.env.DISASMER_EXPIRED_USER_SESSION_FILE; + const expiredFile = process.env.CLUSTERFLUX_EXPIRED_USER_SESSION_FILE; if (expiredFile) { const oldSession = readJson(path.resolve(expiredFile)); assert(oldSession.session_secret, "expired session evidence omitted its secret"); @@ -1335,16 +1835,16 @@ async function finishUserCredentialSecurity({ await sendHostedControl( authenticatedRequest(oldSession.session_secret, { type: "list_projects" }) ), - /expired/i, + /expired|not recognized/i, "expired user session" ); } else if (strictFullRelease) { throw new Error( - "strict full release requires DISASMER_EXPIRED_USER_SESSION_FILE for a genuinely expired hosted session" + "strict full release requires CLUSTERFLUX_EXPIRED_USER_SESSION_FILE for a genuinely expired hosted session" ); } - const logout = runJson(disasmer, ["logout", ...scope, "--yes"], { + const logout = runJson(clusterflux, ["logout", ...scope, "--yes"], { cwd: projectDir, }); assert.strictEqual(logout.server_session_revocation.revoked, true); @@ -1364,8 +1864,8 @@ async function dapVariables(client, variablesReference) { } async function runLiveDapEditRestart({ - disasmerDap, - disasmer, + clusterfluxDap, + clusterflux, projectDir, scope, worker, @@ -1379,17 +1879,17 @@ async function runLiveDapEditRestart({ return line; }; const mainLine = lineFor("pub async fn restart_main()"); - const taskLine = lineFor("fn task_add_one("); + const taskLine = lineFor("fn task_trap("); const client = new DapClient({ cwd: projectDir, - command: disasmerDap, + command: clusterfluxDap, args: [], env: { ...process.env }, }); let sourceRestored = false; try { const initialize = client.send("initialize", { - adapterID: "disasmer", + adapterID: "clusterflux", linesStartAt1: true, columnsStartAt1: true, }); @@ -1456,14 +1956,14 @@ async function runLiveDapEditRestart({ (candidate) => candidate.name === "Task Args and Handles" ); const runtimeScope = scopes.find( - (candidate) => candidate.name === "Disasmer Runtime" + (candidate) => candidate.name === "Clusterflux Runtime" ); assert(argsScope && runtimeScope); const taskArgs = await dapVariables(client, argsScope.variablesReference); assert( taskArgs.some( (variable) => - variable.name === "arg_0" && String(variable.value).includes("41") + variable.name === "arg_0" && String(variable.value).includes("0") ) ); const runtime = await dapVariables(client, runtimeScope.variablesReference); @@ -1485,31 +1985,39 @@ async function runLiveDapEditRestart({ const taskContinue = client.send("continue", { threadId: taskStop.body.threadId }); await client.response(taskContinue, "continue"); - await client.waitFor( + const failedStop = await client.waitFor( (message) => message.seq > taskStop.seq && message.type === "event" && - message.event === "terminated", + message.event === "stopped" && + message.body.reason === "exception", 5 * 60 * 1000 ); + assert.strictEqual(failedStop.body.allThreadsStopped, false); const beforeEdit = runJson( - disasmer, + clusterflux, ["task", "list", ...scope, "--process", dapProcess], { cwd: projectDir } ); const originalTaskEvent = rawTaskEvents(beforeEdit).find( (event) => - event.task_definition === "task_add_one" && - event.terminal_state === "completed" + event.task_definition === "task_trap" && + event.terminal_state === "failed" ); assert(originalTaskEvent, "DAP restart entry omitted its original child event"); - assert.deepStrictEqual(originalTaskEvent.result, { SmallJson: 42 }); + assert(originalTaskEvent.attempt_id, "failed attempt omitted its identity"); - const editedSource = originalSource.replace( - " input + 1\n", - " input + 2 // strict hosted compatible restart probe\n" - ); + const originalTaskBody = `pub extern "C" fn task_trap(_input: i32) -> i32 { + #[cfg(target_arch = "wasm32")] + core::arch::wasm32::unreachable(); + #[cfg(not(target_arch = "wasm32"))] + panic!("intentional task trap") +}`; + const replacementTaskBody = `pub extern "C" fn task_trap(_input: i32) -> i32 { + _input + 42 // strict hosted compatible restart probe +}`; + const editedSource = originalSource.replace(originalTaskBody, replacementTaskBody); assert.notStrictEqual(editedSource, originalSource); fs.writeFileSync(sourcePath, editedSource); @@ -1531,9 +2039,10 @@ async function runLiveDapEditRestart({ await client.response(restartedContinue, "continue"); const restartedNodeReport = await worker.waitFor( (value) => - value.virtual_thread !== originalTaskEvent.task && + value.node_status === "completed" && + value.virtual_thread === originalTaskEvent.task && value.task_assignment_response?.task_spec?.task_definition === - "task_add_one", + "task_trap", "edited task to execute on the live restarted node", 5 * 60 * 1000 ); @@ -1542,13 +2051,13 @@ async function runLiveDapEditRestart({ "completed", `edited task restart failed on the live node: ${JSON.stringify(restartedNodeReport)}` ); - assert.match(restartedNodeReport.stdout_tail, /"SmallJson":43/); + assert.match(restartedNodeReport.stdout_tail, /"SmallJson":42/); const restartedTask = restartedNodeReport.virtual_thread; const afterEdit = await waitForCli( "edited live task event", () => - runJson(disasmer, ["task", "list", ...scope, "--process", dapProcess], { + runJson(clusterflux, ["task", "list", ...scope, "--process", dapProcess], { cwd: projectDir, }), (tasks) => @@ -1556,12 +2065,22 @@ async function runLiveDapEditRestart({ (event) => event.task === restartedTask && event.terminal_state === "completed" && - JSON.stringify(event.result) === JSON.stringify({ SmallJson: 43 }) + JSON.stringify(event.result) === JSON.stringify({ SmallJson: 42 }) ), 5 * 60 * 1000 ); const restartedEvent = rawTaskEvents(afterEdit).find( - (event) => event.task === restartedTask + (event) => + event.task === restartedTask && + event.terminal_state === "completed" && + JSON.stringify(event.result) === JSON.stringify({ SmallJson: 42 }) + ); + assert(restartedEvent, "replacement attempt omitted its completed event"); + assert(restartedEvent.attempt_id, "replacement attempt omitted its identity"); + assert.notStrictEqual(restartedEvent.attempt_id, originalTaskEvent.attempt_id); + await client.waitFor( + (message) => message.type === "event" && message.event === "terminated", + 5 * 60 * 1000 ); fs.writeFileSync(sourcePath, originalSource); sourceRestored = true; @@ -1573,10 +2092,11 @@ async function runLiveDapEditRestart({ main_all_threads_stopped: mainStop.body.allThreadsStopped, task_all_threads_stopped: taskStop.body.allThreadsStopped, original_task_instance: originalTaskEvent.task, - original_result: originalTaskEvent.result, + original_attempt: originalTaskEvent.attempt_id, restarted_task_instance: restartedTask, + restarted_attempt: restartedEvent.attempt_id, restarted_result: restartedEvent.result, - compatible_source_edit: "task_add_one: input + 1 -> input + 2", + compatible_source_edit: "task_trap: trap -> input + 42", original_arguments_preserved: true, clean_vfs_boundary_used: true, }; @@ -1591,11 +2111,11 @@ async function runLiveDapEditRestart({ async function main() { requireEnabled(); const manifest = readJson(manifestPath); - assert.strictEqual(manifest.kind, "disasmer-public-release-dryrun"); + assert.strictEqual(manifest.kind, "clusterflux-public-release-dryrun"); assert.strictEqual(manifest.default_hosted_coordinator_endpoint, serviceEndpoint); - assert.strictEqual(serviceAddr, "disasmer.michelpaulissen.com:443"); + assert.strictEqual(serviceAddr, "clusterflux.michelpaulissen.com:443"); - const workRoot = fs.mkdtempSync(path.join(os.tmpdir(), "disasmer-cli-happy-")); + const workRoot = fs.mkdtempSync(path.join(os.tmpdir(), "clusterflux-cli-happy-")); const installDir = path.join(workRoot, "install"); const checkout = path.join(workRoot, "public-repo"); const downloadPath = path.join(workRoot, "release.tar"); @@ -1604,28 +2124,62 @@ async function main() { run("tar", ["-xzf", binaryArchive(manifest), "-C", installDir]); const publicSource = stagePublicCheckout(manifest, checkout); - const disasmer = executable(installDir, "disasmer"); - const disasmerNode = executable(installDir, "disasmer-node"); - const disasmerDap = executable(installDir, "disasmer-debug-dap"); + const clusterflux = executable(installDir, "clusterflux"); + const clusterfluxNode = executable(installDir, "clusterflux-node"); + const clusterfluxDap = executable(installDir, "clusterflux-debug-dap"); const projectDir = path.join(checkout, "examples/launch-build-demo"); const suffix = String(Date.now()); const requestedProject = `project-${suffix}`; const workerNode = `worker-${suffix}`; - const login = runJson( - disasmer, - ["login", "--browser", "--json", "--project-id", requestedProject], - { - cwd: projectDir, - env: { ...process.env, DISASMER_BROWSER_OPEN_COMMAND: browserOpenCommand }, - } - ); - assert.strictEqual(login.plan.coordinator, serviceEndpoint); - assert.strictEqual(login.boundary.scoped_cli_session_received, true); - assert.strictEqual(login.boundary.provider_tokens_exposed_to_cli, false); - assert.strictEqual(login.boundary.provider_tokens_sent_to_nodes, false); - const loginSession = login.coordinator_response.session; - assert(loginSession, "hosted browser login omitted its scoped session"); + let loginSession; + let receivedRequestedProject; + if (reuseSessionFile) { + const sourceSessionPath = path.resolve(reuseSessionFile); + const sourceProjectPath = path.join(path.dirname(sourceSessionPath), "project.json"); + const sourceSession = readJson(sourceSessionPath); + const sourceProject = readJson(sourceProjectPath); + assert.strictEqual(sourceSession.kind, "human"); + assert.strictEqual(sourceSession.coordinator, serviceEndpoint); + assert.strictEqual(sourceProject.coordinator, serviceEndpoint); + assert.strictEqual(sourceProject.tenant, sourceSession.tenant); + assert.strictEqual(sourceProject.project, sourceSession.project); + assert.strictEqual(sourceProject.user, sourceSession.user); + assert( + Number(sourceSession.expires_at) > Math.floor(Date.now() / 1000) + 300, + "reused CLI session expires too soon for the live journey" + ); + const destinationConfig = path.join(projectDir, ".clusterflux"); + ensureDir(destinationConfig); + fs.copyFileSync(sourceSessionPath, path.join(destinationConfig, "session.json")); + fs.copyFileSync(sourceProjectPath, path.join(destinationConfig, "project.json")); + fs.chmodSync(path.join(destinationConfig, "session.json"), 0o600); + fs.chmodSync(path.join(destinationConfig, "project.json"), 0o644); + loginSession = sourceSession; + receivedRequestedProject = false; + } else { + const login = runJson( + clusterflux, + ["login", "--browser", "--json", "--project-id", requestedProject], + { + cwd: projectDir, + env: { ...process.env, CLUSTERFLUX_BROWSER_OPEN_COMMAND: browserOpenCommand }, + } + ); + assert.strictEqual(login.plan.coordinator, serviceEndpoint); + assert.strictEqual(login.boundary.scoped_cli_session_received, true); + assert.strictEqual(login.boundary.provider_tokens_exposed_to_cli, false); + assert.strictEqual(login.boundary.provider_tokens_sent_to_nodes, false); + loginSession = login.coordinator_response.session; + assert(loginSession, "hosted browser login omitted its scoped session"); + receivedRequestedProject = + loginSession.project === requestedProject || + loginSession.project.endsWith(`-${requestedProject}`); + assert( + receivedRequestedProject, + "login did not return the requested server-owned default project" + ); + } const sessionSecret = loginSession.cli_session_secret || loginSession.session_secret; assert(sessionSecret, "hosted browser login omitted the CLI session credential"); @@ -1636,12 +2190,6 @@ async function main() { assert.strictEqual(typeof value, "string", `hosted session omitted ${name}`); assert.notStrictEqual(value, "", `hosted session returned an empty ${name}`); } - const receivedRequestedProject = - project === requestedProject || project.endsWith(`-${requestedProject}`); - assert( - receivedRequestedProject, - "login did not return the requested server-owned default project" - ); const scope = [ "--coordinator", serviceEndpoint, @@ -1654,36 +2202,38 @@ async function main() { "--json", ]; - const projectInit = runJson( - disasmer, - [ - "project", - "init", - ...scope, - "--new-project", - project, - "--name", - "CLI Happy Path", - "--yes", - ], - { cwd: projectDir } - ); - assert.strictEqual(projectInit.command, "project init"); - assert.strictEqual(projectInit.project_config_written, true); - assert.strictEqual(projectInit.coordinator_response.type, "project_created"); + if (!reuseSessionFile) { + const projectInit = runJson( + clusterflux, + [ + "project", + "init", + ...scope, + "--new-project", + project, + "--name", + "CLI Happy Path", + "--yes", + ], + { cwd: projectDir } + ); + assert.strictEqual(projectInit.command, "project init"); + assert.strictEqual(projectInit.project_config_written, true); + assert.strictEqual(projectInit.coordinator_response.type, "project_created"); + } - const projectList = runJson(disasmer, ["project", "list", ...scope], { + const projectList = runJson(clusterflux, ["project", "list", ...scope], { cwd: projectDir, }); assert(projectList.project_count >= 1); const projectSelect = runJson( - disasmer, + clusterflux, ["project", "select", ...scope, project], { cwd: projectDir } ); assert.strictEqual(projectSelect.command, "project select"); - const inspection = runJson(disasmer, ["inspect", "--project", ".", "--json"], { + const inspection = runJson(clusterflux, ["inspect", "--project", ".", "--json"], { cwd: projectDir, }); assert( @@ -1693,7 +2243,7 @@ async function main() { assert(inspection.source_provider_manifest); const runReport = runJson( - disasmer, + clusterflux, ["run", "build", "--project", ".", "--json"], { cwd: projectDir } ); @@ -1706,7 +2256,7 @@ async function main() { "hosted coordinator main to park before node enrollment", () => runJson( - disasmer, + clusterflux, ["process", "status", ...scope, "--process", processId], { cwd: projectDir } ), @@ -1717,12 +2267,12 @@ async function main() { 120000 ); - const grant = runJson(disasmer, ["node", "enroll", ...scope], { + const grant = runJson(clusterflux, ["node", "enroll", ...scope], { cwd: projectDir, }); assert.strictEqual(grant.command, "node enroll"); const attach = runJson( - disasmer, + clusterflux, [ "node", "attach", @@ -1743,7 +2293,7 @@ async function main() { assert.strictEqual(attach.command, "node attach"); assert.strictEqual(attach.boundary.used_enrollment_exchange, true); - const credentialDir = path.join(projectDir, ".disasmer", "nodes"); + const credentialDir = path.join(projectDir, ".clusterflux", "nodes"); const credentialFiles = fs .readdirSync(credentialDir) .filter((file) => file.endsWith(".json")); @@ -1772,7 +2322,7 @@ async function main() { "--emit-ready", ]; const spawnWorker = () => - spawnJsonLines(disasmerNode, workerArgs, { + spawnJsonLines(clusterfluxNode, workerArgs, { cwd: projectDir, env: { ...process.env }, }); @@ -1784,11 +2334,17 @@ async function main() { "first persisted worker ready" ); assert.strictEqual(firstReady.node, workerNode); + const initiallyOnline = runJson( + clusterflux, + ["node", "status", ...scope, "--node", workerNode], + { cwd: projectDir } + ); + assert.strictEqual(nodeDescriptor(initiallyOnline, workerNode)?.online, true); const completedTasks = await waitForCli( "real hosted flagship completion", () => - runJson(disasmer, ["task", "list", ...scope, "--process", processId], { + runJson(clusterflux, ["task", "list", ...scope, "--process", processId], { cwd: projectDir, }), (tasks) => completedFlagship(rawTaskEvents(tasks)) @@ -1808,7 +2364,29 @@ async function main() { ) ); + const releasedFlagshipStatus = await waitForCli( + "completed flagship process to release its active slot", + () => + runJson( + clusterflux, + ["process", "status", ...scope, "--process", processId], + { cwd: projectDir } + ), + (status) => status.state === "not_active", + 120000 + ); + + const offlineStartedAt = Date.now(); await stopChild(worker.child); + const observedOffline = await waitForCli( + "server-derived worker stale/offline transition", + () => + runJson(clusterflux, ["node", "status", ...scope, "--node", workerNode], { + cwd: projectDir, + }), + (status) => nodeDescriptor(status, workerNode)?.online === false, + 120000 + ); worker = spawnWorker(); const secondReady = await worker.waitFor( (value) => value.node_status === "ready", @@ -1817,61 +2395,42 @@ async function main() { assert.strictEqual(secondReady.node, workerNode); assert.strictEqual(sha256(fs.readFileSync(credentialPath)), credentialDigest); - const nodeStatus = runJson( - disasmer, - ["node", "status", ...scope, "--node", workerNode], - { cwd: projectDir } + const nodeStatus = await waitForCli( + "server-derived worker online recovery", + () => + runJson(clusterflux, ["node", "status", ...scope, "--node", workerNode], { + cwd: projectDir, + }), + (status) => nodeDescriptor(status, workerNode)?.online === true, + 30000 ); assert(JSON.stringify(nodeStatus.response).includes(workerNode)); - - const sourceEvent = firstEvents.find( - (event) => - event.task_definition === "prepare_source" && - event.terminal_state === "completed" - ); - assert(sourceEvent, "flagship omitted its completed source task"); - const restartTask = runJson( - disasmer, - [ - "task", - "restart", - ...scope, - sourceEvent.task, - "--process", - processId, - "--yes", - ], - { cwd: projectDir } - ); - assert.strictEqual(restartTask.restart_request.accepted, true); - const restartedTask = restartTask.response.restarted_task_instance; - assert(restartedTask && restartedTask !== sourceEvent.task); - const restartedCompletion = await worker.waitFor( - (value) => - value.node_status === "completed" && value.virtual_thread === restartedTask, - "restarted node to complete work under the persisted identity", - 5 * 60 * 1000 - ); - assert.strictEqual(restartedCompletion.terminal_state, "completed"); assert.strictEqual(sha256(fs.readFileSync(credentialPath)), credentialDigest); + const nodeLivenessTransition = { + initial_online: nodeDescriptor(initiallyOnline, workerNode).online, + stale_offline: nodeDescriptor(observedOffline, workerNode).online, + recovered_online: nodeDescriptor(nodeStatus, workerNode).online, + offline_detection_ms: Date.now() - offlineStartedAt, + persisted_identity_reused: true, + }; const processStatus = runJson( - disasmer, + clusterflux, ["process", "status", ...scope, "--process", processId], { cwd: projectDir } ); assert.strictEqual(processStatus.command, "process status"); - assert(processStatus.current_task_count >= 5); + assert(processStatus.current_task_count >= firstEvents.length); const logs = runJson( - disasmer, + clusterflux, ["logs", ...scope, "--process", processId], { cwd: projectDir } ); assert(logs.log_entries.length >= 4); const artifacts = runJson( - disasmer, + clusterflux, ["artifact", "list", ...scope, "--process", processId], { cwd: projectDir } ); @@ -1887,7 +2446,7 @@ async function main() { assert.strictEqual(releaseArtifact.state, "metadata_flushed"); const download = runJson( - disasmer, + clusterflux, [ "artifact", "download", @@ -1905,52 +2464,51 @@ async function main() { ensureDir(extractedArtifact); const archiveEntries = run("tar", ["-tf", downloadPath]); - assert.match(archiveEntries, /(?:^|\n)hello-disasmer(?:\n|$)/); + assert.match(archiveEntries, /(?:^|\n)hello-clusterflux(?:\n|$)/); run("tar", ["-xf", downloadPath, "-C", extractedArtifact]); - const builtExecutable = path.join(extractedArtifact, "hello-disasmer"); + const builtExecutable = path.join(extractedArtifact, "hello-clusterflux"); assert(fs.existsSync(builtExecutable)); const builtOutput = run(builtExecutable, []).trim(); - assert.strictEqual(builtOutput, "hello from a real Disasmer build"); - - const abortFlagship = runJson( - disasmer, - ["process", "abort", ...scope, "--process", processId, "--yes"], - { cwd: projectDir } - ); - assert.strictEqual(abortFlagship.abort_request.accepted, true); - assert.strictEqual(abortFlagship.abort_request.process_slot_released, true); - await waitForCli( - "flagship process slot release before live DAP launch", - () => - runJson( - disasmer, - ["process", "status", ...scope, "--process", processId], - { cwd: projectDir } - ), - (status) => status.state === "not_active", - 120000 - ); + assert.strictEqual(builtOutput, "hello from a real Clusterflux build"); const dapEditRestart = await runLiveDapEditRestart({ - disasmerDap, - disasmer, + clusterfluxDap, + clusterflux, projectDir, scope, worker, }); const restart = runJson( - disasmer, + clusterflux, ["process", "restart", ...scope, "--process", dapEditRestart.process, "--yes"], { cwd: projectDir } ); assert.strictEqual(restart.restart_request.accepted, true); const cancel = runJson( - disasmer, + clusterflux, ["process", "cancel", ...scope, "--process", dapEditRestart.process, "--yes"], { cwd: projectDir } ); assert.strictEqual(cancel.cancel_request.accepted, true); + const releasedDap = await waitForCli( + "cancelled DAP process slot release", + () => + runJson( + clusterflux, + ["process", "status", ...scope, "--process", dapEditRestart.process], + { cwd: projectDir } + ), + (status) => status.state === "not_active", + 120000 + ); + assert.strictEqual(releasedDap.state, "not_active"); + + const repeatedParkWake = await runRepeatedParkWakeProof({ + clusterflux, + projectDir, + scope, + }); const tenantIsolation = await runSecondTenantIsolation({ firstTenant: tenant, @@ -1960,40 +2518,67 @@ async function main() { firstArtifact: releaseArtifact.artifact, manifest, }); - const abortDap = runJson( - disasmer, - ["process", "abort", ...scope, "--process", dapEditRestart.process, "--yes"], - { cwd: projectDir } - ); - assert.strictEqual(abortDap.abort_request.process_slot_released, true); const securityNode = await prepareLiveNodeCredentialSecurity({ - disasmer, + clusterflux, projectDir, scope, tenant, project, suffix, + bundle: { + digest: runReport.bundle_digest, + moduleBase64: fs + .readFileSync( + path.resolve(projectDir, runReport.bundle_build.bundle_artifact.module) + ) + .toString("base64"), + entryExport: runReport.entry_export, + entryStableId: runReport.entry_stable_id, + }, + }); + const droppedConnectionRollback = await runDroppedConnectionRollback({ + clusterflux, + projectDir, + scope, + sessionSecret, + suffix, + }); + const bandwidthPreallocation = await runLiveBandwidthPreallocation({ + sessionSecret, + artifact: releaseArtifact.artifact, + maxBytes: download.local_download.bytes_written, }); const agentCredentialSecurity = await runLiveAgentCredentialSecurity({ - disasmer, + clusterflux, projectDir, scope, sessionSecret, tenant, project, suffix, + bundle: { + digest: runReport.bundle_digest, + moduleBase64: fs + .readFileSync( + path.resolve(projectDir, runReport.bundle_build.bundle_artifact.module) + ) + .toString("base64"), + entryExport: runReport.entry_export, + entryStableId: runReport.entry_stable_id, + }, + securityNode, }); await stopChild(worker.child); const liveSoak = await runLiveSoak({ - disasmer, + clusterflux, projectDir, scope, securityNode, }); const revokedNodeCredential = await revokeSecurityNode({ - disasmer, + clusterflux, projectDir, scope, securityNode, @@ -2003,8 +2588,8 @@ async function main() { suffix, }); const serviceRestart = await restartHostedServiceAndResume({ - disasmer, - disasmerNode, + clusterflux, + clusterfluxNode, projectDir, scope, workerArgs, @@ -2014,23 +2599,52 @@ async function main() { }); if (serviceRestart.worker) worker = serviceRestart.worker; const userCredentialSecurity = await finishUserCredentialSecurity({ - disasmer, + clusterflux, projectDir, scope, sessionSecret, - }); - const configProvenance = configurationProvenance(); - const fullReleasePassed = - strictFullRelease && - tenantIsolation.executed && - liveSoak.executed && - serviceRestart.executed && - Boolean(userCredentialSecurity.expired) && - manifest.source_tree_clean === true && - configProvenance.clean; + }); + const configProvenance = configurationProvenance(); + const concurrentCompileTasks = [ + ...new Set( + firstEvents + .filter( + (event) => + event.task_definition === "compile_linux" && + event.terminal_state === "completed" + ) + .map((event) => event.task) + ), + ]; + assert(concurrentCompileTasks.length >= 2); + const strictRequirementLedger = [ + { id: "01_authenticated_project", passed: Boolean(sessionSecret && tenant && project) }, + { id: "02_project_commands", passed: projectList.project_count >= 1 && projectSelect.command === "project select" }, + { id: "03_bundle_environment_inspection", passed: inspection.metadata.environments.some((environment) => environment.name === "linux") }, + { id: "04_main_parks_before_node", passed: parkedStatus.live_process.main_wait_state === "waiting_for_node" }, + { id: "05_enrollment_and_persisted_credential", passed: attach.boundary.used_enrollment_exchange === true && secondReady.node === workerNode }, + { id: "06_server_derived_node_liveness", passed: nodeLivenessTransition.initial_online === true && nodeLivenessTransition.stale_offline === false && nodeLivenessTransition.recovered_online === true }, + { id: "07_real_flagship_and_artifact", passed: completedFlagship(firstEvents) && Boolean(releaseArtifact) && builtOutput === "hello from a real Clusterflux build" }, + { id: "08_same_definition_concurrency", passed: concurrentCompileTasks.length >= 2 }, + { id: "09_repeated_park_wake", passed: repeatedParkWake.completed_cycles >= 16 && repeatedParkWake.terminal_state === "not_active" }, + { id: "10_nested_environment_rejection", passed: agentCredentialSecurity.nested_environment_mismatch.denied === true }, + { id: "11_partial_freeze_warning_resume", passed: agentCredentialSecurity.partial_freeze.partially_frozen === true && agentCredentialSecurity.partial_freeze.resumed_participants.length > 0 }, + { id: "12_live_dap_edit_restart", passed: dapEditRestart.clean_vfs_boundary_used === true }, + { id: "13_process_lifecycle_release", passed: releasedFlagshipStatus.state === "not_active" && restart.restart_request.accepted === true && cancel.cancel_request.accepted === true }, + { id: "14_tenant_isolation", passed: tenantIsolation.executed === true && tenantIsolation.process_hidden === true && tenantIsolation.node_hidden === true }, + { id: "15_user_credential_hostility", passed: Boolean(userCredentialSecurity.expired && userCredentialSecurity.forged && userCredentialSecurity.revoked) }, + { id: "16_node_credential_hostility", passed: securityNode.evidence.replay.denied === true && securityNode.evidence.expired.denied === true && securityNode.evidence.forged.denied === true && revokedNodeCredential.denied.denied === true }, + { id: "17_agent_workflow_and_credentials", passed: agentCredentialSecurity.workflow.flagship_completed === true && agentCredentialSecurity.workflow.authenticated_without_browser === true && agentCredentialSecurity.revoked.denied === true }, + { id: "18_dropped_response_rollback", passed: droppedConnectionRollback.response_body_consumed === false && droppedConnectionRollback.terminal_state === "not_active" }, + { id: "19_quota_and_bandwidth_preallocation", passed: quotaPreallocation.denied_process_allocated === false && bandwidthPreallocation.bytes_served_before_denial === 0 }, + { id: "20_soak_restart_and_provenance", passed: liveSoak.executed === true && serviceRestart.executed === true && manifest.source_tree_clean === true && configProvenance.clean === true }, + ]; + const fullReleasePassed = + strictFullRelease && + strictRequirementLedger.every((requirement) => requirement.passed === true); const report = { - kind: "disasmer-cli-happy-path-live", + kind: "clusterflux-cli-happy-path-live", release_name: manifest.release_name, source_commit: manifest.source_commit, public_tree_identity: manifest.public_tree_identity, @@ -2043,6 +2657,8 @@ async function main() { worker_node: workerNode, process: processId, signed_in: true, + fresh_hosted_login: !reuseSessionFile, + reused_scoped_cli_session: Boolean(reuseSessionFile), received_default_project: receivedRequestedProject, process_started_before_node: true, parked_main_observed: { @@ -2054,8 +2670,10 @@ async function main() { persisted_node_credential_mode: process.platform === "win32" ? null : fs.statSync(credentialPath).mode & 0o777, persisted_node_credential_digest: credentialDigest, - node_restarted_without_grant: true, - restarted_node_completed_task: restartedTask, + node_restarted_without_grant: true, + restarted_node_ready: secondReady.node, + node_liveness_transition: nodeLivenessTransition, + flagship_terminal_state: releasedFlagshipStatus.state, flagship_task_definitions: [ "prepare_source", "compile_linux", @@ -2068,7 +2686,9 @@ async function main() { verified_digest: download.local_download.verified_digest, executable_output: builtOutput, }, - flagship_process_aborted_for_dap: true, + flagship_process_released_automatically: true, + concurrent_same_definition_tasks: concurrentCompileTasks, + repeated_park_wake: repeatedParkWake, live_dap_edit_restart: dapEditRestart, process_restart_requested: true, process_cancel_requested: true, @@ -2084,7 +2704,9 @@ async function main() { }, agent: agentCredentialSecurity, }, - quota_preallocation: quotaPreallocation, + quota_preallocation: quotaPreallocation, + bandwidth_preallocation: bandwidthPreallocation, + dropped_connection_rollback: droppedConnectionRollback, live_soak: liveSoak, hosted_service_restart: { ...serviceRestart, @@ -2099,8 +2721,9 @@ async function main() { deployment: serviceRestart.after || deploymentProvenance(), configuration: configProvenance, }, - commands, - acceptance_result: fullReleasePassed ? "passed" : "partial", + commands, + strict_requirement_ledger: strictRequirementLedger, + acceptance_result: fullReleasePassed ? "passed" : "partial", }; ensureDir(path.dirname(reportPath)); fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); diff --git a/scripts/cli-install-smoke.js b/scripts/cli-install-smoke.js index b2fb2d6..6c72342 100755 --- a/scripts/cli-install-smoke.js +++ b/scripts/cli-install-smoke.js @@ -7,14 +7,14 @@ const os = require("os"); const path = require("path"); const repo = path.resolve(__dirname, ".."); -const temp = fs.mkdtempSync(path.join(os.tmpdir(), "disasmer-cli-install-")); +const temp = fs.mkdtempSync(path.join(os.tmpdir(), "clusterflux-cli-install-")); const installRoot = path.join(temp, "install"); const targetDir = - process.env.DISASMER_CLI_INSTALL_CARGO_TARGET_DIR || + process.env.CLUSTERFLUX_CLI_INSTALL_CARGO_TARGET_DIR || process.env.CARGO_TARGET_DIR || path.join(repo, "target"); const project = path.join(repo, "examples/launch-build-demo"); -const binName = process.platform === "win32" ? "disasmer.exe" : "disasmer"; +const binName = process.platform === "win32" ? "clusterflux.exe" : "clusterflux"; const installedBin = path.join(installRoot, "bin", binName); try { @@ -23,9 +23,9 @@ try { [ "install", "--path", - "crates/disasmer-cli", + "crates/clusterflux-cli", "--bin", - "disasmer", + "clusterflux", "--root", installRoot, "--debug" @@ -40,7 +40,7 @@ try { } ); - assert(fs.existsSync(installedBin), "installed disasmer binary must exist"); + assert(fs.existsSync(installedBin), "installed clusterflux binary must exist"); const inspection = JSON.parse( cp.execFileSync( diff --git a/scripts/cli-local-run-smoke.js b/scripts/cli-local-run-smoke.js index 7bdb737..45d9c8f 100755 --- a/scripts/cli-local-run-smoke.js +++ b/scripts/cli-local-run-smoke.js @@ -10,7 +10,7 @@ const { configurePodmanTestEnvironment } = require("./podman-test-env"); const repo = path.resolve(__dirname, ".."); configurePodmanTestEnvironment(repo); if ( - !process.env.DISASMER_PODMAN_NIX_SHELL && + !process.env.CLUSTERFLUX_PODMAN_NIX_SHELL && cp.spawnSync("podman", ["--version"], { stdio: "ignore" }).status !== 0 && cp.spawnSync("nix", ["--version"], { stdio: "ignore" }).status === 0 ) { @@ -21,7 +21,7 @@ if ( cwd: repo, env: { ...process.env, - DISASMER_PODMAN_NIX_SHELL: "1", + CLUSTERFLUX_PODMAN_NIX_SHELL: "1", }, stdio: "inherit", } @@ -32,7 +32,7 @@ const project = path.join(repo, "examples/launch-build-demo"); cp.execFileSync( "cargo", - ["build", "-q", "-p", "disasmer-node", "--bin", "disasmer-node"], + ["build", "-q", "-p", "clusterflux-node", "--bin", "clusterflux-node"], { cwd: repo, stdio: "inherit" } ); @@ -80,7 +80,7 @@ function runCli(args, env = {}) { return new Promise((resolve, reject) => { const child = cp.spawn( "cargo", - ["run", "-q", "-p", "disasmer-cli", "--bin", "disasmer", "--", ...args], + ["run", "-q", "-p", "clusterflux-cli", "--bin", "clusterflux", "--", ...args], { cwd: repo, env: { @@ -119,9 +119,9 @@ function runCli(args, env = {}) { "run", "-q", "-p", - "disasmer-coordinator", + "clusterflux-coordinator", "--bin", - "disasmer-coordinator", + "clusterflux-coordinator", "--", "--listen", "127.0.0.1:0", diff --git a/scripts/cli-login-smoke.js b/scripts/cli-login-smoke.js index e442467..61b053b 100644 --- a/scripts/cli-login-smoke.js +++ b/scripts/cli-login-smoke.js @@ -6,22 +6,22 @@ const path = require("path"); const repo = path.resolve(__dirname, ".."); const coordinator = "https://coord.example.test"; -const defaultHostedCoordinatorEndpoint = "https://disasmer.michelpaulissen.com"; +const defaultHostedCoordinatorEndpoint = "https://clusterflux.michelpaulissen.com"; -function disasmer(args) { +function clusterflux(args) { return JSON.parse( cp.execFileSync( "cargo", - ["run", "-q", "-p", "disasmer-cli", "--bin", "disasmer", "--", ...args], + ["run", "-q", "-p", "clusterflux-cli", "--bin", "clusterflux", "--", ...args], { cwd: repo, encoding: "utf8" } ) ); } -function disasmerRaw(args, env = {}) { +function clusterfluxRaw(args, env = {}) { return cp.spawnSync( "cargo", - ["run", "-q", "-p", "disasmer-cli", "--bin", "disasmer", "--", ...args], + ["run", "-q", "-p", "clusterflux-cli", "--bin", "clusterflux", "--", ...args], { cwd: repo, encoding: "utf8", @@ -33,7 +33,7 @@ function disasmerRaw(args, env = {}) { ); } -const browser = disasmer(["login", "--plan", "--coordinator", coordinator, "--json"]); +const browser = clusterflux(["login", "--plan", "--coordinator", coordinator, "--json"]); assert.strictEqual(browser.coordinator, coordinator); assert(browser.human_flow.Browser, "browser login should be available for human users"); assert.strictEqual(browser.human_flow.Browser.authorization_url, null); @@ -44,19 +44,19 @@ assert.strictEqual(browser.human_flow.Browser.hosted_callback, true); assert.strictEqual(browser.human_flow.Browser.cli_receives_provider_authorization_code, false); assert.strictEqual(browser.human_flow.Browser.cli_submits_identity_claims, false); -const defaultBrowser = disasmer(["login", "--plan", "--json"]); +const defaultBrowser = clusterflux(["login", "--plan", "--json"]); assert.strictEqual(defaultBrowser.coordinator, defaultHostedCoordinatorEndpoint); assert(defaultBrowser.human_flow.Browser); -const nonInteractiveBrowser = disasmerRaw( +const nonInteractiveBrowser = clusterfluxRaw( ["login", "--browser", "--non-interactive", "--coordinator", coordinator, "--json"], { - DISASMER_BROWSER_OPEN_COMMAND: - "node -e 'require(\"fs\").writeFileSync(\"/tmp/disasmer-browser-should-not-open\", \"opened\")'", + CLUSTERFLUX_BROWSER_OPEN_COMMAND: + "node -e 'require(\"fs\").writeFileSync(\"/tmp/clusterflux-browser-should-not-open\", \"opened\")'", } ); assert.strictEqual(nonInteractiveBrowser.status, 20, nonInteractiveBrowser.stderr); -assert.doesNotMatch(nonInteractiveBrowser.stderr, /Opening Disasmer browser login/); +assert.doesNotMatch(nonInteractiveBrowser.stderr, /Opening Clusterflux browser login/); const nonInteractiveReport = JSON.parse(nonInteractiveBrowser.stdout); assert.strictEqual(nonInteractiveReport.status, "authentication_required"); assert.strictEqual(nonInteractiveReport.non_interactive, true); diff --git a/scripts/cli-output-mode-smoke.js b/scripts/cli-output-mode-smoke.js index e7eec12..73550cb 100644 --- a/scripts/cli-output-mode-smoke.js +++ b/scripts/cli-output-mode-smoke.js @@ -8,9 +8,9 @@ const path = require("path"); const repo = path.resolve(__dirname, ".."); const project = path.join(repo, "examples/launch-build-demo"); -const isolatedCwd = fs.mkdtempSync(path.join(os.tmpdir(), "disasmer-cli-output-")); +const isolatedCwd = fs.mkdtempSync(path.join(os.tmpdir(), "clusterflux-cli-output-")); -function disasmer(args, env = {}, cwd = repo) { +function clusterflux(args, env = {}, cwd = repo) { return cp.execFileSync( "cargo", [ @@ -19,9 +19,9 @@ function disasmer(args, env = {}, cwd = repo) { "--manifest-path", path.join(repo, "Cargo.toml"), "-p", - "disasmer-cli", + "clusterflux-cli", "--bin", - "disasmer", + "clusterflux", "--", ...args, ], @@ -37,7 +37,7 @@ function disasmer(args, env = {}, cwd = repo) { } function json(args, env, cwd) { - return JSON.parse(disasmer(args, env, cwd)); + return JSON.parse(clusterflux(args, env, cwd)); } function assertHuman(name, output, requiredPatterns) { @@ -50,25 +50,25 @@ function assertHuman(name, output, requiredPatterns) { } } -const helpHuman = disasmer(["help"]); +const helpHuman = clusterflux(["help"]); assertHuman("help", helpHuman, [ /Primary workflow:/, - /disasmer login --browser/, - /disasmer project init/, - /disasmer node attach; disasmer-node --worker/, - /Disasmer: Launch Virtual Process/, + /clusterflux login --browser/, + /clusterflux project init/, + /clusterflux node attach; clusterflux-node --worker/, + /Clusterflux: Launch Virtual Process/, /Hosted account creation happens in the browser login flow/, /--json/, ]); -const loginHuman = disasmer([ +const loginHuman = clusterflux([ "login", "--plan", "--coordinator", "https://coord.example.test", ]); assertHuman("login", loginHuman, [ - /Disasmer login/, + /Clusterflux login/, /flow: browser/, ]); @@ -84,9 +84,9 @@ assert(loginJson.human_flow.Browser); assert.strictEqual(loginJson.human_flow.Browser.hosted_callback, true); assert.strictEqual(loginJson.human_flow.Browser.cli_submits_identity_claims, false); -const doctorHuman = disasmer(["doctor"]); +const doctorHuman = clusterflux(["doctor"]); assertHuman("doctor", doctorHuman, [ - /Disasmer doctor/, + /Clusterflux doctor/, /coordinator reachability: not_configured/, /dependencies:/, /auth:/, @@ -112,8 +112,8 @@ assert(Array.isArray(doctorJson.node_readiness_summary.missing_local_dependencie assert(doctorJson.node_readiness_summary.next_actions.length >= 2); const authJson = json(["auth", "status", "--json"], { - DISASMER_TOKEN: "token", - DISASMER_TOKEN_EXPIRES_AT: "2026-07-04T00:00:00Z", + CLUSTERFLUX_TOKEN: "token", + CLUSTERFLUX_TOKEN_EXPIRES_AT: "2026-07-04T00:00:00Z", }, isolatedCwd); assert.strictEqual(authJson.session.kind, "human"); assert.strictEqual(authJson.session.token_expiry_posture, "expires_at"); @@ -125,9 +125,9 @@ assert.strictEqual( false ); -const inspectHuman = disasmer(["bundle", "inspect", "--project", project]); +const inspectHuman = clusterflux(["bundle", "inspect", "--project", project]); assertHuman("bundle inspect", inspectHuman, [ - /Disasmer bundle inspect/, + /Clusterflux bundle inspect/, /bundle: sha256:/, /environments:/, ]); @@ -140,6 +140,7 @@ assert.strictEqual(inspectJson.metadata.task_metadata.default_entrypoint, "build assert.deepStrictEqual(inspectJson.metadata.task_metadata.entrypoints, [ "build", "fail", + "park-wake", "restart", ]); assert.strictEqual( diff --git a/scripts/code-size-guard.js b/scripts/code-size-guard.js deleted file mode 100644 index ad7fae6..0000000 --- a/scripts/code-size-guard.js +++ /dev/null @@ -1,86 +0,0 @@ -#!/usr/bin/env node - -const assert = require("assert"); -const fs = require("fs"); -const path = require("path"); - -const repo = path.resolve(__dirname, ".."); -const maxProductionLines = 1000; - -const ignoredDirs = new Set([".git", ".cache", ".local", "target", "node_modules"]); -const rootIgnoredDirs = new Set(["experiments"]); - -function isTestRustFile(relativePath) { - const normalized = relativePath.split(path.sep).join("/"); - return ( - normalized.endsWith("/tests.rs") || - normalized.includes("/tests/") || - normalized.endsWith("_test.rs") - ); -} - -function lineCount(source) { - return source.endsWith("\n") - ? source.split("\n").length - 1 - : source.split("\n").length; -} - -function productionSource(source) { - // A trailing cfg(test) module is not compiled into the product and therefore - // is not production business logic. Keeping it next to the implementation is - // useful when it exercises private invariants; count only the source before - // that explicitly test-only suffix. - const testModule = /^#\[cfg\(test\)\]\r?\nmod\s+[A-Za-z_][A-Za-z0-9_]*\s*\{/m.exec(source); - return testModule ? source.slice(0, testModule.index) : source; -} - -function walk(directory, results = []) { - const entries = fs.readdirSync(directory, { withFileTypes: true }); - for (const entry of entries) { - const fullPath = path.join(directory, entry.name); - const relativePath = path.relative(repo, fullPath); - if (entry.isDirectory()) { - if (ignoredDirs.has(entry.name)) continue; - if (!relativePath.includes(path.sep) && rootIgnoredDirs.has(entry.name)) continue; - walk(fullPath, results); - continue; - } - if (entry.isFile() && entry.name.endsWith(".rs")) { - results.push(relativePath); - } - } - return results; -} - -const oversizedProductionFiles = []; -const oversizedTestFiles = []; - -for (const relativePath of walk(repo)) { - const source = fs.readFileSync(path.join(repo, relativePath), "utf8"); - const testOnlyFile = isTestRustFile(relativePath); - const lines = lineCount(testOnlyFile ? source : productionSource(source)); - if (lines <= maxProductionLines) continue; - - const entry = { path: relativePath, lines }; - if (testOnlyFile) { - oversizedTestFiles.push(entry); - } else { - oversizedProductionFiles.push(entry); - } -} - -assert.deepStrictEqual( - oversizedProductionFiles, - [], - `production Rust files must stay at or below ${maxProductionLines} lines; split business logic before adding more code` -); - -if (oversizedTestFiles.length > 0) { - console.log( - `Code-size guard ignored ${oversizedTestFiles.length} oversized test-only Rust file(s): ${oversizedTestFiles - .map((entry) => `${entry.path} (${entry.lines})`) - .join(", ")}` - ); -} - -console.log("Code-size guard passed"); diff --git a/scripts/dap-client.js b/scripts/dap-client.js index aaa8445..bd3d004 100644 --- a/scripts/dap-client.js +++ b/scripts/dap-client.js @@ -9,9 +9,9 @@ class DapClient { "run", "-q", "-p", - "disasmer-dap", + "clusterflux-dap", "--bin", - "disasmer-debug-dap", + "clusterflux-debug-dap", ], } = {}) { this.child = cp.spawn( diff --git a/scripts/dap-smoke.js b/scripts/dap-smoke.js index e48f4fb..f72cd89 100644 --- a/scripts/dap-smoke.js +++ b/scripts/dap-smoke.js @@ -17,7 +17,7 @@ const { DapClient } = require("./dap-client"); const client = new DapClient(); try { const initialize = client.send("initialize", { - adapterID: "disasmer", + adapterID: "clusterflux", linesStartAt1: true, columnsStartAt1: true, }); @@ -59,9 +59,9 @@ const { DapClient } = require("./dap-client"); const threads = (await client.response(threadsRequest, "threads")).body .threads; const mainThread = threads.find((thread) => - thread.name.includes("build virtual process") + thread.name.includes("build coordinator main") ); - assert(mainThread, "the running Wasm entrypoint must be a DAP virtual thread"); + assert(mainThread, "the running Wasm entrypoint must be the DAP coordinator-main thread"); assert.strictEqual(stopped.body.threadId, mainThread.id); const stackRequest = client.send("stackTrace", { @@ -90,7 +90,7 @@ const { DapClient } = require("./dap-client"); (scope) => scope.name === "Task Args and Handles" ); const runtimeScope = scopes.find( - (scope) => scope.name === "Disasmer Runtime" + (scope) => scope.name === "Clusterflux Runtime" ); assert(localsScope && wasmScope && argsScope && runtimeScope); @@ -178,7 +178,7 @@ const { DapClient } = require("./dap-client"); const restartClient = new DapClient(); try { const initialize = restartClient.send("initialize", { - adapterID: "disasmer", + adapterID: "clusterflux", linesStartAt1: true, columnsStartAt1: true, }); diff --git a/scripts/docs-smoke.js b/scripts/docs-smoke.js deleted file mode 100644 index 302e836..0000000 --- a/scripts/docs-smoke.js +++ /dev/null @@ -1,413 +0,0 @@ -#!/usr/bin/env node - -const assert = require("assert"); -const fs = require("fs"); -const path = require("path"); - -const repo = path.resolve(__dirname, ".."); -const readmePath = path.join(repo, "README.md"); -const filteredPublicTree = fs.existsSync(path.join(repo, "DISASMER_PUBLIC_TREE.json")); -if (!fs.existsSync(readmePath)) throw new Error("README.md is missing"); -const readme = fs.readFileSync(readmePath, "utf8"); -const canonicalPublicDocs = [ - "architecture.md", - "security.md", - "task-abi.md", - "artifacts.md", - "debugging.md", - "self-hosting.md", -]; -for (const file of canonicalPublicDocs) { - const documentationPath = path.join(repo, "docs", file); - assert( - fs.existsSync(documentationPath), - `canonical public documentation is missing docs/${file}` - ); - assert( - fs.readFileSync(documentationPath, "utf8").trim().length > 400, - `canonical public documentation docs/${file} is unexpectedly empty` - ); -} -if (filteredPublicTree) { - assert.match(readme, /^# Disasmer/m, "filtered public README must identify the product"); - assert.match(readme, /## Quickstart/, "filtered public README must retain the quickstart"); - assert.match( - readme, - /docs\/architecture\.md/, - "filtered public README must link canonical public documentation" - ); - assert.match( - readme, - /cargo install --path crates\/disasmer-cli --bin disasmer/, - "filtered public README must retain CLI installation" - ); - assert.doesNotMatch( - readme, - /DISASMER_PUBLISH_PUBLIC_TREE|DISASMER_FORGEJO_TOKEN|public-release-manifest\.json|public-release-dryrun-e2e\.json/, - "filtered public README must not expose release-internal dry-run mechanics" - ); - console.log("Docs smoke passed for filtered public product README"); - process.exit(0); -} -const dryrunDoc = fs.readFileSync( - path.join(repo, "public_release_dryrun.md"), - "utf8" -); -const userFacingDocs = [ - "README.md", - "public_release_dryrun.md", - "MVP.md", - "acceptance_criteria.md", - "acceptance_criteria_phase2.md", - "cli_acceptance_criteria.md", - "website_mvp_inventory.md", - "phase_3_acceptance_criteria.md", -].map((file) => [file, fs.readFileSync(path.join(repo, file), "utf8")]); -const publicAcceptance = fs.readFileSync( - path.join(repo, "scripts/acceptance-public.sh"), - "utf8" -); -const publicSplit = fs.readFileSync( - path.join(repo, "scripts/verify-public-split.sh"), - "utf8" -); -const privateAcceptance = fs.readFileSync( - path.join(repo, "scripts/acceptance-private.sh"), - "utf8" -); - -const requiredReadmePatterns = [ - ["quickstart heading", /## Quickstart/], - ["workspace build", /cargo build --workspace/], - ["CLI install", /cargo install --path crates\/disasmer-cli --bin disasmer/], - ["node install", /cargo install --path crates\/disasmer-node --bin disasmer-node/], - [ - "coordinator install", - /cargo install --path crates\/disasmer-coordinator --bin disasmer-coordinator/, - ], - ["DAP install", /cargo install --path crates\/disasmer-dap --bin disasmer-debug-dap/], - ["VS Code local extension", /code --extensionDevelopmentPath/], - ["local coordinator", /disasmer-coordinator --listen/], - ["node attach", /disasmer node attach --coordinator/], - ["automatic local run", /disasmer run --local --project examples\/launch-build-demo build/], - ["demo run", /disasmer run --local --coordinator/], - ["entrypoint selection", /disasmer run \[entry\]/], - ["implicit hosted mode", /uses the hosted coordinator/], - ["local override", /force local coordinator mode/], - ["project override", /--project[\s\S]*overrides the project\s+directory/], - ["VS Code debug", /Disasmer: Launch\s+Virtual Process/], - ["artifact download smoke", /node scripts\/artifact-download-smoke\.js/], - ["artifact export smoke", /node scripts\/artifact-export-smoke\.js/], - ["acceptance report smoke", /node scripts\/acceptance-report-smoke\.js/], - ["CLI-first acceptance gate", /scripts\/acceptance-cli-first\.sh/], - ["CLI-first acceptance before e2e", /CLI-first non-e2e gate before any final public-release e2e attempt/], - ["public private boundary smoke", /node scripts\/public-private-boundary-smoke\.js/], - ["release blocker smoke", /node scripts\/release-blocker-smoke\.js/], - ["explicit export", /attached receiver node or user-provided\s+storage integration/], - ["cleanup", /Cleanup for the local quickstart/], - ["flush docs", /`flush\(\)` publishes metadata/], - ["sync docs", /`sync\(\)` is explicit/], - ["storage integration is user code", /User-provided storage\/export integrations are ordinary project code or external\s+commands/], - ["no managed artifact store feature", /does not provide\s+or manage an explicit artifact-store feature/], - ["best-effort retention", /best-effort retained on nodes/], - [ - "secure downloads", - /Download links are scoped to the tenant, project, process, artifact, actor, and policy context, expire after a bounded TTL, can be revoked/, - ], - ["node trust", /Users attach their own nodes for real work/], - ["self-hosted trusted teams", /self-hosted local clouds, trusted teams, or VPN deployments/], - ["self-hosted coordinator smoke", /node scripts\/self-hosted-coordinator-smoke\.js/], - ["wasmtime node smoke", /node scripts\/wasmtime-node-smoke\.js/], - ["wasmtime command host import", /versioned `disasmer\.command_run_v1` host capability/], - ["Windows sandbox limitation", /Production-grade managed Windows sandboxing is behind an explicit backend stub/], - ["hosted community limit", /community tier does not provide arbitrary hosted native commands or hosted containers/], - ["browser login", /disasmer login --browser/], - ["public-key agents", /disasmer agent enroll --public-key/], - ["noninteractive agent CLI", /DISASMER_AGENT_PRIVATE_KEY= disasmer run --non-interactive build/], - ["agent key lifecycle", /register, list, rotate, and revoke an agent key/], - ["capability auto-detect", /auto-detects OS, architecture/], - ["capability override", /--cap /], - ["non-Git source provider", /Non-Git source providers can implement the public source-provider interface/], - ["first-run diagnostics", /## First-Run Diagnostics/], - ["missing nodes diagnostic", /Missing nodes/], - ["missing environment diagnostic", /Missing environments/], - ["quota diagnostic", /Quota limits/], - ["unavailable artifact diagnostic", /Unavailable artifacts/], - ["auth diagnostic", /Auth failures/], - ["debug freeze diagnostic", /Failed debug freezes/], - ["source-provider diagnostic", /Source-provider capability gaps/], - ["browser and VS Code report metadata", /browser\/VS Code harness metadata/], - ["Podman incomplete report", /Linux Podman backend behavior is marked `incomplete`/], - ["manual Windows validation workflow", /manual `Windows validation`\s+workflow/], - ["intermittent Forgejo Windows runner", /intermittent Windows runner/], - ["Windows validation env gate", /DISASMER_WINDOWS_VALIDATION = "forgejo-windows-runner"/], - ["Windows validation attach", /runs `disasmer node attach`/], -]; - -const requiredDryrunPatterns = [ - ["dry-run runbook status", /source-side release runbook, not public product quickstart/], - ["public dry-run hosted coordinator endpoint", /https:\/\/disasmer\.michelpaulissen\.com/], - ["public dry-run DNS record", /record is live[\s\S]*no resolver override is required|DNS record is deployed[\s\S]*no resolver override is required/], - ["public dry-run real deployment", /real externally reachable service/], - ["public dry-run selected users", /shared with selected users/], - ["public dry-run Forgejo repo", /git\.michelpaulissen\.com/], - ["public dry-run Forgejo Release assets", /Forgejo Release publishes compiled\s+assets/], - ["public dry-run filters internal root markdown", /internal root Markdown/], - ["public dry-run retains product README", /product-facing `README\.md` remains in the public repository/], - ["public dry-run filters Forgejo workflows by default", /\.forgejo\/\*\*` by\s+default/], - ["public dry-run Forgejo workflow opt in", /--include-forgejo-workflows/], - ["public dry-run selected-user quickstart", /DISASMER_PUBLIC_DRYRUN_GETTING_STARTED-\*\.md/], - ["public dry-run selected-user invite", /DISASMER_PUBLIC_DRYRUN_INVITE-\*\.md/], - ["public dry-run resolver instructions env", /DISASMER_PUBLIC_DRYRUN_RESOLVER_INSTRUCTIONS=/], - ["public dry-run fallback hosts entry env", /DISASMER_PUBLIC_DRYRUN_HOSTS_ENTRY=" disasmer\.michelpaulissen\.com"/], - ["public dry-run deployment IP env", /DISASMER_PUBLIC_RELEASE_DRYRUN_IP=/], - ["public dry-run prep script", /node scripts\/prepare-public-release-dryrun\.js/], - ["public dry-run publish opt-in", /DISASMER_PUBLISH_PUBLIC_TREE=1/], - ["public dry-run public repo remote", /DISASMER_PUBLIC_REPO_REMOTE=ssh:\/\/git\.michelpaulissen\.com/], - ["public dry-run Forgejo workflow", /Public release dry run assets/], - ["public dry-run manifest", /public-release-manifest\.json/], - ["public dry-run release publisher", /node scripts\/publish-public-release-dryrun\.js/], - ["public dry-run non-e2e preflight", /node scripts\/public-release-dryrun-preflight\.js/], - ["public dry-run Forgejo token", /DISASMER_FORGEJO_TOKEN=/], - ["public dry-run publisher infers repo", /publisher infers the Forgejo owner and repository name/], - ["public dry-run Forgejo repo owner override", /DISASMER_PUBLIC_REPO_OWNER=/], - ["public dry-run Forgejo repo name override", /DISASMER_PUBLIC_REPO_NAME=/], - ["public dry-run service smoke", /public-release-dryrun-service-smoke\.js/], - ["public dry-run service address", /DISASMER_PUBLIC_RELEASE_DRYRUN_SERVICE_ADDR=disasmer\.michelpaulissen\.com:443/], - ["public dry-run hosted coordinator", /hosted coordinator[\s\S]*disasmer\.michelpaulissen\.com[\s\S]*private\/hosted-policy/], - ["public dry-run distinguishes Core", /standalone Core coordinator remains available for local\/self-hosted use/], - ["public dry-run validates both coordinators", /dry-run acceptance validates both coordinator deployments/], - ["public dry-run validates Core coordinator separately", /standalone Core coordinator is validated separately/], - ["public browser login control entry", /POST https:\/\/disasmer\.michelpaulissen\.com\/api\/v1\/control/], - ["public browser login hosted callback", /hosted[\s\S]*\/auth\/callback|\/auth\/callback[\s\S]*hosted/], - ["public browser login server authority", /hosted service creates the OIDC state, nonce, and PKCE verifier/], - ["public browser test driver", /DISASMER_PUBLIC_RELEASE_DRYRUN_BROWSER_OPEN_COMMAND/], - ["browser login plan diagnostic", /disasmer login --browser --plan/], - ["public dry-run deployment prep", /prepare-public-release-dryrun-deployment\.js/], - ["public dry-run systemd unit", /systemd unit[\s\S]*127\.0\.0\.1:9080|loopback[\s\S]*127\.0\.0\.1:9080/], - ["public dry-run e2e runner", /public-release-dryrun-e2e\.js/], - ["public dry-run e2e gate env", /DISASMER_PUBLIC_RELEASE_DRYRUN_E2E=1/], - ["public dry-run e2e service address", /DISASMER_PUBLIC_RELEASE_DRYRUN_SERVICE_ADDR=disasmer\.michelpaulissen\.com:443/], - ["public dry-run final verifier", /public-release-dryrun-final-evidence\.js/], - ["public dry-run final gate env", /DISASMER_PUBLIC_RELEASE_DRYRUN_FINAL=1/], - ["public dry-run e2e evidence", /public-release-dryrun-e2e\.json/], - ["public dry-run e2e checks", /downloaded release assets[\s\S]*VS Code debugger behavior[\s\S]*artifact metadata/], - ["hosted Client compatibility smoke", /hosted-client-compat-smoke\.js/], - ["hosted Client compatibility purpose", /Client CLI browser login[\s\S]*scoped session[\s\S]*node enrollment[\s\S]*cross-tenant denial/], -]; - -for (const [name, pattern] of requiredReadmePatterns) { - assert.match(readme, pattern, `README missing ${name}`); -} - -for (const [name, pattern] of requiredDryrunPatterns) { - assert.match(dryrunDoc, pattern, `public release dry-run runbook missing ${name}`); -} - -assert.doesNotMatch( - readme, - /DISASMER_PUBLISH_PUBLIC_TREE|DISASMER_FORGEJO_TOKEN|public-release-manifest\.json|public-release-dryrun-e2e\.json/, - "README must stay product-facing; release-internal dry-run commands belong in public_release_dryrun.md" -); - -for (const [file, contents] of userFacingDocs) { - assert.doesNotMatch( - contents, - /community-tier/i, - `${file} should use "community tier" in user-facing prose` - ); -} - -for (const script of [publicAcceptance, publicSplit]) { - assert( - script.includes("node scripts/docs-smoke.js"), - "public acceptance gates must run docs-smoke.js" - ); - assert( - script.includes("node scripts/cli-install-smoke.js"), - "public acceptance gates must run cli-install-smoke.js" - ); - assert( - script.includes("node scripts/cli-output-mode-smoke.js"), - "public acceptance gates must run cli-output-mode-smoke.js" - ); - assert( - script.includes("node scripts/cli-login-smoke.js"), - "public acceptance gates must run cli-login-smoke.js" - ); - assert( - script.includes("node scripts/cli-error-exit-smoke.js"), - "public acceptance gates must run cli-error-exit-smoke.js" - ); - assert( - script.includes("node scripts/acceptance-report-smoke.js"), - "public acceptance gates must run acceptance-report-smoke.js" - ); - assert( - script.includes("node scripts/acceptance-doc-contract-smoke.js"), - "public acceptance gates must run acceptance-doc-contract-smoke.js" - ); - assert( - script.includes("node scripts/acceptance-environment-contract-smoke.js"), - "public acceptance gates must run acceptance-environment-contract-smoke.js" - ); - assert( - script.includes("node scripts/acceptance-evidence-contract-smoke.js"), - "public acceptance gates must run acceptance-evidence-contract-smoke.js" - ); - assert( - script.includes("node scripts/public-private-boundary-smoke.js"), - "public acceptance gates must run public-private-boundary-smoke.js" - ); - assert( - script.includes("node scripts/release-blocker-smoke.js"), - "public acceptance gates must run release-blocker-smoke.js" - ); - assert( - script.includes("node scripts/resource-metering-contract-smoke.js"), - "public acceptance gates must run resource-metering-contract-smoke.js" - ); - assert( - script.includes("node scripts/hostile-input-contract-smoke.js"), - "public acceptance gates must run hostile-input-contract-smoke.js" - ); - assert( - script.includes("node scripts/tenant-isolation-contract-smoke.js"), - "public acceptance gates must run tenant-isolation-contract-smoke.js" - ); - assert( - script.includes("node scripts/public-story-contract-smoke.js"), - "public acceptance gates must run public-story-contract-smoke.js" - ); - assert( - script.includes("node scripts/public-release-dryrun-contract-smoke.js"), - "public acceptance gates must run public-release-dryrun-contract-smoke.js" - ); - assert( - script.includes("node scripts/prepare-public-release-dryrun.js"), - "public acceptance gates must run prepare-public-release-dryrun.js" - ); - assert( - script.includes("node scripts/self-hosted-coordinator-smoke.js"), - "public acceptance gates must run self-hosted-coordinator-smoke.js" - ); - assert( - script.includes("node scripts/public-local-demo-matrix-smoke.js"), - "public acceptance gates must run public-local-demo-matrix-smoke.js" - ); - assert( - script.includes("scripts/release-source-scan.sh"), - "public acceptance gates must run release-source-scan.sh" - ); - assert( - script.includes("node scripts/flagship-demo-smoke.js"), - "public acceptance gates must run flagship-demo-smoke.js" - ); - assert( - script.includes("node scripts/wasmtime-assignment-smoke.js"), - "public acceptance gates must run the real coordinator-to-Wasm assignment smoke" - ); - assert( - script.includes("node scripts/sdk-spawn-runtime-smoke.js"), - "public acceptance gates must run sdk-spawn-runtime-smoke.js" - ); - assert( - script.includes("node scripts/node-lifecycle-contract-smoke.js"), - "public acceptance gates must run node-lifecycle-contract-smoke.js" - ); - assert( - script.includes("node scripts/artifact-export-smoke.js"), - "public acceptance gates must run artifact-export-smoke.js" - ); - assert( - script.includes("node scripts/windows-validation-contract-smoke.js"), - "public acceptance gates must run windows-validation-contract-smoke.js" - ); -} - -assert( - publicAcceptance.includes("node scripts/podman-backend-smoke.js"), - "public acceptance must run the Linux Podman backend smoke when Podman is available" -); - -assert( - publicAcceptance.includes("node scripts/wasmtime-node-smoke.js"), - "public acceptance must run the Wasmtime node smoke" -); - -assert( - privateAcceptance.includes("node scripts/resource-metering-contract-smoke.js"), - "private acceptance must run resource-metering-contract-smoke.js" -); - -assert( - privateAcceptance.includes("node scripts/hostile-input-contract-smoke.js"), - "private acceptance must run hostile-input-contract-smoke.js" -); - -assert( - privateAcceptance.includes("node scripts/tenant-isolation-contract-smoke.js"), - "private acceptance must run tenant-isolation-contract-smoke.js" -); - -assert( - privateAcceptance.includes("node scripts/acceptance-doc-contract-smoke.js"), - "private acceptance must run acceptance-doc-contract-smoke.js" -); - -assert( - privateAcceptance.includes("node scripts/acceptance-environment-contract-smoke.js"), - "private acceptance must run acceptance-environment-contract-smoke.js" -); - -assert( - privateAcceptance.includes("node scripts/acceptance-evidence-contract-smoke.js"), - "private acceptance must run acceptance-evidence-contract-smoke.js" -); - -assert( - privateAcceptance.includes("node private/hosted-policy/scripts/hosted-deployment-smoke.js"), - "private acceptance must run hosted-deployment-smoke.js" -); - -assert( - privateAcceptance.includes("node private/hosted-policy/scripts/hosted-client-compat-smoke.js"), - "private acceptance must run hosted-client-compat-smoke.js" -); -assert( - privateAcceptance.includes("node scripts/self-hosted-coordinator-smoke.js"), - "private acceptance must run self-hosted-coordinator-smoke.js before final dry-run evidence" -); - -assert( - privateAcceptance.includes("node private/hosted-policy/scripts/prepare-public-release-dryrun-deployment.js"), - "private acceptance must prepare public release dry-run deployment bundle" -); - -assert( - privateAcceptance.includes("node private/hosted-policy/scripts/public-release-dryrun-service-smoke.js"), - "private acceptance must be able to run public-release-dryrun-service-smoke.js" -); - -for (const script of [publicAcceptance, privateAcceptance]) { - assert( - script.includes("node scripts/public-release-dryrun-final-evidence.js"), - "acceptance must be able to run public-release-dryrun-final-evidence.js" - ); - assert( - script.includes("DISASMER_PUBLIC_RELEASE_DRYRUN_FINAL"), - "acceptance must gate public-release-dryrun-final-evidence.js" - ); -} - -assert( - publicAcceptance.includes("node scripts/public-release-dryrun-e2e.js"), - "public acceptance must be able to run public-release-dryrun-e2e.js" -); - -assert( - publicAcceptance.includes("DISASMER_PUBLIC_RELEASE_DRYRUN_E2E"), - "public acceptance must gate public-release-dryrun-e2e.js" -); - -console.log("Docs smoke passed"); diff --git a/scripts/flagship-demo-smoke.js b/scripts/flagship-demo-smoke.js index c2d2f45..2313e09 100644 --- a/scripts/flagship-demo-smoke.js +++ b/scripts/flagship-demo-smoke.js @@ -20,7 +20,7 @@ assert.deepStrictEqual(extension.diagnoseEnvReferences(source, envs), []); assert.doesNotMatch( source, forbiddenSourceAssumptions, - "flagship build source must not bypass Disasmer host capabilities or rely on coordinator-side filesystem, Git, container, or machine-local assumptions" + "flagship build source must not bypass Clusterflux host capabilities or rely on coordinator-side filesystem, Git, container, or machine-local assumptions" ); const inspection = JSON.parse( @@ -30,9 +30,9 @@ const inspection = JSON.parse( "run", "-q", "-p", - "disasmer-cli", + "clusterflux-cli", "--bin", - "disasmer", + "clusterflux", "--", "bundle", "inspect", @@ -78,9 +78,9 @@ const build = JSON.parse( "run", "-q", "-p", - "disasmer-cli", + "clusterflux-cli", "--bin", - "disasmer", + "clusterflux", "--", "build", "--project", @@ -94,7 +94,7 @@ const build = JSON.parse( ); assert.strictEqual(build.status, "built"); assert.strictEqual(build.bundle_artifact.task_descriptor_count, 9); -assert.strictEqual(build.bundle_artifact.entrypoint_count, 3); +assert.strictEqual(build.bundle_artifact.entrypoint_count, 4); assert.deepStrictEqual(build.bundle_artifact.files.sort(), [ "debug-metadata.json", "entrypoints.json", @@ -113,10 +113,10 @@ const bundleEntrypoints = JSON.parse( ); assert.deepStrictEqual( bundleEntrypoints.map((entrypoint) => entrypoint.name).sort(), - ["build", "fail", "restart"] + ["build", "fail", "park-wake", "restart"] ); const bundleModule = fs.readFileSync(path.join(bundleDirectory, "module.wasm")); -assert.strictEqual(bundleManifest.kind, "disasmer-bundle"); +assert.strictEqual(bundleManifest.kind, "clusterflux-bundle"); assert.strictEqual( bundleManifest.bundle_digest, `sha256:${crypto.createHash("sha256").update(bundleModule).digest("hex")}` @@ -136,7 +136,7 @@ assert( task.argument_schema === "input : i32" && task.result_schema === "i32" && task.restart_compatibility_hash.startsWith("sha256:") && - task.probe_symbol === "disasmer.probe.task_add_one" + task.probe_symbol === "clusterflux.probe.task_add_one" ) ); diff --git a/scripts/hostile-input-contract-smoke.js b/scripts/hostile-input-contract-smoke.js index 0584a8c..0be6153 100644 --- a/scripts/hostile-input-contract-smoke.js +++ b/scripts/hostile-input-contract-smoke.js @@ -20,29 +20,19 @@ function expect(source, name, pattern) { assert.match(source, pattern, `missing hostile-input evidence: ${name}`); } -function expectGate(script, gateName) { - assert( - script.includes("node scripts/hostile-input-contract-smoke.js"), - `${gateName} must run hostile-input-contract-smoke.js` - ); -} - -const coreSource = read("crates/disasmer-core/src/source.rs"); -const coreCapabilities = read("crates/disasmer-core/src/capability.rs"); +const coreSource = read("crates/clusterflux-core/src/source.rs"); +const coreCapabilities = read("crates/clusterflux-core/src/capability.rs"); const coordinatorService = [ - read("crates/disasmer-coordinator/src/service.rs"), - read("crates/disasmer-coordinator/src/service/routing.rs"), - read("crates/disasmer-coordinator/src/service/signed_nodes.rs"), - read("crates/disasmer-coordinator/src/service/logs.rs"), - read("crates/disasmer-coordinator/src/service/tests.rs"), + read("crates/clusterflux-coordinator/src/service.rs"), + read("crates/clusterflux-coordinator/src/service/routing.rs"), + read("crates/clusterflux-coordinator/src/service/signed_nodes.rs"), + read("crates/clusterflux-coordinator/src/service/logs.rs"), + read("crates/clusterflux-coordinator/src/service/tests.rs"), ].join("\n"); const artifactDownloadSmoke = read("scripts/artifact-download-smoke.js"); const operatorPanelSmoke = read("scripts/operator-panel-smoke.js"); const schedulerSmoke = read("scripts/scheduler-placement-smoke.js"); const sourcePreparationSmoke = read("scripts/source-preparation-smoke.js"); -const publicAcceptance = read("scripts/acceptance-public.sh"); -const publicSplit = read("scripts/verify-public-split.sh"); -const privateAcceptance = read("scripts/acceptance-private.sh"); for (const [name, pattern] of [ ["source manifests validate shape", /pub fn validate_public_mvp\(&self\)[\s\S]*self\.validate_shape\(\)\?/], @@ -125,23 +115,19 @@ for (const [name, source, patterns] of [ } } -expectGate(publicAcceptance, "public acceptance"); -expectGate(publicSplit, "public split acceptance"); -expectGate(privateAcceptance, "private acceptance"); - const hostedServiceMain = maybeRead([ "private", "hosted-policy", "src", "bin", - "disasmer-hosted-service.rs", + "clusterflux-hosted-service.rs", ]); const hostedValidation = maybeRead([ "private", "hosted-policy", "src", "bin", - "disasmer-hosted-service", + "clusterflux-hosted-service", "validation.rs", ]); const hostedWire = maybeRead([ @@ -149,7 +135,7 @@ const hostedWire = maybeRead([ "hosted-policy", "src", "bin", - "disasmer-hosted-service", + "clusterflux-hosted-service", "wire.rs", ]); const hostedProtocol = maybeRead([ @@ -157,7 +143,7 @@ const hostedProtocol = maybeRead([ "hosted-policy", "src", "bin", - "disasmer-hosted-service", + "clusterflux-hosted-service", "hosted_service_protocol.rs", ]); const hostedService = @@ -165,7 +151,7 @@ const hostedService = ? [hostedServiceMain, hostedValidation, hostedWire, hostedProtocol].join("\n") : null; const hostedTests = [ - maybeRead(["private", "hosted-policy", "src", "bin", "disasmer-hosted-service", "tests.rs"]), + maybeRead(["private", "hosted-policy", "src", "bin", "clusterflux-hosted-service", "tests.rs"]), maybeRead(["private", "hosted-policy", "scripts", "hosted-deployment-smoke.js"]), maybeRead(["private", "hosted-policy", "scripts", "hosted-client-compat-smoke.js"]), ].filter(Boolean).join("\n"); diff --git a/scripts/migrate-clusterflux-state.sh b/scripts/migrate-clusterflux-state.sh new file mode 100755 index 0000000..356091d --- /dev/null +++ b/scripts/migrate-clusterflux-state.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +migrate_path() { + local source="$1" + local destination="$2" + if [[ ! -e "$source" ]]; then + return + fi + if [[ -e "$destination" ]]; then + printf 'refusing to overwrite existing destination: %s\n' "$destination" >&2 + exit 1 + fi + mv -- "$source" "$destination" + printf 'migrated %s -> %s\n' "$source" "$destination" +} + +migrate_path "${HOME}/.disasmer" "${HOME}/.clusterflux" +migrate_path "${XDG_CONFIG_HOME:-${HOME}/.config}/disasmer" \ + "${XDG_CONFIG_HOME:-${HOME}/.config}/clusterflux" +migrate_path "${PWD}/.disasmer" "${PWD}/.clusterflux" +migrate_path "${PWD}/disasmer.toml" "${PWD}/clusterflux.toml" diff --git a/scripts/node-attach-smoke.js b/scripts/node-attach-smoke.js index ee4a112..d6c4e01 100755 --- a/scripts/node-attach-smoke.js +++ b/scripts/node-attach-smoke.js @@ -78,7 +78,7 @@ function nodeSignatureMessage( issuedAtEpochSeconds ) { const parts = [ - "disasmer-node-request-signature:v2", + "clusterflux-node-request-signature:v2", node, requestKind, payloadDigest, @@ -122,9 +122,9 @@ function runAttach(addr, grant) { "run", "-q", "-p", - "disasmer-cli", + "clusterflux-cli", "--bin", - "disasmer", + "clusterflux", "--", "node", "attach", @@ -148,7 +148,7 @@ function runAttach(addr, grant) { cwd: repo, env: { ...process.env, - DISASMER_NODE_PRIVATE_KEY: identity.privateKey, + CLUSTERFLUX_NODE_PRIVATE_KEY: identity.privateKey, }, } ); @@ -183,9 +183,9 @@ function runAttach(addr, grant) { "run", "-q", "-p", - "disasmer-coordinator", + "clusterflux-coordinator", "--bin", - "disasmer-coordinator", + "clusterflux-coordinator", "--", "--listen", "127.0.0.1:0", diff --git a/scripts/node-lifecycle-contract-smoke.js b/scripts/node-lifecycle-contract-smoke.js index 95acd9d..ea5d666 100755 --- a/scripts/node-lifecycle-contract-smoke.js +++ b/scripts/node-lifecycle-contract-smoke.js @@ -14,24 +14,23 @@ function expect(source, name, pattern) { assert.match(source, pattern, `missing node lifecycle evidence: ${name}`); } -const nodeMain = read("crates/disasmer-node/src/daemon.rs"); -const nodeIdentity = read("crates/disasmer-node/src/node_identity.rs"); -const cliNode = read("crates/disasmer-cli/src/node.rs"); -const nodeTaskReports = read("crates/disasmer-node/src/task_reports.rs"); -const nodeDebugAgent = read("crates/disasmer-node/src/debug_agent.rs"); -const nodeLib = read("crates/disasmer-node/src/lib.rs"); -const sharedWasmtimeRuntime = `${read("crates/disasmer-wasm-runtime/src/lib.rs")}\n${read("crates/disasmer-wasm-runtime/src/task_host_linker.rs")}`; +const nodeMain = read("crates/clusterflux-node/src/daemon.rs"); +const nodeIdentity = read("crates/clusterflux-node/src/node_identity.rs"); +const cliNode = read("crates/clusterflux-cli/src/node.rs"); +const nodeTaskReports = read("crates/clusterflux-node/src/task_reports.rs"); +const nodeDebugAgent = read("crates/clusterflux-node/src/debug_agent.rs"); +const nodeLib = read("crates/clusterflux-node/src/lib.rs"); +const sharedWasmtimeRuntime = `${read("crates/clusterflux-wasm-runtime/src/lib.rs")}\n${read("crates/clusterflux-wasm-runtime/src/task_host_linker.rs")}`; const nodeRuntimeSurface = `${nodeLib}\n${sharedWasmtimeRuntime}`; const nodeLifecycleSurface = `${nodeMain}\n${nodeIdentity}\n${nodeTaskReports}\n${nodeDebugAgent}`; -const nodeAssignmentRunner = `${read("crates/disasmer-node/src/assignment_runner.rs")}\n${read("crates/disasmer-node/src/assignment_runner/control_watcher.rs")}\n${read("crates/disasmer-node/src/assignment_runner/process_runner.rs")}\n${read("crates/disasmer-node/src/assignment_runner/validation.rs")}`; -const coordinatorCore = read("crates/disasmer-coordinator/src/lib.rs"); -const coordinatorService = `${read("crates/disasmer-coordinator/src/service.rs")}\n${read("crates/disasmer-coordinator/src/service/routing.rs")}`; -const coordinatorServiceTests = read("crates/disasmer-coordinator/src/service/tests.rs"); +const nodeAssignmentRunner = `${read("crates/clusterflux-node/src/assignment_runner.rs")}\n${read("crates/clusterflux-node/src/assignment_runner/control_watcher.rs")}\n${read("crates/clusterflux-node/src/assignment_runner/process_runner.rs")}\n${read("crates/clusterflux-node/src/assignment_runner/validation.rs")}`; +const coordinatorCore = read("crates/clusterflux-coordinator/src/lib.rs"); +const coordinatorService = `${read("crates/clusterflux-coordinator/src/service.rs")}\n${read("crates/clusterflux-coordinator/src/service/routing.rs")}`; +const coordinatorServiceTests = read("crates/clusterflux-coordinator/src/service/tests.rs"); const coordinatorServiceSurface = `${coordinatorService}\n${coordinatorServiceTests}`; const wasmtimeAssignmentSmoke = read("scripts/wasmtime-assignment-smoke.js"); const wasmtimeSmoke = read("scripts/wasmtime-node-smoke.js"); -const debugCore = read("crates/disasmer-core/src/debug.rs"); -const readme = read("README.md"); +const debugCore = read("crates/clusterflux-core/src/debug.rs"); assert.strictEqual( (nodeMain.match(/CoordinatorSession::connect/g) || []).length, @@ -144,7 +143,7 @@ for (const [name, pattern] of [ for (const [name, pattern] of [ ["debug model freezes wasm and command participants", /fn breakpoint_creates_all_stop_debug_epoch_for_wasm_and_command_tasks\(\)/], - ["debug model rejects unsupported freeze", /fn debug_epoch_reports_freeze_failure_instead_of_claiming_all_stop\(\)/], + ["debug model rejects unsupported freeze", /fn debug_epoch_reports_failure_when_no_participant_can_freeze\(\)/], ["debug model resumes frozen participants", /fn continue_resumes_every_frozen_participant\(\)/], ["debug model includes captured locals", /local_values/], ["wasm participants are modeled", /DebugParticipantKind::WasmTask/], @@ -153,12 +152,4 @@ for (const [name, pattern] of [ expect(debugCore, name, pattern); } -for (const [name, pattern] of [ - ["docs describe long-lived coordinator and worker processes", /real-flagship-harness\.js[\s\S]*separate long-lived processes/], - ["docs distinguish cooperative cancellation from forced abort", /cooperative cancellation observed by task code and forced abort of uncooperative Wasm\/native work/], - ["docs describe failed freeze diagnostic", /Failed debug freezes/], -]) { - expect(readme, name, pattern); -} - console.log("Node lifecycle contract smoke passed"); diff --git a/scripts/node-signing.js b/scripts/node-signing.js index afd2cdb..e50a27a 100644 --- a/scripts/node-signing.js +++ b/scripts/node-signing.js @@ -70,6 +70,10 @@ function withWireDefaults(request) { value.dependency_cache_digests ??= []; } else if (value.type === "launch_task" || value.type === "launch_child_task") { value.wait_for_node ??= false; + if (value.task_spec && typeof value.task_spec === "object") { + value.task_spec = { ...value.task_spec }; + value.task_spec.failure_policy ??= "fail_fast"; + } } else if (value.type === "start_process") { value.restart ??= false; } else if (value.type === "report_debug_state") { @@ -105,7 +109,7 @@ function nodeSignatureMessage( issuedAtEpochSeconds ) { const parts = [ - "disasmer-node-request-signature:v2", + "clusterflux-node-request-signature:v2", node, requestKind, payloadDigest, diff --git a/scripts/operator-panel-smoke.js b/scripts/operator-panel-smoke.js index 5d248e5..5655c3b 100755 --- a/scripts/operator-panel-smoke.js +++ b/scripts/operator-panel-smoke.js @@ -69,9 +69,9 @@ async function waitForDebugEpochFrozen(addr, process, epoch) { "run", "-q", "-p", - "disasmer-coordinator", + "clusterflux-coordinator", "--bin", - "disasmer-coordinator", + "clusterflux-coordinator", "--", "--listen", "127.0.0.1:0", @@ -104,19 +104,25 @@ async function waitForDebugEpochFrozen(addr, process, epoch) { assert.strictEqual(workerReady.node_status, "ready"); const workerCompletion = waitForJsonLine(worker.child); const flagship = startFlagship(addr); + await waitForTaskEvent( + addr, + flagship.process, + (event) => event.task_definition === "prepare_source", + "prepare_source before breakpoint configuration" + ); const configuredBreakpoints = await send(addr, { type: "set_debug_breakpoints", tenant: "tenant", project: "project", actor_user: "user", process: flagship.process, - probe_symbols: ["disasmer.probe.package_release"], + probe_symbols: ["clusterflux.probe.package_release"], }); assert.strictEqual(configuredBreakpoints.type, "debug_breakpoints"); const breakpointHit = await waitForBreakpointHit(addr, flagship.process); assert.strictEqual( breakpointHit.hit_probe_symbol, - "disasmer.probe.package_release" + "clusterflux.probe.package_release" ); const frozenEpoch = await waitForDebugEpochFrozen( addr, @@ -157,8 +163,9 @@ async function waitForDebugEpochFrozen(addr, process, epoch) { const taskProgress = widget(panel, "task-progress").kind.Progress; assert(taskProgress.current >= 2); assert.strictEqual(taskProgress.current, taskProgress.total); + const taskSummary = widget(panel, "task-summary").kind.Text.value; assert.match( - widget(panel, "task-summary").kind.Text.value, + taskSummary, new RegExp(`compile_linux \\[${compileEvent.task}\\]:Some\\(0\\):panel-node`) ); assert.match(widget(panel, "recent-logs").kind.Text.value, /stdout=\d+ stderr=\d+/); @@ -181,12 +188,11 @@ async function waitForDebugEpochFrozen(addr, process, epoch) { }); assert(panel.control_plane_actions.includes("DebugProcess")); assert(panel.control_plane_actions.includes("CancelProcess")); + const restartTarget = panel.control_plane_actions.find( + (action) => action.RestartTask + )?.RestartTask; assert( - panel.control_plane_actions.some( - (action) => - action.RestartTask && - action.RestartTask === compileEvent.task - ), + restartTarget && taskSummary.includes(`[${restartTarget}]`), "panel restart must target the real flagship task instance" ); assert( diff --git a/scripts/phase3-ledger.js b/scripts/phase3-ledger.js deleted file mode 100644 index 1d7bc36..0000000 --- a/scripts/phase3-ledger.js +++ /dev/null @@ -1,89 +0,0 @@ -const assert = require("assert"); -const fs = require("fs"); -const path = require("path"); - -const CRITERION_HEADING = - /^## \*\*(Passed|Partial|Open):\*\* (P3-[A-Z]+-\d{3}):/gm; - -function readPhase3Ledger(repo) { - return fs.readFileSync(path.join(repo, "phase_3_acceptance_criteria.md"), "utf8"); -} - -function criterionStatuses(source) { - return [...source.matchAll(CRITERION_HEADING)].map((match) => ({ - status: match[1], - id: match[2], - })); -} - -function statusCounts(criteria) { - return Object.fromEntries( - ["Passed", "Partial", "Open"].map((status) => [ - status, - criteria.filter((criterion) => criterion.status === status).length, - ]) - ); -} - -function assertCompleteCriterionSet(criteria) { - assert.strictEqual(criteria.length, 191, "Phase 3 ledger must contain 191 criteria"); - assert.strictEqual( - new Set(criteria.map((criterion) => criterion.id)).size, - 191, - "Phase 3 criterion ids must be unique" - ); -} - -function assertPreFinalLedger(source) { - const criteria = criterionStatuses(source); - assertCompleteCriterionSet(criteria); - const counts = statusCounts(criteria); - assert.deepStrictEqual( - counts, - { Passed: 0, Partial: 181, Open: 10 }, - "the public-release E2E requires the independently reset 181 Partial / 10 Open ledger" - ); - const expectedOpen = Array.from( - { length: 10 }, - (_, index) => `P3-GATE-${String(index + 1).padStart(3, "0")}` - ); - assert.deepStrictEqual( - criteria - .filter((criterion) => criterion.status === "Open") - .map((criterion) => criterion.id), - expectedOpen, - "only the ten final P3-GATE criteria may be Open in the reset ledger" - ); - return counts; -} - -function assertFinalLedger(source) { - const criteria = criterionStatuses(source); - assertCompleteCriterionSet(criteria); - const counts = statusCounts(criteria); - assert.deepStrictEqual( - counts, - { Passed: 191, Partial: 0, Open: 0 }, - "final release evidence requires all 191 Phase 3 criteria to be Passed" - ); - return counts; -} - -function assertPreFinalOrFinalLedger(source) { - const criteria = criterionStatuses(source); - assertCompleteCriterionSet(criteria); - const counts = statusCounts(criteria); - if (counts.Open === 10) { - return assertPreFinalLedger(source); - } - return assertFinalLedger(source); -} - -module.exports = { - assertFinalLedger, - assertPreFinalLedger, - assertPreFinalOrFinalLedger, - criterionStatuses, - readPhase3Ledger, - statusCounts, -}; diff --git a/scripts/podman-backend-smoke.js b/scripts/podman-backend-smoke.js index f946042..438c2df 100755 --- a/scripts/podman-backend-smoke.js +++ b/scripts/podman-backend-smoke.js @@ -23,7 +23,7 @@ function run(command, args, options = {}) { function incomplete(reason) { const error = new Error(`Linux Podman backend incomplete: ${reason}`); - error.code = "DISASMER_PODMAN_INCOMPLETE"; + error.code = "CLUSTERFLUX_PODMAN_INCOMPLETE"; throw error; } @@ -62,9 +62,9 @@ try { "run", "-q", "-p", - "disasmer-node", + "clusterflux-node", "--bin", - "disasmer-podman-smoke" + "clusterflux-podman-smoke" ]); const report = JSON.parse(stdout.trim().split("\n").at(-1)); @@ -78,7 +78,7 @@ try { console.log("Podman backend smoke passed"); } catch (error) { - if (error.code === "DISASMER_PODMAN_INCOMPLETE") { + if (error.code === "CLUSTERFLUX_PODMAN_INCOMPLETE") { console.error(error.message); process.exit(2); } diff --git a/scripts/podman-test-env.js b/scripts/podman-test-env.js index cf7c627..2c0dc3b 100644 --- a/scripts/podman-test-env.js +++ b/scripts/podman-test-env.js @@ -30,11 +30,11 @@ function configurePodmanTestEnvironment(repo) { process.env.HOME = isolatedHome; process.env.XDG_DATA_HOME = path.join( os.tmpdir(), - `disasmer-containers-data-${process.getuid?.() ?? "user"}` + `clusterflux-containers-data-${process.getuid?.() ?? "user"}` ); process.env.XDG_CACHE_HOME = path.join( os.tmpdir(), - `disasmer-containers-cache-${process.getuid?.() ?? "user"}` + `clusterflux-containers-cache-${process.getuid?.() ?? "user"}` ); } diff --git a/scripts/prepare-public-release-dryrun.js b/scripts/prepare-public-release-dryrun.js index 1513760..23e3cbc 100755 --- a/scripts/prepare-public-release-dryrun.js +++ b/scripts/prepare-public-release-dryrun.js @@ -8,33 +8,33 @@ const path = require("path"); const repo = path.resolve(__dirname, ".."); const outputRoot = path.resolve( - process.env.DISASMER_PUBLIC_RELEASE_DIR || + process.env.CLUSTERFLUX_PUBLIC_RELEASE_DIR || path.join(repo, "target/public-release-dryrun") ); const publicTree = path.join(outputRoot, "public-tree"); const assetsDir = path.join(outputRoot, "assets"); const stagingDir = path.join(outputRoot, "staging"); -const publicBuildTarget = path.join(repo, "target"); -const defaultHostedCoordinatorEndpoint = "https://disasmer.michelpaulissen.com"; +const publicBuildTarget = path.join(outputRoot, "cargo-target"); +const defaultHostedCoordinatorEndpoint = "https://clusterflux.michelpaulissen.com"; const forgejoHost = "git.michelpaulissen.com"; const args = new Set(process.argv.slice(2)); const includeForgejoWorkflows = args.has("--include-forgejo-workflows") || - /^(1|true|yes)$/i.test(process.env.DISASMER_INCLUDE_FORGEJO_WORKFLOWS || ""); -const filteredTopLevel = ["private", "experiments", ".git", "target"]; -const filteredDirectoryNames = [".disasmer"]; + /^(1|true|yes)$/i.test(process.env.CLUSTERFLUX_INCLUDE_FORGEJO_WORKFLOWS || ""); +const filteredTopLevel = ["private", "internal", "experiments", ".git", "target"]; +const filteredDirectoryNames = [".clusterflux"]; const archiveIgnoredPathFallbacks = [ "target", - ".disasmer", + ".clusterflux", "vscode-extension/node_modules", "scripts/containers-home", ]; -const publicRepoBranch = process.env.DISASMER_PUBLIC_REPO_BRANCH || "main"; +const publicRepoBranch = process.env.CLUSTERFLUX_PUBLIC_REPO_BRANCH || "main"; const publicBinaries = [ - "disasmer", - "disasmer-coordinator", - "disasmer-node", - "disasmer-debug-dap", + "clusterflux", + "clusterflux-coordinator", + "clusterflux-node", + "clusterflux-debug-dap", ]; function commandOutput(command, args, options = {}) { @@ -91,12 +91,13 @@ function ensureDir(dir) { function filteredOutPatterns() { return [ "private/**", + "internal/**", "experiments/**", ".git", "target", "git-ignored source paths", - "root/*.md except README.md and SECURITY.md", - "**/.disasmer/**", + "root/*.md except README.md", + "**/.clusterflux/**", ...(includeForgejoWorkflows ? [] : [".forgejo/**"]), ]; } @@ -181,14 +182,14 @@ function copyFilteredTree(src, dest, ignoredSourcePaths, relative = "") { } function assertFilteredTree(ignoredSourcePaths) { - for (const excluded of ["private", "experiments"]) { + for (const excluded of ["private", "internal", "experiments"]) { if (fs.existsSync(path.join(publicTree, excluded))) { throw new Error(`${excluded}/ leaked into the dry-run public tree`); } } for (const file of walkFiles(publicTree)) { - if (file.split(path.sep).includes(".disasmer")) { - throw new Error(`generated .disasmer view state leaked into the dry-run public tree: ${file}`); + if (file.split(path.sep).includes(".clusterflux")) { + throw new Error(`generated .clusterflux view state leaked into the dry-run public tree: ${file}`); } } if (!includeForgejoWorkflows && fs.existsSync(path.join(publicTree, ".forgejo"))) { @@ -291,7 +292,7 @@ function stageBinaryAssets(releaseName) { const archive = path.join( assetsDir, - `disasmer-public-binaries-${releaseName}-${platformName()}.tar.gz` + `clusterflux-public-binaries-${releaseName}-${platformName()}.tar.gz` ); tarGz(archive, stageRoot, ["."]); return archive; @@ -326,12 +327,12 @@ function stageEvidenceAsset( included.push(name); } const binding = { - kind: "disasmer-release-evidence-binding", + kind: "clusterflux-release-evidence-binding", source_commit: sourceCommit, public_tree_identity: publicTreeIdentity, binary_digests: binaryDigests, configuration_generation: - process.env.DISASMER_DEPLOYMENT_SYSTEM_GENERATION || null, + process.env.CLUSTERFLUX_DEPLOYMENT_SYSTEM_GENERATION || null, included_evidence: included, }; fs.writeFileSync( @@ -340,14 +341,14 @@ function stageEvidenceAsset( ); const archive = path.join( assetsDir, - `disasmer-public-evidence-${releaseName}.tar.gz` + `clusterflux-public-evidence-${releaseName}.tar.gz` ); tarGz(archive, stage, ["."]); return archive; } function stageSourceAsset(releaseName) { - const archive = path.join(assetsDir, `disasmer-public-source-${releaseName}.tar.gz`); + const archive = path.join(assetsDir, `clusterflux-public-source-${releaseName}.tar.gz`); tarGz(archive, publicTree, ["."]); return archive; } @@ -377,46 +378,46 @@ function stageExtensionAsset() { } function resolverInstructions() { - if (process.env.DISASMER_PUBLIC_DRYRUN_RESOLVER_INSTRUCTIONS) { - return process.env.DISASMER_PUBLIC_DRYRUN_RESOLVER_INSTRUCTIONS.trim(); + if (process.env.CLUSTERFLUX_PUBLIC_DRYRUN_RESOLVER_INSTRUCTIONS) { + return process.env.CLUSTERFLUX_PUBLIC_DRYRUN_RESOLVER_INSTRUCTIONS.trim(); } - if (process.env.DISASMER_PUBLIC_DRYRUN_HOSTS_ENTRY) { + if (process.env.CLUSTERFLUX_PUBLIC_DRYRUN_HOSTS_ENTRY) { return [ "Add the controlled hosts entry supplied for this dry run:", "", "```", - process.env.DISASMER_PUBLIC_DRYRUN_HOSTS_ENTRY.trim(), + process.env.CLUSTERFLUX_PUBLIC_DRYRUN_HOSTS_ENTRY.trim(), "```", ].join("\n"); } - if (process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_IP) { + if (process.env.CLUSTERFLUX_PUBLIC_RELEASE_DRYRUN_IP) { return [ "Add this controlled hosts entry for the dry run:", "", "```", - `${process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_IP} disasmer.michelpaulissen.com`, + `${process.env.CLUSTERFLUX_PUBLIC_RELEASE_DRYRUN_IP} clusterflux.michelpaulissen.com`, "```", ].join("\n"); } return [ - "`disasmer.michelpaulissen.com` should resolve through public DNS. If it", + "`clusterflux.michelpaulissen.com` should resolve through public DNS. If it", "does not resolve yet, wait for DNS propagation or use the fallback hosts", "entry supplied with the invitation.", ].join("\n"); } function writeGettingStartedAsset(releaseName, publicTreeIdentity, resolution) { - const file = path.join(assetsDir, `DISASMER_PUBLIC_DRYRUN_GETTING_STARTED-${releaseName}.md`); + const file = path.join(assetsDir, `CLUSTERFLUX_PUBLIC_DRYRUN_GETTING_STARTED-${releaseName}.md`); fs.writeFileSync( file, - `# Disasmer Public Dry Run + `# Clusterflux Public Dry Run -This dry run is a real Disasmer deployment for selected external users. If +This dry run is a real Clusterflux deployment for selected external users. If public DNS has not propagated yet, use the fallback resolution instructions below. The default hosted coordinator is the deployment at -\`https://disasmer.michelpaulissen.com\`. The word \`public\` in this dry run +\`https://clusterflux.michelpaulissen.com\`. The word \`public\` in this dry run refers to the public Forgejo repository, release downloads, selected-user network access, and public client protocol compatibility. It does not mean the server is the standalone open-source Core coordinator. @@ -431,11 +432,11 @@ ${resolution} \`git.michelpaulissen.com\`. 2. Verify it against \`SHA256SUMS\`. 3. Extract the archive and put the \`bin/\` directory on your \`PATH\`. -4. Download \`disasmer-vscode-*.vsix\` if you want the debugger and Disasmer +4. Download \`clusterflux-vscode-*.vsix\` if you want the debugger and Clusterflux side views, then install it: \`\`\`bash -code --install-extension disasmer-vscode-*.vsix +code --install-extension clusterflux-vscode-*.vsix \`\`\` ## Connect @@ -443,18 +444,18 @@ code --install-extension disasmer-vscode-*.vsix Use the default hosted coordinator endpoint: \`\`\`bash -disasmer login --browser -disasmer bundle inspect --project examples/launch-build-demo +clusterflux login --browser +clusterflux bundle inspect --project examples/launch-build-demo \`\`\` -\`disasmer login --browser\` opens the server-provided Authentik authorization +\`clusterflux login --browser\` opens the server-provided Authentik authorization URL and polls the hosted transaction. The provider callback terminates on the hosted service; provider codes and tokens do not pass through the CLI. Enroll a node identity with the grant supplied by the invitation: \`\`\`bash -disasmer node attach --coordinator https://disasmer.michelpaulissen.com --enrollment-grant --public-key +clusterflux node attach --coordinator https://clusterflux.michelpaulissen.com --enrollment-grant --public-key \`\`\` That command proves the identity/enrollment path and then exits. To actually run @@ -463,14 +464,14 @@ terminal. Use a fresh enrollment grant, or skip the attach command above and use the worker command directly: \`\`\`bash -disasmer-node --coordinator https://disasmer.michelpaulissen.com --tenant --project-id --node --public-key --enrollment-grant --worker --emit-ready +clusterflux-node --coordinator https://clusterflux.michelpaulissen.com --tenant --project-id --node --public-key --enrollment-grant --worker --emit-ready \`\`\` Then run the flagship workflow from the filtered public repository while that worker is still running: \`\`\`bash -disasmer run --project examples/launch-build-demo build +clusterflux run --project examples/launch-build-demo build \`\`\` The dry-run public tree identity is \`${publicTreeIdentity}\`. @@ -482,28 +483,28 @@ The release name is \`${releaseName}\`. } function writeInviteAsset(releaseName, publicTreeIdentity, resolution) { - const file = path.join(assetsDir, `DISASMER_PUBLIC_DRYRUN_INVITE-${releaseName}.md`); + const file = path.join(assetsDir, `CLUSTERFLUX_PUBLIC_DRYRUN_INVITE-${releaseName}.md`); const publicRepo = - process.env.DISASMER_PUBLIC_REPO_URL || + process.env.CLUSTERFLUX_PUBLIC_REPO_URL || "https://git.michelpaulissen.com//"; const releaseUrl = - process.env.DISASMER_FORGEJO_RELEASE_URL || + process.env.CLUSTERFLUX_FORGEJO_RELEASE_URL || "the Forgejo Release attached to the public repository"; fs.writeFileSync( file, - `# Disasmer Public Dry Run Invite + `# Clusterflux Public Dry Run Invite This invite is for selected external users, such as friends helping test the MVP release experience. The deployment is real and externally reachable, but it is intentionally not broadly advertised yet. The hosted coordinator behind this invite is the deployment at -\`https://disasmer.michelpaulissen.com\`. The public part is the Forgejo +\`https://clusterflux.michelpaulissen.com\`. The public part is the Forgejo repository, release downloads, network-reachable dry run, and public client protocol used by the binaries; the hosted server is not the standalone Core coordinator. -If public DNS has not propagated yet, make sure \`disasmer.michelpaulissen.com\` +If public DNS has not propagated yet, make sure \`clusterflux.michelpaulissen.com\` resolves to the deployment host before running the CLI. ## Links @@ -523,15 +524,15 @@ ${resolution} 1. Download the binary archive for your platform and \`SHA256SUMS\` from the Forgejo Release. 2. Extract the archive and put \`bin/\` on your \`PATH\`. -3. Optionally install \`disasmer-vscode-*.vsix\` with - \`code --install-extension disasmer-vscode-*.vsix\`. -4. Run \`disasmer login --browser\`; it opens the server-provided Authentik +3. Optionally install \`clusterflux-vscode-*.vsix\` with + \`code --install-extension clusterflux-vscode-*.vsix\`. +4. Run \`clusterflux login --browser\`; it opens the server-provided Authentik authorization URL and polls the hosted transaction for a scoped CLI session. 5. Enroll a node identity with the enrollment grant supplied out of band. 6. Start a long-lived worker with a fresh enrollment grant, or use the worker command directly instead of step 5: - \`disasmer-node --coordinator https://disasmer.michelpaulissen.com --tenant --project-id --node --public-key --enrollment-grant --worker --emit-ready\`. -7. Run \`disasmer run --project examples/launch-build-demo build\` from the + \`clusterflux-node --coordinator https://clusterflux.michelpaulissen.com --tenant --project-id --node --public-key --enrollment-grant --worker --emit-ready\`. +7. Run \`clusterflux run --project examples/launch-build-demo build\` from the public repository checkout while the worker process is still running. `, "utf8" @@ -555,7 +556,7 @@ function boolEnv(name) { function writePublicTreeProvenance(sourceCommit, releaseName) { const provenance = { - kind: "disasmer-filtered-public-tree", + kind: "clusterflux-filtered-public-tree", source_commit: sourceCommit, release_name: releaseName, filtered_out: filteredOutPatterns(), @@ -567,17 +568,17 @@ function writePublicTreeProvenance(sourceCommit, releaseName) { default_hosted_coordinator_endpoint: defaultHostedCoordinatorEndpoint, }; fs.writeFileSync( - path.join(publicTree, "DISASMER_PUBLIC_TREE.json"), + path.join(publicTree, "CLUSTERFLUX_PUBLIC_TREE.json"), `${JSON.stringify(provenance, null, 2)}\n` ); } function publishPublicTree(releaseName, sourceCommit, publicTreeIdentity) { const remote = - process.env.DISASMER_PUBLIC_REPO_REMOTE || - process.env.DISASMER_PUBLIC_REPO_URL || + process.env.CLUSTERFLUX_PUBLIC_REPO_REMOTE || + process.env.CLUSTERFLUX_PUBLIC_REPO_URL || null; - const enabled = boolEnv("DISASMER_PUBLISH_PUBLIC_TREE"); + const enabled = boolEnv("CLUSTERFLUX_PUBLISH_PUBLIC_TREE"); const result = { enabled, remote, @@ -591,7 +592,7 @@ function publishPublicTree(releaseName, sourceCommit, publicTreeIdentity) { } if (!remote) { throw new Error( - "DISASMER_PUBLISH_PUBLIC_TREE requires DISASMER_PUBLIC_REPO_REMOTE or DISASMER_PUBLIC_REPO_URL" + "CLUSTERFLUX_PUBLISH_PUBLIC_TREE requires CLUSTERFLUX_PUBLIC_REPO_REMOTE or CLUSTERFLUX_PUBLIC_REPO_URL" ); } if (!remote.includes(forgejoHost)) { @@ -600,10 +601,10 @@ function publishPublicTree(releaseName, sourceCommit, publicTreeIdentity) { run("git", ["init"], { cwd: publicTree }); run("git", ["checkout", "-B", publicRepoBranch], { cwd: publicTree }); - run("git", ["config", "user.name", "Disasmer release dry run"], { + run("git", ["config", "user.name", "Clusterflux release dry run"], { cwd: publicTree, }); - run("git", ["config", "user.email", "release-dryrun@disasmer.invalid"], { + run("git", ["config", "user.email", "release-dryrun@clusterflux.invalid"], { cwd: publicTree, }); run("git", ["add", "."], { cwd: publicTree }); @@ -623,7 +624,7 @@ function publishPublicTree(releaseName, sourceCommit, publicTreeIdentity) { result.commit = commandOutput("git", ["rev-parse", "HEAD"], { cwd: publicTree }); run("git", ["remote", "add", "public", remote], { cwd: publicTree }); const pushArgs = ["push", "public", `HEAD:${publicRepoBranch}`]; - if (boolEnv("DISASMER_PUBLIC_REPO_PUSH_FORCE_WITH_LEASE")) { + if (boolEnv("CLUSTERFLUX_PUBLIC_REPO_PUSH_FORCE_WITH_LEASE")) { run( "git", [ @@ -642,11 +643,11 @@ function publishPublicTree(releaseName, sourceCommit, publicTreeIdentity) { function main() { const sourceCommit = - process.env.DISASMER_ACCEPTANCE_COMMIT || + process.env.CLUSTERFLUX_ACCEPTANCE_COMMIT || commandOutput("git", ["rev-parse", "HEAD"]) || "unknown"; const shortCommit = sourceCommit === "unknown" ? "unknown" : sourceCommit.slice(0, 12); - const releaseName = process.env.DISASMER_PUBLIC_RELEASE_NAME || `dryrun-${shortCommit}`; + const releaseName = process.env.CLUSTERFLUX_PUBLIC_RELEASE_NAME || `dryrun-${shortCommit}`; const sourceStatus = commandOutput("git", ["status", "--short"]); const sourceTreeClean = sourceStatus === null ? null : sourceStatus === ""; @@ -662,9 +663,9 @@ function main() { const publicTreeIdentity = hashTree(publicTree); const sourceArchive = stageSourceAsset(releaseName); - run("node", ["scripts/public-release-dryrun-contract-smoke.js"], { - cwd: publicTree, - }); + run("scripts/check-old-name.sh", [], { cwd: publicTree }); + run("node", ["scripts/check-docs.js"], { cwd: publicTree }); + run("scripts/check-code-size.sh", [], { cwd: publicTree }); const publicTreePublish = publishPublicTree( releaseName, sourceCommit, @@ -698,7 +699,7 @@ function main() { const sha256Sums = writeSha256Sums(assets); const manifest = { - kind: "disasmer-public-release-dryrun", + kind: "clusterflux-public-release-dryrun", release_name: releaseName, source_commit: sourceCommit, source_tree_clean: sourceTreeClean, @@ -710,19 +711,19 @@ function main() { include_forgejo_workflows: includeForgejoWorkflows, }, forgejo_host: forgejoHost, - public_repo_url: process.env.DISASMER_PUBLIC_REPO_URL || publicTreePublish.remote, - public_repo_remote: process.env.DISASMER_PUBLIC_REPO_REMOTE || null, + public_repo_url: process.env.CLUSTERFLUX_PUBLIC_REPO_URL || publicTreePublish.remote, + public_repo_remote: process.env.CLUSTERFLUX_PUBLIC_REPO_REMOTE || null, public_tree_publish: publicTreePublish, - forgejo_release_url: process.env.DISASMER_FORGEJO_RELEASE_URL || null, + forgejo_release_url: process.env.CLUSTERFLUX_FORGEJO_RELEASE_URL || null, default_hosted_coordinator_endpoint: defaultHostedCoordinatorEndpoint, dns_publication_state: - process.env.DISASMER_DNS_PUBLICATION_STATE || "published", + process.env.CLUSTERFLUX_DNS_PUBLICATION_STATE || "published", resolver_override: - process.env.DISASMER_RESOLVER_OVERRIDE || "none-required-public-dns", + process.env.CLUSTERFLUX_RESOLVER_OVERRIDE || "none-required-public-dns", platform: platformName(), binary_digests: binaryDigests, configuration_generation: - process.env.DISASMER_DEPLOYMENT_SYSTEM_GENERATION || null, + process.env.CLUSTERFLUX_DEPLOYMENT_SYSTEM_GENERATION || null, tool_versions: { node: process.version, rustc: commandOutput("rustc", ["--version"]) || null, @@ -730,7 +731,9 @@ function main() { tar: commandOutput("tar", ["--version"]) || null, }, commands: [ - "node scripts/public-release-dryrun-contract-smoke.js", + "scripts/check-old-name.sh", + "node scripts/check-docs.js", + "scripts/check-code-size.sh", ...(publicTreePublish.enabled ? [`git push public HEAD:${publicRepoBranch}`] : []), diff --git a/scripts/public-browser-login-contract-smoke.js b/scripts/public-browser-login-contract-smoke.js deleted file mode 100644 index 5cd589d..0000000 --- a/scripts/public-browser-login-contract-smoke.js +++ /dev/null @@ -1,77 +0,0 @@ -#!/usr/bin/env node - -const assert = require("assert"); -const fs = require("fs"); -const path = require("path"); - -const repo = path.resolve(__dirname, ".."); -const infraRepo = path.resolve(repo, "..", "michelpaulissen.com"); - -function read(relativePath, base = repo) { - return fs.readFileSync(path.join(base, relativePath), "utf8"); -} - -function expect(source, name, pattern) { - assert.match(source, pattern, `missing public browser login evidence: ${name}`); -} - -const cliSource = [ - read("crates/disasmer-cli/src/main.rs"), - read("crates/disasmer-cli/src/dispatch.rs"), - read("crates/disasmer-cli/src/auth.rs"), -].join("\n"); -const cliSmoke = read("scripts/cli-browser-login-flow-smoke.js"); -const publicAcceptance = read("scripts/acceptance-public.sh"); -const phase2 = read("acceptance_criteria_phase2.md"); -const base = read("acceptance_criteria.md"); - -for (const [name, source] of [ - ["base acceptance", base], - ["phase 2 acceptance", phase2], -]) { - expect(source, `${name} released binary browser criterion`, /Public released binaries[\s\S]*(?:human |server-owned )browser\/account flow/); -} - -expect(cliSource, "diagnostic plan flag", /plan: bool/); -expect(cliSource, "interactive browser branch", /else if !args\.plan[\s\S]*execute_interactive_browser_login/); -expect(cliSource, "browser command override", /DISASMER_BROWSER_OPEN_COMMAND/); -expect(cliSource, "server-owned login start", /"type": "begin_oidc_browser_login"[\s\S]*requested_project/); -expect(cliSource, "opaque login polling", /"type": "poll_oidc_browser_login"[\s\S]*transaction_id[\s\S]*polling_secret/); -expect(cliSource, "server-owned state nonce and PKCE", /server_owns_state: true[\s\S]*server_owns_nonce: true[\s\S]*pkce_required: true/); -expect(cliSource, "CLI receives no provider code or claims", /cli_receives_provider_authorization_code: false[\s\S]*cli_submits_identity_claims: false/); -assert.doesNotMatch(cliSource, /TcpListener::bind|authorization_code.*issuer_url.*client_id/s); -expect(cliSmoke, "smoke uses fake browser opener", /DISASMER_BROWSER_OPEN_COMMAND/); -expect(cliSmoke, "smoke verifies server-owned browser transaction", /server-owned browser transaction/); -expect(cliSmoke, "smoke rejects client OIDC authority", /assert\.deepStrictEqual\(request,[\s\S]*begin_oidc_browser_login[\s\S]*requested_project/); -expect(cliSmoke, "smoke verifies coordinator completion", /scoped_cli_session_received/); -expect(publicAcceptance, "public acceptance runs browser flow smoke", /node scripts\/cli-browser-login-flow-smoke\.js/); - -if (fs.existsSync(infraRepo)) { - const stack = read("modules/stack.nix", infraRepo); - const hypervisor = read("hosts/hypervisor/default.nix", infraRepo); - const oauth = read("modules/oauth-bootstrap.nix", infraRepo); - const site = read("disasmer-site/index.html", infraRepo); - - expect(stack, "Disasmer public host", /publicHost = "disasmer\.michelpaulissen\.com"/); - expect(stack, "Disasmer loopback API port", /apiPort = 9080/); - expect(hypervisor, "nginx Disasmer vhost", /\$\{stack\.hosts\.disasmer\.publicHost\}/); - expect(hypervisor, "nginx Disasmer site root", /root = \.\.\/\.\.\/disasmer-site/); - expect(hypervisor, "nginx control API proxy", /locations\."= \/api\/v1\/control"[\s\S]*proxyPass/); - expect(hypervisor, "nginx hosted callback proxy", /locations\."= \/auth\/callback"[\s\S]*proxyPass/); - assert.doesNotMatch(hypervisor, /locations\."= \/auth\/browser\/start"/); - expect(hypervisor, "hosted service uses configured API port", /stack\.hosts\.disasmer\.apiPort/); - expect(oauth, "Disasmer Authentik provider", /name: disasmer-provider/); - expect(oauth, "Disasmer public OIDC client", /client_type: public/); - expect(oauth, "Disasmer client id", /client_id: \${disasmerClientId}/); - expect(oauth, "Disasmer hosted callback", /https:\/\/disasmer\.michelpaulissen\.com\/auth\/callback/); - expect(oauth, "Disasmer Authentik application", /slug: disasmer/); - expect(site, "site describes CLI login", /disasmer login --browser/); - expect(site, "site describes hosted callback authority", /hosted service processes the callback/); - expect(site, "site explains attached-node execution", /placed on a node you attach and explicitly authorize/); - expect(site, "site labels pre-release status", /pre-release deployment under active verification/); - assert.doesNotMatch(site, /local callback|provider codes and tokens are returned to the CLI/i); -} else { - console.warn("Skipping VPS config checks because ../michelpaulissen.com is not present"); -} - -console.log("Public browser login contract smoke passed"); diff --git a/scripts/public-local-demo-matrix-smoke.js b/scripts/public-local-demo-matrix-smoke.js index 65bbea8..2aac30d 100755 --- a/scripts/public-local-demo-matrix-smoke.js +++ b/scripts/public-local-demo-matrix-smoke.js @@ -40,7 +40,7 @@ for (const script of [publicAcceptance, publicSplit]) { } } -expect(cliInstall, "CLI install from project path", /cargo[\s\S]*install[\s\S]*crates\/disasmer-cli/); +expect(cliInstall, "CLI install from project path", /cargo[\s\S]*install[\s\S]*crates\/clusterflux-cli/); expect(cliInstall, "CLI install smoke targets flagship project", /const project = path\.join\(repo, "examples\/launch-build-demo"\)/); expect(cliInstall, "installed CLI inspects flagship project", /installedBin[\s\S]*\["bundle", "inspect", "--project", project, "--json"\]/); @@ -56,9 +56,9 @@ expect(nodeAttach, "Linux node attach creates enrollment grant", /create_node_en expect(nodeAttach, "Linux node attach uses enrollment exchange", /used_enrollment_exchange[\s\S]*true/); expect(nodeAttach, "attached Linux node proves signed enrollment", /used_enrollment_exchange[\s\S]*signedNodeHeartbeat/); -expect(vscodeExtension, "extension contributes debugger", /contributes\.debuggers[\s\S]*type === "disasmer"/); +expect(vscodeExtension, "extension contributes debugger", /contributes\.debuggers[\s\S]*type === "clusterflux"/); expect(vscodeF5, "F5 uses local-services backend", /runtimeBackend[\s\S]*local-services/); -expect(vscodeF5, "F5 exposes virtual thread", /thread\.name\.includes\("build virtual process"\)[\s\S]*virtual thread/); +expect(vscodeF5, "F5 exposes real coordinator main thread", /threads\.find\(\(thread\) => thread\.name\.includes\("build coordinator main"\)\)[\s\S]*real coordinator-main entrypoint thread/); expect(vscodeF5, "F5 does not fabricate a terminal event at the entry probe", /coordinator_task_events[\s\S]*value === 0[\s\S]*must not fabricate a terminal task event/); expect(artifactDownload, "artifact download creates scoped link", /create_artifact_download_link/); diff --git a/scripts/public-private-boundary-smoke.js b/scripts/public-private-boundary-smoke.js deleted file mode 100644 index d211e00..0000000 --- a/scripts/public-private-boundary-smoke.js +++ /dev/null @@ -1,185 +0,0 @@ -#!/usr/bin/env node - -const assert = require("assert"); -const fs = require("fs"); -const os = require("os"); -const path = require("path"); - -const repo = path.resolve(__dirname, ".."); - -function walk(relativePath, files = []) { - const fullPath = path.join(repo, relativePath); - if (!fs.existsSync(fullPath)) return files; - const stat = fs.statSync(fullPath); - if (stat.isDirectory()) { - const base = path.basename(relativePath); - if (["target", "node_modules", ".git", "containers-home"].includes(base)) { - return files; - } - for (const entry of fs.readdirSync(fullPath)) { - walk(path.join(relativePath, entry), files); - } - return files; - } - files.push(relativePath); - return files; -} - -function read(relativePath) { - return fs.readFileSync(path.join(repo, relativePath), "utf8"); -} - -function copyPublicTree(sourceRoot, destinationRoot, relativePath = ".") { - const skippedDirectories = new Set([ - ".git", - "target", - "node_modules", - "containers-home", - "private", - "experiments", - ]); - const parts = relativePath.split(path.sep).filter(Boolean); - if (parts.some((part) => skippedDirectories.has(part))) { - return; - } - - const source = path.join(sourceRoot, relativePath); - const destination = path.join(destinationRoot, relativePath); - const stat = fs.statSync(source); - if (stat.isDirectory()) { - fs.mkdirSync(destination, { recursive: true }); - for (const entry of fs.readdirSync(source)) { - copyPublicTree(sourceRoot, destinationRoot, path.join(relativePath, entry)); - } - return; - } - - fs.mkdirSync(path.dirname(destination), { recursive: true }); - fs.copyFileSync(source, destination); -} - -function assertPublicSplitTreeIsCoherent() { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "disasmer-public-split-")); - try { - copyPublicTree(repo, tmp); - - for (const forbidden of ["private", "experiments"]) { - assert( - !fs.existsSync(path.join(tmp, forbidden)), - `${forbidden} must be absent from the public split tree` - ); - } - - const workspaceToml = fs.readFileSync(path.join(tmp, "Cargo.toml"), "utf8"); - const membersBlock = workspaceToml.match(/members\s*=\s*\[([\s\S]*?)\]/); - assert(membersBlock, "public workspace must define members"); - const members = [...membersBlock[1].matchAll(/"([^"]+)"/g)].map( - (match) => match[1] - ); - assert(members.length > 0, "public workspace must list members"); - for (const member of members) { - assert( - !member.startsWith("private/") && !member.startsWith("experiments/"), - `public workspace member must not point at filtered source: ${member}` - ); - assert( - fs.existsSync(path.join(tmp, member, "Cargo.toml")), - `public workspace member is missing after split: ${member}` - ); - } - - for (const required of [ - "scripts/acceptance-public.sh", - "scripts/verify-public-split.sh", - "scripts/public-private-boundary-smoke.js", - "scripts/public-local-demo-matrix-smoke.js", - "vscode-extension/package.json", - "examples/launch-build-demo/Cargo.toml", - ]) { - assert( - fs.existsSync(path.join(tmp, required)), - `public split tree is missing ${required}` - ); - } - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } -} - -function publicSourceFiles() { - return [ - ...walk("crates"), - ...walk("examples"), - ...walk("scripts"), - ...walk("vscode-extension"), - "Cargo.toml", - ].filter((file) => { - if (file === "scripts/acceptance-private.sh") return false; - if (file === "scripts/acceptance-evidence-contract-smoke.js") return false; - if (file === "scripts/acceptance-environment-contract-smoke.js") return false; - if (file === "scripts/docs-smoke.js") return false; - if (file === "scripts/public-private-boundary-smoke.js") return false; - if (file === "scripts/release-blocker-smoke.js") return false; - return /\.(rs|toml|js|json|sh)$/.test(file); - }); -} - -const forbiddenPublicPatterns = [ - /\bdisasmer[_-]hosted[_-]policy\b/, - /private\/hosted-policy/, - /path\s*=\s*["'][^"']*private\//, - /include!\s*\([^)]*private\//, - /mod\s+private_hosted/, -]; - -for (const file of publicSourceFiles()) { - const content = read(file); - for (const pattern of forbiddenPublicPatterns) { - assert( - !pattern.test(content), - `public source ${file} must not reference private hosted code via ${pattern}` - ); - } -} - -const workspace = read("Cargo.toml"); -assert( - !/members\s*=\s*\[[\s\S]*private\//.test(workspace), - "public workspace members must not include private hosted crates" -); -assertPublicSplitTreeIsCoherent(); - -const privateHostedRoot = path.join(repo, "private", "hosted-policy"); -if (fs.existsSync(privateHostedRoot)) { - const privateCargo = read("private/hosted-policy/Cargo.toml"); - assert.match(privateCargo, /name\s*=\s*"disasmer-hosted-policy"/); - assert.match(privateCargo, /disasmer-core\s*=\s*\{/); - assert.match(privateCargo, /disasmer-coordinator\s*=\s*\{/); - - const privateLib = read("private/hosted-policy/src/lib.rs"); - for (const required of [ - "AuthentikOidcConfig", - "CommunityTierPolicy", - "AdminControls", - "preflight_zero_capability_hosted_wasm", - "impl CapabilityPolicy for CommunityTierPolicy", - "AuthContext", - ]) { - assert( - privateLib.includes(required), - `private hosted policy is missing ${required}` - ); - } - - const privateFiles = walk("private").filter((file) => !file.includes("/target/")); - const privateNodeRuntimeFiles = privateFiles.filter((file) => - /(^|\/)(disasmer-node|node-runtime)(\/|$)/.test(file) - ); - assert.deepStrictEqual( - privateNodeRuntimeFiles, - [], - "hosted mode must not carry a forked private node runtime" - ); -} - -console.log("Public/private boundary smoke passed"); diff --git a/scripts/public-release-dryrun-contract-smoke.js b/scripts/public-release-dryrun-contract-smoke.js deleted file mode 100644 index 4b5d26c..0000000 --- a/scripts/public-release-dryrun-contract-smoke.js +++ /dev/null @@ -1,321 +0,0 @@ -#!/usr/bin/env node - -const assert = require("assert"); -const fs = require("fs"); -const path = require("path"); - -const repo = path.resolve(__dirname, ".."); -const serviceEndpoint = "https://disasmer.michelpaulissen.com"; -const serviceHost = "disasmer.michelpaulissen.com"; -const forgejoHost = "git.michelpaulissen.com"; - -function read(relativePath) { - return fs.readFileSync(path.join(repo, relativePath), "utf8"); -} - -function maybeRead(relativePath) { - const file = path.join(repo, relativePath); - return fs.existsSync(file) ? fs.readFileSync(file, "utf8") : null; -} - -function expect(source, name, pattern) { - assert(source !== null && source !== undefined, `missing source for ${name}`); - assert.match(source, pattern, `missing public release dry-run evidence: ${name}`); -} - -const phase2 = maybeRead("acceptance_criteria_phase2.md"); -const readme = maybeRead("README.md"); -const cliSource = [ - read("crates/disasmer-cli/src/main.rs"), - read("crates/disasmer-cli/src/config.rs"), - read("crates/disasmer-cli/src/run.rs"), - read("crates/disasmer-cli/src/client.rs"), - read("crates/disasmer-cli/src/tests.rs"), -].join("\n"); -const controlSource = read("crates/disasmer-control/src/lib.rs"); -const cliLoginSmoke = read("scripts/cli-login-smoke.js"); -const vscodePackage = JSON.parse(read("vscode-extension/package.json")); -const vscodeExtension = read("vscode-extension/extension.js"); -const vscodeSmoke = read("scripts/vscode-extension-smoke.js"); -const prepScript = read("scripts/prepare-public-release-dryrun.js"); -const publishScript = read("scripts/publish-public-release-dryrun.js"); -const preflightScript = read("scripts/public-release-dryrun-preflight.js"); -const e2eScript = read("scripts/public-release-dryrun-e2e.js"); -const finalEvidenceScript = read("scripts/public-release-dryrun-final-evidence.js"); -const workflow = maybeRead(".forgejo/workflows/public-release-dryrun.yml"); -const publicAcceptance = read("scripts/acceptance-public.sh"); -const privateAcceptance = read("scripts/acceptance-private.sh"); -const publicSplit = read("scripts/verify-public-split.sh"); - -if (readme) { - expect(readme, "README", new RegExp(serviceHost.replaceAll(".", "\\."))); -} - -if (phase2) { - const name = "phase 2 criteria"; - expect(phase2, name, new RegExp(serviceHost.replaceAll(".", "\\."))); - expect(phase2, `${name} DNS publication state`, /DNS record|public DNS|dns[-_]publication/i); - expect(phase2, `${name} resolver fallback`, /no resolver override|required resolver override|hosts entry|controlled resolution|fallback/i); - expect(phase2, `${name} Forgejo host`, new RegExp(forgejoHost.replaceAll(".", "\\."))); - expect(phase2, `${name} Forgejo Release`, /Forgejo Release/); - expect(phase2, `${name} compiled assets`, /compiled\s+(release\s+)?assets|compiled release assets/); - expect(phase2, `${name} filtered tree`, /private\/\*\*[\s\S]*experiments\/\*\*/); - expect(phase2, `${name} root Markdown export filter`, /root `\*\.md`|root Markdown/); - expect(phase2, `${name} host-neutral workflow filter`, /\.forgejo[\s\S]*--include-forgejo-workflows/); - expect(phase2, `${name} GitHub release out of scope`, /GitHub[\s\S]*outside this dry run|public GitHub-release[\s\S]*out of scope/); -} - -expect(cliSource, "CLI default hosted coordinator constant", new RegExp(`DEFAULT_HOSTED_COORDINATOR_ENDPOINT: &str =[\\s\\S]*"${serviceEndpoint.replaceAll(".", "\\.")}"`)); -expect(cliSource, "login uses default hosted coordinator", /default_value_t = default_hosted_coordinator_endpoint\(\)/); -expect(cliSource, "hosted run records coordinator endpoint", /hosted_coordinator_endpoint[\s\S]*Some\(default_hosted_coordinator_endpoint\(\)\)/); -expect(controlSource, "hosted URL uses the shared HTTP control transport", /ControlTransport::Https[\s\S]*agent[\s\S]*\.post\(url\)/); -expect(controlSource, "hosted control transport uses the canonical API path", /CONTROL_API_PATH: &str = "\/api\/v1\/control"/); -expect(controlSource, "plain HTTP is restricted to loopback", /endpoint\.starts_with\("http:\/\/"\) && !endpoint_is_loopback\(endpoint\)/); -expect(cliSource, "default hosted coordinator resolves to the HTTPS control API", /hosted_coordinator_remains_a_real_https_control_endpoint[\s\S]*https:\/\/disasmer\.michelpaulissen\.com\/api\/v1\/control/); -assert.doesNotMatch(cliSource, /coord\.disasmer\.invalid/); - -expect(cliLoginSmoke, "CLI login smoke covers default hosted coordinator", new RegExp(`defaultHostedCoordinatorEndpoint = "${serviceEndpoint.replaceAll(".", "\\.")}"`)); -assert.strictEqual( - vscodePackage.contributes.debuggers[0].configurationAttributes.launch.properties.coordinatorEndpoint.default, - undefined -); -expect(vscodeSmoke, "VS Code smoke covers session-inferred coordinator endpoint", /coordinatorEndpoint\.default, undefined/); - -for (const binary of [ - "disasmer", - "disasmer-coordinator", - "disasmer-node", - "disasmer-debug-dap", -]) { - expect(prepScript, `release prep includes ${binary}`, new RegExp(`"${binary}"`)); -} - -for (const [name, pattern] of [ - ["release prep filters private and experiments", /filteredTopLevel = \["private", "experiments", "\.git", "target"\]/], - ["release prep enumerates Git-ignored source paths", /function gitIgnoredSourcePaths\(\)[\s\S]*"ls-files"[\s\S]*"--ignored"[\s\S]*"--exclude-standard"[\s\S]*"--directory"/], - ["release prep has archive-safe ignored-path fallbacks without Git metadata", /archiveIgnoredPathFallbacks = \[[\s\S]*"target"[\s\S]*"\.disasmer"[\s\S]*"vscode-extension\/node_modules"[\s\S]*"scripts\/containers-home"[\s\S]*if \(output === null\) \{[\s\S]*return archiveIgnoredPathFallbacks/], - ["release prep filters Git-ignored source paths", /isGitIgnored\(childRelative, ignoredSourcePaths\)/], - ["release prep verifies Git-ignored paths are absent", /Git-ignored source path leaked into the dry-run public tree/], - ["release prep filters generated view state", /filteredDirectoryNames = \["\.disasmer"\]/], - ["release prep filters internal root Markdown", /isFilteredRootMarkdown[\s\S]*path\.extname\(entry\.name\)\.toLowerCase\(\) === "\.md"[\s\S]*README\.md[\s\S]*SECURITY\.md/], - ["release prep retains product README", /product README\.md is missing from the dry-run public tree/], - ["release prep filters .forgejo by default", /topLevel === "\.forgejo" && !includeForgejoWorkflows/], - ["release prep has Forgejo workflow opt-in argument", /--include-forgejo-workflows/], - ["release prep builds public release bins", /cargo"[\s\S]*"build"[\s\S]*"--workspace"[\s\S]*"--bins"[\s\S]*"--release"/], - ["release prep writes binary archive", /disasmer-public-binaries-\$\{releaseName\}-\$\{platformName\(\)\}\.tar\.gz/], - ["release prep writes source archive", /disasmer-public-source-\$\{releaseName\}\.tar\.gz/], - ["release prep writes extension VSIX", /\$\{packageJson\.name\}-\$\{packageJson\.version\}\.vsix[\s\S]*@vscode\/vsce[\s\S]*package/], - ["release prep packages extension non-interactively", /@vscode\/vsce[\s\S]*package[\s\S]*--skip-license/], - ["release prep writes selected-user guide", /DISASMER_PUBLIC_DRYRUN_GETTING_STARTED-\$\{releaseName\}\.md/], - ["release prep writes selected-user invite", /DISASMER_PUBLIC_DRYRUN_INVITE-\$\{releaseName\}\.md/], - ["release prep accepts resolver instructions", /DISASMER_PUBLIC_DRYRUN_RESOLVER_INSTRUCTIONS/], - ["release prep accepts hosts entry", /DISASMER_PUBLIC_DRYRUN_HOSTS_ENTRY/], - ["release prep accepts deployment IP", /DISASMER_PUBLIC_RELEASE_DRYRUN_IP/], - ["selected-user guide documents DNS or fallback", /public DNS[\s\S]*(fallback|hosts entry)|DNS record[\s\S]*controlled resolution/], - ["selected-user guide names hosted coordinator", /default hosted coordinator is the deployment/], - ["selected-user guide rejects standalone Core coordinator", /standalone open-source Core coordinator/], - ["selected-user invite documents friends dry run", /friends helping test[\s\S]*not broadly advertised/], - ["selected-user invite distinguishes Core", /hosted server is not the standalone Core\s+coordinator/], - ["selected-user guide documents default login", /disasmer login --browser/], - ["selected-user guide documents VSIX install", /code --install-extension disasmer-vscode-\*\.vsix/], - ["selected-user guide documents node attach", /disasmer node attach --coordinator https:\/\/disasmer\.michelpaulissen\.com/], - ["release prep packages generated evidence", /stageEvidenceAsset[\s\S]*EVIDENCE_BINDING\.json[\s\S]*disasmer-public-evidence-/], - ["release prep binds binary digests", /binary_digests: binaryDigests/], - ["release prep writes checksums", /SHA256SUMS/], - ["release prep writes manifest", /public-release-manifest\.json/], - ["release prep writes public tree provenance", /DISASMER_PUBLIC_TREE\.json/], - ["release prep records public tree identity", /public_tree_identity: publicTreeIdentity/], - ["release prep records DNS state", /dns_publication_state:/], - ["release prep records resolver override", /resolver_override:/], - ["release prep records Forgejo URLs", /public_repo_url:[\s\S]*public_repo_remote:[\s\S]*forgejo_release_url:/], - ["release prep requires publish opt-in", /DISASMER_PUBLISH_PUBLIC_TREE/], - ["release prep requires Forgejo remote", /remote\.includes\(forgejoHost\)/], - ["release prep can push filtered tree", /git"[\s\S]*"push"[\s\S]*"public"[\s\S]*`HEAD:\$\{publicRepoBranch\}`/], - ["release prep records publish result", /public_tree_publish: publicTreePublish/], - ["release prep publishes tree before building target", /const publicTreePublish = publishPublicTree[\s\S]*buildPublicBinaries\(\)/], -]) { - expect(prepScript, name, pattern); -} - -for (const [name, pattern] of [ - ["release publisher requires Forgejo token", /DISASMER_FORGEJO_TOKEN/], - ["release publisher infers Forgejo repo identity", /function resolveRepoIdentity\(manifest\)[\s\S]*parseForgejoRepoIdentity/], - ["release publisher uses manifest public repo URL", /manifest\.public_repo_url[\s\S]*manifest\.public_repo_remote/], - ["release publisher uses token auth", /Authorization: `token \$\{token\}`/], - ["release publisher lists releases", /\/repos\/\$\{encodeURIComponent\(owner\)\}\/\$\{encodeURIComponent\(repoName\)\}\/releases\?limit=100/], - ["release publisher creates release", /POST[\s\S]*\/repos\/\$\{encodeURIComponent\(owner\)\}\/\$\{encodeURIComponent\(repoName\)\}\/releases/], - ["release publisher reloads release details", /function loadRelease\(releaseId\)[\s\S]*\/releases\/\$\{releaseId\}/], - ["release publisher uploads assets", /releases\/\$\{release\.id\}\/assets\?name=\$\{encodeURIComponent\(asset\.name\)\}/], - ["release publisher uses attachment multipart field", /multipartFile\("attachment", asset\.file\)/], - ["release publisher skips already attached assets", /existingAssetByName\(release, asset\.name\)/], - ["release publisher rejects stale release manifests", /manifest\.source_commit[\s\S]*expectedSourceCommit\(\)/], - ["release publisher checks manifest kind", /manifest\.kind !== "disasmer-public-release-dryrun"/], - ["release publisher requires public tree push", /public tree must be pushed before publishing the Forgejo Release/], - ["release publisher accepts external public tree push", /function publicTreeAlreadyPushed\(manifest\)[\s\S]*DISASMER_PUBLIC_TREE_ALREADY_PUSHED/], - ["release publisher targets external public tree commit", /function publicTreeCommit\(manifest\)[\s\S]*DISASMER_PUBLIC_TREE_COMMIT/], - ["release publisher writes evidence report", /public-release-dryrun-forgejo-release\.json/], - ["release publisher records reused assets", /reused_assets:/], -]) { - expect(publishScript, name, pattern); -} - -for (const [name, pattern] of [ - ["preflight rejects stale release manifests", /manifest\.source_commit[\s\S]*currentSourceCommit/], - ["preflight disables interactive remote prompts", /function nonInteractiveEnv[\s\S]*GIT_TERMINAL_PROMPT[\s\S]*GIT_ASKPASS[\s\S]*SSH_ASKPASS[\s\S]*GIT_SSH_COMMAND[\s\S]*BatchMode=yes[\s\S]*NumberOfPasswordPrompts=0[\s\S]*git", \["ls-remote"[\s\S]*timeout/], - ["preflight accepts external public tree push", /function publicTreeAlreadyPushed\(manifest\)[\s\S]*DISASMER_PUBLIC_TREE_ALREADY_PUSHED/], - ["preflight verifies public branch commit", /remoteMain[\s\S]*publicTreeCommit/], - ["preflight verifies local asset checksums", /parseSha256Sums[\s\S]*checksum mismatch/], - ["preflight records public tree push source", /public_tree_push_source/], - ["preflight records pending external gates", /external_gates:[\s\S]*forgejo_release_publication[\s\S]*public_release_e2e/], - ["preflight writes evidence report", /public-release-dryrun-preflight\.json/], -]) { - expect(preflightScript, name, pattern); -} - -for (const [name, pattern] of [ - ["e2e runner requires explicit opt-in", /DISASMER_PUBLIC_RELEASE_DRYRUN_E2E=1/], - ["e2e runner rejects stale release manifests", /manifest\.source_commit[\s\S]*expectedSourceCommit\(\)/], - ["e2e runner disables interactive git prompts", /function nonInteractiveGitEnv[\s\S]*GIT_TERMINAL_PROMPT[\s\S]*GIT_ASKPASS[\s\S]*SSH_ASKPASS[\s\S]*GIT_SSH_COMMAND[\s\S]*BatchMode=yes[\s\S]*NumberOfPasswordPrompts=0[\s\S]*function commandEnv/], - ["e2e runner accepts external public tree push", /function publicTreeAlreadyPushed\(manifest\)[\s\S]*DISASMER_PUBLIC_TREE_ALREADY_PUSHED/], - ["e2e runner downloads Forgejo Release assets", /downloadReleaseAssets/], - ["e2e runner verifies SHA256SUMS", /verifyChecksums/], - ["e2e runner clones public repo", /"git", \["clone", "--depth", "1"/], - ["e2e runner rejects private tree leaks", /private[\s\S]*experiments[\s\S]*target/], - ["e2e runner rejects internal root Markdown leaks", /internal root Markdown file leaked into public repo/], - ["e2e runner requires product README", /public checkout must include product README\.md/], - ["e2e runner rejects .forgejo leaks by default", /\.forgejo\/ leaked into public repo/], - ["e2e runner rejects generated view state", /generated \.disasmer view state leaked into public repo/], - ["e2e runner checks public tree identity", /hashTree\(checkout\)[\s\S]*manifest\.public_tree_identity/], - ["e2e runner extracts public binaries", /tar"[\s\S]*"-xzf"/], - ["e2e runner loads public coordinator binary", /executable\(installDir, "disasmer-coordinator"\)/], - ["e2e runner checks default hosted coordinator", /defaultLoginPlan\.coordinator[\s\S]*serviceEndpoint/], - ["e2e runner requires an external browser driver", /DISASMER_PUBLIC_RELEASE_DRYRUN_BROWSER_OPEN_COMMAND/], - ["e2e runner uses the CLI browser opener boundary", /DISASMER_BROWSER_OPEN_COMMAND: browserOpenCommand/], - ["e2e runner derives scope from the hosted session", /const tenant = loginSession\.tenant[\s\S]*const project = loginSession\.project[\s\S]*const user = loginSession\.user/], - ["e2e runner uses public CLI node attach", /"node"[\s\S]*"attach"[\s\S]*serviceEndpoint/], - ["e2e runner starts public worker runtime", /workerArgs[\s\S]*"--worker"[\s\S]*cp\.spawn\(disasmerNode/], - ["e2e runner launches the released product through CLI", /"run"[\s\S]*"build"[\s\S]*runReport\.status[\s\S]*main_launched/], - ["standalone Core proof launches a real Wasm TaskSpec", /type: "launch_task"[\s\S]*task_spec:[\s\S]*kind: "coordinator_node_wasm"[\s\S]*bundle_digest: manifest\.bundle_digest/], - ["e2e runner verifies assignment polling", /worker_assignment_poll_verified/], - ["e2e runner validates standalone Core coordinator", /validateStandaloneCoreCoordinator/], - ["e2e runner uses canonical standalone Core wire envelopes", /function sendCore\(addr, message\)[\s\S]*coordinatorWireRequest\(message, "public-e2e-core"\)/], - ["e2e runner records public coordinator validation", /core_coordinator_validated/], - ["e2e runner validates released live DAP", /validateReleasedLiveDap/], - ["e2e runner verifies attach-mode Debug Epoch transition", /validateReleasedLiveDap[\s\S]*resumed through coordinator[\s\S]*requested freeze/], - ["e2e runner verifies attached coordinator task completion", /validateReleasedLiveDap[\s\S]*variable\.name === "state" && variable\.value === "Completed"[\s\S]*coordinator task event from node/], - ["e2e runner attaches live DAP to Client-authorized process", /attached_to_existing_client_process/], - ["e2e runner verifies task events", /type: "list_task_events"/], - ["e2e runner verifies download link", /type: "create_artifact_download_link"/], - ["e2e runner verifies VS Code debugger", /scripts\/vscode-f5-smoke\.js/], - ["e2e runner writes e2e report", /public-release-dryrun-e2e\.json/], -]) { - expect(e2eScript, name, pattern); -} - -for (const [name, pattern] of [ - ["final evidence reads manifest", /public-release-manifest\.json/], - ["final evidence rejects stale release manifests", /manifest\.source_commit[\s\S]*expectedSourceCommit\(\)/], - ["final evidence verifies filtered export", /function assertFilteredOut\(manifest\)/], - ["final evidence reads Forgejo Release report", /public-release-dryrun-forgejo-release\.json/], - ["final evidence reads deployment manifest", /deployment-manifest\.json/], - ["final evidence reads service smoke report", /public-release-dryrun-service\.json/], - ["final evidence reads hosted Client compatibility report", /hosted-client-compat\.json/], - ["final evidence reads Core coordinator compatibility report", /core-coordinator-compat\.json/], - ["final evidence requires public e2e report", /public-release-dryrun-e2e\.json/], - ["final evidence verifies Forgejo host", /forgejoHost = "git\.michelpaulissen\.com"/], - ["final evidence verifies default hosted coordinator", /serviceEndpoint = "https:\/\/disasmer\.michelpaulissen\.com"/], - ["final evidence accepts external public tree push", /function publicTreeAlreadyPushed\(manifest\)[\s\S]*DISASMER_PUBLIC_TREE_ALREADY_PUSHED/], - ["final evidence records resolved public repo", /resolvedPublicRepoRemote[\s\S]*public_repository_url: resolvedPublicRepoRemote/], - ["final evidence requires private hosted coordinator", /coordinator_implementation[\s\S]*"hosted-policy-coordinator"/], - ["final evidence requires service private coordinator marker", /service\.coordinator_implementation[\s\S]*"hosted-policy-coordinator"/], - ["final evidence requires current service smoke source", /service\.source_commit[\s\S]*manifest\.source_commit/], - ["final evidence requires current service smoke release", /service\.release_name[\s\S]*manifest\.release_name/], - ["final evidence requires current Client compatibility source", /compat\.source_commit[\s\S]*manifest\.source_commit/], - ["final evidence requires current Client compatibility release", /compat\.release_name[\s\S]*manifest\.release_name/], - ["final evidence requires current public coordinator source", /coreCoordinator\.source_commit[\s\S]*manifest\.source_commit/], - ["final evidence requires current public coordinator release", /coreCoordinator\.release_name[\s\S]*manifest\.release_name/], - ["final evidence requires server-generated service enrollment", /"server_generated_enrollment_grant"/], - ["final evidence requires service probe cleanup", /"process_abort"/], - ["final evidence requires process main launch", /e2e\.launch_task_response[\s\S]*"main_launched"/], - ["final evidence requires assignment polling", /worker_assignment_poll_verified/], - ["final evidence requires public coordinator validation", /core_coordinator_validated/], - ["final evidence requires standalone Core coordinator", /standalone-core-coordinator/], - ["final evidence requires released live DAP", /released_live_dap_verified/], - ["final evidence requires pushed public tree", /publicTreeAlreadyPushed\(manifest\)/], - ["final evidence requires release assets", /downloaded_release_assets/], - ["final evidence requires VS Code debugger", /vscode_debugger_verified/], - ["final evidence writes final report", /public-release-dryrun-final\.json/], -]) { - expect(finalEvidenceScript, name, pattern); -} - -if (workflow) { - for (const [name, pattern] of [ - ["manual Forgejo workflow", /workflow_dispatch:/], - ["Linux Forgejo asset job", /linux-assets:[\s\S]*runs-on: docker/], - ["Windows Forgejo asset job", /windows-assets:[\s\S]*runs-on: windows/], - ["Windows runner caveat", /intermittently online/], - ["workflow runs release prep", /node scripts\/prepare-public-release-dryrun\.js/], - ["workflow uploads assets", /actions\/upload-artifact@v4[\s\S]*target\/public-release-dryrun\/assets\/\*/], - ]) { - expect(workflow, name, pattern); - } -} - -expect( - prepScript, - "public tree prep disables interactive git prompts", - /function nonInteractiveGitEnv[\s\S]*GIT_TERMINAL_PROMPT[\s\S]*GIT_ASKPASS[\s\S]*SSH_ASKPASS[\s\S]*GIT_SSH_COMMAND[\s\S]*BatchMode=yes[\s\S]*NumberOfPasswordPrompts=0[\s\S]*function commandEnv/ -); - -for (const [scriptName, script] of [ - ["public acceptance", publicAcceptance], - ["public split", publicSplit], -]) { - assert( - script.includes("node scripts/public-release-dryrun-contract-smoke.js"), - `${scriptName} must run public-release-dryrun-contract-smoke.js` - ); - assert( - script.includes("node scripts/prepare-public-release-dryrun.js"), - `${scriptName} must run prepare-public-release-dryrun.js` - ); - assert( - script.includes("node scripts/publish-public-release-dryrun.js"), - `${scriptName} must be able to publish the Forgejo Release` - ); - assert( - script.includes("DISASMER_FORGEJO_TOKEN"), - `${scriptName} must gate Forgejo Release publishing on a token` - ); - assert( - script.includes('if [[ -n "${DISASMER_FORGEJO_TOKEN:-}" ]]'), - `${scriptName} must not require owner/repo env when the manifest can infer the Forgejo repository` - ); -} - -for (const [scriptName, script] of [ - ["public acceptance", publicAcceptance], - ["private acceptance", privateAcceptance], -]) { - if (scriptName === "public acceptance") { - assert( - script.includes("node scripts/public-release-dryrun-e2e.js"), - "public acceptance must be able to run public-release-dryrun-e2e.js" - ); - assert( - script.includes("DISASMER_PUBLIC_RELEASE_DRYRUN_E2E"), - "public acceptance must gate public-release-dryrun-e2e.js" - ); - } - assert( - script.includes("node scripts/public-release-dryrun-final-evidence.js"), - `${scriptName} must be able to run public-release-dryrun-final-evidence.js` - ); - assert( - script.includes("DISASMER_PUBLIC_RELEASE_DRYRUN_FINAL"), - `${scriptName} must gate final public release dry-run evidence` - ); -} - -console.log("Public release dry-run contract smoke passed"); diff --git a/scripts/public-release-dryrun-e2e.js b/scripts/public-release-dryrun-e2e.js deleted file mode 100755 index 4eb57ed..0000000 --- a/scripts/public-release-dryrun-e2e.js +++ /dev/null @@ -1,1422 +0,0 @@ -#!/usr/bin/env node - -const assert = require("assert"); -const cp = require("child_process"); -const crypto = require("crypto"); -const fs = require("fs"); -const http = require("http"); -const https = require("https"); -const net = require("net"); -const os = require("os"); -const path = require("path"); -const { configurePodmanTestEnvironment } = require("./podman-test-env"); -const { coordinatorWireRequest } = require("./coordinator-wire"); -const { nodeIdentity } = require("./node-signing"); -const { - assertPreFinalLedger, - readPhase3Ledger, -} = require("./phase3-ledger"); - -const repo = path.resolve(__dirname, ".."); -configurePodmanTestEnvironment(repo); -if ( - !process.env.DISASMER_PODMAN_NIX_SHELL && - cp.spawnSync("podman", ["--version"], { stdio: "ignore" }).status !== 0 && - cp.spawnSync("nix", ["--version"], { stdio: "ignore" }).status === 0 -) { - cp.execFileSync( - "nix", - ["shell", "nixpkgs#podman", "--command", "node", __filename], - { - cwd: repo, - env: { ...process.env, DISASMER_PODMAN_NIX_SHELL: "1" }, - stdio: "inherit", - } - ); - process.exit(0); -} -const releaseRoot = path.resolve( - process.env.DISASMER_PUBLIC_RELEASE_DIR || - path.join(repo, "target/public-release-dryrun") -); -const acceptanceRoot = path.join(repo, "target/acceptance"); -const manifestPath = - process.env.DISASMER_PUBLIC_RELEASE_MANIFEST || - path.join(releaseRoot, "public-release-manifest.json"); -const forgejoReportPath = - process.env.DISASMER_PUBLIC_RELEASE_FORGEJO_REPORT || - path.join(acceptanceRoot, "public-release-dryrun-forgejo-release.json"); -const reportPath = - process.env.DISASMER_PUBLIC_RELEASE_E2E_REPORT || - path.join(acceptanceRoot, "public-release-dryrun-e2e.json"); -const serviceEndpoint = "https://disasmer.michelpaulissen.com"; -const serviceHost = "disasmer.michelpaulissen.com"; -const serviceAddr = - process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_SERVICE_ADDR || `${serviceHost}:443`; -const forgejoHost = "git.michelpaulissen.com"; -const enabled = process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_E2E === "1"; -const browserOpenCommand = - process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_BROWSER_OPEN_COMMAND; -const dnsPublicationState = - process.env.DISASMER_DNS_PUBLICATION_STATE || "published"; -const resolverOverride = - process.env.DISASMER_RESOLVER_OVERRIDE || "none-required-public-dns"; - -function requireEnabled() { - if (!enabled) { - throw new Error( - "DISASMER_PUBLIC_RELEASE_DRYRUN_E2E=1 is required because this runs the real public dry-run e2e" - ); - } - if (!browserOpenCommand) { - throw new Error( - "DISASMER_PUBLIC_RELEASE_DRYRUN_BROWSER_OPEN_COMMAND is required to complete the server-owned hosted browser login" - ); - } -} - -function readJson(file) { - return JSON.parse(fs.readFileSync(file, "utf8")); -} - -function ensureDir(dir) { - fs.mkdirSync(dir, { recursive: true }); -} - -function run(command, args, options = {}) { - const commandLine = [command, ...args].join(" "); - commands.push(commandLine); - const execOptions = { - cwd: repo, - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - timeout: 15 * 60 * 1000, - ...options, - }; - execOptions.env = commandEnv(command, options.env); - return cp.execFileSync(command, args, execOptions); -} - -function runJson(command, args, options = {}) { - const output = run(command, args, options); - try { - return JSON.parse(output); - } catch (_) { - const line = output - .trim() - .split("\n") - .filter(Boolean) - .at(-1); - return JSON.parse(line); - } -} - -function commandOutput(command, args, options = {}) { - try { - const execOptions = { - cwd: repo, - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], - ...options, - }; - execOptions.env = commandEnv(command, options.env); - return cp.execFileSync(command, args, execOptions).trim(); - } catch (_) { - return null; - } -} - -function nonInteractiveGitEnv(extra = {}) { - return { - ...process.env, - GIT_TERMINAL_PROMPT: "0", - GIT_ASKPASS: process.env.GIT_ASKPASS || "/bin/false", - SSH_ASKPASS: process.env.SSH_ASKPASS || "/bin/false", - GIT_SSH_COMMAND: - process.env.GIT_SSH_COMMAND || - "ssh -o BatchMode=yes -o NumberOfPasswordPrompts=0", - ...extra, - }; -} - -function commandEnv(command, env) { - if (command !== "git") { - return env; - } - return nonInteractiveGitEnv(env); -} - -function expectedSourceCommit() { - return ( - process.env.DISASMER_ACCEPTANCE_COMMIT || - commandOutput("git", ["rev-parse", "HEAD"]) || - "unknown" - ); -} - -function publicTreeAlreadyPushed(manifest) { - return ( - (manifest.public_tree_publish && manifest.public_tree_publish.pushed === true) || - process.env.DISASMER_PUBLIC_TREE_ALREADY_PUSHED === "1" - ); -} - -function sha256File(file) { - return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); -} - -function parseAddr(value) { - const match = /^([^:]+):(\d+)$/.exec(value); - if (!match) { - throw new Error(`service address must be host:port, got ${value}`); - } - return { host: match[1], port: Number(match[2]) }; -} - -function send(addr, message) { - return new Promise((resolve, reject) => { - const body = Buffer.from(JSON.stringify(message)); - const request = https.request({ - hostname: addr.host, - port: addr.port, - servername: serviceHost, - path: "/api/v1/control", - method: "POST", - headers: { - "Content-Type": "application/json", - "Content-Length": body.length, - }, - timeout: 30000, - }, (response) => { - const chunks = []; - response.on("data", (chunk) => chunks.push(chunk)); - response.on("end", () => { - const responseBody = Buffer.concat(chunks).toString("utf8"); - if (response.statusCode < 200 || response.statusCode >= 300) { - reject(new Error(`hosted control HTTP ${response.statusCode}: ${responseBody}`)); - return; - } - try { - resolve(JSON.parse(responseBody)); - } catch (error) { - reject(error); - } - }); - }); - request.on("timeout", () => request.destroy(new Error(`timed out waiting for ${message.type}`))); - request.on("error", reject); - request.end(body); - }); -} - -function sendCore(addr, message) { - return new Promise((resolve, reject) => { - const socket = net.connect(addr.port, addr.host, () => { - socket.write( - `${JSON.stringify(coordinatorWireRequest(message, "public-e2e-core"))}\n` - ); - }); - let buffer = ""; - const timer = setTimeout(() => { - socket.destroy(); - reject(new Error(`timed out waiting for standalone Core ${message.type}`)); - }, 30000); - socket.on("data", (chunk) => { - buffer += chunk.toString(); - const newline = buffer.indexOf("\n"); - if (newline < 0) return; - clearTimeout(timer); - socket.destroy(); - try { - resolve(JSON.parse(buffer.slice(0, newline))); - } catch (error) { - reject(error); - } - }); - socket.on("error", (error) => { - clearTimeout(timer); - reject(error); - }); - }); -} - -let hostedClientRequestId = 0; -function authenticatedHostedClientRequest(sessionSecret, message) { - hostedClientRequestId += 1; - return { - type: "coordinator_request", - protocol_version: 1, - request_id: `public-e2e-client-${hostedClientRequestId}`, - operation: "authenticated", - authentication: { - kind: "cli_session", - session: true, - request_operation: message.type, - }, - payload: { - type: "authenticated", - session_secret: sessionSecret, - request: message, - }, - }; -} - -function waitForJsonLine(child, label = "process", timeoutMs = 240000) { - return new Promise((resolve, reject) => { - let buffer = ""; - const timer = setTimeout(() => { - cleanup(); - reject(new Error(`timed out waiting for JSON line from ${label}`)); - }, timeoutMs); - function cleanup() { - clearTimeout(timer); - child.stdout.off("data", onData); - child.off("exit", onExit); - } - function onData(chunk) { - buffer += chunk.toString(); - const newline = buffer.indexOf("\n"); - if (newline < 0) return; - try { - cleanup(); - resolve(JSON.parse(buffer.slice(0, newline).trim())); - } catch (error) { - cleanup(); - reject(error); - } - } - function onExit(code) { - cleanup(); - reject(new Error(`${label} exited before JSON line with code ${code}`)); - } - child.stdout.on("data", onData); - child.once("exit", onExit); - }); -} - -function stopChild(child) { - if (!child || child.exitCode !== null) { - return Promise.resolve(); - } - return new Promise((resolve) => { - const timer = setTimeout(() => { - child.kill("SIGKILL"); - resolve(); - }, 5000); - child.once("exit", () => { - clearTimeout(timer); - resolve(); - }); - child.kill("SIGTERM"); - }); -} - -function sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -class DapClient { - constructor(command, options = {}) { - this.child = cp.spawn(command, [], { - cwd: options.cwd, - detached: process.platform !== "win32", - }); - this.seq = 1; - this.buffer = Buffer.alloc(0); - this.messages = []; - this.waiters = []; - this.stderr = ""; - - this.child.stdout.on("data", (chunk) => { - this.buffer = Buffer.concat([this.buffer, chunk]); - this.parse(); - }); - this.child.stderr.on("data", (chunk) => { - this.stderr += chunk.toString(); - }); - this.child.on("exit", () => this.flushWaiters()); - } - - send(command, args = {}) { - const seq = this.seq++; - const message = { seq, type: "request", command, arguments: args }; - const payload = Buffer.from(JSON.stringify(message)); - this.child.stdin.write(`Content-Length: ${payload.length}\r\n\r\n`); - this.child.stdin.write(payload); - return seq; - } - - async response(seq, command) { - const message = await this.waitFor( - (item) => - item.type === "response" && - item.request_seq === seq && - item.command === command - ); - if (!message.success) { - throw new Error(`DAP ${command} failed: ${message.message || JSON.stringify(message)}`); - } - return message; - } - - terminate() { - if (this.child.exitCode !== null) return; - if (process.platform === "win32") { - this.child.kill("SIGKILL"); - return; - } - try { - process.kill(-this.child.pid, "SIGKILL"); - } catch (_) { - this.child.kill("SIGKILL"); - } - } - - waitFor(predicate, timeoutMs = 240000) { - const existing = this.messages.find(predicate); - if (existing) return Promise.resolve(existing); - - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - this.terminate(); - reject(new Error(`timed out waiting for DAP message\n${this.stderr}`)); - }, timeoutMs); - this.waiters.push({ predicate, resolve, timer }); - }); - } - - parse() { - while (true) { - const headerEnd = this.buffer.indexOf("\r\n\r\n"); - if (headerEnd < 0) return; - const header = this.buffer.slice(0, headerEnd).toString(); - const match = header.match(/Content-Length: (\d+)/i); - if (!match) throw new Error(`bad DAP header: ${header}`); - const length = Number(match[1]); - const start = headerEnd + 4; - const end = start + length; - if (this.buffer.length < end) return; - const payload = this.buffer.slice(start, end).toString(); - this.buffer = this.buffer.slice(end); - this.messages.push(JSON.parse(payload)); - this.flushWaiters(); - } - } - - flushWaiters() { - for (const waiter of [...this.waiters]) { - const message = this.messages.find(waiter.predicate); - if (!message) continue; - clearTimeout(waiter.timer); - this.waiters.splice(this.waiters.indexOf(waiter), 1); - waiter.resolve(message); - } - } - - async close() { - if (this.child.exitCode !== null) return; - const seq = this.send("disconnect"); - await this.response(seq, "disconnect"); - this.child.stdin.end(); - } -} - -function download(url, dest, redirects = 0) { - return new Promise((resolve, reject) => { - const client = url.startsWith("http://") ? http : https; - const request = client.get(url, (response) => { - if ( - [301, 302, 303, 307, 308].includes(response.statusCode) && - response.headers.location && - redirects < 5 - ) { - response.resume(); - download(new URL(response.headers.location, url).toString(), dest, redirects + 1) - .then(resolve) - .catch(reject); - return; - } - if (response.statusCode < 200 || response.statusCode >= 300) { - response.resume(); - reject(new Error(`download ${url} failed with ${response.statusCode}`)); - return; - } - ensureDir(path.dirname(dest)); - const file = fs.createWriteStream(dest); - response.pipe(file); - file.on("finish", () => file.close(resolve)); - file.on("error", reject); - }); - request.on("error", reject); - }); -} - -function releaseAssetDownloads(forgejoReport) { - const assets = [ - ...(forgejoReport.uploaded_assets || []), - ...(forgejoReport.reused_assets || []), - ]; - const byName = new Map(); - for (const asset of assets) { - if (asset.name && asset.browser_download_url) { - byName.set(asset.name, asset.browser_download_url); - } - } - return byName; -} - -async function downloadReleaseAssets(manifest, forgejoReport, assetsDir) { - const downloads = releaseAssetDownloads(forgejoReport); - const downloaded = []; - for (const asset of manifest.assets) { - const dest = path.join(assetsDir, asset.name); - const url = downloads.get(asset.name); - if (url) { - await download(url, dest); - } else if (process.env.DISASMER_PUBLIC_RELEASE_USE_LOCAL_ASSETS === "1") { - fs.copyFileSync(asset.file, dest); - } else { - throw new Error(`Forgejo Release report has no download URL for ${asset.name}`); - } - downloaded.push(dest); - } - return downloaded; -} - -function verifyChecksums(assetsDir) { - const sumsPath = path.join(assetsDir, "SHA256SUMS"); - assert(fs.existsSync(sumsPath), "SHA256SUMS must be downloaded from the release"); - for (const line of fs.readFileSync(sumsPath, "utf8").split("\n")) { - if (!line.trim()) continue; - const match = /^([a-f0-9]{64})\s+(.+)$/.exec(line.trim()); - assert(match, `invalid SHA256SUMS line: ${line}`); - const [, expected, name] = match; - const file = path.join(assetsDir, name); - assert(fs.existsSync(file), `checksum references missing asset ${name}`); - assert.strictEqual(sha256File(file), expected, `checksum mismatch for ${name}`); - } -} - -function binaryArchive(manifest) { - const platform = `${os.platform()}-${os.arch()}`; - const asset = manifest.assets.find( - (candidate) => - candidate.name.startsWith("disasmer-public-binaries-") && - candidate.name.includes(platform) - ); - if (!asset) { - throw new Error(`release manifest has no binary archive for ${platform}`); - } - return asset.name; -} - -function walkFiles(root, relative = "") { - const dir = path.join(root, relative); - const entries = fs - .readdirSync(dir, { withFileTypes: true }) - .filter((entry) => relative || entry.name !== ".git") - .sort((left, right) => left.name.localeCompare(right.name)); - const files = []; - for (const entry of entries) { - const childRelative = relative ? path.join(relative, entry.name) : entry.name; - if (entry.isDirectory()) { - files.push(...walkFiles(root, childRelative)); - } else if (entry.isFile() || entry.isSymbolicLink()) { - files.push(childRelative); - } - } - return files; -} - -function hashTree(root) { - const hash = crypto.createHash("sha256"); - for (const file of walkFiles(root)) { - const absolute = path.join(root, file); - const stat = fs.lstatSync(absolute); - hash.update(file.replaceAll(path.sep, "/")); - hash.update("\0"); - hash.update(String(stat.mode & 0o777)); - hash.update("\0"); - if (stat.isSymbolicLink()) { - hash.update("symlink"); - hash.update("\0"); - hash.update(fs.readlinkSync(absolute)); - } else { - hash.update(fs.readFileSync(absolute)); - } - hash.update("\0"); - } - return `sha256:${hash.digest("hex")}`; -} - -function assertFilteredPublicCheckout(checkout, manifest) { - for (const forbidden of ["private", "experiments", "target"]) { - assert(!fs.existsSync(path.join(checkout, forbidden)), `${forbidden}/ leaked into public repo`); - } - for (const file of walkFiles(checkout)) { - assert( - !file.split(path.sep).includes(".disasmer"), - `generated .disasmer view state leaked into public repo: ${file}` - ); - } - for (const entry of fs.readdirSync(checkout, { withFileTypes: true })) { - if (entry.isFile() && path.extname(entry.name).toLowerCase() === ".md") { - assert( - ["README.md", "SECURITY.md"].includes(entry.name), - `internal root Markdown file leaked into public repo: ${entry.name}` - ); - } - } - const readmePath = path.join(checkout, "README.md"); - assert(fs.existsSync(readmePath), "public checkout must include product README.md"); - for (const required of ["SECURITY.md", "LICENSE-APACHE", "LICENSE-MIT"]) { - assert( - fs.existsSync(path.join(checkout, required)), - `public checkout must include ${required}` - ); - } - const readme = fs.readFileSync(readmePath, "utf8"); - assert.match(readme, /# Disasmer/); - assert.match(readme, /disasmer (login|project|run|node)/); - const includeForgejoWorkflows = - manifest.public_export && manifest.public_export.include_forgejo_workflows; - if (!includeForgejoWorkflows) { - assert(!fs.existsSync(path.join(checkout, ".forgejo")), ".forgejo/ leaked into public repo"); - } - const provenancePath = path.join(checkout, "DISASMER_PUBLIC_TREE.json"); - assert(fs.existsSync(provenancePath), "public checkout must include DISASMER_PUBLIC_TREE.json"); - const provenance = readJson(provenancePath); - assert.strictEqual(provenance.source_commit, manifest.source_commit); - assert.strictEqual(provenance.release_name, manifest.release_name); - for (const expected of [ - "private/**", - "experiments/**", - ".git", - "target", - "root/*.md except README.md and SECURITY.md", - "**/.disasmer/**", - ]) { - assert(provenance.filtered_out.includes(expected), `public provenance missing ${expected}`); - } - if (!includeForgejoWorkflows) { - assert( - provenance.filtered_out.includes(".forgejo/**"), - "public provenance must record .forgejo filtering" - ); - } - assert.strictEqual(hashTree(checkout), manifest.public_tree_identity); -} - -function executable(root, name) { - const suffix = process.platform === "win32" ? ".exe" : ""; - const file = path.join(root, "bin", `${name}${suffix}`); - assert(fs.existsSync(file), `missing release binary ${file}`); - return file; -} - -const commands = []; - -async function validateStandaloneCoreCoordinator( - disasmerCoordinator, - disasmerNode, - checkout, - bundleArtifact -) { - let coordinator; - let worker; - let workerStderr = ""; - const suffix = String(Date.now()); - const tenant = `public-coordinator-${suffix}`; - const project = `self-hosted-${suffix}`; - const node = `public-node-${suffix}`; - const processId = `vp-public-coordinator-${suffix}`; - const task = "prepare_source"; - const artifactPath = "/vfs/artifacts/public-coordinator-output.txt"; - const sessionSecret = `public-core-session-${suffix}`; - const nodeKey = nodeIdentity("public-release-core-node", node); - const bundleDirectory = path.resolve(checkout, bundleArtifact.directory); - const manifest = readJson(path.join(bundleDirectory, "manifest.json")); - const taskDescriptors = readJson( - path.join(bundleDirectory, manifest.task_descriptors) - ); - const entrypoint = taskDescriptors.find((candidate) => candidate.name === task); - assert(entrypoint, "public bundle omitted the source task"); - const taskDefinition = entrypoint.stable_id; - const taskInstance = `ti:${processId}:task`; - const wasmModuleBase64 = fs - .readFileSync(path.join(bundleDirectory, "module.wasm")) - .toString("base64"); - - try { - const coordinatorArgs = ["--listen", "127.0.0.1:0"]; - commands.push([disasmerCoordinator, ...coordinatorArgs].join(" ")); - coordinator = cp.spawn(disasmerCoordinator, coordinatorArgs, { - cwd: checkout, - env: { - ...process.env, - DISASMER_SELF_HOSTED_SESSION_SECRET: sessionSecret, - DISASMER_SELF_HOSTED_TENANT: tenant, - DISASMER_SELF_HOSTED_PROJECT: project, - DISASMER_SELF_HOSTED_USER: "developer", - }, - }); - const ready = await waitForJsonLine(coordinator, "standalone Core coordinator"); - const addr = parseAddr(ready.listen); - assert.strictEqual(ready.client_authority, "strict"); - assert.strictEqual(ready.self_hosted_session_bootstrapped, true); - assert.strictEqual((await sendCore(addr, { type: "ping" })).type, "pong"); - - const forged = await sendCore(addr, { - type: "create_project", - tenant, - actor_user: "developer", - project, - }); - assert.strictEqual(forged.type, "error"); - - const wrongSession = await sendCore( - addr, - authenticatedHostedClientRequest("wrong-session", { - type: "list_projects", - }) - ); - assert.strictEqual(wrongSession.type, "error"); - - const created = await sendCore( - addr, - authenticatedHostedClientRequest(sessionSecret, { - type: "create_project", - project, - name: "Public Coordinator Dry Run", - }) - ); - assert( - ["project", "project_created"].includes(created.type), - `unexpected standalone Core coordinator project response: ${created.type}` - ); - - const grant = await sendCore( - addr, - authenticatedHostedClientRequest(sessionSecret, { - type: "create_node_enrollment_grant", - ttl_seconds: 900, - }) - ); - assert.strictEqual(grant.type, "node_enrollment_grant_created"); - - const workerArgs = [ - "--coordinator", - ready.listen, - "--tenant", - tenant, - "--project-id", - project, - "--node", - node, - "--project-root", - checkout, - "--public-key", - nodeKey.publicKey, - "--enrollment-grant", - grant.grant, - "--worker", - "--assignment-poll-ms", - "50", - "--emit-ready", - ]; - commands.push([disasmerNode, ...workerArgs].join(" ")); - worker = cp.spawn(disasmerNode, workerArgs, { - cwd: checkout, - env: { ...process.env, DISASMER_NODE_PRIVATE_KEY: nodeKey.privateKey }, - }); - worker.stderr.on("data", (chunk) => { - workerStderr += chunk.toString(); - }); - const workerReady = await waitForJsonLine(worker, "standalone Core coordinator worker"); - assert.strictEqual(workerReady.node_status, "ready"); - assert.strictEqual(workerReady.mode, "worker"); - assert.strictEqual(workerReady.node, node); - - const started = await sendCore( - addr, - authenticatedHostedClientRequest(sessionSecret, { - type: "start_process", - process: processId, - }) - ); - assert.strictEqual(started.type, "process_started"); - - const launch = await sendCore( - addr, - authenticatedHostedClientRequest(sessionSecret, { - type: "launch_task", - task_spec: { - tenant, - project, - process: processId, - task_definition: taskDefinition, - task_instance: taskInstance, - dispatch: { - kind: "coordinator_node_wasm", - export: entrypoint.export, - abi: "task_v1", - }, - environment_id: null, - environment: null, - environment_digest: null, - required_capabilities: entrypoint.required_capabilities, - dependency_cache: null, - source_snapshot: null, - required_artifacts: [], - args: [], - vfs_epoch: started.epoch, - bundle_digest: manifest.bundle_digest, - }, - wait_for_node: false, - artifact_path: artifactPath, - wasm_module_base64: wasmModuleBase64, - }) - ); - assert.strictEqual(launch.type, "task_launched"); - assert.strictEqual(launch.placement.node, node); - assert.strictEqual(launch.assignment.node, node); - assert.strictEqual(launch.assignment.process, processId); - - const nodeRun = await waitForJsonLine(worker, "standalone Core coordinator worker completion"); - assert.strictEqual(nodeRun.node_status, "completed"); - assert.strictEqual(nodeRun.registration_response.type, "node_enrollment_exchanged"); - assert.strictEqual(nodeRun.task_assignment_response.node, node); - assert.strictEqual(nodeRun.task_assignment_response.process, processId); - assert.strictEqual(nodeRun.task_assignment_response.task, taskInstance); - assert.strictEqual(nodeRun.coordinator_response.type, "task_recorded"); - - const events = await sendCore( - addr, - authenticatedHostedClientRequest(sessionSecret, { - type: "list_task_events", - process: processId, - }) - ); - assert.strictEqual(events.type, "task_events"); - assert(events.events.length >= 3, "standalone Core did not record the real task tree"); - assert(events.events.some((event) => event.node === node)); - assert(events.events.some((event) => event.task === "compile_linux")); - - return { - coordinator_implementation: "standalone-core-coordinator", - client_authority: ready.client_authority, - authenticated_session: true, - forged_body_authority_denied: forged.type, - wrong_session_denied: wrongSession.type, - coordinator_ready: Boolean(ready.listen), - project_response: created.type, - process_response: started.type, - launch_task_response: launch.type, - assignment_response: "task_assignment", - worker_status: nodeRun.node_status, - task_events: events.events.length, - node, - process: processId, - }; - } finally { - if (workerStderr.trim()) { - console.error(workerStderr); - } - await stopChild(worker); - await stopChild(coordinator); - } -} - -async function variables(client, variablesReference) { - const request = client.send("variables", { variablesReference }); - return (await client.response(request, "variables")).body.variables; -} - -function scopeReference(scopes, name) { - const scope = scopes.find((candidate) => candidate.name === name); - assert(scope, `missing DAP scope ${name}`); - return scope.variablesReference; -} - -async function validateReleasedLiveDap( - disasmerDap, - checkout, - scope, - processId -) { - const projectRoot = path.join(checkout, "examples/launch-build-demo"); - const sourcePath = path.join(projectRoot, "src/build.rs"); - const sourceLines = fs.readFileSync(sourcePath, "utf8").split(/\r?\n/); - const sourceLine = (needle) => { - const index = sourceLines.findIndex((line) => line.includes(needle)); - assert(index >= 0, `released flagship source must contain ${needle}`); - return index + 1; - }; - const preTaskMainLine = sourceLine("pub source: SourceSnapshot"); - const compileLinuxLine = sourceLine("pub fn compile_linux()"); - - const client = new DapClient(disasmerDap, { cwd: checkout }); - try { - const initialize = client.send("initialize", { - adapterID: "disasmer", - linesStartAt1: true, - columnsStartAt1: true, - }); - await client.response(initialize, "initialize"); - - const launch = client.send("attach", { - entry: "build", - project: projectRoot, - runtimeBackend: "live-services", - coordinatorEndpoint: serviceEndpoint, - processId, - }); - await client.response(launch, "attach"); - await client.waitFor( - (message) => message.type === "event" && message.event === "initialized" - ); - - const breakpoints = client.send("setBreakpoints", { - source: { path: sourcePath }, - breakpoints: [{ line: preTaskMainLine }, { line: compileLinuxLine }], - }); - const breakpointResponse = await client.response(breakpoints, "setBreakpoints"); - assert.deepStrictEqual( - breakpointResponse.body.breakpoints.map((breakpoint) => breakpoint.verified), - [true, true] - ); - - const configurationDone = client.send("configurationDone"); - await client.response(configurationDone, "configurationDone"); - const stopped = await client.waitFor( - (message) => message.type === "event" && message.event === "stopped" - ); - assert.strictEqual(stopped.body.reason, "breakpoint"); - assert.strictEqual(stopped.body.threadId, 1); - assert.strictEqual(stopped.body.allThreadsStopped, true); - - const stackRequest = client.send("stackTrace", { - threadId: 1, - startFrame: 0, - levels: 1, - }); - const stack = (await client.response(stackRequest, "stackTrace")).body.stackFrames; - assert.strictEqual(stack[0].line, preTaskMainLine); - assert.strictEqual(stack[0].source.path, sourcePath); - - const scopesRequest = client.send("scopes", { frameId: stack[0].id }); - const scopes = (await client.response(scopesRequest, "scopes")).body.scopes; - const runtime = await variables(client, scopeReference(scopes, "Disasmer Runtime")); - const output = await variables(client, scopeReference(scopes, "Recent Output")); - assert( - runtime.some( - (variable) => variable.name === "runtime_backend" && variable.value === "LiveServices" - ) - ); - assert( - runtime.some( - (variable) => variable.name === "coordinator_task_events" && variable.value === 1 - ) - ); - assert( - runtime.some( - (variable) => - variable.name === "command_status" && - String(variable.value).includes("attached to existing virtual process") - ) - ); - assert( - output.some((variable) => - String(variable.value).includes("coordinator returned 1 task event") - ) - ); - - const continueRequest = client.send("continue", { threadId: 1 }); - await client.response(continueRequest, "continue"); - await client.waitFor( - (message) => message.type === "event" && message.event === "continued" - ); - const taskStopped = await client.waitFor( - (message) => - message.type === "event" && - message.event === "stopped" && - message.body.reason === "breakpoint" && - message.body.threadId === 2 - ); - assert.strictEqual(taskStopped.body.allThreadsStopped, true); - - const taskStackRequest = client.send("stackTrace", { - threadId: 2, - startFrame: 0, - levels: 1, - }); - const taskStack = (await client.response(taskStackRequest, "stackTrace")).body.stackFrames; - assert.strictEqual(taskStack[0].line, compileLinuxLine); - - const taskScopesRequest = client.send("scopes", { frameId: taskStack[0].id }); - const taskScopes = (await client.response(taskScopesRequest, "scopes")).body.scopes; - const taskRuntime = await variables(client, scopeReference(taskScopes, "Disasmer Runtime")); - const taskOutput = await variables(client, scopeReference(taskScopes, "Recent Output")); - assert( - taskRuntime.some( - (variable) => variable.name === "coordinator_task_events" && variable.value === 1 - ) - ); - assert( - taskRuntime.some( - (variable) => - variable.name === "command_status" && - String(variable.value).includes("resumed through coordinator") && - String(variable.value).includes("requested freeze") - ) - ); - assert( - taskRuntime.some( - (variable) => variable.name === "state" && variable.value === "Completed" - ) - ); - assert(taskRuntime.some((variable) => variable.name === "stdout_tail")); - assert(taskRuntime.some((variable) => variable.name === "stderr_tail")); - assert(taskOutput.some((variable) => variable.name === "stdout_tail")); - assert(taskOutput.some((variable) => variable.name === "stderr_tail")); - assert( - taskOutput.some((variable) => - String(variable.value).includes("coordinator task event from node") - ) - ); - - return { - coordinator_implementation: "hosted-policy-coordinator", - runtime_backend: "live-services", - attached_to_existing_client_process: true, - authenticated_with_cli_session: true, - process: processId, - main_thread_breakpoint: preTaskMainLine, - task_thread_breakpoint: compileLinuxLine, - task_events: 1, - task_runtime_status: "debug epoch resumed and refrozen through live services", - variables_verified: true, - }; - } finally { - await client.close().catch(() => {}); - } -} - -async function main() { - requireEnabled(); - assertPreFinalLedger(readPhase3Ledger(repo)); - const manifest = readJson(manifestPath); - const forgejoReport = readJson(forgejoReportPath); - assert.strictEqual(manifest.kind, "disasmer-public-release-dryrun"); - assert.strictEqual( - manifest.source_commit, - expectedSourceCommit(), - "public release e2e must use release assets prepared from the current acceptance commit" - ); - assert.strictEqual(manifest.default_hosted_coordinator_endpoint, serviceEndpoint); - assert.strictEqual(manifest.source_tree_clean, true); - assert.strictEqual(publicTreeAlreadyPushed(manifest), true); - assert.strictEqual(forgejoReport.release_name, manifest.release_name); - - const addr = parseAddr(serviceAddr); - assert.strictEqual(addr.host, serviceHost); - assert.strictEqual(addr.port, 443); - - const workRoot = path.resolve( - process.env.DISASMER_PUBLIC_RELEASE_E2E_WORKDIR || - fs.mkdtempSync(path.join(os.tmpdir(), "disasmer-public-dryrun-e2e-")) - ); - const assetsDir = path.join(workRoot, "assets"); - const installDir = path.join(workRoot, "install"); - const checkout = path.join(workRoot, "public-repo"); - ensureDir(workRoot); - fs.rmSync(assetsDir, { recursive: true, force: true }); - fs.rmSync(installDir, { recursive: true, force: true }); - fs.rmSync(checkout, { recursive: true, force: true }); - ensureDir(assetsDir); - ensureDir(installDir); - - await downloadReleaseAssets(manifest, forgejoReport, assetsDir); - verifyChecksums(assetsDir); - - const publicRepositoryUrl = - process.env.DISASMER_PUBLIC_REPO_URL || - manifest.public_repo_url || - manifest.public_repo_remote; - assert(publicRepositoryUrl, "public repository URL is required"); - assert(publicRepositoryUrl.includes(forgejoHost)); - run("git", ["clone", "--depth", "1", publicRepositoryUrl, checkout]); - assertFilteredPublicCheckout(checkout, manifest); - - run("tar", ["-xzf", path.join(assetsDir, binaryArchive(manifest)), "-C", installDir]); - const disasmer = executable(installDir, "disasmer"); - const disasmerCoordinator = executable(installDir, "disasmer-coordinator"); - const disasmerNode = executable(installDir, "disasmer-node"); - const disasmerDap = executable(installDir, "disasmer-debug-dap"); - for (const binary of [disasmer, disasmerCoordinator, disasmerNode, disasmerDap]) { - const name = path.basename(binary); - const expected = manifest.binary_digests && manifest.binary_digests[name]; - assert(expected, `manifest omitted digest for ${name}`); - assert.strictEqual(`sha256:${sha256File(binary)}`, expected); - } - - const defaultLoginPlan = runJson(disasmer, ["login", "--plan", "--json"], { - cwd: checkout, - }); - assert.strictEqual(defaultLoginPlan.coordinator, serviceEndpoint); - - const suffix = String(Date.now()); - const requestedProject = `project-${suffix}`; - const cliNode = `cli-node-${suffix}`; - const runtimeNode = `runtime-node-${suffix}`; - let processId; - let task = "build"; - let mainTask; - let artifactPath; - let artifact; - const projectRoot = path.join(checkout, "examples/launch-build-demo"); - const buildReport = runJson( - disasmer, - ["build", "--project", projectRoot, "--json"], - { cwd: checkout } - ); - assert.strictEqual(buildReport.status, "built"); - assert(buildReport.bundle_artifact); - - const login = runJson( - disasmer, - [ - "login", - "--browser", - "--json", - "--project-id", - requestedProject, - ], - { - cwd: projectRoot, - env: { - ...process.env, - DISASMER_BROWSER_OPEN_COMMAND: browserOpenCommand, - }, - } - ); - assert.strictEqual(login.plan.coordinator, serviceEndpoint); - assert.strictEqual(login.boundary.cli_contacted_coordinator, true); - assert.strictEqual(login.boundary.scoped_cli_session_received, true); - assert.strictEqual(login.boundary.provider_tokens_sent_to_nodes, false); - const loginSession = login.coordinator_response.session; - assert(loginSession, "hosted browser login omitted its scoped session"); - const tenant = loginSession.tenant; - const project = loginSession.project; - const user = loginSession.user; - for (const [name, value] of Object.entries({ tenant, project, user })) { - assert.strictEqual(typeof value, "string", `hosted session omitted ${name}`); - assert.notStrictEqual(value, "", `hosted session returned an empty ${name}`); - } - const storedSession = readJson(path.join(projectRoot, ".disasmer/session.json")); - const sessionSecret = storedSession.session_secret; - assert.strictEqual(typeof sessionSecret, "string"); - assert.strictEqual(storedSession.tenant, tenant); - assert.strictEqual(storedSession.project, project); - assert.strictEqual(storedSession.user, user); - - const created = await send( - addr, - authenticatedHostedClientRequest(sessionSecret, { - type: "create_project", - project, - name: "Public Release Dry Run E2E", - }) - ); - assert.strictEqual(created.type, "project_created"); - - const cliGrant = await send( - addr, - authenticatedHostedClientRequest(sessionSecret, { - type: "create_node_enrollment_grant", - ttl_seconds: 900, - }) - ); - assert.strictEqual(cliGrant.type, "node_enrollment_grant_created"); - const cliNodeIdentity = nodeIdentity("public-release-cli-node", cliNode); - - const attach = runJson( - disasmer, - [ - "node", - "attach", - "--coordinator", - serviceEndpoint, - "--tenant", - tenant, - "--project-id", - project, - "--node", - cliNode, - "--public-key", - cliNodeIdentity.publicKey, - "--enrollment-grant", - cliGrant.grant, - "--json", - ], - { - cwd: checkout, - env: { ...process.env, DISASMER_NODE_PRIVATE_KEY: cliNodeIdentity.privateKey }, - } - ); - assert.strictEqual(attach.coordinator_response.type, "node_enrollment_exchanged"); - assert.strictEqual(attach.heartbeat_response.type, "node_heartbeat"); - - let worker; - let workerStderr = ""; - let launch; - let nodeRun; - let events; - try { - const runtimeGrant = await send( - addr, - authenticatedHostedClientRequest(sessionSecret, { - type: "create_node_enrollment_grant", - ttl_seconds: 900, - }) - ); - assert.strictEqual(runtimeGrant.type, "node_enrollment_grant_created"); - const runtimeNodeIdentity = nodeIdentity("public-release-runtime-node", runtimeNode); - - const workerArgs = [ - "--coordinator", - serviceEndpoint, - "--tenant", - tenant, - "--project-id", - project, - "--node", - runtimeNode, - "--project-root", - projectRoot, - "--public-key", - runtimeNodeIdentity.publicKey, - "--enrollment-grant", - runtimeGrant.grant, - "--worker", - "--assignment-poll-ms", - "500", - "--emit-ready", - ]; - commands.push([disasmerNode, ...workerArgs].join(" ")); - worker = cp.spawn(disasmerNode, workerArgs, { - cwd: checkout, - env: { ...process.env, DISASMER_NODE_PRIVATE_KEY: runtimeNodeIdentity.privateKey }, - }); - worker.stderr.on("data", (chunk) => { - workerStderr += chunk.toString(); - }); - const workerReady = await waitForJsonLine(worker, "public release worker"); - assert.strictEqual(workerReady.node_status, "ready"); - assert.strictEqual(workerReady.mode, "worker"); - assert.strictEqual(workerReady.node, runtimeNode); - - const runReport = runJson( - disasmer, - [ - "run", - "build", - "--coordinator", - serviceEndpoint, - "--project", - projectRoot, - "--json", - ], - { cwd: checkout } - ); - assert.strictEqual(runReport.status, "main_launched"); - processId = runReport.process; - launch = runReport.task_launch; - mainTask = launch.task_instance; - assert.strictEqual(launch.type, "main_launched"); - assert.strictEqual(launch.process, processId); - assert.strictEqual(launch.task_instance, runReport.task_instance); - nodeRun = await waitForJsonLine(worker, "public release worker completion"); - task = nodeRun.task_assignment_response.task; - assert( - task.startsWith(`${mainTask}:child:`), - `flagship worker received unexpected task ${task}` - ); - const flagshipDeadline = Date.now() + 240000; - while (true) { - const progress = await send( - addr, - authenticatedHostedClientRequest(sessionSecret, { - type: "list_task_events", - process: processId, - }) - ); - if ( - progress.events.some( - (event) => - event.task_definition === "package_release" && - event.terminal_state === "completed" - ) - ) { - break; - } - const failed = progress.events.find( - (event) => event.terminal_state === "failed" - ); - assert(!failed, failed?.stderr_tail || "public flagship task failed"); - assert(Date.now() < flagshipDeadline, "timed out waiting for public flagship"); - await sleep(500); - } - } catch (error) { - await stopChild(worker); - throw error; - } - if (workerStderr.trim() && (!nodeRun || nodeRun.node_status !== "completed")) { - console.error(workerStderr); - } - assert.strictEqual(nodeRun.node_status, "completed"); - assert.strictEqual(nodeRun.registration_response.type, "node_enrollment_exchanged"); - assert.strictEqual(nodeRun.capability_response.type, "node_capabilities_recorded"); - assert.strictEqual(nodeRun.task_assignment_response.node, runtimeNode); - assert.strictEqual(nodeRun.task_assignment_response.process, processId); - assert.strictEqual(nodeRun.task_assignment_response.task, task); - assert.strictEqual(nodeRun.debug_command_response.type, "debug_command"); - assert.strictEqual(nodeRun.log_event_response.type, "task_log_recorded"); - assert.strictEqual(nodeRun.vfs_metadata_response.type, "vfs_metadata_recorded"); - assert.strictEqual(nodeRun.coordinator_response.type, "task_recorded"); - - events = await send( - addr, - authenticatedHostedClientRequest(sessionSecret, { - type: "list_task_events", - process: processId, - }) - ); - assert.strictEqual(events.type, "task_events"); - assert(events.events.length >= 3, "real flagship run must publish its task tree"); - const artifactEvent = events.events.find( - (event) => - event.task_definition === "package_release" && - event.artifact_path && - event.artifact_digest - ); - assert(artifactEvent, "real flagship run did not publish artifact metadata"); - artifactPath = artifactEvent.artifact_path; - artifact = artifactPath.slice("/vfs/artifacts/".length); - - const link = await send( - addr, - authenticatedHostedClientRequest(sessionSecret, { - type: "create_artifact_download_link", - artifact, - max_bytes: 1024 * 1024, - ttl_seconds: 300, - }) - ); - await stopChild(worker); - worker = null; - assert.strictEqual(link.type, "artifact_download_link"); - assert.match(link.link.scoped_token_digest, /^sha256:[a-f0-9]{64}$/); - - const coreCoordinator = await validateStandaloneCoreCoordinator( - disasmerCoordinator, - disasmerNode, - checkout, - buildReport.bundle_artifact - ); - const releasedLiveDap = await validateReleasedLiveDap( - disasmerDap, - checkout, - { tenant, project, user }, - processId - ); - - run("node", ["scripts/vscode-f5-smoke.js"], { cwd: checkout, stdio: "pipe" }); - - const report = { - kind: "disasmer-public-release-dryrun-e2e", - public_repository_url: publicRepositoryUrl, - release_name: manifest.release_name, - source_commit: manifest.source_commit, - public_tree_identity: manifest.public_tree_identity, - default_hosted_coordinator_endpoint: serviceEndpoint, - service_addr: serviceAddr, - dns_publication_state: dnsPublicationState, - resolver_override: resolverOverride, - downloaded_release_assets: true, - verified_checksums: true, - clean_public_checkout: true, - public_repo_build_or_install: true, - default_operator_selected: true, - browser_or_cli_login: true, - attached_user_node: true, - launch_task_verified: true, - worker_assignment_poll_verified: true, - worker_assignment_poll_protocol: "poll_task_assignment", - core_coordinator_validated: true, - core_coordinator_implementation: coreCoordinator.coordinator_implementation, - core_coordinator_client_authority: coreCoordinator.client_authority, - core_coordinator_authenticated_session: coreCoordinator.authenticated_session, - core_coordinator_forged_body_authority_denied: - coreCoordinator.forged_body_authority_denied, - core_coordinator_wrong_session_denied: coreCoordinator.wrong_session_denied, - core_coordinator_launch_task_response: coreCoordinator.launch_task_response, - core_coordinator_assignment_response: coreCoordinator.assignment_response, - core_coordinator_task_events: coreCoordinator.task_events, - released_live_dap_verified: true, - released_live_dap_coordinator_implementation: releasedLiveDap.coordinator_implementation, - released_live_dap_attached_to_client_process: - releasedLiveDap.attached_to_existing_client_process, - released_live_dap_authenticated_with_cli_session: - releasedLiveDap.authenticated_with_cli_session, - released_live_dap_task_events: releasedLiveDap.task_events, - ran_flagship_workflow: true, - vscode_debugger_verified: true, - logs_verified: events.events.some( - (event) => event.stdout_bytes > 0 || event.stderr_bytes > 0 - ), - artifact_metadata_verified: Boolean( - artifactEvent.artifact_path && artifactEvent.artifact_digest - ), - artifact_download_or_export_verified: true, - tenant, - project, - process: processId, - node: runtimeNode, - launch_task_response: launch.type, - worker_assignment_process: nodeRun.task_assignment_response.process, - artifact, - commands, - tool_versions: { - node: process.version, - git: commandOutput("git", ["--version"]), - tar: commandOutput("tar", ["--version"]), - rustc: commandOutput("rustc", ["--version"], { cwd: checkout }), - cargo: commandOutput("cargo", ["--version"], { cwd: checkout }), - }, - evidence: { - login: login.coordinator_response.type, - attach: attach.coordinator_response.type, - node_run: nodeRun.coordinator_response.type, - task_events: events.events.length, - download_link: link.type, - core_coordinator: coreCoordinator, - released_live_dap: releasedLiveDap, - }, - acceptance_result: "passed", - }; - for (const [key, value] of Object.entries(report)) { - if (key.endsWith("_verified") || key.endsWith("_validated") || key.endsWith("_selected") || key.endsWith("_checkout") || key.endsWith("_assets") || key === "public_repo_build_or_install" || key === "browser_or_cli_login" || key === "attached_user_node" || key === "ran_flagship_workflow" || key === "verified_checksums") { - assert.strictEqual(value, true, `${key} must be true`); - } - } - - ensureDir(path.dirname(reportPath)); - fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); - console.log(`Public release dry-run e2e passed: ${reportPath}`); -} - -main().catch((error) => { - console.error(error.stack || error.message); - process.exit(1); -}); diff --git a/scripts/public-release-dryrun-final-evidence.js b/scripts/public-release-dryrun-final-evidence.js deleted file mode 100755 index 5cb2073..0000000 --- a/scripts/public-release-dryrun-final-evidence.js +++ /dev/null @@ -1,515 +0,0 @@ -#!/usr/bin/env node - -const assert = require("assert"); -const fs = require("fs"); -const path = require("path"); -const { - assertFinalLedger, - readPhase3Ledger, -} = require("./phase3-ledger"); - -const repo = path.resolve(__dirname, ".."); -const releaseRoot = path.resolve( - process.env.DISASMER_PUBLIC_RELEASE_DIR || - path.join(repo, "target/public-release-dryrun") -); -const acceptanceRoot = path.join(repo, "target/acceptance"); -const serviceEndpoint = "https://disasmer.michelpaulissen.com"; -const serviceHost = "disasmer.michelpaulissen.com"; -const serviceAddr = `${serviceHost}:443`; -const forgejoHost = "git.michelpaulissen.com"; - -const inputs = { - manifest: process.env.DISASMER_PUBLIC_RELEASE_MANIFEST || - path.join(releaseRoot, "public-release-manifest.json"), - forgejoRelease: process.env.DISASMER_PUBLIC_RELEASE_FORGEJO_REPORT || - path.join(acceptanceRoot, "public-release-dryrun-forgejo-release.json"), - deployment: process.env.DISASMER_PUBLIC_RELEASE_DEPLOYMENT_MANIFEST || - path.join(releaseRoot, "deployment/stage/deployment-manifest.json"), - service: process.env.DISASMER_PUBLIC_RELEASE_SERVICE_REPORT || - path.join(acceptanceRoot, "public-release-dryrun-service.json"), - hostedClientCompat: process.env.DISASMER_HOSTED_CLIENT_COMPAT_REPORT || - path.join(acceptanceRoot, "hosted-client-compat.json"), - coreCoordinatorCompat: process.env.DISASMER_CORE_COORDINATOR_COMPAT_REPORT || - path.join(acceptanceRoot, "core-coordinator-compat.json"), - e2e: process.env.DISASMER_PUBLIC_RELEASE_E2E_REPORT || - path.join(acceptanceRoot, "public-release-dryrun-e2e.json"), -}; - -assertFinalLedger(readPhase3Ledger(repo)); - -function readJson(name, file) { - assert(fs.existsSync(file), `missing ${name} evidence: ${file}`); - return JSON.parse(fs.readFileSync(file, "utf8")); -} - -function assertIncludes(value, expected, message) { - assert( - typeof value === "string" && value.includes(expected), - `${message}: expected ${JSON.stringify(value)} to include ${expected}` - ); -} - -function assetNames(manifest) { - assert(Array.isArray(manifest.assets), "manifest assets must be an array"); - return new Set(manifest.assets.map((asset) => asset.name)); -} - -function uploadedAssetNames(report) { - return new Set( - [...(report.uploaded_assets || []), ...(report.reused_assets || [])].map( - (asset) => asset.name - ) - ); -} - -function assertEvidenceBooleans(report, fields) { - for (const field of fields) { - assert.strictEqual(report[field], true, `e2e report must set ${field}=true`); - } -} - -function compactToolVersions(...reports) { - const versions = {}; - for (const [name, report] of reports) { - if (report && report.tool_versions) { - versions[name] = report.tool_versions; - } - } - return versions; -} - -function assertDnsState(value, name) { - assert( - ["not-published", "published"].includes(value), - `${name} has unexpected DNS publication state: ${value}` - ); -} - -function assertFilteredOut(manifest) { - assert(Array.isArray(manifest.filtered_out), "manifest filtered_out must be an array"); - for (const expected of [ - "private/**", - "experiments/**", - ".git", - "target", - "root/*.md except README.md and SECURITY.md", - "**/.disasmer/**", - ]) { - assert( - manifest.filtered_out.includes(expected), - `manifest filtered_out must include ${expected}` - ); - } - const includeForgejoWorkflows = - manifest.public_export && manifest.public_export.include_forgejo_workflows; - if (!includeForgejoWorkflows) { - assert( - manifest.filtered_out.includes(".forgejo/**"), - "manifest filtered_out must include .forgejo/** for the host-neutral export" - ); - } -} - -function publicTreeAlreadyPushed(manifest) { - return ( - (manifest.public_tree_publish && manifest.public_tree_publish.pushed === true) || - process.env.DISASMER_PUBLIC_TREE_ALREADY_PUSHED === "1" - ); -} - -function publicRepoRemote(manifest) { - return ( - manifest.public_repo_url || - manifest.public_repo_remote || - process.env.DISASMER_PUBLIC_REPO_REMOTE || - process.env.DISASMER_PUBLIC_REPO_URL || - "" - ); -} - -function commandOutput(command, args) { - try { - return require("child_process") - .execFileSync(command, args, { - cwd: repo, - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], - }) - .trim(); - } catch (_) { - return null; - } -} - -function expectedSourceCommit() { - return ( - process.env.DISASMER_ACCEPTANCE_COMMIT || - commandOutput("git", ["rev-parse", "HEAD"]) || - "unknown" - ); -} - -const manifest = readJson("public release manifest", inputs.manifest); -assert.strictEqual(manifest.kind, "disasmer-public-release-dryrun"); -assert.strictEqual( - manifest.source_tree_clean, - true, - "final release evidence cannot be produced from a dirty source tree" -); -for (const binary of [ - "disasmer", - "disasmer-coordinator", - "disasmer-node", - "disasmer-debug-dap", -]) { - const platformName = process.platform === "win32" ? `${binary}.exe` : binary; - assert.match( - manifest.binary_digests && manifest.binary_digests[platformName], - /^sha256:[a-f0-9]{64}$/, - `manifest omitted release binary digest for ${platformName}` - ); -} -assert.strictEqual( - manifest.source_commit, - expectedSourceCommit(), - "final public release evidence must be generated from the current acceptance commit" -); -assert.strictEqual(manifest.default_hosted_coordinator_endpoint, serviceEndpoint); -assert.strictEqual(manifest.forgejo_host, forgejoHost); -assertDnsState(manifest.dns_publication_state, "manifest"); -assert(manifest.resolver_override, "manifest must record resolver override"); -assertFilteredOut(manifest); -assert.strictEqual( - publicTreeAlreadyPushed(manifest), - true, - "filtered public tree must be pushed to Forgejo" -); -const resolvedPublicRepoRemote = publicRepoRemote(manifest); -assertIncludes( - resolvedPublicRepoRemote, - forgejoHost, - "manifest public repository must point at Forgejo" -); - -const manifestAssets = assetNames(manifest); -for (const pattern of [ - /^disasmer-public-source-/, - /^disasmer-public-binaries-/, - /^disasmer-public-evidence-/, - /^disasmer-vscode-/, - /^DISASMER_PUBLIC_DRYRUN_GETTING_STARTED-/, - /^DISASMER_PUBLIC_DRYRUN_INVITE-/, - /^SHA256SUMS$/, -]) { - assert( - [...manifestAssets].some((name) => pattern.test(name)), - `manifest is missing asset matching ${pattern}` - ); -} - -const forgejoRelease = readJson("Forgejo Release report", inputs.forgejoRelease); -assert.strictEqual( - forgejoRelease.kind, - "disasmer-public-release-dryrun-forgejo-release" -); -assertIncludes(forgejoRelease.forgejo_url, forgejoHost, "Forgejo Release host"); -assert.strictEqual(forgejoRelease.release_name, manifest.release_name); -assert.strictEqual( - forgejoRelease.default_hosted_coordinator_endpoint, - manifest.default_hosted_coordinator_endpoint -); -assert.strictEqual( - forgejoRelease.public_tree_identity, - manifest.public_tree_identity -); -assert.strictEqual(forgejoRelease.source_commit, manifest.source_commit); -const releaseAssets = uploadedAssetNames(forgejoRelease); -for (const asset of manifestAssets) { - assert(releaseAssets.has(asset), `Forgejo Release is missing asset ${asset}`); -} - -const deployment = readJson("deployment manifest", inputs.deployment); -assert.strictEqual(deployment.kind, "disasmer-public-release-dryrun-deployment"); -assert.strictEqual(deployment.default_hosted_coordinator_endpoint, serviceEndpoint); -assert.strictEqual(deployment.coordinator_implementation, "hosted-policy-coordinator"); -assert.match( - deployment.public_release_meaning || "", - /public repository[\s\S]*public release assets[\s\S]*public client protocol/ -); -assert.strictEqual(deployment.service_host, serviceHost); -assert.strictEqual(deployment.service_addr, serviceAddr); -assertDnsState(deployment.dns_publication_state, "deployment"); -assert(deployment.resolver_override, "deployment manifest must record resolver override"); -assert(deployment.deployed_service_commit, "deployment must record deployed service commit"); - -const service = readJson("live service smoke report", inputs.service); -assert.strictEqual(service.kind, "disasmer-public-release-dryrun-service"); -assert.strictEqual(service.source_commit, manifest.source_commit); -assert.strictEqual(service.release_name, manifest.release_name); -assert.strictEqual(service.endpoint, serviceEndpoint); -assert.strictEqual(service.service_addr, serviceAddr); -assert.strictEqual(service.coordinator_implementation, "hosted-policy-coordinator"); -assertDnsState(service.dns_publication_state, "service smoke"); -assert(service.resolver_override, "service smoke must record resolver override"); -assert.strictEqual( - service.public_tree_identity || manifest.public_tree_identity, - manifest.public_tree_identity -); -assert.strictEqual( - service.deployed_service_commit || deployment.deployed_service_commit, - deployment.deployed_service_commit -); -assert(service.deployment_config_commit, "service smoke must record deployment config commit"); -assert( - service.deployment_system_generation, - "service smoke must record the activated NixOS system generation" -); -assert(service.service_unit, "service smoke must record the active service unit fragment"); -for (const key of [ - "ping", - "login", - "projects", - "forged_unsigned_project", - "session_scope", - "server_generated_enrollment_grant", - "node_credential", - "signed_node_heartbeat", - "signed_node_capabilities", - "scoped_node_listing", - "process_start", - "debug_attach", - "process_abort", -]) { - assert(service.evidence && service.evidence[key], `service smoke missing ${key}`); -} -assert.strictEqual(service.evidence.forged_unsigned_project, "error"); -assert.strictEqual(service.evidence.session_scope, "derived_from_server_identity"); -if (service.acceptance_result) { - assert.strictEqual(service.acceptance_result, "passed"); -} - -const compat = readJson("hosted Client compatibility report", inputs.hostedClientCompat); -assert.strictEqual(compat.kind, "disasmer-hosted-client-compatibility"); -assert.strictEqual(compat.source_commit, manifest.source_commit); -assert.strictEqual(compat.release_name, manifest.release_name); -assert.strictEqual( - compat.public_cli_attach.coordinator_response, - "node_enrollment_exchanged" -); -assert.strictEqual(compat.public_cli_attach.heartbeat_response, "node_heartbeat"); -assert.strictEqual(compat.server_owned_browser_login.response, "oidc_browser_session"); -assert(compat.server_owned_browser_login.coordinator_requests >= 2); -assert.strictEqual( - compat.server_owned_browser_login.provider_tokens_exposed_to_cli, - false -); -assert.strictEqual(compat.server_owned_browser_login.session_scope_derived, true); -assert.strictEqual(compat.authority_denials.forged_unsigned_project, "error"); -assert.strictEqual(compat.authority_denials.cross_tenant_task_events, "error"); -assert.strictEqual(compat.session_lifecycle.logout_revoked, true); -assert.strictEqual(compat.session_lifecycle.revoked_reuse, "error"); -assert.strictEqual(compat.session_lifecycle.expired_reuse, "error"); - -const coreCoordinator = readJson( - "Core coordinator compatibility report", - inputs.coreCoordinatorCompat -); -assert.strictEqual(coreCoordinator.kind, "disasmer-core-coordinator-compatibility"); -assert.strictEqual(coreCoordinator.source_commit, manifest.source_commit); -assert.strictEqual(coreCoordinator.release_name, manifest.release_name); -assert.strictEqual( - coreCoordinator.coordinator_implementation, - "standalone-core-coordinator" -); -assert.strictEqual(coreCoordinator.client_authority, "strict"); -assert.strictEqual(coreCoordinator.authenticated_session, true); -assert.strictEqual(coreCoordinator.forged_body_authority_denied, "error"); -assert.strictEqual(coreCoordinator.wrong_session_denied, "error"); -assert.strictEqual(coreCoordinator.self_hosted_cli.connected, "connected"); -assert.strictEqual(coreCoordinator.self_hosted_cli.secret_read_from_stdin, true); -assert.strictEqual(coreCoordinator.self_hosted_cli.secret_exposed_in_report, false); -assert.strictEqual(coreCoordinator.self_hosted_cli.authenticated_status, "auth_status"); -assert.strictEqual( - coreCoordinator.self_hosted_admin.nonce_bound_proof_succeeded, - "admin_status" -); -assert.strictEqual(coreCoordinator.self_hosted_admin.replay_denied, "error"); -if (process.platform !== "win32") { - assert.strictEqual(coreCoordinator.self_hosted_cli.session_file_mode, "600"); -} -assert.strictEqual(coreCoordinator.task_placement, "task_placement"); -assert.strictEqual(coreCoordinator.task_completion, "task_recorded"); -assert(coreCoordinator.task_events >= 1, "Core coordinator compat must record task events"); -assert.strictEqual(coreCoordinator.artifact_export_plan, "artifact_export_plan"); - -const e2e = readJson("public repository e2e report", inputs.e2e); -assert.strictEqual(e2e.kind, "disasmer-public-release-dryrun-e2e"); -assert.strictEqual(e2e.default_hosted_coordinator_endpoint, serviceEndpoint); -assert.strictEqual(e2e.service_addr, serviceAddr); -assert.strictEqual(e2e.launch_task_verified, true); -assert.strictEqual(e2e.worker_assignment_poll_verified, true); -assert.strictEqual(e2e.worker_assignment_poll_protocol, "poll_task_assignment"); -assert.strictEqual(e2e.launch_task_response, "main_launched"); -assert.strictEqual(e2e.worker_assignment_process, e2e.process); -assert.strictEqual(e2e.core_coordinator_validated, true); -assert.strictEqual( - e2e.core_coordinator_implementation, - "standalone-core-coordinator" -); -assert.strictEqual(e2e.core_coordinator_client_authority, "strict"); -assert.strictEqual(e2e.core_coordinator_authenticated_session, true); -assert.strictEqual(e2e.core_coordinator_forged_body_authority_denied, "error"); -assert.strictEqual(e2e.core_coordinator_wrong_session_denied, "error"); -assert.strictEqual(e2e.core_coordinator_launch_task_response, "task_launched"); -assert.strictEqual(e2e.core_coordinator_assignment_response, "task_assignment"); -assert(e2e.core_coordinator_task_events >= 1, "e2e must record public coordinator task events"); -assert.strictEqual(e2e.released_live_dap_verified, true); -assert.strictEqual( - e2e.released_live_dap_coordinator_implementation, - "hosted-policy-coordinator" -); -assert.strictEqual(e2e.released_live_dap_attached_to_client_process, true); -assert.strictEqual(e2e.released_live_dap_authenticated_with_cli_session, true); -assert(e2e.released_live_dap_task_events >= 1, "e2e must record released live-DAP task events"); -assert( - e2e.evidence && - e2e.evidence.released_live_dap && - e2e.evidence.released_live_dap.attached_to_existing_client_process === true, - "e2e must prove released DAP attaches to the existing Client-authorized process" -); -assert( - e2e.evidence && - e2e.evidence.released_live_dap && - e2e.evidence.released_live_dap.variables_verified === true, - "e2e must prove released live-DAP variables are inspectable" -); -if (e2e.dns_publication_state) { - assertDnsState(e2e.dns_publication_state, "public repository e2e"); -} -if (e2e.acceptance_result) { - assert.strictEqual(e2e.acceptance_result, "passed"); -} -assertIncludes(e2e.public_repository_url, forgejoHost, "e2e public repo URL"); -assert.strictEqual(e2e.release_name, manifest.release_name); -assert.strictEqual(e2e.public_tree_identity, manifest.public_tree_identity); -assert.strictEqual(e2e.source_commit, manifest.source_commit); -assertEvidenceBooleans(e2e, [ - "downloaded_release_assets", - "verified_checksums", - "clean_public_checkout", - "public_repo_build_or_install", - "default_operator_selected", - "browser_or_cli_login", - "attached_user_node", - "core_coordinator_validated", - "released_live_dap_verified", - "ran_flagship_workflow", - "vscode_debugger_verified", - "logs_verified", - "artifact_metadata_verified", - "artifact_download_or_export_verified", -]); -assert(Array.isArray(e2e.commands) && e2e.commands.length > 0); -assert(Array.isArray(e2e.tool_versions) || typeof e2e.tool_versions === "object"); - -const finalReport = { - kind: "disasmer-public-release-dryrun-final-evidence", - release_name: manifest.release_name, - source_commit: manifest.source_commit, - public_tree_identity: manifest.public_tree_identity, - deployed_service_commit: deployment.deployed_service_commit, - default_hosted_coordinator_endpoint: serviceEndpoint, - service_addr: serviceAddr, - dns_publication_state: { - manifest: manifest.dns_publication_state, - deployment: deployment.dns_publication_state, - service: service.dns_publication_state, - public_repository_e2e: e2e.dns_publication_state || null, - }, - resolver_override: { - manifest: manifest.resolver_override, - deployment: deployment.resolver_override, - service: service.resolver_override, - public_repository_e2e: e2e.resolver_override || null, - }, - deployment_config: { - repo: service.deployment_config_repo || null, - commit: service.deployment_config_commit || null, - system_generation: service.deployment_system_generation || null, - service_unit: service.service_unit || null, - }, - coordinator_validation: { - private_hosted_default_operator: { - coordinator_implementation: service.coordinator_implementation, - service_addr: service.service_addr, - launch_task: service.evidence.launch_task, - assignment_poll: service.evidence.assignment_poll, - released_live_dap: e2e.evidence.released_live_dap, - }, - standalone_core_coordinator: { - coordinator_implementation: coreCoordinator.coordinator_implementation, - client_authority: coreCoordinator.client_authority, - authenticated_session: coreCoordinator.authenticated_session, - forged_body_authority_denied: - coreCoordinator.forged_body_authority_denied, - wrong_session_denied: coreCoordinator.wrong_session_denied, - self_hosted_cli: coreCoordinator.self_hosted_cli, - task_placement: coreCoordinator.task_placement, - task_events: coreCoordinator.task_events, - release_binary_e2e: { - client_authority: e2e.core_coordinator_client_authority, - authenticated_session: e2e.core_coordinator_authenticated_session, - forged_body_authority_denied: - e2e.core_coordinator_forged_body_authority_denied, - wrong_session_denied: e2e.core_coordinator_wrong_session_denied, - launch_task: e2e.core_coordinator_launch_task_response, - assignment_poll: e2e.core_coordinator_assignment_response, - task_events: e2e.core_coordinator_task_events, - }, - }, - }, - forgejo_host: forgejoHost, - public_repository_url: resolvedPublicRepoRemote, - forgejo_release: { - owner: forgejoRelease.owner, - repo: forgejoRelease.repo, - release_id: forgejoRelease.release_id, - asset_count: releaseAssets.size, - }, - tool_versions: compactToolVersions( - ["manifest", manifest], - ["service_smoke", service], - ["public_repository_e2e", e2e] - ), - acceptance_results: { - public_release_preparation: { - commands: manifest.commands, - result: "passed", - }, - deployment_bundle: { - commands: deployment.commands, - result: "passed", - }, - live_service_smoke: { - command: service.acceptance_command || null, - result: service.acceptance_result || "passed", - evidence: service.evidence, - }, - core_coordinator_compatibility: { - result: "passed", - evidence: coreCoordinator, - }, - public_repository_e2e: { - commands: e2e.commands, - result: "passed", - evidence: e2e.evidence, - }, - }, - evidence_files: inputs, -}; - -const output = path.join(acceptanceRoot, "public-release-dryrun-final.json"); -fs.mkdirSync(path.dirname(output), { recursive: true }); -fs.writeFileSync(output, `${JSON.stringify(finalReport, null, 2)}\n`); -console.log(`Public release dry-run final evidence passed: ${output}`); diff --git a/scripts/public-release-dryrun-preflight.js b/scripts/public-release-dryrun-preflight.js index 6787186..812d8dc 100755 --- a/scripts/public-release-dryrun-preflight.js +++ b/scripts/public-release-dryrun-preflight.js @@ -8,15 +8,15 @@ const path = require("path"); const repo = path.resolve(__dirname, ".."); const releaseRoot = path.resolve( - process.env.DISASMER_PUBLIC_RELEASE_DIR || + process.env.CLUSTERFLUX_PUBLIC_RELEASE_DIR || path.join(repo, "target/public-release-dryrun") ); const acceptanceRoot = path.join(repo, "target/acceptance"); const manifestPath = - process.env.DISASMER_PUBLIC_RELEASE_MANIFEST || + process.env.CLUSTERFLUX_PUBLIC_RELEASE_MANIFEST || path.join(releaseRoot, "public-release-manifest.json"); const reportPath = - process.env.DISASMER_PUBLIC_RELEASE_PREFLIGHT_REPORT || + process.env.CLUSTERFLUX_PUBLIC_RELEASE_PREFLIGHT_REPORT || path.join(acceptanceRoot, "public-release-dryrun-preflight.json"); function commandOutput(command, args, options = {}) { @@ -49,7 +49,7 @@ function nonInteractiveEnv(extra = {}) { function expectedSourceCommit() { return ( - process.env.DISASMER_ACCEPTANCE_COMMIT || + process.env.CLUSTERFLUX_ACCEPTANCE_COMMIT || commandOutput("git", ["rev-parse", "HEAD"]) || "unknown" ); @@ -77,7 +77,7 @@ function parseSha256Sums(file) { function remoteHead(remote) { const output = commandOutput("git", ["ls-remote", remote, "HEAD", "refs/heads/main"], { env: nonInteractiveEnv(), - timeout: Number(process.env.DISASMER_PUBLIC_REPO_REMOTE_TIMEOUT_MS || 30000), + timeout: Number(process.env.CLUSTERFLUX_PUBLIC_REPO_REMOTE_TIMEOUT_MS || 30000), }); if (!output) return null; const lines = output.split(/\r?\n/).filter(Boolean); @@ -88,7 +88,7 @@ function remoteHead(remote) { function publicTreeAlreadyPushed(manifest) { return ( (manifest.public_tree_publish && manifest.public_tree_publish.pushed === true) || - process.env.DISASMER_PUBLIC_TREE_ALREADY_PUSHED === "1" + process.env.CLUSTERFLUX_PUBLIC_TREE_ALREADY_PUSHED === "1" ); } @@ -102,8 +102,8 @@ function publicRepoRemoteForManifest(manifest) { return ( manifest.public_repo_url || manifest.public_repo_remote || - process.env.DISASMER_PUBLIC_REPO_REMOTE || - process.env.DISASMER_PUBLIC_REPO_URL || + process.env.CLUSTERFLUX_PUBLIC_REPO_REMOTE || + process.env.CLUSTERFLUX_PUBLIC_REPO_URL || null ); } @@ -111,8 +111,8 @@ function publicRepoRemoteForManifest(manifest) { function publicTreeCommitForManifest(manifest) { return ( (manifest.public_tree_publish && manifest.public_tree_publish.commit) || - process.env.DISASMER_PUBLIC_TREE_COMMIT || - process.env.DISASMER_PUBLIC_RELEASE_TARGET || + process.env.CLUSTERFLUX_PUBLIC_TREE_COMMIT || + process.env.CLUSTERFLUX_PUBLIC_RELEASE_TARGET || null ); } @@ -151,7 +151,7 @@ assert.strictEqual( "", "public release preflight requires a clean source tree" ); -assert.strictEqual(manifest.kind, "disasmer-public-release-dryrun"); +assert.strictEqual(manifest.kind, "clusterflux-public-release-dryrun"); assert.strictEqual( manifest.source_commit, currentSourceCommit, @@ -225,7 +225,7 @@ const evidence = [ ]; const report = { - kind: "disasmer-public-release-dryrun-preflight", + kind: "clusterflux-public-release-dryrun-preflight", source_commit: currentSourceCommit, release_name: manifest.release_name, public_tree_commit: publicTreeCommit || remoteMain, @@ -238,50 +238,50 @@ const report = { evidence, external_gates: { forgejo_release_publication: { - status: envState("DISASMER_FORGEJO_TOKEN") === "set" ? "ready" : "pending", + status: envState("CLUSTERFLUX_FORGEJO_TOKEN") === "set" ? "ready" : "pending", env: { - DISASMER_FORGEJO_TOKEN: envState("DISASMER_FORGEJO_TOKEN"), + CLUSTERFLUX_FORGEJO_TOKEN: envState("CLUSTERFLUX_FORGEJO_TOKEN"), }, }, live_service_smoke: { status: - envState("DISASMER_PUBLIC_RELEASE_DRYRUN_SERVICE_ADDR") === "set" && - envState("DISASMER_PUBLIC_RELEASE_DRYRUN_BROWSER_OPEN_COMMAND") === "set" + envState("CLUSTERFLUX_PUBLIC_RELEASE_DRYRUN_SERVICE_ADDR") === "set" && + envState("CLUSTERFLUX_PUBLIC_RELEASE_DRYRUN_BROWSER_OPEN_COMMAND") === "set" ? "ready" : "pending", env: { - DISASMER_PUBLIC_RELEASE_DRYRUN_SERVICE_ADDR: envState( - "DISASMER_PUBLIC_RELEASE_DRYRUN_SERVICE_ADDR" + CLUSTERFLUX_PUBLIC_RELEASE_DRYRUN_SERVICE_ADDR: envState( + "CLUSTERFLUX_PUBLIC_RELEASE_DRYRUN_SERVICE_ADDR" ), - DISASMER_PUBLIC_RELEASE_DRYRUN_BROWSER_OPEN_COMMAND: envState( - "DISASMER_PUBLIC_RELEASE_DRYRUN_BROWSER_OPEN_COMMAND" + CLUSTERFLUX_PUBLIC_RELEASE_DRYRUN_BROWSER_OPEN_COMMAND: envState( + "CLUSTERFLUX_PUBLIC_RELEASE_DRYRUN_BROWSER_OPEN_COMMAND" ), }, }, public_release_e2e: { status: - envState("DISASMER_PUBLIC_RELEASE_DRYRUN_E2E") === "set" && - process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_E2E === "1" && - envState("DISASMER_PUBLIC_RELEASE_DRYRUN_BROWSER_OPEN_COMMAND") === "set" + envState("CLUSTERFLUX_PUBLIC_RELEASE_DRYRUN_E2E") === "set" && + process.env.CLUSTERFLUX_PUBLIC_RELEASE_DRYRUN_E2E === "1" && + envState("CLUSTERFLUX_PUBLIC_RELEASE_DRYRUN_BROWSER_OPEN_COMMAND") === "set" ? "ready" : "pending", env: { - DISASMER_PUBLIC_RELEASE_DRYRUN_E2E: - process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_E2E || "unset", - DISASMER_PUBLIC_RELEASE_DRYRUN_BROWSER_OPEN_COMMAND: envState( - "DISASMER_PUBLIC_RELEASE_DRYRUN_BROWSER_OPEN_COMMAND" + CLUSTERFLUX_PUBLIC_RELEASE_DRYRUN_E2E: + process.env.CLUSTERFLUX_PUBLIC_RELEASE_DRYRUN_E2E || "unset", + CLUSTERFLUX_PUBLIC_RELEASE_DRYRUN_BROWSER_OPEN_COMMAND: envState( + "CLUSTERFLUX_PUBLIC_RELEASE_DRYRUN_BROWSER_OPEN_COMMAND" ), }, }, final_evidence: { status: - envState("DISASMER_PUBLIC_RELEASE_DRYRUN_FINAL") === "set" && - process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_FINAL === "1" + envState("CLUSTERFLUX_PUBLIC_RELEASE_DRYRUN_FINAL") === "set" && + process.env.CLUSTERFLUX_PUBLIC_RELEASE_DRYRUN_FINAL === "1" ? "ready" : "pending", env: { - DISASMER_PUBLIC_RELEASE_DRYRUN_FINAL: - process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_FINAL || "unset", + CLUSTERFLUX_PUBLIC_RELEASE_DRYRUN_FINAL: + process.env.CLUSTERFLUX_PUBLIC_RELEASE_DRYRUN_FINAL || "unset", }, }, }, diff --git a/scripts/public-story-contract-smoke.js b/scripts/public-story-contract-smoke.js deleted file mode 100644 index bbab2bb..0000000 --- a/scripts/public-story-contract-smoke.js +++ /dev/null @@ -1,101 +0,0 @@ -#!/usr/bin/env node - -const assert = require("assert"); -const fs = require("fs"); -const path = require("path"); - -const repo = path.resolve(__dirname, ".."); - -function read(relativePath) { - return fs.readFileSync(path.join(repo, relativePath), "utf8"); -} - -function expect(source, name, pattern) { - assert.match(source, pattern, `missing public-story evidence: ${name}`); -} - -const readme = read("README.md"); -const publicAcceptance = read("scripts/acceptance-public.sh"); -const publicSplit = read("scripts/verify-public-split.sh"); -const cliLocalRunSmoke = read("scripts/cli-local-run-smoke.js"); -const dapSmoke = read("scripts/dap-smoke.js"); -const wasmtimeAssignmentSmoke = read("scripts/wasmtime-assignment-smoke.js"); -const debuggerEvidence = `${dapSmoke}\n${wasmtimeAssignmentSmoke}`; -const flagshipDemoSmoke = read("scripts/flagship-demo-smoke.js"); -const artifactDownloadSmoke = read("scripts/artifact-download-smoke.js"); -const artifactExportSmoke = read("scripts/artifact-export-smoke.js"); - -for (const [scriptName, script] of [ - ["public acceptance", publicAcceptance], - ["public split", publicSplit], -]) { - assert( - script.includes("node scripts/public-story-contract-smoke.js"), - `${scriptName} must run public-story-contract-smoke.js` - ); -} - -for (const [name, pattern] of [ - [ - "README states public story", - /one virtual process, many virtual threads\/tasks, ordinary debugger controls, attached user nodes, and explicit artifact handling/, - ], - [ - "README states local-first bytes policy", - /local source checkouts and large outputs stay node-local unless user code or policy explicitly moves bytes/, - ], - [ - "README states normal debugger controls", - /ordinary debugger\s+controls for breakpoints, continue, pause, and restart/, - ], -]) { - expect(readme, name, pattern); -} - -for (const [name, pattern] of [ - ["CLI local run starts a node process", /cli_process_started_node_process/], - ["CLI local run checks node is separate from CLI", /assert\.notStrictEqual\(report\.boundary\.spawned_node_process_id, cliPid\)/], - ["CLI local run checks node is separate from coordinator", /assert\.notStrictEqual\(report\.boundary\.spawned_node_process_id, coordinator\.pid\)/], - ["CLI local run records the real virtual task tree", /events\.events\.length >= 4[\s\S]*prepare_source[\s\S]*compile_linux[\s\S]*package_release/], - ["CLI local run records real artifact metadata", /events\.events\.some\(\(event\) => event\.artifact_path\)/], - ["CLI local-only run starts coordinator", /cli_process_started_coordinator_process[\s\S]*true/], - ["CLI local-only run hides coordinator address requirement", /\["run"[\s\S]*"--local"[\s\S]*"--project"[\s\S]*project/], -]) { - expect(cliLocalRunSmoke, name, pattern); -} - -for (const [name, pattern] of [ - ["DAP smoke uses local-services runtime", /runtimeBackend: "local-services"/], - ["DAP smoke exposes dynamic entry and child virtual threads", /mainThread[\s\S]*build virtual process[\s\S]*childThread[\s\S]*notStrictEqual\(childThread\.id, failThread\.id\)/], - ["DAP smoke binds the real entrypoint breakpoint", /stack\[0\]\.line, buildMainLine[\s\S]*stack\[0\]\.name, \/build_main::wasm\//], - ["DAP smoke binds the real child task breakpoint", /childStack\[0\]\.line, taskTrapLine[\s\S]*childStack\[0\]\.name, \/task_trap::wasm\//], - ["DAP smoke all-stops on breakpoint", /assert\.strictEqual\(stopped\.body\.allThreadsStopped, true\)/], - ["runtime smoke proves acknowledged pause all-stop", /fully_frozen[\s\S]*resume_debug_epoch[\s\S]*fully_resumed/], - ["DAP smoke refuses active-task restart without a clean boundary", /restartFailure[\s\S]*checkpoint boundary\|still active/], - ["DAP smoke supports rebuilt terminal main restart", /restartFrame[\s\S]*Restarted main from the rebuilt bundle/], - ["DAP smoke avoids native child debugger claims", /doesNotMatch\(stack\[0\]\.name, \/podman\|cmd\\\.exe\|powershell\|pid\|native child\/i\)/], - ["DAP smoke crosses coordinator-node boundary without fabricating terminal state", /runtime_backend[\s\S]*LocalServices[\s\S]*coordinator_task_events[\s\S]*0/], -]) { - expect(debuggerEvidence, name, pattern); -} - -for (const [name, pattern] of [ - ["flagship source does not need coordinator checkout", /coordinator_requires_checkout_access[\s\S]*false/], - ["flagship source bytes remain node-local", /local_source_bytes_remain_node_local[\s\S]*true/], - ["flagship avoids default source upload", /coordinator_receives_source_bytes_by_default[\s\S]*false/], - ["flagship avoids default repo tarball", /default_full_repo_tarball[\s\S]*false/], -]) { - expect(flagshipDemoSmoke, name, pattern); -} - -for (const [name, source, pattern] of [ - ["artifact download creates scoped link", artifactDownloadSmoke, /create_artifact_download_link/], - ["artifact download records retained-node source", artifactDownloadSmoke, /assert\.deepStrictEqual\(link\.link\.source, \{ RetainedNode: "node-download" \}\)/], - ["artifact download opens scoped stream", artifactDownloadSmoke, /open_artifact_download_stream/], - ["artifact export targets attached receiver node", artifactExportSmoke, /export_artifact_to_node[\s\S]*node-export-receiver/], - ["artifact export disables coordinator bulk relay", artifactExportSmoke, /coordinator_bulk_relay_allowed[\s\S]*false/], -]) { - expect(source, name, pattern); -} - -console.log("Public story contract smoke passed"); diff --git a/scripts/publish-public-release-dryrun.js b/scripts/publish-public-release-dryrun.js index 825ecdb..ea2a78c 100755 --- a/scripts/publish-public-release-dryrun.js +++ b/scripts/publish-public-release-dryrun.js @@ -7,7 +7,7 @@ const cp = require("child_process"); const repo = path.resolve(__dirname, ".."); const releaseRoot = path.resolve( - process.env.DISASMER_PUBLIC_RELEASE_DIR || + process.env.CLUSTERFLUX_PUBLIC_RELEASE_DIR || path.join(repo, "target/public-release-dryrun") ); const manifestPath = path.join(releaseRoot, "public-release-manifest.json"); @@ -16,11 +16,11 @@ const reportPath = path.join( "target/acceptance/public-release-dryrun-forgejo-release.json" ); const forgejoUrl = ( - process.env.DISASMER_FORGEJO_URL || "https://git.michelpaulissen.com" + process.env.CLUSTERFLUX_FORGEJO_URL || "https://git.michelpaulissen.com" ).replace(/\/+$/, ""); -const token = process.env.DISASMER_FORGEJO_TOKEN; -let owner = process.env.DISASMER_PUBLIC_REPO_OWNER; -let repoName = process.env.DISASMER_PUBLIC_REPO_NAME; +const token = process.env.CLUSTERFLUX_FORGEJO_TOKEN; +let owner = process.env.CLUSTERFLUX_PUBLIC_REPO_OWNER; +let repoName = process.env.CLUSTERFLUX_PUBLIC_REPO_NAME; function requireEnv(name, value) { if (!value) { @@ -44,7 +44,7 @@ function commandOutput(command, args) { function expectedSourceCommit() { return ( - process.env.DISASMER_ACCEPTANCE_COMMIT || + process.env.CLUSTERFLUX_ACCEPTANCE_COMMIT || commandOutput("git", ["rev-parse", "HEAD"]) || "unknown" ); @@ -87,13 +87,13 @@ function resolveRepoIdentity(manifest) { const inferred = parseForgejoRepoIdentity( manifest.public_repo_url || manifest.public_repo_remote || - process.env.DISASMER_PUBLIC_REPO_REMOTE + process.env.CLUSTERFLUX_PUBLIC_REPO_REMOTE ); owner = owner || (inferred && inferred.owner); repoName = repoName || (inferred && inferred.repoName); if (!owner || !repoName) { throw new Error( - "DISASMER_PUBLIC_REPO_OWNER and DISASMER_PUBLIC_REPO_NAME are required when the manifest does not contain a parseable Forgejo repository URL" + "CLUSTERFLUX_PUBLIC_REPO_OWNER and CLUSTERFLUX_PUBLIC_REPO_NAME are required when the manifest does not contain a parseable Forgejo repository URL" ); } } @@ -101,15 +101,15 @@ function resolveRepoIdentity(manifest) { function publicTreeAlreadyPushed(manifest) { return ( (manifest.public_tree_publish && manifest.public_tree_publish.pushed === true) || - process.env.DISASMER_PUBLIC_TREE_ALREADY_PUSHED === "1" + process.env.CLUSTERFLUX_PUBLIC_TREE_ALREADY_PUSHED === "1" ); } function publicTreeCommit(manifest) { return ( (manifest.public_tree_publish && manifest.public_tree_publish.commit) || - process.env.DISASMER_PUBLIC_TREE_COMMIT || - process.env.DISASMER_PUBLIC_RELEASE_TARGET || + process.env.CLUSTERFLUX_PUBLIC_TREE_COMMIT || + process.env.CLUSTERFLUX_PUBLIC_RELEASE_TARGET || null ); } @@ -177,7 +177,7 @@ function request(method, pathname, { body, headers = {} } = {}) { } function multipartFile(fieldName, file) { - const boundary = `disasmer-${Date.now()}-${Math.random().toString(16).slice(2)}`; + const boundary = `clusterflux-${Date.now()}-${Math.random().toString(16).slice(2)}`; const name = path.basename(file); const header = Buffer.from( `--${boundary}\r\n` + @@ -209,7 +209,7 @@ async function createOrReuseRelease(manifest) { publicTreeCommit(manifest) || "main"; const body = [ - "Disasmer public release dry run.", + "Clusterflux public release dry run.", "", `Default hosted coordinator endpoint: ${manifest.default_hosted_coordinator_endpoint}`, `DNS publication state: ${manifest.dns_publication_state}`, @@ -264,14 +264,14 @@ function existingAssetByName(release, name) { } async function main() { - requireEnv("DISASMER_FORGEJO_TOKEN", token); + requireEnv("CLUSTERFLUX_FORGEJO_TOKEN", token); if (!fs.existsSync(manifestPath)) { throw new Error(`missing public release manifest: ${manifestPath}`); } const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); resolveRepoIdentity(manifest); - if (manifest.kind !== "disasmer-public-release-dryrun") { + if (manifest.kind !== "clusterflux-public-release-dryrun") { throw new Error(`unexpected public release manifest kind: ${manifest.kind}`); } if (manifest.source_commit !== expectedSourceCommit()) { @@ -283,7 +283,7 @@ async function main() { !publicTreeAlreadyPushed(manifest) ) { throw new Error( - "public tree must be pushed before publishing the Forgejo Release; run prepare-public-release-dryrun.js with DISASMER_PUBLISH_PUBLIC_TREE=1" + "public tree must be pushed before publishing the Forgejo Release; run prepare-public-release-dryrun.js with CLUSTERFLUX_PUBLISH_PUBLIC_TREE=1" ); } if (!Array.isArray(manifest.assets) || manifest.assets.length === 0) { @@ -308,7 +308,7 @@ async function main() { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); const report = { - kind: "disasmer-public-release-dryrun-forgejo-release", + kind: "clusterflux-public-release-dryrun-forgejo-release", forgejo_url: forgejoUrl, owner, repo: repoName, diff --git a/scripts/quic-smoke.js b/scripts/quic-smoke.js index b61f16b..5ad4634 100755 --- a/scripts/quic-smoke.js +++ b/scripts/quic-smoke.js @@ -77,12 +77,12 @@ function rendezvousRequest(overrides = {}) { (async () => { const output = cp.execFileSync( "cargo", - ["run", "-q", "-p", "disasmer-node", "--bin", "disasmer-quic-smoke"], + ["run", "-q", "-p", "clusterflux-node", "--bin", "clusterflux-quic-smoke"], { cwd: repo, encoding: "utf8" } ); const report = JSON.parse(output.trim().split("\n").at(-1)); - assert.strictEqual(report.kind, "disasmer_quic_smoke"); + assert.strictEqual(report.kind, "clusterflux_quic_smoke"); assert.strictEqual(report.transport, "NativeQuic"); assert.strictEqual(report.rust_native_quic, true); assert.strictEqual(report.authenticated_direct_connection, true); @@ -105,9 +105,9 @@ function rendezvousRequest(overrides = {}) { "run", "-q", "-p", - "disasmer-coordinator", + "clusterflux-coordinator", "--bin", - "disasmer-coordinator", + "clusterflux-coordinator", "--", "--listen", "127.0.0.1:0", diff --git a/scripts/real-flagship-harness.js b/scripts/real-flagship-harness.js index a315b3f..f5c9106 100644 --- a/scripts/real-flagship-harness.js +++ b/scripts/real-flagship-harness.js @@ -100,7 +100,7 @@ async function runFlagshipWorker(addr, node, identity) { }); assert.strictEqual(enrollment.type, "node_enrollment_grant_created"); const cargoArgs = [ - "run", "-q", "-p", "disasmer-node", "--bin", "disasmer-node", "--", + "run", "-q", "-p", "clusterflux-node", "--bin", "clusterflux-node", "--", "--coordinator", `${addr.host}:${addr.port}`, "--tenant", "tenant", "--project-id", "project", @@ -116,7 +116,7 @@ async function runFlagshipWorker(addr, node, identity) { cwd: repo, env: { ...process.env, - DISASMER_NODE_PRIVATE_KEY: identity.privateKey, + CLUSTERFLUX_NODE_PRIVATE_KEY: identity.privateKey, }, }); return { child, ready: waitForJsonLine(child) }; @@ -146,9 +146,9 @@ function startFlagship(addr) { cp.execFileSync( "cargo", [ - "run", "-q", "-p", "disasmer-cli", "--bin", "disasmer", "--", + "run", "-q", "-p", "clusterflux-cli", "--bin", "clusterflux", "--", "run", "build", - "--coordinator", `disasmer+tcp://${addr.host}:${addr.port}`, + "--coordinator", `clusterflux+tcp://${addr.host}:${addr.port}`, "--project", project, "--json", ], @@ -163,7 +163,7 @@ function startFlagship(addr) { assert.strictEqual(report.task_launch.task_definition, report.task_definition); assert.strictEqual(report.worker_placement_requested, true); assert.match(report.bundle_digest, /^sha256:[0-9a-f]{64}$/); - assert.match(report.entry_export, /^disasmer_entry_v1_/); + assert.match(report.entry_export, /^clusterflux_entry_v1_/); const virtualProcess = report.process; return { report, process: virtualProcess }; } diff --git a/scripts/release-blocker-smoke.js b/scripts/release-blocker-smoke.js deleted file mode 100755 index 0509db2..0000000 --- a/scripts/release-blocker-smoke.js +++ /dev/null @@ -1,189 +0,0 @@ -#!/usr/bin/env node - -const assert = require("assert"); -const fs = require("fs"); -const path = require("path"); - -const repo = path.resolve(__dirname, ".."); - -function read(relativePath) { - return fs.readFileSync(path.join(repo, relativePath), "utf8"); -} - -function maybeRead(relativePath) { - const fullPath = path.join(repo, relativePath); - if (!fs.existsSync(fullPath)) return null; - return fs.readFileSync(fullPath, "utf8"); -} - -function section(source, heading) { - const marker = `## ${heading}`; - const start = source.indexOf(marker); - assert(start >= 0, `missing section ${marker}`); - const next = source.indexOf("\n## ", start + marker.length); - return source.slice(start, next >= 0 ? next : source.length); -} - -function expect(source, name, pattern) { - assert.match(source, pattern, `missing release-blocker evidence: ${name}`); -} - -const hiddenDemoBlockerPattern = new RegExp( - [ - "flagship demo requires", - ["undocumented", "manual state"].join(" "), - ["hard-coded", "local paths"].join(" "), - ["demo-only", "credentials"].join(" "), - ["hidden", "setup"].join(" "), - ].join("[\\s\\S]*") -); -const hiddenDemoScanPattern = new RegExp( - `demo_setup_pattern='${[ - ["undocumented", "manual state"].join(" "), - ["hidden", "setup"].join(" "), - ["demo-only", "credentials?"].join(" "), - ["hard-coded", "local paths?"].join(" "), - ] - .map((term) => term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) - .join("\\\\|")}'` -); - -const phase2 = read("acceptance_criteria_phase2.md"); -const base = read("acceptance_criteria.md"); -const publicAcceptance = read("scripts/acceptance-public.sh"); -const privateAcceptance = read("scripts/acceptance-private.sh"); -const publicSplit = read("scripts/verify-public-split.sh"); -const artifactDownloadSmoke = read("scripts/artifact-download-smoke.js"); -const operatorPanelSmoke = read("scripts/operator-panel-smoke.js"); -const schedulerSmoke = read("scripts/scheduler-placement-smoke.js"); -const sourcePreparationSmoke = read("scripts/source-preparation-smoke.js"); -const hostedClientCompatSmoke = maybeRead("private/hosted-policy/scripts/hosted-client-compat-smoke.js"); -const releaseSourceScan = read("scripts/release-source-scan.sh"); -const flagshipDemoSmoke = read("scripts/flagship-demo-smoke.js"); - -const releaseBlockers = section(phase2, "20. Release blockers"); -expect( - releaseBlockers, - "cross-tenant access is listed as a release blocker", - /Cross-tenant access succeeds for projects, nodes, processes, logs, artifacts, downloads, debug state, panels, capabilities, source manifests, credentials, or metadata/ -); -expect( - releaseBlockers, - "manual-state flagship blocker is listed", - hiddenDemoBlockerPattern -); - -expect( - section(base, "21. Authorization and tenant isolation"), - "base criteria require tenant isolation failures to block release", - /Tenant isolation failures are treated as release blockers/ -); - -for (const [scriptName, script] of [ - ["public acceptance", publicAcceptance], - ["public split", publicSplit], -]) { - for (const smoke of [ - "scripts/artifact-download-smoke.js", - "scripts/operator-panel-smoke.js", - "scripts/source-preparation-smoke.js", - "scripts/scheduler-placement-smoke.js", - "scripts/flagship-demo-smoke.js", - ]) { - assert( - script.includes(`node ${smoke}`), - `${scriptName} must run ${smoke} as part of tenant-isolation release blocking` - ); - } - assert( - script.includes("scripts/release-source-scan.sh"), - `${scriptName} must run release-source-scan.sh as part of release blocking` - ); -} - -assert( - privateAcceptance.includes("node private/hosted-policy/scripts/hosted-client-compat-smoke.js"), - "private acceptance must run hosted Client cross-tenant checks" -); -assert( - privateAcceptance.includes("node private/hosted-policy/scripts/hosted-deployment-smoke.js"), - "private acceptance must run hosted deployment checks" -); - -const boundaryEvidence = [ - [ - "artifact download", - artifactDownloadSmoke, - [/const crossTenant = await send/, /const crossTenantOpen = await send/, /tenant mismatch/], - ], - [ - "operator panel", - operatorPanelSmoke, - [/const crossTenant = await send/, /render_operator_panel/, /scope\|tenant\|project/], - ], - [ - "source preparation", - sourcePreparationSmoke, - [/const crossTenantCompletion = await send/, /complete_source_preparation/, /tenant\\\/project scope/i], - ], - [ - "scheduler/node capability", - schedulerSmoke, - [/const crossTenantReport = await send/, /report_node_capabilities/, /tenant\\\/project scope/], - ], -]; - -if (hostedClientCompatSmoke) { - boundaryEvidence.push([ - "hosted Client compatibility", - hostedClientCompatSmoke, - [ - /const forged = await sendHostedControl/, - /const crossTenantTaskEventsDenied = await sendHostedControl/, - /scope\|denied\|unauthorized/, - ], - ]); -} - -for (const [name, source, patterns] of boundaryEvidence) { - for (const pattern of patterns) { - expect(source, name, pattern); - } -} - -for (const [name, pattern] of [ - ["release source scan rejects manual demo state", hiddenDemoScanPattern], - ["release source scan rejects hidden local paths", /hidden_local_pattern='file:\/\/\|\/home\/\[.*\]_.-\]\+\/\|\/Users\/\[.*\]_.-\]\+\/\|C:\\\\Users\\\\\|https\?:\/\/\(localhost\|127\\\.0\\\.0\\\.1\)/], -]) { - expect(releaseSourceScan, name, pattern); -} - -for (const [name, pattern] of [ - ["public split excludes private modules", /--exclude='\.\/private'/], - ["public split excludes experiments", /--exclude='\.\/experiments'/], - ["public split tests copied workspace", /cargo test --workspace --manifest-path "\$tmp_dir\/Cargo\.toml"/], - ["public split builds copied workspace bins", /cargo build --workspace --bins --manifest-path "\$tmp_dir\/Cargo\.toml"/], - ["public split installs CLI from copied tree", /\(cd "\$tmp_dir" && node scripts\/cli-install-smoke\.js\)/], - ["public split installs VS Code extension from copied tree", /\(cd "\$tmp_dir" && node scripts\/vscode-extension-smoke\.js\)/], - ["public split attaches node from copied tree", /\(cd "\$tmp_dir" && node scripts\/node-attach-smoke\.js\)/], - ["public split runs real Wasm assignments from copied tree", /\(cd "\$tmp_dir" && node scripts\/wasmtime-assignment-smoke\.js\)/], - ["public split runs CLI local workflow from copied tree", /\(cd "\$tmp_dir" && node scripts\/cli-local-run-smoke\.js\)/], - ["public split runs artifact download from copied tree", /\(cd "\$tmp_dir" && node scripts\/artifact-download-smoke\.js\)/], - ["public split runs artifact export from copied tree", /\(cd "\$tmp_dir" && node scripts\/artifact-export-smoke\.js\)/], - ["public split runs DAP smoke from copied tree", /\(cd "\$tmp_dir" && node scripts\/dap-smoke\.js\)/], - ["public split runs flagship demo smoke from copied tree", /\(cd "\$tmp_dir" && node scripts\/flagship-demo-smoke\.js\)/], -]) { - expect(publicSplit, name, pattern); -} - -for (const [name, pattern] of [ - ["flagship demo rejects local machine assumptions", /forbiddenSourceAssumptions/], - ["flagship demo rejects coordinator checkout access", /coordinator_requires_checkout_access[\s\S]*false/], - ["flagship demo asserts local source bytes stay node-local", /local_source_bytes_remain_node_local[\s\S]*true/], - ["flagship demo asserts coordinator receives no source bytes by default", /coordinator_receives_source_bytes_by_default[\s\S]*false/], - ["flagship demo asserts no default full repo tarball", /default_full_repo_tarball[\s\S]*false/], -]) { - expect(flagshipDemoSmoke, name, pattern); -} - -console.log("Release blocker smoke passed"); diff --git a/scripts/release-source-scan.sh b/scripts/release-source-scan.sh index 4f2db0c..d8bf013 100755 --- a/scripts/release-source-scan.sh +++ b/scripts/release-source-scan.sh @@ -42,9 +42,11 @@ scan_globs=( --glob '!**/target/**' --glob '!**/node_modules/**' --glob '!scripts/release-source-scan.sh' + --glob '!scripts/rename-to-clusterflux.sh' + --glob '!scripts/check-docs.js' ) -placeholder_pattern='debugger-gate|experiments/debugger-gate|DISASMER-DEMO|device-code-placeholder|artifact://demo|vp-local-demo' +placeholder_pattern='debugger-gate|experiments/debugger-gate|CLUSTERFLUX-DEMO|device-code-placeholder|artifact://demo|vp-local-demo' if rg -n "${scan_globs[@]}" "$placeholder_pattern" "${existing_release_paths[@]}"; then echo "release source scan failed: stale experiment/demo placeholder reference found" >&2 exit 1 diff --git a/scripts/rename-to-clusterflux.sh b/scripts/rename-to-clusterflux.sh new file mode 100755 index 0000000..622a162 --- /dev/null +++ b/scripts/rename-to-clusterflux.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo_root" + +excluded_dirs=( + .git + target + node_modules + vendor + .direnv + .cache + dist + out +) + +find_expression=() +for directory in "${excluded_dirs[@]}"; do + if ((${#find_expression[@]})); then + find_expression+=( -o ) + fi + find_expression+=( -name "$directory" ) +done + +mapfile -d '' rename_paths < <( + find . \ + \( -type d \( "${find_expression[@]}" \) -prune \) -o \ + \( -name '*disasmer*' -o -name '*Disasmer*' -o -name '*DISASMER*' \) -print0 +) + +for ((index=${#rename_paths[@]} - 1; index >= 0; index--)); do + path="${rename_paths[$index]}" + parent="$(dirname "$path")" + name="$(basename "$path")" + renamed="${name//DISASMER/CLUSTERFLUX}" + renamed="${renamed//Disasmer/Clusterflux}" + renamed="${renamed//disasmer/clusterflux}" + destination="$parent/$renamed" + if [[ "$path" != "$destination" ]]; then + if [[ -e "$destination" ]]; then + printf 'rename destination already exists: %s\n' "$destination" >&2 + exit 1 + fi + mv -- "$path" "$destination" + fi +done + +rg_args=( + --files-with-matches + --null + --hidden + --glob '!**/.git/**' + --glob '!**/target/**' + --glob '!**/node_modules/**' + --glob '!**/vendor/**' + --glob '!**/.direnv/**' + --glob '!**/.cache/**' + --glob '!**/dist/**' + --glob '!**/out/**' + --glob '!scripts/rename-to-clusterflux.sh' + --glob '!scripts/migrate-clusterflux-state.sh' + --glob '!internal/finish_mvp.md' +) + +while IFS= read -r -d '' file; do + perl -0pi -e 's/DISASMER/CLUSTERFLUX/g; s/Disasmer/Clusterflux/g; s/disasmer/clusterflux/g' "$file" +done < <(rg "${rg_args[@]}" 'DISASMER|Disasmer|disasmer' . || true) + +rm -f Cargo.lock private/hosted-policy/Cargo.lock experiments/debugger-gate/Cargo.lock +cargo generate-lockfile +cargo generate-lockfile --manifest-path private/hosted-policy/Cargo.toml +cargo generate-lockfile --manifest-path experiments/debugger-gate/Cargo.toml + +for package in vscode-extension experiments/debugger-gate/vscode-extension; do + if [[ -f "$package/package-lock.json" ]]; then + npm install --package-lock-only --ignore-scripts --prefix "$package" + fi +done + +printf 'Clusterflux rename applied. Rerunning this script is a no-op apart from lockfile regeneration.\n' diff --git a/scripts/resource-metering-contract-smoke.js b/scripts/resource-metering-contract-smoke.js index 63c01c7..995350b 100644 --- a/scripts/resource-metering-contract-smoke.js +++ b/scripts/resource-metering-contract-smoke.js @@ -20,34 +20,24 @@ function expect(source, name, pattern) { assert.match(source, pattern, `missing resource metering evidence: ${name}`); } -function expectGate(script, gateName) { - assert( - script.includes("node scripts/resource-metering-contract-smoke.js"), - `${gateName} must run resource-metering-contract-smoke.js` - ); -} - -const coreLimits = read("crates/disasmer-core/src/limits.rs"); +const coreLimits = read("crates/clusterflux-core/src/limits.rs"); // Phase 3 keeps the protocol dispatch in service.rs and the metered operation // implementations in focused service modules. Read the complete relevant // boundary so this contract follows the refactor instead of one mega-file. const coordinatorService = [ - read("crates/disasmer-coordinator/src/service.rs"), - read("crates/disasmer-coordinator/src/service/routing.rs"), - read("crates/disasmer-coordinator/src/service/processes.rs"), - read("crates/disasmer-coordinator/src/service/process_launch.rs"), - read("crates/disasmer-coordinator/src/service/artifacts.rs"), + read("crates/clusterflux-coordinator/src/service.rs"), + read("crates/clusterflux-coordinator/src/service/routing.rs"), + read("crates/clusterflux-coordinator/src/service/processes.rs"), + read("crates/clusterflux-coordinator/src/service/process_launch.rs"), + read("crates/clusterflux-coordinator/src/service/artifacts.rs"), ].join("\n"); -const coordinatorQuota = read("crates/disasmer-coordinator/src/service/quota.rs"); -const coordinatorLogs = read("crates/disasmer-coordinator/src/service/logs.rs"); -const coordinatorDebug = read("crates/disasmer-coordinator/src/service/debug.rs"); -const coordinatorTests = read("crates/disasmer-coordinator/src/service/tests.rs"); +const coordinatorQuota = read("crates/clusterflux-coordinator/src/service/quota.rs"); +const coordinatorLogs = read("crates/clusterflux-coordinator/src/service/logs.rs"); +const coordinatorDebug = read("crates/clusterflux-coordinator/src/service/debug.rs"); +const coordinatorTests = read("crates/clusterflux-coordinator/src/service/tests.rs"); const artifactDownloadSmoke = read("scripts/artifact-download-smoke.js"); const operatorPanelSmoke = read("scripts/operator-panel-smoke.js"); const quicSmoke = read("scripts/quic-smoke.js"); -const publicAcceptance = read("scripts/acceptance-public.sh"); -const publicSplit = read("scripts/verify-public-split.sh"); -const privateAcceptance = read("scripts/acceptance-private.sh"); for (const [name, pattern] of [ ["API call limit kind", /\bApiCall,/], @@ -179,10 +169,6 @@ for (const [name, source, patterns] of [ } } -expectGate(publicAcceptance, "public acceptance"); -expectGate(publicSplit, "public split acceptance"); -expectGate(privateAcceptance, "private acceptance"); - const privateHostedLibSource = maybeRead(["private", "hosted-policy", "src", "lib.rs"]); const privateHostedTests = maybeRead(["private", "hosted-policy", "src", "tests.rs"]); const privateHostedLib = privateHostedLibSource diff --git a/scripts/scheduler-placement-smoke.js b/scripts/scheduler-placement-smoke.js index 5d36dda..eb03a41 100755 --- a/scripts/scheduler-placement-smoke.js +++ b/scripts/scheduler-placement-smoke.js @@ -20,7 +20,7 @@ function buildFlagshipBundle() { const output = cp.execFileSync( "cargo", [ - "run", "-q", "-p", "disasmer-cli", "--bin", "disasmer", "--", + "run", "-q", "-p", "clusterflux-cli", "--bin", "clusterflux", "--", "build", "--project", "examples/launch-build-demo", "--json", ], { cwd: repo, encoding: "utf8" } @@ -155,9 +155,9 @@ async function reportNode(addr, node, identity, locality) { "run", "-q", "-p", - "disasmer-coordinator", + "clusterflux-coordinator", "--bin", - "disasmer-coordinator", + "clusterflux-coordinator", "--", "--listen", "127.0.0.1:0", diff --git a/scripts/sdk-spawn-runtime-smoke.js b/scripts/sdk-spawn-runtime-smoke.js index 5e01c30..ed5ddbb 100755 --- a/scripts/sdk-spawn-runtime-smoke.js +++ b/scripts/sdk-spawn-runtime-smoke.js @@ -6,9 +6,9 @@ const fs = require("fs"); const path = require("path"); const repo = path.resolve(__dirname, ".."); -const sdk = fs.readFileSync(path.join(repo, "crates/disasmer-sdk/src/lib.rs"), "utf8"); +const sdk = fs.readFileSync(path.join(repo, "crates/clusterflux-sdk/src/lib.rs"), "utf8"); const productRuntime = fs.readFileSync( - path.join(repo, "crates/disasmer-sdk/src/sdk_runtime.rs"), + path.join(repo, "crates/clusterflux-sdk/src/sdk_runtime.rs"), "utf8" ); @@ -18,7 +18,7 @@ assert.match(sdk, /fn register_runtime_thread/); assert.match(sdk, /register_runtime_thread\(id, self\.name, self\.env\)/); assert.match(sdk, /pub fn debugger_visible\(&self\) -> bool/); assert.match(sdk, /ProductRuntimeConfig::from_env\(\)/); -assert.match(sdk, /start_remote_task\(config, task_id/); +assert.match(sdk, /start_remote_task\(\s*config,\s*task_id/); assert.match(sdk, /start_guest_host_task/); assert.match(sdk, /join_remote_task\(remote\)/); assert.match(productRuntime, /let spec = TaskSpec \{/); @@ -40,7 +40,7 @@ cp.execFileSync( [ "test", "-p", - "disasmer-sdk", + "clusterflux-sdk", "spawn_task_start_registers_debugger_visible_runtime_thread", ], { cwd: repo, stdio: "inherit" } diff --git a/scripts/self-hosted-coordinator-smoke.js b/scripts/self-hosted-coordinator-smoke.js index 90087fd..2923e39 100644 --- a/scripts/self-hosted-coordinator-smoke.js +++ b/scripts/self-hosted-coordinator-smoke.js @@ -30,7 +30,7 @@ const coordinatorTaskBundleDigest = `sha256:${crypto const adminToken = "self-hosted-smoke-admin-token"; const clientSessionSecret = "self-hosted-smoke-client-session-secret"; const releaseManifestPath = - process.env.DISASMER_PUBLIC_RELEASE_MANIFEST || + process.env.CLUSTERFLUX_PUBLIC_RELEASE_MANIFEST || path.join(repo, "target/public-release-dryrun/public-release-manifest.json"); function sha256(value) { @@ -57,7 +57,7 @@ function adminRequest(token, operation, tenant, actorUser, targetTenant, nonce) actor_user: actorUser, ...(operation === "suspend_tenant" ? { target_tenant: targetTenant } : {}), admin_proof: digestFromParts([ - "disasmer-admin-request-proof:v1", + "clusterflux-admin-request-proof:v1", sha256(token), operation, tenant, @@ -174,7 +174,7 @@ function nodeSignatureMessage( issuedAtEpochSeconds ) { const parts = [ - "disasmer-node-request-signature:v2", + "clusterflux-node-request-signature:v2", node, requestKind, payloadDigest, @@ -268,7 +268,7 @@ function runJson(command, args, options = {}) { function expectedSourceCommit() { return ( - process.env.DISASMER_ACCEPTANCE_COMMIT || + process.env.CLUSTERFLUX_ACCEPTANCE_COMMIT || commandOutput("git", ["rev-parse", "HEAD"]) ); } @@ -276,15 +276,9 @@ function expectedSourceCommit() { function readReleaseManifest() { if (!fs.existsSync(releaseManifestPath)) return null; const manifest = JSON.parse(fs.readFileSync(releaseManifestPath, "utf8")); - assert.strictEqual(manifest.kind, "disasmer-public-release-dryrun"); + if (manifest.kind !== "clusterflux-public-release-dryrun") return null; const expectedCommit = expectedSourceCommit(); - if (expectedCommit) { - assert.strictEqual( - manifest.source_commit, - expectedCommit, - "self-hosted coordinator smoke must use the manifest for the current acceptance commit" - ); - } + if (expectedCommit && manifest.source_commit !== expectedCommit) return null; return manifest; } @@ -294,7 +288,7 @@ function releaseIdentity() { sourceCommit: manifest ? manifest.source_commit : expectedSourceCommit(), releaseName: (manifest && manifest.release_name) || - process.env.DISASMER_PUBLIC_RELEASE_NAME || + process.env.CLUSTERFLUX_PUBLIC_RELEASE_NAME || null, }; } @@ -349,9 +343,9 @@ async function attachTrustedNode(addr, node, capabilities = linuxNodeCapabilitie "run", "-q", "-p", - "disasmer-coordinator", + "clusterflux-coordinator", "--bin", - "disasmer-coordinator", + "clusterflux-coordinator", "--", "--listen", "127.0.0.1:0" @@ -360,11 +354,11 @@ async function attachTrustedNode(addr, node, capabilities = linuxNodeCapabilitie cwd: repo, env: { ...process.env, - DISASMER_ADMIN_TOKEN: adminToken, - DISASMER_SELF_HOSTED_SESSION_SECRET: clientSessionSecret, - DISASMER_SELF_HOSTED_TENANT: "team", - DISASMER_SELF_HOSTED_PROJECT: "self-hosted", - DISASMER_SELF_HOSTED_USER: "developer", + CLUSTERFLUX_ADMIN_TOKEN: adminToken, + CLUSTERFLUX_SELF_HOSTED_SESSION_SECRET: clientSessionSecret, + CLUSTERFLUX_SELF_HOSTED_TENANT: "team", + CLUSTERFLUX_SELF_HOSTED_PROJECT: "self-hosted", + CLUSTERFLUX_SELF_HOSTED_USER: "developer", }, } ); @@ -436,7 +430,7 @@ async function attachTrustedNode(addr, node, capabilities = linuxNodeCapabilitie const cliBin = path.join( cargoTargetDir, "debug", - process.platform === "win32" ? "disasmer.exe" : "disasmer" + process.platform === "win32" ? "clusterflux.exe" : "clusterflux" ); const selfHostedCliProject = path.join( repo, @@ -468,7 +462,7 @@ async function attachTrustedNode(addr, node, capabilities = linuxNodeCapabilitie assert.strictEqual(cliConnect.coordinator_response.type, "auth_status"); const sessionPath = path.join( selfHostedCliProject, - ".disasmer/session.json" + ".clusterflux/session.json" ); const storedSession = JSON.parse(fs.readFileSync(sessionPath, "utf8")); assert.strictEqual(storedSession.kind, "self_hosted"); @@ -670,7 +664,7 @@ async function attachTrustedNode(addr, node, capabilities = linuxNodeCapabilitie reportPath, `${JSON.stringify( { - kind: "disasmer-core-coordinator-compatibility", + kind: "clusterflux-core-coordinator-compatibility", source_commit: release.sourceCommit, release_name: release.releaseName, coordinator_implementation: "standalone-core-coordinator", diff --git a/scripts/source-preparation-smoke.js b/scripts/source-preparation-smoke.js index 039a46a..9f9acd8 100755 --- a/scripts/source-preparation-smoke.js +++ b/scripts/source-preparation-smoke.js @@ -66,9 +66,9 @@ function sourceCapableNode(sourceProviders = ["git"]) { "run", "-q", "-p", - "disasmer-coordinator", + "clusterflux-coordinator", "--bin", - "disasmer-coordinator", + "clusterflux-coordinator", "--", "--listen", "127.0.0.1:0", diff --git a/scripts/tenant-isolation-contract-smoke.js b/scripts/tenant-isolation-contract-smoke.js index 35ea3c5..fb93e49 100644 --- a/scripts/tenant-isolation-contract-smoke.js +++ b/scripts/tenant-isolation-contract-smoke.js @@ -20,35 +20,24 @@ function expect(source, name, pattern) { assert.match(source, pattern, `missing tenant-isolation evidence: ${name}`); } -function expectGate(script, gateName) { - assert( - script.includes("node scripts/tenant-isolation-contract-smoke.js"), - `${gateName} must run tenant-isolation-contract-smoke.js` - ); -} - -const auth = read("crates/disasmer-core/src/auth.rs"); -const artifact = read("crates/disasmer-core/src/artifact.rs"); -const operatorPanel = read("crates/disasmer-core/src/operator_panel.rs"); -const source = read("crates/disasmer-core/src/source.rs"); +const auth = read("crates/clusterflux-core/src/auth.rs"); +const artifact = read("crates/clusterflux-core/src/artifact.rs"); +const operatorPanel = read("crates/clusterflux-core/src/operator_panel.rs"); +const source = read("crates/clusterflux-core/src/source.rs"); const coordinatorService = [ - read("crates/disasmer-coordinator/src/service.rs"), - read("crates/disasmer-coordinator/src/service/routing.rs"), - read("crates/disasmer-coordinator/src/service/nodes.rs"), - read("crates/disasmer-coordinator/src/service/keys.rs"), - read("crates/disasmer-coordinator/src/service/artifacts.rs"), - read("crates/disasmer-coordinator/src/service/processes.rs"), - read("crates/disasmer-coordinator/src/service/process_launch.rs"), - read("crates/disasmer-coordinator/src/service/tests.rs"), + read("crates/clusterflux-coordinator/src/service.rs"), + read("crates/clusterflux-coordinator/src/service/routing.rs"), + read("crates/clusterflux-coordinator/src/service/nodes.rs"), + read("crates/clusterflux-coordinator/src/service/keys.rs"), + read("crates/clusterflux-coordinator/src/service/artifacts.rs"), + read("crates/clusterflux-coordinator/src/service/processes.rs"), + read("crates/clusterflux-coordinator/src/service/process_launch.rs"), + read("crates/clusterflux-coordinator/src/service/tests.rs"), ].join("\n"); const artifactDownloadSmoke = read("scripts/artifact-download-smoke.js"); const operatorPanelSmoke = read("scripts/operator-panel-smoke.js"); const schedulerSmoke = read("scripts/scheduler-placement-smoke.js"); const sourcePreparationSmoke = read("scripts/source-preparation-smoke.js"); -const releaseBlockerSmoke = read("scripts/release-blocker-smoke.js"); -const publicAcceptance = read("scripts/acceptance-public.sh"); -const publicSplit = read("scripts/verify-public-split.sh"); -const privateAcceptance = read("scripts/acceptance-private.sh"); for (const [name, pattern] of [ ["auth contexts carry tenant and project", /pub struct AuthContext[\s\S]*pub tenant: TenantId[\s\S]*pub project: ProjectId/], @@ -140,18 +129,13 @@ for (const [name, sourceText, patterns] of [ } } -expect(releaseBlockerSmoke, "release blockers list tenant-isolation failure", /cross-tenant access is listed as a release blocker/); -expectGate(publicAcceptance, "public acceptance"); -expectGate(publicSplit, "public split acceptance"); -expectGate(privateAcceptance, "private acceptance"); - const hostedLibRoot = maybeRead(["private", "hosted-policy", "src", "lib.rs"]); const hostedServiceSource = maybeRead([ "private", "hosted-policy", "src", "bin", - "disasmer-hosted-service.rs", + "clusterflux-hosted-service.rs", ]); const hostedLib = hostedLibRoot; const hostedSmoke = maybeRead([ diff --git a/scripts/user-session-token-boundary-smoke.js b/scripts/user-session-token-boundary-smoke.js index dce7096..d00a9da 100755 --- a/scripts/user-session-token-boundary-smoke.js +++ b/scripts/user-session-token-boundary-smoke.js @@ -43,7 +43,7 @@ function assertNoUserSessionCredential(surface, text) { ); } -const coordinatorService = `${read("crates/disasmer-coordinator/src/service/protocol.rs")}\n${read("crates/disasmer-coordinator/src/service/protocol/responses.rs")}`; +const coordinatorService = `${read("crates/clusterflux-coordinator/src/service/protocol.rs")}\n${read("crates/clusterflux-coordinator/src/service/protocol/responses.rs")}`; for (const variant of [ "AdminStatus", "SuspendTenant", @@ -74,8 +74,8 @@ for (const variant of [ } const nodeRuntime = [ - read("crates/disasmer-node/src/lib.rs"), - read("crates/disasmer-node/src/command_runner.rs"), + read("crates/clusterflux-node/src/lib.rs"), + read("crates/clusterflux-node/src/command_runner.rs"), ].join("\n"); for (const marker of [ "pub struct LinuxCommandRunPlan", @@ -87,25 +87,25 @@ for (const marker of [ assertNoUserSessionCredential(marker, extractBalancedBlock(nodeRuntime, marker)); } -const coreExecution = read("crates/disasmer-core/src/execution.rs"); +const coreExecution = read("crates/clusterflux-core/src/execution.rs"); assertNoUserSessionCredential( "CommandInvocation", extractBalancedBlock(coreExecution, "pub struct CommandInvocation") ); -const dapAdapter = read("crates/disasmer-dap/src/variables.rs"); +const dapAdapter = read("crates/clusterflux-dap/src/variables.rs"); assertNoUserSessionCredential( "DAP variables response", extractBalancedBlock(dapAdapter, "fn variables_response") ); -const panel = read("crates/disasmer-core/src/operator_panel.rs"); +const panel = read("crates/clusterflux-core/src/operator_panel.rs"); assertNoUserSessionCredential( "PanelEvent", extractBalancedBlock(panel, "pub struct PanelEvent") ); -const auth = read("crates/disasmer-core/src/auth.rs"); +const auth = read("crates/clusterflux-core/src/auth.rs"); assert.match( auth, /task_credentials_do_not_contain_user_session/, diff --git a/scripts/verify-public-split.sh b/scripts/verify-public-split.sh index ad8c9f5..f264477 100755 --- a/scripts/verify-public-split.sh +++ b/scripts/verify-public-split.sh @@ -3,7 +3,7 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" source_commit="$(git -C "$repo_root" rev-parse HEAD)" -export DISASMER_ACCEPTANCE_COMMIT="$source_commit" +export CLUSTERFLUX_ACCEPTANCE_COMMIT="$source_commit" tmp_dir="$(mktemp -d)" trap 'rm -rf "$tmp_dir"' EXIT @@ -11,8 +11,9 @@ tar \ --exclude='./.git' \ --exclude='./target' \ --exclude='./private' \ + --exclude='./internal' \ --exclude='./experiments' \ - --exclude='./.disasmer' \ + --exclude='./.clusterflux' \ --exclude='./vscode-extension/node_modules' \ --exclude='./scripts/containers-home' \ -C "$repo_root" \ @@ -28,48 +29,44 @@ if find "$tmp_dir" -path "$tmp_dir/experiments" -print -quit | grep -q .; then exit 1 fi -cargo test --workspace --manifest-path "$tmp_dir/Cargo.toml" -cargo build --workspace --bins --manifest-path "$tmp_dir/Cargo.toml" -(cd "$tmp_dir" && node scripts/acceptance-report-smoke.js) -(cd "$tmp_dir" && node scripts/acceptance-doc-contract-smoke.js) -(cd "$tmp_dir" && node scripts/acceptance-environment-contract-smoke.js) -(cd "$tmp_dir" && node scripts/acceptance-evidence-contract-smoke.js) -(cd "$tmp_dir" && node scripts/code-size-guard.js) -(cd "$tmp_dir" && node scripts/public-private-boundary-smoke.js) -(cd "$tmp_dir" && node scripts/release-blocker-smoke.js) +if find "$tmp_dir" -path "$tmp_dir/internal" -print -quit | grep -q .; then + echo "internal directory leaked into public split" >&2 + exit 1 +fi + +(cd "$tmp_dir" && scripts/check-old-name.sh) +(cd "$tmp_dir" && CLUSTERFLUX_FILTERED_PUBLIC_TREE=1 node scripts/check-docs.js) +(cd "$tmp_dir" && scripts/check-code-size.sh) (cd "$tmp_dir" && node scripts/resource-metering-contract-smoke.js) (cd "$tmp_dir" && node scripts/hostile-input-contract-smoke.js) (cd "$tmp_dir" && node scripts/tenant-isolation-contract-smoke.js) -(cd "$tmp_dir" && node scripts/public-story-contract-smoke.js) -(cd "$tmp_dir" && node scripts/public-release-dryrun-contract-smoke.js) -(cd "$tmp_dir" && node scripts/self-hosted-coordinator-smoke.js) -(cd "$tmp_dir" && node scripts/public-local-demo-matrix-smoke.js) -(cd "$tmp_dir" && scripts/release-source-scan.sh) -(cd "$tmp_dir" && node scripts/prepare-public-release-dryrun.js) -if [[ -n "${DISASMER_FORGEJO_TOKEN:-}" ]]; then - (cd "$tmp_dir" && node scripts/publish-public-release-dryrun.js) -fi -(cd "$tmp_dir" && node scripts/docs-smoke.js) -(cd "$tmp_dir" && node scripts/cli-output-mode-smoke.js) -(cd "$tmp_dir" && node scripts/cli-login-smoke.js) -(cd "$tmp_dir" && node scripts/cli-error-exit-smoke.js) -(cd "$tmp_dir" && node scripts/cli-browser-login-flow-smoke.js) +(cd "$tmp_dir" && cargo fmt --all --check) +CARGO_TARGET_DIR="$tmp_dir/target" cargo test \ + --workspace \ + --all-targets \ + --manifest-path "$tmp_dir/Cargo.toml" +CARGO_TARGET_DIR="$tmp_dir/target" cargo build \ + --workspace \ + --all-targets \ + --manifest-path "$tmp_dir/Cargo.toml" (cd "$tmp_dir" && node scripts/cli-install-smoke.js) -(cd "$tmp_dir" && node scripts/user-session-token-boundary-smoke.js) -(cd "$tmp_dir" && node scripts/sdk-spawn-runtime-smoke.js) -(cd "$tmp_dir" && node scripts/node-lifecycle-contract-smoke.js) +(cd "$tmp_dir" && node scripts/flagship-demo-smoke.js) +(cd "$tmp_dir" && node scripts/cli-local-run-smoke.js) +(cd "$tmp_dir" && node scripts/node-attach-smoke.js) (cd "$tmp_dir" && node scripts/vscode-extension-smoke.js) (cd "$tmp_dir" && node scripts/vscode-f5-smoke.js) -(cd "$tmp_dir" && node scripts/node-attach-smoke.js) -(cd "$tmp_dir" && node scripts/wasmtime-assignment-smoke.js) -(cd "$tmp_dir" && node scripts/cli-local-run-smoke.js) (cd "$tmp_dir" && node scripts/artifact-download-smoke.js) (cd "$tmp_dir" && node scripts/artifact-export-smoke.js) -(cd "$tmp_dir" && node scripts/operator-panel-smoke.js) -(cd "$tmp_dir" && node scripts/source-preparation-smoke.js) -(cd "$tmp_dir" && node scripts/scheduler-placement-smoke.js) -(cd "$tmp_dir" && node scripts/windows-best-effort-smoke.js) -(cd "$tmp_dir" && node scripts/windows-validation-contract-smoke.js) -(cd "$tmp_dir" && node scripts/quic-smoke.js) -(cd "$tmp_dir" && node scripts/dap-smoke.js) -(cd "$tmp_dir" && node scripts/flagship-demo-smoke.js) + +public_digest="$( + find "$tmp_dir" \ + -path "$tmp_dir/target" -prune -o \ + -type f -print0 \ + | LC_ALL=C sort -z \ + | xargs -0 sha256sum \ + | sha256sum \ + | cut -d' ' -f1 +)" +printf 'Filtered public tree passed: commit=%s digest=sha256:%s\n' \ + "$source_commit" \ + "$public_digest" diff --git a/scripts/vscode-extension-smoke.js b/scripts/vscode-extension-smoke.js index a77c651..7eddcea 100755 --- a/scripts/vscode-extension-smoke.js +++ b/scripts/vscode-extension-smoke.js @@ -11,20 +11,17 @@ const extensionSource = fs.readFileSync( path.join(__dirname, "../vscode-extension/extension.js"), "utf8" ); -const readme = fs.readFileSync(path.join(__dirname, "../README.md"), "utf8"); const repo = path.resolve(__dirname, ".."); assert.strictEqual(packageJson.main, "./extension.js"); assert(fs.existsSync(path.join(__dirname, "../vscode-extension", packageJson.main))); assert.deepStrictEqual(packageJson.dependencies || {}, {}); -assert.match(readme, /code --extensionDevelopmentPath "\$\(pwd\)\/vscode-extension"/); - -const root = fs.mkdtempSync(path.join(os.tmpdir(), "disasmer-vscode-")); +const root = fs.mkdtempSync(path.join(os.tmpdir(), "clusterflux-vscode-")); fs.mkdirSync(path.join(root, "envs/linux"), { recursive: true }); fs.writeFileSync(path.join(root, "envs/linux/Containerfile"), "FROM alpine\n"); -fs.mkdirSync(path.join(root, ".disasmer"), { recursive: true }); +fs.mkdirSync(path.join(root, ".clusterflux"), { recursive: true }); fs.writeFileSync( - extension.disasmerViewStatePath(root), + extension.clusterfluxViewStatePath(root), JSON.stringify({ nodes: [{ id: "node-linux", status: "online", capabilities: "Command RootlessPodman" }], processes: [{ id: "vp-build", status: "running", entry: "build" }], @@ -51,9 +48,9 @@ assert.deepStrictEqual(inspectCommand.args.slice(0, 8), [ "run", "-q", "-p", - "disasmer-cli", + "clusterflux-cli", "--bin", - "disasmer", + "clusterflux", "--", "bundle" ]); @@ -74,20 +71,20 @@ const refreshed = extension.refreshBundleBeforeLaunch(root, "/repo", (command, a }); assert.strictEqual(refreshed.metadata.identity, "sha256:abc"); -const launch = extension.resolveDisasmerDebugConfiguration( +const launch = extension.resolveClusterfluxDebugConfiguration( { uri: { fsPath: root } }, {} ); assert.deepStrictEqual(launch, { - name: "Disasmer: Launch Virtual Process", - type: "disasmer", + name: "Clusterflux: Launch Virtual Process", + type: "clusterflux", request: "launch", entry: "build", project: root, runtimeBackend: "local-services" }); assert.strictEqual( - extension.disasmerProcessId("/workspace/app", "build"), + extension.clusterfluxProcessId("/workspace/app", "build"), "vp-e4bd6ef50539" ); assert.strictEqual( @@ -110,7 +107,7 @@ const liveProcesses = extension.loadLiveProcesses(root, "/repo", (_command, args return { status: 0, stdout: JSON.stringify({ - coordinator: "https://disasmer.michelpaulissen.com", + coordinator: "https://clusterflux.michelpaulissen.com", tenant: "tenant-live", project: "project-live", user: "user-live", @@ -130,13 +127,13 @@ assert.deepStrictEqual(adapter.args, [ "run", "-q", "-p", - "disasmer-dap", + "clusterflux-dap", "--bin", - "disasmer-debug-dap" + "clusterflux-debug-dap" ]); assert.deepStrictEqual(adapter.options, { cwd: repo }); -const releasedAdapterPath = path.join(root, process.platform === "win32" ? "disasmer-debug-dap.exe" : "disasmer-debug-dap"); +const releasedAdapterPath = path.join(root, process.platform === "win32" ? "clusterflux-debug-dap.exe" : "clusterflux-debug-dap"); fs.writeFileSync(releasedAdapterPath, ""); const releasedAdapter = extension.debugAdapterExecutableSpec(root, repo); assert.strictEqual(releasedAdapter.command, releasedAdapterPath); @@ -155,27 +152,27 @@ assert.throws( assert( packageJson.contributes.viewsContainers.activitybar.some( - (container) => container.id === "disasmer" && container.title === "Disasmer" + (container) => container.id === "clusterflux" && container.title === "Clusterflux" ), - "package.json must contribute a Disasmer activity-bar container" + "package.json must contribute a Clusterflux activity-bar container" ); assert( - fs.existsSync(path.join(__dirname, "../vscode-extension/resources/disasmer.svg")), - "Disasmer activity-bar icon must exist" + fs.existsSync(path.join(__dirname, "../vscode-extension/resources/clusterflux.svg")), + "Clusterflux activity-bar icon must exist" ); -const packageViewIds = packageJson.contributes.views.disasmer.map((view) => view.id).sort(); -const descriptorViewIds = extension.disasmerViewDescriptors().map((view) => view.id).sort(); +const packageViewIds = packageJson.contributes.views.clusterflux.map((view) => view.id).sort(); +const descriptorViewIds = extension.clusterfluxViewDescriptors().map((view) => view.id).sort(); assert.deepStrictEqual(descriptorViewIds, [ - "disasmer.artifacts", - "disasmer.inspector", - "disasmer.logs", - "disasmer.nodes", - "disasmer.processes" + "clusterflux.artifacts", + "clusterflux.inspector", + "clusterflux.logs", + "clusterflux.nodes", + "clusterflux.processes" ]); assert.deepStrictEqual(packageViewIds, descriptorViewIds); for (const viewId of descriptorViewIds) { - const items = extension.disasmerViewItems( - extension.loadDisasmerViewState(root), + const items = extension.clusterfluxViewItems( + extension.loadClusterfluxViewState(root), viewId ); assert( @@ -184,7 +181,7 @@ for (const viewId of descriptorViewIds) { ); } assert.deepStrictEqual( - extension.disasmerViewItems(extension.loadDisasmerViewState(root), "disasmer.nodes")[0], + extension.clusterfluxViewItems(extension.loadClusterfluxViewState(root), "clusterflux.nodes")[0], { label: "node-linux", description: "online Command RootlessPodman" @@ -192,8 +189,8 @@ assert.deepStrictEqual( ); assert( - packageJson.contributes.debuggers.some((debuggerContribution) => debuggerContribution.type === "disasmer"), - "package.json must contribute the disasmer debugger type" + packageJson.contributes.debuggers.some((debuggerContribution) => debuggerContribution.type === "clusterflux"), + "package.json must contribute the clusterflux debugger type" ); for (const viewId of descriptorViewIds) { assert( @@ -214,19 +211,19 @@ assert( "package.json must contribute an attach configuration" ); for (const command of [ - "disasmer.refreshProcesses", - "disasmer.process.attach", - "disasmer.process.cancel", - "disasmer.process.abort" + "clusterflux.refreshProcesses", + "clusterflux.process.attach", + "clusterflux.process.cancel", + "clusterflux.process.abort" ]) { assert( packageJson.contributes.commands.some((entry) => entry.command === command), `${command} must be contributed` ); } -assert.match(extensionSource, /registerDebugAdapterDescriptorFactory\("disasmer"/); -assert.match(extensionSource, /disasmer-debug-dap/); -assert.match(extensionSource, /\.disasmer\/views\.json/); +assert.match(extensionSource, /registerDebugAdapterDescriptorFactory\("clusterflux"/); +assert.match(extensionSource, /clusterflux-debug-dap/); +assert.match(extensionSource, /\.clusterflux\/views\.json/); fs.rmSync(root, { recursive: true, force: true }); console.log("VS Code extension smoke passed"); diff --git a/scripts/vscode-f5-smoke.js b/scripts/vscode-f5-smoke.js index c6e8c22..97a6654 100755 --- a/scripts/vscode-f5-smoke.js +++ b/scripts/vscode-f5-smoke.js @@ -128,12 +128,12 @@ class DapClient { return index + 1; }; const buildMainLine = sourceLine("pub async fn build_main()"); - const launchConfig = extension.resolveDisasmerDebugConfiguration( + const launchConfig = extension.resolveClusterfluxDebugConfiguration( { uri: { fsPath: project } }, {} ); - assert.strictEqual(launchConfig.type, "disasmer"); + assert.strictEqual(launchConfig.type, "clusterflux"); assert.strictEqual(launchConfig.request, "launch"); assert.strictEqual(launchConfig.entry, "build"); assert.strictEqual(launchConfig.project, project); @@ -151,13 +151,13 @@ class DapClient { "build", "-q", "-p", - "disasmer-coordinator", + "clusterflux-coordinator", "--bin", - "disasmer-coordinator", + "clusterflux-coordinator", "-p", - "disasmer-node", + "clusterflux-node", "--bin", - "disasmer-node", + "clusterflux-node", ], { cwd: repo, stdio: "inherit" } ); @@ -167,17 +167,17 @@ class DapClient { ...(adapterSpec.options || {}), env: { ...process.env, - DISASMER_COORDINATOR_BIN: path.join( + CLUSTERFLUX_COORDINATOR_BIN: path.join( repo, "target", "debug", - `disasmer-coordinator${executableSuffix}` + `clusterflux-coordinator${executableSuffix}` ), - DISASMER_NODE_BIN: path.join( + CLUSTERFLUX_NODE_BIN: path.join( repo, "target", "debug", - `disasmer-node${executableSuffix}` + `clusterflux-node${executableSuffix}` ), }, }; @@ -185,7 +185,7 @@ class DapClient { const client = new DapClient(adapterSpec); try { const initialize = client.send("initialize", { - adapterID: "disasmer", + adapterID: "clusterflux", linesStartAt1: true, columnsStartAt1: true }); @@ -214,8 +214,8 @@ class DapClient { const threadsRequest = client.send("threads"); const threads = (await client.response(threadsRequest, "threads")).body.threads; - const mainThread = threads.find((thread) => thread.name.includes("build virtual process")); - assert(mainThread, "F5 launch must expose the real entrypoint as a virtual thread"); + const mainThread = threads.find((thread) => thread.name.includes("build coordinator main")); + assert(mainThread, "F5 launch must expose the real coordinator-main entrypoint thread"); const stackRequest = client.send("stackTrace", { threadId: mainThread.id, @@ -235,11 +235,11 @@ class DapClient { const scopes = (await client.response(scopesRequest, "scopes")).body.scopes; const localsScope = scopes.find((scope) => scope.name === "Source Locals"); const argsScope = scopes.find((scope) => scope.name === "Task Args and Handles"); - const runtimeScope = scopes.find((scope) => scope.name === "Disasmer Runtime"); + const runtimeScope = scopes.find((scope) => scope.name === "Clusterflux Runtime"); const outputScope = scopes.find((scope) => scope.name === "Recent Output"); assert(localsScope, "F5 launch must expose source locals scope"); assert(argsScope, "F5 launch must expose task args and handles"); - assert(runtimeScope, "F5 launch must expose Disasmer runtime state"); + assert(runtimeScope, "F5 launch must expose Clusterflux runtime state"); assert(outputScope, "F5 launch must expose recent output state"); const localsRequest = client.send("variables", { diff --git a/scripts/wasmtime-assignment-smoke.js b/scripts/wasmtime-assignment-smoke.js index e4adb3d..22e5a09 100644 --- a/scripts/wasmtime-assignment-smoke.js +++ b/scripts/wasmtime-assignment-smoke.js @@ -209,6 +209,41 @@ async function waitForBreakpointHit(addr, process) { throw new Error(`timed out waiting for executing Wasm breakpoint in ${process}`); } +async function waitForRunningTaskInstances(addr, process, taskInstances) { + for (let attempt = 0; attempt < 1200; attempt += 1) { + const response = await send(addr, authenticated({ + type: "list_task_snapshots", + process, + })); + assert.strictEqual(response.type, "task_snapshots", JSON.stringify(response)); + const running = new Set( + response.snapshots + .filter((snapshot) => snapshot.current && snapshot.state === "running") + .map((snapshot) => snapshot.task) + ); + if (taskInstances.every((task) => running.has(task))) return; + await delay(50); + } + throw new Error( + `timed out waiting for running task instances ${taskInstances.join(", ")} in ${process}` + ); +} + +async function waitForTaskJoin(addr, process, task, timeoutMs = 60000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const response = await send(addr, authenticated({ + type: "join_task", + process, + task, + })); + assert.strictEqual(response.type, "task_joined", JSON.stringify(response)); + if (response.join.state !== "pending") return response; + await delay(25); + } + throw new Error(`timed out waiting to join task ${task} in ${process}`); +} + (async () => { configurePodmanTestEnvironment(repo); const build = JSON.parse( @@ -218,9 +253,9 @@ async function waitForBreakpointHit(addr, process) { "run", "-q", "-p", - "disasmer-cli", + "clusterflux-cli", "--bin", - "disasmer", + "clusterflux", "--", "build", "--project", @@ -250,9 +285,9 @@ async function waitForBreakpointHit(addr, process) { "run", "-q", "-p", - "disasmer-coordinator", + "clusterflux-coordinator", "--bin", - "disasmer-coordinator", + "clusterflux-coordinator", "--", "--listen", "127.0.0.1:0", @@ -261,10 +296,10 @@ async function waitForBreakpointHit(addr, process) { cwd: repo, env: { ...process.env, - DISASMER_SELF_HOSTED_SESSION_SECRET: clientSessionSecret, - DISASMER_SELF_HOSTED_TENANT: "tenant", - DISASMER_SELF_HOSTED_PROJECT: "project", - DISASMER_SELF_HOSTED_USER: "user", + CLUSTERFLUX_SELF_HOSTED_SESSION_SECRET: clientSessionSecret, + CLUSTERFLUX_SELF_HOSTED_TENANT: "tenant", + CLUSTERFLUX_SELF_HOSTED_PROJECT: "project", + CLUSTERFLUX_SELF_HOSTED_USER: "user", }, } ); @@ -299,9 +334,9 @@ async function waitForBreakpointHit(addr, process) { "run", "-q", "-p", - "disasmer-node", + "clusterflux-node", "--bin", - "disasmer-node", + "clusterflux-node", "--", "--coordinator", ready.listen, @@ -327,7 +362,7 @@ async function waitForBreakpointHit(addr, process) { initialWorkerInvocation.args, { cwd: repo, - env: { ...process.env, DISASMER_NODE_PRIVATE_KEY: identity.privateKey }, + env: { ...process.env, CLUSTERFLUX_NODE_PRIVATE_KEY: identity.privateKey }, } ); worker.stderr.on("data", (chunk) => { @@ -361,20 +396,20 @@ async function waitForBreakpointHit(addr, process) { encoding: "utf8", env: { ...process.env, - DISASMER_SDK_COORDINATOR: ready.listen, - DISASMER_SDK_TENANT: "tenant", - DISASMER_SDK_PROJECT: "project", - DISASMER_SDK_PROCESS: "vp-wasmtime-assignment", - DISASMER_SDK_USER: "user", - DISASMER_SDK_SESSION_SECRET: clientSessionSecret, - DISASMER_SDK_BUNDLE_DIGEST: bundleDigest, - DISASMER_SDK_WASM_MODULE_PATH: wasmPath, - DISASMER_SDK_VFS_EPOCH: String(started.epoch), + CLUSTERFLUX_SDK_COORDINATOR: ready.listen, + CLUSTERFLUX_SDK_TENANT: "tenant", + CLUSTERFLUX_SDK_PROJECT: "project", + CLUSTERFLUX_SDK_PROCESS: "vp-wasmtime-assignment", + CLUSTERFLUX_SDK_USER: "user", + CLUSTERFLUX_SDK_SESSION_SECRET: clientSessionSecret, + CLUSTERFLUX_SDK_BUNDLE_DIGEST: bundleDigest, + CLUSTERFLUX_SDK_WASM_MODULE_PATH: wasmPath, + CLUSTERFLUX_SDK_VFS_EPOCH: String(started.epoch), }, } ) ); - assert.strictEqual(sdkRun.kind, "disasmer-sdk-product-runtime"); + assert.strictEqual(sdkRun.kind, "clusterflux-sdk-product-runtime"); assert.strictEqual(sdkRun.remote_result, 42); assert.strictEqual(sdkRun.local_function_invoked_by_spawn, false); assert.strictEqual(sdkRun.task_spec.task_definition, "task_add_one"); @@ -492,9 +527,9 @@ async function waitForBreakpointHit(addr, process) { "run", "-q", "-p", - "disasmer-cli", + "clusterflux-cli", "--bin", - "disasmer", + "clusterflux", "--", "build", "--project", @@ -555,8 +590,11 @@ async function waitForBreakpointHit(addr, process) { assert.strictEqual(editedRestart.active_task, false); assert.strictEqual(editedRestart.completed_event_observed, true); assert.strictEqual(editedRestart.requires_whole_process_restart, false); - assert.match(editedRestart.restarted_task_instance, /^ti_/); - assert.notStrictEqual(editedRestart.restarted_task_instance, "task_add_one-editable-1"); + assert.strictEqual( + editedRestart.restarted_task_instance, + "task_add_one-editable-1", + "a restart attempt must retain the logical task instance identity" + ); const editedNodeRun = await workerLines.next( 60000, @@ -634,9 +672,9 @@ async function waitForBreakpointHit(addr, process) { "run", "-q", "-p", - "disasmer-cli", + "clusterflux-cli", "--bin", - "disasmer", + "clusterflux", "--", "build", "--project", @@ -729,9 +767,9 @@ async function waitForBreakpointHit(addr, process) { "run", "-q", "-p", - "disasmer-node", + "clusterflux-node", "--bin", - "disasmer-node", + "clusterflux-node", "--", "--coordinator", ready.listen, @@ -759,7 +797,7 @@ async function waitForBreakpointHit(addr, process) { cwd: repo, env: { ...process.env, - DISASMER_NODE_PRIVATE_KEY: trapIdentity.privateKey, + CLUSTERFLUX_NODE_PRIVATE_KEY: trapIdentity.privateKey, }, } ); @@ -846,7 +884,11 @@ async function waitForBreakpointHit(addr, process) { assert.strictEqual(trapRestart.active_task, false); assert.strictEqual(trapRestart.completed_event_observed, true); assert.strictEqual(trapRestart.requires_whole_process_restart, false); - assert.match(trapRestart.restarted_task_instance, /^ti_/); + assert.strictEqual( + trapRestart.restarted_task_instance, + "task_trap-1", + "a failed-task retry must retain the logical task instance identity" + ); assert.match(trapRestart.message, /clean VFS entry boundary epoch/); const restartedTrapNodeRun = await trapWorkerLines.next( 60000, @@ -862,11 +904,11 @@ async function waitForBreakpointHit(addr, process) { const configuredBreakpoints = await send(addr, authenticated({ type: "set_debug_breakpoints", process: "vp-wasmtime-assignment", - probe_symbols: ["disasmer.probe.cooperative_cancellation_probe"], + probe_symbols: ["clusterflux.probe.cooperative_cancellation_probe"], })); assert.strictEqual(configuredBreakpoints.type, "debug_breakpoints"); assert.deepStrictEqual(configuredBreakpoints.probe_symbols, [ - "disasmer.probe.cooperative_cancellation_probe", + "clusterflux.probe.cooperative_cancellation_probe", ]); const cancellationLaunch = await send(addr, authenticated({ type: "launch_task", @@ -903,7 +945,7 @@ async function waitForBreakpointHit(addr, process) { ); assert.strictEqual( breakpointHit.hit_probe_symbol, - "disasmer.probe.cooperative_cancellation_probe" + "clusterflux.probe.cooperative_cancellation_probe" ); assert.strictEqual( breakpointHit.hit_task, @@ -965,14 +1007,17 @@ async function waitForBreakpointHit(addr, process) { assert.strictEqual(cancellationJoin.join.state, "completed"); assert.deepStrictEqual(cancellationJoin.join.result, { SmallJson: 17 }); - // Cooperative cancellation leaves process cleanup to the process code. End - // this test process explicitly before starting the forced-abort scenario. + // Completing the cooperatively cancelled task releases the process slot. + // A redundant forced abort must not resurrect or mutate that process. const cancelledProcessCleanup = await send(addr, authenticated({ type: "abort_process", process: "vp-wasmtime-assignment", })); - assert.strictEqual(cancelledProcessCleanup.type, "process_aborted"); - assert.deepStrictEqual(cancelledProcessCleanup.aborted_tasks, []); + assert.strictEqual(cancelledProcessCleanup.type, "error"); + assert.match( + cancelledProcessCleanup.message, + /process abort requires an active virtual process/ + ); const multiParticipantProcess = await send(addr, authenticated({ type: "start_process", @@ -1010,42 +1055,27 @@ async function waitForBreakpointHit(addr, process) { })); assert.strictEqual(multiParticipantLaunch.type, "task_launched"); const debugChildInstance = "debug_parent_probe-1:child:1"; - await delay(1000); - let multiParticipantFreeze; - let multiParticipantFrozen; - for (let attempt = 0; attempt < 20; attempt += 1) { - multiParticipantFreeze = await send(addr, authenticated({ - type: "create_debug_epoch", - process: multiParticipantProcess.process, - stopped_task: debugChildInstance, - reason: "prove all-stop across active parent and child Wasm participants", - })); - assert.strictEqual( - multiParticipantFreeze.type, - "debug_epoch", - JSON.stringify(multiParticipantFreeze) - ); - multiParticipantFrozen = await waitForDebugEpochState( - addr, - multiParticipantProcess.process, - multiParticipantFreeze.epoch, - "fully_frozen" - ); - if (multiParticipantFreeze.affected_tasks.length === 2) break; - const earlyResume = await send(addr, authenticated({ - type: "resume_debug_epoch", - process: multiParticipantProcess.process, - epoch: multiParticipantFreeze.epoch, - })); - assert.strictEqual(earlyResume.type, "debug_epoch"); - await waitForDebugEpochState( - addr, - multiParticipantProcess.process, - multiParticipantFreeze.epoch, - "fully_resumed" - ); - await delay(100); - } + await waitForRunningTaskInstances(addr, multiParticipantProcess.process, [ + "debug_parent_probe-1", + debugChildInstance, + ]); + const multiParticipantFreeze = await send(addr, authenticated({ + type: "create_debug_epoch", + process: multiParticipantProcess.process, + stopped_task: debugChildInstance, + reason: "prove all-stop across active parent and child Wasm participants", + })); + assert.strictEqual( + multiParticipantFreeze.type, + "debug_epoch", + JSON.stringify(multiParticipantFreeze) + ); + const multiParticipantFrozen = await waitForDebugEpochState( + addr, + multiParticipantProcess.process, + multiParticipantFreeze.epoch, + "fully_frozen" + ); assert.strictEqual(multiParticipantFreeze.affected_tasks.length, 2); assert.strictEqual(multiParticipantFrozen.acknowledgements.length, 2); assert.deepStrictEqual( @@ -1076,25 +1106,27 @@ async function waitForBreakpointHit(addr, process) { "process_cancellation_requested" ); assert.strictEqual(multiParticipantCancellation.cancelled_tasks.length, 2); + const multiParticipantJoin = await waitForTaskJoin( + addr, + multiParticipantProcess.process, + "debug_parent_probe-1" + ); const multiParticipantNodeRun = await trapWorkerLines.next( 10000, "multi-participant parent completion" ); assert.strictEqual(multiParticipantNodeRun.node_status, "completed"); - const multiParticipantJoin = await send(addr, authenticated({ - type: "join_task", - process: multiParticipantProcess.process, - task: "debug_parent_probe-1", - })); - assert.strictEqual(multiParticipantJoin.type, "task_joined"); assert.strictEqual(multiParticipantJoin.join.state, "completed"); assert.deepStrictEqual(multiParticipantJoin.join.result, { SmallJson: 23 }); const multiParticipantCleanup = await send(addr, authenticated({ type: "abort_process", process: multiParticipantProcess.process, })); - assert.strictEqual(multiParticipantCleanup.type, "process_aborted"); - assert.deepStrictEqual(multiParticipantCleanup.aborted_tasks, []); + assert.strictEqual(multiParticipantCleanup.type, "error"); + assert.match( + multiParticipantCleanup.message, + /process abort requires an active virtual process/ + ); const abortProcess = await send(addr, authenticated({ type: "start_process", @@ -1194,7 +1226,7 @@ async function waitForBreakpointHit(addr, process) { assert.deepStrictEqual(afterAbort.processes, []); const report = { - kind: "disasmer-wasmtime-assignment-smoke", + kind: "clusterflux-wasmtime-assignment-smoke", coordinator: ready.listen, client_authority: ready.client_authority, process: started.process, @@ -1251,7 +1283,8 @@ async function waitForBreakpointHit(addr, process) { task_observed_request: true, terminal_state: cancellationNodeRun.terminal_state, result: cancellationJoin.join.result, - process_cleanup_was_explicit: true, + process_cleanup_was_explicit: false, + process_slot_released: true, }, debug_epoch: { epoch: debugEpoch.epoch, diff --git a/scripts/wasmtime-node-smoke.js b/scripts/wasmtime-node-smoke.js index 7da7e74..03b8f81 100644 --- a/scripts/wasmtime-node-smoke.js +++ b/scripts/wasmtime-node-smoke.js @@ -44,9 +44,9 @@ const output = cp.execFileSync( "run", "-q", "-p", - "disasmer-node", + "clusterflux-node", "--bin", - "disasmer-wasmtime-smoke", + "clusterflux-wasmtime-smoke", "--", wasmTarget, "task_add_one", @@ -68,9 +68,9 @@ const debugOutput = cp.execFileSync( "run", "-q", "-p", - "disasmer-node", + "clusterflux-node", "--bin", - "disasmer-wasmtime-smoke", + "clusterflux-wasmtime-smoke", "--", "--debug-freeze-resume", wasmTarget, @@ -103,9 +103,9 @@ const hostCommandOutput = cp.execFileSync( "run", "-q", "-p", - "disasmer-node", + "clusterflux-node", "--bin", - "disasmer-wasmtime-smoke", + "clusterflux-wasmtime-smoke", "--", "--host-command", wasmTarget, @@ -116,15 +116,15 @@ const hostCommandOutput = cp.execFileSync( const hostCommandReport = JSON.parse(hostCommandOutput); assert.strictEqual(hostCommandReport.type, "wasmtime_host_command_smoke"); assert.strictEqual(hostCommandReport.task, "compile_linux"); -assert.match(hostCommandReport.export, /^disasmer_task_v1_[0-9a-f]{64}$/); +assert.match(hostCommandReport.export, /^clusterflux_task_v1_[0-9a-f]{64}$/); assert.strictEqual(hostCommandReport.program, "cc"); assert.deepStrictEqual(hostCommandReport.args, [ "-Os", "-static", "-s", - "fixture/hello-disasmer.c", + "fixture/hello-clusterflux.c", "-o", - "/disasmer/output/hello-disasmer", + "/clusterflux/output/hello-clusterflux", ]); assert.strictEqual(hostCommandReport.working_directory, "/workspace"); assert.deepStrictEqual(hostCommandReport.environment_variables, { @@ -134,11 +134,11 @@ assert.strictEqual(hostCommandReport.timeout_ms, 180000); assert.strictEqual(hostCommandReport.network, "disabled"); assert.strictEqual(hostCommandReport.status_code, 0); assert.strictEqual(hostCommandReport.stdout, ""); -assert.strictEqual(hostCommandReport.artifact_name, "hello-disasmer"); -assert.strictEqual(hostCommandReport.artifact_size_bytes, "hello-disasmer".length); +assert.strictEqual(hostCommandReport.artifact_name, "hello-clusterflux"); +assert.strictEqual(hostCommandReport.artifact_size_bytes, "hello-clusterflux".length); assert.match(hostCommandReport.artifact_digest, /^sha256:[0-9a-f]{64}$/); -assert.strictEqual(hostCommandReport.node_host_import, "disasmer.command_run_v1"); -assert.strictEqual(hostCommandReport.artifact_host_import, "disasmer.vfs_operation_v1"); +assert.strictEqual(hostCommandReport.node_host_import, "clusterflux.command_run_v1"); +assert.strictEqual(hostCommandReport.artifact_host_import, "clusterflux.vfs_operation_v1"); assert.strictEqual(hostCommandReport.flagship_linux_build_task, true); assert.strictEqual(hostCommandReport.node_executed_host_command, true); assert.strictEqual(hostCommandReport.hosted_control_plane_ran_command, false); @@ -149,9 +149,9 @@ const artifactOutput = cp.execFileSync( "run", "-q", "-p", - "disasmer-node", + "clusterflux-node", "--bin", - "disasmer-wasmtime-smoke", + "clusterflux-wasmtime-smoke", "--", "--task-artifact", wasmTarget, @@ -165,7 +165,7 @@ assert.strictEqual(artifactReport.task, "package_release"); assert.strictEqual(artifactReport.artifact_name, "release.tar"); assert.strictEqual(artifactReport.artifact_size_bytes, "release.tar".length); assert.match(artifactReport.artifact_digest, /^sha256:[0-9a-f]{64}$/); -assert.strictEqual(artifactReport.host_import, "disasmer.vfs_operation_v1"); +assert.strictEqual(artifactReport.host_import, "clusterflux.vfs_operation_v1"); assert.strictEqual(artifactReport.host_issued_handle_returned, true); assert.ok(artifactReport.artifact.id.endsWith(artifactReport.artifact_digest.slice("sha256:".length))); diff --git a/scripts/website-inventory-contract-smoke.js b/scripts/website-inventory-contract-smoke.js deleted file mode 100644 index 63caf7f..0000000 --- a/scripts/website-inventory-contract-smoke.js +++ /dev/null @@ -1,105 +0,0 @@ -#!/usr/bin/env node - -const assert = require("assert"); -const fs = require("fs"); -const path = require("path"); - -const repo = path.resolve(__dirname, ".."); -const inventoryPath = path.join(repo, "website_mvp_inventory.md"); -if (!fs.existsSync(inventoryPath)) { - if (fs.existsSync(path.join(repo, "DISASMER_PUBLIC_TREE.json"))) { - console.log( - "Website inventory contract smoke skipped: website_mvp_inventory.md is filtered from this public tree" - ); - process.exit(0); - } - throw new Error("website_mvp_inventory.md is missing"); -} -const source = fs.readFileSync(inventoryPath, "utf8"); - -function expect(name, pattern) { - assert.match(source, pattern, `missing website inventory evidence: ${name}`); -} - -function criterionLines() { - return source - .split(/\r?\n/) - .filter((line) => /^- \[[ x]\] \*\*/.test(line)); -} - -expect("header", /^# Disasmer MVP Website Acceptance Criteria/m); -expect( - "acceptance-style status", - /\*\*Status:\*\* (?:private )?hosted website addendum to `acceptance_criteria\.md`, `acceptance_criteria_phase2\.md`, and `cli_acceptance_criteria\.md`/ -); -expect( - "hosted signup exception", - /except hosted Authentik account creation/ -); -expect( - "no duplicate work note", - /does not automatically mean new product code, a new feature, or even actual implementation work is required/ -); -expect( - "prove existing behavior before implementation", - /prefer proving, documenting, or tightening that existing behavior over duplicating capability or overengineering a parallel website mechanism/ -); -expect( - "barebones no CSS", - /barebones functional HTML with no CSS/ -); -expect( - "future hosted business scope", - /Billing is not part of the MVP[\s\S]*Paid checkout, paid-plan management, upgrade flows, hosted support tooling, a full hosted admin console, broad moderation workflows/ -); -expect( - "billing is not MVP website work", - /Do not build billing\/upgrade flows into the minimal website for this MVP/ -); -expect( - "billing-only surfaces are postponed", - /If a website route, API, database field, control, or acceptance item exists only for billing, paid plans, hosted business operations, broad admin\/moderation, team management, provider setup, secret management, durable account\/business-process management, or any similarly future hosted-business concern, postpone it instead of building it for this MVP\./ -); -expect( - "billing plan flags are placeholders", - /Design-document references to billing, paid plans, or plan flags are future metadata placeholders; they do not require MVP website routes, database fields, UI controls, CLI commands, coordinator routes, or service logic/ -); -assert.doesNotMatch(source, /community-tier/, "use `community tier`, not `community-tier`"); - -const lines = criterionLines(); -assert(lines.length > 0, "website inventory must contain status-prefixed checklist items"); -for (const line of lines) { - assert.match( - line, - /^- \[[ x]\] \*\*(Passed|Partial|Open|Postponed)(?: \([^)]+\))?:\*\*/, - `website inventory item lacks an explicit status prefix: ${line}` - ); -} - -for (const [name, pattern] of [ - ["CLI login command", /`disasmer login \[--browser\] \[--coordinator \]`/], - ["CLI auth status command", /`disasmer auth status`/], - ["CLI key command", /`disasmer key add --public-key `/], - ["CLI project init command", /`disasmer project init`/], - ["CLI node enroll command", /`disasmer node enroll --coordinator --ttl-seconds `/], - ["CLI process status command", /`disasmer process status`/], - ["CLI task list command", /`disasmer task list`/], - ["CLI DAP command", /`disasmer dap`/], - ["CLI quota command", /`disasmer quota status`/], - ["CLI admin status command", /`disasmer admin status`/], -]) { - expect(name, pattern); -} - -for (const [name, pattern] of [ - ["website default project is open", /- \[ \] \*\*Open:\*\* land in their default project\./], - ["website node attach is open", /- \[ \] \*\*Open:\*\* copy a node attach command\./], - ["website process view is open", /- \[ \] \*\*Open:\*\* see the single current virtual process\./], - ["website logs are open", /- \[ \] \*\*Open:\*\* see recent bounded logs\./], - ["website artifact download is open", /- \[ \] \*\*Open:\*\* securely download an available best-effort retained artifact\./], - ["website keys are partial through CLI", /- \[ \] \*\*Partial:\*\* manage\/revoke public keys or know the CLI command to do so\./], -]) { - expect(name, pattern); -} - -console.log("Website inventory contract smoke passed"); diff --git a/scripts/windows-best-effort-smoke.js b/scripts/windows-best-effort-smoke.js index dba5865..ec3bdec 100755 --- a/scripts/windows-best-effort-smoke.js +++ b/scripts/windows-best-effort-smoke.js @@ -18,23 +18,23 @@ const windowsArtifactBytes = Buffer.from("windows-output-data"); const windowsArtifactDigest = digest(windowsArtifactBytes); const emptyWasm = Buffer.from([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]); const emptyWasmDigest = digest(emptyWasm); -const nodeDaemon = fs.readFileSync(path.join(repo, "crates/disasmer-node/src/daemon.rs"), "utf8"); +const nodeDaemon = fs.readFileSync(path.join(repo, "crates/clusterflux-node/src/daemon.rs"), "utf8"); const taskReports = fs.readFileSync( - path.join(repo, "crates/disasmer-node/src/task_reports.rs"), + path.join(repo, "crates/clusterflux-node/src/task_reports.rs"), "utf8" ); -const nodeLib = fs.readFileSync(path.join(repo, "crates/disasmer-node/src/lib.rs"), "utf8"); +const nodeLib = fs.readFileSync(path.join(repo, "crates/clusterflux-node/src/lib.rs"), "utf8"); const windowsDev = fs.readFileSync( - path.join(repo, "crates/disasmer-node/src/windows_dev.rs"), + path.join(repo, "crates/clusterflux-node/src/windows_dev.rs"), "utf8" ); const executionCore = fs.readFileSync( - path.join(repo, "crates/disasmer-core/src/execution.rs"), + path.join(repo, "crates/clusterflux-core/src/execution.rs"), "utf8" ); const dapSource = [ - fs.readFileSync(path.join(repo, "crates/disasmer-dap/src/demo_backend.rs"), "utf8"), - fs.readFileSync(path.join(repo, "crates/disasmer-dap/src/tests.rs"), "utf8"), + fs.readFileSync(path.join(repo, "crates/clusterflux-dap/src/demo_backend.rs"), "utf8"), + fs.readFileSync(path.join(repo, "crates/clusterflux-dap/src/tests.rs"), "utf8"), ].join("\n"); const windowsNode = "windows-node"; const windowsIdentity = nodeIdentity("windows-best-effort-smoke", windowsNode); @@ -119,9 +119,9 @@ function assertWindowsBackendBoundary() { "run", "-q", "-p", - "disasmer-coordinator", + "clusterflux-coordinator", "--bin", - "disasmer-coordinator", + "clusterflux-coordinator", "--", "--listen", "127.0.0.1:0", diff --git a/scripts/windows-runner-smoke.js b/scripts/windows-runner-smoke.js index e49ab94..a80889c 100755 --- a/scripts/windows-runner-smoke.js +++ b/scripts/windows-runner-smoke.js @@ -14,18 +14,18 @@ const forgejoWindowsNode = "forgejo-windows-node"; const forgejoWindowsIdentity = nodeIdentity("windows-runner-smoke", forgejoWindowsNode); const windowsEnvironmentDigest = `sha256:${crypto .createHash("sha256") - .update("disasmer/windows-command-dev/v1") + .update("clusterflux/windows-command-dev/v1") .digest("hex")}`; const sourceSnapshot = `sha256:${crypto .createHash("sha256") - .update("disasmer/windows-runner/source/v1") + .update("clusterflux/windows-runner/source/v1") .digest("hex")}`; class DapClient { constructor() { this.child = cp.spawn( "cargo", - ["run", "-q", "-p", "disasmer-dap", "--bin", "disasmer-debug-dap"], + ["run", "-q", "-p", "clusterflux-dap", "--bin", "clusterflux-debug-dap"], { cwd: repo } ); this.seq = 1; @@ -163,9 +163,9 @@ function runWindowsAttach(addr, grant) { "run", "-q", "-p", - "disasmer-cli", + "clusterflux-cli", "--bin", - "disasmer", + "clusterflux", "--", "node", "attach", @@ -189,7 +189,7 @@ function runWindowsAttach(addr, grant) { cwd: repo, env: { ...process.env, - DISASMER_NODE_PRIVATE_KEY: forgejoWindowsIdentity.privateKey, + CLUSTERFLUX_NODE_PRIVATE_KEY: forgejoWindowsIdentity.privateKey, }, } ); @@ -225,9 +225,9 @@ function runWindowsNode(addr, grant) { "run", "-q", "-p", - "disasmer-node", + "clusterflux-node", "--bin", - "disasmer-node", + "clusterflux-node", "--", "--coordinator", `${addr.host}:${addr.port}`, @@ -250,7 +250,7 @@ function runWindowsNode(addr, grant) { "--arg", "/C", "--arg", - "echo disasmer-windows-runner", + "echo clusterflux-windows-runner", "--artifact", "/vfs/artifacts/windows-runner-output.txt" ], @@ -258,7 +258,7 @@ function runWindowsNode(addr, grant) { cwd: repo, env: { ...process.env, - DISASMER_NODE_PRIVATE_KEY: forgejoWindowsIdentity.privateKey, + CLUSTERFLUX_NODE_PRIVATE_KEY: forgejoWindowsIdentity.privateKey, }, } ); @@ -288,7 +288,7 @@ async function assertDebuggerShowsWindowsThread() { const client = new DapClient(); try { const initialize = client.send("initialize", { - adapterID: "disasmer", + adapterID: "clusterflux", linesStartAt1: true, columnsStartAt1: true }); @@ -334,9 +334,9 @@ async function assertDebuggerShowsWindowsThread() { "run", "-q", "-p", - "disasmer-coordinator", + "clusterflux-coordinator", "--bin", - "disasmer-coordinator", + "clusterflux-coordinator", "--", "--listen", "127.0.0.1:0", @@ -462,7 +462,7 @@ async function assertDebuggerShowsWindowsThread() { path.join(outDir, "windows-runner.json"), `${JSON.stringify( { - kind: "disasmer_windows_runner_validation", + kind: "clusterflux_windows_runner_validation", platform: process.platform, runner: process.env.FORGEJO_RUNNER_NAME || process.env.RUNNER_NAME || null, validated: true diff --git a/scripts/windows-validation-contract-smoke.js b/scripts/windows-validation-contract-smoke.js deleted file mode 100755 index 07b0911..0000000 --- a/scripts/windows-validation-contract-smoke.js +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/env node - -const assert = require("assert"); -const fs = require("fs"); -const path = require("path"); - -const repo = path.resolve(__dirname, ".."); - -function read(relativePath) { - return fs.readFileSync(path.join(repo, relativePath), "utf8"); -} - -function expect(source, name, pattern) { - assert.match(source, pattern, `missing Windows validation evidence: ${name}`); -} - -const workflow = read(".forgejo/workflows/windows-validation.yml"); -const runnerSmoke = read("scripts/windows-runner-smoke.js"); -const readme = read("README.md"); -const publicAcceptance = read("scripts/acceptance-public.sh"); -const publicSplit = read("scripts/verify-public-split.sh"); - -expect(workflow, "manual workflow dispatch", /workflow_dispatch/); -expect(workflow, "intermittent Forgejo Windows runner note", /intermittently online/); -expect(workflow, "Windows runner label", /runs-on:\s*windows/); -expect(workflow, "Windows validation acceptance report env", /DISASMER_WINDOWS_VALIDATION:\s*forgejo-windows-runner/); -expect(workflow, "acceptance report step", /node scripts\/acceptance-report\.js windows/); -expect(workflow, "Windows runtime unit coverage", /cargo test -p disasmer-node windows_backend_is_labeled_user_attached_dev_execution/); -expect(workflow, "Windows runner smoke step", /node scripts\/windows-runner-smoke\.js/); - -expect(runnerSmoke, "real Windows platform guard", /process\.platform !== "win32"/); -expect(runnerSmoke, "CLI node attach function", /function runWindowsAttach/); -expect(runnerSmoke, "CLI node attach command", /"node"[\s\S]*"attach"[\s\S]*"windows-command-dev"/); -expect(runnerSmoke, "server-generated enrollment grant creation", /create_node_enrollment_grant[\s\S]*attachGrant\.grant/); -expect(runnerSmoke, "CLI enrollment exchange asserted", /used_enrollment_exchange[\s\S]*true/); -expect(runnerSmoke, "server-generated runtime enrollment grant", /const runtimeGrant[\s\S]*create_node_enrollment_grant[\s\S]*runtimeGrant\.grant/); -expect(runnerSmoke, "runtime uses enrollment grant", /"--enrollment-grant"[\s\S]*grant/); -expect(runnerSmoke, "Windows command task runs", /"windows-command-dev"[\s\S]*"cmd"[\s\S]*"echo disasmer-windows-runner"/); -expect(runnerSmoke, "artifact metadata is asserted", /windows-runner-output\.txt[\s\S]*artifact_download_link/); -expect(runnerSmoke, "Windows placement is asserted", /schedule_task[\s\S]*WindowsCommandDev[\s\S]*forgejo-windows-node/); -expect(runnerSmoke, "debugger shows Windows virtual thread", /compile windows/); -expect(runnerSmoke, "validation result artifact", /disasmer_windows_runner_validation/); - -expect(readme, "docs mention manual Windows workflow", /manual `Windows validation`\s+workflow/); -expect(readme, "docs mention intermittent runner", /intermittent Windows runner/); -expect(readme, "docs mention CLI attach in Windows validation", /runs `disasmer node attach`/); -expect(readme, "docs keep Windows unvalidated when report is not-run", /windows_validation: "not-run"[\s\S]*best-effort and unvalidated/); - -for (const [scriptName, script] of [ - ["public acceptance", publicAcceptance], - ["public split", publicSplit], -]) { - assert( - script.includes("node scripts/windows-validation-contract-smoke.js"), - `${scriptName} must run windows-validation-contract-smoke.js` - ); -} - -console.log("Windows validation contract smoke passed"); diff --git a/vscode-extension/extension.js b/vscode-extension/extension.js index 5b7250e..8e8b65f 100644 --- a/vscode-extension/extension.js +++ b/vscode-extension/extension.js @@ -49,7 +49,7 @@ function diagnoseEnvReferences(text, environmentNames) { .filter((reference) => !known.has(reference.name)) .map((reference) => ({ ...reference, - message: `Missing Disasmer environment "${reference.name}". Add envs/${reference.name}/Containerfile or envs/${reference.name}/Dockerfile.` + message: `Missing Clusterflux environment "${reference.name}". Add envs/${reference.name}/Containerfile or envs/${reference.name}/Dockerfile.` })); } @@ -83,7 +83,7 @@ function resolveProjectPath(folder, value) { } function bundleInspectCommand(projectRoot, adapterWorkspace = path.resolve(__dirname, "..")) { - const workspaceCli = executableWorkspaceBinary(projectRoot, "disasmer"); + const workspaceCli = executableWorkspaceBinary(projectRoot, "clusterflux"); if (workspaceCli) { return { command: workspaceCli, @@ -101,9 +101,9 @@ function bundleInspectCommand(projectRoot, adapterWorkspace = path.resolve(__dir "run", "-q", "-p", - "disasmer-cli", + "clusterflux-cli", "--bin", - "disasmer", + "clusterflux", "--", "bundle", "inspect", @@ -118,12 +118,12 @@ function bundleInspectCommand(projectRoot, adapterWorkspace = path.resolve(__dir }; } -function disasmerCliCommand( +function clusterfluxCliCommand( projectRoot, args, adapterWorkspace = path.resolve(__dirname, "..") ) { - const workspaceCli = executableWorkspaceBinary(projectRoot, "disasmer"); + const workspaceCli = executableWorkspaceBinary(projectRoot, "clusterflux"); if (workspaceCli) { return { command: workspaceCli, @@ -133,37 +133,37 @@ function disasmerCliCommand( } if (!hasCargoWorkspace(adapterWorkspace)) { return { - command: "disasmer", + command: "clusterflux", args, options: { cwd: projectRoot, encoding: "utf8" } }; } return { command: "cargo", - args: ["run", "-q", "-p", "disasmer-cli", "--bin", "disasmer", "--", ...args], + args: ["run", "-q", "-p", "clusterflux-cli", "--bin", "clusterflux", "--", ...args], options: { cwd: adapterWorkspace, encoding: "utf8" } }; } -function runDisasmerCliJson( +function runClusterfluxCliJson( projectRoot, args, adapterWorkspace = path.resolve(__dirname, ".."), runner = childProcess.spawnSync ) { - const invocation = disasmerCliCommand(projectRoot, [...args, "--json"], adapterWorkspace); + const invocation = clusterfluxCliCommand(projectRoot, [...args, "--json"], adapterWorkspace); const result = runner(invocation.command, invocation.args, invocation.options); if (result.error) { - throw new Error(`Disasmer CLI failed: ${result.error.message}`); + throw new Error(`Clusterflux CLI failed: ${result.error.message}`); } if (result.status !== 0) { const detail = String(result.stderr || result.stdout || "").trim(); - throw new Error(detail || `Disasmer CLI exited with status ${result.status}`); + throw new Error(detail || `Clusterflux CLI exited with status ${result.status}`); } try { return JSON.parse(result.stdout); } catch (error) { - throw new Error(`Disasmer CLI returned invalid JSON: ${error.message}`); + throw new Error(`Clusterflux CLI returned invalid JSON: ${error.message}`); } } @@ -173,7 +173,7 @@ function loadLiveProcesses( runner = childProcess.spawnSync ) { try { - const report = runDisasmerCliJson( + const report = runClusterfluxCliJson( projectRoot, ["process", "list"], adapterWorkspace, @@ -211,7 +211,7 @@ function digestFromParts(parts) { return hasher.digest("hex"); } -function disasmerProcessId(projectRoot, entry = "build") { +function clusterfluxProcessId(projectRoot, entry = "build") { return `vp-${digestFromParts(["dap-process:v1", projectRoot, entry]).slice(0, 12)}`; } @@ -223,12 +223,12 @@ function refreshBundleBeforeLaunch( const command = bundleInspectCommand(projectRoot, adapterWorkspace); const result = runner(command.command, command.args, command.options); if (result.error) { - throw new Error(`Disasmer bundle refresh failed: ${result.error.message}`); + throw new Error(`Clusterflux bundle refresh failed: ${result.error.message}`); } if (result.status !== 0) { const detail = (result.stderr || result.stdout || "").trim(); throw new Error( - `Disasmer bundle refresh failed before debug launch${detail ? `: ${detail}` : "."}` + `Clusterflux bundle refresh failed before debug launch${detail ? `: ${detail}` : "."}` ); } @@ -236,29 +236,29 @@ function refreshBundleBeforeLaunch( try { inspection = JSON.parse(result.stdout); } catch (error) { - throw new Error(`Disasmer bundle refresh returned invalid metadata: ${error.message}`); + throw new Error(`Clusterflux bundle refresh returned invalid metadata: ${error.message}`); } if (!inspection.metadata || !inspection.metadata.identity) { - throw new Error("Disasmer bundle refresh did not return a bundle identity."); + throw new Error("Clusterflux bundle refresh did not return a bundle identity."); } return inspection; } -function disasmerViewDescriptors() { +function clusterfluxViewDescriptors() { return [ - { id: "disasmer.nodes", emptyLabel: "No attached nodes" }, - { id: "disasmer.processes", emptyLabel: "No virtual processes" }, - { id: "disasmer.logs", emptyLabel: "No log streams" }, - { id: "disasmer.artifacts", emptyLabel: "No artifacts" }, - { id: "disasmer.inspector", emptyLabel: "No inspector state" } + { id: "clusterflux.nodes", emptyLabel: "No attached nodes" }, + { id: "clusterflux.processes", emptyLabel: "No virtual processes" }, + { id: "clusterflux.logs", emptyLabel: "No log streams" }, + { id: "clusterflux.artifacts", emptyLabel: "No artifacts" }, + { id: "clusterflux.inspector", emptyLabel: "No inspector state" } ]; } -function disasmerViewStatePath(projectRoot) { - return path.join(projectRoot, ".disasmer", "views.json"); +function clusterfluxViewStatePath(projectRoot) { + return path.join(projectRoot, ".clusterflux", "views.json"); } -function emptyDisasmerViewState() { +function emptyClusterfluxViewState() { return { nodes: [], processes: [], @@ -272,10 +272,10 @@ function asArray(value) { return Array.isArray(value) ? value : []; } -function loadDisasmerViewState(projectRoot, reader = fs.readFileSync) { - const statePath = disasmerViewStatePath(projectRoot); +function loadClusterfluxViewState(projectRoot, reader = fs.readFileSync) { + const statePath = clusterfluxViewStatePath(projectRoot); if (!fs.existsSync(statePath)) { - return emptyDisasmerViewState(); + return emptyClusterfluxViewState(); } let parsed; @@ -283,7 +283,7 @@ function loadDisasmerViewState(projectRoot, reader = fs.readFileSync) { parsed = JSON.parse(reader(statePath, "utf8")); } catch (error) { return { - ...emptyDisasmerViewState(), + ...emptyClusterfluxViewState(), inspector: [ { label: "View state error", @@ -310,13 +310,13 @@ function item(label, description = "", options = {}) { }; } -function disasmerViewItems(state, viewId) { +function clusterfluxViewItems(state, viewId) { switch (viewId) { - case "disasmer.nodes": + case "clusterflux.nodes": return state.nodes.map((node) => item(node.id || node.name || "node", [node.status, node.capabilities].filter(Boolean).join(" ")) ); - case "disasmer.processes": + case "clusterflux.processes": return state.processes.map((process) => item( process.id || process.process || "process", @@ -324,24 +324,24 @@ function disasmerViewItems(state, viewId) { { processId: process.id || process.process, processState: process.state || process.status || "unknown", - contextValue: "disasmer.virtualProcess", + contextValue: "clusterflux.virtualProcess", command: { - command: "disasmer.process.attach", + command: "clusterflux.process.attach", title: "Attach to Virtual Process", arguments: [process.id || process.process] } } ) ); - case "disasmer.logs": + case "clusterflux.logs": return state.logs.map((log) => item(log.task || log.stream || "log", [log.message, log.bytes].filter(Boolean).join(" ")) ); - case "disasmer.artifacts": + case "clusterflux.artifacts": return state.artifacts.map((artifact) => item(artifact.path || artifact.id || "artifact", [artifact.status, artifact.size].filter(Boolean).join(" ")) ); - case "disasmer.inspector": + case "clusterflux.inspector": return state.inspector.map((entry) => item(entry.label || entry.name || "inspector", entry.value || entry.detail || "") ); @@ -351,18 +351,18 @@ function disasmerViewItems(state, viewId) { } function treeItemsForView(projectRoot, descriptor, liveProcesses = null) { - const state = projectRoot ? loadDisasmerViewState(projectRoot) : emptyDisasmerViewState(); - if (descriptor.id === "disasmer.processes" && liveProcesses) { + const state = projectRoot ? loadClusterfluxViewState(projectRoot) : emptyClusterfluxViewState(); + if (descriptor.id === "clusterflux.processes" && liveProcesses) { if (liveProcesses.error) { return [ item("Live process state unavailable", liveProcesses.error, { - contextValue: "disasmer.processError" + contextValue: "clusterflux.processError" }) ]; } state.processes = liveProcesses.processes; } - const items = disasmerViewItems(state, descriptor.id); + const items = clusterfluxViewItems(state, descriptor.id); return items.length > 0 ? items : [item(descriptor.emptyLabel)]; } @@ -378,16 +378,16 @@ function toVsCodeTreeItem(viewItem) { return treeItem; } -function resolveDisasmerDebugConfiguration(folder, config = {}) { +function resolveClusterfluxDebugConfiguration(folder, config = {}) { const project = resolveProjectPath(folder, config.project); return { ...config, name: config.name || (config.request === "attach" - ? "Disasmer: Attach to Virtual Process" - : "Disasmer: Launch Virtual Process"), - type: "disasmer", + ? "Clusterflux: Attach to Virtual Process" + : "Clusterflux: Launch Virtual Process"), + type: "clusterflux", request: config.request || "launch", entry: config.entry || "build", project, @@ -399,7 +399,7 @@ function debugAdapterExecutableSpec( projectRoot, adapterWorkspace = path.resolve(__dirname, "..") ) { - const workspaceAdapter = executableWorkspaceBinary(projectRoot, "disasmer-debug-dap"); + const workspaceAdapter = executableWorkspaceBinary(projectRoot, "clusterflux-debug-dap"); if (workspaceAdapter) { return { command: workspaceAdapter, @@ -410,7 +410,7 @@ function debugAdapterExecutableSpec( if (!hasCargoWorkspace(adapterWorkspace)) { return { - command: "disasmer-debug-dap", + command: "clusterflux-debug-dap", args: [], options: { cwd: projectRoot || process.cwd() } }; @@ -418,12 +418,12 @@ function debugAdapterExecutableSpec( return { command: "cargo", - args: ["run", "-q", "-p", "disasmer-dap", "--bin", "disasmer-debug-dap"], + args: ["run", "-q", "-p", "clusterflux-dap", "--bin", "clusterflux-debug-dap"], options: { cwd: adapterWorkspace } }; } -function registerDisasmerViews(context) { +function registerClusterfluxViews(context) { if (!vscode) return null; const workspaceRoot = vscode.workspace.workspaceFolders && @@ -433,11 +433,11 @@ function registerDisasmerViews(context) { let liveProcesses = null; const refreshProcesses = () => { liveProcesses = workspaceRoot ? loadLiveProcesses(workspaceRoot) : null; - const emitter = emitters.get("disasmer.processes"); + const emitter = emitters.get("clusterflux.processes"); if (emitter) emitter.fire(); return liveProcesses; }; - for (const descriptor of disasmerViewDescriptors()) { + for (const descriptor of clusterfluxViewDescriptors()) { const emitter = new vscode.EventEmitter(); emitters.set(descriptor.id, emitter); context.subscriptions.push( @@ -447,7 +447,7 @@ function registerDisasmerViews(context) { return item; }, getChildren() { - if (descriptor.id === "disasmer.processes" && liveProcesses === null) { + if (descriptor.id === "clusterflux.processes" && liveProcesses === null) { refreshProcesses(); } return treeItemsForView(workspaceRoot, descriptor, liveProcesses).map(toVsCodeTreeItem); @@ -458,7 +458,7 @@ function registerDisasmerViews(context) { } if (workspaceRoot && vscode.workspace.createFileSystemWatcher) { const watcher = vscode.workspace.createFileSystemWatcher( - new vscode.RelativePattern(workspaceRoot, ".disasmer/views.json") + new vscode.RelativePattern(workspaceRoot, ".clusterflux/views.json") ); const refresh = () => emitters.forEach((emitter) => emitter.fire()); context.subscriptions.push( @@ -469,7 +469,7 @@ function registerDisasmerViews(context) { ); } context.subscriptions.push( - vscode.commands.registerCommand("disasmer.refreshProcesses", refreshProcesses) + vscode.commands.registerCommand("clusterflux.refreshProcesses", refreshProcesses) ); return { workspaceRoot, refreshProcesses, getLiveProcesses: () => liveProcesses }; } @@ -481,9 +481,9 @@ function commandProcessId(value) { function liveDebugConfiguration(workspaceRoot, processId, liveProcesses) { return { - type: "disasmer", + type: "clusterflux", request: "attach", - name: `Disasmer: Attach to ${processId}`, + name: `Clusterflux: Attach to ${processId}`, project: workspaceRoot, entry: "build", runtimeBackend: "live-services", @@ -492,7 +492,7 @@ function liveDebugConfiguration(workspaceRoot, processId, liveProcesses) { } function processAction(projectRoot, action, processId) { - return runDisasmerCliJson(projectRoot, ["process", action, "--process", processId, "--yes"]); + return runClusterfluxCliJson(projectRoot, ["process", action, "--process", processId, "--yes"]); } function registerProcessCommands(context, viewController) { @@ -501,12 +501,12 @@ function registerProcessCommands(context, viewController) { const folder = vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders[0]; context.subscriptions.push( - vscode.commands.registerCommand("disasmer.process.attach", async (value) => { + vscode.commands.registerCommand("clusterflux.process.attach", async (value) => { const processId = commandProcessId(value); if (!processId) return; const live = viewController.refreshProcesses(); if (live.error) { - vscode.window.showErrorMessage(`Unable to load Disasmer process state: ${live.error}`); + vscode.window.showErrorMessage(`Unable to load Clusterflux process state: ${live.error}`); return; } await vscode.debug.startDebugging( @@ -514,7 +514,7 @@ function registerProcessCommands(context, viewController) { liveDebugConfiguration(projectRoot, processId, live) ); }), - vscode.commands.registerCommand("disasmer.process.cancel", async (value) => { + vscode.commands.registerCommand("clusterflux.process.cancel", async (value) => { const processId = commandProcessId(value); if (!processId) return; const selected = await vscode.window.showWarningMessage( @@ -528,10 +528,10 @@ function registerProcessCommands(context, viewController) { viewController.refreshProcesses(); vscode.window.showInformationMessage(`Cancellation requested for ${processId}.`); } catch (error) { - vscode.window.showErrorMessage(`Disasmer cancellation failed: ${error.message}`); + vscode.window.showErrorMessage(`Clusterflux cancellation failed: ${error.message}`); } }), - vscode.commands.registerCommand("disasmer.process.abort", async (value) => { + vscode.commands.registerCommand("clusterflux.process.abort", async (value) => { const processId = commandProcessId(value); if (!processId) return; const selected = await vscode.window.showWarningMessage( @@ -545,7 +545,7 @@ function registerProcessCommands(context, viewController) { viewController.refreshProcesses(); vscode.window.showInformationMessage(`Aborted ${processId}.`); } catch (error) { - vscode.window.showErrorMessage(`Disasmer abort failed: ${error.message}`); + vscode.window.showErrorMessage(`Clusterflux abort failed: ${error.message}`); } }) ); @@ -571,7 +571,7 @@ async function resolveExistingProcessBeforeLaunch(folder, config) { const active = live.processes[0]; const intendedProcessId = - config.processId || disasmerProcessId(config.project, config.entry || "build"); + config.processId || clusterfluxProcessId(config.project, config.entry || "build"); const relationship = existingProcessRelationship(active, intendedProcessId); const runningId = active.process; const detail = `${runningId} is ${active.state || "running"} in Coordinator Project ${ @@ -603,7 +603,7 @@ async function resolveExistingProcessBeforeLaunch(folder, config) { const selected = await vscode.window.showQuickPick(choices, { title: relationship === "same_launch_target" - ? "This Disasmer virtual process is already running" + ? "This Clusterflux virtual process is already running" : "Another launch is using this Coordinator Project", placeHolder: detail, ignoreFocusOut: true @@ -615,7 +615,7 @@ async function resolveExistingProcessBeforeLaunch(folder, config) { } if (selected.action === "other_project") { await vscode.window.showInformationMessage( - `Choose or create another Coordinator Project with the Disasmer CLI, then run \`disasmer project select\` from this workspace. The current project is ${ + `Choose or create another Coordinator Project with the Clusterflux CLI, then run \`clusterflux project select\` from this workspace. The current project is ${ live.project || "the project in the authenticated CLI session" }.`, { modal: true } @@ -624,19 +624,19 @@ async function resolveExistingProcessBeforeLaunch(folder, config) { } if (selected.action === "cancel") { processAction(config.project, "cancel", runningId); - vscode.commands.executeCommand("disasmer.refreshProcesses"); + vscode.commands.executeCommand("clusterflux.refreshProcesses"); return undefined; } if (selected.action === "abort_launch") { processAction(config.project, "abort", runningId); - vscode.commands.executeCommand("disasmer.refreshProcesses"); + vscode.commands.executeCommand("clusterflux.refreshProcesses"); return config; } if (selected.action === "attach") { return { ...config, request: "attach", - name: `Disasmer: Attach to ${runningId}`, + name: `Clusterflux: Attach to ${runningId}`, processId: runningId }; } @@ -649,9 +649,9 @@ async function resolveExistingProcessBeforeLaunch(folder, config) { function activate(context) { if (!vscode) return; - const diagnostics = vscode.languages.createDiagnosticCollection("disasmer"); + const diagnostics = vscode.languages.createDiagnosticCollection("clusterflux"); context.subscriptions.push(diagnostics); - const viewController = registerDisasmerViews(context); + const viewController = registerClusterfluxViews(context); registerProcessCommands(context, viewController); const refresh = (document) => { @@ -675,12 +675,12 @@ function activate(context) { vscode.workspace.onDidOpenTextDocument(refresh), vscode.workspace.onDidChangeTextDocument((event) => refresh(event.document)), vscode.workspace.onDidSaveTextDocument(refresh), - vscode.debug.registerDebugAdapterDescriptorFactory("disasmer", { + vscode.debug.registerDebugAdapterDescriptorFactory("clusterflux", { createDebugAdapterDescriptor(session) { const folder = session.workspaceFolder; const project = session.configuration.project || (folder && folder.uri.fsPath); if (!project) { - throw new Error("Disasmer debug launch requires a workspace folder or project path."); + throw new Error("Clusterflux debug launch requires a workspace folder or project path."); } const adapterWorkspace = path.resolve(__dirname, ".."); refreshBundleBeforeLaunch(project, adapterWorkspace); @@ -692,9 +692,9 @@ function activate(context) { ); } }), - vscode.debug.registerDebugConfigurationProvider("disasmer", { + vscode.debug.registerDebugConfigurationProvider("clusterflux", { async resolveDebugConfiguration(folder, config) { - const resolved = resolveDisasmerDebugConfiguration(folder, config); + const resolved = resolveClusterfluxDebugConfiguration(folder, config); return resolveExistingProcessBeforeLaunch(folder, resolved); } }) @@ -713,18 +713,18 @@ module.exports = { bundleInspectCommand, debugAdapterExecutableSpec, diagnoseEnvReferences, - disasmerCliCommand, - disasmerProcessId, - disasmerViewDescriptors, - disasmerViewItems, - disasmerViewStatePath, + clusterfluxCliCommand, + clusterfluxProcessId, + clusterfluxViewDescriptors, + clusterfluxViewItems, + clusterfluxViewStatePath, discoverEnvironmentNames, findEnvReferences, existingProcessRelationship, loadLiveProcesses, - loadDisasmerViewState, - registerDisasmerViews, + loadClusterfluxViewState, + registerClusterfluxViews, refreshBundleBeforeLaunch, - runDisasmerCliJson, - resolveDisasmerDebugConfiguration + runClusterfluxCliJson, + resolveClusterfluxDebugConfiguration }; diff --git a/vscode-extension/package.json b/vscode-extension/package.json index 1521bc0..8f616bc 100644 --- a/vscode-extension/package.json +++ b/vscode-extension/package.json @@ -1,9 +1,9 @@ { - "name": "disasmer-vscode", - "displayName": "Disasmer", - "description": "VS Code support for Disasmer build programs, environments, and debugging.", + "name": "clusterflux-vscode", + "displayName": "Clusterflux", + "description": "VS Code support for Clusterflux build programs, environments, and debugging.", "version": "0.1.0", - "publisher": "disasmer", + "publisher": "clusterflux", "engines": { "vscode": "^1.80.0" }, @@ -13,12 +13,12 @@ ], "activationEvents": [ "onLanguage:rust", - "onDebugResolve:disasmer", - "onView:disasmer.nodes", - "onView:disasmer.processes", - "onView:disasmer.logs", - "onView:disasmer.artifacts", - "onView:disasmer.inspector", + "onDebugResolve:clusterflux", + "onView:clusterflux.nodes", + "onView:clusterflux.processes", + "onView:clusterflux.logs", + "onView:clusterflux.artifacts", + "onView:clusterflux.inspector", "workspaceContains:envs/*/Containerfile", "workspaceContains:envs/*/Dockerfile" ], @@ -31,16 +31,16 @@ "viewsContainers": { "activitybar": [ { - "id": "disasmer", - "title": "Disasmer", - "icon": "resources/disasmer.svg" + "id": "clusterflux", + "title": "Clusterflux", + "icon": "resources/clusterflux.svg" } ] }, "debuggers": [ { - "type": "disasmer", - "label": "Disasmer", + "type": "clusterflux", + "label": "Clusterflux", "languages": [ "rust" ], @@ -55,11 +55,11 @@ "properties": { "name": { "type": "string", - "default": "Disasmer: Launch Virtual Process" + "default": "Clusterflux: Launch Virtual Process" }, "type": { "type": "string", - "default": "disasmer" + "default": "clusterflux" }, "request": { "type": "string", @@ -70,7 +70,7 @@ }, "entry": { "type": "string", - "description": "Disasmer entrypoint such as build, test, package, or release." + "description": "Clusterflux entrypoint such as build, test, package, or release." }, "project": { "type": "string", @@ -78,7 +78,7 @@ }, "sourcePath": { "type": "string", - "description": "Source file shown in the Disasmer virtual stack. Defaults to src/build.rs or src/main.rs." + "description": "Source file shown in the Clusterflux virtual stack. Defaults to src/build.rs or src/main.rs." }, "runtimeBackend": { "type": "string", @@ -111,11 +111,11 @@ "properties": { "name": { "type": "string", - "default": "Disasmer: Attach to Virtual Process" + "default": "Clusterflux: Attach to Virtual Process" }, "type": { "type": "string", - "default": "disasmer" + "default": "clusterflux" }, "request": { "type": "string", @@ -138,7 +138,7 @@ }, "sourcePath": { "type": "string", - "description": "Source file shown in the Disasmer virtual stack." + "description": "Source file shown in the Clusterflux virtual stack." }, "runtimeBackend": { "type": "string", @@ -160,73 +160,73 @@ ], "commands": [ { - "command": "disasmer.refreshProcesses", - "title": "Disasmer: Refresh Virtual Processes", + "command": "clusterflux.refreshProcesses", + "title": "Clusterflux: Refresh Virtual Processes", "icon": "$(refresh)" }, { - "command": "disasmer.process.attach", - "title": "Disasmer: Attach to Virtual Process", + "command": "clusterflux.process.attach", + "title": "Clusterflux: Attach to Virtual Process", "icon": "$(debug-alt)" }, { - "command": "disasmer.process.cancel", - "title": "Disasmer: Request Process Cancellation", + "command": "clusterflux.process.cancel", + "title": "Clusterflux: Request Process Cancellation", "icon": "$(debug-stop)" }, { - "command": "disasmer.process.abort", - "title": "Disasmer: Abort Virtual Process", + "command": "clusterflux.process.abort", + "title": "Clusterflux: Abort Virtual Process", "icon": "$(error)" } ], "menus": { "view/title": [ { - "command": "disasmer.refreshProcesses", - "when": "view == disasmer.processes", + "command": "clusterflux.refreshProcesses", + "when": "view == clusterflux.processes", "group": "navigation" } ], "view/item/context": [ { - "command": "disasmer.process.attach", - "when": "view == disasmer.processes && viewItem == disasmer.virtualProcess", + "command": "clusterflux.process.attach", + "when": "view == clusterflux.processes && viewItem == clusterflux.virtualProcess", "group": "inline@1" }, { - "command": "disasmer.process.cancel", - "when": "view == disasmer.processes && viewItem == disasmer.virtualProcess", + "command": "clusterflux.process.cancel", + "when": "view == clusterflux.processes && viewItem == clusterflux.virtualProcess", "group": "process@2" }, { - "command": "disasmer.process.abort", - "when": "view == disasmer.processes && viewItem == disasmer.virtualProcess", + "command": "clusterflux.process.abort", + "when": "view == clusterflux.processes && viewItem == clusterflux.virtualProcess", "group": "process@3" } ] }, "views": { - "disasmer": [ + "clusterflux": [ { - "id": "disasmer.nodes", - "name": "Disasmer Nodes" + "id": "clusterflux.nodes", + "name": "Clusterflux Nodes" }, { - "id": "disasmer.processes", - "name": "Disasmer Processes" + "id": "clusterflux.processes", + "name": "Clusterflux Processes" }, { - "id": "disasmer.logs", - "name": "Disasmer Logs" + "id": "clusterflux.logs", + "name": "Clusterflux Logs" }, { - "id": "disasmer.artifacts", - "name": "Disasmer Artifacts" + "id": "clusterflux.artifacts", + "name": "Clusterflux Artifacts" }, { - "id": "disasmer.inspector", - "name": "Disasmer Inspector" + "id": "clusterflux.inspector", + "name": "Clusterflux Inspector" } ] } diff --git a/vscode-extension/resources/disasmer.svg b/vscode-extension/resources/clusterflux.svg similarity index 100% rename from vscode-extension/resources/disasmer.svg rename to vscode-extension/resources/clusterflux.svg