diff --git a/bun.lock b/bun.lock index 779b6bc4c..f1a58f213 100644 --- a/bun.lock +++ b/bun.lock @@ -11,6 +11,7 @@ "@aws-sdk/client-iam": "^3.1080.0", "@opentelemetry/api": "^1.9.1", "@opentelemetry/exporter-metrics-otlp-http": "^0.221.0", + "@opentelemetry/otlp-transformer": "0.213.0", "@opentelemetry/resources": "^2.10.0", "@opentelemetry/sdk-metrics": "^2.10.0", "@smithy/core": "3.29.3", @@ -90,7 +91,7 @@ "@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], - "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.221.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-OlanaW1vv7ufTqQ3/fPLI4arGt5ZoM+P8abOMki6uEYnpRazepSWDwDnnw+la7kE26SHVC18//SMccrDvLKOXQ=="], + "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.213.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-zRM5/Qj6G84Ej3F1yt33xBVY/3tnMxtL1fiDIxYbDWYaZ/eudVw3/PBiZ8G7JwUxXxjW8gU4g6LnOyfGKYHYgw=="], "@opentelemetry/core": ["@opentelemetry/core@2.10.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ=="], @@ -98,16 +99,18 @@ "@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.221.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/otlp-transformer": "0.221.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-UFPIq80OH3Ns/oPFHRj14d4DTOxUo+MUFU8hUiCq5jTqFhdeJnfVSANHT+xp92409cA+oxzvlZCe6NM1wvCuBA=="], - "@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.221.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.221.0", "@opentelemetry/core": "2.10.0", "@opentelemetry/resources": "2.10.0", "@opentelemetry/sdk-logs": "0.221.0", "@opentelemetry/sdk-metrics": "2.10.0", "@opentelemetry/sdk-trace": "2.10.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-lg6lkOU08Az23jVcn/0Els9HP+V8PnR4Km6p0KgpTggS0n/WuhnmY64rSh83Of9iR9nD+dpWr6adlcX8KzAwjg=="], + "@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.213.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.213.0", "@opentelemetry/core": "2.6.0", "@opentelemetry/resources": "2.6.0", "@opentelemetry/sdk-logs": "0.213.0", "@opentelemetry/sdk-metrics": "2.6.0", "@opentelemetry/sdk-trace-base": "2.6.0", "protobufjs": "^7.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-RSuAlxFFPjeK4d5Y6ps8L2WhaQI6CXWllIjvo5nkAlBpmq2XdYWEBGiAbOF4nDs8CX4QblJDv5BbMUft3sEfDw=="], "@opentelemetry/resources": ["@opentelemetry/resources@2.10.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-q6MMm2zhggzsHVNbabYwut+a6nbuQQe3URUoxaojM/8K1IBfwwPzvxIjNi2/lI1TFe+fMHMW9MWhrtDLEXEnkA=="], - "@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.221.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.221.0", "@opentelemetry/core": "2.10.0", "@opentelemetry/resources": "2.10.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-FaDcazjyMp7TZZZAsqbo4IkovP0UegoCu0EBkiNt+qCqvUf7FPAsfcrZ3+ZEkKgXZ/jHafop+JoGPDk3A0SmLg=="], + "@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.213.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.213.0", "@opentelemetry/core": "2.6.0", "@opentelemetry/resources": "2.6.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-00xlU3GZXo3kXKve4DLdrAL0NAFUaZ9appU/mn00S/5kSUdAvyYsORaDUfR04Mp2CLagAOhrzfUvYozY/EZX2g=="], "@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.10.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/resources": "2.10.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-t6r1VSvXNtSDnPXU1FbZeetJb7yyovHmgu0wRSoftxtE0g2rSNhQZQUy69sRUCL+iioJpX8SN/S6wq6ZtvLySQ=="], "@opentelemetry/sdk-trace": ["@opentelemetry/sdk-trace@2.10.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/resources": "2.10.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-MfQGq3GRmTh5fM/y+OjaO0vj6+luCB1XO2gfXCalKCfgKw0eHL++sm75DNweC6ohlp+aFvACqeE0fYayqdRaoQ=="], + "@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.6.0", "", { "dependencies": { "@opentelemetry/core": "2.6.0", "@opentelemetry/resources": "2.6.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-g/OZVkqlxllgFM7qMKqbPV9c1DUPhQ7d4n3pgZFcrnrNft9eJXZM2TNHTPYREJBrtNdRytYyvwjgL5geDKl3EQ=="], + "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="], "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.74.0", "", { "os": "android", "cpu": "arm" }, "sha512-+gHd12muVI9ZLBaWLPkHt3Fj7jihFjgQ1MGtBaRL8vWrWrI0P7dLUty/cHrHS0oqPYIRgQUJsPu2CExQuMcwNw=="], @@ -148,6 +151,24 @@ "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.74.0", "", { "os": "win32", "cpu": "x64" }, "sha512-VTC9IYTIMrVUk/i6Ms1ohzzDKZFkWn0KU2OBbPBzgmVZ2V30165T/zK4LztTr0Xgp9fZ1qQZ1rsZAu/rEmySlA=="], + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], + + "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], + + "@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="], + + "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.1", "", {}, "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg=="], + + "@protobufjs/fetch": ["@protobufjs/fetch@1.1.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1" } }, "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw=="], + + "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], + + "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], + + "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], + + "@protobufjs/utf8": ["@protobufjs/utf8@1.1.2", "", {}, "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug=="], + "@smithy/core": ["@smithy/core@3.29.8", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-rpCbCV+TimOBi3VLNBMmtTvgfOWcFIEAru3+TFlG87SL2F+te4jOnnNR+cf3uR4eJ5Qf4LnT80fqnBKgPRS6zA=="], "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.4.13", "", { "dependencies": { "@smithy/core": "^3.29.8", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-X+2HNZhWi5i3rJsCas0LPf6fTQUaKyJ40zd8aTO/bwpRfpU3biYaqLr7C1WMibL7PVKJalpi1PyybjGPNoHC8Q=="], @@ -266,6 +287,8 @@ "logform": ["logform@2.7.0", "", { "dependencies": { "@colors/colors": "1.6.0", "@types/triple-beam": "^1.3.2", "fecha": "^4.2.0", "ms": "^2.1.1", "safe-stable-stringify": "^2.3.1", "triple-beam": "^1.3.0" } }, "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ=="], + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], @@ -292,6 +315,8 @@ "prettier": ["prettier@3.9.5", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg=="], + "protobufjs": ["protobufjs@7.6.5", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw=="], + "react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], "react-devtools-core": ["react-devtools-core@7.0.1", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-C3yNvRHaizlpiASzy7b9vbnBGLrhvdhl1CbdU6EnZgxPNbai60szdLtl+VL76UNOt5bOoVTOz5rNWZxgGt+Gsw=="], @@ -406,6 +431,24 @@ "@aws-sdk/client-iam/@smithy/node-http-handler": ["@smithy/node-http-handler@4.9.5", "", { "dependencies": { "@smithy/core": "^3.29.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-bNqdxTQTxmLbomSmlkZFz8L6B/feQ2HHzw4L2zY7Ecp2XffYAZq2uzdWDdxJHJFbEvqd+SRuluJso0P8+xPdbw=="], + "@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.221.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.221.0", "@opentelemetry/core": "2.10.0", "@opentelemetry/resources": "2.10.0", "@opentelemetry/sdk-logs": "0.221.0", "@opentelemetry/sdk-metrics": "2.10.0", "@opentelemetry/sdk-trace": "2.10.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-lg6lkOU08Az23jVcn/0Els9HP+V8PnR4Km6p0KgpTggS0n/WuhnmY64rSh83Of9iR9nD+dpWr6adlcX8KzAwjg=="], + + "@opentelemetry/otlp-exporter-base/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.221.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.221.0", "@opentelemetry/core": "2.10.0", "@opentelemetry/resources": "2.10.0", "@opentelemetry/sdk-logs": "0.221.0", "@opentelemetry/sdk-metrics": "2.10.0", "@opentelemetry/sdk-trace": "2.10.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-lg6lkOU08Az23jVcn/0Els9HP+V8PnR4Km6p0KgpTggS0n/WuhnmY64rSh83Of9iR9nD+dpWr6adlcX8KzAwjg=="], + + "@opentelemetry/otlp-transformer/@opentelemetry/core": ["@opentelemetry/core@2.6.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg=="], + + "@opentelemetry/otlp-transformer/@opentelemetry/resources": ["@opentelemetry/resources@2.6.0", "", { "dependencies": { "@opentelemetry/core": "2.6.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-D4y/+OGe3JSuYUCBxtH5T9DSAWNcvCb/nQWIga8HNtXTVPQn59j0nTBAgaAXxUVBDl40mG3Tc76b46wPlZaiJQ=="], + + "@opentelemetry/otlp-transformer/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.6.0", "", { "dependencies": { "@opentelemetry/core": "2.6.0", "@opentelemetry/resources": "2.6.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-CicxWZxX6z35HR83jl+PLgtFgUrKRQ9LCXyxgenMnz5A1lgYWfAog7VtdOvGkJYyQgMNPhXQwkYrDLujk7z1Iw=="], + + "@opentelemetry/sdk-logs/@opentelemetry/core": ["@opentelemetry/core@2.6.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg=="], + + "@opentelemetry/sdk-logs/@opentelemetry/resources": ["@opentelemetry/resources@2.6.0", "", { "dependencies": { "@opentelemetry/core": "2.6.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-D4y/+OGe3JSuYUCBxtH5T9DSAWNcvCb/nQWIga8HNtXTVPQn59j0nTBAgaAXxUVBDl40mG3Tc76b46wPlZaiJQ=="], + + "@opentelemetry/sdk-trace-base/@opentelemetry/core": ["@opentelemetry/core@2.6.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg=="], + + "@opentelemetry/sdk-trace-base/@opentelemetry/resources": ["@opentelemetry/resources@2.6.0", "", { "dependencies": { "@opentelemetry/core": "2.6.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-D4y/+OGe3JSuYUCBxtH5T9DSAWNcvCb/nQWIga8HNtXTVPQn59j0nTBAgaAXxUVBDl40mG3Tc76b46wPlZaiJQ=="], + "listr2/cli-truncate": ["cli-truncate@5.2.0", "", { "dependencies": { "slice-ansi": "^8.0.0", "string-width": "^8.2.0" } }, "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw=="], "log-update/cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], @@ -470,6 +513,14 @@ "@aws-sdk/client-iam/@aws-sdk/credential-provider-node/@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.4.8", "", { "dependencies": { "@smithy/core": "^3.29.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-q9J7JTiXrAhB8sDp4px97uEPT7CwKH61Co78grdNQvU8QZAdiuaSRhP0tUVf2ogy36RZTrlMU1rBmDEH+cnkiA=="], + "@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.221.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-OlanaW1vv7ufTqQ3/fPLI4arGt5ZoM+P8abOMki6uEYnpRazepSWDwDnnw+la7kE26SHVC18//SMccrDvLKOXQ=="], + + "@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.221.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.221.0", "@opentelemetry/core": "2.10.0", "@opentelemetry/resources": "2.10.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-FaDcazjyMp7TZZZAsqbo4IkovP0UegoCu0EBkiNt+qCqvUf7FPAsfcrZ3+ZEkKgXZ/jHafop+JoGPDk3A0SmLg=="], + + "@opentelemetry/otlp-exporter-base/@opentelemetry/otlp-transformer/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.221.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-OlanaW1vv7ufTqQ3/fPLI4arGt5ZoM+P8abOMki6uEYnpRazepSWDwDnnw+la7kE26SHVC18//SMccrDvLKOXQ=="], + + "@opentelemetry/otlp-exporter-base/@opentelemetry/otlp-transformer/@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.221.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.221.0", "@opentelemetry/core": "2.10.0", "@opentelemetry/resources": "2.10.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-FaDcazjyMp7TZZZAsqbo4IkovP0UegoCu0EBkiNt+qCqvUf7FPAsfcrZ3+ZEkKgXZ/jHafop+JoGPDk3A0SmLg=="], + "listr2/cli-truncate/slice-ansi": ["slice-ansi@8.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg=="], "log-update/cli-cursor/restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], diff --git a/package.json b/package.json index 279d871a6..c1223ecbc 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,9 @@ "agentcore": "./dist/index.js" }, "main": "./dist/index.js", + "engines": { + "node": ">=20.12.0" + }, "files": [ "dist" ], @@ -55,6 +58,7 @@ "@aws-sdk/client-iam": "^3.1080.0", "@opentelemetry/api": "^1.9.1", "@opentelemetry/exporter-metrics-otlp-http": "^0.221.0", + "@opentelemetry/otlp-transformer": "0.213.0", "@opentelemetry/resources": "^2.10.0", "@opentelemetry/sdk-metrics": "^2.10.0", "@smithy/core": "3.29.3", diff --git a/src/assets/templates/shared/env.local.template b/src/assets/templates/shared/env.local.template index 30a18b616..fb931a12b 100644 --- a/src/assets/templates/shared/env.local.template +++ b/src/assets/templates/shared/env.local.template @@ -1,7 +1,11 @@ # Environment variables for local development. -# `agentcore dev` loads this file into your agent's process. Values here -# override anything the CLI injects. This file is gitignored — keep secrets -# out of version control, but they are safe here. +# `agentcore project dev` loads this file into your agent's process. Values here +# override injected values except PORT, FASTMCP_PORT, and LOCAL_DEV, which the +# CLI owns. While trace collection is on (the default), the CLI also owns the +# OTEL_* and AGENT_OBSERVABILITY_ENABLED variables so traces reach its local +# collector — pass --no-traces (or set instrumentation.enableOtel to false in +# agentcore.json) to disable collection and set your own. +# This file is gitignored — keep secrets out of version control. # # Example: # MY_API_KEY=... diff --git a/src/core/dev/codezip.test.ts b/src/core/dev/codezip.test.ts index 1fdd1c5c7..a255a7584 100644 --- a/src/core/dev/codezip.test.ts +++ b/src/core/dev/codezip.test.ts @@ -1,10 +1,10 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { delimiter, join } from "node:path"; import type { ProjectRuntime } from "../../projectSchemas/runtime"; import type { DevEvent, DevServerInput } from "../../handlers/project/dev/types"; -import type { ProcessEvent, ProcessStreamer, StreamProcessOptions } from "../../io"; +import type { ProcessEvent, ProcessRunner, ProcessStreamer, StreamProcessOptions } from "../../io"; import { CodeZipDevRunner } from "./codezip"; type ProcessCall = { @@ -43,15 +43,22 @@ async function projectRoot(withNodeModules = false): Promise { return root; } -function harness(output: ProcessEvent[] = []) { +function harness(output: ProcessEvent[] = [], probe: { dir?: string; fail?: boolean } = {}) { const calls: ProcessCall[] = []; + const probeCalls: string[][] = []; const fakeStreamProcess: ProcessStreamer = async function* (command, options) { calls.push({ command, options }); yield* output; }; + const fakeRunProcess: ProcessRunner = async (command, options) => { + probeCalls.push(command); + if (probe.fail) throw new Error("probe failed"); + options.onOutput?.(`${probe.dir ?? ""}\n`); + }; return { calls, - runner: new CodeZipDevRunner({ streamProcess: fakeStreamProcess }), + probeCalls, + runner: new CodeZipDevRunner({ streamProcess: fakeStreamProcess, runProcess: fakeRunProcess }), }; } @@ -153,3 +160,69 @@ describe("CodeZipDevRunner", () => { ]); }); }); + +describe("CodeZipDevRunner OTEL instrumentation", () => { + async function sitecustomizeDir(): Promise { + const directory = await mkdtemp(join(tmpdir(), "otel-site-")); + tempDirectories.push(directory); + await writeFile(join(directory, "sitecustomize.py"), ""); + return directory; + } + + function otelInput(root: string, extraEnv: Record = {}): DevServerInput { + const base = input(root, runtime()); + return { + ...base, + env: { ...base.env, OTEL_EXPORTER_OTLP_ENDPOINT: "http://127.0.0.1:4318", ...extraEnv }, + }; + } + + test("prepends the sitecustomize directory to PYTHONPATH when instrumentation is installed", async () => { + const root = await projectRoot(); + const directory = await sitecustomizeDir(); + const { calls, probeCalls, runner } = harness([], { dir: directory }); + + await collect(runner.run(otelInput(root))); + + expect(probeCalls[0]?.slice(0, 4)).toEqual(["uv", "run", "python", "-c"]); + expect(calls[0]?.options.env?.PYTHONPATH).toBe(directory); + }); + + test("preserves an existing PYTHONPATH", async () => { + const root = await projectRoot(); + const directory = await sitecustomizeDir(); + const { calls, runner } = harness([], { dir: directory }); + + await collect(runner.run(otelInput(root, { PYTHONPATH: "/existing" }))); + + expect(calls[0]?.options.env?.PYTHONPATH).toBe(`${directory}${delimiter}/existing`); + }); + + test("does not probe without an OTEL endpoint or for Node entrypoints", async () => { + const root = await projectRoot(true); + const { probeCalls, runner } = harness(); + + await collect(runner.run(input(root, runtime()))); + await collect(runner.run({ ...otelInput(root), runtime: runtime({ entrypoint: "index.js" }) })); + + expect(probeCalls).toEqual([]); + }); + + test.each([ + ["probe failure", { fail: true }], + ["missing sitecustomize.py", { dir: "/nonexistent" }], + ] as const)("warns and starts untraced on %s", async (_case, probe) => { + const root = await projectRoot(); + const { calls, probeCalls, runner } = harness([], probe); + + const events = await collect(runner.run(otelInput(root))); + + expect(probeCalls).toHaveLength(1); + expect(calls).toHaveLength(1); + expect(calls[0]?.options.env?.PYTHONPATH).toBeUndefined(); + expect(events).toContainEqual({ + type: "status", + message: expect.stringContaining("traces will not be collected"), + }); + }); +}); diff --git a/src/core/dev/codezip.ts b/src/core/dev/codezip.ts index 0b2314cf0..7d6e83ba9 100644 --- a/src/core/dev/codezip.ts +++ b/src/core/dev/codezip.ts @@ -1,18 +1,27 @@ import { existsSync } from "node:fs"; -import { join } from "node:path"; +import { delimiter, join } from "node:path"; import { InputValidationError } from "../../errors"; import type { DevEvent, DevRunner, DevServerInput } from "../../handlers/project/dev/types"; -import { streamProcess, type ProcessStreamer, type StreamProcessOptions } from "../../io"; +import { + runProcess, + streamProcess, + type ProcessRunner, + type ProcessStreamer, + type StreamProcessOptions, +} from "../../io"; type CodeZipDevRunnerConfig = { streamProcess?: ProcessStreamer; + runProcess?: ProcessRunner; }; export class CodeZipDevRunner implements DevRunner { private readonly streamProcess: ProcessStreamer; + private readonly runProcess: ProcessRunner; constructor(config: CodeZipDevRunnerConfig = {}) { this.streamProcess = config.streamProcess ?? streamProcess; + this.runProcess = config.runProcess ?? runProcess; } public async *run(input: DevServerInput): AsyncGenerator { @@ -33,8 +42,48 @@ export class CodeZipDevRunner implements DevRunner { yield { type: "status", message: "Starting development server" }; const serverProcess = commandForRuntime(entrypoint!, directory, input); + if (entrypoint!.endsWith(".py") && input.env?.OTEL_EXPORTER_OTLP_ENDPOINT) { + const sitecustomizeDir = await this.findOtelSitecustomizeDir(directory); + if (sitecustomizeDir) { + const existing = serverProcess.options.env?.PYTHONPATH; + serverProcess.options.env = { + ...serverProcess.options.env, + PYTHONPATH: existing ? `${sitecustomizeDir}${delimiter}${existing}` : sitecustomizeDir, + }; + } else { + yield { + type: "status", + message: + "OTEL auto-instrumentation is not installed in the agent environment; traces will not be collected. Add aws-opentelemetry-distro to the agent's dependencies to enable them.", + }; + } + } yield* this.streamProcess(serverProcess.command, serverProcess.options); } + + /** + * Locate the auto-instrumentation sitecustomize.py directory inside the agent's + * uv environment. Prepending it to PYTHONPATH instruments every Python process — + * an `opentelemetry-instrument` wrapper would only instrument uvicorn's reloader + * parent, leaving the re-spawned worker processes untraced. + */ + private async findOtelSitecustomizeDir(directory: string): Promise { + const output: string[] = []; + const probe = + "import opentelemetry.instrumentation.auto_instrumentation as m; import os; print(os.path.dirname(m.__file__))"; + try { + await this.runProcess(["uv", "run", "python", "-c", probe], { + cwd: directory, + onOutput: (chunk) => output.push(chunk), + }); + } catch { + return undefined; + } + const sitecustomizeDir = output.join("").trim().split("\n").at(-1)?.trim(); + if (!sitecustomizeDir || !existsSync(join(sitecustomizeDir, "sitecustomize.py"))) + return undefined; + return sitecustomizeDir; + } } function commandForRuntime( diff --git a/src/core/dev/container.test.ts b/src/core/dev/container.test.ts index c8ece4891..0a4ce0ff4 100644 --- a/src/core/dev/container.test.ts +++ b/src/core/dev/container.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test"; import { createHash } from "node:crypto"; -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { InputValidationError, InvalidEnvironmentError } from "../../errors"; @@ -61,6 +61,8 @@ function harness( config: { available?: (tool: string, probeArgs?: string[]) => Promise; stream?: StreamBehavior; + awsDirectory?: string; + processEnv?: NodeJS.ProcessEnv; } = {}, ) { const calls: ProcessCall[] = []; @@ -77,6 +79,11 @@ function harness( (async (tool) => { return tool === "docker"; }), + awsDirectory: config.awsDirectory ?? join(tmpdir(), "agentcore-container-no-aws"), + processEnv: config.processEnv ?? { + AWS_ACCESS_KEY_ID: "test-access-key", + AWS_SECRET_ACCESS_KEY: "test-secret-key", + }, }), }; } @@ -189,6 +196,10 @@ describe("ContainerDevRunner", () => { "-p", `127.0.0.1:3000:${containerPort}`, "-e", + "AWS_ACCESS_KEY_ID=test-access-key", + "-e", + "AWS_SECRET_ACCESS_KEY=test-secret-key", + "-e", "API_KEY=super-secret", "-e", `PORT=${containerPort}`, @@ -202,6 +213,37 @@ describe("ContainerDevRunner", () => { expect(run.options.redactedCommand?.join(" ")).not.toContain("super-secret"); }); + test("uses a shared AWS config and rejects missing credentials", async () => { + const projectRuntime = runtime(); + const root = await projectRoot(projectRuntime); + const awsDirectory = join(root, ".aws"); + await mkdir(awsDirectory); + await writeFile(join(awsDirectory, "config"), "[profile sandbox]\nregion=us-east-1\n"); + const { calls, runner } = harness({ + awsDirectory, + processEnv: { AWS_PROFILE: "sandbox", AWS_REGION: "us-east-1" }, + }); + + await collect(runner.run(input(root, projectRuntime))); + + const run = commandCall(calls, "run"); + expect(run.command).toContain(`${awsDirectory}:/aws-config:ro`); + expect(run.command).toContain("AWS_PROFILE=sandbox"); + expect(run.command).toContain("AWS_CONFIG_FILE=/aws-config/config"); + expect(run.options.redactedCommand?.join(" ")).not.toContain("sandbox"); + + const missing = harness({ + awsDirectory: join(root, "missing-aws"), + processEnv: {}, + }); + const missingCredentials = collect(missing.runner.run(input(root, projectRuntime))); + await expect(missingCredentials).rejects.toBeInstanceOf(InvalidEnvironmentError); + await expect(missingCredentials).rejects.toThrow( + "Unable to resolve AWS credentials for the container", + ); + expect(missing.calls).toHaveLength(0); + }); + test("preserves an existing build context .dockerignore", async () => { const projectRuntime = runtime({ buildContextPath: "." }); const root = await projectRoot(projectRuntime); @@ -409,6 +451,33 @@ describe("ContainerDevRunner", () => { expect(calls.map(({ command }) => command[1])).toEqual(["rm"]); }); + test("rejects build contexts outside the project root, including symlinks", async () => { + const root = await mkdtemp(join(tmpdir(), "agentcore-container-")); + const outside = await mkdtemp(join(tmpdir(), "agentcore-container-outside-")); + tempDirectories.push(root, outside); + await symlink(outside, join(root, "linked"), process.platform === "win32" ? "junction" : "dir"); + const probes: string[] = []; + + for (const buildContextPath of ["..", "linked"]) { + const { calls, runner } = harness({ + available: async (tool) => { + probes.push(tool); + return true; + }, + }); + + const escapedContext = collect(runner.run(input(root, runtime({ buildContextPath })))); + await expect(escapedContext).rejects.toBeInstanceOf(InputValidationError); + await expect(escapedContext).rejects.toThrow( + "container build context must be within the project root", + ); + expect(calls).toHaveLength(0); + } + + expect(probes).toHaveLength(0); + await expect(readFile(join(outside, ".dockerignore"), "utf8")).rejects.toThrow(); + }); + test("rejects a build context that is not a directory", async () => { const root = await mkdtemp(join(tmpdir(), "agentcore-container-")); tempDirectories.push(root); diff --git a/src/core/dev/container.ts b/src/core/dev/container.ts index 720d6e8dd..84aaee28b 100644 --- a/src/core/dev/container.ts +++ b/src/core/dev/container.ts @@ -1,6 +1,7 @@ import { createHash } from "node:crypto"; -import { existsSync, statSync, writeFileSync } from "node:fs"; -import { join, resolve } from "node:path"; +import { existsSync, realpathSync, statSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { isAbsolute, join, relative, resolve, sep } from "node:path"; import { InputValidationError, InvalidEnvironmentError } from "../../errors"; import type { DevEvent, DevRunner, DevServerInput } from "../../handlers/project/dev/types"; import { @@ -10,8 +11,17 @@ import { type ProcessStreamer, type StreamProcessOptions, } from "../../io"; +import { DEV_PORTS } from "./port"; const CONTAINER_TOOLS = ["docker", "podman", "finch"] as const; +const AWS_ENV_KEYS = [ + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_REGION", + "AWS_DEFAULT_REGION", + "AWS_PROFILE", +] as const; const CLEANUP_TIMEOUT_MS = 2_000; const DOCKERFILE_NAME = "Dockerfile"; const CONTAINER_RUNTIME_INSTALL_HINT = @@ -44,15 +54,21 @@ type ToolAvailable = typeof toolAvailable; type ContainerDevRunnerConfig = { streamProcess?: ProcessStreamer; toolAvailable?: ToolAvailable; + awsDirectory?: string; + processEnv?: NodeJS.ProcessEnv; }; export class ContainerDevRunner implements DevRunner { private readonly streamProcess: ProcessStreamer; private readonly toolAvailable: ToolAvailable; + private readonly awsDirectory: string; + private readonly processEnv: NodeJS.ProcessEnv; constructor(config: ContainerDevRunnerConfig = {}) { this.streamProcess = config.streamProcess ?? streamProcess; this.toolAvailable = config.toolAvailable ?? toolAvailable; + this.awsDirectory = config.awsDirectory ?? join(homedir(), ".aws"); + this.processEnv = config.processEnv ?? process.env; } public async *run(input: DevServerInput): AsyncGenerator { @@ -65,12 +81,36 @@ export class ContainerDevRunner implements DevRunner { throw new InputValidationError(`container build context directory not found: ${context}`); } + const canonicalContext = realpathSync(context); + const relativeContext = relative(realpathSync(input.projectRoot), canonicalContext); + if ( + relativeContext === ".." || + relativeContext.startsWith(`..${sep}`) || + isAbsolute(relativeContext) + ) { + throw new InputValidationError( + `container build context must be within the project root: ${canonicalContext}`, + ); + } + const dockerfile = input.runtime.dockerfile ?? DOCKERFILE_NAME; const dockerfilePath = join(context, dockerfile); if (!isFile(dockerfilePath)) { throw new InputValidationError(`container Dockerfile not found: ${dockerfilePath}`); } + const hasAwsCredentials = Boolean( + (input.env?.AWS_ACCESS_KEY_ID ?? this.processEnv.AWS_ACCESS_KEY_ID) && + (input.env?.AWS_SECRET_ACCESS_KEY ?? this.processEnv.AWS_SECRET_ACCESS_KEY), + ); + const hasAwsConfig = existsSync(this.awsDirectory); + if (!hasAwsCredentials && !hasAwsConfig) { + throw new InvalidEnvironmentError( + "Unable to resolve AWS credentials for the container. Configure AWS credentials " + + "or set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY, then retry.", + ); + } + const tool = await this.resolveContainerTool(input.signal); input.signal.throwIfAborted(); if (input.runtime.buildContextPath) { @@ -116,15 +156,23 @@ export class ContainerDevRunner implements DevRunner { yield { type: "status", message: `Building image with ${tool}` }; yield* this.streamProcess(buildCommand, buildOptions); - const containerPort = portForProtocol(input.runtime.protocol); - const forwardedEnv: Record = { - ...input.env, + const containerPort = DEV_PORTS[input.runtime.protocol ?? "HTTP"]; + const forwardedEnv: Record = {}; + for (const key of AWS_ENV_KEYS) { + if (this.processEnv[key]) forwardedEnv[key] = this.processEnv[key]; + } + Object.assign(forwardedEnv, input.env, { PORT: String(containerPort), LOCAL_DEV: "1", - }; + }); if (input.runtime.protocol === "MCP") { forwardedEnv.FASTMCP_PORT = String(containerPort); } + const awsMount = hasAwsConfig ? ["-v", `${this.awsDirectory}:/aws-config:ro`] : []; + if (awsMount.length) { + forwardedEnv.AWS_CONFIG_FILE = "/aws-config/config"; + forwardedEnv.AWS_SHARED_CREDENTIALS_FILE = "/aws-config/credentials"; + } const envFlags = Object.entries(forwardedEnv).flatMap(([key, value]) => [ "-e", `${key}=${value}`, @@ -141,6 +189,7 @@ export class ContainerDevRunner implements DevRunner { containerName, "-p", `127.0.0.1:${input.port}:${containerPort}`, + ...awsMount, ...envFlags, imageTag, ]; @@ -158,6 +207,7 @@ export class ContainerDevRunner implements DevRunner { containerName, "-p", `127.0.0.1:${input.port}:${containerPort}`, + ...awsMount, ...redactedEnvFlags, imageTag, ], @@ -204,12 +254,6 @@ export class ContainerDevRunner implements DevRunner { } } -function portForProtocol(protocol: DevServerInput["runtime"]["protocol"]): number { - if (protocol === "MCP") return 8000; - if (protocol === "A2A") return 9000; - return 8080; -} - function isDirectory(path: string): boolean { try { return statSync(path).isDirectory(); diff --git a/src/core/dev/otel/collector.test.ts b/src/core/dev/otel/collector.test.ts new file mode 100644 index 000000000..eb4fc16e7 --- /dev/null +++ b/src/core/dev/otel/collector.test.ts @@ -0,0 +1,172 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + ExportLogsServiceRequest, + ExportTraceServiceRequest, + type OtelCollector, + startOtelCollector, +} from "./collector"; + +const TRACE_ID_HEX = "0123456789abcdef0123456789abcdef"; + +function protobufTracePayload(): Uint8Array { + const message = ExportTraceServiceRequest.fromObject({ + resourceSpans: [ + { + resource: { attributes: [{ key: "service.name", value: { stringValue: "proto-agent" } }] }, + scopeSpans: [ + { + scope: { name: "test" }, + spans: [ + { + traceId: Buffer.from(TRACE_ID_HEX, "hex"), + spanId: Buffer.from("0123456789abcdef", "hex"), + name: "invoke_agent strands", + kind: 1, + startTimeUnixNano: `${BigInt(Date.now()) * 1_000_000n}`, + endTimeUnixNano: `${BigInt(Date.now()) * 1_000_000n}`, + }, + ], + }, + ], + }, + ], + }); + return ExportTraceServiceRequest.encode(message).finish(); +} + +function protobufLogsPayload(): Uint8Array { + const message = ExportLogsServiceRequest.fromObject({ + resourceLogs: [ + { + resource: { attributes: [{ key: "service.name", value: { stringValue: "proto-agent" } }] }, + scopeLogs: [ + { + scope: { name: "test" }, + logRecords: [ + { + traceId: Buffer.from(TRACE_ID_HEX, "hex"), + timeUnixNano: `${BigInt(Date.now()) * 1_000_000n}`, + body: { stringValue: "a log line" }, + }, + ], + }, + ], + }, + ], + }); + return ExportLogsServiceRequest.encode(message).finish(); +} + +let directory: string; +let collector: OtelCollector; + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), "otel-collector-")); + collector = await startOtelCollector({ tracesDirectory: directory }); +}); + +afterEach(async () => { + await collector.close(); + await rm(directory, { recursive: true, force: true }); +}); + +function post( + path: string, + body: string | Uint8Array, + contentType = "application/x-protobuf", +): Promise { + return fetch(`http://127.0.0.1:${collector.port}${path}`, { + method: "POST", + headers: { "Content-Type": contentType }, + body, + }); +} + +describe("startOtelCollector", () => { + test("ingests protobuf trace exports and serves them back through the store", async () => { + const response = await post("/v1/traces", protobufTracePayload()); + expect(response.status).toBe(200); + + const traces = await collector.store.list(); + expect(traces).toHaveLength(1); + expect(traces[0]!.traceId).toBe(TRACE_ID_HEX); + }); + + test("ingests protobuf log exports into the same trace", async () => { + await post("/v1/traces", protobufTracePayload()); + const response = await post("/v1/logs", protobufLogsPayload()); + expect(response.status).toBe(200); + + const detail = await collector.store.get(TRACE_ID_HEX); + expect(detail?.resourceLogs).toBeDefined(); + }); + + test("ingests JSON trace exports", async () => { + const body = JSON.stringify({ + resourceSpans: [ + { + resource: { attributes: [{ key: "service.name", value: { stringValue: "json-agent" } }] }, + scopeSpans: [ + { + scope: { name: "test" }, + spans: [ + { + traceId: TRACE_ID_HEX, + spanId: "0123456789abcdef", + name: "invoke_agent strands", + kind: 1, + startTimeUnixNano: `${BigInt(Date.now()) * 1_000_000n}`, + endTimeUnixNano: `${BigInt(Date.now()) * 1_000_000n}`, + }, + ], + }, + ], + }, + ], + }); + const response = await post("/v1/traces", body, "application/json"); + expect(response.status).toBe(200); + expect((await collector.store.list()).map((trace) => trace.traceId)).toEqual([TRACE_ID_HEX]); + }); + + test("rejects malformed payloads with 400", async () => { + expect((await post("/v1/traces", "not json", "application/json")).status).toBe(400); + expect((await post("/v1/traces", Buffer.from([0xff, 0xff, 0xff]))).status).toBe(400); + expect(await collector.store.list()).toEqual([]); + }); + + test("health check responds ok and unknown routes 404", async () => { + const health = await fetch(`http://127.0.0.1:${collector.port}/`); + expect(await health.json()).toEqual({ status: "ok" }); + expect( + (await fetch(`http://127.0.0.1:${collector.port}/v1/metrics`, { method: "POST" })).status, + ).toBe(404); + }); + + test("envVars point the SDK at the collector, including signal-specific overrides", () => { + const endpoint = `http://127.0.0.1:${collector.port}`; + expect(collector.envVars).toMatchObject({ + OTEL_EXPORTER_OTLP_ENDPOINT: endpoint, + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: `${endpoint}/v1/traces`, + OTEL_EXPORTER_OTLP_LOGS_ENDPOINT: `${endpoint}/v1/logs`, + OTEL_EXPORTER_OTLP_PROTOCOL: "http/protobuf", + OTEL_EXPORTER_OTLP_TRACES_PROTOCOL: "http/protobuf", + OTEL_EXPORTER_OTLP_LOGS_PROTOCOL: "http/protobuf", + OTEL_METRICS_EXPORTER: "none", + }); + }); + + test("abort signal closes the receiver", async () => { + const controller = new AbortController(); + const aborted = await startOtelCollector({ + tracesDirectory: directory, + signal: controller.signal, + }); + controller.abort(); + await Bun.sleep(20); + expect(fetch(`http://127.0.0.1:${aborted.port}/`)).rejects.toThrow(); + }); +}); diff --git a/src/core/dev/otel/collector.ts b/src/core/dev/otel/collector.ts new file mode 100644 index 000000000..c19a3375a --- /dev/null +++ b/src/core/dev/otel/collector.ts @@ -0,0 +1,152 @@ +// Decodes OTLP/HTTP protobuf payloads (the only protocol Python and Node OTEL +// SDKs export over HTTP) with the generated types from @opentelemetry/otlp-transformer. +// The version is pinned: newer releases dropped the generated request decoders. +import root from "@opentelemetry/otlp-transformer/build/src/generated/root"; +import { type HttpRequest, type HttpResponse, startHttpServer } from "../../../io"; +import { TraceStore } from "./store"; +import type { OtlpPayload } from "./types"; + +/** The slice of a generated protobufjs message type the collector (and its tests) use. */ +export interface OtlpMessageType { + decode(data: Uint8Array): unknown; + fromObject(object: object): unknown; + encode(message: unknown): { finish(): Uint8Array }; +} + +// The generated root's declaration file types it as an opaque protobufjs Root, +// so the real static-message shape is asserted once, here. +const { trace, logs } = ( + root as unknown as { + opentelemetry: { + proto: { + collector: { + trace: { v1: { ExportTraceServiceRequest: OtlpMessageType } }; + logs: { v1: { ExportLogsServiceRequest: OtlpMessageType } }; + }; + }; + }; + } +).opentelemetry.proto.collector; + +export const ExportTraceServiceRequest = trace.v1.ExportTraceServiceRequest; +export const ExportLogsServiceRequest = logs.v1.ExportLogsServiceRequest; +type OtlpDecoder = Pick; + +export interface OtelCollector { + /** The loopback port the OTLP/HTTP receiver listens on. */ + port: number; + /** Reads the traces this collector persists. */ + store: TraceStore; + /** Environment variables that point an agent's OTEL SDK at this collector. */ + envVars: Record; + /** Stops the receiver. Also invoked by the start signal, if one was given. */ + close(): Promise; +} + +export interface StartOtelCollectorOptions { + /** Directory to persist OTLP JSON Lines files into. */ + tracesDirectory: string; + /** Closes the collector when aborted. */ + signal?: AbortSignal; +} + +/** + * Starts an in-process OTLP/HTTP receiver for dev mode on an OS-assigned + * loopback port. Accepts `POST /v1/traces` and `POST /v1/logs` in protobuf or + * JSON encoding and appends the raw payloads to a TraceStore. + */ +export async function startOtelCollector( + options: StartOtelCollectorOptions, +): Promise { + const store = new TraceStore(options.tracesDirectory); + const server = await startHttpServer((request) => route(request, store), { + signal: options.signal, + }); + + return { port: server.port, store, envVars: otelEnvVars(server.port), close: server.close }; +} + +async function route(request: HttpRequest, store: TraceStore): Promise { + if (request.method === "POST" && request.url === "/v1/traces") { + return ingest(request, store, ExportTraceServiceRequest); + } + if (request.method === "POST" && request.url === "/v1/logs") { + return ingest(request, store, ExportLogsServiceRequest); + } + if (request.method === "GET" && request.url === "/") { + return json(200, { status: "ok" }); + } + return { status: 404 }; +} + +async function ingest( + request: HttpRequest, + store: TraceStore, + decoder: OtlpDecoder, +): Promise { + let payload: OtlpPayload; + try { + payload = decodePayload(request.body, String(request.headers["content-type"] ?? ""), decoder); + } catch { + return json(400, { error: "Invalid OTLP payload" }); + } + await store.append(payload); + return json(200, {}); +} + +/** + * Decode an OTLP payload. The JSON round-trip on the protobuf path converts the + * message to plain objects (protobufjs renders Long as string and bytes as base64). + */ +function decodePayload(body: Buffer, contentType: string, decoder: OtlpDecoder): OtlpPayload { + if (contentType.includes("application/json")) { + return JSON.parse(body.toString()) as OtlpPayload; + } + return JSON.parse(JSON.stringify(decoder.decode(new Uint8Array(body)))) as OtlpPayload; +} + +/** + * Environment for a spawned agent so its OTEL SDK exports to the collector at + * `port`. Signal-specific variables are set alongside the generic ones because + * they take precedence in the SDK — a stray OTEL_EXPORTER_OTLP_TRACES_ENDPOINT + * from the shell or .env.local must not silently redirect traces elsewhere. + * Per the OTEL spec, signal-specific endpoints are full URLs (the signal path + * is only appended to the generic endpoint). + */ +export function otelEnvVars(port: number): Record { + const endpoint = `http://127.0.0.1:${port}`; + return { + OTEL_EXPORTER_OTLP_ENDPOINT: endpoint, + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: `${endpoint}/v1/traces`, + OTEL_EXPORTER_OTLP_LOGS_ENDPOINT: `${endpoint}/v1/logs`, + OTEL_EXPORTER_OTLP_PROTOCOL: "http/protobuf", + OTEL_EXPORTER_OTLP_TRACES_PROTOCOL: "http/protobuf", + OTEL_EXPORTER_OTLP_LOGS_PROTOCOL: "http/protobuf", + OTEL_METRICS_EXPORTER: "none", + AGENT_OBSERVABILITY_ENABLED: "true", + OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT: "true", + OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED: "true", + }; +} + +/** + * Rewrite loopback OTLP endpoints so a containerized agent can reach the + * collector on the host. host.docker.internal resolves on Docker Desktop, + * Finch, and Podman; bare-metal Linux Docker would additionally need + * `--add-host=host.docker.internal:host-gateway` (matches the reference CLI). + */ +export function rewriteOtelEndpointForContainer( + env: Record, +): Record { + const rewritten = { ...env }; + for (const [key, value] of Object.entries(rewritten)) { + if (key.startsWith("OTEL_EXPORTER_OTLP") && key.endsWith("_ENDPOINT")) { + rewritten[key] = value.replace(/127\.0\.0\.1|localhost/, "host.docker.internal"); + } + } + return rewritten; +} + +function json(status: number, body: unknown): HttpResponse { + return { status, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }; +} diff --git a/src/core/dev/otel/store.test.ts b/src/core/dev/otel/store.test.ts new file mode 100644 index 000000000..f4bb450c7 --- /dev/null +++ b/src/core/dev/otel/store.test.ts @@ -0,0 +1,135 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { TraceStore } from "./store"; +import type { OtlpPayload } from "./types"; + +const TRACE_A = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const TRACE_B = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + +function payload( + traceId: string, + options: { serviceName?: string; startNano?: string; name?: string } = {}, +): OtlpPayload { + return { + resourceSpans: [ + { + resource: { + attributes: [ + { key: "service.name", value: { stringValue: options.serviceName ?? "agent-1" } }, + ], + }, + scopeSpans: [ + { + scope: { name: "test" }, + spans: [ + { + traceId, + spanId: "0123456789abcdef", + name: options.name ?? "invoke_agent strands", + kind: 1, + startTimeUnixNano: options.startNano ?? `${BigInt(Date.now()) * 1_000_000n}`, + endTimeUnixNano: options.startNano ?? `${BigInt(Date.now()) * 1_000_000n}`, + }, + ], + }, + ], + }, + ], + }; +} + +let directory: string; +let store: TraceStore; + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), "trace-store-")); + store = new TraceStore(directory); +}); + +afterEach(async () => { + await rm(directory, { recursive: true, force: true }); +}); + +describe("TraceStore", () => { + test("append then list returns the trace with metadata", async () => { + await store.append(payload(TRACE_A)); + const traces = await store.list(); + expect(traces).toHaveLength(1); + expect(traces[0]!.traceId).toBe(TRACE_A); + expect(traces[0]!.spanCount).toBe("1"); + expect(traces[0]!.resourceSpans).toBeDefined(); + }); + + test("appends to the same trace accumulate spans", async () => { + await store.append(payload(TRACE_A)); + await store.append(payload(TRACE_A, { name: "tool_use" })); + const traces = await store.list(); + expect(traces).toHaveLength(1); + expect(traces[0]!.spanCount).toBe("2"); + }); + + test("payloads without a trace id are dropped", async () => { + await store.append({ resourceSpans: [] }); + expect(await store.list()).toEqual([]); + }); + + test("a batch carrying several traces lands in each trace's own file", async () => { + const batch = payload(TRACE_A); + batch.resourceSpans![0]!.scopeSpans![0]!.spans!.push({ + ...batch.resourceSpans![0]!.scopeSpans![0]!.spans![0]!, + traceId: TRACE_B, + name: "tool_use", + }); + await store.append(batch); + + const traces = await store.list(); + expect(traces.map((trace) => trace.traceId).sort()).toEqual([TRACE_A, TRACE_B]); + expect(traces.every((trace) => trace.spanCount === "1")).toBe(true); + expect(await store.get(TRACE_B)).toBeDefined(); + }); + + test("list filters by service name", async () => { + await store.append(payload(TRACE_A, { serviceName: "agent-1" })); + await store.append(payload(TRACE_B, { serviceName: "agent-2" })); + const traces = await store.list({ serviceName: "agent-2" }); + expect(traces.map((trace) => trace.traceId)).toEqual([TRACE_B]); + }); + + test("list filters by time window and sorts newest first", async () => { + const oldNano = `${BigInt(Date.now() - 24 * 60 * 60 * 1000) * 1_000_000n}`; + await store.append(payload(TRACE_A, { startNano: oldNano })); + await store.append(payload(TRACE_B)); + + expect((await store.list()).map((trace) => trace.traceId)).toEqual([TRACE_B]); + + const all = await store.list({ startTime: 0 }); + expect(all.map((trace) => trace.traceId)).toEqual([TRACE_B, TRACE_A]); + }); + + test("get returns the trace detail or undefined for unknown ids", async () => { + await store.append(payload(TRACE_A)); + const detail = await store.get(TRACE_A); + expect(detail?.resourceSpans).toBeDefined(); + expect(await store.get(TRACE_B)).toBeUndefined(); + }); + + test("skips malformed lines and files without failing", async () => { + await store.append(payload(TRACE_A)); + await writeFile(join(directory, `${TRACE_A}.otlp.jsonl`), "{not json}\n", { + flag: "a", + }); + await writeFile(join(directory, "garbage.otlp.jsonl"), "also not json\n"); + + const traces = await store.list(); + expect(traces).toHaveLength(1); + expect(traces[0]!.spanCount).toBe("1"); + }); + + test("list on a directory that does not exist returns empty", async () => { + const empty = new TraceStore(join(directory, "missing")); + expect(await empty.list()).toEqual([]); + expect(await empty.get(TRACE_A)).toBeUndefined(); + }); +}); diff --git a/src/core/dev/otel/store.ts b/src/core/dev/otel/store.ts new file mode 100644 index 000000000..57e7e4593 --- /dev/null +++ b/src/core/dev/otel/store.ts @@ -0,0 +1,129 @@ +import { appendFile, mkdir, readFile, readdir } from "node:fs/promises"; +import { join } from "node:path"; +import { buildTraceDetail, extractTraceMeta, partitionByTraceId } from "./transforms"; +import type { OtlpPayload, OtlpResourceLog, OtlpResourceSpan } from "./types"; + +const OTLP_EXT = ".otlp.jsonl"; +const DEFAULT_LIST_WINDOW_MS = 12 * 60 * 60 * 1000; + +export interface TraceSummary { + traceId: string; + timestamp: string; + sessionId?: string; + spanCount: string; + resourceSpans?: unknown[]; + resourceLogs?: unknown[]; +} + +export interface TraceDetail { + resourceSpans?: unknown[]; + resourceLogs?: unknown[]; +} + +export interface ListTracesOptions { + serviceName?: string; + startTime?: number; + endTime?: number; +} + +/** + * Append-only local trace storage: one JSON Lines file per trace (named by its + * trace id), each line a per-trace slice of an OTLP export payload. No in-memory + * state — reads go to disk on demand, which is fine because the inspector only + * fetches traces on user actions. Malformed files and lines are skipped, never fatal. + */ +export class TraceStore { + constructor(private readonly directory: string) {} + + /** + * Persist one OTLP export payload, partitioned by trace id so a batch that + * carries several traces lands in each trace's own file. Spans and log + * records without a trace id are dropped. + */ + public async append(payload: OtlpPayload): Promise { + const partitions = partitionByTraceId(payload); + if (partitions.size === 0) return; + + await mkdir(this.directory, { recursive: true }); + await Promise.all( + [...partitions].map(([traceId, partition]) => + appendFile( + join(this.directory, `${sanitize(traceId)}${OTLP_EXT}`), + JSON.stringify(partition) + "\n", + ), + ), + ); + } + + /** List traces newest-first, filtered by service name and time range (default: last 12 hours). */ + public async list(options: ListTracesOptions = {}): Promise { + const now = Date.now(); + const start = options.startTime ?? now - DEFAULT_LIST_WINDOW_MS; + const end = options.endTime ?? now; + + const summaries: TraceSummary[] = []; + for (const file of await this.traceFiles()) { + const trace = await this.readTraceFile(file); + if (!trace) continue; + + const meta = extractTraceMeta(trace.resourceSpans, trace.resourceLogs); + if (!meta.traceId) continue; + if (meta.lastSeen < start || meta.firstSeen > end) continue; + if (options.serviceName && meta.serviceName !== options.serviceName) continue; + + summaries.push({ + traceId: meta.traceId, + timestamp: new Date(meta.lastSeen).toISOString(), + sessionId: meta.sessionId, + spanCount: String(meta.spanCount), + ...buildTraceDetail(trace.resourceSpans, trace.resourceLogs), + }); + } + + return summaries.sort((a, b) => b.timestamp.localeCompare(a.timestamp)); + } + + /** All spans and logs for one trace, or undefined when the trace is unknown. */ + public async get(traceId: string): Promise { + const trace = await this.readTraceFile(`${sanitize(traceId)}${OTLP_EXT}`); + if (!trace) return undefined; + return buildTraceDetail(trace.resourceSpans, trace.resourceLogs); + } + + private async traceFiles(): Promise { + try { + return (await readdir(this.directory)).filter((file) => file.endsWith(OTLP_EXT)); + } catch { + return []; + } + } + + private async readTraceFile( + fileName: string, + ): Promise<{ resourceSpans: OtlpResourceSpan[]; resourceLogs: OtlpResourceLog[] } | undefined> { + let content: string; + try { + content = await readFile(join(this.directory, fileName), "utf8"); + } catch { + return undefined; + } + + const resourceSpans: OtlpResourceSpan[] = []; + const resourceLogs: OtlpResourceLog[] = []; + for (const line of content.split("\n")) { + if (!line.trim()) continue; + try { + const payload = JSON.parse(line) as OtlpPayload; + if (payload.resourceSpans) resourceSpans.push(...payload.resourceSpans); + if (payload.resourceLogs) resourceLogs.push(...payload.resourceLogs); + } catch { + // Skip malformed lines — a partially written line must not break reads. + } + } + return { resourceSpans, resourceLogs }; + } +} + +function sanitize(value: string): string { + return value.replace(/[^a-zA-Z0-9_-]/g, "_"); +} diff --git a/src/core/dev/otel/transforms.test.ts b/src/core/dev/otel/transforms.test.ts new file mode 100644 index 000000000..0b3bc2eef --- /dev/null +++ b/src/core/dev/otel/transforms.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, test } from "bun:test"; +import { + buildTraceDetail, + extractAnyValue, + extractTraceMeta, + flattenAttributes, + hexFromB64OrString, + nanoToMs, + partitionByTraceId, +} from "./transforms"; +import type { OtlpResourceLog, OtlpResourceSpan } from "./types"; + +const TRACE_ID_HEX = "0123456789abcdef0123456789abcdef"; +const TRACE_ID_B64 = Buffer.from(TRACE_ID_HEX, "hex").toString("base64"); +const SPAN_ID_HEX = "0123456789abcdef"; + +function resourceSpan(overrides: { serviceName?: string; spans: object[] }): OtlpResourceSpan { + return { + resource: overrides.serviceName + ? { attributes: [{ key: "service.name", value: { stringValue: overrides.serviceName } }] } + : undefined, + scopeSpans: [{ scope: { name: "test-scope" }, spans: overrides.spans }], + }; +} + +const agentSpan = { + traceId: TRACE_ID_B64, + spanId: SPAN_ID_HEX, + name: "invoke_agent strands", + kind: 1, + startTimeUnixNano: "1700000000000000000", + endTimeUnixNano: "1700000001500000000", + attributes: [ + { key: "gen_ai.prompt", value: { stringValue: "hello" } }, + { key: "session.id", value: { stringValue: "session-1" } }, + ], +}; + +describe("extractTraceMeta", () => { + test("collects trace id, time bounds, session, service, and span count", () => { + const meta = extractTraceMeta( + [resourceSpan({ serviceName: "my-agent", spans: [agentSpan] })], + [], + ); + expect(meta).toEqual({ + traceId: TRACE_ID_HEX, + firstSeen: 1700000000000, + lastSeen: 1700000001500, + sessionId: "session-1", + serviceName: "my-agent", + spanCount: 1, + }); + }); + + test("counts log records and falls back to observed time", () => { + const logs: OtlpResourceLog[] = [ + { + resource: { attributes: [{ key: "service.name", value: { stringValue: "log-agent" } }] }, + scopeLogs: [ + { + scope: {}, + logRecords: [{ traceId: TRACE_ID_HEX, observedTimeUnixNano: "1700000002000000000" }], + }, + ], + }, + ]; + const meta = extractTraceMeta([], logs); + expect(meta.traceId).toBe(TRACE_ID_HEX); + expect(meta.serviceName).toBe("log-agent"); + expect(meta.spanCount).toBe(1); + expect(meta.firstSeen).toBe(1700000002000); + expect(meta.lastSeen).toBe(1700000002000); + }); + + test("defaults time bounds to now when no timestamps exist", () => { + const before = Date.now(); + const meta = extractTraceMeta([], []); + expect(meta.firstSeen).toBeGreaterThanOrEqual(before); + expect(meta.lastSeen).toBeGreaterThanOrEqual(before); + expect(meta.traceId).toBeUndefined(); + }); +}); + +describe("partitionByTraceId", () => { + const OTHER_TRACE_HEX = "ffffffffffffffffffffffffffffffff"; + + test("splits a batch carrying several traces into per-trace payloads", () => { + const otherSpan = { ...agentSpan, traceId: OTHER_TRACE_HEX, name: "tool_use" }; + const partitions = partitionByTraceId({ + resourceSpans: [resourceSpan({ serviceName: "svc", spans: [agentSpan, otherSpan] })], + }); + + expect([...partitions.keys()].sort()).toEqual([TRACE_ID_HEX, OTHER_TRACE_HEX]); + const first = partitions.get(TRACE_ID_HEX)!.resourceSpans![0] as OtlpResourceSpan; + expect(first.scopeSpans![0]!.spans).toEqual([agentSpan]); + expect(first.resource).toBeDefined(); + const second = partitions.get(OTHER_TRACE_HEX)!.resourceSpans![0] as OtlpResourceSpan; + expect(second.scopeSpans![0]!.spans).toEqual([otherSpan]); + }); + + test("partitions log records by trace id and keys base64 ids as hex", () => { + const partitions = partitionByTraceId({ + resourceLogs: [ + { + scopeLogs: [ + { + scope: {}, + logRecords: [{ traceId: TRACE_ID_B64 }, { traceId: OTHER_TRACE_HEX }], + }, + ], + }, + ], + }); + + expect([...partitions.keys()].sort()).toEqual([TRACE_ID_HEX, OTHER_TRACE_HEX]); + }); + + test("drops spans without a trace id and returns empty for empty payloads", () => { + expect(partitionByTraceId({}).size).toBe(0); + const partitions = partitionByTraceId({ + resourceSpans: [resourceSpan({ spans: [{ name: "orphan" }] })], + }); + expect(partitions.size).toBe(0); + }); +}); + +describe("buildTraceDetail", () => { + test("hexes ids, flattens attributes, and unwraps log bodies", () => { + const detail = buildTraceDetail( + [resourceSpan({ serviceName: "svc", spans: [agentSpan] })], + [ + { + resource: { attributes: [{ key: "service.name", value: { stringValue: "svc" } }] }, + scopeLogs: [ + { + scope: {}, + logRecords: [ + { traceId: TRACE_ID_B64, spanId: SPAN_ID_HEX, body: { stringValue: "log line" } }, + ], + }, + ], + }, + ], + ); + + const spans = detail.resourceSpans as { + resource: { attributes: Record }; + scopeSpans: { spans: { traceId: string; attributes: Record }[] }[]; + }[]; + expect(spans[0]!.resource.attributes).toEqual({ "service.name": "svc" }); + expect(spans[0]!.scopeSpans[0]!.spans[0]!.traceId).toBe(TRACE_ID_HEX); + expect(spans[0]!.scopeSpans[0]!.spans[0]!.attributes).toEqual({ + "gen_ai.prompt": "hello", + "session.id": "session-1", + }); + + const logs = detail.resourceLogs as { + scopeLogs: { logRecords: { traceId: string; body: unknown }[] }[]; + }[]; + expect(logs[0]!.scopeLogs[0]!.logRecords[0]!.traceId).toBe(TRACE_ID_HEX); + expect(logs[0]!.scopeLogs[0]!.logRecords[0]!.body).toBe("log line"); + }); + + test("filters transport noise but keeps meaningful spans", () => { + const noiseSpans = [ + { name: "GET / http send", attributes: [] }, + { + name: "http.request", + attributes: [{ key: "asgi.event.type", value: { stringValue: "http.request" } }], + }, + { name: "POST", kind: 3, attributes: [] }, + { + name: "POST /invocations", + kind: 2, + attributes: [{ key: "http.method", value: { stringValue: "POST" } }], + }, + ]; + const detail = buildTraceDetail([resourceSpan({ spans: [...noiseSpans, agentSpan] })], []); + const spans = detail.resourceSpans as { scopeSpans: { spans: { name: string }[] }[] }[]; + expect(spans[0]!.scopeSpans[0]!.spans.map((span) => span.name)).toEqual([ + "invoke_agent strands", + ]); + }); + + test("string span kinds from JSON ingest are normalized before filtering", () => { + const detail = buildTraceDetail( + [resourceSpan({ spans: [{ name: "POST", kind: "SPAN_KIND_CLIENT", attributes: [] }] })], + [], + ); + expect(detail.resourceSpans).toBeUndefined(); + }); + + test("returns undefined sections when everything is filtered or empty", () => { + expect(buildTraceDetail([], [])).toEqual({ resourceSpans: undefined, resourceLogs: undefined }); + }); +}); + +describe("helpers", () => { + test("nanoToMs converts and handles absence", () => { + expect(nanoToMs("1700000000123456789")).toBe(1700000000123); + expect(nanoToMs(undefined)).toBe(0); + }); + + test("hexFromB64OrString accepts hex, base64, and empty", () => { + expect(hexFromB64OrString(TRACE_ID_HEX.toUpperCase())).toBe(TRACE_ID_HEX); + expect(hexFromB64OrString(TRACE_ID_B64)).toBe(TRACE_ID_HEX); + expect(hexFromB64OrString(undefined)).toBe(""); + }); + + test("flattenAttributes handles typed values, arrays, and flat passthrough", () => { + expect( + flattenAttributes([ + { key: "s", value: { stringValue: "x" } }, + { key: "i", value: { intValue: "42" } }, + { key: "d", value: { doubleValue: 1.5 } }, + { key: "b", value: { boolValue: true } }, + { key: "a", value: { arrayValue: { values: [{ stringValue: "y" }, { intValue: "7" }] } } }, + { key: "skipped" }, + ]), + ).toEqual({ s: "x", i: 42, d: 1.5, b: true, a: ["y", "7"] }); + expect(flattenAttributes({ already: "flat" })).toEqual({ already: "flat" }); + expect(flattenAttributes([])).toBeUndefined(); + expect(flattenAttributes(undefined)).toBeUndefined(); + }); + + test("extractAnyValue unwraps nested kvlist and array values", () => { + expect( + extractAnyValue({ + kvlistValue: { + values: [ + { + key: "nested", + value: { arrayValue: { values: [{ intValue: "1" }, { boolValue: false }] } }, + }, + { key: "plain", value: { stringValue: "v" } }, + ], + }, + }), + ).toEqual({ nested: [1, false], plain: "v" }); + expect(extractAnyValue("passthrough")).toBe("passthrough"); + expect(extractAnyValue(null)).toBeNull(); + }); +}); diff --git a/src/core/dev/otel/transforms.ts b/src/core/dev/otel/transforms.ts new file mode 100644 index 000000000..1c161d5e0 --- /dev/null +++ b/src/core/dev/otel/transforms.ts @@ -0,0 +1,311 @@ +import type { + OtlpAttributes, + OtlpAttributeValue, + OtlpPayload, + OtlpResource, + OtlpResourceLog, + OtlpResourceSpan, +} from "./types"; + +export interface TraceMeta { + traceId?: string; + firstSeen: number; + lastSeen: number; + sessionId?: string; + serviceName?: string; + spanCount: number; +} + +/** Extract listing metadata (trace id, time bounds, session, service, count) from raw OTLP arrays. */ +export function extractTraceMeta( + resourceSpans: OtlpResourceSpan[], + resourceLogs: OtlpResourceLog[], +): TraceMeta { + const meta: TraceMeta = { firstSeen: Infinity, lastSeen: 0, spanCount: 0 }; + + for (const resourceSpan of resourceSpans) { + meta.serviceName ??= getResourceAttribute(resourceSpan.resource, "service.name"); + for (const scopeSpan of resourceSpan.scopeSpans ?? []) { + for (const span of scopeSpan.spans ?? []) { + meta.spanCount++; + meta.traceId ??= hexFromB64OrString(span.traceId) || undefined; + widenTimeBounds(meta, nanoToMs(span.startTimeUnixNano)); + widenTimeBounds(meta, nanoToMs(span.endTimeUnixNano)); + meta.sessionId ??= + getAttributeValue(span.attributes, "session.id") ?? + getAttributeValue(span.attributes, "attributes.session.id"); + } + } + } + + for (const resourceLog of resourceLogs) { + meta.serviceName ??= getResourceAttribute(resourceLog.resource, "service.name"); + for (const scopeLog of resourceLog.scopeLogs ?? []) { + for (const record of scopeLog.logRecords ?? []) { + meta.spanCount++; + meta.traceId ??= hexFromB64OrString(record.traceId) || undefined; + widenTimeBounds( + meta, + nanoToMs(record.timeUnixNano) || nanoToMs(record.observedTimeUnixNano), + ); + } + } + } + + const now = Date.now(); + if (meta.firstSeen === Infinity) meta.firstSeen = now; + if (meta.lastSeen === 0) meta.lastSeen = now; + return meta; +} + +/** + * Split one OTLP export payload into per-trace payloads, keyed by hex trace id. + * A single export batch routinely carries spans from several traces (SDKs batch + * by time, not by trace), so persistence must not attribute a whole batch to + * the first trace id it sees. Spans and log records without a trace id are dropped. + * Resource and scope structure is preserved within each partition. + */ +export function partitionByTraceId(payload: OtlpPayload): Map { + const partitions = new Map(); + const partition = (traceId: string): OtlpPayload => { + let entry = partitions.get(traceId); + if (!entry) { + entry = {}; + partitions.set(traceId, entry); + } + return entry; + }; + + for (const resourceSpan of payload.resourceSpans ?? []) { + for (const scopeSpan of resourceSpan.scopeSpans ?? []) { + const byTrace = groupBy(scopeSpan.spans ?? [], (span) => hexFromB64OrString(span.traceId)); + for (const [traceId, spans] of byTrace) { + (partition(traceId).resourceSpans ??= []).push({ + resource: resourceSpan.resource, + scopeSpans: [{ scope: scopeSpan.scope, spans }], + }); + } + } + } + + for (const resourceLog of payload.resourceLogs ?? []) { + for (const scopeLog of resourceLog.scopeLogs ?? []) { + const byTrace = groupBy(scopeLog.logRecords ?? [], (record) => + hexFromB64OrString(record.traceId), + ); + for (const [traceId, logRecords] of byTrace) { + (partition(traceId).resourceLogs ??= []).push({ + resource: resourceLog.resource, + scopeLogs: [{ scope: scopeLog.scope, logRecords }], + }); + } + } + } + + return partitions; +} + +function groupBy(items: T[], key: (item: T) => string): Map { + const groups = new Map(); + for (const item of items) { + const groupKey = key(item); + if (!groupKey) continue; + const group = groups.get(groupKey); + if (group) group.push(item); + else groups.set(groupKey, [item]); + } + return groups; +} + +/** + * Build frontend-ready trace detail from raw OTLP arrays: ids to hex, attributes + * flattened to plain records, transport-noise spans dropped, log bodies unwrapped. + */ +export function buildTraceDetail( + resourceSpans: OtlpResourceSpan[], + resourceLogs: OtlpResourceLog[], +): { resourceSpans?: unknown[]; resourceLogs?: unknown[] } { + const spans = resourceSpans + .map((resourceSpan) => ({ + resource: resourceSpan.resource + ? { attributes: flattenAttributes(resourceSpan.resource.attributes) } + : undefined, + scopeSpans: resourceSpan.scopeSpans + ?.map((scopeSpan) => ({ + scope: scopeSpan.scope, + spans: scopeSpan.spans + ?.map((span) => ({ + ...span, + traceId: hexFromB64OrString(span.traceId), + spanId: hexFromB64OrString(span.spanId), + parentSpanId: hexFromB64OrString(span.parentSpanId), + attributes: flattenAttributes(span.attributes), + })) + .filter((span) => isMeaningfulSpan(span)), + })) + .filter((scopeSpan) => scopeSpan.spans && scopeSpan.spans.length > 0), + })) + .filter((resourceSpan) => resourceSpan.scopeSpans && resourceSpan.scopeSpans.length > 0); + + const logs = resourceLogs + .map((resourceLog) => ({ + resource: resourceLog.resource + ? { attributes: flattenAttributes(resourceLog.resource.attributes) } + : undefined, + scopeLogs: resourceLog.scopeLogs?.map((scopeLog) => ({ + scope: scopeLog.scope, + logRecords: scopeLog.logRecords?.map((record) => ({ + ...record, + traceId: hexFromB64OrString(record.traceId), + spanId: hexFromB64OrString(record.spanId), + body: record.body === undefined ? undefined : extractAnyValue(record.body), + attributes: flattenAttributes(record.attributes), + })), + })), + })) + .filter((resourceLog) => resourceLog.scopeLogs && resourceLog.scopeLogs.length > 0); + + return { + resourceSpans: spans.length > 0 ? spans : undefined, + resourceLogs: logs.length > 0 ? logs : undefined, + }; +} + +/** + * Whether a span carries application-level signal. Filters ASGI transport events, + * bare HTTP client/server noise, and other framework spans that add nothing in the UI. + */ +function isMeaningfulSpan(span: { + name?: string; + kind?: number | string; + attributes?: Record; +}): boolean { + const name = span.name ?? ""; + const attributes = span.attributes ?? {}; + const kind = normalizeSpanKind(span.kind); + + if (name.endsWith(" http send") || name.endsWith(" http receive")) return false; + if (attributes["asgi.event.type"]) return false; + if (Object.keys(attributes).some((key) => key.startsWith("gen_ai."))) return true; + if (attributes["rpc.system"] || attributes["rpc.method"]) return true; + + const scopeHints = ["strands", "bedrock", "langchain", "crewai", "autogen", "google_adk"]; + if (scopeHints.some((hint) => name.toLowerCase().includes(hint))) return true; + if (name === "tool_use" || name === "tool_call" || attributes["tool.name"]) return true; + + if (kind === SPAN_KIND.CLIENT && (name === "POST" || name === "GET" || name.startsWith("HTTP "))) + return false; + if (kind === SPAN_KIND.SERVER && name.startsWith("POST /") && attributes["http.method"]) + return false; + + return true; +} + +const SPAN_KIND = { INTERNAL: 1, SERVER: 2, CLIENT: 3, PRODUCER: 4, CONSUMER: 5 } as const; + +/** Normalize a span kind from its protobuf enum name or number to the numeric value. */ +function normalizeSpanKind(kind: number | string | undefined): number { + if (typeof kind === "number") return kind; + if (typeof kind === "string") { + const name = kind.replace(/^SPAN_KIND_/, "") as keyof typeof SPAN_KIND; + return SPAN_KIND[name] ?? 0; + } + return 0; +} + +/** Convert a nanosecond timestamp string to milliseconds (0 when absent). */ +export function nanoToMs(nano: string | undefined): number { + if (!nano) return 0; + return Math.floor(Number(nano) / 1_000_000); +} + +/** + * Normalize a trace/span id that may be base64 (protobuf JSON conversion) or + * already hex (JSON ingest) into lowercase hex. + */ +export function hexFromB64OrString(value: string | undefined): string { + if (!value) return ""; + if (/^[0-9a-f]+$/i.test(value) && (value.length === 32 || value.length === 16)) + return value.toLowerCase(); + try { + return Buffer.from(value, "base64").toString("hex"); + } catch { + return value; + } +} + +/** Flatten OTLP attributes into a plain record; passes already-flat records through. */ +export function flattenAttributes( + attributes: OtlpAttributes | undefined, +): Record | undefined { + if (!attributes) return undefined; + if (!Array.isArray(attributes)) return attributes; + if (attributes.length === 0) return undefined; + + const flat: Record = {}; + for (const attribute of attributes) { + if (!attribute.value) continue; + const value = attribute.value; + if (value.stringValue !== undefined) flat[attribute.key] = value.stringValue; + else if (value.intValue !== undefined) flat[attribute.key] = Number(value.intValue); + else if (value.doubleValue !== undefined) flat[attribute.key] = value.doubleValue; + else if (value.boolValue !== undefined) flat[attribute.key] = value.boolValue; + else if (value.arrayValue?.values) { + flat[attribute.key] = value.arrayValue.values.map( + (item: OtlpAttributeValue) => + item.stringValue ?? item.intValue ?? item.doubleValue ?? item.boolValue ?? null, + ); + } + } + return flat; +} + +/** Unwrap an OTLP AnyValue (string/int/double/bool/array/kvlist) into a plain value. */ +export function extractAnyValue(value: unknown): unknown { + if (!value || typeof value !== "object") return value; + const anyValue = value as Record; + if (anyValue.stringValue !== undefined) return anyValue.stringValue; + if (anyValue.intValue !== undefined) return Number(anyValue.intValue); + if (anyValue.doubleValue !== undefined) return anyValue.doubleValue; + if (anyValue.boolValue !== undefined) return anyValue.boolValue; + if (anyValue.arrayValue && typeof anyValue.arrayValue === "object") { + const { values } = anyValue.arrayValue as { values?: unknown[] }; + return (values ?? []).map(extractAnyValue); + } + if (anyValue.kvlistValue && typeof anyValue.kvlistValue === "object") { + const { values } = anyValue.kvlistValue as { values?: { key: string; value?: unknown }[] }; + const record: Record = {}; + for (const entry of values ?? []) { + record[entry.key] = entry.value === undefined ? undefined : extractAnyValue(entry.value); + } + return record; + } + return value; +} + +function getResourceAttribute(resource: OtlpResource | undefined, key: string): string | undefined { + return getAttributeValue(resource?.attributes, key); +} + +function getAttributeValue( + attributes: OtlpAttributes | undefined, + key: string, +): string | undefined { + if (!attributes) return undefined; + if (Array.isArray(attributes)) { + const attribute = attributes.find((entry) => entry.key === key); + if (!attribute?.value) return undefined; + return ( + attribute.value.stringValue ?? + (attribute.value.intValue != null ? String(attribute.value.intValue) : undefined) + ); + } + const value = attributes[key]; + return typeof value === "string" ? value : undefined; +} + +function widenTimeBounds(meta: TraceMeta, timeMs: number): void { + if (!timeMs) return; + if (timeMs < meta.firstSeen) meta.firstSeen = timeMs; + if (timeMs > meta.lastSeen) meta.lastSeen = timeMs; +} diff --git a/src/core/dev/otel/types.ts b/src/core/dev/otel/types.ts new file mode 100644 index 000000000..a458de9d4 --- /dev/null +++ b/src/core/dev/otel/types.ts @@ -0,0 +1,65 @@ +/** + * Wire shapes for OTLP/HTTP payloads after protobuf JSON conversion or JSON ingest. + * Attributes appear either as OTLP key/value arrays (from the SDK exporters) or as + * already-flat records (after our own flattening) — helpers accept both. + */ + +export interface OtlpAttributeValue { + stringValue?: string; + intValue?: string; + doubleValue?: number; + boolValue?: boolean; + arrayValue?: { values?: OtlpAttributeValue[] }; + kvlistValue?: { values?: OtlpAttribute[] }; +} + +export interface OtlpAttribute { + key: string; + value?: OtlpAttributeValue; +} + +export type OtlpAttributes = OtlpAttribute[] | Record; + +export interface OtlpResource { + attributes?: OtlpAttributes; +} + +export interface OtlpSpan { + traceId?: string; + spanId?: string; + parentSpanId?: string; + name?: string; + kind?: number | string; + startTimeUnixNano?: string; + endTimeUnixNano?: string; + attributes?: OtlpAttributes; + status?: { code?: number; message?: string }; + events?: unknown[]; +} + +export interface OtlpResourceSpan { + resource?: OtlpResource; + scopeSpans?: { scope?: { name?: string; version?: string }; spans?: OtlpSpan[] }[]; +} + +export interface OtlpLogRecord { + timeUnixNano?: string; + observedTimeUnixNano?: string; + severityNumber?: number; + severityText?: string; + body?: unknown; + attributes?: OtlpAttributes; + traceId?: string; + spanId?: string; +} + +export interface OtlpResourceLog { + resource?: OtlpResource; + scopeLogs?: { scope?: { name?: string; version?: string }; logRecords?: OtlpLogRecord[] }[]; +} + +/** One OTLP export payload: what a single POST /v1/traces or /v1/logs carries. */ +export interface OtlpPayload { + resourceSpans?: OtlpResourceSpan[]; + resourceLogs?: OtlpResourceLog[]; +} diff --git a/src/core/dev/port.test.ts b/src/core/dev/port.test.ts new file mode 100644 index 000000000..cdb84411e --- /dev/null +++ b/src/core/dev/port.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "bun:test"; +import type { PortChecker } from "../../io"; +import { PortInUseError, resolveDevPort } from "./port"; + +const signal = new AbortController().signal; + +describe("resolveDevPort", () => { + test.each([ + ["HTTP", 8080], + ["AGUI", 8080], + ["MCP", 8000], + ["A2A", 9000], + ] as const)("uses the %s default", async (protocol, port) => { + expect(await resolveDevPort(protocol, undefined, async () => true, signal)).toEqual({ + port, + requestedPort: port, + }); + }); + + test("walks up from occupied defaults", async () => { + const checked: number[] = []; + const check: PortChecker = async (port) => { + checked.push(port); + return port === 8002; + }; + + expect(await resolveDevPort("MCP", undefined, check, signal)).toEqual({ + port: 8002, + requestedPort: 8000, + }); + expect(checked).toEqual([8000, 8001, 8002]); + }); + + test("accepts a free explicit port and rejects an occupied one", async () => { + expect(await resolveDevPort("A2A", 4567, async () => true, signal)).toEqual({ + port: 4567, + requestedPort: 4567, + }); + const occupied = resolveDevPort("A2A", 4567, async () => false, signal); + await expect(occupied).rejects.toBeInstanceOf(PortInUseError); + await expect(occupied).rejects.toThrow("lsof -i :4567"); + }); + + test("bounds the default search", async () => { + await expect(resolveDevPort("HTTP", undefined, async () => false, signal)).rejects.toThrow( + "No free port found in range 8080-8179", + ); + }); +}); diff --git a/src/core/dev/port.ts b/src/core/dev/port.ts new file mode 100644 index 000000000..1b88425ec --- /dev/null +++ b/src/core/dev/port.ts @@ -0,0 +1,47 @@ +import { InputValidationError } from "../../errors"; +import type { ProjectRuntime } from "../../projectSchemas/runtime"; +import type { PortChecker } from "../../io"; + +const MAX_PORT_ATTEMPTS = 100; +export const DEV_PORTS = { HTTP: 8080, AGUI: 8080, MCP: 8000, A2A: 9000 } as const; + +export type DevPort = { + port: number; + requestedPort: number; +}; + +export class PortInUseError extends InputValidationError { + constructor(port: number) { + super( + `Port ${port} is already in use. Find the process with ` + + `'lsof -i :${port}' (macOS/Linux) or 'netstat -ano | findstr :${port}' (Windows), ` + + "then stop it or choose a different --port.", + ); + } +} + +export async function resolveDevPort( + protocol: ProjectRuntime["protocol"], + explicitPort: number | undefined, + checkPort: PortChecker, + signal: AbortSignal, +): Promise { + const defaultPort = DEV_PORTS[protocol ?? "HTTP"]; + const requestedPort = explicitPort ?? defaultPort; + + if (await checkPort(requestedPort, signal)) { + return { port: requestedPort, requestedPort }; + } + + if (explicitPort !== undefined) { + throw new PortInUseError(requestedPort); + } + + for (let port = requestedPort + 1; port < requestedPort + MAX_PORT_ATTEMPTS; port++) { + if (await checkPort(port, signal)) return { port, requestedPort }; + } + + throw new InputValidationError( + `No free port found in range ${requestedPort}-${requestedPort + MAX_PORT_ATTEMPTS - 1}.`, + ); +} diff --git a/src/core/types.tsx b/src/core/types.tsx index 9b1a2d4a8..98a2f338d 100644 --- a/src/core/types.tsx +++ b/src/core/types.tsx @@ -37,7 +37,7 @@ export type CoreFetch = ( // full ClientConfig so callers can request any client customization (region, // endpoint, ...). export interface AwsClients { - control(config: ClientConfig): BedrockAgentCoreControlClient + control(config: ClientConfig): BedrockAgentCoreControlClient; data(config: ClientConfig): BedrockAgentCoreClient; iam(config: ClientConfig): IAMClient; // logs reads the CloudWatch Logs streams AgentCore writes batch-evaluation diff --git a/src/errors/errors.tsx b/src/errors/errors.tsx index e671c1996..efc9a9e11 100644 --- a/src/errors/errors.tsx +++ b/src/errors/errors.tsx @@ -69,6 +69,9 @@ export class InputValidationError extends AgentCoreCLIError { } } +/** Error raised when valid user input references a resource that does not exist. */ +export class ResourceNotFoundError extends InputValidationError {} + /** Error raised when a command or operation has not been implemented yet. */ export class NotImplementedError extends AgentCoreCLIError { constructor(message?: string, options?: Omit) { @@ -135,7 +138,7 @@ export class EmbeddedAssetNotFoundError extends AgentCoreCLIError { } } -export class RuntimeInvokeInterruptedError extends AgentCoreCLIError { +export class CommandInterruptedError extends AgentCoreCLIError { readonly reported: boolean; constructor(cause?: unknown, reported = false) { diff --git a/src/errors/index.tsx b/src/errors/index.tsx index 9b3249fa2..bd2f99684 100644 --- a/src/errors/index.tsx +++ b/src/errors/index.tsx @@ -1,5 +1,6 @@ export { AgentCoreCLIError, + CommandInterruptedError, DeserializationError, EmbeddedAssetNotFoundError, FileWriteError, @@ -12,8 +13,8 @@ export { NetworkingError, NotImplementedError, ProjectFileExistsError, + ResourceNotFoundError, ResultTruncationError, - RuntimeInvokeInterruptedError, RuntimeInvokeResponseError, SourceResolutionError, type AgentCoreCLIErrorOptions, diff --git a/src/handlers/eval/ondemand/ondemand.test.tsx b/src/handlers/eval/ondemand/ondemand.test.tsx index 6d7173444..77c0c688a 100644 --- a/src/handlers/eval/ondemand/ondemand.test.tsx +++ b/src/handlers/eval/ondemand/ondemand.test.tsx @@ -24,7 +24,9 @@ const TRACE: SessionTrace = { const RESULT: EvaluateResult = { sessionsRequested: 1, sessionsEvaluated: 1, - results: [{ evaluatorId: "Builtin.Helpfulness", value: 0.9 } as EvaluateResult["results"][number]], + results: [ + { evaluatorId: "Builtin.Helpfulness", value: 0.9 } as EvaluateResult["results"][number], + ], }; async function run(args: string[], configure?: (core: TestCoreClient) => void) { diff --git a/src/handlers/project/dev/environment.test.ts b/src/handlers/project/dev/environment.test.ts new file mode 100644 index 000000000..219cf7d37 --- /dev/null +++ b/src/handlers/project/dev/environment.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from "bun:test"; +import { join } from "node:path"; +import type { ProjectRuntime } from "../../../projectSchemas/runtime"; +import { createDevEnvironmentLoader } from "./environment"; + +const projectRoot = "/workspace/project"; + +const runtime = (envVars: { name: string; value: string }[] = []) => + ({ name: "orders", build: "Container", envVars }) as ProjectRuntime; + +const input = (envVars: { name: string; value: string }[] = []) => ({ + projectRoot, + runtime: runtime(envVars), + region: "us-east-1", +}); + +describe("createDevEnvironmentLoader", () => { + test("merges runtime, region, and .env.local while removing runner-owned keys", async () => { + const loader = createDevEnvironmentLoader({ + readFile: async () => ` +SHARED="local value" +AWS_REGION=local-region +PORT=9999 +FASTMCP_PORT=9998 +LOCAL_DEV=0 +MULTILINE="first +second" +`, + }); + + await expect( + loader( + input([ + { name: "SHARED", value: "runtime" }, + { name: "RUNTIME_ONLY", value: "yes" }, + { name: "PORT", value: "1234" }, + ]), + ), + ).resolves.toEqual({ + env: { + SHARED: "local value", + RUNTIME_ONLY: "yes", + AWS_REGION: "local-region", + MULTILINE: "first\nsecond", + }, + }); + }); + + test.each([ + ["ENOENT", undefined], + [ + "EACCES", + `Unable to read local environment file at ${join(projectRoot, "agentcore", ".env.local")}`, + ], + ] as const)("handles .env.local read error %s", async (code, expectedError) => { + const loader = createDevEnvironmentLoader({ + readFile: async () => { + throw Object.assign(new Error("read failed"), { code }); + }, + }); + + const pending = loader(input([{ name: "RUNTIME_ONLY", value: "yes" }])); + if (expectedError) { + await expect(pending).rejects.toThrow(expectedError); + } else { + await expect(pending).resolves.toEqual({ + env: { RUNTIME_ONLY: "yes", AWS_REGION: "us-east-1" }, + }); + } + }); +}); diff --git a/src/handlers/project/dev/environment.ts b/src/handlers/project/dev/environment.ts new file mode 100644 index 000000000..f852d5431 --- /dev/null +++ b/src/handlers/project/dev/environment.ts @@ -0,0 +1,65 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { parseEnv } from "node:util"; +import type { ProjectRuntime } from "../../../projectSchemas/runtime"; +import { InputValidationError } from "../../../errors"; + +const RESERVED_ENV_KEYS = ["PORT", "FASTMCP_PORT", "LOCAL_DEV"] as const; + +export type DevEnvironmentInput = { + projectRoot: string; + runtime: ProjectRuntime; + region?: string; +}; + +export type DevEnvironment = { + env: Record; +}; + +export type DevEnvironmentLoader = (input: DevEnvironmentInput) => Promise; + +type DevEnvironmentLoaderConfig = { + readFile?: (path: string, encoding: BufferEncoding) => Promise; +}; + +async function localEnvironment( + projectRoot: string, + read: (path: string, encoding: BufferEncoding) => Promise, +): Promise> { + const path = join(projectRoot, "agentcore", ".env.local"); + let contents: string; + try { + contents = await read(path, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return {}; + throw new InputValidationError(`Unable to read local environment file at ${path}`, { + cause: error, + }); + } + + try { + return parseEnv(contents) as Record; + } catch (error) { + throw new InputValidationError(`Invalid local environment file at ${path}`, { cause: error }); + } +} + +export function createDevEnvironmentLoader( + config: DevEnvironmentLoaderConfig = {}, +): DevEnvironmentLoader { + const read = config.readFile ?? readFile; + + return async (input) => { + const env = Object.fromEntries( + (input.runtime.envVars ?? []).map(({ name, value }) => [name, value]), + ); + if (input.region) env.AWS_REGION = input.region; + + Object.assign(env, await localEnvironment(input.projectRoot, read)); + for (const key of RESERVED_ENV_KEYS) delete env[key]; + + return { env }; + }; +} + +export const loadDevEnvironment = createDevEnvironmentLoader(); diff --git a/src/handlers/project/dev/index.test.ts b/src/handlers/project/dev/index.test.ts new file mode 100644 index 000000000..b7370be52 --- /dev/null +++ b/src/handlers/project/dev/index.test.ts @@ -0,0 +1,291 @@ +import { describe, expect, test } from "bun:test"; +import type { ProjectRuntime } from "../../../projectSchemas/runtime"; +import { InputValidationError, ResourceNotFoundError } from "../../../errors"; +import type { PortChecker } from "../../../io"; +import { ProjectKey, ValueContext } from "../../../router"; +import { testIO } from "../../../testing"; +import { JsonRendererKey } from "../../../tui"; +import { JsonKey, RegionKey } from "../../keys"; +import type { Project } from "../types"; +import { createDevProjectHandler, type DevProjectHandlerConfig } from "."; +import type { DevEnvironmentInput } from "./environment"; +import type { DevEvent, DevRunner, DevServerInput, DevTraceCollector } from "./types"; + +function runtime(name = "orders", build: ProjectRuntime["build"] = "CodeZip"): ProjectRuntime { + return { + name, + build, + protocol: "HTTP", + entrypoint: "main.py", + codeLocation: `app/${name}`, + } as ProjectRuntime; +} + +function project(...runtimes: ProjectRuntime[]): Project { + return { name: "test-project", rootPath: "/workspace/project", managedBy: "CDK", runtimes }; +} + +function captureRunner(events: DevEvent[] = []) { + const inputs: DevServerInput[] = []; + const runner: DevRunner = { + run: async function* (input) { + inputs.push(input); + yield* events; + }, + }; + return { runner, inputs }; +} + +function fakeCollector() { + const starts: { tracesDirectory: string; signal?: AbortSignal }[] = []; + const state = { closed: 0 }; + const collector: DevTraceCollector = { + port: 43180, + envVars: { + OTEL_EXPORTER_OTLP_ENDPOINT: "http://127.0.0.1:43180", + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "http://127.0.0.1:43180/v1/traces", + OTEL_EXPORTER_OTLP_PROTOCOL: "http/protobuf", + }, + close: async () => { + state.closed++; + }, + }; + const start: DevProjectHandlerConfig["startTraceCollector"] = async (options) => { + starts.push(options); + return collector; + }; + return { start, starts, state }; +} + +type HarnessOptions = { + project?: Project; + codeZip?: ReturnType; + container?: ReturnType; + checkPort?: PortChecker; + json?: boolean; + loadEnvironment?: DevProjectHandlerConfig["loadDevEnvironment"]; +}; + +function harness(options: HarnessOptions = {}) { + const io = testIO(); + const codeZip = options.codeZip ?? captureRunner(); + const container = options.container ?? captureRunner(); + const collector = fakeCollector(); + const environmentInputs: DevEnvironmentInput[] = []; + const handler = createDevProjectHandler({ + io: io.io, + runners: { CodeZip: codeZip.runner, Container: container.runner }, + loadDevEnvironment: + options.loadEnvironment ?? + (async (input) => { + environmentInputs.push(input); + return { env: { FROM_LOADER: "yes" } }; + }), + checkPort: options.checkPort ?? (async () => true), + startTraceCollector: collector.start, + }); + const ctx = ValueContext.EmptyContext() + .withValue(ProjectKey, options.project ?? project(runtime())) + .withValue(JsonKey, options.json ?? false) + .withValue(RegionKey, "us-west-2") + .withValue(JsonRendererKey, { + renderJson: (data) => io.io.stdout.write(`${JSON.stringify(data, null, 2)}\n`), + renderJsonLine: (data) => io.io.stdout.write(`${JSON.stringify(data)}\n`), + }); + + return { + codeZip, + container, + collector, + environmentInputs, + io, + run: (flags: { agent?: string; port?: number; traces?: boolean } = {}) => + handler.handle(ctx, { traces: true, ...flags }, {}), + }; +} + +describe("project dev selection and dispatch", () => { + test.each([ + [project(), {}, "This project has no runtimes", InputValidationError], + [ + project(runtime("orders"), runtime("support", "Container")), + {}, + "Use --agent to select one. Available runtimes: orders, support", + InputValidationError, + ], + [ + project(runtime("orders"), runtime("support", "Container")), + { agent: "missing" }, + "Runtime 'missing' was not found. Available runtimes: orders, support", + ResourceNotFoundError, + ], + ] as const)( + "rejects invalid runtime selection", + async (configuredProject, flags, message, ErrorType) => { + const pending = harness({ project: configuredProject }).run(flags); + await expect(pending).rejects.toBeInstanceOf(ErrorType); + await expect(pending).rejects.toThrow(message); + }, + ); + + test("loads the environment and dispatches the selected runtime", async () => { + const subject = harness({ + project: project(runtime("orders"), runtime("support", "Container")), + }); + await subject.run({ agent: "support", port: 4567 }); + + expect(subject.codeZip.inputs).toHaveLength(0); + expect(subject.environmentInputs).toEqual([ + { + projectRoot: "/workspace/project", + runtime: expect.objectContaining({ name: "support" }), + region: "us-west-2", + }, + ]); + expect(subject.container.inputs[0]).toMatchObject({ + projectRoot: "/workspace/project", + port: 4567, + env: { + FROM_LOADER: "yes", + OTEL_EXPORTER_OTLP_ENDPOINT: "http://host.docker.internal:43180", + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "http://host.docker.internal:43180/v1/traces", + OTEL_SERVICE_NAME: "support", + }, + runtime: { name: "support", build: "Container" }, + }); + }); + + test("announces an automatically selected port", async () => { + const checked: number[] = []; + const subject = harness({ + checkPort: async (port) => { + checked.push(port); + return port === 8081; + }, + }); + await subject.run(); + + expect(checked).toEqual([8080, 8081]); + expect(subject.codeZip.inputs[0]?.port).toBe(8081); + expect(subject.io.stderr()).toContain("Port 8080 is in use; using 8081."); + }); +}); + +describe("project dev trace collection", () => { + test("starts the collector, announces it, and points a CodeZip agent at loopback", async () => { + const subject = harness(); + await subject.run(); + + expect(subject.collector.starts).toEqual([ + { + tracesDirectory: "/workspace/project/agentcore/.cli/traces/otlp", + signal: expect.any(AbortSignal), + }, + ]); + expect(subject.io.stderr()).toContain("OTEL collector listening on port 43180"); + expect(subject.codeZip.inputs[0]?.env).toMatchObject({ + OTEL_EXPORTER_OTLP_ENDPOINT: "http://127.0.0.1:43180", + OTEL_SERVICE_NAME: "orders", + }); + expect(subject.collector.state.closed).toBe(1); + }); + + test("--no-traces skips the collector entirely", async () => { + const subject = harness(); + await subject.run({ traces: false }); + + expect(subject.collector.starts).toHaveLength(0); + expect(subject.codeZip.inputs[0]?.env).toEqual({ FROM_LOADER: "yes" }); + }); + + test("a runtime with instrumentation disabled skips the collector", async () => { + const disabled = { ...runtime(), instrumentation: { enableOtel: false } } as ProjectRuntime; + const subject = harness({ project: project(disabled) }); + await subject.run(); + + expect(subject.collector.starts).toHaveLength(0); + expect(subject.codeZip.inputs[0]?.env).toEqual({ FROM_LOADER: "yes" }); + }); + + test("the collector is closed when the runner fails", async () => { + const codeZip = captureRunner(); + codeZip.runner.run = async function* () { + yield* []; + throw new InputValidationError("runner failed"); + }; + const subject = harness({ codeZip }); + + await expect(subject.run()).rejects.toThrow("runner failed"); + expect(subject.collector.state.closed).toBe(1); + }); +}); + +test("project dev renders human and NDJSON output", async () => { + const events: DevEvent[] = [ + { type: "status", message: "Starting" }, + { type: "stdout", line: "agent output" }, + { type: "stderr", line: "agent warning" }, + ]; + + for (const json of [false, true]) { + const subject = harness({ codeZip: captureRunner(events), json }); + await subject.run({ traces: false }); + expect(subject.io.stdout()).toBe( + json ? events.map((event) => JSON.stringify(event)).join("\n") : "agent output", + ); + expect(subject.io.stderr()).toBe(json ? "" : "Starting\nagent warning"); + } +}); + +function heldRunner() { + let start!: (input: DevServerInput) => void; + let release: (() => void) | undefined; + const started = new Promise((resolve) => (start = resolve)); + const runner: DevRunner = { + run: async function* (input) { + yield* []; + start(input); + await new Promise((resolve) => (release = resolve)); + input.signal.throwIfAborted(); + }, + }; + return { runner, inputs: [], started, release: () => release?.() }; +} + +describe("project dev interruption", () => { + test.each(["SIGINT", "SIGTERM"] as const)( + "%s aborts, reports exit 130, and removes its listener", + async (signal) => { + const codeZip = heldRunner(); + const before = process.listenerCount(signal); + const subject = harness({ codeZip }); + const pending = subject.run(); + const input = await codeZip.started; + + process.emit(signal, signal); + process.emit(signal, signal); + codeZip.release(); + + expect(input.signal.aborted).toBe(true); + await expect(pending).rejects.toMatchObject({ + name: "AbortError", + reported: true, + exitCode: 130, + }); + expect(subject.io.stderr()).toContain("Shutting down…"); + expect(subject.collector.state.closed).toBe(1); + expect(process.listenerCount(signal)).toBe(before); + }, + ); + + test("preserves an ordinary runner failure", async () => { + const failure = new InputValidationError("runner failed"); + const codeZip = captureRunner(); + codeZip.runner.run = async function* () { + yield* []; + throw failure; + }; + + await expect(harness({ codeZip }).run()).rejects.toBe(failure); + }); +}); diff --git a/src/handlers/project/dev/index.ts b/src/handlers/project/dev/index.ts index 310634651..9b6dfaf9d 100644 --- a/src/handlers/project/dev/index.ts +++ b/src/handlers/project/dev/index.ts @@ -1,11 +1,157 @@ -import { createHandler } from "../../../router"; -import { NotImplementedError } from "../../../errors"; +import { join } from "node:path"; +import z from "zod"; +import { rewriteOtelEndpointForContainer } from "../../../core/dev/otel/collector"; +import { resolveDevPort } from "../../../core/dev/port"; +import type { ProjectRuntime } from "../../../projectSchemas/runtime"; +import { + CommandInterruptedError, + InputValidationError, + ResourceNotFoundError, +} from "../../../errors"; +import type { AppIO, PortChecker } from "../../../io"; +import { createHandler, flag, ProjectKey } from "../../../router"; +import { JsonRendererKey, type JsonRenderer } from "../../../tui"; +import { JsonKey, RegionKey } from "../../keys"; +import type { Project } from "../types"; +import type { DevEnvironmentLoader } from "./environment"; +import type { DevEvent, DevRunner, DevTraceCollector, DevTraceCollectorStarter } from "./types"; -export const createDevProjectHandler = () => +export type DevProjectHandlerConfig = { + io: AppIO; + runners: { CodeZip: DevRunner; Container: DevRunner }; + loadDevEnvironment: DevEnvironmentLoader; + checkPort: PortChecker; + startTraceCollector: DevTraceCollectorStarter; +}; + +/** Env for a spawned agent so its OTEL SDK reports to the collector as this runtime. */ +function otelEnvForRuntime( + collector: DevTraceCollector, + runtime: ProjectRuntime, +): Record { + const env = { ...collector.envVars, OTEL_SERVICE_NAME: runtime.name }; + return runtime.build === "Container" ? rewriteOtelEndpointForContainer(env) : env; +} + +function selectRuntime(project: Project, name?: string): ProjectRuntime { + if (project.runtimes.length === 0) { + throw new InputValidationError( + "This project has no runtimes. Add a runtime to agentcore/agentcore.json and retry.", + ); + } + const available = project.runtimes.map(({ name }) => name).join(", "); + + if (name) { + const runtime = project.runtimes.find((candidate) => candidate.name === name); + if (runtime) return runtime; + throw new ResourceNotFoundError( + `Runtime '${name}' was not found. Available runtimes: ${available}.`, + ); + } + + if (project.runtimes.length === 1) return project.runtimes[0]!; + throw new InputValidationError( + `Multiple runtimes found. Use --agent to select one. Available runtimes: ${available}.`, + ); +} + +function renderEvent(io: AppIO, event: DevEvent, json?: JsonRenderer): void { + if (json) { + json.renderJsonLine(event); + return; + } + + const output = event.type === "stdout" ? io.stdout : io.stderr; + output.write(`${event.type === "status" ? event.message : event.line}\n`); +} + +export const createDevProjectHandler = (config: DevProjectHandlerConfig) => createHandler({ name: "dev", description: "run the project locally for development", - handle: async () => { - throw new NotImplementedError("agentcore project dev is not implemented yet"); + flags: [ + flag("agent", "runtime to run", z.string().optional()), + flag( + "port", + "port for the development server", + z.coerce.number().int().min(1).max(65535).optional(), + ), + flag("traces", "disable local OTEL trace collection", z.boolean().default(true)), + ], + handle: async (ctx, flags) => { + const controller = new AbortController(); + const json = ctx.require(JsonKey) ? ctx.require(JsonRendererKey) : undefined; + const interrupt = () => { + if (controller.signal.aborted) return; + config.io.stderr.write("Shutting down…\n"); + controller.abort(); + }; + + const signals = ["SIGINT", "SIGTERM"] as const; + for (const signal of signals) process.on(signal, interrupt); + let collector: DevTraceCollector | undefined; + try { + const project = ctx.require(ProjectKey); + const runtime = selectRuntime(project, flags.agent); + const devPort = await resolveDevPort( + runtime.protocol, + flags.port, + config.checkPort, + controller.signal, + ); + if (devPort.port !== devPort.requestedPort) { + renderEvent( + config.io, + { + type: "status", + message: `Port ${devPort.requestedPort} is in use; using ${devPort.port}.`, + }, + json, + ); + } + + const { env } = await config.loadDevEnvironment({ + projectRoot: project.rootPath, + runtime, + region: ctx.require(RegionKey), + }); + controller.signal.throwIfAborted(); + + let otelEnv: Record = {}; + if (flags.traces && (runtime.instrumentation?.enableOtel ?? true)) { + const tracesDirectory = join(project.rootPath, "agentcore", ".cli", "traces", "otlp"); + collector = await config.startTraceCollector({ + tracesDirectory, + signal: controller.signal, + }); + otelEnv = otelEnvForRuntime(collector, runtime); + renderEvent( + config.io, + { + type: "status", + message: `OTEL collector listening on port ${collector.port}; traces persist to ${tracesDirectory}.`, + }, + json, + ); + } + controller.signal.throwIfAborted(); + + const runner = config.runners[runtime.build]; + for await (const event of runner.run({ + runtime, + projectRoot: project.rootPath, + port: devPort.port, + env: { ...env, ...otelEnv }, + signal: controller.signal, + })) { + renderEvent(config.io, event, json); + } + } catch (error) { + if (!controller.signal.aborted) throw error; + throw new CommandInterruptedError(error, true); + } finally { + for (const signal of signals) process.removeListener(signal, interrupt); + await collector?.close(); + } }, }); diff --git a/src/handlers/project/dev/types.ts b/src/handlers/project/dev/types.ts index 933c0c452..289c1b5c9 100644 --- a/src/handlers/project/dev/types.ts +++ b/src/handlers/project/dev/types.ts @@ -16,3 +16,16 @@ export type DevServerInput = { export interface DevRunner { run(input: DevServerInput): AsyncGenerator; } + +/** A local OTLP receiver that spawned agents export traces to. */ +export interface DevTraceCollector { + port: number; + /** Environment variables that point an agent's OTEL SDK at the receiver. */ + envVars: Record; + close(): Promise; +} + +export type DevTraceCollectorStarter = (options: { + tracesDirectory: string; + signal?: AbortSignal; +}) => Promise; diff --git a/src/handlers/project/index.ts b/src/handlers/project/index.ts index 91d0350ad..f5ad74a26 100644 --- a/src/handlers/project/index.ts +++ b/src/handlers/project/index.ts @@ -1,9 +1,13 @@ import { Router } from "../../router"; +import { checkPort, type AppIO } from "../../io"; +import { CodeZipDevRunner } from "../../core/dev/codezip"; +import { ContainerDevRunner } from "../../core/dev/container"; +import { startOtelCollector } from "../../core/dev/otel/collector"; import { withProject } from "../../middleware"; -import type { AppIO } from "../../io"; import { createCreateProjectHandler } from "./create"; import { createRemoveProjectHandler } from "./remove"; import { createDevProjectHandler } from "./dev"; +import { loadDevEnvironment } from "./dev/environment"; import { createDeployProjectHandler } from "./deploy"; import { createStatusProjectHandler } from "./status"; import { createBuildProjectHandler } from "./build"; @@ -23,7 +27,20 @@ export function createProjectHandler(config: ProjectHandlerConfig): Router { ); project.handler(createAddProjectResourceHandler(config)); project.handler(createRemoveProjectHandler()); - project.handler(createDevProjectHandler()); + project.handler( + withProject({ projectManager: config.projectManager })( + createDevProjectHandler({ + io: config.io, + runners: { + CodeZip: new CodeZipDevRunner(), + Container: new ContainerDevRunner(), + }, + loadDevEnvironment, + checkPort, + startTraceCollector: startOtelCollector, + }), + ), + ); project.handler(createDeployProjectHandler()); project.handler(createStatusProjectHandler()); // withProject wraps only the commands that require an existing project, so diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index d9dce3bba..8b501fd9c 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -23,12 +23,17 @@ async function run(args: string[]) { return { io, core }; } -describe.each(["remove", "dev", "deploy", "status"])("project %s", (command) => { +describe.each(["remove", "deploy", "status"])("project %s", (command) => { test("throws because it is not implemented yet", async () => { await expect(run([command])).rejects.toThrow(/not implemented/); }); }); +test("project dev requires an AgentCore project", async () => { + await inTempDirectory(); + await expect(run(["dev"])).rejects.toThrow(/No AgentCore project found/); +}); + const originalCwd = process.cwd(); const tempDirectories: string[] = []; diff --git a/src/handlers/runtime/invoke/index.tsx b/src/handlers/runtime/invoke/index.tsx index 0110273a3..c7f1d6a58 100644 --- a/src/handlers/runtime/invoke/index.tsx +++ b/src/handlers/runtime/invoke/index.tsx @@ -2,7 +2,7 @@ import z from "zod"; import { InputValidationError, InvalidEnvironmentError, - RuntimeInvokeInterruptedError, + CommandInterruptedError, } from "../../../errors"; import { createHandler, flag, PathKey } from "../../../router"; import type { AppIO } from "../../../io"; @@ -159,8 +159,8 @@ export const createInvokeRuntimeHandler = (core: Core, io: AppIO) => }); } catch (error) { if (controller.signal.aborted && (error as Error)?.name === "AbortError") { - if (error instanceof RuntimeInvokeInterruptedError) throw error; - throw new RuntimeInvokeInterruptedError(error); + if (error instanceof CommandInterruptedError) throw error; + throw new CommandInterruptedError(error); } throw error; } finally { diff --git a/src/handlers/runtime/invoke/response.ts b/src/handlers/runtime/invoke/response.ts index 190138c4f..2893d1d75 100644 --- a/src/handlers/runtime/invoke/response.ts +++ b/src/handlers/runtime/invoke/response.ts @@ -1,4 +1,4 @@ -import { RuntimeInvokeInterruptedError, RuntimeInvokeResponseError } from "../../../errors"; +import { CommandInterruptedError, RuntimeInvokeResponseError } from "../../../errors"; import { classifyStreamingResponse, writeStreamingResponse, @@ -24,7 +24,7 @@ export async function writeRuntimeInvokeFile( function failure(error: unknown): never { const interrupted = (error as Error)?.name === "AbortError"; - if (interrupted) throw new RuntimeInvokeInterruptedError(error, true); + if (interrupted) throw new CommandInterruptedError(error, true); throw new RuntimeInvokeResponseError(RESPONSE_STREAM_FAILED, error); } diff --git a/src/io/httpServer.test.ts b/src/io/httpServer.test.ts new file mode 100644 index 000000000..770afb5c2 --- /dev/null +++ b/src/io/httpServer.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { type HttpServerHandle, startHttpServer } from "./httpServer"; + +let handle: HttpServerHandle | undefined; + +afterEach(async () => { + await handle?.close(); + handle = undefined; +}); + +describe("startHttpServer", () => { + test("serves requests on an OS-assigned loopback port", async () => { + handle = await startHttpServer((request) => ({ + status: 200, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + method: request.method, + url: request.url, + body: request.body.toString(), + }), + })); + + expect(handle.port).toBeGreaterThan(0); + const response = await fetch(`http://127.0.0.1:${handle.port}/v1/traces`, { + method: "POST", + body: "ping", + }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ method: "POST", url: "/v1/traces", body: "ping" }); + }); + + test("handler errors become 500s without crashing the server", async () => { + handle = await startHttpServer(() => { + throw new Error("boom"); + }); + + const response = await fetch(`http://127.0.0.1:${handle.port}/`); + expect(response.status).toBe(500); + + const again = await fetch(`http://127.0.0.1:${handle.port}/`); + expect(again.status).toBe(500); + }); + + test("aborting the signal closes the server", async () => { + const controller = new AbortController(); + const server = await startHttpServer(() => ({ status: 200 }), { signal: controller.signal }); + + controller.abort(); + await Bun.sleep(20); + expect(fetch(`http://127.0.0.1:${server.port}/`)).rejects.toThrow(); + }); + + test("close is idempotent", async () => { + const server = await startHttpServer(() => ({ status: 200 })); + await server.close(); + await server.close(); + }); + + test("listen failure rejects instead of hanging", async () => { + handle = await startHttpServer(() => ({ status: 200 })); + expect(startHttpServer(() => ({ status: 200 }), { port: handle.port })).rejects.toThrow(); + }); +}); diff --git a/src/io/httpServer.ts b/src/io/httpServer.ts new file mode 100644 index 000000000..dfdf32333 --- /dev/null +++ b/src/io/httpServer.ts @@ -0,0 +1,117 @@ +// Uses node:http rather than Bun.serve because the npm bundle targets Node, +// where Bun APIs are absent (same constraint as exec.ts). +import { + type IncomingHttpHeaders, + type IncomingMessage, + type Server, + type ServerResponse, + createServer, +} from "node:http"; + +/** Cap request bodies so a runaway local client cannot exhaust memory. */ +const MAX_BODY_BYTES = 50 * 1024 * 1024; + +export interface HttpRequest { + method: string; + url: string; + headers: IncomingHttpHeaders; + body: Buffer; +} + +export interface HttpResponse { + status: number; + headers?: Record; + body?: string | Buffer; +} + +export type HttpRequestHandler = (request: HttpRequest) => HttpResponse | Promise; + +export interface HttpServerHandle { + /** The port the server is listening on. */ + port: number; + /** Stops accepting connections and closes active ones. Idempotent. */ + close(): Promise; +} + +/** + * Starts a loopback-only HTTP server for local dev tooling. Binds 127.0.0.1 on + * the given port (0 lets the OS assign one). Handler errors become plain 500s; + * oversized bodies become 413s. Aborting the signal closes the server. + */ +export async function startHttpServer( + handler: HttpRequestHandler, + options: { port?: number; signal?: AbortSignal } = {}, +): Promise { + const server = createServer((request, response) => { + void respond(handler, request, response); + }); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(options.port ?? 0, "127.0.0.1", resolve); + }); + + const address = server.address(); + const port = typeof address === "object" && address !== null ? address.port : 0; + + const close = () => closeServer(server); + options.signal?.addEventListener("abort", () => void close(), { once: true }); + + return { port, close }; +} + +async function respond( + handler: HttpRequestHandler, + request: IncomingMessage, + response: ServerResponse, +): Promise { + let body: Buffer; + try { + body = await readBody(request); + } catch (error) { + const status = error instanceof BodyTooLargeError ? 413 : 400; + response.writeHead(status).end(); + return; + } + + try { + const result = await handler({ + method: request.method ?? "GET", + url: request.url ?? "/", + headers: request.headers, + body, + }); + response.writeHead(result.status, result.headers); + response.end(result.body); + } catch { + response.writeHead(500, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ error: "internal error" })); + } +} + +class BodyTooLargeError extends Error {} + +function readBody(request: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let size = 0; + request.on("data", (chunk: Buffer) => { + size += chunk.length; + if (size > MAX_BODY_BYTES) { + request.destroy(); + reject(new BodyTooLargeError()); + return; + } + chunks.push(chunk); + }); + request.on("end", () => resolve(Buffer.concat(chunks))); + request.on("error", reject); + }); +} + +function closeServer(server: Server): Promise { + return new Promise((resolve) => { + server.close(() => resolve()); + server.closeAllConnections(); + }); +} diff --git a/src/io/index.ts b/src/io/index.ts index e93169768..23eb4a03d 100644 --- a/src/io/index.ts +++ b/src/io/index.ts @@ -36,3 +36,11 @@ export { } from "./streamingResponse"; export type { AppIO, ReadWriteJson } from "./types"; export { warn } from "./warn"; +export { checkPort, type PortChecker } from "./port"; +export { + startHttpServer, + type HttpRequest, + type HttpRequestHandler, + type HttpResponse, + type HttpServerHandle, +} from "./httpServer"; diff --git a/src/io/port.test.ts b/src/io/port.test.ts new file mode 100644 index 000000000..f3ccf0d68 --- /dev/null +++ b/src/io/port.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from "bun:test"; +import { createServer, type Server } from "node:net"; +import { checkPort } from "./port"; + +function listen(): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => resolve(server)); + }); +} + +test("checkPort rejects an occupied loopback port and accepts it after release", async () => { + const server = await listen(); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("expected a TCP address"); + const signal = new AbortController().signal; + + expect(await checkPort(address.port, signal)).toBe(false); + await new Promise((resolve) => server.close(() => resolve())); + expect(await checkPort(address.port, signal)).toBe(true); +}); + +test("checkPort respects an aborted signal", async () => { + const controller = new AbortController(); + controller.abort(); + + await expect(checkPort(49152, controller.signal)).rejects.toHaveProperty("name", "AbortError"); +}); diff --git a/src/io/port.ts b/src/io/port.ts new file mode 100644 index 000000000..897de2da5 --- /dev/null +++ b/src/io/port.ts @@ -0,0 +1,27 @@ +import { createServer } from "node:net"; + +export type PortChecker = (port: number, signal: AbortSignal) => Promise; + +function canBind(port: number, host: string, signal: AbortSignal): Promise { + signal.throwIfAborted(); + + return new Promise((resolve, reject) => { + const server = createServer().unref(); + server.once("error", () => resolve(false)); + server.once("close", () => { + if (signal.aborted) reject(signal.reason); + }); + server.listen({ port, host, exclusive: true, signal }, () => { + server.close(() => resolve(true)); + }); + }); +} + +/** Checks that a port can be bound by both loopback-only and all-interface servers. */ +export const checkPort: PortChecker = async (port, signal) => { + if (!(await canBind(port, "127.0.0.1", signal))) return false; + signal.throwIfAborted(); + const available = await canBind(port, "0.0.0.0", signal); + signal.throwIfAborted(); + return available; +}; diff --git a/src/middleware/withJsonRenderer.tsx b/src/middleware/withJsonRenderer.tsx index 8be45ef4c..6e6ef2463 100644 --- a/src/middleware/withJsonRenderer.tsx +++ b/src/middleware/withJsonRenderer.tsx @@ -11,6 +11,7 @@ import type { AppIO } from "../io"; export function withJsonRenderer(io: AppIO): Middleware { const renderer = { renderJson: (data: unknown) => renderJson(data, (line) => io.stdout.write(line + "\n")), + renderJsonLine: (data: unknown) => io.stdout.write(JSON.stringify(data) + "\n"), }; return (h) => ({ diff --git a/src/router/flags.tsx b/src/router/flags.tsx index 2b690d094..5b26a8978 100644 --- a/src/router/flags.tsx +++ b/src/router/flags.tsx @@ -5,15 +5,17 @@ import type { Flag, GlobalFlag } from "./handler"; import { coerce, formatZodError, inspect } from "./schema"; // toOption builds a Commander Option from a flag's schema. Booleans become value-less -// toggles; everything else takes a value (`` / variadic ``). A -// required, non-boolean flag is made mandatory; defaults are forwarded. +// toggles — declared as `--no-` when they default to true, so the flag turns +// the behavior off (Commander's negation stores the value under the positive name). +// Everything else takes a value (`` / variadic ``). A required, +// non-boolean flag is made mandatory; defaults are forwarded. export function toOption(flag: Flag): Option { const info = inspect(flag.schema); const long = `--${flag.name}`; let token: string; if (info.boolean) { - token = long; + token = info.hasDefault && info.defaultValue === true ? `--no-${flag.name}` : long; } else if (info.variadic) { token = `${long} <${flag.name}...>`; } else { diff --git a/src/router/router.test.ts b/src/router/router.test.ts index 5b17f96d9..dba80053b 100644 --- a/src/router/router.test.ts +++ b/src/router/router.test.ts @@ -284,6 +284,27 @@ test("boolean flags default to false when omitted", async () => { expect(seen).toEqual({ verbose: false }); }); +test("a boolean flag defaulting to true is declared as its --no- negation", async () => { + const seen: { traces: boolean }[] = []; + + const run = createHandler({ + name: "run", + description: "", + flags: [flag("traces", "collect traces", z.boolean().default(true))], + handle: async (_ctx, flags) => { + seen.push(flags); + }, + }); + + const root = new Router("app"); + root.handler(run); + + await root.route(["node", "app", "run"]); + await root.route(["node", "app", "run", "--no-traces"]); + + expect(seen).toEqual([{ traces: true }, { traces: false }]); +}); + test("applies a schema default for an omitted flag", async () => { let seen: { count: number } | undefined; diff --git a/src/runnable/index.test.ts b/src/runnable/index.test.ts index acf43b090..a695ca09e 100644 --- a/src/runnable/index.test.ts +++ b/src/runnable/index.test.ts @@ -1,7 +1,7 @@ import { expect, spyOn, test } from "bun:test"; import { CommanderError } from "commander"; -import { AgentCoreCLIError, InputValidationError } from "../errors"; +import { AgentCoreCLIError, CommandInterruptedError, InputValidationError } from "../errors"; import { ExitCode, runRunnable, runWithExitCode, type Runnable } from "./index.tsx"; async function captureErrors(run: () => Promise) { @@ -89,6 +89,12 @@ test.each([ ExitCode.INTERRUPTED, ["AbortError: The operation was aborted"], ], + [ + "reported command interruption", + new CommandInterruptedError(undefined, true), + ExitCode.INTERRUPTED, + [], + ], [ "Commander parse failure", new CommanderError(1, "commander.invalidArgument", "invalid option"), diff --git a/src/testing/renderScreen.tsx b/src/testing/renderScreen.tsx index f638bb6cb..1cc7ae47a 100644 --- a/src/testing/renderScreen.tsx +++ b/src/testing/renderScreen.tsx @@ -43,7 +43,7 @@ function baseContext(core: TestCoreClient, endpointUrl?: string): Context { .withValue(EndpointKey, endpointUrl) .withValue(JsonKey, false) .withValue(DebugKey, false) - .withValue(JsonRendererKey, { renderJson: () => {} }); + .withValue(JsonRendererKey, { renderJson: () => {}, renderJsonLine: () => {} }); } // testQueryClient returns a QueryClient with retries and caching disabled so diff --git a/src/tui/index.tsx b/src/tui/index.tsx index 8555a53f0..2ae966f6b 100644 --- a/src/tui/index.tsx +++ b/src/tui/index.tsx @@ -29,6 +29,7 @@ export function renderJson(data: unknown, writer: (line: string) => void = conso // of any direct dependency on a global output stream. export interface JsonRenderer { renderJson(data: unknown): void; + renderJsonLine(data: unknown): void; } // JsonRendererKey exposes the prewired JsonRenderer on the context. Installed by