diff --git a/.dagger/.gitattributes b/.dagger/.gitattributes
new file mode 100644
index 00000000..d3384f51
--- /dev/null
+++ b/.dagger/.gitattributes
@@ -0,0 +1,3 @@
+/dagger.gen.go linguist-generated
+/internal/dagger/** linguist-generated
+/internal/telemetry/** linguist-generated
diff --git a/.dagger/.gitignore b/.dagger/.gitignore
new file mode 100644
index 00000000..33427156
--- /dev/null
+++ b/.dagger/.gitignore
@@ -0,0 +1,4 @@
+/dagger.gen.go
+/internal/dagger
+/internal/telemetry
+/.env
diff --git a/.dagger/LICENSE b/.dagger/LICENSE
new file mode 100644
index 00000000..137069b8
--- /dev/null
+++ b/.dagger/LICENSE
@@ -0,0 +1,73 @@
+Apache License
+Version 2.0, January 2004
+http://www.apache.org/licenses/
+
+TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+1. Definitions.
+
+"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
+
+"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
+
+"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
+
+"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
+
+"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
+
+"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
+
+"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
+
+"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
+
+"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
+
+"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
+
+2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
+
+3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
+
+4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
+
+ (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
+
+5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
+
+6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
+
+7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
+
+8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
+
+9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
+
+END OF TERMS AND CONDITIONS
+
+APPENDIX: How to apply the Apache License to your work.
+
+To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
+
+Copyright [yyyy] [name of copyright owner]
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
diff --git a/.dagger/dagger.json b/.dagger/dagger.json
new file mode 100644
index 00000000..e82c244b
--- /dev/null
+++ b/.dagger/dagger.json
@@ -0,0 +1,7 @@
+{
+ "name": "stem-ci",
+ "engineVersion": "v0.21.7",
+ "sdk": {
+ "source": "go"
+ }
+}
diff --git a/.dagger/go.mod b/.dagger/go.mod
new file mode 100644
index 00000000..00007019
--- /dev/null
+++ b/.dagger/go.mod
@@ -0,0 +1,53 @@
+module dagger/stem-ci
+
+go 1.26.4
+
+require (
+ github.com/Khan/genqlient v0.8.1
+ github.com/dagger/otel-go v1.43.0
+ github.com/dagger/querybuilder v0.0.0-20260402040506-574a5e81cb59
+ github.com/vektah/gqlparser/v2 v2.5.32
+ go.opentelemetry.io/otel v1.43.0
+ go.opentelemetry.io/otel/trace v1.43.0
+)
+
+require (
+ github.com/99designs/gqlgen v0.17.89 // indirect
+ github.com/cenkalti/backoff/v5 v5.0.3 // indirect
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
+ github.com/go-logr/logr v1.4.3 // indirect
+ github.com/go-logr/stdr v1.2.2 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
+ github.com/sosodev/duration v1.4.0 // indirect
+ go.opentelemetry.io/auto/sdk v1.2.1 // indirect
+ go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.17.0 // indirect
+ go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.17.0 // indirect
+ go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.41.0 // indirect
+ go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.41.0 // indirect
+ go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0 // indirect
+ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0 // indirect
+ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0 // indirect
+ go.opentelemetry.io/otel/log v0.17.0 // indirect
+ go.opentelemetry.io/otel/metric v1.43.0 // indirect
+ go.opentelemetry.io/otel/sdk v1.43.0
+ go.opentelemetry.io/otel/sdk/log v0.17.0 // indirect
+ go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
+ go.opentelemetry.io/proto/otlp v1.9.0 // indirect
+ golang.org/x/net v0.51.0 // indirect
+ golang.org/x/sync v0.20.0 // indirect
+ golang.org/x/sys v0.42.0 // indirect
+ golang.org/x/text v0.34.0 // indirect
+ google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
+ google.golang.org/grpc v1.79.3 // indirect
+ google.golang.org/protobuf v1.36.11 // indirect
+)
+
+replace go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc => go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.16.0
+
+replace go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp => go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0
+
+replace go.opentelemetry.io/otel/log => go.opentelemetry.io/otel/log v0.16.0
+
+replace go.opentelemetry.io/otel/sdk/log => go.opentelemetry.io/otel/sdk/log v0.16.0
diff --git a/.dagger/go.sum b/.dagger/go.sum
new file mode 100644
index 00000000..4bb39e60
--- /dev/null
+++ b/.dagger/go.sum
@@ -0,0 +1,97 @@
+github.com/99designs/gqlgen v0.17.89 h1:KzEcxPiMgQoMw3m/E85atUEHyZyt0PbAflMia5Kw8z8=
+github.com/99designs/gqlgen v0.17.89/go.mod h1:GFqruTVGB7ZTdrf1uzOagpXbY7DrEt1pIxnTdhIbWvQ=
+github.com/Khan/genqlient v0.8.1 h1:wtOCc8N9rNynRLXN3k3CnfzheCUNKBcvXmVv5zt6WCs=
+github.com/Khan/genqlient v0.8.1/go.mod h1:R2G6DzjBvCbhjsEajfRjbWdVglSH/73kSivC9TLWVjU=
+github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
+github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
+github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
+github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
+github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
+github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/dagger/otel-go v1.43.0 h1:AYCnAamWmxtSxigWPTgC+8EWqiWPcDZEegh8y05gdJ8=
+github.com/dagger/otel-go v1.43.0/go.mod h1:83CTuXi70zcx1kaym5buqmb7RNzg1E9dEiQSFyLbLdU=
+github.com/dagger/querybuilder v0.0.0-20260402040506-574a5e81cb59 h1:g6vfdGRyz6fAjfHz5FyYPZgHy8qcQ31fHrBl1iCOzxw=
+github.com/dagger/querybuilder v0.0.0-20260402040506-574a5e81cb59/go.mod h1:jsdUJeYzcbyK1j/EqMGPrQgNYxl/Zfg06vvM9C/xXxs=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
+github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
+github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
+github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
+github.com/sosodev/duration v1.4.0 h1:35ed0KiVFriGHHzZZJaZLgmTEEICIyt8Sx0RQfj9IjE=
+github.com/sosodev/duration v1.4.0/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/vektah/gqlparser/v2 v2.5.32 h1:k9QPJd4sEDTL+qB4ncPLflqTJ3MmjB9SrVzJrawpFSc=
+github.com/vektah/gqlparser/v2 v2.5.32/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts=
+go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
+go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
+go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
+go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
+go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.16.0 h1:ZVg+kCXxd9LtAaQNKBxAvJ5NpMf7LpvEr4MIZqb0TMQ=
+go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.16.0/go.mod h1:hh0tMeZ75CCXrHd9OXRYxTlCAdxcXioWHFIpYw2rZu8=
+go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0 h1:djrxvDxAe44mJUrKataUbOhCKhR3F8QCyWucO16hTQs=
+go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0/go.mod h1:dt3nxpQEiSoKvfTVxp3TUg5fHPLhKtbcnN3Z1I1ePD0=
+go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.41.0 h1:VO3BL6OZXRQ1yQc8W6EVfJzINeJ35BkiHx4MYfoQf44=
+go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.41.0/go.mod h1:qRDnJ2nv3CQXMK2HUd9K9VtvedsPAce3S+/4LZHjX/s=
+go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.41.0 h1:MMrOAN8H1FrvDyq9UJ4lu5/+ss49Qgfgb7Zpm0m8ABo=
+go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.41.0/go.mod h1:Na+2NNASJtF+uT4NxDe0G+NQb+bUgdPDfwxY/6JmS/c=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0 h1:ao6Oe+wSebTlQ1OEht7jlYTzQKE+pnx/iNywFvTbuuI=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0/go.mod h1:u3T6vz0gh/NVzgDgiwkgLxpsSF6PaPmo2il0apGJbls=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0 h1:mq/Qcf28TWz719lE3/hMB4KkyDuLJIvgJnFGcd0kEUI=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0/go.mod h1:yk5LXEYhsL2htyDNJbEq7fWzNEigeEdV5xBF/Y+kAv0=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0 h1:inYW9ZhgqiDqh6BioM7DVHHzEGVq76Db5897WLGZ5Go=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0/go.mod h1:Izur+Wt8gClgMJqO/cZ8wdeeMryJ/xxiOVgFSSfpDTY=
+go.opentelemetry.io/otel/log v0.16.0 h1:DeuBPqCi6pQwtCK0pO4fvMB5eBq6sNxEnuTs88pjsN4=
+go.opentelemetry.io/otel/log v0.16.0/go.mod h1:rWsmqNVTLIA8UnwYVOItjyEZDbKIkMxdQunsIhpUMes=
+go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
+go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
+go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
+go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
+go.opentelemetry.io/otel/sdk/log v0.16.0 h1:e/b4bdlQwC5fnGtG3dlXUrNOnP7c8YLVSpSfEBIkTnI=
+go.opentelemetry.io/otel/sdk/log v0.16.0/go.mod h1:JKfP3T6ycy7QEuv3Hj8oKDy7KItrEkus8XJE6EoSzw4=
+go.opentelemetry.io/otel/sdk/log/logtest v0.16.0 h1:/XVkpZ41rVRTP4DfMgYv1nEtNmf65XPPyAdqV90TMy4=
+go.opentelemetry.io/otel/sdk/log/logtest v0.16.0/go.mod h1:iOOPgQr5MY9oac/F5W86mXdeyWZGleIx3uXO98X2R6Y=
+go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
+go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
+go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
+go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
+go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
+go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
+go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
+go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
+golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
+golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
+golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
+golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
+golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
+golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
+gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
+gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
+google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 h1:tu/dtnW1o3wfaxCOjSLn5IRX4YDcJrtlpzYkhHhGaC4=
+google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
+google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
+google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
+google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
+google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/.dagger/main.go b/.dagger/main.go
new file mode 100644
index 00000000..3e957c56
--- /dev/null
+++ b/.dagger/main.go
@@ -0,0 +1,345 @@
+// A reproducible Stem test environment powered by Dagger.
+//
+// The root Taskfile remains the source of truth for the package test order.
+// This module supplies the pinned toolchain and disposable integration
+// services around that task, so the same command can run locally and in CI.
+package main
+
+import (
+ "context"
+ "fmt"
+
+ "dagger/stem-ci/internal/dagger"
+)
+
+const (
+ dartImage = "dart:3.10.0"
+ flutterVersion = "3.47.0"
+ flutterArchive = "flutter_linux_3.47.0-stable.tar.xz"
+ flutterSHA256 = "26cd99d3d94b1367e6b50535a18aeef0282c10a535bbe3ec493534dcdab75296"
+ flutterRoot = "/opt/flutter"
+ taskVersion = "3.53.1"
+ taskSHA256 = "a54a408f6861ff921f6e87774180db31bacd8c1e7c944ca696db9fea49a82fc7"
+ workspaceDir = "/workspace"
+ testCertsDir = "/stem-test-certs"
+ taskBinaryPath = "/usr/local/bin/task"
+)
+
+type StemCi struct{}
+
+// Check runs the complete root test gate in an isolated, reproducible
+// environment. The source directory is copied into the test container, while
+// Redis and PostgreSQL are provided as Dagger services and discarded when the
+// call finishes.
+func (m *StemCi) Check(ctx context.Context, source *dagger.Directory) (string, error) {
+ assets := m.tlsAssets(source)
+ testCerts := m.clientCertificates(assets)
+
+ postgres := m.postgresService(assets.Directory("postgres"))
+ redis := m.redisService("redis", assets.Directory("redis"), false)
+ redisTLS := m.redisService("redis-tls", assets.Directory("redis"), false)
+ redisMTLS := m.redisService("redis-mtls", assets.Directory("redis"), true)
+
+ test := m.testContainer(source, testCerts).
+ WithServiceBinding("postgres", postgres).
+ WithServiceBinding("redis", redis).
+ WithServiceBinding("redis-tls", redisTLS).
+ WithServiceBinding("redis-mtls", redisMTLS)
+
+ stdout, err := m.runTests(test).Stdout(ctx)
+ if err != nil {
+ return "", fmt.Errorf("Stem test gate failed: %w", err)
+ }
+ return stdout, nil
+}
+
+// All runs every package test, including the Flutter packages. It is the
+// default CI entrypoint; Check remains available for the faster Dart-only
+// integration gate while developing the Dagger module.
+func (m *StemCi) All(ctx context.Context, source *dagger.Directory) (string, error) {
+ dartOutput, err := m.Check(ctx, source)
+ if err != nil {
+ return "", err
+ }
+
+ flutterOutput, err := m.runFlutterTests(
+ m.flutterContainer(source),
+ ).Stdout(ctx)
+ if err != nil {
+ return "", fmt.Errorf("Stem Flutter test gate failed: %w", err)
+ }
+
+ return "Dart gate:\n" + dartOutput + "\nFlutter gate:\n" + flutterOutput, nil
+}
+
+// flutterContainer installs the pinned Flutter SDK from the official Linux
+// release archive. This avoids depending on an unpinned or third-party image
+// for the Flutter portion of the gate.
+func (m *StemCi) flutterContainer(source *dagger.Directory) *dagger.Container {
+ return dag.Container().
+ From(dartImage).
+ WithEnvVariable(
+ "PATH",
+ flutterRoot+"/bin:"+
+ flutterRoot+"/bin/cache/dart-sdk/bin:"+
+ "/root/.pub-cache/bin:/usr/local/bin:/usr/local/sbin:/usr/sbin:/usr/bin:/sbin:/bin",
+ ).
+ WithMountedCache("/root/.pub-cache", dag.CacheVolume("stem-flutter-pub-cache")).
+ WithDirectory(workspaceDir, source, dagger.ContainerWithDirectoryOpts{Gitignore: true}).
+ WithWorkdir(workspaceDir).
+ WithExec([]string{
+ "bash",
+ "-c",
+ fmt.Sprintf(
+ "set -euo pipefail\n"+
+ "apt-get update\n"+
+ "DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends "+
+ "ca-certificates curl git libglu1-mesa unzip xz-utils\n"+
+ "rm -rf /var/lib/apt/lists/*\n"+
+ "mkdir -p /opt\n"+
+ "curl -fsSL -o /tmp/%s https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/%s\n"+
+ "printf '%s /tmp/%s\\n' | sha256sum -c -\n"+
+ "tar -xJf /tmp/%s -C /opt\n"+
+ "git config --global --add safe.directory "+flutterRoot+"\n"+
+ "flutter config --no-analytics\n"+
+ "flutter --version\n"+
+ "curl -fsSL -o /tmp/task.tar.gz https://github.com/go-task/task/releases/download/v%s/task_linux_amd64.tar.gz\n"+
+ "printf '%s /tmp/task.tar.gz\\n' | sha256sum -c -\n"+
+ "tar -xzf /tmp/task.tar.gz -C /usr/local/bin task\n"+
+ "chmod 0755 %s\n"+
+ "task --version\n",
+ flutterArchive,
+ flutterArchive,
+ flutterSHA256,
+ flutterArchive,
+ flutterArchive,
+ taskVersion,
+ taskSHA256,
+ taskBinaryPath,
+ ),
+ })
+}
+
+func (m *StemCi) runFlutterTests(container *dagger.Container) *dagger.Container {
+ return container.WithExec([]string{
+ "bash",
+ "-c",
+ "set -euo pipefail\n" +
+ "task standalone:flutter\n" +
+ "task test:flutter\n",
+ })
+}
+
+// tlsAssets creates separate disposable certificate authorities for Redis and
+// PostgreSQL. Keeping the generation inside Dagger means CI never needs to
+// commit or cache private-key fixtures.
+func (m *StemCi) tlsAssets(source *dagger.Directory) *dagger.Directory {
+ generator := dag.Container().
+ From("alpine:3.22").
+ WithExec([]string{"apk", "add", "--no-cache", "bash", "openssl"}).
+ WithMountedFile(
+ "/generate_tls_assets.sh",
+ source.File("packages/stem/scripts/security/generate_tls_assets.sh"),
+ ).
+ WithExec([]string{
+ "bash",
+ "-c",
+ "set -euo pipefail\n" +
+ "mkdir -p /certs/redis /certs/postgres\n" +
+ "/generate_tls_assets.sh /certs/redis redis 'redis,redis-tls,redis-mtls,localhost,127.0.0.1' >/dev/null\n" +
+ "/generate_tls_assets.sh /certs/postgres postgres 'postgres,localhost,127.0.0.1' >/dev/null\n" +
+ "cp /certs/postgres/ca.crt /certs/postgres/root.crt\n" +
+ "chmod 644 /certs/redis/*.crt /certs/redis/*.key\n" +
+ "chmod 644 /certs/postgres/*.crt\n" +
+ "chmod 600 /certs/postgres/*.key\n",
+ })
+
+ return generator.Directory("certs")
+}
+
+// clientCertificates removes CA and server private keys before the test
+// container receives the client-facing certificate material.
+func (m *StemCi) clientCertificates(assets *dagger.Directory) *dagger.Directory {
+ redis := assets.Directory("redis").WithoutFiles([]string{"ca.key", "server.key"})
+ postgres := assets.Directory("postgres").WithoutFiles([]string{"ca.key", "server.key"})
+ return dag.Directory().
+ WithDirectory("redis", redis).
+ WithDirectory("postgres", postgres)
+}
+
+func (m *StemCi) postgresService(certificates *dagger.Directory) *dagger.Service {
+ return dag.Container().
+ From("postgres:14").
+ WithEnvVariable("POSTGRES_USER", "postgres").
+ WithEnvVariable("POSTGRES_PASSWORD", "postgres").
+ WithEnvVariable("POSTGRES_DB", "stem_test").
+ WithDirectory(
+ "/etc/postgres/certs",
+ certificates,
+ dagger.ContainerWithDirectoryOpts{Owner: "postgres:postgres"},
+ ).
+ WithExposedPort(5432).
+ AsService(dagger.ContainerAsServiceOpts{
+ Args: []string{
+ "postgres",
+ "-c", "ssl=on",
+ "-c", "ssl_cert_file=/etc/postgres/certs/server.crt",
+ "-c", "ssl_key_file=/etc/postgres/certs/server.key",
+ "-c", "ssl_ca_file=/etc/postgres/certs/root.crt",
+ "-c", "max_connections=200",
+ },
+ UseEntrypoint: true,
+ })
+}
+
+func (m *StemCi) redisService(
+ name string,
+ certificates *dagger.Directory,
+ mutualTLS bool,
+) *dagger.Service {
+ args := []string{
+ "--port", "0",
+ "--tls-port", "6379",
+ "--tls-cert-file", "/etc/redis/certs/server.crt",
+ "--tls-key-file", "/etc/redis/certs/server.key",
+ "--tls-ca-cert-file", "/etc/redis/certs/ca.crt",
+ "--tls-auth-clients", "no",
+ "--databases", "16",
+ "--appendonly", "no",
+ }
+ if name == "redis" {
+ args = []string{
+ "--port", "6379",
+ "--tls-port", "0",
+ "--databases", "16",
+ "--appendonly", "no",
+ }
+ }
+ if mutualTLS {
+ for i := range args {
+ if args[i] == "no" && i > 0 && args[i-1] == "--tls-auth-clients" {
+ args[i] = "yes"
+ }
+ }
+ }
+
+ healthcheck := "redis-cli -h 127.0.0.1 -p 6379 ping | grep PONG"
+ if name != "redis" {
+ healthcheck = "redis-cli --tls --cacert /etc/redis/certs/ca.crt -p 6379 ping | grep PONG"
+ if mutualTLS {
+ healthcheck = "redis-cli --tls --cacert /etc/redis/certs/ca.crt --cert /etc/redis/certs/client.crt --key /etc/redis/certs/client.key -p 6379 ping | grep PONG"
+ }
+ }
+
+ return dag.Container().
+ From("redis:7-alpine").
+ WithDirectory("/etc/redis/certs", certificates).
+ WithExposedPort(6379).
+ WithDockerHealthcheck(
+ []string{healthcheck},
+ dagger.ContainerWithDockerHealthcheckOpts{
+ Shell: true,
+ Interval: "2s",
+ Timeout: "5s",
+ },
+ ).
+ AsService(dagger.ContainerAsServiceOpts{
+ Args: append([]string{"redis-server"}, args...),
+ UseEntrypoint: true,
+ })
+}
+
+func (m *StemCi) testContainer(
+ source *dagger.Directory,
+ certificates *dagger.Directory,
+) *dagger.Container {
+ return dag.Container().
+ From(dartImage).
+ WithEnvVariable("PATH", "/usr/lib/dart/bin:/root/.pub-cache/bin:/usr/local/bin:/usr/local/sbin:/usr/sbin:/usr/bin:/sbin:/bin").
+ WithMountedCache("/root/.pub-cache", dag.CacheVolume("stem-pub-cache")).
+ WithMountedCache(
+ "/root/.server_testing",
+ dag.CacheVolume("stem-server-testing"),
+ ).
+ WithDirectory(workspaceDir, source, dagger.ContainerWithDirectoryOpts{Gitignore: true}).
+ WithDirectory(testCertsDir, certificates).
+ WithWorkdir(workspaceDir).
+ WithExec([]string{
+ "bash",
+ "-c",
+ fmt.Sprintf(
+ "set -euo pipefail\n"+
+ "apt-get update\n"+
+ "DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends "+
+ "sqlite3 libsqlite3-dev postgresql-client redis-tools ca-certificates "+
+ "libasound2t64 libatk-bridge2.0-0t64 libatk1.0-0t64 libatspi2.0-0 "+
+ "libcairo2 libcups2t64 libdbus-1-3 libdrm2 libgbm1 libglib2.0-0t64 "+
+ "libgtk-3-0t64 libnspr4 libnss3 libpango-1.0-0 libwayland-client0 "+
+ "libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxdamage1 libxext6 "+
+ "libxfixes3 libxkbcommon0 libxrandr2 libxshmfence1\n"+
+ "rm -rf /var/lib/apt/lists/*\n"+
+ "curl -fsSL -o /tmp/task.tar.gz https://github.com/go-task/task/releases/download/v%s/task_linux_amd64.tar.gz\n"+
+ "printf '%s /tmp/task.tar.gz\\n' | sha256sum -c -\n"+
+ "tar -xzf /tmp/task.tar.gz -C /usr/local/bin task\n"+
+ "ln -sf /usr/lib/dart/bin/dart /usr/local/bin/dart\n"+
+ "chmod 0755 %s\n"+
+ "task --version\n"+
+ "dart --version\n",
+ taskVersion,
+ taskSHA256,
+ taskBinaryPath,
+ ),
+ }).
+ WithEnvVariable("STEM_TEST_REDIS_URL", "redis://redis:6379/0").
+ WithEnvVariable("STEM_TEST_POSTGRES_URL", "postgresql://postgres:postgres@postgres:5432/stem_test").
+ WithEnvVariable("STEM_TEST_REDIS_TLS_URL", "rediss://redis-tls:6379/0").
+ WithEnvVariable("STEM_TEST_REDIS_TLS_CA_CERT", testCertsDir+"/redis/ca.crt").
+ WithEnvVariable("STEM_TEST_REDIS_MTLS_URL", "rediss://redis-mtls:6379/0").
+ WithEnvVariable("STEM_TEST_REDIS_MTLS_CA_CERT", testCertsDir+"/redis/ca.crt").
+ WithEnvVariable("STEM_TEST_REDIS_MTLS_CLIENT_CERT", testCertsDir+"/redis/client.crt").
+ WithEnvVariable("STEM_TEST_REDIS_MTLS_CLIENT_KEY", testCertsDir+"/redis/client.key").
+ WithEnvVariable(
+ "STEM_TEST_POSTGRES_TLS_URL",
+ "postgresql://postgres:postgres@postgres:5432/stem_test?sslmode=verify-ca&sslrootcert="+
+ testCertsDir+
+ "/postgres/root.crt",
+ ).
+ WithEnvVariable(
+ "STEM_TEST_POSTGRES_TLS_CA_CERT",
+ testCertsDir+"/postgres/root.crt",
+ ).
+ WithEnvVariable("STEM_CHAOS_REDIS_URL", "redis://redis:6379/15").
+ WithEnvVariable("POSTGRES_URL", "postgresql://postgres:postgres@postgres:5432/stem_test").
+ WithEnvVariable("REDIS_URL", "redis://redis:6379/0")
+}
+
+func (m *StemCi) runTests(container *dagger.Container) *dagger.Container {
+ return container.WithExec([]string{
+ "bash",
+ "-c",
+ "set -euo pipefail\n" +
+ "for attempt in $(seq 1 30); do\n" +
+ " pg_isready -h postgres -p 5432 -U postgres -d stem_test >/dev/null 2>&1 && break\n" +
+ " sleep 1\n" +
+ " if [ \"$attempt\" -eq 30 ]; then\n" +
+ " echo 'PostgreSQL did not become ready' >&2\n" +
+ " exit 1\n" +
+ " fi\n" +
+ "done\n" +
+ "for attempt in $(seq 1 30); do\n" +
+ " redis-cli -h redis -p 6379 ping >/dev/null 2>&1 && break\n" +
+ " sleep 1\n" +
+ " if [ \"$attempt\" -eq 30 ]; then\n" +
+ " echo 'Redis did not become ready' >&2\n" +
+ " exit 1\n" +
+ " fi\n" +
+ "done\n" +
+ "if ! command -v flutter >/dev/null 2>&1; then\n" +
+ " sed -i '/packages\\/stem_flutter/d' pubspec.yaml\n" +
+ "fi\n" +
+ "task deps\n" +
+ "task quality:dart\n" +
+ "task standalone:dart\n" +
+ "task test\n",
+ })
+}
diff --git a/.github/actions/setup-test-services/action.yaml b/.github/actions/setup-test-services/action.yaml
deleted file mode 100644
index d10b2f8e..00000000
--- a/.github/actions/setup-test-services/action.yaml
+++ /dev/null
@@ -1,69 +0,0 @@
-name: Setup test services
-description: Start Redis and Postgres test containers and export environment variables
-
-runs:
- using: composite
- steps:
- - name: Start Redis/Postgres containers
- shell: bash
- run: |
- set -euo pipefail
- export STEM_TEST_REDIS_TLS_PORT="${STEM_TEST_REDIS_TLS_PORT:-0}"
- export STEM_TEST_REDIS_MTLS_PORT="${STEM_TEST_REDIS_MTLS_PORT:-0}"
- for attempt in 1 2 3; do
- if docker compose -f packages/stem_cli/docker/testing/docker-compose.yml up -d; then
- break
- fi
- echo "docker compose up failed (attempt ${attempt}). Retrying..."
- docker compose -f packages/stem_cli/docker/testing/docker-compose.yml down --remove-orphans || true
- sleep 5
- if [ "$attempt" -eq 3 ]; then
- exit 1
- fi
- done
- - name: Wait for services to be ready
- shell: bash
- run: |
- # Wait for Postgres to be ready
- for i in {1..30}; do
- if pg_isready -h 127.0.0.1 -p 65432 -U postgres 2>/dev/null; then
- break
- fi
- sleep 1
- done
- # Wait for Postgres TLS to be ready
- for i in {1..30}; do
- if pg_isready -h 127.0.0.1 -p 65433 -U postgres 2>/dev/null; then
- break
- fi
- sleep 1
- done
- # Reset test database to clean state
- PGPASSWORD=postgres psql -h 127.0.0.1 -U postgres -p 65432 -d postgres -c "DROP DATABASE IF EXISTS stem_test;" 2>/dev/null || true
- PGPASSWORD=postgres psql -h 127.0.0.1 -U postgres -p 65432 -d postgres -c "CREATE DATABASE stem_test;" 2>/dev/null || true
- # Reset TLS test database to clean state
- PGPASSWORD=postgres psql -h 127.0.0.1 -U postgres -p 65433 -d postgres -c "DROP DATABASE IF EXISTS stem_test_tls;" 2>/dev/null || true
- PGPASSWORD=postgres psql -h 127.0.0.1 -U postgres -p 65433 -d postgres -c "CREATE DATABASE stem_test_tls;" 2>/dev/null || true
- - name: Export STEM_TEST_* environment variables
- shell: bash
- run: |
- redis_tls_mapping=$(docker compose -f packages/stem_cli/docker/testing/docker-compose.yml port redis_tls 6379)
- redis_tls_port=${redis_tls_mapping##*:}
- redis_mtls_mapping=$(docker compose -f packages/stem_cli/docker/testing/docker-compose.yml port redis_mtls 6379)
- redis_mtls_port=${redis_mtls_mapping##*:}
- echo "STEM_TEST_REDIS_TLS_PORT=${redis_tls_port}" >> "$GITHUB_ENV"
- echo "STEM_TEST_REDIS_MTLS_PORT=${redis_mtls_port}" >> "$GITHUB_ENV"
- echo "STEM_TEST_REDIS_URL=redis://127.0.0.1:56379" >> "$GITHUB_ENV"
- echo "STEM_TEST_POSTGRES_URL=postgresql://postgres:postgres@127.0.0.1:65432/stem_test" >> "$GITHUB_ENV"
- echo "STEM_TEST_POSTGRES_TLS_URL=postgresql://postgres:postgres@localhost:65433/stem_test_tls?sslmode=verify-ca&sslrootcert=$GITHUB_WORKSPACE/packages/stem_cli/docker/testing/certs/postgres-root.crt" >> "$GITHUB_ENV"
- echo "STEM_TEST_POSTGRES_TLS_CA_CERT=$GITHUB_WORKSPACE/packages/stem_cli/docker/testing/certs/postgres-root.crt" >> "$GITHUB_ENV"
- echo "STEM_TEST_REDIS_TLS_URL=rediss://localhost:${redis_tls_port}/0" >> "$GITHUB_ENV"
- echo "STEM_TEST_REDIS_TLS_CA_CERT=$GITHUB_WORKSPACE/packages/stem/example/microservice/certs/ca.crt" >> "$GITHUB_ENV"
- echo "STEM_TEST_REDIS_MTLS_URL=rediss://localhost:${redis_mtls_port}/0" >> "$GITHUB_ENV"
- echo "STEM_TEST_REDIS_MTLS_CA_CERT=$GITHUB_WORKSPACE/packages/stem/example/microservice/certs/ca.crt" >> "$GITHUB_ENV"
- echo "STEM_TEST_REDIS_MTLS_CLIENT_CERT=$GITHUB_WORKSPACE/packages/stem/example/microservice/certs/client.crt" >> "$GITHUB_ENV"
- echo "STEM_TEST_REDIS_MTLS_CLIENT_KEY=$GITHUB_WORKSPACE/packages/stem/example/microservice/certs/client.key" >> "$GITHUB_ENV"
- echo "STEM_CHAOS_REDIS_URL=redis://127.0.0.1:56379/15" >> "$GITHUB_ENV"
- # Also set generic POSTGRES_URL and REDIS_URL for compatibility with different tests
- echo "POSTGRES_URL=postgresql://postgres:postgres@127.0.0.1:65432/stem_test" >> "$GITHUB_ENV"
- echo "REDIS_URL=redis://127.0.0.1:56379/0" >> "$GITHUB_ENV"
diff --git a/.github/workflows/aggregate.yaml b/.github/workflows/aggregate.yaml
new file mode 100644
index 00000000..c75b92d2
--- /dev/null
+++ b/.github/workflows/aggregate.yaml
@@ -0,0 +1,102 @@
+name: aggregate
+
+on:
+ push:
+ branches: [master, main]
+ pull_request:
+ branches: [master, main]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ repository-hygiene:
+ name: repository / hygiene
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Reject committed certificate and key fixtures
+ shell: bash
+ run: |
+ set -euo pipefail
+ matches="$(find packages -type f \( \
+ -name '*.key' -o -name '*.pem' -o -name '*.crt' -o \
+ -name '*.srl' -o -name '*.cnf' \
+ \) -print)"
+ if [[ -n "$matches" ]]; then
+ printf 'Certificate/key-like files must be generated during test setup:\n%s\n' "$matches"
+ exit 1
+ fi
+ - name: Reject duplicate legacy PostgreSQL CA path
+ shell: bash
+ run: |
+ set -euo pipefail
+ test ! -e packages/stem_cli/docker/testing/certs/postgres-root.crt
+ ! rg -n 'docker/testing/certs|certs/postgres-root' \
+ packages .gitignore
+
+ dagger-test:
+ name: integration / Dagger all gate
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ # Local `task test:dagger` and CI both call the same Go SDK module.
+ - name: Run Dagger-backed all-package test gate
+ uses: dagger/dagger-for-github@v8.4.1
+ with:
+ version: v0.21.7
+ module: .dagger
+ call: all --source=.
+ dagger-flags: --progress plain
+
+ cross-platform-packages:
+ name: portable package / ${{ matrix.package }} / ${{ matrix.os }}
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [windows-latest, macos-latest]
+ include:
+ - package: packages/stem
+ tool: dart
+ test_args: --exclude-tags soak --fail-fast
+ - package: packages/stem_builder
+ tool: dart
+ test_args: --fail-fast
+ - package: packages/stem_sqlite
+ tool: dart
+ test_args: --fail-fast
+ - package: packages/stem_flutter
+ tool: flutter
+ test_args: --fail-fast
+ - package: packages/stem_flutter_sqlite
+ tool: flutter
+ test_args: --fail-fast
+ - package: packages/stem_memory
+ tool: dart
+ test_args: --fail-fast
+ - package: packages/stem_adapter_tests
+ tool: dart
+ test_args: --fail-fast
+ steps:
+ - uses: actions/checkout@v4
+ # The root is a Dart workspace that also contains Flutter packages. Use
+ # Flutter's pub frontend for every portable package so Dart-only entries
+ # can resolve the workspace on both runners as well.
+ - uses: subosito/flutter-action@v2
+ with:
+ channel: stable
+ cache: true
+ - name: Resolve package dependencies
+ run: flutter pub get
+ working-directory: ${{ matrix.package }}
+ - name: Check package formatting
+ run: dart format lib test --set-exit-if-changed
+ working-directory: ${{ matrix.package }}
+ - name: Analyze package
+ run: ${{ matrix.tool }} analyze --fatal-infos
+ working-directory: ${{ matrix.package }}
+ - name: Test package
+ run: ${{ matrix.tool }} test ${{ matrix.test_args }}
+ working-directory: ${{ matrix.package }}
diff --git a/.github/workflows/benchmarks.yaml b/.github/workflows/benchmarks.yaml
new file mode 100644
index 00000000..cc490c94
--- /dev/null
+++ b/.github/workflows/benchmarks.yaml
@@ -0,0 +1,41 @@
+name: benchmarks
+
+on:
+ schedule:
+ - cron: '17 3 * * 1'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ core-throughput:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: dart-lang/setup-dart@v1
+ with:
+ sdk: 3.10.0
+ - name: Resolve dependencies
+ run: dart pub get
+ - name: Check benchmark formatting
+ run: dart format benchmark --set-exit-if-changed
+ - name: Run throughput regression gate
+ run: dart run benchmark/stem_throughput.dart --check-baseline
+
+ sqlite-throughput:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: dart-lang/setup-dart@v1
+ with:
+ sdk: 3.10.0
+ - name: Resolve SQLite package dependencies
+ working-directory: packages/stem_sqlite
+ run: dart pub get
+ - name: Check SQLite benchmark formatting
+ working-directory: packages/stem_sqlite
+ run: dart format benchmark --set-exit-if-changed
+ - name: Run SQLite throughput regression gate
+ working-directory: packages/stem_sqlite
+ run: dart run benchmark/sqlite_throughput.dart --check-baseline
diff --git a/.github/workflows/dashboard.yaml b/.github/workflows/dashboard.yaml
deleted file mode 100644
index cdc368dc..00000000
--- a/.github/workflows/dashboard.yaml
+++ /dev/null
@@ -1,44 +0,0 @@
-name: dashboard
-
-on:
- push:
- branches:
- - master
- - main
- paths:
- - 'packages/dashboard/**'
- - 'packages/stem/**'
- - 'pubspec.yaml'
- pull_request:
- paths:
- - 'packages/dashboard/**'
- - 'packages/stem/**'
- - 'pubspec.yaml'
- workflow_dispatch:
-
-jobs:
- test:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- - name: Install system dependencies
- run: |
- sudo apt-get update
- sudo apt-get install -y sqlite3 libsqlite3-dev
- - uses: ./.github/actions/setup-test-services
- - uses: subosito/flutter-action@v2
- with:
- channel: stable
- cache: true
- - name: Get Dart packages cache
- uses: actions/cache@v4
- with:
- path: ~/.pub-cache
- key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.yaml') }}
- restore-keys: |
- ${{ runner.os }}-pub-
- - name: Install dependencies
- run: flutter pub get --offline
- continue-on-error: true
- - name: Run dashboard tests
- run: echo "Dashboard tests skipped - external dependencies unavailable in CI"
diff --git a/.github/workflows/docusaurus-site.yaml b/.github/workflows/docusaurus-site.yaml
index 2d20a7c2..1ac60922 100644
--- a/.github/workflows/docusaurus-site.yaml
+++ b/.github/workflows/docusaurus-site.yaml
@@ -4,13 +4,18 @@ on:
push:
branches:
- master
+ - main
+ pull_request:
+ branches:
+ - master
+ - main
workflow_dispatch:
jobs:
build-docs:
runs-on: ubuntu-latest
permissions:
- contents: write
+ contents: read
defaults:
run:
working-directory: .site
@@ -28,11 +33,33 @@ jobs:
- name: Install dependencies
run: npm ci
+ - name: Type-check site configuration
+ run: npm run typecheck
+
- name: Build Docusaurus site
run: npm run build
+ - name: Store generated site
+ uses: actions/upload-artifact@v4
+ with:
+ name: docusaurus-site
+ path: .site/build
+ if-no-files-found: error
+
+ deploy-docs:
+ needs: build-docs
+ if: github.event_name != 'pull_request' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main')
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ steps:
+ - name: Download generated site
+ uses: actions/download-artifact@v4
+ with:
+ name: docusaurus-site
+ path: .site/build
+
- name: Deploy to GitHub Pages branch
- if: github.ref == 'refs/heads/master'
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml
index ab51f9fc..abd579ca 100644
--- a/.github/workflows/publish.yaml
+++ b/.github/workflows/publish.yaml
@@ -1,22 +1,41 @@
name: Publish
-# When a pull request is opened or updated the job will run the validation
-# steps (using firehose, this checks for correct pubspec versioning, changelog entries, etc.).
-# When you push a tag (for a package), firehose will attempt to publish that package.
+# Pull requests and release tags run Stem's repository-owned release gate.
+# Tags then continue through Dart's trusted publishing workflow.
on:
pull_request:
branches: [ master ]
types: [ opened, synchronize, reopened, labeled, unlabeled ]
push:
# For mono repos the tag must be prefixed with the package name,
- # e.g. "routed-v1.0.0". Adjust the regular expression as needed.
+ # e.g. "stem-v0.3.0". Every published package uses this form.
tags:
- - 'v[0-9]+.[0-9]+.[0-9]+'
- - '[A-z]+-v[0-9]+.[0-9]+.[0-9]+'
+ - '[A-Za-z0-9_]+-v[0-9]+.[0-9]+.[0-9]+'
workflow_dispatch:
jobs:
+ release-gate:
+ name: release gate
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ - uses: dart-lang/setup-dart@v1
+ with:
+ sdk: 3.10.0
+ - uses: subosito/flutter-action@v2
+ with:
+ channel: stable
+ cache: true
+ - name: Resolve workspace dependencies
+ run: dart pub get
+ - name: Validate the complete release train
+ run: dart run tool/publish.dart --skip-published --include-unchanged
+
publish:
+ needs: release-gate
+ if: startsWith(github.ref, 'refs/tags/') && needs.release-gate.result == 'success'
# This job uses the common publish workflow provided by the ecosystem.
# See https://github.com/dart-lang/ecosystem/wiki/Publishing-automation
uses: dart-lang/ecosystem/.github/workflows/publish.yaml@main
diff --git a/.github/workflows/stem.yaml b/.github/workflows/stem.yaml
deleted file mode 100644
index 976a65d6..00000000
--- a/.github/workflows/stem.yaml
+++ /dev/null
@@ -1,50 +0,0 @@
-name: stem
-
-on:
- push:
- branches:
- - master
- - main
- paths:
- - 'packages/stem/**'
- - 'packages/stem_adapter_tests/**'
- - 'pubspec.yaml'
- pull_request:
- paths:
- - 'packages/stem/**'
- - 'packages/stem_adapter_tests/**'
- - 'pubspec.yaml'
- workflow_dispatch:
-
-jobs:
- test:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- - name: Install system dependencies
- run: |
- sudo apt-get update
- sudo apt-get install -y sqlite3 libsqlite3-dev lcov
- - uses: ./.github/actions/setup-test-services
- - uses: subosito/flutter-action@v2
- with:
- channel: stable
- cache: true
- - name: Get Dart packages cache
- uses: actions/cache@v4
- with:
- path: ~/.pub-cache
- key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.yaml') }}
- restore-keys: |
- ${{ runner.os }}-pub-
- - name: Install dependencies
- run: flutter pub get
- - name: Run stem package tests
- working-directory: packages/stem
- run: dart test --exclude-tags soak --coverage=coverage
- - name: Format coverage
- working-directory: packages/stem
- run: dart run coverage:format_coverage --lcov --in=coverage --out=coverage/lcov.info --report-on=lib
- - name: Check coverage threshold
- working-directory: packages/stem
- run: dart run ../../tool/coverage/coverage_badge.dart --lcov coverage/lcov.info --out coverage/coverage.json --min 75
diff --git a/.github/workflows/stem_cli.yaml b/.github/workflows/stem_cli.yaml
deleted file mode 100644
index f018a09a..00000000
--- a/.github/workflows/stem_cli.yaml
+++ /dev/null
@@ -1,54 +0,0 @@
-name: stem_cli
-
-on:
- push:
- branches:
- - master
- - main
- paths:
- - 'packages/stem_cli/**'
- - 'packages/stem/**'
- - 'packages/stem_adapter_tests/**'
- - 'pubspec.yaml'
- pull_request:
- paths:
- - 'packages/stem_cli/**'
- - 'packages/stem/**'
- - 'packages/stem_adapter_tests/**'
- - 'pubspec.yaml'
- workflow_dispatch:
-
-jobs:
- test:
- runs-on: ubuntu-latest
- env:
- STEM_CLI_RUN_MULTI: 'false'
- steps:
- - uses: actions/checkout@v4
- - name: Install system dependencies
- run: |
- sudo apt-get update
- sudo apt-get install -y sqlite3 libsqlite3-dev lcov
- - uses: ./.github/actions/setup-test-services
- - uses: subosito/flutter-action@v2
- with:
- channel: stable
- cache: true
- - name: Get Dart packages cache
- uses: actions/cache@v4
- with:
- path: ~/.pub-cache
- key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.yaml') }}
- restore-keys: |
- ${{ runner.os }}-pub-
- - name: Install dependencies
- run: flutter pub get
- - name: Run stem_cli tests
- working-directory: packages/stem_cli
- run: dart test --coverage=coverage
- - name: Format coverage
- working-directory: packages/stem_cli
- run: dart run coverage:format_coverage --lcov --in=coverage --out=coverage/lcov.info --report-on=lib
- - name: Check coverage threshold
- working-directory: packages/stem_cli
- run: dart run ../../tool/coverage/coverage_badge.dart --lcov coverage/lcov.info --out coverage/coverage.json --min 50
diff --git a/.github/workflows/stem_flutter.yaml b/.github/workflows/stem_flutter.yaml
deleted file mode 100644
index 6f8b7c91..00000000
--- a/.github/workflows/stem_flutter.yaml
+++ /dev/null
@@ -1,42 +0,0 @@
-name: stem_flutter
-
-on:
- push:
- branches:
- - master
- - main
- paths:
- - 'packages/stem_flutter/**'
- - 'packages/stem/**'
- - 'pubspec.yaml'
- pull_request:
- paths:
- - 'packages/stem_flutter/**'
- - 'packages/stem/**'
- - 'pubspec.yaml'
- workflow_dispatch:
-
-jobs:
- test:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- - uses: subosito/flutter-action@v2
- with:
- channel: stable
- cache: true
- - name: Get Dart packages cache
- uses: actions/cache@v4
- with:
- path: ~/.pub-cache
- key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.yaml') }}
- restore-keys: |
- ${{ runner.os }}-pub-
- - name: Install dependencies
- run: flutter pub get
- - name: Analyze stem_flutter
- working-directory: packages/stem_flutter
- run: flutter analyze
- - name: Run stem_flutter tests
- working-directory: packages/stem_flutter
- run: flutter test
diff --git a/.github/workflows/stem_flutter_sqlite.yaml b/.github/workflows/stem_flutter_sqlite.yaml
deleted file mode 100644
index af3efb3e..00000000
--- a/.github/workflows/stem_flutter_sqlite.yaml
+++ /dev/null
@@ -1,50 +0,0 @@
-name: stem_flutter_sqlite
-
-on:
- push:
- branches:
- - master
- - main
- paths:
- - 'packages/stem_flutter_sqlite/**'
- - 'packages/stem_flutter/**'
- - 'packages/stem_sqlite/**'
- - 'packages/stem/**'
- - 'pubspec.yaml'
- pull_request:
- paths:
- - 'packages/stem_flutter_sqlite/**'
- - 'packages/stem_flutter/**'
- - 'packages/stem_sqlite/**'
- - 'packages/stem/**'
- - 'pubspec.yaml'
- workflow_dispatch:
-
-jobs:
- test:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- - name: Install system dependencies
- run: |
- sudo apt-get update
- sudo apt-get install -y sqlite3 libsqlite3-dev
- - uses: subosito/flutter-action@v2
- with:
- channel: stable
- cache: true
- - name: Get Dart packages cache
- uses: actions/cache@v4
- with:
- path: ~/.pub-cache
- key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.yaml') }}
- restore-keys: |
- ${{ runner.os }}-pub-
- - name: Install dependencies
- run: flutter pub get
- - name: Analyze stem_flutter_sqlite
- working-directory: packages/stem_flutter_sqlite
- run: flutter analyze
- - name: Run stem_flutter_sqlite tests
- working-directory: packages/stem_flutter_sqlite
- run: flutter test
diff --git a/.github/workflows/stem_memory.yaml b/.github/workflows/stem_memory.yaml
deleted file mode 100644
index 9ddc67e9..00000000
--- a/.github/workflows/stem_memory.yaml
+++ /dev/null
@@ -1,47 +0,0 @@
-name: stem_memory
-
-on:
- push:
- branches:
- - master
- - main
- paths:
- - 'packages/stem_memory/**'
- - 'packages/stem/**'
- - 'packages/stem_adapter_tests/**'
- - 'pubspec.yaml'
- pull_request:
- paths:
- - 'packages/stem_memory/**'
- - 'packages/stem/**'
- - 'packages/stem_adapter_tests/**'
- - 'pubspec.yaml'
- workflow_dispatch:
-
-jobs:
- test:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- - uses: subosito/flutter-action@v2
- with:
- channel: stable
- cache: true
- - name: Get Dart packages cache
- uses: actions/cache@v4
- with:
- path: ~/.pub-cache
- key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.yaml') }}
- restore-keys: |
- ${{ runner.os }}-pub-
- - name: Install dependencies
- run: flutter pub get
- - name: Run stem_memory tests
- working-directory: packages/stem_memory
- run: dart test --coverage=coverage --fail-fast
- - name: Format coverage
- working-directory: packages/stem_memory
- run: dart run coverage:format_coverage --lcov --in=coverage --out=coverage/lcov.info --report-on=lib
- - name: Check coverage threshold
- working-directory: packages/stem_memory
- run: dart run ../../tool/coverage/coverage_badge.dart --lcov coverage/lcov.info --out coverage/coverage.json --min 80
diff --git a/.github/workflows/stem_postgres.yaml b/.github/workflows/stem_postgres.yaml
deleted file mode 100644
index 505d464d..00000000
--- a/.github/workflows/stem_postgres.yaml
+++ /dev/null
@@ -1,52 +0,0 @@
-name: stem_postgres
-
-on:
- push:
- branches:
- - master
- - main
- paths:
- - 'packages/stem_postgres/**'
- - 'packages/stem/**'
- - 'packages/stem_adapter_tests/**'
- - 'pubspec.yaml'
- pull_request:
- paths:
- - 'packages/stem_postgres/**'
- - 'packages/stem/**'
- - 'packages/stem_adapter_tests/**'
- - 'pubspec.yaml'
- workflow_dispatch:
-
-jobs:
- test:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- - name: Install system dependencies
- run: |
- sudo apt-get update
- sudo apt-get install -y sqlite3 libsqlite3-dev lcov
- - uses: ./.github/actions/setup-test-services
- - uses: subosito/flutter-action@v2
- with:
- channel: stable
- cache: true
- - name: Get Dart packages cache
- uses: actions/cache@v4
- with:
- path: ~/.pub-cache
- key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.yaml') }}
- restore-keys: |
- ${{ runner.os }}-pub-
- - name: Install dependencies
- run: flutter pub get
- - name: Run stem_postgres tests
- working-directory: packages/stem_postgres
- run: dart test --coverage=coverage
- - name: Format coverage
- working-directory: packages/stem_postgres
- run: dart run coverage:format_coverage --lcov --in=coverage --out=coverage/lcov.info --report-on=lib
- - name: Check coverage threshold
- working-directory: packages/stem_postgres
- run: dart run ../../tool/coverage/coverage_badge.dart --lcov coverage/lcov.info --out coverage/coverage.json --min 45
diff --git a/.github/workflows/stem_redis.yaml b/.github/workflows/stem_redis.yaml
deleted file mode 100644
index 2a79a745..00000000
--- a/.github/workflows/stem_redis.yaml
+++ /dev/null
@@ -1,52 +0,0 @@
-name: stem_redis
-
-on:
- push:
- branches:
- - master
- - main
- paths:
- - 'packages/stem_redis/**'
- - 'packages/stem/**'
- - 'packages/stem_adapter_tests/**'
- - 'pubspec.yaml'
- pull_request:
- paths:
- - 'packages/stem_redis/**'
- - 'packages/stem/**'
- - 'packages/stem_adapter_tests/**'
- - 'pubspec.yaml'
- workflow_dispatch:
-
-jobs:
- test:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- - name: Install system dependencies
- run: |
- sudo apt-get update
- sudo apt-get install -y sqlite3 libsqlite3-dev lcov
- - uses: ./.github/actions/setup-test-services
- - uses: subosito/flutter-action@v2
- with:
- channel: stable
- cache: true
- - name: Get Dart packages cache
- uses: actions/cache@v4
- with:
- path: ~/.pub-cache
- key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.yaml') }}
- restore-keys: |
- ${{ runner.os }}-pub-
- - name: Install dependencies
- run: flutter pub get
- - name: Run stem_redis tests
- working-directory: packages/stem_redis
- run: dart test --coverage=coverage
- - name: Format coverage
- working-directory: packages/stem_redis
- run: dart run coverage:format_coverage --lcov --in=coverage --out=coverage/lcov.info --report-on=lib
- - name: Check coverage threshold
- working-directory: packages/stem_redis
- run: dart run ../../tool/coverage/coverage_badge.dart --lcov coverage/lcov.info --out coverage/coverage.json --min 75
diff --git a/.github/workflows/stem_sqlite.yaml b/.github/workflows/stem_sqlite.yaml
deleted file mode 100644
index 4c20e39c..00000000
--- a/.github/workflows/stem_sqlite.yaml
+++ /dev/null
@@ -1,52 +0,0 @@
-name: stem_sqlite
-
-on:
- push:
- branches:
- - master
- - main
- paths:
- - 'packages/stem_sqlite/**'
- - 'packages/stem/**'
- - 'packages/stem_adapter_tests/**'
- - 'pubspec.yaml'
- pull_request:
- paths:
- - 'packages/stem_sqlite/**'
- - 'packages/stem/**'
- - 'packages/stem_adapter_tests/**'
- - 'pubspec.yaml'
- workflow_dispatch:
-
-jobs:
- test:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- - name: Install system dependencies
- run: |
- sudo apt-get update
- sudo apt-get install -y sqlite3 libsqlite3-dev lcov
- - uses: ./.github/actions/setup-test-services
- - uses: subosito/flutter-action@v2
- with:
- channel: stable
- cache: true
- - name: Get Dart packages cache
- uses: actions/cache@v4
- with:
- path: ~/.pub-cache
- key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.yaml') }}
- restore-keys: |
- ${{ runner.os }}-pub-
- - name: Install dependencies
- run: flutter pub get
- - name: Run stem_sqlite tests
- working-directory: packages/stem_sqlite
- run: dart test --coverage=coverage
- - name: Format coverage
- working-directory: packages/stem_sqlite
- run: dart run coverage:format_coverage --lcov --in=coverage --out=coverage/lcov.info --report-on=lib
- - name: Check coverage threshold
- working-directory: packages/stem_sqlite
- run: dart run ../../tool/coverage/coverage_badge.dart --lcov coverage/lcov.info --out coverage/coverage.json --min 50
diff --git a/.gitignore b/.gitignore
index d7c1f300..cedf6d84 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,8 +5,12 @@ pubspec.lock
build/
**/.flutter-plugins-dependencies
textwrap/
-examples/microservice/certs/
-examples/security/*/certs/
+packages/stem/example/microservice/certs/
+packages/stem/example/postgres_tls/certs/
+packages/stem/example/security/*/certs/
+packages/stem_cli/docker/testing/postgres/certs/*.crt
+packages/stem_cli/docker/testing/postgres/certs/*.key
+packages/stem_cli/docker/testing/postgres/certs/*.srl
packages/dashboard/storage/
.beads/
mcp_agent_mail/
diff --git a/.site/docs/about/index.md b/.site/docs/about/index.md
new file mode 100644
index 00000000..f2074919
--- /dev/null
+++ b/.site/docs/about/index.md
@@ -0,0 +1,16 @@
+---
+title: About Stem
+slug: /about
+sidebar_position: 0
+---
+
+Stem is an experimental Dart-native background work platform. This section
+explains the project's current scope and the work that is deliberately being
+prioritised before new platform features.
+
+- **[Roadmap](./roadmap.md)** – current hardening work, proven capabilities and
+ deliberately deferred features.
+
+Stem is not presented as production-ready. Production use requires validating
+the selected broker, backend, failure semantics and operational procedures for
+the workload being deployed.
diff --git a/.site/docs/about/roadmap.md b/.site/docs/about/roadmap.md
new file mode 100644
index 00000000..79e83eb0
--- /dev/null
+++ b/.site/docs/about/roadmap.md
@@ -0,0 +1,90 @@
+---
+title: Roadmap
+sidebar_label: Roadmap
+slug: /about/roadmap
+sidebar_position: 1
+---
+
+## Direction
+
+The next release cycle is a hardening cycle. Stem already has a broad set of
+queue, worker, scheduler, workflow and adapter features; the priority is to
+narrow the public surface, make release artifacts reproducible and prove
+failure behaviour before expanding the platform.
+
+## Delivered foundations
+
+These capabilities exist in the repository and are covered by package tests,
+adapter contract tests or integration tests as applicable:
+
+- Typed task definitions, DTO codecs and generated task/workflow definitions.
+- Redis, Postgres, SQLite and in-memory broker/backend implementations.
+- Explicit worker lifecycle, isolate execution and documented timeout modes.
+- Retries, leases, acknowledgements, uniqueness coordination and revocation.
+- Durable workflow checkpoints, suspension/resumption and external events.
+- Scheduler time zones, jitter, persistence, leases and CLI control commands.
+- OpenTelemetry context propagation and task/result instrumentation.
+- Redis/Postgres distributed rate-limiter implementations and a Postgres
+ transactional outbox integration.
+- Adapter contract suites, standalone package staging and workspace example
+ checks in CI.
+- Atomic terminal-result arbitration in the built-in result backends, with
+ worker tests covering lease-loss redelivery and late cross-worker
+ completion.
+- SQLite workflow persistence now has a restart-recovery integration test that
+ reopens the store and resumes a checkpoint with replacement runtime and
+ broker instances.
+- SQLite and Postgres migration suites now cover current adapters reading
+ legacy-shaped queue or lock records after upgrade.
+- Core in-memory and SQLite file-backed throughput benchmarks now have checked
+ minimum baselines and scheduled CI regression gates.
+
+The existence of a feature does not mean that every adapter offers identical
+guarantees. Read the broker and backend caveats before choosing a deployment.
+
+## Current hardening
+
+Work in this phase is focused on evidence and boundaries:
+
+1. Exercise crash, lease-loss, duplicate-delivery, retry-storm and shutdown
+ interleavings with failure-injection and soak tests.
+2. Test schema upgrades, mixed-version workers and standalone package
+ resolution without workspace dependency overrides.
+3. Stabilise the public entrypoints and keep low-level compatibility APIs out
+ of the recommended onboarding path.
+4. Publish reproducible benchmark baselines for queue throughput, SQLite
+ contention, workflow checkpoints and adapter recovery.
+5. Document adapter-specific delivery, lease, delay, priority and recovery
+ guarantees.
+
+## Deliberately deferred
+
+The following are not release priorities while the reliability work is in
+progress:
+
+- Compensation/saga primitives for workflows.
+- Additional broker integrations.
+- A broader dashboard product; the current dashboard remains experimental.
+- Exactly-once execution claims for external side effects.
+- More Canvas policies or composition features beyond the semantics already
+ implemented.
+
+## Contribution gate
+
+Before proposing a release-facing change, run the package checks relevant to
+the change, including:
+
+```sh
+dart run tool/check_examples.dart --skip-diff
+dart run tool/publish.dart --plan
+```
+
+For package-level work, also run `dart format`, `dart analyze --fatal-infos`
+and the package test suite. Changes to generated definitions must leave the
+working tree clean after generation.
+
+## Status language
+
+Stem should be described as experimental until the project has accumulated
+long-running fault-test evidence, compatibility guarantees, migration stories
+and recovery reports for the supported adapters.
diff --git a/.site/docs/about/testing.md b/.site/docs/about/testing.md
new file mode 100644
index 00000000..c03f4e26
--- /dev/null
+++ b/.site/docs/about/testing.md
@@ -0,0 +1,71 @@
+---
+title: Testing and Quality Gates
+sidebar_label: Testing
+sidebar_position: 2
+slug: /about/testing
+---
+
+Stem treats tests as evidence for runtime guarantees, not as a substitute for
+adapter-specific operational validation. Run the checks relevant to the
+package and behavior you changed.
+
+## Core development loop
+
+From the repository root:
+
+```sh
+dart pub get
+dart run tool/check_examples.dart --skip-diff
+```
+
+Then run the core package gates:
+
+```sh
+cd packages/stem
+dart format lib test --set-exit-if-changed
+dart analyze --fatal-infos
+dart test --exclude-tags soak --fail-fast
+```
+
+Examples are part of the user-facing API. The example checker discovers every
+example under the root workspace packages, resolves its dependencies, runs
+code generation where needed and analyzes the result.
+
+## Package and adapter validation
+
+Run `dart format`, `dart analyze --fatal-infos` and the test suite for every
+package affected by a change. Adapter changes should include the shared
+contract tests and, when infrastructure is available, the real Redis or
+Postgres integration tests.
+
+The adapters do not all provide identical delivery, lease, delay or priority
+guarantees. Read the broker caveats and test the selected deployment rather
+than treating an in-memory pass as proof of distributed recovery.
+
+## Reliability tests
+
+Soak tests are tagged and excluded from the normal fast suite:
+
+```sh
+cd packages/stem
+dart test --tags soak
+```
+
+Failure-oriented tests should cover duplicate delivery, acknowledgement
+failure, lease loss, retry storms, worker shutdown, checkpoint recovery and
+broker restart. Changes to persistence or scheduling should also run migration
+upgrade and lease/fencing tests.
+
+## Release gate
+
+The release planner derives the package graph from workspace manifests and
+checks package metadata, generated output, standalone resolution and publish
+archives:
+
+```sh
+dart run tool/publish.dart --plan
+```
+
+A real release requires a clean tree and the exact commit used to produce the
+artifacts. The aggregate CI workflow repeats package checks on Linux, Windows
+and macOS, tests standalone source staging, and checks workspace examples.
diff --git a/.site/docs/brokers/caveats.md b/.site/docs/brokers/caveats.md
index d2cf17cb..36d8b51c 100644
--- a/.site/docs/brokers/caveats.md
+++ b/.site/docs/brokers/caveats.md
@@ -9,6 +9,72 @@ This page highlights broker-specific constraints that affect routing, priorities
and control-plane behavior. These caveats are based on the adapter
implementations.
+## Capability interfaces
+
+The broad `Broker` contract remains the compatibility surface for existing
+adapters. New integrations should expose only the optional capabilities they
+actually implement:
+
+- `QueueBroker` — publish, consume, acknowledge, negative acknowledge, and
+ close.
+- `LeaseBroker` — extend an active delivery lease.
+- `InspectableBroker` — pending and in-flight queue counts.
+- `DeadLetterBroker` — dead-letter, list, retrieve, replay, and purge dead
+ letters.
+
+`BrokerCapabilities` defaults optional behavior to `false`. Built-in adapters
+explicitly declare lease extension and dead-letter support. A queue-only
+adapter therefore remains valid: terminal failures are persisted in the
+result backend and discarded without requeueing when no dead-letter store is
+available. Built-in adapters advertise `atLeastOnce`; applications must still
+make external side effects idempotent because a crash after side-effect
+completion and before acknowledgement can produce a duplicate delivery.
+
+Built-in adapters declare these interfaces in addition to `Broker`. A new
+queue-only adapter can implement `QueueBroker` without implementing the
+optional operations; consumers that need one can check its capability
+interface or use the adapter's `BrokerCapabilities` snapshot instead of
+assuming every transport has identical operational semantics. The historical
+`Broker` facade supplies compatibility defaults that throw
+`UnsupportedError` for operations an adapter does not support.
+
+## Delivery and recovery matrix
+
+The lease values below are configuration defaults. Actual recovery time also
+includes the adapter's poll, claim, or sweeper interval and any broker/network
+latency.
+
+| Adapter | Lease creation | Expired-delivery recovery | Delivery guarantee |
+| --- | --- | --- | --- |
+| In-memory | `defaultVisibilityTimeout` | In-process claim timer (`claimInterval`) | At least once while the process is alive |
+| SQLite | `defaultVisibilityTimeout` and row lock | Expiry sweeper (`sweeperInterval`) plus polling (`pollInterval`) | At least once for durable queue rows |
+| Redis Streams | `defaultVisibilityTimeout` and consumer-group pending entry | `XAUTOCLAIM` on `claimInterval` | At least once while Redis data is retained |
+| Postgres | `defaultVisibilityTimeout` and `locked_until` | Expiry sweeper (`sweeperInterval`) plus polling (`pollInterval`) | At least once for committed queue rows |
+
+An automatic lease renewal is scheduled at roughly half of the remaining
+lease. Short leases use that shorter interval even when the normal minimum is
+one second. A renewal failure is transient from the worker's perspective:
+later attempts continue, but a lease that remains expired may be delivered to
+another worker. Set the visibility timeout long enough for the handler's
+normal execution, terminal result handling, and broker round-trip time;
+renewal starts when a delivery enters the worker and remains active through
+acknowledgement. This also covers slow consume middleware and pre-execution
+validation. Renewal is a safety mechanism, not a substitute for idempotent
+side effects.
+
+The worker suppresses a duplicate envelope that arrives while its original
+delivery is active in that same process. A process crash, or the same task
+running on different workers, remains at-least-once behavior and must be
+handled with idempotent application effects.
+
+Built-in result backends also expose `AtomicTerminalResultBackend`. Worker
+terminal writes use it to ensure that a concurrent late completion cannot
+replace an already persisted terminal state. This arbitration protects the
+stored task result and worker-owned terminal side effects; it does not make
+external HTTP calls, emails, or other application side effects exactly once.
+Custom backends may omit the capability, in which case terminal persistence
+uses the legacy unconditional `ResultBackend.set` path.
+
## In-memory broker
- **No priority buckets**: `supportsPriority` is false, so priorities are not
@@ -51,8 +117,8 @@ implementations.
- **Single-queue consumption**: only one queue can be consumed per subscription.
- **Polling-based delivery**: workers poll for due jobs on an interval.
- **Visibility timeouts**: tasks are locked with a `locked_until` lease; if a
- worker dies or stops heartbeating, jobs become visible again after the lease
- expires.
+ worker dies or stops renewing its lease, jobs become visible again after the
+ lease expires.
- **Dead letter retention**: dead letters are retained for a default window
(7 days) unless configured otherwise.
- **Broadcast channels**: broadcasts are stored in a separate table and read
@@ -71,8 +137,12 @@ implementations.
requeues). If a worker stops without acking a delivery, the task becomes
visible again after the visibility lease expires (Redis reclaim interval /
Postgres `locked_until`).
-- **Long-running tasks** should emit heartbeats or extend leases so the broker
- does not re-deliver them mid-execution.
+- **Long-running tasks** should emit heartbeats for liveness and rely on the
+ worker's automatic renewal or call `context.extendLease(...)` when they need
+ explicit lease time. A heartbeat alone does not extend the broker lease.
+- **Renewal failures** are exposed through `stem.lease.renewal_failed` and
+ structured worker logs. Renewal attempts continue after a transient failure;
+ a lease that remains lost can still produce an at-least-once redelivery.
## Tips
diff --git a/.site/docs/brokers/sqlite.md b/.site/docs/brokers/sqlite.md
index 48aa5ebd..99289fe6 100644
--- a/.site/docs/brokers/sqlite.md
+++ b/.site/docs/brokers/sqlite.md
@@ -26,7 +26,7 @@ Add the adapter package:
```yaml
dependencies:
- stem_sqlite: ^0.1.1
+ stem_sqlite: ^0.2.0
```
## Quick start (broker)
@@ -80,6 +80,12 @@ contention:
- **Keep producers off the backend** (let workers be the only writers).
- **Do not share a single SQLite file** between broker and backend.
+Stem serializes its own transactional broker/backend mutations when multiple
+handles point at the same file, which prevents savepoint corruption and
+in-process writer races. This coordination does not cover unrelated processes
+or tools opening the file, and it cannot turn SQLite into a multi-host queue.
+Use separate files when throughput matters.
+
A simple layout:
```
@@ -116,6 +122,9 @@ SQLite brokers are intentionally minimal:
- **Single-queue subscriptions only** (one queue per worker subscription).
- **Polling-based delivery** (latency depends on `pollInterval`).
- **Single-writer constraint** (plan your processes and DB files accordingly).
+- Stem coordinates in-process writes across its broker, backend, workflow, and
+ control handles, but external writers and separate processes still need
+ SQLite-compatible locking discipline.
If you need cross-process broadcast control, multi-queue consumption, or
multi-host scaling,
diff --git a/.site/docs/core-concepts/canvas.md b/.site/docs/core-concepts/canvas.md
index 18573185..8088fb58 100644
--- a/.site/docs/core-concepts/canvas.md
+++ b/.site/docs/core-concepts/canvas.md
@@ -3,15 +3,16 @@ title: Canvas Patterns
sidebar_label: Canvas
sidebar_position: 5
slug: /core-concepts/canvas
+description: Compose task work with typed chains, groups, batches and chords.
---
This guide walks through Stem's task composition primitives—chains, groups, and
chords—using in-memory brokers and backends. Each snippet references a runnable
file under `packages/stem/example/docs_snippets/` so you can experiment locally
with `dart run`. If you bootstrap with `StemApp`, use `app.canvas` to reuse the
-same broker, backend, task handlers, and encoder registry. `StemApp` lazy-starts
-its managed worker for canvas dispatch too, so the common path does not need an
-explicit `await app.start()`.
+same broker, backend, task handlers, and encoder registry. Start the worker
+explicitly in processes that should consume work; constructing a canvas or
+inspecting its state never starts execution as a side effect.
## Chains
@@ -26,6 +27,33 @@ Chains execute tasks serially. Each step receives the previous result via
If any step fails, the chain stops immediately. Retry by invoking `canvas.chain`
again with the same signatures.
+For heterogeneous transitions, use the typed fluent API. Each `then` accepts a
+`TaskDefinition` whose argument type must match the previous result type, and
+the final result is decoded into the last task's result type:
+
+```dart
+final download = TaskDefinition.codec(
+ name: 'download',
+ argsCodec: downloadRequestCodec,
+ resultCodec: downloadResultCodec,
+);
+final resize = TaskDefinition.codec(
+ name: 'resize',
+ argsCodec: downloadResultCodec,
+ resultCodec: resizeResultCodec,
+);
+
+final result = await canvas
+ .typedChain(download, request)
+ .then(resize)
+ .run();
+```
+
+The compiler rejects a `then` whose argument type does not accept the previous
+task's result. The existing homogeneous `Canvas.chain` remains available for
+raw signatures and migration compatibility; it transports the previous result
+through `chainPrevResult`.
+
## Groups
Groups fan out work and persist each branch in the result backend.
@@ -49,8 +77,9 @@ state:
## Chords
-Chords combine a group with a callback. Once all body tasks succeed, the
-callback runs with `context.meta['chordResults']` populated. Prefer
+Chords combine a group with a callback. By default, once all body tasks
+succeed, the callback runs with `context.meta['chordResults']` populated.
+Prefer
`context.meta.valueListOr('chordResults', const [])` over manual list casts
when reading those results.
@@ -58,10 +87,27 @@ when reading those results.
```
-If any branch fails, the callback is skipped and the chord group is marked as
-failed. Inspect the latest group status via `StemApp.getGroupStatus(...)` or
-`StemClient.getGroupStatus(...)` before retrying. If you are operating below
-the runtime layer, read the raw backend directly.
+The default `ChordPolicy.allOrFail()` skips the callback when any branch fails.
+Use an explicit policy when a callback should receive terminal failures:
+
+```dart
+await canvas.chord(
+ body: body,
+ callback: summarize,
+ policy: const ChordPolicy.collectTerminalResults(),
+);
+```
+
+`ChordPolicy.collectTerminalResults()` waits for every body task and passes
+`null` in `chordResults` for failed or cancelled branches. The callback also
+receives failure summaries in `context.meta['stem.chord.failures']`.
+`ChordPolicy.allowPartial(minSuccessful: 2)` has the same terminal-result
+behavior but dispatches only when the required number of body tasks succeeded.
+If the policy cannot be satisfied, the callback is skipped and the chord
+operation fails. Inspect the latest group status via
+`StemApp.getGroupStatus(...)` or `StemClient.getGroupStatus(...)` before
+retrying. If you are operating below the runtime layer, read the raw backend
+directly.
## Dependency semantics
@@ -69,8 +115,8 @@ the runtime layer, read the raw backend directly.
the previous one succeeds.
- **Groups** model fan-out dependencies: a group is “complete” once all child
tasks finish. The expected count is stored in the backend.
-- **Chords** combine both: a callback depends on the entire group finishing
- successfully.
+- **Chords** combine both: a callback depends on the entire group reaching a
+ terminal state and the configured `ChordPolicy` being satisfied.
## Child result retrieval
diff --git a/.site/docs/core-concepts/cli-control.md b/.site/docs/core-concepts/cli-control.md
index 4084d11b..ec2cb4d2 100644
--- a/.site/docs/core-concepts/cli-control.md
+++ b/.site/docs/core-concepts/cli-control.md
@@ -194,6 +194,7 @@ stem schedule apply \
--yes
stem schedule list
+stem schedule trigger cleanup
stem schedule dry-run --spec "every:5m"
```
@@ -205,8 +206,10 @@ Run Beat from a Dart entrypoint wired to your schedule store:
Requirements:
-- `stem schedule apply/list/dry-run` use `STEM_SCHEDULE_STORE_URL` when set,
+- `stem schedule apply/list/trigger/dry-run` use `STEM_SCHEDULE_STORE_URL` when set,
otherwise they operate on local schedule files.
+- `schedule trigger` also requires `STEM_BROKER_URL` and publishes one task
+ without changing the recurring entry's next-run or execution counters.
- Beat needs a broker, schedule store, and (for HA) a lock store.
Expected output (schedule list):
@@ -245,6 +248,7 @@ Use this table to sanity-check which connection strings are required:
| `stem worker shutdown` | ✅ | ❌ | ❌ | ❌ | ❌ |
| `stem worker pause/resume` | ✅ | ❌ | ❌ | optional | ❌ |
| `stem schedule apply/list/dry-run` | ❌ | ❌ | ✅ | ❌ | ❌ |
+| `stem schedule trigger` | ✅ | optional | ✅ | ❌ | ❌ |
| `stem health` | ✅ | optional | ❌ | ❌ | ❌ |
Notes:
diff --git a/.site/docs/core-concepts/observability.md b/.site/docs/core-concepts/observability.md
index 2a590e95..5d55ed42 100644
--- a/.site/docs/core-concepts/observability.md
+++ b/.site/docs/core-concepts/observability.md
@@ -49,6 +49,11 @@ Traces include spans for `stem.enqueue`, `stem.consume`, and task execution.
Use attributes (`stem.task`, `stem.queue`, `stem.retry.attempt`) to filter in
your tracing backend.
+Canvas fan-out also emits a `stem.canvas.group` composition span. Each group
+body's `stem.consume` span links back to that composition span while retaining
+its normal task trace parent, so groups and chords remain understandable in
+backends that support OpenTelemetry span links.
+
## Signals
`StemSignals` fire lifecycle hooks for tasks, workers, scheduler events, and
@@ -82,13 +87,16 @@ your own tracing/logging systems.
class LoggingWorkflowIntrospectionSink implements WorkflowIntrospectionSink {
@override
Future recordStepEvent(WorkflowStepEvent event) async {
- stemLogger.info('workflow.execution', {
- 'run': event.runId,
- 'workflow': event.workflow,
- 'step': event.stepId,
- 'type': event.type.name,
- 'iteration': event.iteration,
- });
+ stemLogger.info(
+ 'workflow.execution',
+ fields: {
+ 'run': event.runId,
+ 'workflow': event.workflow,
+ 'step': event.stepId,
+ 'type': event.type.name,
+ 'iteration': event.iteration,
+ },
+ );
}
}
```
@@ -114,14 +122,16 @@ Persisted worker heartbeats expose the same typed decode path on `extras` via
## Logging
-Use `stemLogger` (Contextual logger) for structured logs.
+Import `package:stem/observability.dart` when you need Stem's structured
+logging facade. It accepts Stem-owned severity and field types; the underlying
+logging dependency is kept out of the public API.
```dart file=/../packages/stem/example/docs_snippets/lib/observability.dart#observability-logging
```
The shared `stemLogger` starts silent by default, so opt in explicitly with
-`configureStemLogging(level: Level.info, format: StemLogFormat.pretty)`.
+`configureStemLogging(level: StemLogLevel.info, format: StemLogFormat.pretty)`.
When you want machine-oriented output for production log shipping, switch to
`configureStemLogging(format: StemLogFormat.plain)`.
diff --git a/.site/docs/core-concepts/producer.md b/.site/docs/core-concepts/producer.md
index 168b318c..9ca2d2fb 100644
--- a/.site/docs/core-concepts/producer.md
+++ b/.site/docs/core-concepts/producer.md
@@ -69,6 +69,26 @@ If you later inspect the raw `Envelope`, prefer `envelope.argsJson(...)`,
`envelope.argsVersionedJson(...)`, `envelope.metaJson(...)`, or
`envelope.metaVersionedJson(...)` over manual map casts.
+## Split process roles
+
+When a process only publishes or observes work, depend on the narrow role
+interfaces rather than passing around a full `StemApp`:
+
+```dart
+Future publish(StemProducer producer) async {
+ await producer.enqueue('tasks.email', args: {'to': 'ops@example.com'});
+}
+
+Future inspect(StemObserver observer, String taskId) {
+ return observer.getTaskStatus(taskId);
+}
+```
+
+`StemWorkerHost` is only exposed by managed application wrappers and owns the
+explicit `start()`/`shutdown()` lifecycle. A producer or observer reference
+does not provide worker lifecycle methods, which keeps API and dashboard
+processes from accidentally becoming consumers.
+
## Enqueue options
Use `TaskEnqueueOptions` to override scheduling, routing, retry behavior, and
diff --git a/.site/docs/core-concepts/rate-limiting.md b/.site/docs/core-concepts/rate-limiting.md
index e3b29fea..aadcd44a 100644
--- a/.site/docs/core-concepts/rate-limiting.md
+++ b/.site/docs/core-concepts/rate-limiting.md
@@ -120,19 +120,30 @@ Run the `rate_limit_delay` example for a full demo:
- `packages/stem/example/rate_limit_delay`
-## Rate limit syntax
+## Rate limit values
-`rateLimit` accepts short strings like:
+In Dart code, use the typed `RateLimit` value object:
+
+```dart
+const TaskOptions(
+ rateLimit: RateLimit.perMinute(100),
+ groupRateLimit: RateLimit.perSecond(5),
+)
+```
+
+String values remain supported at JSON/YAML and environment-configuration
+boundaries:
- `10/s` — 10 tokens per second
- `100/m` — 100 tokens per minute
- `500/h` — 500 tokens per hour
-`groupRateLimit` uses the same syntax.
+`groupRateLimit` uses the same syntax. The worker receives a validated
+`RateLimit` value rather than parsing strings during task execution.
## How it works
-- The worker parses `rateLimit` for each task.
+- The worker asks the configured limiter to acquire the typed `rateLimit`.
- The worker asks the `RateLimiter` for an acquire decision.
- If denied, the task is retried with backoff and `rateLimited=true` metadata.
- Retry delays come from the limiter `retryAfter` if provided, otherwise the
@@ -157,18 +168,13 @@ Group rate limits share a limiter bucket across related tasks.
## Redis-backed limiter example
-The `packages/stem/example/rate_limit_delay` demo ships a Redis fixed-window limiter. It:
+The `packages/stem/example/rate_limit_delay` demo uses the shipped Redis
+token-bucket limiter. It:
- shares tokens across multiple workers,
-- logs when a token is granted or denied,
+- uses Redis server time and an atomic Lua refill/acquire operation,
- reschedules denied tasks with retry metadata.
-Inspect it here:
-
-```dart title="lib/rate_limiter.dart" file=/../packages/stem/example/rate_limit_delay/lib/rate_limiter.dart#rate-limit-redis-limiter
-
-```
-
## Observability
When a task is rate limited:
@@ -194,9 +200,10 @@ The `rate_limit_delay` example reads `STEM_RATE_LIMIT_URL` to point the limiter
at Redis. Use a dedicated Redis DB or key prefix to keep limiter state isolated
from your broker/result backend.
-```dart title="lib/shared.dart" file=/../packages/stem/example/rate_limit_delay/lib/shared.dart#rate-limit-redis-connector
-
-```
+The Redis limiter is constructed with `RedisRateLimiter.connect(...)` from
+`stem_redis`. `stem_postgres` provides the equivalent
+`PostgresRateLimiter.connect(...)`; it uses a server-clock token bucket with a
+row lock inside one transaction.
## Tips
diff --git a/.site/docs/core-concepts/stem-builder.md b/.site/docs/core-concepts/stem-builder.md
index f745e275..541ee647 100644
--- a/.site/docs/core-concepts/stem-builder.md
+++ b/.site/docs/core-concepts/stem-builder.md
@@ -194,8 +194,9 @@ final taskApp = await client.createApp();
```
Plain `StemApp` bootstrap infers task queue subscriptions from the bundled or
-explicitly supplied task handlers when `workerConfig.subscription` is omitted,
-and it lazy-starts on the first enqueue or wait call.
+explicitly supplied task handlers when `workerConfig.subscription` is omitted.
+Worker startup is always an explicit lifecycle operation: call `start()` in the
+process that is intended to consume work.
If you already centralize broker/backend wiring in a `StemClient`, stay on the
shared-client path:
diff --git a/.site/docs/core-concepts/tasks.md b/.site/docs/core-concepts/tasks.md
index b97a0332..9ff80328 100644
--- a/.site/docs/core-concepts/tasks.md
+++ b/.site/docs/core-concepts/tasks.md
@@ -149,7 +149,9 @@ every retry signal and shows how the strategy interacts with broker timings.
`TaskContext` provides metadata and control helpers:
- `context.attempt` – current attempt number (0-based).
-- `context.heartbeat()` – extend the lease to avoid timeouts.
+- `context.heartbeat()` – emit a liveness signal for monitoring. Automatic
+ lease renewal is handled by the worker; use `context.extendLease(...)` when
+ task code needs an explicit lease extension.
- `context.extendLease(Duration by)` – request additional processing time.
- `context.progress(percent, data: {...})` – emit progress signals for UI hooks.
- `context.progressJson(percent, dto)` – emit DTO progress payloads without
@@ -245,8 +247,21 @@ Set soft/hard timeouts to guard against runaway tasks:
- **Soft timeouts** trigger `WorkerEventType.timeout` so you can log or notify.
- **Hard timeouts** raise `TimeoutException` to force retries or failure.
-- Provide an `isolateEntrypoint` to run the task in a dedicated isolate when
- enforcing hard limits or dealing with CPU-intensive code.
+- Task handlers declare `TaskExecutionMode.inline` or
+ `TaskExecutionMode.isolate`. Isolate mode requires a top-level
+ `isolateEntrypoint`; generated handlers set both values for you.
+- Use isolate mode for CPU-intensive code or when enforcing a hard limit. Use
+ inline mode for handlers that need coordinator-isolate state and can observe
+ cooperative cancellation.
+
+Timeout guarantees depend on the execution mode:
+
+- For isolate-backed handlers, a hard timeout can terminate the execution
+ isolate, so the timed-out handler does not continue running there.
+- For inline handlers, a hard timeout stops the worker from awaiting the
+ result, but Dart cannot forcibly cancel an arbitrary `Future`; the handler
+ may continue until it returns. Use `context.cancellation` and cooperative
+ checkpoints for inline cancellation.
## Idempotency Checklist
diff --git a/.site/docs/getting-started/first-steps.md b/.site/docs/getting-started/first-steps.md
index 8749265a..1c57f20c 100644
--- a/.site/docs/getting-started/first-steps.md
+++ b/.site/docs/getting-started/first-steps.md
@@ -19,9 +19,8 @@ Create a task handler (StemApp will register it for you):
## 2. Bootstrap the in-memory runtime
-Use `StemApp` to create the broker, backend, and worker in memory. The worker
-lazy-starts on the first enqueue or wait call, so the common path does not need
-an explicit `await app.start()`:
+Use the in-memory shortcut to create the broker, backend, and worker. It does
+not start the worker implicitly; start it explicitly before enqueueing work:
```dart file=/../packages/stem/example/docs_snippets/lib/first_steps.dart#first-steps-bootstrap
diff --git a/.site/docs/getting-started/intro.md b/.site/docs/getting-started/intro.md
index 3d6693a9..0efe52bf 100644
--- a/.site/docs/getting-started/intro.md
+++ b/.site/docs/getting-started/intro.md
@@ -10,10 +10,10 @@ aliases:
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
-Stem is a Dart-native background work platform that gives you Celery‑level
-capabilities without leaving the Dart ecosystem. This onboarding path assumes
-you have never touched Stem before and walks you from “what is this?” to “I can
-ship a production deployment.”
+Stem is an experimental Dart-native background work platform with Celery-like
+building blocks for queues, workers, and durable workflows. This onboarding
+path assumes you have never touched Stem before and walks you from “what is
+this?” to “I can evaluate a production-shaped deployment.”
## What is a task queue?
diff --git a/.site/docs/getting-started/production-checklist.md b/.site/docs/getting-started/production-checklist.md
index 1709d891..9b2f8758 100644
--- a/.site/docs/getting-started/production-checklist.md
+++ b/.site/docs/getting-started/production-checklist.md
@@ -139,7 +139,8 @@ stem worker diagnose --node web-1 \
Before every deployment run through these guardrails:
-- **Quality gates** – run `packages/stem/example/quality_gates` (`just quality`) to execute
+- **Quality gates** – run the aggregate workflow or execute `dart format`,
+ `dart analyze --fatal-infos`, and the package test suites locally to execute
format, analyze, unit/chaos/perf tests, and coverage targets.
- **Observability** – confirm Grafana dashboards (task success rate, latency
p95, queue depth) and OpenTelemetry exporters are healthy.
diff --git a/.site/docs/getting-started/reliability.md b/.site/docs/getting-started/reliability.md
index e60bec80..ed34f50a 100644
--- a/.site/docs/getting-started/reliability.md
+++ b/.site/docs/getting-started/reliability.md
@@ -20,14 +20,68 @@ import TabItem from '@theme/TabItem';
## Broker fetch notes
- **Redis Streams** uses consumer groups plus `XAUTOCLAIM` to reclaim idle
- deliveries; long-running tasks should emit heartbeats or extend leases.
+ deliveries; long-running tasks should rely on automatic renewal or explicitly
+ extend their leases.
- **Postgres** uses polling with `locked_until` leases; tasks become visible
again after the lease expires.
+Worker lease renewal is isolated from the task loop. A failed automatic renewal
+is contained, recorded as the `stem.lease.renewal_failed` metric, and logged with
+the task, queue, and broker receipt; a shorter retry cadence gives transient
+broker failures another chance before expiry. If the lease is ultimately lost,
+the broker may redeliver the task. Stem persists the terminal result before
+acknowledging, so a redelivery can observe terminal state instead of executing
+the handler a second time.
+
+The same lease remains active through terminal result persistence,
+group/chord bookkeeping, retry or dead-letter publication, linked-task
+dispatch, and acknowledgement. A slow backend or broker round trip during
+that final handling window therefore gets the same renewal protection as the
+handler itself.
+
+Lease protection starts as soon as the delivery enters the worker, so slow
+consume middleware, signature validation, backend lookups, and rate-limit
+decisions are covered before handler execution begins.
+
+If the same envelope is redelivered while its first delivery is still active
+in the same worker, the duplicate is acknowledged without invoking the
+handler again. This is a worker-local guard; recovery after a process crash
+still follows Stem's at-least-once delivery model and requires idempotent
+external side effects.
+
+Hard shutdown requeues active deliveries, including deliveries already held in
+the broker prefetch window. A replacement worker can therefore drain the full
+batch; applications should still expect at-least-once execution for any
+handler that was already running when the process stopped.
+
+When two different workers complete the same task concurrently, built-in
+result backends use `AtomicTerminalResultBackend` to arbitrate the terminal
+state. Only the worker that wins that atomic write performs terminal group,
+chord, linked-task, signal, and unique-lock side effects. This prevents a late
+success or failure record from replacing the result that already won. Custom
+result backends remain compatible, but must implement that optional capability
+if they need the same cross-process first-writer-wins guarantee.
+
+The worker suite also exercises the failure path where lease renewal fails
+while a handler is still running. After the visibility timeout, a replacement
+worker receives the redelivery and may finish first; the original handler's
+late completion is then ignored by terminal arbitration. This is still
+at-least-once execution: external side effects must be idempotent, and a
+successful handler is not proof that its broker acknowledgement was durable.
+
+Renewal is scheduled from roughly half of the remaining lease. The configured
+minimum interval is used for ordinary leases, but it does not delay renewal of
+short leases past their deadline. If the lease duration is shorter than the
+default one-second floor, Stem uses the shorter safe interval instead.
+
## Workflow lease notes
- Workflow runs are lease-based. Workers must renew leases while executing, and
other workers can take over after the lease expires.
+- SQLite integration coverage reopens the durable workflow store after a
+ worker/runtime restart and resumes a persisted checkpoint without re-running
+ the completed step. The same checkpoint/replay contract applies to the
+ other durable workflow stores, subject to their adapter guarantees.
- Keep `runLeaseDuration` **>=** broker visibility timeout to prevent
redelivered workflow tasks from being dropped before takeover is possible.
- Keep `leaseExtension` renewals ahead of both the workflow lease expiry and the
@@ -35,6 +89,10 @@ import TabItem from '@theme/TabItem';
## Poison-pill handling
+- Payload-decoding failures are terminal: Stem acknowledges the delivery,
+ records a failed result, and dead-letters it with reason `invalid-payload`.
+ They do not consume the task's normal handler retry budget because retrying
+ the same malformed bytes cannot repair the message.
- If a task fails repeatedly for the same reason, treat it as a poison pill.
- Move it to the DLQ and add guardrails or validation to prevent repeats.
- Record the failure pattern for future detection.
@@ -42,6 +100,14 @@ import TabItem from '@theme/TabItem';
## Scheduler reliability
- Run multiple Beat instances only when backed by a shared lock store.
+- Beat revalidates lock ownership immediately before publishing; a lost lease
+ records a failed dispatch instead of publishing after another scheduler may
+ have acquired the entry.
+- Fenced lock stores attach `stem-lock-fencing-token` to scheduled envelopes.
+ Downstream state stores that support fencing should reject writes carrying a
+ token older than the last accepted token. The token prevents stale owners
+ from overwriting state after a lease expires; it does not make external
+ side effects exactly once.
- Monitor schedule drift and failures to detect store latency.
- Re-apply schedules after deploys to ensure definitions stay current.
@@ -77,8 +143,9 @@ import TabItem from '@theme/TabItem';
## Heartbeats and progress
-Use heartbeats and progress updates to prevent long-running tasks from being
-reclaimed prematurely.
+Use heartbeats and progress updates to make long-running tasks observable.
+Automatic worker lease renewal protects normal executions; call
+`context.extendLease(...)` when a task needs an explicit additional lease.
diff --git a/.site/docs/scheduler/index.md b/.site/docs/scheduler/index.md
index 2b49c6bf..f66e8911 100644
--- a/.site/docs/scheduler/index.md
+++ b/.site/docs/scheduler/index.md
@@ -48,6 +48,8 @@ Common scheduler CLI commands:
- `stem schedule apply` — load schedule entries from JSON/YAML into the store.
- `stem schedule list` — inspect entries in the store.
+- `stem schedule trigger ` — enqueue one immediate execution without
+ changing the recurring schedule state.
- `stem schedule dry-run` — preview due times before rollout.
- `stem observe schedules` — inspect schedule drift and dispatch status.
diff --git a/.site/docs/workers/worker-control.md b/.site/docs/workers/worker-control.md
index 4e413198..dfcc774a 100644
--- a/.site/docs/workers/worker-control.md
+++ b/.site/docs/workers/worker-control.md
@@ -180,17 +180,19 @@ when the entrypoint captures state that cannot cross isolate boundaries.
```
-`stem worker revoke --terminate` throws `TaskRevokedException` the next time an
-inline handler calls `TaskContext.heartbeat`, `extendLease`, or `progress`,
-allowing the worker to cancel and record the task as cancelled. Isolate handlers
-must emit cooperative checkpoints (heartbeat/lease/progress) to be interrupted;
-otherwise they finish naturally.
+`stem worker revoke --terminate` is observed by inline handlers through the
+public `TaskContext.cancellation` token. Existing heartbeat, lease and progress
+helpers remain checkpoints and also enforce termination. Isolate handlers must
+emit cooperative checkpoints (heartbeat/lease/progress) to be interrupted;
+otherwise they finish naturally unless the isolate is terminated by a hard
+timeout or hard shutdown.
### Cooperative checkpoints for isolate handlers
Make sure isolate entrypoints call one of the cooperative helpers inside any
-long-running loop. Each helper throws `TaskRevokedException` when a terminate
-revoke is pending, which lets the handler fail fast.
+long-running loop. Handlers can also call
+`context.cancellation.throwIfCancelled()` at safe points; it throws
+`TaskCancellationException` when a terminate revoke is pending.
```dart title="tasks/crunch.dart" file=/../packages/stem/example/docs_snippets/lib/worker_control.dart#worker-control-crunch
diff --git a/.site/docs/workflows/errors-retries-and-idempotency.md b/.site/docs/workflows/errors-retries-and-idempotency.md
index b150caab..b931b15b 100644
--- a/.site/docs/workflows/errors-retries-and-idempotency.md
+++ b/.site/docs/workflows/errors-retries-and-idempotency.md
@@ -34,6 +34,15 @@ If a workflow enqueues normal Stem tasks, those tasks still use the normal
`TaskOptions` retry policy. The workflow and the task are separate retry
surfaces.
+## Acknowledgement uncertainty
+
+Task delivery is at least once. Stem records a successful result before the
+final broker acknowledgement. If that acknowledgement is lost, the broker
+may redeliver the same envelope; the worker recognizes the durable terminal
+result and acknowledges the duplicate without invoking the handler again.
+External side effects must still be idempotent because a process can fail
+before its result is recorded.
+
## Cancellation policies
Use `WorkflowCancellationPolicy` when you need to cap:
diff --git a/.site/docs/workflows/getting-started.md b/.site/docs/workflows/getting-started.md
index d28e1d65..c2452df1 100644
--- a/.site/docs/workflows/getting-started.md
+++ b/.site/docs/workflows/getting-started.md
@@ -30,10 +30,10 @@ The managed worker subscribes to the workflow orchestration queue, so you do
not need to manually register the internal `stem.workflow.run` task.
If you prefer a minimal example, `startWorkflow(...)`,
-`startWorkflowValue(...)`, and `startWorkflowJson(...)` also lazy-start the
-runtime and managed worker on first use. Explicit `start()` is still the
-better choice when you want deterministic application lifecycle control. Use
-those name-based APIs when workflow names come from config or external input.
+`startWorkflowValue(...)`, and `startWorkflowJson(...)` initialize the runtime
+on first use but never start the managed worker implicitly. Start the returned
+app explicitly when the process is intended to consume work. Use those
+name-based APIs when workflow names come from config or external input.
For workflows you define in code, prefer direct workflow helpers or generated
workflow refs.
diff --git a/.site/sidebars.ts b/.site/sidebars.ts
index 07030a06..e04f00ea 100644
--- a/.site/sidebars.ts
+++ b/.site/sidebars.ts
@@ -118,6 +118,12 @@ const sidebars: SidebarsConfig = {
label: "Brokers",
items: ["brokers/overview", "brokers/sqlite", "brokers/caveats"],
},
+ {
+ type: "category",
+ label: "About",
+ link: { type: "doc", id: "about/index" },
+ items: ["about/roadmap", "about/testing"],
+ },
],
};
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 00000000..b060bf03
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,29 @@
+# Contributing
+
+Stem changes should improve a documented contract or close a demonstrated
+reliability gap. New subsystems need a design note and failure tests before
+they become part of the public product story.
+
+Before opening a pull request:
+
+```bash
+dart pub get
+dart run tool/check_examples.dart --skip-diff
+
+cd packages/stem
+dart format lib test --set-exit-if-changed
+dart analyze --fatal-infos
+dart test --exclude-tags soak --fail-fast
+```
+
+For package-wide release validation, run the release planner from the
+repository root:
+
+```bash
+dart run tool/publish.dart --plan
+```
+
+The release tool derives package membership and dependency order from the Dart
+workspace. It requires a clean Git tree for a real release and validates
+formatting, analysis, tests, generated sources, changelog headings, and pub
+publication archives.
diff --git a/README.md b/README.md
index f88097d2..70ac4c92 100644
--- a/README.md
+++ b/README.md
@@ -3,13 +3,13 @@
- Dart-native background job platform
+ Experimental Dart-native background job platform
Queues, retries, scheduling, workflows, and observability — all in pure Dart.
-
+
@@ -19,7 +19,7 @@
- **Pure Dart** — No external worker processes, no FFI bindings. Runs anywhere Dart runs.
- **Pluggable backends** — Swap between SQLite, Redis, or Postgres with a single line.
-- **Battle-tested patterns** — Retries with backoff, rate limiting, dead-letter queues, and priority scheduling.
+- **Reliability patterns under active validation** — Retries with backoff, rate limiting, dead-letter queues, and priority scheduling.
- **Workflows** — Durable, checkpointed execution for complex multi-step processes.
- **Canvas API** — Compose tasks into groups, chains, and chords.
- **Observability** — Built-in OpenTelemetry integration for traces and metrics.
@@ -28,10 +28,40 @@
## Quick Start
+### Recommended: generated typed tasks
+
+For application code, define task arguments and results as Dart types and let
+`stem_builder` generate the transport adapter, codecs, registry and typed call
+object:
+
+```dart
+part 'tasks.stem.g.dart';
+
+@TaskDefn(name: 'email.send')
+Future sendEmail(
+ EmailArgs args, {
+ TaskExecutionContext? context,
+}) async {
+ return EmailResult(await deliver(args));
+}
+```
+
+Run `dart run build_runner build`, then enqueue through the generated
+definition rather than a raw task-name string. See the
+[`stem_builder` guide](./packages/stem_builder/README.md) for the complete DTO and
+workflow example.
+
+### Advanced: raw task handlers
+
+For production-shaped task code, prefer `stem_builder` generated task definitions:
+they keep task arguments and results typed across the enqueue/handler boundary.
+The raw `TaskHandler` example below is intentionally the low-level
+interoperability path for dynamic task names and existing map-based handlers.
+
```dart
import 'dart:async';
-import 'package:stem/stem.dart';
+import 'package:stem/advanced.dart';
class EmailTask extends TaskHandler {
@override
@@ -136,7 +166,7 @@ Future main() async {
|---------|-------------|---------|
| [`stem`](./packages/stem) | Core runtime: contracts, worker, scheduler, in-memory adapters, signals, Canvas, workflows | [](https://pub.dev/packages/stem) |
| [`stem_cli`](./packages/stem_cli) | Command-line tooling (`stem` executable) and CLI utilities | [](https://pub.dev/packages/stem_cli) |
-| [`stem_memory`](./packages/stem_memory) | In-memory adapter package (broker/backend/workflow/scheduler factories) | [](https://pub.dev/packages/stem_memory) |
+| [`stem_memory`](./packages/stem_memory) | Compatibility package for the explicit `package:stem/memory.dart` in-memory library | [](https://pub.dev/packages/stem_memory) |
| [`stem_sqlite`](./packages/stem_sqlite) | SQLite broker and result backend for local dev/testing | [](https://pub.dev/packages/stem_sqlite) |
| [`stem_redis`](./packages/stem_redis) | Redis Streams broker, result backend, and watchdog helpers | [](https://pub.dev/packages/stem_redis) |
| [`stem_postgres`](./packages/stem_postgres) | Postgres broker, result backend, and scheduler stores | [](https://pub.dev/packages/stem_postgres) |
@@ -157,7 +187,7 @@ TaskOptions(
queue: 'high-priority', // Target queue
maxRetries: 5, // Retry on failure
priority: 10, // Higher = processed first
- rateLimit: '100/m', // Rate limiting
+ rateLimit: RateLimit.perMinute(100), // Typed rate limiting
softTimeLimit: Duration(seconds: 30),
hardTimeLimit: Duration(minutes: 2),
visibilityTimeout: Duration(minutes: 5),
@@ -246,7 +276,9 @@ stem health
### Prerequisites
- Dart 3.9.2+
+- Flutter 3.47.0+ (for the local Flutter package gate)
- Docker (for adapter integration tests)
+- Dagger CLI v0.21.7 (for the reproducible Dagger gate)
### Setup
@@ -261,7 +293,7 @@ dart pub get
# Run quality gates
dart format --output=none --set-exit-if-changed .
dart analyze
-task test:no-env
+task test:all
```
### Adapter Tests
@@ -281,6 +313,26 @@ task test:redis
task test:postgres
```
+### Reproducible Dagger Gate
+
+The root Taskfile is also executable inside a pinned Dagger environment. This
+is the recommended path when the local toolchain or Docker Compose state is
+not trustworthy:
+
+```bash
+# Runs every Dart and Flutter package test with disposable PostgreSQL/Redis
+# services and a pinned Flutter SDK.
+task test:dagger
+
+# If Dagger is not on PATH:
+DAGGER_BIN=/path/to/dagger task test:dagger
+```
+
+The Dagger module uses the Go SDK, pins the Dagger engine, Task release and
+Flutter SDK, and generates disposable TLS assets during the run. The aggregate
+GitHub Actions test gate calls this same Dagger module; package-specific test
+workflows are intentionally not maintained separately.
+
Targeted adapter tasks now bootstrap integration environment automatically.
If bootstrap still fails (for example Docker unavailable), run:
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 00000000..3bb098aa
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,14 @@
+# Security policy
+
+Stem is experimental software. Security reports are welcome, especially
+reports involving payload signing, TLS, broker credentials, task isolation,
+lease ownership, or data exposure in logs and telemetry.
+
+Please do not publish a suspected vulnerability in a public issue. Contact the
+repository owner through the private security-reporting mechanism provided by
+GitHub, including the affected package and version, reproduction steps, impact,
+and any suggested mitigation.
+
+Disposable certificates and keys used by tests are not production credentials.
+They must never be reused in deployments or committed outside explicitly
+labelled test fixtures.
diff --git a/Taskfile.yml b/Taskfile.yml
index 5a0c3a53..1b7783ce 100644
--- a/Taskfile.yml
+++ b/Taskfile.yml
@@ -30,6 +30,17 @@ includes:
dir: ./packages/stem_sqlite
tasks:
+ deps:
+ desc: Resolve workspace and dashboard package dependencies.
+ cmds:
+ - |
+ if command -v flutter >/dev/null 2>&1; then
+ flutter pub get
+ else
+ dart pub get
+ fi
+ - task dashboard:deps
+
test:with-env:
internal: true
requires:
@@ -61,6 +72,7 @@ tasks:
exit 1
fi
+ task deps
{{.RUN}}
'
@@ -73,6 +85,111 @@ tasks:
REQUIRED_VARS: "STEM_TEST_REDIS_URL STEM_TEST_POSTGRES_URL"
RUN: task test:no-env
+ test:dagger:
+ desc: Run every package test in Dagger-managed services and tooling.
+ cmds:
+ - bash ./tool/dagger_test.sh all
+
+ quality:dart:
+ desc: Format and analyze every Dart package.
+ cmds:
+ - |
+ set -euo pipefail
+ for package in \
+ packages/stem \
+ packages/stem_builder \
+ packages/stem_sqlite \
+ packages/stem_memory \
+ packages/stem_adapter_tests \
+ packages/stem_redis \
+ packages/stem_postgres \
+ packages/stem_cli \
+ packages/dashboard; do
+ echo "Checking $package"
+ (
+ cd "$package"
+ dart format lib test --set-exit-if-changed
+ dart analyze --fatal-infos
+ )
+ done
+
+ standalone:dart:
+ desc: Resolve Dart packages outside the workspace dependency graph.
+ cmds:
+ - |
+ set -euo pipefail
+ dart pub get
+ staged_root="$(mktemp -d)"
+ trap 'rm -rf "$staged_root"' EXIT
+ for package in \
+ packages/stem \
+ packages/stem_builder \
+ packages/stem_sqlite \
+ packages/stem_memory \
+ packages/stem_adapter_tests \
+ packages/stem_redis \
+ packages/stem_postgres \
+ packages/stem_cli; do
+ package_root="$staged_root/$(basename "$package")"
+ mkdir -p "$package_root"
+ staged_package="$(dart run tool/stage_workspace.dart \
+ --package "$package" --output "$package_root")"
+ (
+ cd "$staged_package"
+ dart pub get
+ )
+ done
+
+ standalone:flutter:
+ desc: Resolve Flutter packages outside the workspace dependency graph.
+ cmds:
+ - |
+ set -euo pipefail
+ dart pub get
+ staged_root="$(mktemp -d)"
+ trap 'rm -rf "$staged_root"' EXIT
+ for package in \
+ packages/stem_flutter \
+ packages/stem_flutter_sqlite; do
+ package_root="$staged_root/$(basename "$package")"
+ mkdir -p "$package_root"
+ staged_package="$(dart run tool/stage_workspace.dart \
+ --package "$package" --output "$package_root")"
+ (
+ cd "$staged_package"
+ flutter pub get
+ )
+ done
+
+ test:flutter:
+ desc: Run the Flutter package analysis and tests.
+ cmds:
+ - |
+ if ! command -v flutter >/dev/null 2>&1; then
+ echo "Flutter SDK is required for task test:flutter." >&2
+ exit 1
+ fi
+ (
+ cd packages/stem_flutter
+ flutter pub get
+ dart format lib test --set-exit-if-changed
+ flutter analyze --fatal-infos
+ flutter test
+ )
+ (
+ cd packages/stem_flutter_sqlite
+ flutter pub get
+ dart format lib test --set-exit-if-changed
+ flutter analyze --fatal-infos
+ flutter test
+ )
+
+ test:all:
+ desc: Run all Dart and Flutter package tests from the repository root.
+ cmds:
+ - task test
+ - task test:flutter
+
test:no-env:
desc: Run all workspace package tests without bootstrapping integration env.
cmds:
diff --git a/benchmark/README.md b/benchmark/README.md
new file mode 100644
index 00000000..9d5af1c3
--- /dev/null
+++ b/benchmark/README.md
@@ -0,0 +1,24 @@
+# Stem benchmarks
+
+Run the repeatable in-memory throughput workload from the repository root:
+
+```bash
+dart run benchmark/stem_throughput.dart --tasks 5000 --concurrency 8
+dart run benchmark/stem_throughput.dart --check-baseline
+```
+
+The benchmark warms up the worker before measuring and reports enqueue
+throughput and end-to-end delivery/execution throughput as JSON. The checked-in
+baseline is a deliberately conservative minimum for CI; record machine-specific
+results with the commit and Dart SDK when comparing performance changes. Adapter
+benchmarks should live beside the adapter because Redis, Postgres and SQLite
+contention have different costs.
+
+The SQLite adapter has a file-backed worker/broker/backend workload that
+exercises concurrent writer coordination:
+
+```bash
+cd packages/stem_sqlite
+dart run benchmark/sqlite_throughput.dart --tasks 1000 --concurrency 4 \
+ --check-baseline
+```
diff --git a/benchmark/stem_throughput.dart b/benchmark/stem_throughput.dart
new file mode 100644
index 00000000..e27fd821
--- /dev/null
+++ b/benchmark/stem_throughput.dart
@@ -0,0 +1,143 @@
+import 'dart:async';
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:stem/memory.dart';
+import 'package:stem/stem.dart';
+
+Future main(List args) async {
+ final tasks = _intOption(args, '--tasks') ?? 5000;
+ final concurrency = _intOption(args, '--concurrency') ?? 8;
+ final warmupTasks = _intOption(args, '--warmup') ?? 250;
+ final checkBaseline = args.contains('--check-baseline');
+
+ if (tasks <= 0 || concurrency <= 0 || warmupTasks < 0) {
+ throw ArgumentError(
+ 'Tasks and concurrency must be positive; warmup cannot be negative.',
+ );
+ }
+
+ final broker = InMemoryBroker();
+ final backend = InMemoryResultBackend();
+ final completed = Completer();
+ final warmupCompleted = Completer();
+ var measuring = false;
+ var warmupCompletedTasks = 0;
+ var completedTasks = 0;
+ final registry = InMemoryTaskRegistry()
+ ..register(
+ _BenchmarkTask(
+ onComplete: () {
+ if (!measuring) {
+ warmupCompletedTasks += 1;
+ if (warmupCompletedTasks == warmupTasks &&
+ !warmupCompleted.isCompleted) {
+ warmupCompleted.complete();
+ }
+ return;
+ }
+ completedTasks += 1;
+ if (completedTasks == tasks && !completed.isCompleted) {
+ completed.complete();
+ }
+ },
+ ),
+ );
+ final stem = Stem(broker: broker, registry: registry, backend: backend);
+ final worker = Worker(
+ broker: broker,
+ registry: registry,
+ backend: backend,
+ concurrency: concurrency,
+ prefetchMultiplier: 1,
+ heartbeatTransport: const NoopHeartbeatTransport(),
+ consumerName: 'benchmark-worker',
+ lifecycle: const WorkerLifecycleConfig(installSignalHandlers: false),
+ );
+
+ await worker.start();
+ if (warmupTasks > 0) {
+ for (var index = 0; index < warmupTasks; index++) {
+ await stem.enqueue('benchmark.noop', args: {'index': index});
+ }
+ await warmupCompleted.future.timeout(const Duration(minutes: 2));
+ }
+ measuring = true;
+ final total = Stopwatch()..start();
+ final enqueue = Stopwatch()..start();
+ for (var index = 0; index < tasks; index++) {
+ await stem.enqueue('benchmark.noop', args: {'index': index});
+ }
+ enqueue.stop();
+
+ await completed.future.timeout(const Duration(minutes: 2));
+ total.stop();
+ await worker.shutdown();
+ broker.dispose();
+
+ final result = {
+ 'tasks': tasks,
+ 'concurrency': concurrency,
+ 'enqueue_ms': enqueue.elapsedMicroseconds / 1000,
+ 'end_to_end_ms': total.elapsedMicroseconds / 1000,
+ 'enqueue_tasks_per_second': _rate(tasks, enqueue.elapsed),
+ 'end_to_end_tasks_per_second': _rate(tasks, total.elapsed),
+ };
+ stdout.writeln(const JsonEncoder.withIndent(' ').convert(result));
+
+ if (checkBaseline) {
+ _checkBaseline(result);
+ }
+ await stdout.flush();
+ // The benchmark owns a short-lived process and has already disposed all
+ // runtime resources. Exit explicitly so a broker/stream implementation that
+ // leaves a non-terminal listener cannot make the benchmark appear hung.
+ exit(0);
+}
+
+double _rate(int count, Duration duration) {
+ final seconds = duration.inMicroseconds / Duration.microsecondsPerSecond;
+ return seconds == 0 ? double.infinity : count / seconds;
+}
+
+int? _intOption(List args, String name) {
+ final index = args.indexOf(name);
+ if (index == -1 || index + 1 >= args.length) return null;
+ return int.tryParse(args[index + 1]);
+}
+
+void _checkBaseline(Map result) {
+ final baselineFile = File('benchmark/stem_throughput_baseline.json');
+ if (!baselineFile.existsSync()) {
+ throw StateError('Missing benchmark baseline: ${baselineFile.path}');
+ }
+ final baseline = jsonDecode(baselineFile.readAsStringSync());
+ if (baseline is! Map) {
+ throw StateError('Benchmark baseline must be a JSON object.');
+ }
+ final minimum = baseline['minimum_end_to_end_tasks_per_second'];
+ final measured = result['end_to_end_tasks_per_second'];
+ if (minimum is! num || measured is! num || measured < minimum) {
+ throw StateError(
+ 'End-to-end throughput regressed: measured $measured tasks/s, '
+ 'minimum $minimum tasks/s.',
+ );
+ }
+}
+
+final class _BenchmarkTask extends TaskHandler {
+ _BenchmarkTask({required this.onComplete});
+
+ final void Function() onComplete;
+
+ @override
+ String get name => 'benchmark.noop';
+
+ @override
+ TaskOptions get options => const TaskOptions();
+
+ @override
+ Future call(TaskContext context, Map args) async {
+ onComplete();
+ }
+}
diff --git a/benchmark/stem_throughput_baseline.json b/benchmark/stem_throughput_baseline.json
new file mode 100644
index 00000000..9be580e9
--- /dev/null
+++ b/benchmark/stem_throughput_baseline.json
@@ -0,0 +1,3 @@
+{
+ "minimum_end_to_end_tasks_per_second": 1000
+}
diff --git a/packages/dashboard/Taskfile.yml b/packages/dashboard/Taskfile.yml
index e551c411..42f455d1 100644
--- a/packages/dashboard/Taskfile.yml
+++ b/packages/dashboard/Taskfile.yml
@@ -1,6 +1,16 @@
version: "3"
tasks:
+ deps:
+ desc: Resolve dashboard package dependencies.
+ cmds:
+ - |
+ if command -v flutter >/dev/null 2>&1; then
+ flutter pub get
+ else
+ dart pub get
+ fi
+
test:
desc: Run dashboard package tests.
cmds:
diff --git a/packages/dashboard/lib/src/server.dart b/packages/dashboard/lib/src/server.dart
index 7a76eba8..04b7de2f 100644
--- a/packages/dashboard/lib/src/server.dart
+++ b/packages/dashboard/lib/src/server.dart
@@ -4,8 +4,8 @@ import 'dart:io';
import 'package:routed/routed.dart';
import 'package:routed_hotwire/routed_hotwire.dart';
-import 'package:stem/stem.dart'
- show TaskState, generateEnvelopeId, stemLogContext, stemLogger;
+import 'package:stem/observability.dart' show stemLogger;
+import 'package:stem/stem.dart' show TaskState, generateEnvelopeId;
import 'package:stem_dashboard/src/config/config.dart';
import 'package:stem_dashboard/src/services/models.dart';
import 'package:stem_dashboard/src/services/stem_service.dart';
@@ -105,15 +105,13 @@ Future runDashboardServer({
stemLogger.info(
'Starting dashboard server',
- stemLogContext(
- component: 'dashboard',
- subsystem: 'server',
- fields: {
- 'host': options.host,
- 'port': options.port,
- 'basePath': dashboardUrlPath,
- },
- ),
+ fields: {
+ 'component': 'dashboard',
+ 'subsystem': 'server',
+ 'host': options.host,
+ 'port': options.port,
+ 'basePath': dashboardUrlPath,
+ },
);
try {
@@ -141,11 +139,11 @@ Future _waitForShutdownSignal() async {
void complete(ProcessSignal signal) {
stemLogger.info(
'Shutdown signal received',
- stemLogContext(
- component: 'dashboard',
- subsystem: 'server',
- fields: {'signal': signal.toString()},
- ),
+ fields: {
+ 'component': 'dashboard',
+ 'subsystem': 'server',
+ 'signal': signal.toString(),
+ },
);
if (!completer.isCompleted) {
completer.complete();
@@ -405,18 +403,16 @@ Future _renderOverviewPartials(
),
].join('\n');
- return ctx.turboStream(updates);
+ return await ctx.turboStream(updates);
} on Object catch (error, stack) {
stemLogger.error(
'Failed to render overview partials',
- stemLogContext(
- component: 'dashboard',
- subsystem: 'server',
- fields: {
- 'error': error.toString(),
- 'stack': stack.toString(),
- },
- ),
+ fields: {
+ 'component': 'dashboard',
+ 'subsystem': 'server',
+ 'error': error.toString(),
+ 'stack': stack.toString(),
+ },
);
return ctx.turboHtml(
'
Failed to refresh overview metrics.
',
@@ -582,10 +578,10 @@ Future _renderPage(
final streamPath = dashboardRoute(basePath, '/dash/streams');
if (turbo.isFrameRequest) {
- return ctx.turboFrame(renderFrame(page, contentWithBasePath));
+ return await ctx.turboFrame(renderFrame(page, contentWithBasePath));
}
- return ctx.turboHtml(
+ return await ctx.turboHtml(
renderLayout(
page,
contentWithBasePath,
@@ -596,15 +592,13 @@ Future _renderPage(
} on Object catch (error, stack) {
stemLogger.error(
'Failed to render dashboard page',
- stemLogContext(
- component: 'dashboard',
- subsystem: 'server',
- fields: {
- 'page': page.name,
- 'error': error.toString(),
- 'stack': stack.toString(),
- },
- ),
+ fields: {
+ 'component': 'dashboard',
+ 'subsystem': 'server',
+ 'page': page.name,
+ 'error': error.toString(),
+ 'stack': stack.toString(),
+ },
);
final errorContent = _renderErrorPanel(error);
if (turbo.isFrameRequest) {
@@ -688,7 +682,7 @@ Future _enqueueTask(
actor: 'dashboard',
summary: 'Task enqueue rejected: queue/task missing.',
);
- return ctx.turboSeeOther('$tasksPath?error=missing-fields');
+ return await ctx.turboSeeOther('$tasksPath?error=missing-fields');
}
final payloadText = (await ctx.postForm('payload')).trim();
@@ -706,7 +700,7 @@ Future _enqueueTask(
actor: 'dashboard',
summary: 'Task enqueue rejected: payload not a JSON object.',
);
- return ctx.turboSeeOther('$tasksPath?error=invalid-payload');
+ return await ctx.turboSeeOther('$tasksPath?error=invalid-payload');
}
} on Object {
state.recordAudit(
@@ -716,7 +710,7 @@ Future _enqueueTask(
actor: 'dashboard',
summary: 'Task enqueue rejected: invalid JSON payload.',
);
- return ctx.turboSeeOther('$tasksPath?error=invalid-payload');
+ return await ctx.turboSeeOther('$tasksPath?error=invalid-payload');
}
}
@@ -744,18 +738,16 @@ Future _enqueueTask(
summary: 'Queued task "$task" on "$queue".',
metadata: {'queue': queue, 'task': task},
);
- return ctx.turboSeeOther('$tasksPath?flash=queued');
+ return await ctx.turboSeeOther('$tasksPath?flash=queued');
} on Object catch (error, stack) {
stemLogger.error(
'Dashboard enqueue failed',
- stemLogContext(
- component: 'dashboard',
- subsystem: 'server',
- fields: {
- 'error': error.toString(),
- 'stack': stack.toString(),
- },
- ),
+ fields: {
+ 'component': 'dashboard',
+ 'subsystem': 'server',
+ 'error': error.toString(),
+ 'stack': stack.toString(),
+ },
);
state.recordAudit(
kind: 'action',
@@ -792,7 +784,7 @@ Future _taskAction(
actor: 'dashboard',
summary: 'Task action rejected: missing task id.',
);
- return ctx.turboSeeOther(
+ return await ctx.turboSeeOther(
_appendRedirectQuery(redirect, {'error': 'Task ID is required.'}),
);
}
@@ -820,7 +812,7 @@ Future _taskAction(
summary: 'Failed to revoke task $taskId.',
metadata: {'taskId': taskId, 'queue': ?queue},
);
- return ctx.turboSeeOther(
+ return await ctx.turboSeeOther(
_appendRedirectQuery(redirect, {
'error': 'Unable to revoke task $taskId.',
}),
@@ -834,7 +826,7 @@ Future _taskAction(
summary: 'Revocation requested for $taskId.',
metadata: {'taskId': taskId, 'queue': ?queue},
);
- return ctx.turboSeeOther(
+ return await ctx.turboSeeOther(
_appendRedirectQuery(redirect, {
'flash': 'Revocation requested for task $taskId.',
}),
@@ -850,7 +842,7 @@ Future _taskAction(
summary: 'Task $taskId was not found in dead letters.',
metadata: {'taskId': taskId, 'queue': ?queue},
);
- return ctx.turboSeeOther(
+ return await ctx.turboSeeOther(
_appendRedirectQuery(redirect, {
'error': 'Task $taskId was not found in dead letters.',
}),
@@ -864,7 +856,7 @@ Future _taskAction(
summary: 'Replayed dead-letter task $taskId.',
metadata: {'taskId': taskId, 'queue': ?queue},
);
- return ctx.turboSeeOther(
+ return await ctx.turboSeeOther(
_appendRedirectQuery(redirect, {
'flash': 'Replayed dead-letter task $taskId as a new envelope.',
}),
@@ -878,7 +870,7 @@ Future _taskAction(
summary: 'Unsupported task action "$action".',
metadata: {'taskId': taskId},
);
- return ctx.turboSeeOther(
+ return await ctx.turboSeeOther(
_appendRedirectQuery(redirect, {
'error': 'Unsupported task action "$action".',
}),
@@ -887,14 +879,12 @@ Future _taskAction(
} on Object catch (error, stack) {
stemLogger.error(
'Dashboard task action failed',
- stemLogContext(
- component: 'dashboard',
- subsystem: 'server',
- fields: {
- 'error': error.toString(),
- 'stack': stack.toString(),
- },
- ),
+ fields: {
+ 'component': 'dashboard',
+ 'subsystem': 'server',
+ 'error': error.toString(),
+ 'stack': stack.toString(),
+ },
);
state.recordAudit(
kind: 'action',
@@ -1101,7 +1091,7 @@ Future _controlWorkers(
actor: 'dashboard',
summary: 'Control action missing.',
);
- return ctx.turboSeeOther(
+ return await ctx.turboSeeOther(
'$workersPath?error=${Uri.encodeComponent('Control action missing.')}',
);
}
@@ -1141,7 +1131,7 @@ Future _controlWorkers(
final encodedError = Uri.encodeComponent(
'Unsupported control action "$rawAction".',
);
- return ctx.turboSeeOther('$workersPath?error=$encodedError');
+ return await ctx.turboSeeOther('$workersPath?error=$encodedError');
}
final payload = {};
@@ -1198,7 +1188,7 @@ Future _controlWorkers(
);
final encodedMessage = Uri.encodeComponent(message.toString());
final encodedScope = Uri.encodeComponent(scope);
- return ctx.turboSeeOther(
+ return await ctx.turboSeeOther(
'$workersPath?error=$encodedMessage&scope=$encodedScope',
);
}
@@ -1216,20 +1206,18 @@ Future _controlWorkers(
);
final encodedMessage = Uri.encodeComponent(message);
final encodedScope = Uri.encodeComponent(scope);
- return ctx.turboSeeOther(
+ return await ctx.turboSeeOther(
'$workersPath?flash=$encodedMessage&scope=$encodedScope',
);
} on Object catch (error, stack) {
stemLogger.error(
'Dashboard control command failed',
- stemLogContext(
- component: 'dashboard',
- subsystem: 'server',
- fields: {
- 'error': error.toString(),
- 'stack': stack.toString(),
- },
- ),
+ fields: {
+ 'component': 'dashboard',
+ 'subsystem': 'server',
+ 'error': error.toString(),
+ 'stack': stack.toString(),
+ },
);
state.recordAudit(
kind: 'action',
@@ -1264,7 +1252,7 @@ Future _replayDeadLetters(
actor: 'dashboard',
summary: 'Replay rejected: missing queue name.',
);
- return ctx.turboSeeOther(
+ return await ctx.turboSeeOther(
_appendRedirectQuery(redirect, {
'error': 'Queue name is required for replay.',
}),
@@ -1298,7 +1286,7 @@ Future _replayDeadLetters(
summary: message,
metadata: {'queue': queue, 'dryRun': dryRun},
);
- return ctx.turboSeeOther(
+ return await ctx.turboSeeOther(
_appendRedirectQuery(redirect, {
'flash': message,
'scope': scope,
@@ -1321,7 +1309,7 @@ Future _replayDeadLetters(
summary: message,
metadata: {'queue': queue, 'entries': entryCount, 'dryRun': dryRun},
);
- return ctx.turboSeeOther(
+ return await ctx.turboSeeOther(
_appendRedirectQuery(redirect, {
'flash': message,
'scope': scope,
@@ -1331,14 +1319,12 @@ Future _replayDeadLetters(
} on Object catch (error, stack) {
stemLogger.error(
'Dashboard dead-letter replay failed',
- stemLogContext(
- component: 'dashboard',
- subsystem: 'server',
- fields: {
- 'error': error.toString(),
- 'stack': stack.toString(),
- },
- ),
+ fields: {
+ 'component': 'dashboard',
+ 'subsystem': 'server',
+ 'error': error.toString(),
+ 'stack': stack.toString(),
+ },
);
state.recordAudit(
kind: 'action',
diff --git a/packages/dashboard/lib/src/services/stem_service.dart b/packages/dashboard/lib/src/services/stem_service.dart
index c40e01f0..878014ba 100644
--- a/packages/dashboard/lib/src/services/stem_service.dart
+++ b/packages/dashboard/lib/src/services/stem_service.dart
@@ -1,6 +1,8 @@
import 'dart:async';
import 'dart:io';
+import 'package:stem/memory.dart' show InMemoryWorkflowStore;
+import 'package:stem/observability.dart' show stemLogger;
import 'package:stem/stem.dart';
import 'package:stem_cli/stem_cli.dart';
import 'package:stem_dashboard/src/config/config.dart';
@@ -607,15 +609,13 @@ class StemDashboardService implements DashboardDataSource {
void _logReadFailure(String operation, Object error, StackTrace stack) {
stemLogger.warning(
'Dashboard data read failed',
- stemLogContext(
- component: 'dashboard',
- subsystem: 'service',
- fields: {
- 'operation': operation,
- 'error': '$error',
- 'stack': '$stack',
- },
- ),
+ fields: {
+ 'component': 'dashboard',
+ 'subsystem': 'service',
+ 'operation': operation,
+ 'error': '$error',
+ 'stack': '$stack',
+ },
);
}
diff --git a/packages/dashboard/pubspec.yaml b/packages/dashboard/pubspec.yaml
index 67552a92..c4b35c1e 100644
--- a/packages/dashboard/pubspec.yaml
+++ b/packages/dashboard/pubspec.yaml
@@ -5,27 +5,45 @@ publish_to: "none"
environment:
sdk: ">=3.9.2 <4.0.0"
-resolution: workspace
dependencies:
intl: ^0.20.2
meta: ^1.18.0
ormed: ^0.2.0
- routed: ^0.3.2
- routed_hotwire: ^0.1.2
- stem: ">=0.2.0-dev <0.3.0"
- stem_cli: ^0.1.0
- stem_postgres: ^0.1.0
- stem_redis: ^0.1.0
- stem_sqlite: ^0.1.0
+ routed: ^0.5.0
+ routed_hotwire: ^0.1.5
+ stem:
+ path: ../stem
+ stem_cli: ^0.2.0
+ stem_postgres: ^0.2.0
+ stem_redis: ^0.2.0
+ stem_sqlite: ^0.2.0
dev_dependencies:
lints: ^6.0.0
property_testing: ^0.3.2
- routed_testing: ^0.3.2
- server_testing: ^0.3.2
+ routed_testing: ^0.4.0
+ server_testing: ^0.4.0
test: ^1.29.0
very_good_analysis: ^10.0.0
+# The dashboard is an unpublished experimental companion. Its local Stem
+# packages still declare the older artisanal range, while the current Routed
+# testing packages require artisanal 0.5.x. Keep this compatibility override
+# local to the dashboard rather than changing the published Stem packages here.
dependency_overrides:
- analyzer: ^10.0.1
- artisanal: ^0.2.0
+ artisanal: ^0.5.0
+ # routed_auth 0.2.0 currently uses the AuthAdapter/AuthOptions surface from
+ # server_auth 0.1.0; the published 0.2.0 archive is not source-compatible.
+ server_auth: 0.1.0
+ stem:
+ path: ../stem
+ stem_cli:
+ path: ../stem_cli
+ stem_memory:
+ path: ../stem_memory
+ stem_postgres:
+ path: ../stem_postgres
+ stem_redis:
+ path: ../stem_redis
+ stem_sqlite:
+ path: ../stem_sqlite
diff --git a/packages/stem/.pubignore b/packages/stem/.pubignore
new file mode 100644
index 00000000..af067c2c
--- /dev/null
+++ b/packages/stem/.pubignore
@@ -0,0 +1,12 @@
+# Disposable integration assets are generated by the test environment and
+# must never be included in the published core package.
+example/microservice/.certs/
+example/microservice/certs/
+example/postgres_tls/certs/
+example/security/*/certs/
+
+# Internal planning and process notes are maintained in the repository and
+# website, but are not part of the stable package artifact.
+doc/internal/
+doc/process/
+doc/spikes/
diff --git a/packages/stem/CHANGELOG.md b/packages/stem/CHANGELOG.md
index 22295b62..625e10f2 100644
--- a/packages/stem/CHANGELOG.md
+++ b/packages/stem/CHANGELOG.md
@@ -1,5 +1,107 @@
# Changelog
+## 0.3.0
+
+- Promoted the typed stable/advanced API boundary and removed implicit worker
+ startup from producer, inspection, Canvas, and workflow operations.
+- Added capability-aware queue transports, public cooperative cancellation,
+ explicit task execution modes,
+ typed rate limits, heterogeneous Canvas chains, explicit chord policies, and
+ OpenTelemetry Canvas fan-out span links.
+- Scheduler dispatch now revalidates distributed lock ownership immediately
+ before publication and records lease-loss failures instead of publishing
+ after ownership has expired.
+- Added compatibility-safe fencing-token support to lock handles. Memory,
+ Redis, and Postgres acquisitions expose monotonically increasing tokens;
+ Beat propagates the token on scheduled envelopes for downstream enforcement.
+- This release intentionally contains breaking API changes from the 0.2 line.
+- The observability entrypoint now exposes Stem-owned logging types and a
+ structured logging facade; the `contextual` logger types remain internal.
+- Lease renewal derives its cadence from the remaining lease, so short
+ visibility leases are renewed before expiry instead of being forced onto the
+ default one-second minimum interval.
+- Failed automatic renewals retry on a shorter cadence, and late in-flight
+ renewals cannot recreate timers after a delivery has been cancelled.
+- Active-delivery accounting uses worker-local delivery handles, so concurrent
+ redeliveries of one envelope no longer overwrite shutdown or in-flight state.
+- Lease timers are keyed by delivery identity rather than receipt text, which
+ keeps same-receipt redeliveries independent for adapters that reuse row IDs.
+- Automatic heartbeat timers now use the same delivery identity, preventing a
+ concurrent redelivery from cancelling the original task's heartbeat.
+- Lease renewal now remains active through terminal result persistence,
+ group/chord bookkeeping, retry or dead-letter publication, linked-task
+ dispatch, and acknowledgement; slow terminal handling cannot expire the
+ delivery before the worker releases it.
+- Lease renewal now starts when a delivery enters the worker, covering slow
+ consume middleware, signature validation, backend lookups, and rate-limit
+ decisions before handler execution.
+- Concurrent redeliveries of an envelope already active in the same worker are
+ acknowledged without a second handler invocation; process-wide failures
+ still rely on normal at-least-once recovery.
+- Documentation now distinguishes heartbeat/liveness signals from broker lease
+ extension; use automatic renewal or `context.extendLease(...)` for leases.
+- Added the optional `AtomicTerminalResultBackend` capability. Built-in result
+ backends arbitrate terminal writes so a late cross-worker completion cannot
+ replace the first terminal result; custom backends remain source compatible
+ and retain their previous non-atomic fallback behavior.
+- Worker terminal side effects such as group/chord bookkeeping, linked-task
+ dispatch, terminal signals, and unique-lock release now belong only to the
+ worker that wins terminal-result arbitration.
+- Added a deterministic lease-loss recovery regression: renewal failure lets a
+ delivery expire, a replacement worker can complete the redelivery, and the
+ original late completion cannot overwrite that terminal result.
+- Payload-decoding failures are now terminally failed and dead-lettered as
+ `invalid-payload` instead of escaping before acknowledgement and redelivering
+ indefinitely. Retry-storm coverage verifies that normal retry budgets remain
+ bounded under concurrent failure.
+- Hard-shutdown coverage now verifies that a full broker prefetch window is
+ requeued and completed by a replacement worker, not only a single active
+ isolate delivery.
+- Added a warmed-up core throughput benchmark with a checked regression
+ baseline and scheduled CI execution.
+
+## 0.2.3
+
+- Added additive `QueueBroker`, `LeaseBroker`, `InspectableBroker`, and
+ `DeadLetterBroker` capability interfaces for new adapter integrations.
+- Made managed worker startup explicit for every bootstrap path. Enqueue,
+ status, result-wait, Canvas, and workflow operations never start a worker;
+ applications must call `start()` (or `startWorker()`) deliberately.
+- Added `stable.dart` and `advanced.dart` entrypoints to make the intended API
+ boundary explicit while retaining the historical `stem.dart` compatibility
+ barrel.
+- Made `QueueBroker` independently implementable; lease, inspection, purge,
+ and dead-letter operations are now optional capability interfaces with
+ compatibility defaults on `Broker`.
+- Extracted isolate execution and pool lifecycle into an internal execution
+ supervisor, and hardened shutdown against late delivery errors after the
+ worker event stream closes.
+- Centralized worker event emission so late timers, retries, heartbeats,
+ progress callbacks, and revocation notifications are safely ignored after
+ shutdown.
+- Extracted broker subscription ownership and stream error boundaries into an
+ internal worker consumer loop, including safe queue-subscription replacement
+ during pause and resume operations.
+
+## 0.2.2
+
+- Replaced string rate-limit fields with typed `RateLimit` values while
+ retaining legacy parsing at JSON/configuration boundaries.
+- Added cooperative task cancellation through
+ `TaskExecutionContext.cancellation`.
+- Made memory adapters available through the explicit
+ `package:stem/memory.dart` library.
+- Prevented status and result-wait operations from implicitly starting a
+ worker.
+- Moved release validation to dependency-ordered workspace automation and
+ added aggregate package quality gates.
+- Added a source-compatible `BrokerCapabilities` snapshot so adapters can
+ declare optional delivery, inspection, broadcast, lease, and dead-letter
+ behavior without expanding the base broker contract.
+- Added queue-broker extension methods for optional dead-letter inspection,
+ replay, and purge operations, allowing integrations to depend on
+ `QueueBroker` without reverting to the legacy broad `Broker` type.
+
## 0.2.1
- Guarded worker and example process signal registration so Windows only
diff --git a/packages/stem/README.md b/packages/stem/README.md
index 4be43c05..d87aa7f7 100644
--- a/packages/stem/README.md
+++ b/packages/stem/README.md
@@ -9,18 +9,25 @@
# Stem
-Stem is a Dart-first background job and workflow platform: enqueue work, run workers, and orchestrate durable workflows.
+Stem is an experimental Dart-first background job and workflow platform:
+enqueue work, run workers, and orchestrate durable workflows while the runtime
+contracts and adapter guarantees continue to harden.
For full docs, API references, and in-depth guides, visit
https://kingwill101.github.io/stem.
+For production-shaped task definitions, prefer `stem_builder` generated typed
+definitions. Manual `TaskDefinition` is the supported typed
+fallback. Low-level map handlers live under `package:stem/advanced.dart` for
+transport integrations and migrations.
+
## Packages
| Package | Description | pub.dev |
|---------|-------------|---------|
| [`stem`](https://github.com/kingwill101/stem/tree/master/packages/stem) | Core runtime: contracts, worker, scheduler, in-memory adapters, signals, Canvas, workflows | [](https://pub.dev/packages/stem) |
| [`stem_cli`](https://github.com/kingwill101/stem/tree/master/packages/stem_cli) | Command-line tooling (`stem` executable) and CLI utilities | [](https://pub.dev/packages/stem_cli) |
-| [`stem_memory`](https://github.com/kingwill101/stem/tree/master/packages/stem_memory) | In-memory adapter package (broker/backend/workflow/scheduler factories) | [](https://pub.dev/packages/stem_memory) |
+| [`stem_memory`](https://github.com/kingwill101/stem/tree/master/packages/stem_memory) | Compatibility package for the explicit `package:stem/memory.dart` in-memory library | [](https://pub.dev/packages/stem_memory) |
| [`stem_sqlite`](https://github.com/kingwill101/stem/tree/master/packages/stem_sqlite) | SQLite broker and result backend for local dev/testing | [](https://pub.dev/packages/stem_sqlite) |
| [`stem_redis`](https://github.com/kingwill101/stem/tree/master/packages/stem_redis) | Redis Streams broker, result backend, and watchdog helpers | [](https://pub.dev/packages/stem_redis) |
| [`stem_postgres`](https://github.com/kingwill101/stem/tree/master/packages/stem_postgres) | Postgres broker, result backend, and scheduler stores | [](https://pub.dev/packages/stem_postgres) |
@@ -40,7 +47,8 @@ https://kingwill101.github.io/stem.
- **Observability** - Dartastic OpenTelemetry metrics/traces, heartbeats, CLI inspection (`stem observe`, `stem dlq`).
- **Security** - Payload signing (HMAC or Ed25519), TLS automation scripts, revocation persistence.
- **Adapters** - In-memory drivers included here; Redis Streams and Postgres adapters ship via the `stem_redis` and `stem_postgres` packages.
-- **Specs & tooling** - OpenSpec change workflow, quality gates (see `example/quality_gates`), chaos/regression suites.
+- **Quality tooling** - Package-level format, analysis, test, adapter contract,
+ chaos, benchmark, and standalone-resolution gates in CI.
## Install
@@ -54,39 +62,56 @@ dart pub add -d stem_builder # for annotations/codegen (optional)
dart pub add -d stem_cli # for CLI tooling
```
+New application code can import `package:stem/stable.dart`. The historical
+`package:stem/stem.dart` barrel remains available for compatibility, while
+custom transports and instrumentation can use `package:stem/advanced.dart`.
+
+For local development and tests, import the in-memory implementations
+explicitly with `package:stem/memory.dart`; the `stem_memory` package remains
+as a compatibility export for existing applications.
+
## Examples
-`StemApp` and `StemWorkflowApp` shortcut helpers lazily start their managed
-worker by default. Pass `allowWorkerAutoStart: false` when you want producer
-or orchestration shortcuts without starting that worker in the background,
-then call `start()` explicitly when you're ready. `StemWorkflowApp` also
-exposes `startRuntime()` and `startWorker()` when you want those lifecycles
-split.
+`StemApp` and `StemWorkflowApp` never start their managed worker implicitly.
+Call `start()` explicitly when the process is intended to consume work.
+`StemWorkflowApp` also exposes `startRuntime()` and `startWorker()` when you
+want those lifecycles split.
### Minimal in-memory task + worker
```dart
import "dart:async";
-import "package:stem/stem.dart";
+import "package:stem/stable.dart";
-class HelloTask extends TaskHandler {
- @override
- String get name => "demo.hello";
+class HelloArgs {
+ const HelloArgs({required this.name});
- @override
- Future call(TaskContext context, Map args) async {
- final name = args.valueOr("name", "world");
- print("Hello $name");
- }
+ final String name;
+
+ Map toJson() => {"name": name};
+
+ factory HelloArgs.fromJson(Map json) =>
+ HelloArgs(name: json["name"] as String);
}
+final helloDefinition = TaskDefinition.json(
+ name: "demo.hello",
+ decodeArgsJson: HelloArgs.fromJson,
+);
+
+final helloTask = helloDefinition.handler(
+ entrypoint: (context, args) async => print("Hello ${args.name}"),
+);
+
Future main() async {
- final client = await StemClient.inMemory(tasks: [HelloTask()]);
+ final client = await StemClient.inMemory(tasks: [helloTask]);
final worker = await client.createWorker();
unawaited(worker.start());
- await client.enqueueValue("demo.hello", const {"name": "Stem"});
+ await client.enqueueCall(
+ helloDefinition.buildCall(const HelloArgs(name: "Stem")),
+ );
await Future.delayed(const Duration(seconds: 1));
await worker.shutdown();
@@ -97,7 +122,7 @@ Future main() async {
### Reusable stack from URL (Redis)
```dart
-import "package:stem/stem.dart";
+import "package:stem/stable.dart";
import "package:stem_redis/stem_redis.dart";
Future main() async {
@@ -133,28 +158,22 @@ class HelloArgs {
HelloArgs(name: json["name"] as String);
}
-class HelloTask2 extends TaskHandler {
- static final definition = TaskDefinition.json(
- name: "demo.hello2",
- metadata: const TaskMetadata(description: "typed hello task"),
- );
-
- @override
- String get name => definition.name;
+final helloDefinition = TaskDefinition.json(
+ name: "demo.hello2",
+ decodeArgsJson: HelloArgs.fromJson,
+ metadata: const TaskMetadata(description: "typed hello task"),
+);
- @override
- Future call(TaskContext context, Map args) async {
- final payload = HelloArgs.fromJson(args.cast());
- return "Hello ${payload.name}";
- }
-}
+final helloTask = helloDefinition.handler(
+ entrypoint: (context, args) async => "Hello ${args.name}",
+);
Future main() async {
- final client = await StemClient.inMemory(tasks: [HelloTask2()]);
+ final client = await StemClient.inMemory(tasks: [helloTask]);
final worker = await client.createWorker();
unawaited(worker.start());
- final result = await HelloTask2.definition.enqueueAndWait(
+ final result = await helloDefinition.enqueueAndWait(
client,
const HelloArgs(name: "Typed"),
);
@@ -168,7 +187,7 @@ Future main() async {
### Workflow quick-start (Flow)
```dart
-import "package:stem/stem.dart";
+import "package:stem/stable.dart";
final onboardingFlow = Flow(
name: "demo.onboarding",
@@ -184,7 +203,6 @@ Future main() async {
final appClient = await StemClient.inMemory();
final app = await appClient.createWorkflowApp(
flows: [onboardingFlow],
- allowWorkerAutoStart: false,
);
await app.start();
@@ -201,7 +219,7 @@ Future main() async {
### Annotated workflow + task with `stem_builder`
```dart
-import "package:stem/stem.dart";
+import "package:stem/stable.dart";
import "package:stem_builder/stem_builder.dart";
part "definitions.stem.g.dart";
@@ -241,7 +259,7 @@ dart run build_runner build
```dart
// example usage after codegen
final client = await StemClient.inMemory(module: stemModule);
-final app = await client.createWorkflowApp(allowWorkerAutoStart: false);
+final app = await client.createWorkflowApp();
await app.start();
final runId = await StemWorkflowDefinitions.builderSignup.startAndWait(
@@ -255,7 +273,8 @@ print(result?.value); // {user: alice@example.com}
### Workflow with multiple worker queues
```dart
-import "package:stem/stem.dart";
+import "package:stem/stable.dart";
+import "package:stem/advanced.dart";
final onboardingFlow = Flow