diff --git a/package.json b/package.json index 19a4775..4ec33f0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tangle-network/agent-runtime", - "version": "0.12.1", + "version": "0.13.0", "description": "Reusable runtime lifecycle for domain-specific agents.", "homepage": "https://github.com/tangle-network/agent-runtime#readme", "repository": { @@ -58,7 +58,9 @@ "devDependencies": { "@biomejs/biome": "^2.4.0", "@tangle-network/sandbox": "0.1.2", + "@types/better-sqlite3": "^7.6.13", "@types/node": "^25.6.0", + "better-sqlite3": "^12.10.0", "tsup": "^8.0.0", "typescript": "^5.7.0", "vitest": "^3.0.0" @@ -69,6 +71,7 @@ "@tangle-network/agent-eval" ], "onlyBuiltDependencies": [ + "better-sqlite3", "esbuild" ] }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6c12d20..dd193fb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,9 +18,15 @@ importers: '@tangle-network/sandbox': specifier: 0.1.2 version: 0.1.2(viem@2.48.8(typescript@5.9.3)(zod@4.4.2)) + '@types/better-sqlite3': + specifier: ^7.6.13 + version: 7.6.13 '@types/node': specifier: ^25.6.0 version: 25.6.0 + better-sqlite3: + specifier: ^12.10.0 + version: 12.10.0 tsup: specifier: ^8.0.0 version: 8.5.1(postcss@8.5.13)(typescript@5.9.3)(yaml@2.8.4) @@ -467,6 +473,9 @@ packages: engines: {node: '>=18'} hasBin: true + '@types/better-sqlite3@7.6.13': + resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -531,6 +540,22 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + better-sqlite3@12.10.0: + resolution: {integrity: sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==} + engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x || 26.x} + + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + bundle-require@5.1.0: resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -553,6 +578,9 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + commander@14.0.3: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} @@ -580,10 +608,25 @@ packages: supports-color: optional: true + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} @@ -598,6 +641,10 @@ packages: eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -611,18 +658,36 @@ packages: picomatch: optional: true + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + fix-dts-default-cjs-exports@1.0.1: resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + hono@4.12.16: resolution: {integrity: sha512-jN0ZewiNAWSe5khM3EyCmBb250+b40wWbwNILNfEvq84VREWwOIkuUsFONk/3i3nqkz7Oe1PcpM2mwQEK2L9Kg==} engines: {node: '>=16.9.0'} + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + isows@1.0.7: resolution: {integrity: sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==} peerDependencies: @@ -652,6 +717,16 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + mlly@1.8.2: resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} @@ -666,10 +741,20 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + + node-abi@3.92.0: + resolution: {integrity: sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==} + engines: {node: '>=10'} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + openapi3-ts@4.5.0: resolution: {integrity: sha512-jaL+HgTq2Gj5jRcfdutgRGLosCy/hT8sQf6VOy+P+g36cZOjI1iukdPnijC+4CmeRzg/jEllJUboEic2FhxhtQ==} @@ -724,6 +809,23 @@ packages: resolution: {integrity: sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==} engines: {node: ^10 || ^12 || >=14} + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} @@ -737,9 +839,23 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + semver@7.8.0: + resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} + engines: {node: '>=10'} + hasBin: true + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -754,6 +870,13 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} @@ -762,6 +885,13 @@ packages: engines: {node: '>=16 || 14 >=14.17'} hasBin: true + tar-fs@2.1.4: + resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -817,6 +947,9 @@ packages: typescript: optional: true + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -828,6 +961,9 @@ packages: undici-types@7.19.2: resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + viem@2.48.8: resolution: {integrity: sha512-Xj3Nrt66SKtn06kczU91ELn9Difr84ZM5A62BTlaisT5lpgt058i2mBkfMZCXHGb1ocOLjzC2ztPhD0Lvky7uQ==} peerDependencies: @@ -914,6 +1050,9 @@ packages: engines: {node: '>=8'} hasBin: true + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.18.3: resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} engines: {node: '>=10.0.0'} @@ -1231,6 +1370,10 @@ snapshots: - utf-8-validate - zod + '@types/better-sqlite3@7.6.13': + dependencies: + '@types/node': 25.6.0 + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -1297,6 +1440,28 @@ snapshots: assertion-error@2.0.1: {} + base64-js@1.5.1: {} + + better-sqlite3@12.10.0: + dependencies: + bindings: 1.5.0 + prebuild-install: 7.1.3 + + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + bundle-require@5.1.0(esbuild@0.27.7): dependencies: esbuild: 0.27.7 @@ -1318,6 +1483,8 @@ snapshots: dependencies: readdirp: 4.1.2 + chownr@1.1.4: {} + commander@14.0.3: {} commander@4.1.1: {} @@ -1332,8 +1499,20 @@ snapshots: dependencies: ms: 2.1.3 + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + deep-eql@5.0.2: {} + deep-extend@0.6.0: {} + + detect-libc@2.1.2: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + es-module-lexer@1.7.0: {} esbuild@0.27.7: @@ -1371,23 +1550,37 @@ snapshots: eventemitter3@5.0.1: {} + expand-template@2.0.3: {} + expect-type@1.3.0: {} fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 + file-uri-to-path@1.0.0: {} + fix-dts-default-cjs-exports@1.0.1: dependencies: magic-string: 0.30.21 mlly: 1.8.2 rollup: 4.60.2 + fs-constants@1.0.0: {} + fsevents@2.3.3: optional: true + github-from-package@0.0.0: {} + hono@4.12.16: {} + ieee754@1.2.1: {} + + inherits@2.0.4: {} + + ini@1.3.8: {} + isows@1.0.7(ws@8.18.3): dependencies: ws: 8.18.3 @@ -1408,6 +1601,12 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + mimic-response@3.1.0: {} + + minimist@1.2.8: {} + + mkdirp-classic@0.5.3: {} + mlly@1.8.2: dependencies: acorn: 8.16.0 @@ -1425,8 +1624,18 @@ snapshots: nanoid@3.3.12: {} + napi-build-utils@2.0.0: {} + + node-abi@3.92.0: + dependencies: + semver: 7.8.0 + object-assign@4.1.1: {} + once@1.4.0: + dependencies: + wrappy: 1.0.2 + openapi3-ts@4.5.0: dependencies: yaml: 2.8.4 @@ -1475,6 +1684,39 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.92.0 + pump: 3.0.4 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.4 + tunnel-agent: 0.6.0 + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + readdirp@4.1.2: {} resolve-from@5.0.0: {} @@ -1510,8 +1752,20 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.60.2 fsevents: 2.3.3 + safe-buffer@5.2.1: {} + + semver@7.8.0: {} + siginfo@2.0.0: {} + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + source-map-js@1.2.1: {} source-map@0.7.6: {} @@ -1520,6 +1774,12 @@ snapshots: std-env@3.10.0: {} + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-json-comments@2.0.1: {} + strip-literal@3.1.0: dependencies: js-tokens: 9.0.1 @@ -1534,6 +1794,21 @@ snapshots: tinyglobby: 0.2.16 ts-interface-checker: 0.1.13 + tar-fs@2.1.4: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.4 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + thenify-all@1.6.0: dependencies: thenify: 3.3.1 @@ -1589,12 +1864,18 @@ snapshots: - tsx - yaml + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + typescript@5.9.3: {} ufo@1.6.4: {} undici-types@7.19.2: {} + util-deprecate@1.0.2: {} + viem@2.48.8(typescript@5.9.3)(zod@4.4.2): dependencies: '@noble/curves': 1.9.1 @@ -1692,6 +1973,8 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + wrappy@1.0.2: {} + ws@8.18.3: {} yaml@2.8.4: {} diff --git a/src/durable/d1-store.ts b/src/durable/d1-store.ts new file mode 100644 index 0000000..fc538bb --- /dev/null +++ b/src/durable/d1-store.ts @@ -0,0 +1,461 @@ +/** + * D1DurableRunStore — the production path for Cloudflare Workers. Backed by + * a D1 (SQLite-compatible) database via the binding the worker already holds. + * + * Apply `./schema.sql` once before use; the store itself does not run DDL. + * Migration version is recorded in `durable_schema_info`; consumers can + * inspect `getSchemaVersion()` if they ship a migration tool. + * + * Why structural typing: agent-runtime stays Cloudflare-free at the dep + * level. Consumers pass their `D1Database` binding — TypeScript matches the + * minimal `D1DatabaseLike` surface below. Tests use the same interface with + * a fake. + */ + +import { manifestHash } from './identity' +import { + DurableRunDivergenceError, + DurableRunInputMismatchError, + DurableRunLeaseHeldError, + type DurableRunManifest, + type DurableRunStore, + type EventRecord, + type RunOutcome, + type RunRecord, + type StepError, + type StepKind, + type StepRecord, +} from './types' + +const DEFAULT_LEASE_MS = 30_000 + +/** + * Minimal D1 surface this store uses. Compatible with Cloudflare's + * `D1Database` from `@cloudflare/workers-types`. Defined locally so + * agent-runtime does not depend on workers-types at the package level. + */ +export interface D1DatabaseLike { + prepare(query: string): D1PreparedStatementLike + batch(statements: D1PreparedStatementLike[]): Promise +} + +export interface D1PreparedStatementLike { + bind(...values: unknown[]): D1PreparedStatementLike + first(): Promise + all(): Promise<{ results: T[] }> + run(): Promise<{ success: boolean; meta?: { changes?: number } }> +} + +interface RunRow { + run_id: string + manifest_hash: string + project_id: string + scenario_id: string | null + status: RunRecord['status'] + created_at: string + updated_at: string + completed_at: string | null + lease_holder_id: string | null + lease_expires_at: string | null + outcome_json: string | null + step_count: number +} + +interface StepRow { + run_id: string + step_index: number + intent: string + kind: string + input_hash: string + status: StepRecord['status'] + attempts: number + result_json: string | null + error_json: string | null + started_at: string | null + completed_at: string | null +} + +interface EventRow { + run_id: string + key: string + payload_json: string | null + emitted_at: string +} + +export class D1DurableRunStore implements DurableRunStore { + constructor(private readonly db: D1DatabaseLike) {} + + /** Override for tests — defaults to Date.now(). */ + public now: () => number = () => Date.now() + + async startOrResume(input: { + runId: string + manifest: DurableRunManifest + workerId: string + leaseMs?: number + }): ReturnType { + const leaseMs = input.leaseMs ?? DEFAULT_LEASE_MS + const hash = manifestHash(input.manifest) + const nowMs = this.now() + const nowIso = new Date(nowMs).toISOString() + const leaseExpiresAt = new Date(nowMs + leaseMs).toISOString() + + const existing = await this.db + .prepare('SELECT * FROM durable_runs WHERE run_id = ?') + .bind(input.runId) + .first() + + if (!existing) { + await this.db + .prepare( + `INSERT INTO durable_runs + (run_id, manifest_hash, project_id, scenario_id, status, + created_at, updated_at, lease_holder_id, lease_expires_at, step_count) + VALUES (?, ?, ?, ?, 'running', ?, ?, ?, ?, 0)`, + ) + .bind( + input.runId, + hash, + input.manifest.projectId, + input.manifest.scenarioId ?? null, + nowIso, + nowIso, + input.workerId, + leaseExpiresAt, + ) + .run() + const record: RunRecord = { + runId: input.runId, + manifestHash: hash, + projectId: input.manifest.projectId, + scenarioId: input.manifest.scenarioId, + status: 'running', + createdAt: nowIso, + updatedAt: nowIso, + leaseHolderId: input.workerId, + leaseExpiresAt, + stepCount: 0, + } + return { run: record, completedSteps: [], leaseExpiresAt } + } + + if (existing.manifest_hash !== hash) { + throw new DurableRunInputMismatchError( + `runId ${input.runId} exists with a different manifest hash; refuse to corrupt prior steps`, + ) + } + // Lease takeover — conditional UPDATE. + const claim = await this.db + .prepare( + `UPDATE durable_runs + SET lease_holder_id = ?, + lease_expires_at = ?, + updated_at = ?, + status = CASE WHEN status IN ('completed','failed') THEN status ELSE 'running' END + WHERE run_id = ? + AND ( + lease_holder_id = ? OR + lease_holder_id IS NULL OR + lease_expires_at IS NULL OR + lease_expires_at < ? + )`, + ) + .bind(input.workerId, leaseExpiresAt, nowIso, input.runId, input.workerId, nowIso) + .run() + const changes = claim.meta?.changes ?? 0 + if (changes === 0) { + throw new DurableRunLeaseHeldError( + `runId ${input.runId} leased by ${existing.lease_holder_id} until ${existing.lease_expires_at}`, + ) + } + const completedSteps = await this.readSteps(input.runId, 'completed') + const record: RunRecord = rowToRunRecord({ + ...existing, + lease_holder_id: input.workerId, + lease_expires_at: leaseExpiresAt, + updated_at: nowIso, + status: + existing.status === 'completed' || existing.status === 'failed' + ? existing.status + : 'running', + }) + return { run: record, completedSteps, leaseExpiresAt } + } + + async renewLease(input: { + runId: string + workerId: string + leaseMs?: number + }): Promise<{ ok: boolean; leaseExpiresAt?: string }> { + const nowMs = this.now() + const nowIso = new Date(nowMs).toISOString() + const leaseExpiresAt = new Date(nowMs + (input.leaseMs ?? DEFAULT_LEASE_MS)).toISOString() + const res = await this.db + .prepare( + `UPDATE durable_runs + SET lease_expires_at = ?, updated_at = ? + WHERE run_id = ? + AND (lease_holder_id = ? OR lease_expires_at IS NULL OR lease_expires_at < ?)`, + ) + .bind(leaseExpiresAt, nowIso, input.runId, input.workerId, nowIso) + .run() + const ok = (res.meta?.changes ?? 0) > 0 + return ok ? { ok: true, leaseExpiresAt } : { ok: false } + } + + async loadStep(runId: string, stepIndex: number): Promise { + const row = await this.db + .prepare('SELECT * FROM durable_steps WHERE run_id = ? AND step_index = ?') + .bind(runId, stepIndex) + .first() + return row ? rowToStepRecord(row) : undefined + } + + async beginStep(input: { + runId: string + stepIndex: number + intent: string + kind: StepKind + inputHash: string + }): Promise { + const nowIso = new Date(this.now()).toISOString() + const prior = await this.loadStep(input.runId, input.stepIndex) + if (prior) { + if (prior.intent !== input.intent) { + throw new DurableRunDivergenceError( + `step ${input.stepIndex}: intent changed ('${prior.intent}' -> '${input.intent}')`, + ) + } + await this.db + .prepare( + `UPDATE durable_steps + SET status='running', attempts = attempts + 1, started_at = ?, error_json = NULL + WHERE run_id = ? AND step_index = ?`, + ) + .bind(nowIso, input.runId, input.stepIndex) + .run() + await this.bumpUpdated(input.runId, nowIso) + return { + ...prior, + attempts: prior.attempts + 1, + status: 'running', + startedAt: nowIso, + error: undefined, + } + } + await this.db + .prepare( + `INSERT INTO durable_steps + (run_id, step_index, intent, kind, input_hash, status, attempts, started_at) + VALUES (?, ?, ?, ?, ?, 'running', 1, ?)`, + ) + .bind(input.runId, input.stepIndex, input.intent, input.kind, input.inputHash, nowIso) + .run() + await this.db + .prepare( + `UPDATE durable_runs + SET step_count = MAX(step_count, ?), updated_at = ? + WHERE run_id = ?`, + ) + .bind(input.stepIndex + 1, nowIso, input.runId) + .run() + return { + runId: input.runId, + stepIndex: input.stepIndex, + intent: input.intent, + kind: input.kind, + inputHash: input.inputHash, + status: 'running', + attempts: 1, + startedAt: nowIso, + } + } + + async completeStep(input: { + runId: string + stepIndex: number + result: unknown + }): Promise { + const nowIso = new Date(this.now()).toISOString() + await this.db + .prepare( + `UPDATE durable_steps + SET status='completed', result_json = ?, completed_at = ?, error_json = NULL + WHERE run_id = ? AND step_index = ?`, + ) + .bind(JSON.stringify(input.result ?? null), nowIso, input.runId, input.stepIndex) + .run() + await this.bumpUpdated(input.runId, nowIso) + const row = await this.loadStep(input.runId, input.stepIndex) + if (!row) { + throw new Error(`durable-runs: completeStep cannot find step ${input.stepIndex}`) + } + return row + } + + async failStep(input: { + runId: string + stepIndex: number + error: StepError + }): Promise { + const nowIso = new Date(this.now()).toISOString() + await this.db + .prepare( + `UPDATE durable_steps + SET status='failed', error_json = ?, completed_at = ? + WHERE run_id = ? AND step_index = ?`, + ) + .bind(JSON.stringify(input.error), nowIso, input.runId, input.stepIndex) + .run() + await this.bumpUpdated(input.runId, nowIso) + const row = await this.loadStep(input.runId, input.stepIndex) + if (!row) { + throw new Error(`durable-runs: failStep cannot find step ${input.stepIndex}`) + } + return row + } + + async endRun(input: { + runId: string + workerId: string + status: 'completed' | 'failed' + outcome?: RunOutcome + }): Promise { + const nowIso = new Date(this.now()).toISOString() + await this.db + .prepare( + `UPDATE durable_runs + SET status = ?, completed_at = ?, updated_at = ?, + outcome_json = ?, + lease_holder_id = CASE WHEN lease_holder_id = ? THEN NULL ELSE lease_holder_id END, + lease_expires_at = CASE WHEN lease_holder_id = ? THEN NULL ELSE lease_expires_at END + WHERE run_id = ?`, + ) + .bind( + input.status, + nowIso, + nowIso, + input.outcome ? JSON.stringify(input.outcome) : null, + input.workerId, + input.workerId, + input.runId, + ) + .run() + const row = await this.db + .prepare('SELECT * FROM durable_runs WHERE run_id = ?') + .bind(input.runId) + .first() + if (!row) throw new Error(`durable-runs: endRun cannot find run ${input.runId}`) + return rowToRunRecord(row) + } + + async emitEvent(input: { + runId: string + key: string + payload: unknown + }): ReturnType { + const nowIso = new Date(this.now()).toISOString() + // INSERT OR IGNORE — first emit wins; subsequent inserts no-op. + const res = await this.db + .prepare( + `INSERT OR IGNORE INTO durable_events (run_id, key, payload_json, emitted_at) + VALUES (?, ?, ?, ?)`, + ) + .bind(input.runId, input.key, JSON.stringify(input.payload ?? null), nowIso) + .run() + const accepted = (res.meta?.changes ?? 0) > 0 + const row = await this.db + .prepare('SELECT * FROM durable_events WHERE run_id = ? AND key = ?') + .bind(input.runId, input.key) + .first() + if (!row) throw new Error('durable-runs: emitEvent failed to persist or read back') + return { + accepted, + record: rowToEventRecord(row), + } + } + + async loadEvent(runId: string, key: string): Promise { + const row = await this.db + .prepare('SELECT * FROM durable_events WHERE run_id = ? AND key = ?') + .bind(runId, key) + .first() + return row ? rowToEventRecord(row) : undefined + } + + async close(): Promise { + // D1 binding lifecycle is owned by the runtime; no-op. + } + + /** Inspect the currently-applied schema version. */ + async getSchemaVersion(): Promise { + const row = await this.db + .prepare('SELECT MAX(version) AS version FROM durable_schema_info') + .first<{ version: number | null }>() + return row?.version ?? undefined + } + + // ── internals ────────────────────────────────────────────────────── + + private async readSteps( + runId: string, + status: StepRecord['status'], + ): Promise> { + const { results } = await this.db + .prepare('SELECT * FROM durable_steps WHERE run_id = ? AND status = ? ORDER BY step_index') + .bind(runId, status) + .all() + return results.map(rowToStepRecord) + } + + private async bumpUpdated(runId: string, nowIso: string): Promise { + await this.db + .prepare('UPDATE durable_runs SET updated_at = ? WHERE run_id = ?') + .bind(nowIso, runId) + .run() + } +} + +// ── row → record helpers ─────────────────────────────────────────────── + +function rowToRunRecord(row: RunRow): RunRecord { + return { + runId: row.run_id, + manifestHash: row.manifest_hash, + projectId: row.project_id, + scenarioId: row.scenario_id ?? undefined, + status: row.status, + createdAt: row.created_at, + updatedAt: row.updated_at, + completedAt: row.completed_at ?? undefined, + leaseHolderId: row.lease_holder_id ?? undefined, + leaseExpiresAt: row.lease_expires_at ?? undefined, + outcome: row.outcome_json ? (JSON.parse(row.outcome_json) as RunOutcome) : undefined, + stepCount: row.step_count, + } +} + +function rowToStepRecord(row: StepRow): StepRecord { + return { + runId: row.run_id, + stepIndex: row.step_index, + intent: row.intent, + kind: row.kind as StepKind, + inputHash: row.input_hash, + status: row.status, + attempts: row.attempts, + result: row.result_json ? JSON.parse(row.result_json) : undefined, + error: row.error_json ? (JSON.parse(row.error_json) as StepError) : undefined, + startedAt: row.started_at ?? undefined, + completedAt: row.completed_at ?? undefined, + } +} + +function rowToEventRecord(row: EventRow): EventRecord { + return { + runId: row.run_id, + key: row.key, + payload: row.payload_json ? JSON.parse(row.payload_json) : null, + emittedAt: row.emitted_at, + } +} diff --git a/src/durable/file-system-store.ts b/src/durable/file-system-store.ts new file mode 100644 index 0000000..9f5549f --- /dev/null +++ b/src/durable/file-system-store.ts @@ -0,0 +1,373 @@ +/** + * FileSystemDurableRunStore — durable-run substrate backed by a directory + * tree under a single root. One subdir per run: + * + * // + * run.json — RunRecord (rewritten on every mutation; the only + * scalar fields are status/lease, so this stays small) + * steps.jsonl — append-only StepRecord stream; one JSON per line + * events.jsonl — append-only EventRecord stream + * lease.json — current leaseholder + deadline (separate from + * run.json so renewLease writes one tiny file + * instead of round-tripping the whole run record) + * + * Concurrency: the eval harness is single-process — we rely on Node's + * append-mode semantics for atomicity of step / event writes (single-line + * writes < PIPE_BUF are atomic on POSIX). For run.json / lease.json we write + * to a `.tmp` then `rename` to make replacement atomic. This is + * sufficient for the single-process eval harness use case. Multi-process + * concurrency on the SAME filesystem requires a flock-based extension; + * for that path use D1DurableRunStore. + */ + +import { existsSync, mkdirSync } from 'node:fs' +import { appendFile, readdir, readFile, rename, writeFile } from 'node:fs/promises' +import { join } from 'node:path' + +import { manifestHash } from './identity' +import { + DurableRunDivergenceError, + DurableRunInputMismatchError, + DurableRunLeaseHeldError, + type DurableRunManifest, + type DurableRunStore, + type EventRecord, + type RunOutcome, + type RunRecord, + type StepError, + type StepKind, + type StepRecord, +} from './types' + +const DEFAULT_LEASE_MS = 30_000 + +interface LeaseFile { + workerId: string + leaseExpiresAt: string +} + +export class FileSystemDurableRunStore implements DurableRunStore { + constructor(private readonly root: string) { + mkdirSync(root, { recursive: true }) + } + + /** Override for tests — defaults to Date.now(). */ + public now: () => number = () => Date.now() + + async startOrResume(input: { + runId: string + manifest: DurableRunManifest + workerId: string + leaseMs?: number + }): ReturnType { + const leaseMs = input.leaseMs ?? DEFAULT_LEASE_MS + const hash = manifestHash(input.manifest) + const nowMs = this.now() + const nowIso = new Date(nowMs).toISOString() + const leaseExpiresAt = new Date(nowMs + leaseMs).toISOString() + const dir = this.runDir(input.runId) + + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + const record: RunRecord = { + runId: input.runId, + manifestHash: hash, + projectId: input.manifest.projectId, + scenarioId: input.manifest.scenarioId, + status: 'running', + createdAt: nowIso, + updatedAt: nowIso, + leaseHolderId: input.workerId, + leaseExpiresAt, + stepCount: 0, + } + await this.writeRun(record) + await this.writeLease(input.runId, { workerId: input.workerId, leaseExpiresAt }) + // Touch the jsonl files so listing them later doesn't ENOENT. + await appendFile(join(dir, 'steps.jsonl'), '', 'utf8') + await appendFile(join(dir, 'events.jsonl'), '', 'utf8') + return { run: record, completedSteps: [], leaseExpiresAt } + } + + const record = await this.readRun(input.runId) + if (record.manifestHash !== hash) { + throw new DurableRunInputMismatchError( + `runId ${input.runId} exists with a different manifest hash; refuse to corrupt prior steps`, + ) + } + const lease = await this.readLeaseSafe(input.runId) + const leaseStillLive = + lease && lease.workerId !== input.workerId && new Date(lease.leaseExpiresAt).getTime() > nowMs + if (leaseStillLive) { + throw new DurableRunLeaseHeldError( + `runId ${input.runId} leased by ${lease.workerId} until ${lease.leaseExpiresAt}`, + ) + } + const completedSteps = await this.readSteps(input.runId) + const nextRecord: RunRecord = { + ...record, + status: + record.status === 'completed' || record.status === 'failed' ? record.status : 'running', + updatedAt: nowIso, + leaseHolderId: input.workerId, + leaseExpiresAt, + } + await this.writeRun(nextRecord) + await this.writeLease(input.runId, { workerId: input.workerId, leaseExpiresAt }) + return { run: nextRecord, completedSteps, leaseExpiresAt } + } + + async renewLease(input: { + runId: string + workerId: string + leaseMs?: number + }): Promise<{ ok: boolean; leaseExpiresAt?: string }> { + const lease = await this.readLeaseSafe(input.runId) + const nowMs = this.now() + if (lease && lease.workerId !== input.workerId) { + if (new Date(lease.leaseExpiresAt).getTime() > nowMs) { + return { ok: false } + } + } + const leaseExpiresAt = new Date(nowMs + (input.leaseMs ?? DEFAULT_LEASE_MS)).toISOString() + await this.writeLease(input.runId, { workerId: input.workerId, leaseExpiresAt }) + return { ok: true, leaseExpiresAt } + } + + async loadStep(runId: string, stepIndex: number): Promise { + const steps = await this.readSteps(runId, { includeFailed: true, includeRunning: true }) + return steps.find((s) => s.stepIndex === stepIndex) + } + + async beginStep(input: { + runId: string + stepIndex: number + intent: string + kind: StepKind + inputHash: string + }): Promise { + const all = await this.readSteps(input.runId, { includeFailed: true, includeRunning: true }) + const prior = all.find((s) => s.stepIndex === input.stepIndex) + const nowIso = new Date(this.now()).toISOString() + if (prior) { + if (prior.intent !== input.intent) { + throw new DurableRunDivergenceError( + `step ${input.stepIndex}: intent changed ('${prior.intent}' -> '${input.intent}')`, + ) + } + const rec: StepRecord = { + ...prior, + attempts: prior.attempts + 1, + status: 'running', + startedAt: nowIso, + error: undefined, + } + await this.appendStep(input.runId, rec) + await this.bumpRunUpdated(input.runId, nowIso) + return rec + } + const rec: StepRecord = { + runId: input.runId, + stepIndex: input.stepIndex, + intent: input.intent, + kind: input.kind, + inputHash: input.inputHash, + status: 'running', + attempts: 1, + startedAt: nowIso, + } + await this.appendStep(input.runId, rec) + // Bump run.stepCount opportunistically. + const record = await this.readRun(input.runId) + record.stepCount = Math.max(record.stepCount, input.stepIndex + 1) + record.updatedAt = nowIso + await this.writeRun(record) + return rec + } + + async completeStep(input: { + runId: string + stepIndex: number + result: unknown + }): Promise { + const all = await this.readSteps(input.runId, { includeFailed: true, includeRunning: true }) + const prior = all.find((s) => s.stepIndex === input.stepIndex) + if (!prior) { + throw new Error( + `durable-runs: completeStep called before beginStep (step ${input.stepIndex})`, + ) + } + const nowIso = new Date(this.now()).toISOString() + const rec: StepRecord = { + ...prior, + status: 'completed', + result: input.result, + completedAt: nowIso, + error: undefined, + } + await this.appendStep(input.runId, rec) + await this.bumpRunUpdated(input.runId, nowIso) + return rec + } + + async failStep(input: { + runId: string + stepIndex: number + error: StepError + }): Promise { + const all = await this.readSteps(input.runId, { includeFailed: true, includeRunning: true }) + const prior = all.find((s) => s.stepIndex === input.stepIndex) + if (!prior) { + throw new Error(`durable-runs: failStep called before beginStep (step ${input.stepIndex})`) + } + const nowIso = new Date(this.now()).toISOString() + const rec: StepRecord = { + ...prior, + status: 'failed', + error: input.error, + completedAt: nowIso, + } + await this.appendStep(input.runId, rec) + await this.bumpRunUpdated(input.runId, nowIso) + return rec + } + + async endRun(input: { + runId: string + workerId: string + status: 'completed' | 'failed' + outcome?: RunOutcome + }): Promise { + const record = await this.readRun(input.runId) + const nowIso = new Date(this.now()).toISOString() + record.status = input.status + record.outcome = input.outcome + record.completedAt = nowIso + record.updatedAt = nowIso + const lease = await this.readLeaseSafe(input.runId) + if (lease && lease.workerId === input.workerId) { + record.leaseHolderId = undefined + record.leaseExpiresAt = undefined + await this.writeLease(input.runId, null) + } + await this.writeRun(record) + return record + } + + async emitEvent(input: { + runId: string + key: string + payload: unknown + }): ReturnType { + const existing = await this.loadEvent(input.runId, input.key) + if (existing) return { accepted: false, record: existing } + const rec: EventRecord = { + runId: input.runId, + key: input.key, + payload: input.payload, + emittedAt: new Date(this.now()).toISOString(), + } + await appendFile( + join(this.runDir(input.runId), 'events.jsonl'), + `${JSON.stringify(rec)}\n`, + 'utf8', + ) + return { accepted: true, record: rec } + } + + async loadEvent(runId: string, key: string): Promise { + const path = join(this.runDir(runId), 'events.jsonl') + if (!existsSync(path)) return undefined + const content = await readFile(path, 'utf8') + for (const line of content.split('\n').reverse()) { + if (!line) continue + const rec = JSON.parse(line) as EventRecord + if (rec.key === key) return rec + } + return undefined + } + + async close(): Promise { + // No persistent handles to close. + } + + /** @internal — used by tests to list runs in the store. */ + async _listRunIds(): Promise { + if (!existsSync(this.root)) return [] + const entries = await readdir(this.root, { withFileTypes: true }) + return entries.filter((e) => e.isDirectory()).map((e) => e.name) + } + + // ── internals ────────────────────────────────────────────────────── + + private runDir(runId: string): string { + return join(this.root, runId) + } + + private async readRun(runId: string): Promise { + const path = join(this.runDir(runId), 'run.json') + const content = await readFile(path, 'utf8') + return JSON.parse(content) as RunRecord + } + + private async writeRun(record: RunRecord): Promise { + const dir = this.runDir(record.runId) + const path = join(dir, 'run.json') + const tmp = `${path}.tmp` + await writeFile(tmp, JSON.stringify(record, null, 2), 'utf8') + await rename(tmp, path) + } + + private async readLeaseSafe(runId: string): Promise { + const path = join(this.runDir(runId), 'lease.json') + if (!existsSync(path)) return undefined + try { + const content = await readFile(path, 'utf8') + if (!content.trim()) return undefined + return JSON.parse(content) as LeaseFile + } catch { + return undefined + } + } + + private async writeLease(runId: string, lease: LeaseFile | null): Promise { + const path = join(this.runDir(runId), 'lease.json') + const tmp = `${path}.tmp` + await writeFile(tmp, lease ? JSON.stringify(lease) : '', 'utf8') + await rename(tmp, path) + } + + private async readSteps( + runId: string, + opts: { includeFailed?: boolean; includeRunning?: boolean } = {}, + ): Promise { + const path = join(this.runDir(runId), 'steps.jsonl') + if (!existsSync(path)) return [] + const content = await readFile(path, 'utf8') + // Append-only log: later writes for the same stepIndex override earlier + // ones. Walk forward and keep the latest per index. + const latest = new Map() + for (const line of content.split('\n')) { + if (!line) continue + const rec = JSON.parse(line) as StepRecord + latest.set(rec.stepIndex, rec) + } + const out = [...latest.values()].sort((a, b) => a.stepIndex - b.stepIndex) + return out.filter((s) => { + if (s.status === 'completed') return true + if (s.status === 'failed') return opts.includeFailed ?? false + if (s.status === 'running') return opts.includeRunning ?? false + return false + }) + } + + private async appendStep(runId: string, rec: StepRecord): Promise { + await appendFile(join(this.runDir(runId), 'steps.jsonl'), `${JSON.stringify(rec)}\n`, 'utf8') + } + + private async bumpRunUpdated(runId: string, nowIso: string): Promise { + const record = await this.readRun(runId) + record.updatedAt = nowIso + await this.writeRun(record) + } +} diff --git a/src/durable/identity.ts b/src/durable/identity.ts new file mode 100644 index 0000000..ecae774 --- /dev/null +++ b/src/durable/identity.ts @@ -0,0 +1,103 @@ +/** + * Identity + canonical-hash helpers for the durable-runs substrate. + * + * Two boundary disciplines: + * + * 1. **Manifest hash** — sha256 over a sorted-key JSON of (projectId, + * scenarioId, task.id, task.intent, task.domain, input). Same hash = + * same run identity. Used to detect "same runId, different inputs." + * + * 2. **Step input hash** — sha256 over a sorted-key JSON of the step's + * input fingerprint. Used to detect drift across replays. + * + * Sorted-key JSON makes hashes deterministic regardless of object insertion + * order. NaN / Infinity / undefined / functions / symbols / class instances + * are rejected — pure JSON only at the boundary, so the hash matches whatever + * the store round-trips. + */ + +import { createHash } from 'node:crypto' + +import type { DurableRunManifest } from './types' + +/** sha256-hex over a JSON-canonicalized value. */ +export function canonicalHash(value: unknown): string { + const json = canonicalJson(value) + return createHash('sha256').update(json).digest('hex') +} + +/** Canonical JSON: object keys sorted lexicographically; arrays preserved. */ +export function canonicalJson(value: unknown): string { + return JSON.stringify(canonicalize(value)) +} + +function canonicalize(value: unknown): unknown { + if (value === null) return null + if (Array.isArray(value)) return value.map(canonicalize) + const t = typeof value + if (t === 'string' || t === 'boolean') return value + if (t === 'number') { + if (!Number.isFinite(value as number)) { + throw new TypeError(`canonicalJson: non-finite number ${String(value)} not serializable`) + } + return value + } + if (t === 'undefined' || t === 'function' || t === 'symbol') { + throw new TypeError(`canonicalJson: ${t} is not JSON-serializable`) + } + if (t === 'bigint') { + // BigInts have no JSON representation. Encode as string; tag for + // disambiguation so consumers can choose to reject or decode. + return { __bigint: String(value) } + } + if (t === 'object') { + const obj = value as Record + // Reject class instances — Date / Error / Map / Set / TypedArray — to + // force callers to project to plain JSON at the boundary. Errors round- + // tripped silently are the #1 source of "looks the same but differs" + // bugs. + const proto = Object.getPrototypeOf(obj) + if (proto !== null && proto !== Object.prototype) { + const ctor = obj.constructor?.name ?? 'unknown' + throw new TypeError( + `canonicalJson: class instance (${ctor}) is not JSON-serializable. Project to plain { ... } at the boundary.`, + ) + } + const keys = Object.keys(obj).sort() + const out: Record = {} + for (const k of keys) out[k] = canonicalize(obj[k]) + return out + } + throw new TypeError(`canonicalJson: unsupported type ${t}`) +} + +/** Hash a DurableRunManifest into the run identity component. */ +export function manifestHash(manifest: DurableRunManifest): string { + return canonicalHash({ + projectId: manifest.projectId, + scenarioId: manifest.scenarioId ?? null, + taskId: manifest.task.id, + taskIntent: manifest.task.intent, + taskDomain: manifest.task.domain, + input: manifest.input, + }) +} + +/** Stable per-step identifier — hash of (runId, position, intent). */ +export function stepId(runId: string, stepIndex: number, intent: string): string { + return canonicalHash({ runId, stepIndex, intent }) +} + +let counter = 0 +/** + * Stable worker id for a single process. Format: `host:pid:rand`. Random + * suffix prevents collisions when the host/pid pair is short-lived (e.g., + * Cloudflare isolates that recycle fast). + */ +export function deriveWorkerId(): string { + const host = process.env.HOSTNAME ?? 'host' + const pid = process.pid ?? 0 + const rand = Math.random().toString(36).slice(2, 10) + counter += 1 + return `${host}:${pid}:${rand}:${counter}` +} diff --git a/src/durable/in-memory-store.ts b/src/durable/in-memory-store.ts new file mode 100644 index 0000000..06e8b90 --- /dev/null +++ b/src/durable/in-memory-store.ts @@ -0,0 +1,291 @@ +/** + * In-memory DurableRunStore for dev + tests. Single-process. All state lives + * in maps. Lease enforcement is real (Date.now() vs lease deadline) so the + * crash-recovery + multi-worker race tests run identically against this and + * the file-system / D1 stores. + */ + +import { manifestHash } from './identity' +import type { + DurableRunManifest, + DurableRunStore, + EventRecord, + RunOutcome, + RunRecord, + StepError, + StepKind, + StepRecord, +} from './types' +import { + DurableRunDivergenceError, + DurableRunInputMismatchError, + DurableRunLeaseHeldError, +} from './types' + +const DEFAULT_LEASE_MS = 30_000 + +interface RunState { + record: RunRecord + steps: Map + events: Map +} + +export class InMemoryDurableRunStore implements DurableRunStore { + private readonly runs = new Map() + /** Override for tests — defaults to Date.now(). */ + public now: () => number = () => Date.now() + + async startOrResume(input: { + runId: string + manifest: DurableRunManifest + workerId: string + leaseMs?: number + }): ReturnType { + const leaseMs = input.leaseMs ?? DEFAULT_LEASE_MS + const hash = manifestHash(input.manifest) + const nowMs = this.now() + const nowIso = new Date(nowMs).toISOString() + const leaseExpiresMs = nowMs + leaseMs + const leaseExpiresAt = new Date(leaseExpiresMs).toISOString() + + let state = this.runs.get(input.runId) + if (!state) { + const record: RunRecord = { + runId: input.runId, + manifestHash: hash, + projectId: input.manifest.projectId, + scenarioId: input.manifest.scenarioId, + status: 'running', + createdAt: nowIso, + updatedAt: nowIso, + leaseHolderId: input.workerId, + leaseExpiresAt, + stepCount: 0, + } + state = { record, steps: new Map(), events: new Map() } + this.runs.set(input.runId, state) + return { run: { ...record }, completedSteps: [], leaseExpiresAt } + } + + if (state.record.manifestHash !== hash) { + throw new DurableRunInputMismatchError( + `runId ${input.runId} exists with a different manifest hash; refuse to corrupt prior steps`, + ) + } + // Lease check — held by another worker with a non-expired lease. + const leaseStillLive = + state.record.leaseHolderId !== undefined && + state.record.leaseHolderId !== input.workerId && + state.record.leaseExpiresAt !== undefined && + new Date(state.record.leaseExpiresAt).getTime() > nowMs + if (leaseStillLive) { + throw new DurableRunLeaseHeldError( + `runId ${input.runId} is leased by ${state.record.leaseHolderId} until ${state.record.leaseExpiresAt}`, + ) + } + // Acquire / renew. + state.record.leaseHolderId = input.workerId + state.record.leaseExpiresAt = leaseExpiresAt + state.record.status = + state.record.status === 'completed' || state.record.status === 'failed' + ? state.record.status + : 'running' + state.record.updatedAt = nowIso + const completed = [...state.steps.values()] + .filter((s) => s.status === 'completed') + .sort((a, b) => a.stepIndex - b.stepIndex) + return { + run: { ...state.record }, + completedSteps: completed.map((s) => ({ ...s })), + leaseExpiresAt, + } + } + + async renewLease(input: { + runId: string + workerId: string + leaseMs?: number + }): Promise<{ ok: boolean; leaseExpiresAt?: string }> { + const state = this.runs.get(input.runId) + if (!state) return { ok: false } + const nowMs = this.now() + if (state.record.leaseHolderId !== input.workerId) { + // Lease lapsed — another worker may have taken over. + if (state.record.leaseExpiresAt && new Date(state.record.leaseExpiresAt).getTime() > nowMs) { + return { ok: false } + } + } + const leaseExpiresMs = nowMs + (input.leaseMs ?? DEFAULT_LEASE_MS) + const leaseExpiresAt = new Date(leaseExpiresMs).toISOString() + state.record.leaseHolderId = input.workerId + state.record.leaseExpiresAt = leaseExpiresAt + state.record.updatedAt = new Date(nowMs).toISOString() + return { ok: true, leaseExpiresAt } + } + + async loadStep(runId: string, stepIndex: number): Promise { + const state = this.runs.get(runId) + return state ? cloneStep(state.steps.get(stepIndex)) : undefined + } + + async beginStep(input: { + runId: string + stepIndex: number + intent: string + kind: StepKind + inputHash: string + }): Promise { + const state = this.requireRun(input.runId) + const nowIso = new Date(this.now()).toISOString() + const prior = state.steps.get(input.stepIndex) + if (prior) { + if (prior.intent !== input.intent) { + throw new DurableRunDivergenceError( + `step ${input.stepIndex}: intent changed across replays ('${prior.intent}' -> '${input.intent}')`, + ) + } + // Begin called again — bump attempts. A prior failed or running step + // can re-execute; a prior completed step would be filtered before begin + // is called (the runner short-circuits on cached results). + prior.attempts += 1 + prior.status = 'running' + prior.startedAt = nowIso + prior.error = undefined + state.record.updatedAt = nowIso + return cloneStep(prior)! + } + const rec: StepRecord = { + runId: input.runId, + stepIndex: input.stepIndex, + intent: input.intent, + kind: input.kind, + inputHash: input.inputHash, + status: 'running', + attempts: 1, + startedAt: nowIso, + } + state.steps.set(input.stepIndex, rec) + state.record.stepCount = Math.max(state.record.stepCount, input.stepIndex + 1) + state.record.updatedAt = nowIso + return cloneStep(rec)! + } + + async completeStep(input: { + runId: string + stepIndex: number + result: unknown + }): Promise { + const state = this.requireRun(input.runId) + const rec = state.steps.get(input.stepIndex) + if (!rec) { + throw new Error( + `durable-runs: completeStep called before beginStep (step ${input.stepIndex})`, + ) + } + const nowIso = new Date(this.now()).toISOString() + rec.status = 'completed' + rec.result = input.result + rec.completedAt = nowIso + rec.error = undefined + state.record.updatedAt = nowIso + return cloneStep(rec)! + } + + async failStep(input: { + runId: string + stepIndex: number + error: StepError + }): Promise { + const state = this.requireRun(input.runId) + const rec = state.steps.get(input.stepIndex) + if (!rec) { + throw new Error(`durable-runs: failStep called before beginStep (step ${input.stepIndex})`) + } + const nowIso = new Date(this.now()).toISOString() + rec.status = 'failed' + rec.error = input.error + rec.completedAt = nowIso + state.record.updatedAt = nowIso + return cloneStep(rec)! + } + + async endRun(input: { + runId: string + workerId: string + status: 'completed' | 'failed' + outcome?: RunOutcome + }): Promise { + const state = this.requireRun(input.runId) + const nowIso = new Date(this.now()).toISOString() + state.record.status = input.status + state.record.outcome = input.outcome + state.record.completedAt = nowIso + state.record.updatedAt = nowIso + // Release lease iff caller still holds it. + if (state.record.leaseHolderId === input.workerId) { + state.record.leaseHolderId = undefined + state.record.leaseExpiresAt = undefined + } + return { ...state.record } + } + + async emitEvent(input: { + runId: string + key: string + payload: unknown + }): ReturnType { + const state = this.requireRun(input.runId) + const existing = state.events.get(input.key) + if (existing) { + return { accepted: false, record: { ...existing } } + } + const rec: EventRecord = { + runId: input.runId, + key: input.key, + payload: input.payload, + emittedAt: new Date(this.now()).toISOString(), + } + state.events.set(input.key, rec) + return { accepted: true, record: { ...rec } } + } + + async loadEvent(runId: string, key: string): Promise { + const state = this.runs.get(runId) + if (!state) return undefined + const rec = state.events.get(key) + return rec ? { ...rec } : undefined + } + + async close(): Promise { + this.runs.clear() + } + + // ── test helpers ─────────────────────────────────────────────────── + /** @internal — used by tests to inspect lease metadata. */ + _inspect(runId: string): RunRecord | undefined { + const s = this.runs.get(runId) + return s ? { ...s.record } : undefined + } + + /** @internal — used by tests to simulate lease expiry. */ + _expireLease(runId: string): void { + const s = this.runs.get(runId) + if (s) { + s.record.leaseHolderId = undefined + s.record.leaseExpiresAt = undefined + } + } + + private requireRun(runId: string): RunState { + const s = this.runs.get(runId) + if (!s) { + throw new Error(`durable-runs: run ${runId} not found (must call startOrResume first)`) + } + return s + } +} + +function cloneStep(rec: StepRecord | undefined): StepRecord | undefined { + if (!rec) return undefined + return { ...rec, error: rec.error ? { ...rec.error } : undefined } +} diff --git a/src/durable/index.ts b/src/durable/index.ts new file mode 100644 index 0000000..c28019d --- /dev/null +++ b/src/durable/index.ts @@ -0,0 +1,44 @@ +/** + * Durable-run substrate for `@tangle-network/agent-runtime`. + * + * Public surface — what consumers import. Implementations live in + * sibling files (in-memory / file-system / D1). Runner + ctx live in + * runner.ts. Identity helpers live in identity.ts. + * + * See `./types.ts` for the full type contract and concurrency model. + */ + +export type { D1DatabaseLike, D1PreparedStatementLike } from './d1-store' +export { D1DurableRunStore } from './d1-store' +export { FileSystemDurableRunStore } from './file-system-store' +export { canonicalHash, canonicalJson, deriveWorkerId, manifestHash, stepId } from './identity' +export { InMemoryDurableRunStore } from './in-memory-store' +export type { DurableContext, RunDurableInput, RunDurableResult } from './runner' +export { runDurable } from './runner' +export type { + DurableRunManifest, + DurableRunStore, + EventRecord, + RunOutcome, + RunRecord, + RunStatus, + StepError, + StepKind, + StepRecord, + StepStatus, +} from './types' +export { + DurableAwaitEventTimeoutError, + DurableRunDivergenceError, + DurableRunError, + DurableRunInputMismatchError, + DurableRunLeaseHeldError, +} from './types' + +// ── Cloudflare Workflows integration ────────────────────────────────── +export type { + RunOnWorkflowStepInput, + WorkflowStepConfig, + WorkflowStepLike, +} from './workflows' +export { runOnWorkflowStep } from './workflows' diff --git a/src/durable/runner.ts b/src/durable/runner.ts new file mode 100644 index 0000000..c3177f9 --- /dev/null +++ b/src/durable/runner.ts @@ -0,0 +1,360 @@ +/** + * Durable runner — wraps a user-supplied async function in checkpoint / + * resume / lease semantics. The user writes plain async code, awaiting + * `ctx.step(intent, fn)` boundaries. On worker crash, the next caller with + * the same `runId` skips completed steps and resumes from the first unfinished + * one. + * + * Invariants: + * + * - Step positions are derived from a monotonic counter on the ctx. The + * same intent at position N is the same step across replays. If the user + * reorders steps, position N changes intent and we raise + * DurableRunDivergenceError fail-loud. + * + * - `ctx.now()` and `ctx.uuid()` are checkpointed as zero-input logic steps + * with kind='deterministic'. On replay they return the recorded value. + * + * - `awaitEvent` writes a 'event' step that records the event payload on + * first awaited completion. On replay, the cached payload returns + * synchronously. If the event has not been emitted and the runner is in + * a fresh execution, it polls the store until timeout. + * + * - Lease renewal happens on a wall-clock interval (every leaseMs/3). If + * the store reports a lost lease, the runner aborts the current step + * execution and throws — letting whichever worker holds the lease pick + * up. Committed steps survive. + */ + +import { canonicalHash, deriveWorkerId, manifestHash } from './identity' +import type { + DurableRunManifest, + DurableRunStore, + RunOutcome, + RunRecord, + StepKind, + StepRecord, +} from './types' +import { DurableAwaitEventTimeoutError, DurableRunDivergenceError } from './types' + +export interface DurableContext { + readonly runId: string + readonly projectId: string + readonly scenarioId?: string + + /** + * Execute a checkpointed step. The step is identified by its **position** + * (monotonic counter on this ctx); `intent` is a human-readable label that + * must stay stable across replays. + * + * On first execution: runs `fn`, records the result, returns it. + * On replay: returns the recorded result WITHOUT calling `fn`. + * + * The `inputFingerprint` (optional) lets the runner detect "same intent, + * different inputs" — it gets hashed and compared. If you don't supply + * one, drift is allowed (input not checked). + */ + step( + intent: string, + fn: () => Promise, + opts?: { kind?: StepKind; inputFingerprint?: unknown }, + ): Promise + + /** Race-free first-emit-wins event wait. */ + awaitEvent(key: string, opts?: { timeoutMs?: number; pollMs?: number }): Promise + + /** Emit an event. First emit wins. Subsequent emits no-op. */ + emitEvent(key: string, payload: unknown): Promise<{ accepted: boolean }> + + /** Deterministic clock — checkpointed once per call. */ + now(): Promise + + /** Deterministic uuid — checkpointed once per call. */ + uuid(): Promise +} + +const DEFAULT_LEASE_MS = 30_000 +const DEFAULT_AWAIT_POLL_MS = 250 + +export interface RunDurableInput { + runId: string + manifest: DurableRunManifest + store: DurableRunStore + workerId?: string + leaseMs?: number + /** Total time budget for the run. Used for awaitEvent timeouts; runner + * itself doesn't kill long-running steps (the step fn must respect + * AbortSignal if it cares). */ + signal?: AbortSignal + taskFn: (ctx: DurableContext) => Promise + /** Default outcome on successful completion. */ + defaultOutcome?: RunOutcome +} + +export interface RunDurableResult { + result: TResult + record: RunRecord + /** All steps captured this run (replayed + freshly executed). */ + steps: ReadonlyArray +} + +export async function runDurable( + input: RunDurableInput, +): Promise> { + const workerId = input.workerId ?? deriveWorkerId() + const leaseMs = input.leaseMs ?? DEFAULT_LEASE_MS + const { completedSteps } = await input.store.startOrResume({ + runId: input.runId, + manifest: input.manifest, + workerId, + leaseMs, + }) + + // Pre-compute a position-indexed view of prior steps for O(1) replay + // lookups. We DON'T trust that the store returned them in order — sort. + const priorByIndex = new Map() + for (const s of completedSteps) priorByIndex.set(s.stepIndex, s) + + const collected: StepRecord[] = [...completedSteps].sort((a, b) => a.stepIndex - b.stepIndex) + + // Lease renewal heartbeat — best-effort. Errors are surfaced via + // `leaseLost` so steps can short-circuit. + let leaseLost = false + const heartbeatIntervalMs = Math.max(1_000, Math.floor(leaseMs / 3)) + const heartbeat = setInterval(() => { + void input.store + .renewLease({ runId: input.runId, workerId, leaseMs }) + .then((res) => { + if (!res.ok) leaseLost = true + }) + .catch(() => { + leaseLost = true + }) + }, heartbeatIntervalMs) + // unref() so the heartbeat doesn't keep the process alive on test exit. + if (typeof heartbeat.unref === 'function') heartbeat.unref() + + let positionCounter = 0 + + const ctx: DurableContext = { + runId: input.runId, + projectId: input.manifest.projectId, + scenarioId: input.manifest.scenarioId, + async step( + intent: string, + fn: () => Promise, + opts?: { kind?: StepKind; inputFingerprint?: unknown }, + ): Promise { + checkAbortAndLease(input.signal, leaseLost) + const stepIndex = positionCounter++ + const prior = priorByIndex.get(stepIndex) + const inputHash = + opts?.inputFingerprint !== undefined ? canonicalHash(opts.inputFingerprint) : '' + if (prior && prior.status === 'completed') { + // Replay path — return cached result. + if (prior.intent !== intent) { + throw new DurableRunDivergenceError( + `step ${stepIndex}: intent changed across replays ('${prior.intent}' -> '${intent}')`, + ) + } + return prior.result as T + } + // Begin a fresh attempt (either net-new or retry of a failed step). + const begun = await input.store.beginStep({ + runId: input.runId, + stepIndex, + intent, + kind: opts?.kind ?? 'logic', + inputHash, + }) + try { + const result = await fn() + const completed = await input.store.completeStep({ + runId: input.runId, + stepIndex, + result, + }) + upsertCollected(collected, completed) + priorByIndex.set(stepIndex, completed) + return result + } catch (err) { + const failed = await input.store.failStep({ + runId: input.runId, + stepIndex, + error: toStepError(err), + }) + upsertCollected(collected, failed) + priorByIndex.set(stepIndex, failed) + throw err + } finally { + // The `begun` reference suppresses "unused" warnings without an + // eslint-disable comment. + void begun + } + }, + async awaitEvent(key: string, opts?: { timeoutMs?: number; pollMs?: number }): Promise { + const stepIndex = positionCounter++ + const prior = priorByIndex.get(stepIndex) + if (prior && prior.status === 'completed') { + if (prior.intent !== `event:${key}`) { + throw new DurableRunDivergenceError( + `step ${stepIndex}: awaitEvent key changed across replays`, + ) + } + return prior.result as T + } + const beginAt = Date.now() + const timeoutMs = opts?.timeoutMs ?? 60_000 + const pollMs = opts?.pollMs ?? DEFAULT_AWAIT_POLL_MS + await input.store.beginStep({ + runId: input.runId, + stepIndex, + intent: `event:${key}`, + kind: 'event', + inputHash: '', + }) + try { + for (;;) { + checkAbortAndLease(input.signal, leaseLost) + const evt = await input.store.loadEvent(input.runId, key) + if (evt) { + const completed = await input.store.completeStep({ + runId: input.runId, + stepIndex, + result: evt.payload, + }) + upsertCollected(collected, completed) + priorByIndex.set(stepIndex, completed) + return evt.payload as T + } + if (Date.now() - beginAt > timeoutMs) { + const err = new DurableAwaitEventTimeoutError( + `awaitEvent('${key}') timed out after ${timeoutMs}ms`, + ) + const failed = await input.store.failStep({ + runId: input.runId, + stepIndex, + error: toStepError(err), + }) + upsertCollected(collected, failed) + priorByIndex.set(stepIndex, failed) + throw err + } + await sleep(pollMs, input.signal) + } + } catch (err) { + // Any non-timeout error: mark failed (if not already), surface. + if (!(err instanceof DurableAwaitEventTimeoutError)) { + const existing = priorByIndex.get(stepIndex) + if (!existing || existing.status !== 'failed') { + const failed = await input.store.failStep({ + runId: input.runId, + stepIndex, + error: toStepError(err), + }) + upsertCollected(collected, failed) + priorByIndex.set(stepIndex, failed) + } + } + throw err + } + }, + async emitEvent(key: string, payload: unknown): Promise<{ accepted: boolean }> { + const res = await input.store.emitEvent({ runId: input.runId, key, payload }) + return { accepted: res.accepted } + }, + async now(): Promise { + const v = await this.step(`deterministic:now`, async () => new Date().toISOString(), { + kind: 'deterministic', + }) + return new Date(v) + }, + async uuid(): Promise { + return this.step(`deterministic:uuid`, async () => cryptoRandomUuid(), { + kind: 'deterministic', + }) + }, + } + + try { + const result = await input.taskFn(ctx) + const finalRecord = await input.store.endRun({ + runId: input.runId, + workerId, + status: 'completed', + outcome: input.defaultOutcome, + }) + return { result, record: finalRecord, steps: collected } + } catch (err) { + const finalRecord = await input.store.endRun({ + runId: input.runId, + workerId, + status: 'failed', + outcome: { + ...input.defaultOutcome, + notes: err instanceof Error ? err.message : String(err), + }, + }) + void finalRecord + throw err + } finally { + clearInterval(heartbeat) + } +} + +function checkAbortAndLease(signal: AbortSignal | undefined, leaseLost: boolean): void { + if (signal?.aborted) throw signal.reason ?? new Error('aborted') + if (leaseLost) throw new Error('durable-runs: lease lost; another worker has taken over this run') +} + +function upsertCollected(list: StepRecord[], rec: StepRecord): void { + const i = list.findIndex((s) => s.stepIndex === rec.stepIndex) + if (i === -1) list.push(rec) + else list[i] = rec +} + +function toStepError(err: unknown): { message: string; code?: string; stack?: string } { + if (err instanceof Error) { + return { + message: err.message, + code: (err as { code?: string }).code, + stack: err.stack, + } + } + return { message: String(err) } +} + +function sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(signal.reason ?? new Error('aborted')) + return + } + const t = setTimeout(() => { + signal?.removeEventListener('abort', onAbort) + resolve() + }, ms) + const onAbort = () => { + clearTimeout(t) + reject(signal?.reason ?? new Error('aborted')) + } + signal?.addEventListener('abort', onAbort, { once: true }) + }) +} + +function cryptoRandomUuid(): string { + // Defensive: globalThis.crypto.randomUUID may not be available in older + // Node — fall back to a manual v4 derivation. + if (typeof globalThis.crypto?.randomUUID === 'function') { + return globalThis.crypto.randomUUID() + } + const bytes = new Uint8Array(16) + for (let i = 0; i < 16; i++) bytes[i] = Math.floor(Math.random() * 256) + bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x40 + bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80 + const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('') + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` +} + +// Re-export for callers that want manifestHash without reaching into identity. +export { manifestHash } diff --git a/src/durable/schema.sql b/src/durable/schema.sql new file mode 100644 index 0000000..df5b71c --- /dev/null +++ b/src/durable/schema.sql @@ -0,0 +1,67 @@ +-- Durable-run substrate — versioned schema for D1 / SQLite. +-- +-- Apply once per database. Subsequent migrations append; never rewrite a +-- prior version. See `durable_schema_info` for the migration trail. +-- +-- Concurrency notes for D1: +-- - SQLite supports UNIQUE constraints for first-emit-wins (`durable_events` +-- PK is (run_id, key) — duplicate insert raises, caller treats as "already +-- emitted"). +-- - Lease takeover happens via a conditional UPDATE: we only claim the lease +-- if (lease_holder_id IS NULL OR lease_expires_at < :now) — atomic under +-- SQLite's row-level locking. +-- - All timestamps stored as ISO-8601 TEXT for cross-platform consistency. +-- - `result_json` / `error_json` / `outcome_json` / `payload_json` are +-- JSON-encoded TEXT; the application enforces canonical-JSON discipline at +-- the boundary so the store stays type-agnostic. + +CREATE TABLE IF NOT EXISTS durable_schema_info ( + version INTEGER PRIMARY KEY, + applied_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS durable_runs ( + run_id TEXT PRIMARY KEY, + manifest_hash TEXT NOT NULL, + project_id TEXT NOT NULL, + scenario_id TEXT, + status TEXT NOT NULL CHECK (status IN ('pending','running','completed','failed','suspended')), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + completed_at TEXT, + lease_holder_id TEXT, + lease_expires_at TEXT, + outcome_json TEXT, + step_count INTEGER NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_durable_runs_project_status ON durable_runs(project_id, status); +CREATE INDEX IF NOT EXISTS idx_durable_runs_lease_expires ON durable_runs(lease_expires_at); + +CREATE TABLE IF NOT EXISTS durable_steps ( + run_id TEXT NOT NULL, + step_index INTEGER NOT NULL, + intent TEXT NOT NULL, + kind TEXT NOT NULL, + input_hash TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL CHECK (status IN ('pending','running','completed','failed')), + attempts INTEGER NOT NULL DEFAULT 0, + result_json TEXT, + error_json TEXT, + started_at TEXT, + completed_at TEXT, + PRIMARY KEY (run_id, step_index) +); + +CREATE INDEX IF NOT EXISTS idx_durable_steps_status ON durable_steps(run_id, status); + +CREATE TABLE IF NOT EXISTS durable_events ( + run_id TEXT NOT NULL, + key TEXT NOT NULL, + payload_json TEXT, + emitted_at TEXT NOT NULL, + PRIMARY KEY (run_id, key) +); + +INSERT OR IGNORE INTO durable_schema_info (version, applied_at) +VALUES (1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')); diff --git a/src/durable/tests/durable-runs.test.ts b/src/durable/tests/durable-runs.test.ts new file mode 100644 index 0000000..1538356 --- /dev/null +++ b/src/durable/tests/durable-runs.test.ts @@ -0,0 +1,331 @@ +/** + * Durable-run substrate tests — crash recovery, lease semantics, event races, + * divergence detection. The tests run identically against the in-memory store + * and the file-system store (same `DurableRunStore` contract), so a single + * matrix proves both implementations. + */ + +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { + D1DurableRunStore, + DurableAwaitEventTimeoutError, + DurableRunDivergenceError, + DurableRunInputMismatchError, + DurableRunLeaseHeldError, + type DurableRunManifest, + type DurableRunStore, + FileSystemDurableRunStore, + InMemoryDurableRunStore, + manifestHash, + runDurable, +} from '../index' +import { createSqliteD1 } from './sqlite-d1-adapter' + +const SCHEMA_SQL = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8') + +function makeManifest(overrides?: Partial): DurableRunManifest { + return { + projectId: 'test-project', + scenarioId: 'scenario-1', + task: { + id: 'task-1', + intent: 'unit-test', + domain: 'test', + requiredKnowledge: [], + metadata: {}, + }, + input: { x: 1 }, + ...overrides, + } +} + +const storeKinds = [ + { + name: 'InMemoryDurableRunStore', + factory: () => ({ store: new InMemoryDurableRunStore(), cleanup: () => undefined }), + }, + { + name: 'FileSystemDurableRunStore', + factory: () => { + const dir = mkdtempSync(join(tmpdir(), 'durable-runs-test-')) + return { + store: new FileSystemDurableRunStore(dir), + cleanup: () => rmSync(dir, { recursive: true, force: true }), + } + }, + }, + { + name: 'D1DurableRunStore (better-sqlite3)', + factory: () => { + const handle = createSqliteD1() + handle.raw.exec(SCHEMA_SQL) + return { + store: new D1DurableRunStore(handle.db), + cleanup: () => handle.close(), + } + }, + }, +] as const + +for (const kind of storeKinds) { + describe(`durable-runs / ${kind.name}`, () => { + let store: DurableRunStore + let cleanup: () => void + + beforeEach(() => { + const made = kind.factory() + store = made.store + cleanup = made.cleanup + }) + + afterEach(async () => { + await store.close() + cleanup() + }) + + it('executes a fresh run end-to-end and returns the result', async () => { + const { result, record, steps } = await runDurable({ + runId: 'r1', + manifest: makeManifest(), + store, + taskFn: async (ctx) => { + const a = await ctx.step('add', async () => 1 + 1) + const b = await ctx.step('multiply', async () => a * 5) + return b + }, + }) + expect(result).toBe(10) + expect(record.status).toBe('completed') + expect(steps.map((s) => ({ idx: s.stepIndex, intent: s.intent, status: s.status }))).toEqual([ + { idx: 0, intent: 'add', status: 'completed' }, + { idx: 1, intent: 'multiply', status: 'completed' }, + ]) + }) + + it('replays completed steps without re-executing fn', async () => { + let calls = 0 + // First run — abort mid-stream. + try { + await runDurable({ + runId: 'r2', + manifest: makeManifest(), + store, + taskFn: async (ctx) => { + await ctx.step('first', async () => { + calls += 1 + return 'one' + }) + await ctx.step('boom', async () => { + throw new Error('forced failure') + }) + return 'never' + }, + }) + } catch (e) { + expect((e as Error).message).toBe('forced failure') + } + expect(calls).toBe(1) + + // Second run — fix the failing step. Verify 'first' is NOT re-executed. + const { result } = await runDurable({ + runId: 'r2', + manifest: makeManifest(), + store, + taskFn: async (ctx) => { + const a = await ctx.step('first', async () => { + calls += 1 + return 'one' + }) + const b = await ctx.step('boom', async () => 'fixed') + return `${a}-${b}` + }, + }) + expect(result).toBe('one-fixed') + // First-step callback called exactly once across the two runs. + expect(calls).toBe(1) + }) + + it('rejects manifest mismatch on resume', async () => { + await runDurable({ + runId: 'r3', + manifest: makeManifest({ input: { x: 1 } }), + store, + taskFn: async (ctx) => ctx.step('s', async () => 'ok'), + }) + await expect( + runDurable({ + runId: 'r3', + manifest: makeManifest({ input: { x: 2 } }), + store, + taskFn: async () => 'noop', + }), + ).rejects.toBeInstanceOf(DurableRunInputMismatchError) + }) + + it('rejects step divergence (intent change at same position)', async () => { + try { + await runDurable({ + runId: 'r4', + manifest: makeManifest(), + store, + taskFn: async (ctx) => { + await ctx.step('first', async () => 1) + await ctx.step('second', async () => { + throw new Error('boom') + }) + return 'never' + }, + }) + } catch { + /* expected */ + } + await expect( + runDurable({ + runId: 'r4', + manifest: makeManifest(), + store, + taskFn: async (ctx) => { + await ctx.step('first', async () => 1) + await ctx.step('DIFFERENT', async () => 2) // diverges at idx 1 + return 'noop' + }, + }), + ).rejects.toBeInstanceOf(DurableRunDivergenceError) + }) + + it('refuses concurrent acquisition while lease is live', async () => { + const { run } = await store.startOrResume({ + runId: 'r5', + manifest: makeManifest(), + workerId: 'workerA', + leaseMs: 60_000, + }) + expect(run.leaseHolderId).toBe('workerA') + await expect( + store.startOrResume({ + runId: 'r5', + manifest: makeManifest(), + workerId: 'workerB', + leaseMs: 60_000, + }), + ).rejects.toBeInstanceOf(DurableRunLeaseHeldError) + }) + + it('allows takeover after lease expires', async () => { + const { run } = await store.startOrResume({ + runId: 'r6', + manifest: makeManifest(), + workerId: 'workerA', + leaseMs: 50, + }) + expect(run.leaseHolderId).toBe('workerA') + // Wait past lease expiry. + await new Promise((r) => setTimeout(r, 80)) + const taken = await store.startOrResume({ + runId: 'r6', + manifest: makeManifest(), + workerId: 'workerB', + leaseMs: 60_000, + }) + expect(taken.run.leaseHolderId).toBe('workerB') + }) + + it('awaitEvent receives the first emit and is replayed thereafter', async () => { + // Concurrently emit the event while runDurable is awaiting it — this + // mirrors the production pattern (external system fires a webhook / + // tool callback while the agent task is suspended). + const emitSoon = new Promise((resolve) => { + setTimeout(() => { + void store + .emitEvent({ runId: 'r7', key: 'shipment', payload: { tracking: 'X1' } }) + .then(() => resolve()) + }, 40) + }) + + const { result } = await runDurable({ + runId: 'r7', + manifest: makeManifest(), + store, + taskFn: async (ctx) => { + const ev = await ctx.awaitEvent<{ tracking: string }>('shipment', { + timeoutMs: 2_000, + pollMs: 10, + }) + return ev.tracking + }, + }) + await emitSoon + expect(result).toBe('X1') + + // Replay the same run — awaitEvent returns from cache. + const replay = await runDurable({ + runId: 'r7', + manifest: makeManifest(), + store, + taskFn: async (ctx) => { + const ev = await ctx.awaitEvent<{ tracking: string }>('shipment', { timeoutMs: 1_000 }) + return ev.tracking + }, + }) + expect(replay.result).toBe('X1') + }) + + it('awaitEvent times out cleanly', async () => { + await expect( + runDurable({ + runId: 'r8', + manifest: makeManifest(), + store, + taskFn: async (ctx) => ctx.awaitEvent('never', { timeoutMs: 50, pollMs: 10 }), + }), + ).rejects.toBeInstanceOf(DurableAwaitEventTimeoutError) + }) + + it('emitEvent enforces first-emit-wins', async () => { + await store.startOrResume({ runId: 'r9', manifest: makeManifest(), workerId: 'w1' }) + const first = await store.emitEvent({ runId: 'r9', key: 'k', payload: { v: 1 } }) + expect(first.accepted).toBe(true) + const second = await store.emitEvent({ runId: 'r9', key: 'k', payload: { v: 2 } }) + expect(second.accepted).toBe(false) + expect((second.record.payload as { v: number }).v).toBe(1) + }) + + it('ctx.now and ctx.uuid are stable across replay', async () => { + const first = await runDurable({ + runId: 'r10', + manifest: makeManifest(), + store, + taskFn: async (ctx) => { + const t = await ctx.now() + const id = await ctx.uuid() + return { t: t.toISOString(), id } + }, + }) + // Force a "fresh" replay by manually re-running. The fact that the + // run is already 'completed' shouldn't matter for cached step replay. + const replay = await runDurable({ + runId: 'r10', + manifest: makeManifest(), + store, + taskFn: async (ctx) => { + const t = await ctx.now() + const id = await ctx.uuid() + return { t: t.toISOString(), id } + }, + }) + expect(replay.result).toEqual(first.result) + }) + }) +} + +describe('manifestHash', () => { + it('is stable across object insertion order', () => { + const a = makeManifest({ input: { a: 1, b: 2 } }) + const b = makeManifest({ input: { b: 2, a: 1 } }) + expect(manifestHash(a)).toBe(manifestHash(b)) + }) +}) diff --git a/src/durable/tests/sqlite-d1-adapter.ts b/src/durable/tests/sqlite-d1-adapter.ts new file mode 100644 index 0000000..5acdb3e --- /dev/null +++ b/src/durable/tests/sqlite-d1-adapter.ts @@ -0,0 +1,69 @@ +/** + * Wrap better-sqlite3 in the D1DatabaseLike surface so the durable-runs + * test matrix exercises the D1DurableRunStore against a real SQLite engine. + * Catches actual SQL semantics — unique constraints, CASE expressions, + * conditional UPDATEs — instead of a hand-rolled parser stub. + */ + +import type { Database as SqliteDatabase, Statement as SqliteStatement } from 'better-sqlite3' +import Database from 'better-sqlite3' + +import type { D1DatabaseLike, D1PreparedStatementLike } from '../d1-store' + +export interface SqliteD1Handle { + db: D1DatabaseLike + raw: SqliteDatabase + close(): void +} + +export function createSqliteD1(): SqliteD1Handle { + const raw = new Database(':memory:') + raw.pragma('journal_mode = WAL') + raw.pragma('foreign_keys = ON') + const adapter: D1DatabaseLike = { + prepare(query: string): D1PreparedStatementLike { + // D1's ISO-8601 millis: `strftime('%Y-%m-%dT%H:%M:%fZ', 'now')` is + // SQLite-native; keep as-is. + return new SqliteAdaptedStatement(raw.prepare(query)) + }, + async batch(statements: D1PreparedStatementLike[]): Promise { + const results: unknown[] = [] + for (const s of statements) results.push(await s.run()) + return results + }, + } + return { db: adapter, raw, close: () => raw.close() } +} + +class SqliteAdaptedStatement implements D1PreparedStatementLike { + private bound: unknown[] = [] + + constructor(private readonly stmt: SqliteStatement) {} + + bind(...values: unknown[]): D1PreparedStatementLike { + this.bound = values.map(coerceBindValue) + return this + } + + async first(): Promise { + const row = this.stmt.get(...this.bound) + return (row as T | undefined) ?? null + } + + async all(): Promise<{ results: T[] }> { + const rows = this.stmt.all(...this.bound) as T[] + return { results: rows } + } + + async run(): Promise<{ success: boolean; meta?: { changes?: number } }> { + const info = this.stmt.run(...this.bound) + return { success: true, meta: { changes: info.changes } } + } +} + +/** better-sqlite3 rejects raw `undefined`; map to null. */ +function coerceBindValue(v: unknown): unknown { + if (v === undefined) return null + if (typeof v === 'boolean') return v ? 1 : 0 + return v +} diff --git a/src/durable/types.ts b/src/durable/types.ts new file mode 100644 index 0000000..dac576a --- /dev/null +++ b/src/durable/types.ts @@ -0,0 +1,247 @@ +/** + * Durable-run substrate: the typed contract for checkpointed agent runs that + * survive worker crashes, deploy rolls, OOM, and transient transport errors. + * + * The model — directly inspired by Absurd (Postgres-backed) and Cloudflare + * Workflows — splits a run into ordered, idempotent **steps**. Each step's + * result is persisted before the next step runs. On resume, the runner reads + * the prior steps from a `DurableRunStore` and fast-replays them (returning + * cached values) until it reaches the first unfinished step, where execution + * actually resumes. + * + * Three boundary disciplines: + * + * 1. Step results MUST be JSON-serializable. No closures, no class + * instances, no live streams. The store treats results as opaque JSON. + * + * 2. Step intents MUST be stable across replays. The runner derives a + * stable step id from (runId, stepIndex, intent). Mismatched intent at + * the same index = `DurableRunDivergenceError`. + * + * 3. Non-determinism (now / uuid / random) MUST flow through the + * `DurableContext` helpers — `ctx.now()`, `ctx.uuid()` — so the values + * are checkpointed and identical on replay. Bare `Date.now()` / + * `crypto.randomUUID()` inside a task fn breaks replay equality. + */ + +import type { AgentTaskSpec } from '../types' + +/** Caller-facing kinds. The runner uses these for telemetry + querying. */ +export type StepKind = + /** Logical step that ran user code (the default for ctx.step). */ + | 'logic' + /** A wrapped LLM call. */ + | 'llm' + /** A wrapped tool call. */ + | 'tool' + /** A wrapped readiness probe. */ + | 'readiness' + /** A deterministic clock or uuid read. */ + | 'deterministic' + /** A suspend-for-event boundary. */ + | 'event' + +export type StepStatus = 'pending' | 'running' | 'completed' | 'failed' + +export interface StepError { + message: string + code?: string + /** Optional stack — stored for diagnostics, NEVER replayed as an exception. */ + stack?: string +} + +export interface StepRecord { + runId: string + /** Monotonic 0-based index. Position is the load-bearing identifier — the + * same intent string at different positions is a different step. */ + stepIndex: number + /** Caller-supplied label; intended for human reading + log correlation. */ + intent: string + kind: StepKind + /** sha256 of the canonical input fingerprint at begin-time. Used to detect + * divergence (caller changed inputs across replays). Empty for steps where + * the input cannot be canonicalized (e.g. ctx.now()). */ + inputHash: string + status: StepStatus + /** Re-entry count. Increments each time the step begins. */ + attempts: number + /** JSON-serializable result. Present when status === 'completed'. */ + result?: T + error?: StepError + startedAt?: string + completedAt?: string +} + +export interface EventRecord { + runId: string + key: string + payload: unknown + emittedAt: string +} + +export type RunStatus = 'pending' | 'running' | 'completed' | 'failed' | 'suspended' + +export interface RunOutcome { + pass?: boolean + score?: number + notes?: string + /** Free-form bag of run-level metrics — surfaced in OTLP / TraceStore. */ + metadata?: Record +} + +export interface DurableRunManifest { + /** Stable per-product id (e.g. 'legal-agent', 'creative-agent'). */ + projectId: string + /** Optional scenario / persona / session id — surfaced in telemetry. */ + scenarioId?: string + task: AgentTaskSpec + /** Input payload. Hashed into the run identity so two runs with the same + * runId but different inputs raise DurableRunInputMismatchError. */ + input: Record + /** Free-form tags surfaced into RunRecord / OTLP. */ + tags?: Record +} + +export interface RunRecord { + runId: string + manifestHash: string + projectId: string + scenarioId?: string + status: RunStatus + createdAt: string + updatedAt: string + completedAt?: string + /** Stable per-worker id holding the lease. */ + leaseHolderId?: string + leaseExpiresAt?: string + outcome?: RunOutcome + stepCount: number +} + +/** + * The durable-run substrate. Implementations: in-memory (dev), file-system + * (eval harness), D1 (Cloudflare prod). All stores share this exact contract + * — swap by changing one factory call. + * + * Concurrency model: at most one worker holds a run's lease at a time. Lease + * renewal happens on a heartbeat; on lease expiry, another worker can + * `startOrResume` and pick up. Steps committed by the prior worker survive. + */ +export interface DurableRunStore { + /** + * Begin or resume a run. Returns the canonical RunRecord, all previously + * completed steps (in order), and the lease deadline. + * + * If the run did not exist, creates it with status='running'. If it existed + * with a different manifest hash, throws DurableRunInputMismatchError. + * If it existed with a live lease held by a different worker, throws + * DurableRunLeaseHeldError (caller can retry or back off). + */ + startOrResume(input: { + runId: string + manifest: DurableRunManifest + workerId: string + leaseMs?: number + }): Promise<{ + run: RunRecord + completedSteps: ReadonlyArray + leaseExpiresAt: string + }> + + /** Renew the lease. Returns false if another worker now holds it. */ + renewLease(input: { + runId: string + workerId: string + leaseMs?: number + }): Promise<{ ok: boolean; leaseExpiresAt?: string }> + + /** Load a step by position. Returns undefined if not yet begun. */ + loadStep(runId: string, stepIndex: number): Promise + + /** Record step start (intent + input hash + kind). Bumps attempt count. */ + beginStep(input: { + runId: string + stepIndex: number + intent: string + kind: StepKind + inputHash: string + }): Promise + + /** Mark step completed with a JSON-serializable result. */ + completeStep(input: { runId: string; stepIndex: number; result: unknown }): Promise + + /** Mark step failed with a captured error. */ + failStep(input: { runId: string; stepIndex: number; error: StepError }): Promise + + /** End the run; releases lease. */ + endRun(input: { + runId: string + workerId: string + status: 'completed' | 'failed' + outcome?: RunOutcome + }): Promise + + /** + * Emit an event. First emit wins; subsequent emits return the existing + * record under `existing` and accepted=false. Caller can treat that as + * idempotency-by-design — never double-fire a downstream side effect. + */ + emitEvent(input: { + runId: string + key: string + payload: unknown + }): Promise<{ accepted: boolean; record: EventRecord }> + + /** Load the cached event payload if it has been emitted. */ + loadEvent(runId: string, key: string): Promise + + /** Cleanup hook for in-memory / fs stores; no-op for D1. Idempotent. */ + close(): Promise +} + +// ── Public error contract ──────────────────────────────────────────────── + +/** Base class for durable-run errors. */ +export class DurableRunError extends Error { + constructor( + message: string, + public readonly code: + | 'lease_held' + | 'manifest_mismatch' + | 'step_divergence' + | 'step_input_mismatch' + | 'await_event_timeout' + | 'event_emit_race', + ) { + super(message) + this.name = this.constructor.name + } +} + +/** Thrown when another worker holds the lease for this runId. */ +export class DurableRunLeaseHeldError extends DurableRunError { + constructor(message: string) { + super(message, 'lease_held') + } +} + +/** Thrown when the manifest hash differs from a prior run with the same id. */ +export class DurableRunInputMismatchError extends DurableRunError { + constructor(message: string) { + super(message, 'manifest_mismatch') + } +} + +/** Thrown when the same stepIndex re-runs with a different intent string. */ +export class DurableRunDivergenceError extends DurableRunError { + constructor(message: string) { + super(message, 'step_divergence') + } +} + +/** Thrown when `awaitEvent` times out. */ +export class DurableAwaitEventTimeoutError extends DurableRunError { + constructor(message: string) { + super(message, 'await_event_timeout') + } +} diff --git a/src/durable/workflows.ts b/src/durable/workflows.ts new file mode 100644 index 0000000..1af75ef --- /dev/null +++ b/src/durable/workflows.ts @@ -0,0 +1,162 @@ +/** + * Cloudflare Workflows integration for the durable-run substrate. + * + * Two valid deployment patterns on Cloudflare: + * + * A. **Plain Worker + D1DurableRunStore.** Each request invokes + * `runDurable(...)` directly against a D1 binding. Survives worker + * isolate restarts; lease takeover happens via D1 row-level + * conditional UPDATE. The default path; no Workflows binding needed. + * + * B. **Cloudflare Workflows entrypoint.** Wrap an entire `runDurable(...)` + * call inside a single Workflow `step.do(...)`. Workflows gives you + * retry-on-throw with platform-managed exponential backoff and + * survives full Workers deploy rolls. Use it when the task can take + * minutes to hours, or when you want the Workflows dashboard for + * observability. Inside the step, `runDurable` still uses D1 for + * step-level checkpoints — so a half-completed run resumes from + * its last checkpoint on retry rather than restarting from scratch. + * + * This module provides the surface for pattern B: a thin helper that + * converts a Workflows `WorkflowStep` into a `DurableContext`. We do not + * take a runtime dep on `cloudflare:workers` — the integration is purely + * structural typing. + * + * Example (pattern B): + * + * import { WorkflowEntrypoint } from 'cloudflare:workers' + * import { runOnWorkflowStep } from '@tangle-network/agent-runtime' + * + * export class LegalChatWorkflow extends WorkflowEntrypoint { + * async run(event, step) { + * return runOnWorkflowStep(step, { + * workflowName: 'legal-chat', + * taskFn: async (ctx) => { + * const ready = await ctx.step('readiness', () => probeKnowledge(...)) + * const answer = await ctx.step('llm:turn-1', () => callLlm(...)) + * const shipped = await ctx.awaitEvent('shipped', { timeoutMs: 5 * 60_000 }) + * return { answer, shipped } + * }, + * }) + * } + * } + * + * Step ordering, replay semantics, and divergence detection inside the + * `taskFn` are inherited from Cloudflare's Workflows engine — we + * intentionally do NOT layer a second durable store inside this path. + * Pick pattern A or pattern B per agent; do not mix. + */ + +import type { DurableContext } from './runner' + +/** + * Structural subset of Cloudflare's `WorkflowStep`. Mirrors the public surface + * documented at https://developers.cloudflare.com/workflows/build/. Defined + * here so this module imposes zero `cloudflare:workers` runtime dependency. + */ +export interface WorkflowStepLike { + do(name: string, opts: WorkflowStepConfig, fn: () => Promise): Promise + do(name: string, fn: () => Promise): Promise + sleep(name: string, duration: string | number): Promise + waitForEvent( + name: string, + opts: { type: string; timeout?: string }, + ): Promise<{ + payload: T + timestamp: number + type: string + }> +} + +export interface WorkflowStepConfig { + retries?: { + limit: number + delay: string | number + backoff?: 'constant' | 'linear' | 'exponential' + } + timeout?: string | number +} + +export interface RunOnWorkflowStepInput { + /** Logical workflow name; used as a prefix on step ids for filtering. */ + workflowName: string + /** User task — same shape as runDurable's taskFn. */ + taskFn: (ctx: DurableContext) => Promise + /** Optional per-step retry / timeout policy applied to ctx.step calls. */ + stepConfig?: WorkflowStepConfig + /** Optional clock — defaults to Date.now. */ + now?: () => number +} + +/** + * Adapt a Cloudflare `WorkflowStep` into a `DurableContext` and run a task. + * + * Every `ctx.step(intent, fn)` becomes `step.do(, fn)` with stable + * names — Workflows checkpoints + replays based on step name + position, + * matching our model. + * + * `ctx.awaitEvent(key)` becomes `step.waitForEvent(key, { type: key })`. + * Caller is responsible for emitting from the platform side (e.g. via the + * Workflows REST API or a sibling worker that publishes events). + * + * `ctx.now()` and `ctx.uuid()` go through `step.do` so the values are + * captured in the platform's checkpoint state and remain stable across + * replay — same invariant as our own stores. + */ +export async function runOnWorkflowStep( + workflowStep: WorkflowStepLike, + input: RunOnWorkflowStepInput, +): Promise { + const stepCfg = input.stepConfig + let counter = 0 + const stepName = (intent: string) => `${input.workflowName}/${counter++}:${intent}` + + const ctx: DurableContext = { + runId: `cf-workflow:${input.workflowName}`, + projectId: input.workflowName, + async step(intent: string, fn: () => Promise): Promise { + const name = stepName(intent) + if (stepCfg) { + return workflowStep.do(name, stepCfg, fn) + } + return workflowStep.do(name, fn) + }, + async awaitEvent(key: string, opts?: { timeoutMs?: number }): Promise { + const timeout = opts?.timeoutMs ? `${Math.ceil(opts.timeoutMs / 1000)}s` : undefined + const ev = await workflowStep.waitForEvent(stepName(`event:${key}`), { + type: key, + timeout, + }) + return ev.payload + }, + async emitEvent(): Promise<{ accepted: boolean }> { + // Workflows events are emitted from OUTSIDE the workflow (via the + // platform API). A workflow that emits its own events is an + // anti-pattern — surface that fail-loud rather than silently no-op. + throw new Error( + 'runOnWorkflowStep: ctx.emitEvent is not available inside a Workflows entrypoint. ' + + 'Emit from the sibling worker that drives the workflow.', + ) + }, + async now(): Promise { + const iso = await this.step('deterministic:now', async () => new Date().toISOString()) + return new Date(iso) + }, + async uuid(): Promise { + return this.step('deterministic:uuid', async () => { + if (typeof globalThis.crypto?.randomUUID === 'function') { + return globalThis.crypto.randomUUID() + } + // Cloudflare runtime always has Web Crypto; fallback is defensive. + const bytes = new Uint8Array(16) + crypto.getRandomValues(bytes) + bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x40 + bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80 + const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('') + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` + }) + }, + } + + return input.taskFn(ctx) +} diff --git a/src/index.ts b/src/index.ts index 5f515af..39762fa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,6 +24,7 @@ export type { RunRecord, UserQuestion, } from '@tangle-network/agent-eval' +export type { BackendRetryPolicy } from './backends' // ── Backends ────────────────────────────────────────────────────────── export { createIterableBackend, @@ -43,6 +44,12 @@ export { runChatTurn, sandboxAsChatTurnTarget, } from './chat-turn' +// ── Durable-run substrate ───────────────────────────────────────────── +// Step-checkpointed agent runs that survive worker crashes, deploy rolls, +// rate-limit cascades, and transient transport errors. See ./durable for +// the full contract; in-memory + filesystem stores ship out of the box, +// D1 store + Cloudflare Workflows adapter land as opt-in subpath exports. +export * from './durable' // ── Errors ─────────────────────────────────────────────────────────── export { AgentEvalError,