From d3c1fe7de0cd08fd95f363528b4b7b3bbc6e8f82 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Wed, 12 Aug 2026 10:40:25 -0400 Subject: [PATCH 01/16] feat(dev): collect local OTEL traces in project dev Adds an in-process OTLP/HTTP collector (protobuf + JSON ingest, JSONL persistence per trace) started by the dev handler unless --no-traces or instrumentation.enableOtel is false. Spawned agents receive OTEL env pointing at the collector; container runtimes get a host.docker.internal endpoint, and Python CodeZip agents get sitecustomize-based auto-instrumentation so uvicorn --reload workers stay traced. --- bun.lock | 57 +++- package.json | 1 + .../templates/shared/env.local.template | 6 +- src/core/dev/codezip.test.ts | 79 ++++- src/core/dev/codezip.ts | 53 +++- src/core/dev/otel/collector.test.ts | 167 +++++++++++ src/core/dev/otel/collector.ts | 144 +++++++++ src/core/dev/otel/store.test.ts | 120 ++++++++ src/core/dev/otel/store.ts | 122 ++++++++ src/core/dev/otel/transforms.test.ts | 217 ++++++++++++++ src/core/dev/otel/transforms.ts | 276 ++++++++++++++++++ src/core/dev/otel/types.ts | 65 +++++ src/handlers/project/dev/index.test.ts | 90 +++++- src/handlers/project/dev/index.ts | 38 ++- src/handlers/project/dev/types.ts | 13 + src/handlers/project/index.ts | 2 + src/io/httpServer.test.ts | 63 ++++ src/io/httpServer.ts | 119 ++++++++ src/io/index.ts | 8 + src/router/flags.tsx | 8 +- src/router/router.test.ts | 21 ++ 21 files changed, 1649 insertions(+), 20 deletions(-) create mode 100644 src/core/dev/otel/collector.test.ts create mode 100644 src/core/dev/otel/collector.ts create mode 100644 src/core/dev/otel/store.test.ts create mode 100644 src/core/dev/otel/store.ts create mode 100644 src/core/dev/otel/transforms.test.ts create mode 100644 src/core/dev/otel/transforms.ts create mode 100644 src/core/dev/otel/types.ts create mode 100644 src/io/httpServer.test.ts create mode 100644 src/io/httpServer.ts 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 8786ec2a3..c1223ecbc 100644 --- a/package.json +++ b/package.json @@ -58,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 cc9e703e7..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 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. This file is gitignored — keep secrets out of version control. +# 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 24b83f9f3..0639dbb1b 100644 --- a/src/core/dev/codezip.test.ts +++ b/src/core/dev/codezip.test.ts @@ -5,7 +5,7 @@ import { join, relative } from "node:path"; import { InputValidationError } from "../../errors"; 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 = { @@ -54,15 +54,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 }), }; } @@ -193,3 +200,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}:/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 777656023..49a33169b 100644 --- a/src/core/dev/codezip.ts +++ b/src/core/dev/codezip.ts @@ -1,19 +1,28 @@ import { existsSync } from "node:fs"; -import { join, resolve } from "node:path"; +import { delimiter, join, resolve } 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"; import { isDirectory, isFile, resolvePathWithinProject } from "./path"; 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 { @@ -41,8 +50,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 opentelemetry-distro 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/otel/collector.test.ts b/src/core/dev/otel/collector.test.ts new file mode 100644 index 000000000..fb0f49b6d --- /dev/null +++ b/src/core/dev/otel/collector.test.ts @@ -0,0 +1,167 @@ +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", () => { + expect(collector.envVars.OTEL_EXPORTER_OTLP_ENDPOINT).toBe( + `http://127.0.0.1:${collector.port}`, + ); + expect(collector.envVars.OTEL_EXPORTER_OTLP_PROTOCOL).toBe("http/protobuf"); + expect(collector.envVars.OTEL_METRICS_EXPORTER).toBe("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..0e1a2d422 --- /dev/null +++ b/src/core/dev/otel/collector.ts @@ -0,0 +1,144 @@ +// 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, + type HttpServerStarter, + 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; + startServer?: HttpServerStarter; +} + +/** + * 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 startServer = options.startServer ?? startHttpServer; + const server = await startServer((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`. */ +export function otelEnvVars(port: number): Record { + return { + OTEL_EXPORTER_OTLP_ENDPOINT: `http://127.0.0.1:${port}`, + OTEL_EXPORTER_OTLP_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 a loopback OTLP endpoint 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 endpoint = env.OTEL_EXPORTER_OTLP_ENDPOINT; + if (!endpoint) return env; + return { + ...env, + OTEL_EXPORTER_OTLP_ENDPOINT: endpoint.replace(/127\.0\.0\.1|localhost/, "host.docker.internal"), + }; +} + +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..59c5bd694 --- /dev/null +++ b/src/core/dev/otel/store.test.ts @@ -0,0 +1,120 @@ +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("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, `agent-1-${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..2df3ba643 --- /dev/null +++ b/src/core/dev/otel/store.ts @@ -0,0 +1,122 @@ +import { appendFile, mkdir, readFile, readdir } from "node:fs/promises"; +import { join } from "node:path"; +import { buildTraceDetail, extractFirstTraceInfo, extractTraceMeta } 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 under the store + * directory, each line a raw 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) {} + + /** Append one OTLP export payload to its trace's file. Payloads without a trace id are dropped. */ + public async append(payload: OtlpPayload): Promise { + const { traceId, serviceName } = extractFirstTraceInfo(payload); + if (!traceId) return; + + await mkdir(this.directory, { recursive: true }); + const fileName = `${sanitize(serviceName ?? "dev")}-${sanitize(traceId)}${OTLP_EXT}`; + await appendFile(join(this.directory, fileName), JSON.stringify(payload) + "\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 match = (await this.traceFiles()).find((file) => file.includes(sanitize(traceId))); + if (!match) return undefined; + + const trace = await this.readTraceFile(match); + 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..7b68ce4c8 --- /dev/null +++ b/src/core/dev/otel/transforms.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, test } from "bun:test"; +import { + buildTraceDetail, + extractAnyValue, + extractFirstTraceInfo, + extractTraceMeta, + flattenAttributes, + hexFromB64OrString, + nanoToMs, +} 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("extractFirstTraceInfo", () => { + test("finds the first span's trace id and service name", () => { + const info = extractFirstTraceInfo({ + resourceSpans: [resourceSpan({ serviceName: "svc", spans: [agentSpan] })], + }); + expect(info).toEqual({ traceId: TRACE_ID_HEX, serviceName: "svc" }); + }); + + test("falls back to log records and returns empty when nothing matches", () => { + expect(extractFirstTraceInfo({})).toEqual({}); + const info = extractFirstTraceInfo({ + resourceLogs: [{ scopeLogs: [{ logRecords: [{ traceId: TRACE_ID_HEX }] }] }], + }); + expect(info.traceId).toBe(TRACE_ID_HEX); + }); +}); + +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..b2c814b40 --- /dev/null +++ b/src/core/dev/otel/transforms.ts @@ -0,0 +1,276 @@ +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; +} + +/** Extract the traceId and serviceName of the first span or log record in a payload. */ +export function extractFirstTraceInfo(payload: OtlpPayload): { + traceId?: string; + serviceName?: string; +} { + for (const resourceSpan of payload.resourceSpans ?? []) { + const serviceName = getResourceAttribute(resourceSpan.resource, "service.name"); + for (const scopeSpan of resourceSpan.scopeSpans ?? []) { + for (const span of scopeSpan.spans ?? []) { + if (span.traceId) return { traceId: hexFromB64OrString(span.traceId), serviceName }; + } + } + } + for (const resourceLog of payload.resourceLogs ?? []) { + const serviceName = getResourceAttribute(resourceLog.resource, "service.name"); + for (const scopeLog of resourceLog.scopeLogs ?? []) { + for (const record of scopeLog.logRecords ?? []) { + if (record.traceId) return { traceId: hexFromB64OrString(record.traceId), serviceName }; + } + } + } + return {}; +} + +/** + * 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/handlers/project/dev/index.test.ts b/src/handlers/project/dev/index.test.ts index 5540bfde1..53b8d2238 100644 --- a/src/handlers/project/dev/index.test.ts +++ b/src/handlers/project/dev/index.test.ts @@ -9,7 +9,7 @@ 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 } from "./types"; +import type { DevEvent, DevRunner, DevServerInput, DevTraceCollector } from "./types"; function runtime(name = "orders", build: ProjectRuntime["build"] = "CodeZip"): ProjectRuntime { return { @@ -40,6 +40,26 @@ function captureRunner(events: DevEvent[] = []) { 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_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; @@ -53,6 +73,7 @@ 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, @@ -64,6 +85,7 @@ function harness(options: HarnessOptions = {}) { return { env: { FROM_LOADER: "yes" } }; }), checkPort: options.checkPort ?? (async () => true), + startTraceCollector: collector.start, }); const ctx = ValueContext.EmptyContext() .withValue(ProjectKey, options.project ?? project(runtime())) @@ -77,9 +99,11 @@ function harness(options: HarnessOptions = {}) { return { codeZip, container, + collector, environmentInputs, io, - run: (flags: { agent?: string; port?: number } = {}) => handler.handle(ctx, flags, {}), + run: (flags: { agent?: string; port?: number; traces?: boolean } = {}) => + handler.handle(ctx, { traces: true, ...flags }, {}), }; } @@ -124,7 +148,11 @@ describe("project dev selection and dispatch", () => { expect(subject.container.inputs[0]).toMatchObject({ projectRoot: "/workspace/project", port: 4567, - env: { FROM_LOADER: "yes" }, + env: { + FROM_LOADER: "yes", + OTEL_EXPORTER_OTLP_ENDPOINT: "http://host.docker.internal:43180", + OTEL_SERVICE_NAME: "support", + }, runtime: { name: "support", build: "Container" }, }); }); @@ -141,7 +169,56 @@ describe("project dev selection and dispatch", () => { expect(checked).toEqual([8080, 8081]); expect(subject.codeZip.inputs[0]?.port).toBe(8081); - expect(subject.io.stderr()).toBe("Port 8080 is in use; using 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); }); }); @@ -154,7 +231,7 @@ test("project dev renders human and NDJSON output", async () => { for (const json of [false, true]) { const subject = harness({ codeZip: captureRunner(events), json }); - await subject.run(); + await subject.run({ traces: false }); expect(subject.io.stdout()).toBe( json ? events.map((event) => JSON.stringify(event)).join("\n") : "agent output", ); @@ -197,7 +274,8 @@ describe("project dev interruption", () => { reported: true, exitCode: 130, }); - expect(subject.io.stderr()).toBe("Shutting down…"); + expect(subject.io.stderr()).toContain("Shutting down…"); + expect(subject.collector.state.closed).toBe(1); expect(process.listenerCount(signal)).toBe(before); }, ); diff --git a/src/handlers/project/dev/index.ts b/src/handlers/project/dev/index.ts index 9787a0422..59a153e94 100644 --- a/src/handlers/project/dev/index.ts +++ b/src/handlers/project/dev/index.ts @@ -1,4 +1,6 @@ +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 { @@ -12,15 +14,25 @@ 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 } from "./types"; +import type { DevEvent, DevRunner, DevTraceCollector, DevTraceCollectorStarter } from "./types"; 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.spec.runtimes.length === 0) { throw new InputValidationError( @@ -64,6 +76,7 @@ export const createDevProjectHandler = (config: DevProjectHandlerConfig) => "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(); @@ -76,6 +89,7 @@ export const createDevProjectHandler = (config: DevProjectHandlerConfig) => 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); @@ -103,12 +117,31 @@ export const createDevProjectHandler = (config: DevProjectHandlerConfig) => }); 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: { ...env, ...otelEnv }, signal: controller.signal, })) { renderEvent(config.io, event, json); @@ -118,6 +151,7 @@ export const createDevProjectHandler = (config: DevProjectHandlerConfig) => 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 474ac5511..f5ad74a26 100644 --- a/src/handlers/project/index.ts +++ b/src/handlers/project/index.ts @@ -2,6 +2,7 @@ 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 { createCreateProjectHandler } from "./create"; import { createRemoveProjectHandler } from "./remove"; @@ -36,6 +37,7 @@ export function createProjectHandler(config: ProjectHandlerConfig): Router { }, loadDevEnvironment, checkPort, + startTraceCollector: startOtelCollector, }), ), ); 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..d95ee831a --- /dev/null +++ b/src/io/httpServer.ts @@ -0,0 +1,119 @@ +// 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; +} + +export type HttpServerStarter = typeof startHttpServer; + +/** + * 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 fb0da0b0e..2fddca22d 100644 --- a/src/io/index.ts +++ b/src/io/index.ts @@ -37,3 +37,11 @@ export { 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, + type HttpServerStarter, +} from "./httpServer"; 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; From 9494d7e8cf4db2c68d13d6999e6e0a739e918704 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Wed, 12 Aug 2026 10:42:46 -0400 Subject: [PATCH 02/16] refactor(dev): drop unused collector server injection seam --- src/core/dev/otel/collector.ts | 13 ++++--------- src/io/httpServer.ts | 2 -- src/io/index.ts | 1 - 3 files changed, 4 insertions(+), 12 deletions(-) diff --git a/src/core/dev/otel/collector.ts b/src/core/dev/otel/collector.ts index 0e1a2d422..e3b0191f2 100644 --- a/src/core/dev/otel/collector.ts +++ b/src/core/dev/otel/collector.ts @@ -2,12 +2,7 @@ // 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, - type HttpServerStarter, - startHttpServer, -} from "../../../io"; +import { type HttpRequest, type HttpResponse, startHttpServer } from "../../../io"; import { TraceStore } from "./store"; import type { OtlpPayload } from "./types"; @@ -53,7 +48,6 @@ export interface StartOtelCollectorOptions { tracesDirectory: string; /** Closes the collector when aborted. */ signal?: AbortSignal; - startServer?: HttpServerStarter; } /** @@ -65,8 +59,9 @@ export async function startOtelCollector( options: StartOtelCollectorOptions, ): Promise { const store = new TraceStore(options.tracesDirectory); - const startServer = options.startServer ?? startHttpServer; - const server = await startServer((request) => route(request, store), { signal: options.signal }); + const server = await startHttpServer((request) => route(request, store), { + signal: options.signal, + }); return { port: server.port, store, envVars: otelEnvVars(server.port), close: server.close }; } diff --git a/src/io/httpServer.ts b/src/io/httpServer.ts index d95ee831a..dfdf32333 100644 --- a/src/io/httpServer.ts +++ b/src/io/httpServer.ts @@ -33,8 +33,6 @@ export interface HttpServerHandle { close(): Promise; } -export type HttpServerStarter = typeof startHttpServer; - /** * 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; diff --git a/src/io/index.ts b/src/io/index.ts index 2fddca22d..23eb4a03d 100644 --- a/src/io/index.ts +++ b/src/io/index.ts @@ -43,5 +43,4 @@ export { type HttpRequestHandler, type HttpResponse, type HttpServerHandle, - type HttpServerStarter, } from "./httpServer"; From 2e142b44d5a30b27f86d61f7de8d68ca272262b7 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Wed, 12 Aug 2026 10:46:40 -0400 Subject: [PATCH 03/16] fix(dev): name aws-opentelemetry-distro in the missing-instrumentation hint --- src/core/dev/codezip.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/dev/codezip.ts b/src/core/dev/codezip.ts index 49a33169b..1c84c9317 100644 --- a/src/core/dev/codezip.ts +++ b/src/core/dev/codezip.ts @@ -62,7 +62,7 @@ export class CodeZipDevRunner implements DevRunner { yield { type: "status", message: - "OTEL auto-instrumentation is not installed in the agent environment; traces will not be collected. Add opentelemetry-distro to enable them.", + "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.", }; } } From 881af193dae6870356228de19eb32ed1601c4a18 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Fri, 14 Aug 2026 16:58:23 -0400 Subject: [PATCH 04/16] fix(dev): address trace-identity, env-precedence, and Windows review findings - Partition each OTLP export batch by trace id before persistence: a batch routinely carries spans from several traces, and writing it whole to the first trace's file corrupted trace identity (get() missed every other trace in the batch). Files are now keyed .otlp.jsonl, making get() a direct path lookup. - Set signal-specific OTEL_EXPORTER_OTLP_TRACES_ENDPOINT/_LOGS_ENDPOINT (and protocols) alongside the generic variables: signal-specific values take SDK precedence, so a stray value from the shell or .env.local could silently redirect traces away from the local collector. The container rewrite now covers every OTLP endpoint variable. - Build the PYTHONPATH test expectation with node:path.delimiter so it passes on Windows. --- src/core/dev/codezip.test.ts | 4 +- src/core/dev/otel/collector.test.ts | 17 +++++--- src/core/dev/otel/collector.ts | 31 ++++++++++---- src/core/dev/otel/store.test.ts | 17 +++++++- src/core/dev/otel/store.ts | 35 +++++++++------ src/core/dev/otel/transforms.test.ts | 48 ++++++++++++++++----- src/core/dev/otel/transforms.ts | 59 ++++++++++++++++++++------ src/handlers/project/dev/index.test.ts | 2 + 8 files changed, 158 insertions(+), 55 deletions(-) diff --git a/src/core/dev/codezip.test.ts b/src/core/dev/codezip.test.ts index 0639dbb1b..da366d59d 100644 --- a/src/core/dev/codezip.test.ts +++ b/src/core/dev/codezip.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join, relative } from "node:path"; +import { delimiter, join, relative } from "node:path"; import { InputValidationError } from "../../errors"; import type { ProjectRuntime } from "../../projectSchemas/runtime"; import type { DevEvent, DevServerInput } from "../../handlers/project/dev/types"; @@ -235,7 +235,7 @@ describe("CodeZipDevRunner OTEL instrumentation", () => { await collect(runner.run(otelInput(root, { PYTHONPATH: "/existing" }))); - expect(calls[0]?.options.env?.PYTHONPATH).toBe(`${directory}:/existing`); + expect(calls[0]?.options.env?.PYTHONPATH).toBe(`${directory}${delimiter}/existing`); }); test("does not probe without an OTEL endpoint or for Node entrypoints", async () => { diff --git a/src/core/dev/otel/collector.test.ts b/src/core/dev/otel/collector.test.ts index fb0f49b6d..eb4fc16e7 100644 --- a/src/core/dev/otel/collector.test.ts +++ b/src/core/dev/otel/collector.test.ts @@ -146,12 +146,17 @@ describe("startOtelCollector", () => { ).toBe(404); }); - test("envVars point the SDK at the collector", () => { - expect(collector.envVars.OTEL_EXPORTER_OTLP_ENDPOINT).toBe( - `http://127.0.0.1:${collector.port}`, - ); - expect(collector.envVars.OTEL_EXPORTER_OTLP_PROTOCOL).toBe("http/protobuf"); - expect(collector.envVars.OTEL_METRICS_EXPORTER).toBe("none"); + 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 () => { diff --git a/src/core/dev/otel/collector.ts b/src/core/dev/otel/collector.ts index e3b0191f2..c19a3375a 100644 --- a/src/core/dev/otel/collector.ts +++ b/src/core/dev/otel/collector.ts @@ -105,11 +105,23 @@ function decodePayload(body: Buffer, contentType: string, decoder: OtlpDecoder): 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`. */ +/** + * 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: `http://127.0.0.1:${port}`, + 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", @@ -118,7 +130,7 @@ export function otelEnvVars(port: number): Record { } /** - * Rewrite a loopback OTLP endpoint so a containerized agent can reach the + * 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). @@ -126,12 +138,13 @@ export function otelEnvVars(port: number): Record { export function rewriteOtelEndpointForContainer( env: Record, ): Record { - const endpoint = env.OTEL_EXPORTER_OTLP_ENDPOINT; - if (!endpoint) return env; - return { - ...env, - OTEL_EXPORTER_OTLP_ENDPOINT: endpoint.replace(/127\.0\.0\.1|localhost/, "host.docker.internal"), - }; + 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 { diff --git a/src/core/dev/otel/store.test.ts b/src/core/dev/otel/store.test.ts index 59c5bd694..f4bb450c7 100644 --- a/src/core/dev/otel/store.test.ts +++ b/src/core/dev/otel/store.test.ts @@ -75,6 +75,21 @@ describe("TraceStore", () => { 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" })); @@ -102,7 +117,7 @@ describe("TraceStore", () => { test("skips malformed lines and files without failing", async () => { await store.append(payload(TRACE_A)); - await writeFile(join(directory, `agent-1-${TRACE_A}.otlp.jsonl`), "{not json}\n", { + await writeFile(join(directory, `${TRACE_A}.otlp.jsonl`), "{not json}\n", { flag: "a", }); await writeFile(join(directory, "garbage.otlp.jsonl"), "also not json\n"); diff --git a/src/core/dev/otel/store.ts b/src/core/dev/otel/store.ts index 2df3ba643..57e7e4593 100644 --- a/src/core/dev/otel/store.ts +++ b/src/core/dev/otel/store.ts @@ -1,6 +1,6 @@ import { appendFile, mkdir, readFile, readdir } from "node:fs/promises"; import { join } from "node:path"; -import { buildTraceDetail, extractFirstTraceInfo, extractTraceMeta } from "./transforms"; +import { buildTraceDetail, extractTraceMeta, partitionByTraceId } from "./transforms"; import type { OtlpPayload, OtlpResourceLog, OtlpResourceSpan } from "./types"; const OTLP_EXT = ".otlp.jsonl"; @@ -27,22 +27,32 @@ export interface ListTracesOptions { } /** - * Append-only local trace storage: one JSON Lines file per trace under the store - * directory, each line a raw 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. + * 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) {} - /** Append one OTLP export payload to its trace's file. Payloads without a trace id are dropped. */ + /** + * 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 { traceId, serviceName } = extractFirstTraceInfo(payload); - if (!traceId) return; + const partitions = partitionByTraceId(payload); + if (partitions.size === 0) return; await mkdir(this.directory, { recursive: true }); - const fileName = `${sanitize(serviceName ?? "dev")}-${sanitize(traceId)}${OTLP_EXT}`; - await appendFile(join(this.directory, fileName), JSON.stringify(payload) + "\n"); + 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). */ @@ -75,10 +85,7 @@ export class TraceStore { /** All spans and logs for one trace, or undefined when the trace is unknown. */ public async get(traceId: string): Promise { - const match = (await this.traceFiles()).find((file) => file.includes(sanitize(traceId))); - if (!match) return undefined; - - const trace = await this.readTraceFile(match); + const trace = await this.readTraceFile(`${sanitize(traceId)}${OTLP_EXT}`); if (!trace) return undefined; return buildTraceDetail(trace.resourceSpans, trace.resourceLogs); } diff --git a/src/core/dev/otel/transforms.test.ts b/src/core/dev/otel/transforms.test.ts index 7b68ce4c8..0b3bc2eef 100644 --- a/src/core/dev/otel/transforms.test.ts +++ b/src/core/dev/otel/transforms.test.ts @@ -2,11 +2,11 @@ import { describe, expect, test } from "bun:test"; import { buildTraceDetail, extractAnyValue, - extractFirstTraceInfo, extractTraceMeta, flattenAttributes, hexFromB64OrString, nanoToMs, + partitionByTraceId, } from "./transforms"; import type { OtlpResourceLog, OtlpResourceSpan } from "./types"; @@ -81,20 +81,46 @@ describe("extractTraceMeta", () => { }); }); -describe("extractFirstTraceInfo", () => { - test("finds the first span's trace id and service name", () => { - const info = extractFirstTraceInfo({ - resourceSpans: [resourceSpan({ serviceName: "svc", spans: [agentSpan] })], +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(info).toEqual({ traceId: TRACE_ID_HEX, serviceName: "svc" }); + + expect([...partitions.keys()].sort()).toEqual([TRACE_ID_HEX, OTHER_TRACE_HEX]); }); - test("falls back to log records and returns empty when nothing matches", () => { - expect(extractFirstTraceInfo({})).toEqual({}); - const info = extractFirstTraceInfo({ - resourceLogs: [{ scopeLogs: [{ logRecords: [{ traceId: TRACE_ID_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(info.traceId).toBe(TRACE_ID_HEX); + expect(partitions.size).toBe(0); }); }); diff --git a/src/core/dev/otel/transforms.ts b/src/core/dev/otel/transforms.ts index b2c814b40..1c161d5e0 100644 --- a/src/core/dev/otel/transforms.ts +++ b/src/core/dev/otel/transforms.ts @@ -58,28 +58,63 @@ export function extractTraceMeta( return meta; } -/** Extract the traceId and serviceName of the first span or log record in a payload. */ -export function extractFirstTraceInfo(payload: OtlpPayload): { - traceId?: string; - serviceName?: string; -} { +/** + * 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 ?? []) { - const serviceName = getResourceAttribute(resourceSpan.resource, "service.name"); for (const scopeSpan of resourceSpan.scopeSpans ?? []) { - for (const span of scopeSpan.spans ?? []) { - if (span.traceId) return { traceId: hexFromB64OrString(span.traceId), serviceName }; + 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 ?? []) { - const serviceName = getResourceAttribute(resourceLog.resource, "service.name"); for (const scopeLog of resourceLog.scopeLogs ?? []) { - for (const record of scopeLog.logRecords ?? []) { - if (record.traceId) return { traceId: hexFromB64OrString(record.traceId), serviceName }; + 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 {}; + + 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; } /** diff --git a/src/handlers/project/dev/index.test.ts b/src/handlers/project/dev/index.test.ts index 53b8d2238..90b6d78d5 100644 --- a/src/handlers/project/dev/index.test.ts +++ b/src/handlers/project/dev/index.test.ts @@ -47,6 +47,7 @@ function fakeCollector() { 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 () => { @@ -151,6 +152,7 @@ describe("project dev selection and dispatch", () => { 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" }, From aabb87f729a68665fec998fb5424ec4db102bfb8 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Mon, 17 Aug 2026 13:39:54 -0400 Subject: [PATCH 05/16] fix(ci): dedupe @opentelemetry/core past audit advisory; join paths in test - bun audit failed on GHSA-8988-4f7v-96qf: otlp-transformer 0.213.0 pins @opentelemetry/core 2.6.0 (<2.8.0). Override to ^2.10.0, matching the version the telemetry sink packages already resolve. - Build the expected tracesDirectory with path.join so the assertion holds on Windows separators. --- bun.lock | 9 +++------ package.json | 3 +++ src/handlers/project/dev/index.test.ts | 3 ++- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/bun.lock b/bun.lock index f1a58f213..2d059282b 100644 --- a/bun.lock +++ b/bun.lock @@ -44,6 +44,9 @@ }, }, }, + "overrides": { + "@opentelemetry/core": "^2.10.0", + }, "packages": { "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.3.0", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA=="], @@ -435,18 +438,12 @@ "@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=="], diff --git a/package.json b/package.json index c1223ecbc..42fdb3f57 100644 --- a/package.json +++ b/package.json @@ -76,5 +76,8 @@ "winston": "^3.19.0", "winston-daily-rotate-file": "^5.0.0", "zod": "^4.4.3" + }, + "overrides": { + "@opentelemetry/core": "^2.10.0" } } diff --git a/src/handlers/project/dev/index.test.ts b/src/handlers/project/dev/index.test.ts index 90b6d78d5..560efc46f 100644 --- a/src/handlers/project/dev/index.test.ts +++ b/src/handlers/project/dev/index.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { join } from "node:path"; import type { ProjectRuntime } from "../../../projectSchemas/runtime"; import { InputValidationError, ResourceNotFoundError } from "../../../errors"; import type { PortChecker } from "../../../io"; @@ -182,7 +183,7 @@ describe("project dev trace collection", () => { expect(subject.collector.starts).toEqual([ { - tracesDirectory: "/workspace/project/agentcore/.cli/traces/otlp", + tracesDirectory: join("/workspace/project", "agentcore", ".cli", "traces", "otlp"), signal: expect.any(AbortSignal), }, ]); From b4c86d491e157b15f750468693bdb16dfcb8af26 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Mon, 17 Aug 2026 14:29:14 -0400 Subject: [PATCH 06/16] refactor(dev): split inspector presentation from raw OTLP storage; stream HTTP bodies TraceStore becomes raw per-trace persistence; frontend shaping (meta, detail, noise filtering) moves to core/dev/inspector with an InspectorTraceSource query wrapper. io/httpServer responses can now stream AsyncIterable bodies for SSE. --- src/core/dev/inspector/traces.test.ts | 60 ++++++ src/core/dev/inspector/traces.ts | 65 ++++++ src/core/dev/inspector/transforms.test.ts | 186 +++++++++++++++++ src/core/dev/inspector/transforms.ts | 236 ++++++++++++++++++++++ src/core/dev/otel/collector.test.ts | 13 +- src/core/dev/otel/store.test.ts | 75 +++---- src/core/dev/otel/store.ts | 73 ++----- src/core/dev/otel/transforms.test.ts | 199 +----------------- src/core/dev/otel/transforms.ts | 232 +-------------------- src/io/httpServer.test.ts | 27 +++ src/io/httpServer.ts | 32 ++- 11 files changed, 664 insertions(+), 534 deletions(-) create mode 100644 src/core/dev/inspector/traces.test.ts create mode 100644 src/core/dev/inspector/traces.ts create mode 100644 src/core/dev/inspector/transforms.test.ts create mode 100644 src/core/dev/inspector/transforms.ts diff --git a/src/core/dev/inspector/traces.test.ts b/src/core/dev/inspector/traces.test.ts new file mode 100644 index 000000000..b408a8110 --- /dev/null +++ b/src/core/dev/inspector/traces.test.ts @@ -0,0 +1,60 @@ +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 { TraceStore } from "../otel/store"; +import { payload } from "../otel/store.test"; +import { InspectorTraceSource } from "./traces"; + +const TRACE_A = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const TRACE_B = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + +let directory: string; +let store: TraceStore; +let source: InspectorTraceSource; + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), "trace-source-")); + store = new TraceStore(directory); + source = new InspectorTraceSource(store); +}); + +afterEach(async () => { + await rm(directory, { recursive: true, force: true }); +}); + +describe("InspectorTraceSource", () => { + test("lists traces with metadata and frontend-shaped detail", async () => { + await store.append(payload(TRACE_A)); + const traces = await source.list(); + expect(traces).toHaveLength(1); + expect(traces[0]!.traceId).toBe(TRACE_A); + expect(traces[0]!.spanCount).toBe("1"); + expect(traces[0]!.resourceSpans).toBeDefined(); + }); + + test("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 source.list({ serviceName: "agent-2" }); + expect(traces.map((trace) => trace.traceId)).toEqual([TRACE_B]); + }); + + test("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 source.list()).map((trace) => trace.traceId)).toEqual([TRACE_B]); + expect((await source.list({ startTime: 0 })).map((trace) => trace.traceId)).toEqual([ + TRACE_B, + TRACE_A, + ]); + }); + + test("get returns detail or undefined for unknown ids", async () => { + await store.append(payload(TRACE_A)); + expect((await source.get(TRACE_A))?.resourceSpans).toBeDefined(); + expect(await source.get(TRACE_B)).toBeUndefined(); + }); +}); diff --git a/src/core/dev/inspector/traces.ts b/src/core/dev/inspector/traces.ts new file mode 100644 index 000000000..d42233ca2 --- /dev/null +++ b/src/core/dev/inspector/traces.ts @@ -0,0 +1,65 @@ +import type { TraceStore } from "../otel/store"; +import { buildTraceDetail, extractTraceMeta } from "./transforms"; + +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; +} + +/** + * Reads persisted traces and shapes them for the Inspector frontend. The list + * includes full trace detail per entry — the SPA renders from the list response, + * so this matches the wire contract of the original CLI. + */ +export class InspectorTraceSource { + constructor(private readonly store: TraceStore) {} + + /** 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 trace of await this.store.readAll()) { + 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.store.read(traceId); + if (!trace) return undefined; + return buildTraceDetail(trace.resourceSpans, trace.resourceLogs); + } +} diff --git a/src/core/dev/inspector/transforms.test.ts b/src/core/dev/inspector/transforms.test.ts new file mode 100644 index 000000000..12c7e8db3 --- /dev/null +++ b/src/core/dev/inspector/transforms.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, test } from "bun:test"; +import type { OtlpResourceLog, OtlpResourceSpan } from "../otel/types"; +import { + buildTraceDetail, + extractAnyValue, + extractTraceMeta, + flattenAttributes, +} from "./transforms"; + +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("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("value helpers", () => { + 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/inspector/transforms.ts b/src/core/dev/inspector/transforms.ts new file mode 100644 index 000000000..e9106bae3 --- /dev/null +++ b/src/core/dev/inspector/transforms.ts @@ -0,0 +1,236 @@ +/** + * Presentation transforms for the Agent Inspector: turn raw persisted OTLP data + * into the flattened, noise-filtered shapes the frontend renders. Ingest-side + * OTLP handling (partitioning, id normalization) lives in ../otel/transforms. + */ +import { hexFromB64OrString, nanoToMs } from "../otel/transforms"; +import type { + OtlpAttributes, + OtlpAttributeValue, + OtlpResource, + OtlpResourceLog, + OtlpResourceSpan, +} from "../otel/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; +} + +/** + * 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; +} + +/** 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/collector.test.ts b/src/core/dev/otel/collector.test.ts index eb4fc16e7..58b38f532 100644 --- a/src/core/dev/otel/collector.test.ts +++ b/src/core/dev/otel/collector.test.ts @@ -90,9 +90,8 @@ describe("startOtelCollector", () => { 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); + const trace = await collector.store.read(TRACE_ID_HEX); + expect(trace?.resourceSpans).toHaveLength(1); }); test("ingests protobuf log exports into the same trace", async () => { @@ -100,8 +99,8 @@ describe("startOtelCollector", () => { 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(); + const trace = await collector.store.read(TRACE_ID_HEX); + expect(trace?.resourceLogs).toHaveLength(1); }); test("ingests JSON trace exports", async () => { @@ -129,13 +128,13 @@ describe("startOtelCollector", () => { }); 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]); + expect((await collector.store.read(TRACE_ID_HEX))?.resourceSpans).toHaveLength(1); }); 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([]); + expect(await collector.store.readAll()).toEqual([]); }); test("health check responds ok and unknown routes 404", async () => { diff --git a/src/core/dev/otel/store.test.ts b/src/core/dev/otel/store.test.ts index f4bb450c7..9a64c3dc6 100644 --- a/src/core/dev/otel/store.test.ts +++ b/src/core/dev/otel/store.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { TraceStore } from "./store"; @@ -8,7 +8,7 @@ import type { OtlpPayload } from "./types"; const TRACE_A = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; const TRACE_B = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; -function payload( +export function payload( traceId: string, options: { serviceName?: string; startNano?: string; name?: string } = {}, ): OtlpPayload { @@ -53,26 +53,23 @@ afterEach(async () => { }); describe("TraceStore", () => { - test("append then list returns the trace with metadata", async () => { + test("append then read returns the raw trace", 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(); + const trace = await store.read(TRACE_A); + expect(trace?.resourceSpans).toHaveLength(1); + expect(trace?.resourceLogs).toEqual([]); }); - test("appends to the same trace accumulate spans", async () => { + test("appends to the same trace accumulate", 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"); + const trace = await store.read(TRACE_A); + expect(trace?.resourceSpans).toHaveLength(2); }); test("payloads without a trace id are dropped", async () => { await store.append({ resourceSpans: [] }); - expect(await store.list()).toEqual([]); + expect(await store.readAll()).toEqual([]); }); test("a batch carrying several traces lands in each trace's own file", async () => { @@ -84,52 +81,28 @@ describe("TraceStore", () => { }); 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(); + expect((await readdir(directory)).sort()).toEqual([ + `${TRACE_A}.otlp.jsonl`, + `${TRACE_B}.otlp.jsonl`, + ]); + expect((await store.read(TRACE_B))?.resourceSpans).toHaveLength(1); + expect(await store.readAll()).toHaveLength(2); }); - 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("read of an unknown trace returns undefined", async () => { + expect(await store.read(TRACE_A)).toBeUndefined(); }); - 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 () => { + test("skips malformed lines without failing", 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"); + await writeFile(join(directory, `${TRACE_A}.otlp.jsonl`), "{not json}\n", { flag: "a" }); - const traces = await store.list(); - expect(traces).toHaveLength(1); - expect(traces[0]!.spanCount).toBe("1"); + const trace = await store.read(TRACE_A); + expect(trace?.resourceSpans).toHaveLength(1); }); - test("list on a directory that does not exist returns empty", async () => { + test("readAll 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(); + expect(await empty.readAll()).toEqual([]); }); }); diff --git a/src/core/dev/otel/store.ts b/src/core/dev/otel/store.ts index 57e7e4593..04bf88caa 100644 --- a/src/core/dev/otel/store.ts +++ b/src/core/dev/otel/store.ts @@ -1,36 +1,21 @@ import { appendFile, mkdir, readFile, readdir } from "node:fs/promises"; import { join } from "node:path"; -import { buildTraceDetail, extractTraceMeta, partitionByTraceId } from "./transforms"; +import { 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; +/** All raw OTLP data persisted for one trace. */ +export interface RawTrace { + resourceSpans: OtlpResourceSpan[]; + resourceLogs: OtlpResourceLog[]; } /** * 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. + * trace id), each line a per-trace slice of an OTLP export payload. Raw storage + * only — presentation shaping lives in core/dev/inspector. No in-memory state; + * reads go to disk on demand. Malformed files and lines are skipped, never fatal. */ export class TraceStore { constructor(private readonly directory: string) {} @@ -55,39 +40,19 @@ export class TraceStore { ); } - /** 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; + /** The raw persisted data for one trace, or undefined when the trace is unknown. */ + public async read(traceId: string): Promise { + return this.readTraceFile(`${sanitize(traceId)}${OTLP_EXT}`); + } - const summaries: TraceSummary[] = []; + /** The raw persisted data of every stored trace. */ + public async readAll(): Promise { + const traces: RawTrace[] = []; 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), - }); + if (trace) traces.push(trace); } - - 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); + return traces; } private async traceFiles(): Promise { @@ -98,9 +63,7 @@ export class TraceStore { } } - private async readTraceFile( - fileName: string, - ): Promise<{ resourceSpans: OtlpResourceSpan[]; resourceLogs: OtlpResourceLog[] } | undefined> { + private async readTraceFile(fileName: string): Promise { let content: string; try { content = await readFile(join(this.directory, fileName), "utf8"); diff --git a/src/core/dev/otel/transforms.test.ts b/src/core/dev/otel/transforms.test.ts index 0b3bc2eef..5d5ee8813 100644 --- a/src/core/dev/otel/transforms.test.ts +++ b/src/core/dev/otel/transforms.test.ts @@ -1,93 +1,25 @@ import { describe, expect, test } from "bun:test"; -import { - buildTraceDetail, - extractAnyValue, - extractTraceMeta, - flattenAttributes, - hexFromB64OrString, - nanoToMs, - partitionByTraceId, -} from "./transforms"; -import type { OtlpResourceLog, OtlpResourceSpan } from "./types"; +import { hexFromB64OrString, nanoToMs, partitionByTraceId } from "./transforms"; +import type { 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"; +const OTHER_TRACE_HEX = "ffffffffffffffffffffffffffffffff"; -function resourceSpan(overrides: { serviceName?: string; spans: object[] }): OtlpResourceSpan { +function resourceSpan(spans: object[]): OtlpResourceSpan { return { - resource: overrides.serviceName - ? { attributes: [{ key: "service.name", value: { stringValue: overrides.serviceName } }] } - : undefined, - scopeSpans: [{ scope: { name: "test-scope" }, spans: overrides.spans }], + resource: { attributes: [{ key: "service.name", value: { stringValue: "svc" } }] }, + scopeSpans: [{ scope: { name: "test-scope" }, 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(); - }); -}); +const agentSpan = { traceId: TRACE_ID_B64, spanId: "0123456789abcdef", name: "invoke_agent" }; 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] })], + resourceSpans: [resourceSpan([agentSpan, otherSpan])], }); expect([...partitions.keys()].sort()).toEqual([TRACE_ID_HEX, OTHER_TRACE_HEX]); @@ -103,10 +35,7 @@ describe("partitionByTraceId", () => { resourceLogs: [ { scopeLogs: [ - { - scope: {}, - logRecords: [{ traceId: TRACE_ID_B64 }, { traceId: OTHER_TRACE_HEX }], - }, + { scope: {}, logRecords: [{ traceId: TRACE_ID_B64 }, { traceId: OTHER_TRACE_HEX }] }, ], }, ], @@ -117,81 +46,9 @@ describe("partitionByTraceId", () => { 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(partitionByTraceId({ resourceSpans: [resourceSpan([{ name: "orphan" }])] }).size).toBe( + 0, ); - expect(detail.resourceSpans).toBeUndefined(); - }); - - test("returns undefined sections when everything is filtered or empty", () => { - expect(buildTraceDetail([], [])).toEqual({ resourceSpans: undefined, resourceLogs: undefined }); }); }); @@ -206,38 +63,4 @@ describe("helpers", () => { 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 index 1c161d5e0..0d478ccf5 100644 --- a/src/core/dev/otel/transforms.ts +++ b/src/core/dev/otel/transforms.ts @@ -1,62 +1,4 @@ -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; -} +import type { OtlpPayload } from "./types"; /** * Split one OTLP export payload into per-trace payloads, keyed by hex trace id. @@ -117,102 +59,6 @@ function groupBy(items: T[], key: (item: T) => string): Map { 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; @@ -233,79 +79,3 @@ export function hexFromB64OrString(value: string | undefined): string { 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/io/httpServer.test.ts b/src/io/httpServer.test.ts index 770afb5c2..a28cd07af 100644 --- a/src/io/httpServer.test.ts +++ b/src/io/httpServer.test.ts @@ -29,6 +29,33 @@ describe("startHttpServer", () => { expect(await response.json()).toEqual({ method: "POST", url: "/v1/traces", body: "ping" }); }); + test("an AsyncIterable body streams chunks to the client", async () => { + async function* chunks(): AsyncGenerator { + yield new TextEncoder().encode("data: one\n\n"); + await Bun.sleep(5); + yield new TextEncoder().encode("data: two\n\n"); + } + handle = await startHttpServer(() => ({ + status: 200, + headers: { "Content-Type": "text/event-stream" }, + body: chunks(), + })); + + const response = await fetch(`http://127.0.0.1:${handle.port}/events`); + expect(response.headers.get("content-type")).toBe("text/event-stream"); + expect(await response.text()).toBe("data: one\n\ndata: two\n\n"); + }); + + test("a mid-stream failure cuts the connection instead of ending cleanly", async () => { + async function* broken(): AsyncGenerator { + yield new TextEncoder().encode("partial"); + throw new Error("stream died"); + } + handle = await startHttpServer(() => ({ status: 200, body: broken() })); + + expect(fetch(`http://127.0.0.1:${handle.port}/`).then((r) => r.text())).rejects.toThrow(); + }); + test("handler errors become 500s without crashing the server", async () => { handle = await startHttpServer(() => { throw new Error("boom"); diff --git a/src/io/httpServer.ts b/src/io/httpServer.ts index dfdf32333..fc25e7572 100644 --- a/src/io/httpServer.ts +++ b/src/io/httpServer.ts @@ -21,7 +21,8 @@ export interface HttpRequest { export interface HttpResponse { status: number; headers?: Record; - body?: string | Buffer; + /** An AsyncIterable body streams chunks to the client as they are produced (e.g. SSE). */ + body?: string | Buffer | AsyncIterable; } export type HttpRequestHandler = (request: HttpRequest) => HttpResponse | Promise; @@ -82,13 +83,40 @@ async function respond( body, }); response.writeHead(result.status, result.headers); - response.end(result.body); + if ( + result.body === undefined || + typeof result.body === "string" || + Buffer.isBuffer(result.body) + ) { + response.end(result.body); + return; + } + await streamBody(result.body, response); } catch { + if (response.headersSent) { + // Mid-stream failure: the status line is gone, so cut the connection to + // signal an incomplete response instead of ending it as if successful. + response.destroy(); + return; + } response.writeHead(500, { "Content-Type": "application/json" }); response.end(JSON.stringify({ error: "internal error" })); } } +async function streamBody( + body: AsyncIterable, + response: ServerResponse, +): Promise { + for await (const chunk of body) { + if (response.destroyed) return; + if (!response.write(chunk)) { + await new Promise((resolve) => response.once("drain", resolve)); + } + } + response.end(); +} + class BodyTooLargeError extends Error {} function readBody(request: IncomingMessage): Promise { From 61fcf4d12ef671d173cd6bf42d2c28a1ff7dec11 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Mon, 17 Aug 2026 14:33:13 -0400 Subject: [PATCH 07/16] feat(dev): add DevSupervisor for lazy multi-agent lifecycle Agents start on demand (the Inspector's POST /api/start path), each in an abort scope chained to the command signal for its whole lifetime, with every runner's events merged into one attributed stream for the dev handler to render. Start dedup and retry-after-failure fall out of a per-agent state machine. --- src/core/dev/supervisor.test.ts | 221 ++++++++++++++++++++++++++++++ src/core/dev/supervisor.ts | 234 ++++++++++++++++++++++++++++++++ 2 files changed, 455 insertions(+) create mode 100644 src/core/dev/supervisor.test.ts create mode 100644 src/core/dev/supervisor.ts diff --git a/src/core/dev/supervisor.test.ts b/src/core/dev/supervisor.test.ts new file mode 100644 index 000000000..85153f49f --- /dev/null +++ b/src/core/dev/supervisor.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, test } from "bun:test"; +import type { DevEvent, DevRunner, DevServerInput } from "../../handlers/project/dev/types"; +import type { ProjectRuntime } from "../../projectSchemas/runtime"; +import { DevSupervisor, type SupervisedEvent } from "./supervisor"; + +function runtime(name: string, build: ProjectRuntime["build"] = "CodeZip"): ProjectRuntime { + return { + name, + build, + protocol: "HTTP", + entrypoint: "main.py", + codeLocation: `app/${name}`, + } as ProjectRuntime; +} + +/** A runner that emits `events`, then stays alive until its signal aborts (like a real server). */ +function serverRunner(events: DevEvent[] = []) { + const inputs: DevServerInput[] = []; + const runner: DevRunner = { + run: async function* (input) { + inputs.push(input); + yield* events; + await new Promise((resolve) => + input.signal.addEventListener("abort", () => resolve(), { once: true }), + ); + }, + }; + return { runner, inputs }; +} + +/** A runner whose process dies immediately (optionally with an error). */ +function dyingRunner(failure?: Error) { + const runner: DevRunner = { + run: async function* () { + yield { type: "status", message: "starting" }; + if (failure) throw failure; + }, + }; + return { runner }; +} + +type HarnessOptions = { + runtimes?: ProjectRuntime[]; + codeZip?: { runner: DevRunner }; + container?: { runner: DevRunner }; + ready?: (port: number, signal: AbortSignal) => Promise; +}; + +function harness(options: HarnessOptions = {}) { + const controller = new AbortController(); + const codeZip = options.codeZip ?? serverRunner(); + const container = options.container ?? serverRunner(); + let nextPort = 9100; + const supervisor = new DevSupervisor({ + runtimes: options.runtimes ?? [runtime("orders"), runtime("billing", "Container")], + projectRoot: "/workspace/project", + runners: { CodeZip: codeZip.runner, Container: container.runner }, + environment: async (agentRuntime) => ({ AGENT: agentRuntime.name }), + resolvePort: async () => nextPort++, + waitReady: options.ready ?? (async () => {}), + signal: controller.signal, + }); + return { supervisor, controller, codeZip, container }; +} + +async function drain( + supervisor: DevSupervisor, + controller: AbortController, +): Promise { + controller.abort(); + const collected: SupervisedEvent[] = []; + for await (const event of supervisor.events()) collected.push(event); + return collected; +} + +describe("DevSupervisor", () => { + test("agents are idle until started, then report running with their port", async () => { + const { supervisor, controller } = harness(); + expect(supervisor.snapshot()).toMatchObject([ + { name: "orders", phase: "idle", buildType: "CodeZip", protocol: "HTTP" }, + { name: "billing", phase: "idle", buildType: "Container" }, + ]); + + const started = await supervisor.start("orders"); + expect(started).toEqual({ name: "orders", port: 9100 }); + expect(supervisor.snapshot()[0]).toMatchObject({ + name: "orders", + phase: "running", + port: 9100, + }); + expect(supervisor.running("orders")).toEqual({ port: 9100, protocol: "HTTP" }); + expect(supervisor.running("billing")).toBeUndefined(); + controller.abort(); + }); + + test("passes environment, project root, and port to the runner", async () => { + const codeZip = serverRunner(); + const { supervisor, controller } = harness({ codeZip }); + await supervisor.start("orders"); + + expect(codeZip.inputs[0]).toMatchObject({ + projectRoot: "/workspace/project", + port: 9100, + env: { AGENT: "orders" }, + runtime: { name: "orders" }, + }); + controller.abort(); + }); + + test("concurrent starts of the same agent share one attempt", async () => { + const codeZip = serverRunner(); + let readiness!: () => void; + const { supervisor, controller } = harness({ + codeZip, + ready: () => new Promise((resolve) => (readiness = resolve)), + }); + + const [first, second] = [supervisor.start("orders"), supervisor.start("orders")]; + expect(supervisor.snapshot()[0]!.phase).toBe("starting"); + await Bun.sleep(1); // let the launch reach its readiness wait + readiness(); + expect(await first).toEqual(await second); + expect(codeZip.inputs).toHaveLength(1); + + // A start after running returns the existing port without a new attempt. + expect(await supervisor.start("orders")).toEqual({ name: "orders", port: 9100 }); + expect(codeZip.inputs).toHaveLength(1); + controller.abort(); + }); + + test("an agent that exits before readiness fails the start and can be retried", async () => { + const { supervisor, controller } = harness({ + codeZip: dyingRunner(new Error("boom")), + ready: () => new Promise(() => {}), + }); + + expect(supervisor.start("orders")).rejects.toThrow("boom"); + await supervisor.start("orders").catch(() => {}); + expect(supervisor.snapshot()[0]).toMatchObject({ + name: "orders", + phase: "failed", + error: "boom", + }); + + // Retry hits the runner again rather than being stuck. + await supervisor.start("orders").catch(() => {}); + controller.abort(); + }); + + test("unknown agents are rejected with the available names", () => { + const { supervisor, controller } = harness(); + expect(() => supervisor.start("missing")).toThrow("Available agents: orders, billing"); + controller.abort(); + }); + + test("merges attributed events from several agents into one stream", async () => { + const codeZip = serverRunner([{ type: "stdout", line: "orders out" }]); + const container = serverRunner([{ type: "stderr", line: "billing err" }]); + const { supervisor, controller } = harness({ codeZip, container }); + + await supervisor.start("orders"); + await supervisor.start("billing"); + const events = await drain(supervisor, controller); + + expect(events).toContainEqual({ + agent: "orders", + event: { type: "stdout", line: "orders out" }, + }); + expect(events).toContainEqual({ + agent: "billing", + event: { type: "stderr", line: "billing err" }, + }); + expect(events).toContainEqual({ + agent: "orders", + event: { type: "status", message: "Agent 'orders' is running on port 9100." }, + }); + }); + + test("a running agent that crashes reports failed and leaves the stream alive", async () => { + let fail!: () => void; + const crashing: DevRunner = { + run: async function* () { + yield { type: "status", message: "up" }; + await new Promise((resolve) => (fail = resolve)); + throw new Error("segfault"); + }, + }; + const { supervisor, controller } = harness({ codeZip: { runner: crashing } }); + + await supervisor.start("orders"); + fail(); + await Bun.sleep(5); + + expect(supervisor.snapshot()[0]).toMatchObject({ + name: "orders", + phase: "failed", + error: "segfault", + }); + expect(supervisor.running("orders")).toBeUndefined(); + const events = await drain(supervisor, controller); + expect(events).toContainEqual({ + agent: "orders", + event: { type: "status", message: "Agent 'orders' crashed: segfault" }, + }); + }); + + test("aborting the parent signal stops running agents and ends the stream", async () => { + const codeZip = serverRunner(); + const { supervisor, controller } = harness({ codeZip }); + await supervisor.start("orders"); + + const events: SupervisedEvent[] = []; + const consuming = (async () => { + for await (const event of supervisor.events()) events.push(event); + })(); + controller.abort(); + await consuming; + + expect(codeZip.inputs[0]!.signal.aborted).toBe(true); + }); +}); diff --git a/src/core/dev/supervisor.ts b/src/core/dev/supervisor.ts new file mode 100644 index 000000000..ef575b32f --- /dev/null +++ b/src/core/dev/supervisor.ts @@ -0,0 +1,234 @@ +import { connect } from "node:net"; +import { ResourceNotFoundError } from "../../errors"; +import type { DevEvent, DevRunner } from "../../handlers/project/dev/types"; +import type { ProjectRuntime } from "../../projectSchemas/runtime"; + +export type AgentPhase = "idle" | "starting" | "running" | "failed"; + +export interface AgentStatus { + name: string; + buildType: ProjectRuntime["build"]; + protocol: NonNullable; + phase: AgentPhase; + port?: number; + error?: string; +} + +/** A dev event attributed to the agent that produced it. */ +export interface SupervisedEvent { + agent: string; + event: DevEvent; +} + +export type SupervisorConfig = { + runtimes: ProjectRuntime[]; + projectRoot: string; + runners: { CodeZip: DevRunner; Container: DevRunner }; + /** Resolves the full child environment for a runtime (dev env + OTEL vars). */ + environment: (runtime: ProjectRuntime) => Promise>; + /** Resolves the port a runtime should serve on. */ + resolvePort: (runtime: ProjectRuntime) => Promise; + /** Resolves once a started agent accepts connections on its port. */ + waitReady?: (port: number, signal: AbortSignal) => Promise; + signal: AbortSignal; +}; + +type AgentEntry = { + runtime: ProjectRuntime; + phase: AgentPhase; + port?: number; + error?: string; + starting?: Promise<{ name: string; port: number }>; + controller?: AbortController; +}; + +/** + * Owns the lifecycle of every dev-able runtime for the Inspector: agents start + * lazily (triggered from the browser), each in its own abort scope chained off + * the command's signal, and every runner's events merge into one attributed + * stream the dev handler renders. Restart-on-edit stays inside the child + * (uvicorn --reload / tsx watch) — the supervisor never restarts processes. + */ +export class DevSupervisor { + private readonly agents = new Map(); + private readonly queue: SupervisedEvent[] = []; + private wake: (() => void) | undefined; + private readonly waitReady: (port: number, signal: AbortSignal) => Promise; + + constructor(private readonly config: SupervisorConfig) { + for (const runtime of config.runtimes) { + this.agents.set(runtime.name, { runtime, phase: "idle" }); + } + this.waitReady = config.waitReady ?? waitForPort; + config.signal.addEventListener("abort", () => this.wake?.(), { once: true }); + } + + /** Current phase, port, and last error of every managed agent. */ + public snapshot(): AgentStatus[] { + return [...this.agents.values()].map(({ runtime, phase, port, error }) => ({ + name: runtime.name, + buildType: runtime.build, + protocol: runtime.protocol ?? "HTTP", + phase, + port, + error, + })); + } + + /** The port and protocol of a running agent, for proxying requests to it. */ + public running( + name: string, + ): { port: number; protocol: NonNullable } | undefined { + const entry = this.agents.get(name); + if (entry?.phase !== "running" || entry.port === undefined) return undefined; + return { port: entry.port, protocol: entry.runtime.protocol ?? "HTTP" }; + } + + /** + * Start an agent by name, resolving once it accepts connections. Concurrent + * and repeated starts of the same agent share one attempt; a previously + * failed agent may be started again. + */ + public start(name: string): Promise<{ name: string; port: number }> { + const entry = this.agents.get(name); + if (!entry) { + const available = [...this.agents.keys()].join(", "); + throw new ResourceNotFoundError( + `Agent '${name}' was not found. Available agents: ${available}.`, + ); + } + if (entry.phase === "running" && entry.port !== undefined) { + return Promise.resolve({ name, port: entry.port }); + } + if (entry.starting) return entry.starting; + + entry.starting = this.launch(entry).finally(() => { + entry.starting = undefined; + }); + return entry.starting; + } + + /** + * The merged event stream of every agent this supervisor has started. Ends + * when the supervisor's signal aborts and all pending events are drained. + */ + public async *events(): AsyncGenerator { + while (true) { + while (this.queue.length > 0) yield this.queue.shift()!; + if (this.config.signal.aborted) return; + await new Promise((resolve) => { + this.wake = resolve; + }); + this.wake = undefined; + } + } + + private push(agent: string, event: DevEvent): void { + this.queue.push({ agent, event }); + this.wake?.(); + } + + private async launch(entry: AgentEntry): Promise<{ name: string; port: number }> { + const name = entry.runtime.name; + entry.phase = "starting"; + entry.error = undefined; + + const controller = new AbortController(); + const onParentAbort = () => controller.abort(); + // Chained for the agent's whole lifetime (not just startup): the command's + // Ctrl-C must tear down every running child. The pump removes it on exit. + this.config.signal.addEventListener("abort", onParentAbort, { once: true }); + const unchain = () => this.config.signal.removeEventListener("abort", onParentAbort); + entry.controller = controller; + + try { + const port = await this.config.resolvePort(entry.runtime); + const env = await this.config.environment(entry.runtime); + const runner = this.config.runners[entry.runtime.build]; + + let ready = false; + const readiness = this.waitReady(port, controller.signal).then(() => { + ready = true; + }); + const earlyExit = this.pump(entry, runner, { port, env, signal: controller.signal }) + .finally(unchain) + .then(() => { + if (!ready) + throw new Error(entry.error ?? `Agent '${name}' exited before it became ready.`); + }); + // Both branches outlive the race (the pump runs for the agent's lifetime); + // swallow their late rejections so losing branches never become unhandled. + readiness.catch(() => {}); + earlyExit.catch(() => {}); + await Promise.race([readiness, earlyExit]); + + entry.phase = "running"; + entry.port = port; + this.push(name, { type: "status", message: `Agent '${name}' is running on port ${port}.` }); + return { name, port }; + } catch (error) { + controller.abort(); + entry.phase = "failed"; + entry.error = error instanceof Error ? error.message : String(error); + this.push(name, { + type: "status", + message: `Agent '${name}' failed to start: ${entry.error}`, + }); + throw error; + } + } + + /** Drives one runner generator, attributing its events; resolves when the runner ends. */ + private async pump( + entry: AgentEntry, + runner: DevRunner, + input: { port: number; env: Record; signal: AbortSignal }, + ): Promise { + const name = entry.runtime.name; + try { + for await (const event of runner.run({ + runtime: entry.runtime, + projectRoot: this.config.projectRoot, + port: input.port, + env: input.env, + signal: input.signal, + })) { + this.push(name, event); + } + if (entry.phase === "running") { + entry.phase = "idle"; + entry.port = undefined; + this.push(name, { type: "status", message: `Agent '${name}' stopped.` }); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + entry.error = message; + if (entry.phase === "running") { + entry.phase = "failed"; + entry.port = undefined; + this.push(name, { type: "status", message: `Agent '${name}' crashed: ${message}` }); + } + } + } +} + +/** Poll until a loopback TCP connection to `port` succeeds, or the signal aborts. */ +function waitForPort(port: number, signal: AbortSignal, intervalMs = 250): Promise { + return new Promise((resolve, reject) => { + const attempt = () => { + if (signal.aborted) { + reject(new Error("Aborted while waiting for the agent to become ready.")); + return; + } + const socket = connect({ port, host: "127.0.0.1" }, () => { + socket.destroy(); + resolve(); + }); + socket.on("error", () => { + socket.destroy(); + setTimeout(attempt, intervalMs); + }); + }; + attempt(); + }); +} From 2fb9a947aaa9f2bde1ac1a1e8d1e95a62bca9bf3 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Mon, 17 Aug 2026 14:37:16 -0400 Subject: [PATCH 08/16] feat(dev): ship the Agent Inspector SPA through the asset pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage @aws/agent-inspector's prebuilt dist-assets into the (gitignored) asset tree at build time so the npm bundle mirrors it into dist/assets and compiled binaries embed it — zero new distribution-aware code. At runtime InspectorAssets resolves AGENT_INSPECTOR_PATH (SPA development), then the staged assets, then node_modules (running from source before a build). --- .gitignore | 1 + bun.lock | 767 +++++++++++++++++++++++++++ package.json | 1 + scripts/build.ts | 17 + src/core/dev/inspectorAssets.test.ts | 73 +++ src/core/dev/inspectorAssets.ts | 76 +++ 6 files changed, 935 insertions(+) create mode 100644 src/core/dev/inspectorAssets.test.ts create mode 100644 src/core/dev/inspectorAssets.ts diff --git a/.gitignore b/.gitignore index 89cc7556b..60bf7cfb5 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json .DS_Store .agentreview +src/assets/agent-inspector/ diff --git a/bun.lock b/bun.lock index 2d059282b..7562a8ab5 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,7 @@ "@aws-sdk/client-bedrock-agentcore-control": "^3.1102.0", "@aws-sdk/client-cloudwatch-logs": "^3.1092.0", "@aws-sdk/client-iam": "^3.1080.0", + "@aws/agent-inspector": "0.6.1", "@opentelemetry/api": "^1.9.1", "@opentelemetry/exporter-metrics-otlp-http": "^0.221.0", "@opentelemetry/otlp-transformer": "0.213.0", @@ -48,6 +49,8 @@ "@opentelemetry/core": "^2.10.0", }, "packages": { + "@ag-ui/core": ["@ag-ui/core@0.0.52", "", { "dependencies": { "zod": "^3.22.4" } }, "sha512-Xo0bUaNV56EqylzcrAuhUkQX7et7+SZIrqZZtEByGwEq/I1EHny6ZMkWHLkKR7UNi0FJZwJyhKYmKJS3B2SEgA=="], + "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.3.0", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA=="], "@aws-sdk/client-bedrock-agentcore": ["@aws-sdk/client-bedrock-agentcore@3.1094.0", "", { "dependencies": { "@aws-sdk/core": "^3.976.0", "@aws-sdk/credential-provider-node": "^3.972.71", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/fetch-http-handler": "^5.6.6", "@smithy/node-http-handler": "^4.9.6", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-K8+YaYFyaVJvidwVSnmXlHjpOel/qz4ii2TZY2shXbkMoQxftjHuiXlplKeb5pWhzdS092q6YqPR5CZrMH90Ug=="], @@ -86,12 +89,84 @@ "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.36", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-RdGmS1GLrtaTOLE1ElSluMldNrpk9Emq6uYs8SS8iHlu5xTAmM9rRkM91o48+rIRryBtyO9t+uLYCoMG6jVMVA=="], + "@aws/agent-inspector": ["@aws/agent-inspector@0.6.1", "", { "dependencies": { "@ag-ui/core": "^0.0.52", "eslint-plugin-import": "^2.32.0", "eslint-plugin-prettier": "^5.5.5", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-testing-library": "^7.16.0", "lucide-react": "^0.575.0", "react": "^19.1.0", "react-dom": "^19.1.0", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1" } }, "sha512-+0KJRZe/mK1qenDOGdnCYZdSAt8jZK4tLb8Bc6vbpQqKc3MsRP2ICh0X9mtSAA6iT2KyG7crprsSXZJ3laEijQ=="], + "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.3.0", "", {}, "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ=="], + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], + + "@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], + + "@babel/generator": ["@babel/generator@7.29.8", "", { "dependencies": { "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="], + + "@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="], + + "@babel/parser": ["@babel/parser@7.29.8", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="], + + "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], + + "@babel/traverse": ["@babel/traverse@7.29.8", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg=="], + + "@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + "@colors/colors": ["@colors/colors@1.6.0", "", {}, "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA=="], "@dabh/diagnostics": ["@dabh/diagnostics@2.0.8", "", { "dependencies": { "@so-ric/colorspace": "^1.1.6", "enabled": "2.0.x", "kuler": "^2.0.0" } }, "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q=="], + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.10.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg=="], + + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], + + "@eslint/config-array": ["@eslint/config-array@0.21.2", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.5" } }, "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw=="], + + "@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="], + + "@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="], + + "@eslint/eslintrc": ["@eslint/eslintrc@3.3.6", "", { "dependencies": { "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.3.0", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" } }, "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA=="], + + "@eslint/js": ["@eslint/js@9.39.5", "", {}, "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A=="], + + "@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="], + + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], + + "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], + + "@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="], + + "@humanfs/types": ["@humanfs/types@0.15.0", "", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="], + + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + + "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.213.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-zRM5/Qj6G84Ej3F1yt33xBVY/3tnMxtL1fiDIxYbDWYaZ/eudVw3/PBiZ8G7JwUxXxjW8gU4g6LnOyfGKYHYgw=="], @@ -154,6 +229,8 @@ "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.74.0", "", { "os": "win32", "cpu": "x64" }, "sha512-VTC9IYTIMrVUk/i6Ms1ohzzDKZFkWn0KU2OBbPBzgmVZ2V30165T/zK4LztTr0Xgp9fZ1qQZ1rsZAu/rEmySlA=="], + "@pkgr/core": ["@pkgr/core@0.3.6", "", {}, "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA=="], + "@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=="], @@ -172,6 +249,8 @@ "@protobufjs/utf8": ["@protobufjs/utf8@1.1.2", "", {}, "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug=="], + "@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="], + "@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=="], @@ -192,28 +271,120 @@ "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], + + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + + "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], + + "@types/hast": ["@types/hast@3.0.5", "", { "dependencies": { "@types/unist": "*" } }, "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g=="], + + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + + "@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="], + + "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], + + "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], + "@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="], "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], "@types/triple-beam": ["@types/triple-beam@1.3.5", "", {}, "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw=="], + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.67.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.67.0", "@typescript-eslint/types": "^8.67.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw=="], + + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.67.0", "", { "dependencies": { "@typescript-eslint/types": "8.67.0", "@typescript-eslint/visitor-keys": "8.67.0" } }, "sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg=="], + + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.67.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg=="], + + "@typescript-eslint/types": ["@typescript-eslint/types@8.67.0", "", {}, "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww=="], + + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.67.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.67.0", "@typescript-eslint/tsconfig-utils": "8.67.0", "@typescript-eslint/types": "8.67.0", "@typescript-eslint/visitor-keys": "8.67.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw=="], + + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.67.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.67.0", "@typescript-eslint/types": "8.67.0", "@typescript-eslint/typescript-estree": "8.67.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A=="], + + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.67.0", "", { "dependencies": { "@typescript-eslint/types": "8.67.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA=="], + + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.3", "", {}, "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg=="], + + "acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], + + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + + "ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], + "ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="], + + "array-includes": ["array-includes@3.1.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.0", "es-object-atoms": "^1.1.1", "get-intrinsic": "^1.3.0", "is-string": "^1.1.1", "math-intrinsics": "^1.1.0" } }, "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ=="], + + "array.prototype.findlast": ["array.prototype.findlast@1.2.5", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "es-shim-unscopables": "^1.0.2" } }, "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ=="], + + "array.prototype.findlastindex": ["array.prototype.findlastindex@1.2.6", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-shim-unscopables": "^1.1.0" } }, "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ=="], + + "array.prototype.flat": ["array.prototype.flat@1.3.3", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg=="], + + "array.prototype.flatmap": ["array.prototype.flatmap@1.3.3", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg=="], + + "array.prototype.tosorted": ["array.prototype.tosorted@1.1.4", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3", "es-errors": "^1.3.0", "es-shim-unscopables": "^1.0.2" } }, "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA=="], + + "arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="], + "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], + "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], + "auto-bind": ["auto-bind@5.0.1", "", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="], + "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], + + "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], + + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.11.15", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-FwMjJJ7HnyZpWe+oWxegG0fezZyBZUagI5LZEoO3GCbtbKNwRfMH9Ue5d5v01PNePBy1QSfPSDTTeVL0Hb9EzA=="], + "bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="], + "brace-expansion": ["brace-expansion@1.1.18", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw=="], + + "browserslist": ["browserslist@4.28.8", "", { "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", "electron-to-chromium": "^1.5.402", "node-releases": "^2.0.53", "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001809", "", {}, "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ=="], + + "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], + + "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], + + "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], + + "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], + "cli-boxes": ["cli-boxes@4.0.1", "", {}, "sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw=="], "cli-cursor": ["cli-cursor@4.0.0", "", { "dependencies": { "restore-cursor": "^4.0.0" } }, "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg=="], @@ -230,38 +401,200 @@ "color-string": ["color-string@2.1.4", "", { "dependencies": { "color-name": "^2.0.0" } }, "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg=="], + "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], + "commander": ["commander@15.0.0", "", {}, "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg=="], + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "convert-to-spaces": ["convert-to-spaces@2.0.1", "", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="], "cookie-es": ["cookie-es@3.1.1", "", {}, "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="], + + "data-view-byte-length": ["data-view-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ=="], + + "data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="], + + "debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], + + "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], + + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + + "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], + + "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], + + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + + "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + + "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.408", "", {}, "sha512-SLoprcYpJ/OH2v2ps0+N5biv9H4/KBT3+YmmDew64TwK5y9j2wv7pMOFY7IorVkyMtEyLSCRlXKLsNlakeAlPw=="], + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], "enabled": ["enabled@2.0.0", "", {}, "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ=="], "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], + "es-abstract": ["es-abstract@1.24.2", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg=="], + + "es-abstract-get": ["es-abstract-get@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "es-object-atoms": "^1.1.2", "is-callable": "^1.2.7", "object-inspect": "^1.13.4" } }, "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-iterator-helpers": ["es-iterator-helpers@1.4.0", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.2", "es-errors": "^1.3.0", "es-set-tostringtag": "^2.1.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.3.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "iterator.prototype": "^1.1.5", "math-intrinsics": "^1.1.0" } }, "sha512-c/A0P0oxkACDc+cKWw8evLXK83oBKgn0qPOqCYT4x9uolpCIJAcYvJC9QYKNDRPsTeGyCrQ326jrvgZWdCdK5Q=="], + + "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], + + "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + + "es-shim-unscopables": ["es-shim-unscopables@1.1.0", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw=="], + + "es-to-primitive": ["es-to-primitive@1.3.4", "", { "dependencies": { "es-abstract-get": "^1.0.0", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "is-callable": "^1.2.7", "is-date-object": "^1.1.0", "is-symbol": "^1.1.1" } }, "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw=="], + "es-toolkit": ["es-toolkit@1.49.0", "", {}, "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g=="], + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], + "eslint": ["eslint@9.39.5", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.6", "@eslint/js": "9.39.5", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw=="], + + "eslint-import-resolver-node": ["eslint-import-resolver-node@0.3.10", "", { "dependencies": { "debug": "^3.2.7", "is-core-module": "^2.16.1", "resolve": "^2.0.0-next.6" } }, "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ=="], + + "eslint-module-utils": ["eslint-module-utils@2.14.0", "", { "dependencies": { "debug": "^3.2.7" }, "peerDependencies": { "eslint": "*" }, "optionalPeers": ["eslint"] }, "sha512-W2WCRZ9Dqntd+2u8jJcVMV2PKulc6RdLgUUoh/yQr3uB6lo/ZOeGx11sv60/8S4QFFKNslAlWhr9u0Ef7ZW6Ig=="], + + "eslint-plugin-import": ["eslint-plugin-import@2.32.0", "", { "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", "array.prototype.findlastindex": "^1.2.6", "array.prototype.flat": "^1.3.3", "array.prototype.flatmap": "^1.3.3", "debug": "^3.2.7", "doctrine": "^2.1.0", "eslint-import-resolver-node": "^0.3.9", "eslint-module-utils": "^2.12.1", "hasown": "^2.0.2", "is-core-module": "^2.16.1", "is-glob": "^4.0.3", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "object.groupby": "^1.0.3", "object.values": "^1.2.1", "semver": "^6.3.1", "string.prototype.trimend": "^1.0.9", "tsconfig-paths": "^3.15.0" }, "peerDependencies": { "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" } }, "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA=="], + + "eslint-plugin-prettier": ["eslint-plugin-prettier@5.5.6", "", { "dependencies": { "prettier-linter-helpers": "^1.0.1", "synckit": "^0.11.13" }, "peerDependencies": { "@types/eslint": ">=8.0.0", "eslint": ">=8.0.0", "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", "prettier": ">=3.0.0" }, "optionalPeers": ["@types/eslint", "eslint-config-prettier"] }, "sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ=="], + + "eslint-plugin-react": ["eslint-plugin-react@7.37.5", "", { "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", "array.prototype.flatmap": "^1.3.3", "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", "es-iterator-helpers": "^1.2.1", "estraverse": "^5.3.0", "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", "object.entries": "^1.1.9", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.5", "semver": "^6.3.1", "string.prototype.matchall": "^4.0.12", "string.prototype.repeat": "^1.0.0" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA=="], + + "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.1.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g=="], + + "eslint-plugin-testing-library": ["eslint-plugin-testing-library@7.16.2", "", { "dependencies": { "@typescript-eslint/scope-manager": "^8.56.0", "@typescript-eslint/utils": "^8.56.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0" } }, "sha512-8gleGnQXK2ZA3hHwjCwpYTZvM+9VsrJ+/9kDI8CjqAQGAdMQOdn/rJNu7ZySENuiWlGKQWyZJ4ZjEg2zamaRHw=="], + + "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], + + "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], + + "espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], + + "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], + + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-diff": ["fast-diff@1.3.0", "", {}, "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw=="], + + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "fecha": ["fecha@4.2.3", "", {}, "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw=="], + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + "file-stream-rotator": ["file-stream-rotator@0.6.1", "", { "dependencies": { "moment": "^2.29.1" } }, "sha512-u+dBid4PvZw17PmDeRcNOtCP9CCK/9lRN2w+r1xIS7yOL9JFrIBKTvrYsxT4P0pGtThYTn++QS5ChHaUov3+zQ=="], + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + + "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], + + "flatted": ["flatted@3.4.4", "", {}, "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q=="], + "fn.name": ["fn.name@1.1.0", "", {}, "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw=="], + "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "function.prototype.name": ["function.prototype.name@1.2.0", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2", "hasown": "^2.0.4", "is-callable": "^1.2.7", "is-document.all": "^1.0.0" } }, "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew=="], + + "functions-have-names": ["functions-have-names@1.2.3", "", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="], + + "generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="], + + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + + "globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], + + "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + "handlebars": ["handlebars@4.7.9", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": { "handlebars": "bin/handlebars" } }, "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ=="], + "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="], + + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], + + "has-proto": ["has-proto@1.2.0", "", { "dependencies": { "dunder-proto": "^1.0.0" } }, "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + + "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="], + + "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], + + "hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="], + + "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], + + "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], + "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + "indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="], "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], @@ -272,94 +605,420 @@ "ink-testing-library": ["ink-testing-library@4.0.0", "", { "peerDependencies": { "@types/react": ">=18.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-yF92kj3pmBvk7oKbSq5vEALO//o7Z9Ck/OaLNlkzXNeYdwfpxMQkSowGTFUCS5MSu9bWfSZMewGpp7bFc66D7Q=="], + "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], + + "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], + + "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], + + "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], + + "is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="], + + "is-async-function": ["is-async-function@2.1.1", "", { "dependencies": { "async-function": "^1.0.0", "call-bound": "^1.0.3", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ=="], + + "is-bigint": ["is-bigint@1.1.0", "", { "dependencies": { "has-bigints": "^1.0.2" } }, "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ=="], + + "is-boolean-object": ["is-boolean-object@1.2.2", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A=="], + + "is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="], + + "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="], + + "is-data-view": ["is-data-view@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "is-typed-array": "^1.1.13" } }, "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw=="], + + "is-date-object": ["is-date-object@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg=="], + + "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], + + "is-document.all": ["is-document.all@1.0.0", "", { "dependencies": { "call-bound": "^1.0.4" } }, "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-finalizationregistry": ["is-finalizationregistry@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg=="], + "is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], + "is-generator-function": ["is-generator-function@1.1.2", "", { "dependencies": { "call-bound": "^1.0.4", "generator-function": "^2.0.0", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], + "is-in-ci": ["is-in-ci@2.0.0", "", { "bin": { "is-in-ci": "cli.js" } }, "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w=="], + "is-map": ["is-map@2.0.3", "", {}, "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw=="], + + "is-negative-zero": ["is-negative-zero@2.0.3", "", {}, "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw=="], + + "is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="], + + "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + + "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], + + "is-set": ["is-set@2.0.3", "", {}, "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg=="], + + "is-shared-array-buffer": ["is-shared-array-buffer@1.0.4", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A=="], + "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], + "is-string": ["is-string@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA=="], + + "is-symbol": ["is-symbol@1.1.1", "", { "dependencies": { "call-bound": "^1.0.2", "has-symbols": "^1.1.0", "safe-regex-test": "^1.1.0" } }, "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w=="], + + "is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="], + + "is-weakmap": ["is-weakmap@2.0.2", "", {}, "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w=="], + + "is-weakref": ["is-weakref@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew=="], + + "is-weakset": ["is-weakset@2.0.4", "", { "dependencies": { "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ=="], + + "isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "iterator.prototype": ["iterator.prototype@1.1.5", "", { "dependencies": { "define-data-property": "^1.1.4", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "get-proto": "^1.0.0", "has-symbols": "^1.1.0", "set-function-name": "^2.0.2" } }, "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + + "json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="], + + "jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="], + + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + "kuler": ["kuler@2.0.0", "", {}, "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A=="], + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + "lint-staged": ["lint-staged@17.0.8", "", { "dependencies": { "listr2": "^10.2.1", "picomatch": "^4.0.4", "string-argv": "^0.3.2", "tinyexec": "^1.2.4" }, "optionalDependencies": { "yaml": "^2.9.0" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-B2P/d+jVW0UXOQ0MVMLrB/9ydA1P+zz6jYfdrbbEd9ur3S2rcbduFWKiUCC02Sm5hbC8nrm7y24WuYMG54HfxA=="], "listr2": ["listr2@10.2.2", "", { "dependencies": { "cli-truncate": "^5.2.0", "eventemitter3": "^5.0.4", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^10.0.0" } }, "sha512-JtNtbZj8q5BnDMR7trpwvwk3RIrANtIVzEUm8w7amp6xelLgyuq+4WZoTH913XaQAoH/cNdYhaNzBPA2U3xbDw=="], + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], + "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + "log-update": ["log-update@6.1.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "cli-cursor": "^5.0.0", "slice-ansi": "^7.1.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w=="], "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=="], + "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], + + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "lucide-react": ["lucide-react@0.575.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-VuXgKZrk0uiDlWjGGXmKV6MSk9Yy4l10qgVvzGn2AWBx1Ylt0iBexKOAoA6I7JO3m+M9oeovJd3yYENfkUbOeg=="], + + "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="], + + "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="], + + "mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="], + + "mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="], + + "mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="], + + "mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="], + + "mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="], + + "mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="], + + "mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="], + + "mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="], + + "mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="], + + "mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="], + + "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="], + + "mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="], + + "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], + + "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], + + "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], + + "micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="], + + "micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="], + + "micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="], + + "micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="], + + "micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="], + + "micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="], + + "micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="], + + "micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="], + + "micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="], + + "micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="], + + "micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="], + + "micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="], + + "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="], + + "micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="], + + "micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="], + + "micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="], + + "micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="], + + "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="], + + "micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="], + + "micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="], + + "micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="], + + "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="], + + "micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="], + + "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], + "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], + "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], "moment": ["moment@2.30.1", "", {}, "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], + "node-exports-info": ["node-exports-info@1.6.2", "", { "dependencies": { "array.prototype.flatmap": "^1.3.3", "es-errors": "^1.3.0", "object.entries": "^1.1.9", "semver": "^6.3.1" } }, "sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag=="], + + "node-releases": ["node-releases@2.0.53", "", {}, "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + "object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="], + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], + + "object.assign": ["object.assign@4.1.7", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0", "has-symbols": "^1.1.0", "object-keys": "^1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="], + + "object.entries": ["object.entries@1.1.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-object-atoms": "^1.1.1" } }, "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw=="], + + "object.fromentries": ["object.fromentries@2.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-object-atoms": "^1.0.0" } }, "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ=="], + + "object.groupby": ["object.groupby@1.0.3", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2" } }, "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ=="], + + "object.values": ["object.values@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="], + "one-time": ["one-time@1.0.0", "", { "dependencies": { "fn.name": "1.x.x" } }, "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g=="], "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + + "own-keys": ["own-keys@1.0.2", "", { "dependencies": { "call-bound": "^1.0.4", "get-intrinsic": "^1.3.0", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg=="], + "oxlint": ["oxlint@1.74.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.74.0", "@oxlint/binding-android-arm64": "1.74.0", "@oxlint/binding-darwin-arm64": "1.74.0", "@oxlint/binding-darwin-x64": "1.74.0", "@oxlint/binding-freebsd-x64": "1.74.0", "@oxlint/binding-linux-arm-gnueabihf": "1.74.0", "@oxlint/binding-linux-arm-musleabihf": "1.74.0", "@oxlint/binding-linux-arm64-gnu": "1.74.0", "@oxlint/binding-linux-arm64-musl": "1.74.0", "@oxlint/binding-linux-ppc64-gnu": "1.74.0", "@oxlint/binding-linux-riscv64-gnu": "1.74.0", "@oxlint/binding-linux-riscv64-musl": "1.74.0", "@oxlint/binding-linux-s390x-gnu": "1.74.0", "@oxlint/binding-linux-x64-gnu": "1.74.0", "@oxlint/binding-linux-x64-musl": "1.74.0", "@oxlint/binding-openharmony-arm64": "1.74.0", "@oxlint/binding-win32-arm64-msvc": "1.74.0", "@oxlint/binding-win32-ia32-msvc": "1.74.0", "@oxlint/binding-win32-x64-msvc": "1.74.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.24.0", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-odGl2s2x5IOJoj3A0v1k0PGBXVFBZeZ2+AK/+K2MJur7Ghi3bkyX5NuLUWHKqa4js1wjep3hJeuTQJOlr+4+dA=="], + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + + "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], + "patch-console": ["patch-console@2.0.0", "", {}, "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA=="], + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], + "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], + + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + "prettier": ["prettier@3.9.5", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg=="], + "prettier-linter-helpers": ["prettier-linter-helpers@1.0.1", "", { "dependencies": { "fast-diff": "^1.1.2" } }, "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg=="], + + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], + + "property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="], + "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=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "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=="], + "react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="], + + "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + + "react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="], + "react-reconciler": ["react-reconciler@0.33.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="], "react-router": ["react-router@8.3.0", "", { "dependencies": { "cookie-es": "^3.1.1" }, "peerDependencies": { "react": ">=19.2.7", "react-dom": ">=19.2.7" }, "optionalPeers": ["react-dom"] }, "sha512-qyPMvW83jGIct3yiieisxdk9M745anqhpIMKN5m1t6yBMfgVPpt77aHOqs5fUlEJRMCGffg9BaQLH9oPVOL7xQ=="], "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], + + "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], + + "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="], + + "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], + + "remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="], + + "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], + + "resolve": ["resolve@2.0.0-next.7", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.2", "node-exports-info": "^1.6.0", "object-keys": "^1.1.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ=="], + + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + "restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="], "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], + "safe-array-concat": ["safe-array-concat@1.1.4", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "get-intrinsic": "^1.3.0", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg=="], + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="], + + "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="], + "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], + + "set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="], + + "set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "shell-quote": ["shell-quote@1.10.0", "", {}, "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA=="], + "side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="], + + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], "slice-ansi": ["slice-ansi@9.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA=="], "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], + "stack-trace": ["stack-trace@0.0.10", "", {}, "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg=="], "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], + "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], + "string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="], "string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="], + "string.prototype.matchall": ["string.prototype.matchall@4.0.12", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-abstract": "^1.23.6", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "regexp.prototype.flags": "^1.5.3", "set-function-name": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA=="], + + "string.prototype.repeat": ["string.prototype.repeat@1.0.0", "", { "dependencies": { "define-properties": "^1.1.3", "es-abstract": "^1.17.5" } }, "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w=="], + + "string.prototype.trim": ["string.prototype.trim@1.2.11", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "define-data-property": "^1.1.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.2", "es-object-atoms": "^1.1.2", "has-property-descriptors": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w=="], + + "string.prototype.trimend": ["string.prototype.trimend@1.0.10", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-object-atoms": "^1.1.2" } }, "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw=="], + + "string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="], + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], + "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], + + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + + "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], + + "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], + + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + + "synckit": ["synckit@0.11.13", "", { "dependencies": { "@pkgr/core": "^0.3.6" } }, "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg=="], + "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], "terminal-size": ["terminal-size@4.0.1", "", {}, "sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ=="], @@ -368,20 +1027,72 @@ "tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], + "triple-beam": ["triple-beam@1.4.1", "", {}, "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg=="], + "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], + + "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], + + "tsconfig-paths": ["tsconfig-paths@3.15.0", "", { "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + "type-fest": ["type-fest@5.8.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA=="], + "typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="], + + "typed-array-byte-length": ["typed-array-byte-length@1.0.3", "", { "dependencies": { "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.14" } }, "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg=="], + + "typed-array-byte-offset": ["typed-array-byte-offset@1.0.4", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.15", "reflect.getprototypeof": "^1.0.9" } }, "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ=="], + + "typed-array-length": ["typed-array-length@1.0.8", "", { "dependencies": { "call-bind": "^1.0.9", "for-each": "^0.3.5", "gopd": "^1.2.0", "is-typed-array": "^1.1.15", "possible-typed-array-names": "^1.1.0", "reflect.getprototypeof": "^1.0.10" } }, "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "uglify-js": ["uglify-js@3.19.3", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ=="], + "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], + + "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], + + "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="], + + "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], + + "unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="], + + "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], + + "update-browserslist-db": ["update-browserslist-db@1.3.1", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ=="], + + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], + + "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="], + + "which-builtin-type": ["which-builtin-type@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "function.prototype.name": "^1.1.6", "has-tostringtag": "^1.0.2", "is-async-function": "^2.0.0", "is-date-object": "^1.1.0", "is-finalizationregistry": "^1.1.0", "is-generator-function": "^1.0.10", "is-regex": "^1.2.1", "is-weakref": "^1.0.2", "isarray": "^2.0.5", "which-boxed-primitive": "^1.1.0", "which-collection": "^1.0.2", "which-typed-array": "^1.1.16" } }, "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q=="], + + "which-collection": ["which-collection@1.0.2", "", { "dependencies": { "is-map": "^2.0.3", "is-set": "^2.0.3", "is-weakmap": "^2.0.2", "is-weakset": "^2.0.3" } }, "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw=="], + + "which-typed-array": ["which-typed-array@1.1.22", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.9", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw=="], + "widest-line": ["widest-line@6.0.0", "", { "dependencies": { "string-width": "^8.1.0" } }, "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA=="], "winston": ["winston@3.19.0", "", { "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.8", "async": "^3.2.3", "is-stream": "^2.0.0", "logform": "^2.7.0", "one-time": "^1.0.0", "readable-stream": "^3.4.0", "safe-stable-stringify": "^2.3.1", "stack-trace": "0.0.x", "triple-beam": "^1.3.0", "winston-transport": "^4.9.0" } }, "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA=="], @@ -390,18 +1101,30 @@ "winston-transport": ["winston-transport@4.9.0", "", { "dependencies": { "logform": "^2.7.0", "readable-stream": "^3.6.2", "triple-beam": "^1.3.0" } }, "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A=="], + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + "wordwrap": ["wordwrap@1.0.0", "", {}, "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q=="], "wrap-ansi": ["wrap-ansi@10.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "string-width": "^8.2.0", "strip-ansi": "^7.1.2" } }, "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ=="], "ws": ["ws@8.21.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw=="], + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], + + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + + "@ag-ui/core/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/core": ["@aws-sdk/core@3.977.5", "", { "dependencies": { "@aws-sdk/types": "^3.974.2", "@aws-sdk/xml-builder": "^3.972.37", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.31.1", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.16.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-O5otOc1c6UZh5HsHAaPdYBcUUR9HL6mtnKqvc8nxN/CKDGUBUpsdh0q8K04Uz/dd1i0TaGyIQuQNqoO7+ad2TQ=="], "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.77", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.66", "@aws-sdk/credential-provider-http": "^3.972.68", "@aws-sdk/credential-provider-ini": "^3.973.11", "@aws-sdk/credential-provider-process": "^3.972.66", "@aws-sdk/credential-provider-sso": "^3.973.10", "@aws-sdk/credential-provider-web-identity": "^3.972.72", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-l4nitYCN/Ls57vtUfdextCjTjW41JD7lQiAnuR0RTbdByFc/6OmEAzwGd+lrp6CUtiXGQL1FCaYiamfHASrwBw=="], @@ -434,6 +1157,18 @@ "@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=="], + "@babel/core/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "@babel/core/json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "@babel/traverse/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "@eslint/config-array/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "@eslint/eslintrc/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "@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=="], @@ -446,6 +1181,22 @@ "@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=="], + "@typescript-eslint/project-service/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "@typescript-eslint/typescript-estree/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], + + "@typescript-eslint/typescript-estree/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + + "eslint/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "eslint/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "eslint/escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + "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=="], @@ -454,6 +1205,12 @@ "log-update/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], + "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + + "micromark/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + "react-devtools-core/ws": ["ws@7.5.12", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-1xGnbYN3zbog9CwuNDQULNRrTCLIn46/WmpR1f0w6PsCYQHkylZr5vkd6kfMZYV6pRnQkcPNRyiA8LsrNKyhpg=="], "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.37", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg=="], @@ -518,6 +1275,10 @@ "@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=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="], + + "eslint/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "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=="], @@ -554,6 +1315,10 @@ "@aws-sdk/client-iam/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.32", "", { "dependencies": { "@aws-sdk/core": "^3.975.2", "@aws-sdk/signature-v4-multi-region": "^3.996.40", "@aws-sdk/types": "^3.974.1", "@smithy/core": "^3.29.3", "@smithy/fetch-http-handler": "^5.6.5", "@smithy/node-http-handler": "^4.9.5", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-6Yj2fr9XF67cndITea48rchTdVr3VGx6PN47bIKNinJAjLkmaIlz/4EBPCgJ8UmhVopiXmeAuPLI3+DXDDbMhQ=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "eslint/chalk/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + "log-update/cli-cursor/restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], "log-update/cli-cursor/restore-cursor/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], @@ -576,6 +1341,8 @@ "@aws-sdk/client-iam/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.40", "", { "dependencies": { "@aws-sdk/types": "^3.974.1", "@smithy/signature-v4": "^5.6.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-wrGZ/authosokclY1DXsiWT/1WjfCI22FuZGgdcilF+XLTXs5dCjAtiFYSPsEToZkbm3Lj2YP8PoWg0yoMNu0g=="], + "eslint/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region/@smithy/signature-v4": ["@smithy/signature-v4@5.6.12", "", { "dependencies": { "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-I6KLtq3H0qqSuV9vLglfi8puHqzygzWHOnI4z/Rdoo+q50vvo18vBRdPAvvEtcaKROz7Zn6qnPa14kRfPH6PcQ=="], "@aws-sdk/client-bedrock-agentcore-control/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region/@smithy/signature-v4": ["@smithy/signature-v4@5.6.12", "", { "dependencies": { "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-I6KLtq3H0qqSuV9vLglfi8puHqzygzWHOnI4z/Rdoo+q50vvo18vBRdPAvvEtcaKROz7Zn6qnPa14kRfPH6PcQ=="], diff --git a/package.json b/package.json index 42fdb3f57..4b08c22e1 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "@aws-sdk/client-bedrock-agentcore-control": "^3.1102.0", "@aws-sdk/client-cloudwatch-logs": "^3.1092.0", "@aws-sdk/client-iam": "^3.1080.0", + "@aws/agent-inspector": "0.6.1", "@opentelemetry/api": "^1.9.1", "@opentelemetry/exporter-metrics-otlp-http": "^0.221.0", "@opentelemetry/otlp-transformer": "0.213.0", diff --git a/scripts/build.ts b/scripts/build.ts index ea2b316ec..ab59bfacf 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -21,6 +21,21 @@ function discoverAssets(): string[] { return files.sort().map((relativePath) => join(ASSETS_DIR, relativePath)); } +/** + * Stage the prebuilt Agent Inspector SPA into the asset tree (gitignored) so + * both distribution paths ship it through the ordinary asset machinery: + * `bundle` mirrors it into dist/assets/, `compile` embeds it in the binary. + */ +async function stageInspectorAssets(): Promise { + const source = join(REPO_ROOT, "node_modules", "@aws", "agent-inspector", "dist-assets"); + if (!(await Bun.file(join(source, "index.html")).exists())) { + throw new Error("@aws/agent-inspector is not installed — run `bun install` before building."); + } + const target = join(ASSETS_DIR, "agent-inspector"); + await $`rm -rf ${target}`; + await $`cp -R ${source} ${target}`; +} + /** Force asset files through the file loader so template .ts/.js are embedded as bytes, not compiled. */ function assetLoaderPlugin(): Bun.BunPlugin { return { @@ -49,6 +64,7 @@ async function assertAssetsAreText(assets: string[]): Promise { // Bun.build rejects with an AggregateError on failure (throw defaults to true), // so build errors propagate to runWithExitCode like any other. async function bundle(): Promise { + await stageInspectorAssets(); await Bun.build({ entrypoints: [ENTRYPOINT], outdir: DIST, @@ -64,6 +80,7 @@ async function bundle(): Promise { } async function compile(target: string): Promise { + await stageInspectorAssets(); const assets = discoverAssets(); await assertAssetsAreText(assets); diff --git a/src/core/dev/inspectorAssets.test.ts b/src/core/dev/inspectorAssets.test.ts new file mode 100644 index 000000000..b19ccb832 --- /dev/null +++ b/src/core/dev/inspectorAssets.test.ts @@ -0,0 +1,73 @@ +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 type { AssetSource } from "../project/source"; +import { InspectorAssets } from "./inspectorAssets"; + +function fakeSource(files: Record): AssetSource { + return { + read: async (path) => { + const content = files[path]; + if (content === undefined) throw new Error(`missing: ${path}`); + return content; + }, + list: async () => Object.keys(files), + }; +} + +let overrideDir: string; + +beforeEach(async () => { + overrideDir = await mkdtemp(join(tmpdir(), "inspector-assets-")); +}); + +afterEach(async () => { + await rm(overrideDir, { recursive: true, force: true }); +}); + +describe("InspectorAssets", () => { + test("serves staged assets with MIME by extension", async () => { + const assets = new InspectorAssets({ + source: fakeSource({ + "agent-inspector/index.html": "", + "agent-inspector/index.js": "app()", + "agent-inspector/favicon.svg": "", + }), + overrideDir: "", + }); + + const html = await assets.read("/index.html"); + expect(new TextDecoder().decode(html!.body)).toBe(""); + expect(html!.contentType).toBe("text/html; charset=utf-8"); + expect((await assets.read("index.js"))!.contentType).toBe("text/javascript; charset=utf-8"); + expect((await assets.read("favicon.svg"))!.contentType).toBe("image/svg+xml"); + }); + + test("falls back to the packaged dist-assets when nothing is staged", async () => { + const assets = new InspectorAssets({ source: fakeSource({}), overrideDir: "" }); + const html = await assets.read("index.html"); + // @aws/agent-inspector is a real dependency of this repo, so the fallback resolves. + expect(html).toBeDefined(); + expect(html!.contentType).toBe("text/html; charset=utf-8"); + }); + + test("AGENT_INSPECTOR_PATH overrides everything for SPA development", async () => { + await writeFile(join(overrideDir, "index.html"), "local dev build"); + const assets = new InspectorAssets({ source: fakeSource({}), overrideDir }); + + const html = await assets.read("index.html"); + expect(new TextDecoder().decode(html!.body)).toBe("local dev build"); + expect(await assets.read("missing.js")).toBeUndefined(); + }); + + test("rejects path traversal and empty paths", async () => { + const assets = new InspectorAssets({ + source: fakeSource({ "agent-inspector/index.html": "x" }), + overrideDir: "", + }); + expect(await assets.read("../secrets.txt")).toBeUndefined(); + expect(await assets.read("a/../../b")).toBeUndefined(); + expect(await assets.read("/")).toBeUndefined(); + }); +}); diff --git a/src/core/dev/inspectorAssets.ts b/src/core/dev/inspectorAssets.ts new file mode 100644 index 000000000..9bfdd09fc --- /dev/null +++ b/src/core/dev/inspectorAssets.ts @@ -0,0 +1,76 @@ +import { readFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { dirname, extname, join } from "node:path"; +import { defaultSource, type AssetSource } from "../project/source"; + +/** A static file of the Agent Inspector SPA, ready to serve. */ +export interface InspectorAsset { + body: Uint8Array; + contentType: string; +} + +/** The SPA ships exactly these types today; anything else is served as plain text. */ +const CONTENT_TYPES: Record = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".svg": "image/svg+xml", +}; + +export type InspectorAssetReader = (assetPath: string) => Promise; + +/** + * Reads the prebuilt Agent Inspector SPA. Resolution order: + * 1. AGENT_INSPECTOR_PATH — a local inspector build, for SPA development. + * 2. The staged asset tree (embedded in compiled binaries, dist/assets in the + * npm bundle, src/assets after any build). + * 3. node_modules/@aws/agent-inspector/dist-assets — running from source + * before the first build, when nothing has been staged yet. + */ +export class InspectorAssets { + private readonly source: AssetSource; + private readonly overrideDir: string | undefined; + + constructor(options: { source?: AssetSource; overrideDir?: string } = {}) { + this.source = options.source ?? defaultSource(); + this.overrideDir = options.overrideDir ?? process.env.AGENT_INSPECTOR_PATH; + } + + public read: InspectorAssetReader = async (assetPath) => { + const relative = assetPath.replace(/^\/+/, ""); + if (!relative || relative.split("/").some((part) => part === "..")) return undefined; + const contentType = CONTENT_TYPES[extname(relative)] ?? "text/plain; charset=utf-8"; + + if (this.overrideDir) { + try { + return { body: await readFile(join(this.overrideDir, relative)), contentType }; + } catch { + return undefined; + } + } + + try { + const text = await this.source.read(`agent-inspector/${relative}`); + return { body: new TextEncoder().encode(text), contentType }; + } catch { + // Not staged (running from source before a build) — fall through. + } + + const packaged = packagedAssetsDir(); + if (!packaged) return undefined; + try { + return { body: await readFile(join(packaged, relative)), contentType }; + } catch { + return undefined; + } + }; +} + +function packagedAssetsDir(): string | undefined { + try { + const require = createRequire(import.meta.url); + return join(dirname(require.resolve("@aws/agent-inspector/package.json")), "dist-assets"); + } catch { + return undefined; + } +} From 627714555d2fd52aa23d8fd2a0b1eb966d7ddad6 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Mon, 17 Aug 2026 14:39:52 -0400 Subject: [PATCH 09/16] fix(build): stage inspector SPA files with a neutral suffix for compile Bun.build bundles bare .html entrypoints through its HTML-imports pipeline (colliding with the SPA's own index.js) instead of embedding them verbatim. Staged files carry an .asset suffix that InspectorAssets strips on read. --- scripts/build.ts | 10 +++++++++- src/core/dev/inspectorAssets.test.ts | 8 ++++---- src/core/dev/inspectorAssets.ts | 3 ++- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/scripts/build.ts b/scripts/build.ts index ab59bfacf..a15075bd9 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -25,6 +25,10 @@ function discoverAssets(): string[] { * Stage the prebuilt Agent Inspector SPA into the asset tree (gitignored) so * both distribution paths ship it through the ordinary asset machinery: * `bundle` mirrors it into dist/assets/, `compile` embeds it in the binary. + * Every file gains a neutral `.asset` suffix — compile passes assets as + * Bun.build entrypoints, and a bare .html entrypoint would be bundled through + * Bun's HTML-imports pipeline instead of embedded verbatim. InspectorAssets + * strips the suffix when reading. */ async function stageInspectorAssets(): Promise { const source = join(REPO_ROOT, "node_modules", "@aws", "agent-inspector", "dist-assets"); @@ -33,7 +37,11 @@ async function stageInspectorAssets(): Promise { } const target = join(ASSETS_DIR, "agent-inspector"); await $`rm -rf ${target}`; - await $`cp -R ${source} ${target}`; + await $`mkdir -p ${target}`; + const files = [...new Bun.Glob("**/*").scanSync({ cwd: source, onlyFiles: true })]; + for (const file of files) { + await $`cp ${join(source, file)} ${join(target, `${file}.asset`)}`; + } } /** Force asset files through the file loader so template .ts/.js are embedded as bytes, not compiled. */ diff --git a/src/core/dev/inspectorAssets.test.ts b/src/core/dev/inspectorAssets.test.ts index b19ccb832..57480762b 100644 --- a/src/core/dev/inspectorAssets.test.ts +++ b/src/core/dev/inspectorAssets.test.ts @@ -30,9 +30,9 @@ describe("InspectorAssets", () => { test("serves staged assets with MIME by extension", async () => { const assets = new InspectorAssets({ source: fakeSource({ - "agent-inspector/index.html": "", - "agent-inspector/index.js": "app()", - "agent-inspector/favicon.svg": "", + "agent-inspector/index.html.asset": "", + "agent-inspector/index.js.asset": "app()", + "agent-inspector/favicon.svg.asset": "", }), overrideDir: "", }); @@ -63,7 +63,7 @@ describe("InspectorAssets", () => { test("rejects path traversal and empty paths", async () => { const assets = new InspectorAssets({ - source: fakeSource({ "agent-inspector/index.html": "x" }), + source: fakeSource({ "agent-inspector/index.html.asset": "x" }), overrideDir: "", }); expect(await assets.read("../secrets.txt")).toBeUndefined(); diff --git a/src/core/dev/inspectorAssets.ts b/src/core/dev/inspectorAssets.ts index 9bfdd09fc..041bb62a5 100644 --- a/src/core/dev/inspectorAssets.ts +++ b/src/core/dev/inspectorAssets.ts @@ -50,7 +50,8 @@ export class InspectorAssets { } try { - const text = await this.source.read(`agent-inspector/${relative}`); + // Staged files carry a neutral `.asset` suffix (see scripts/build.ts). + const text = await this.source.read(`agent-inspector/${relative}.asset`); return { body: new TextEncoder().encode(text), contentType }; } catch { // Not staged (running from source before a build) — fall through. From 5bd135b394c3d9d84de1a7555646e4a9db042a19 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Mon, 17 Aug 2026 14:41:46 -0400 Subject: [PATCH 10/16] feat(io): add best-effort default-browser opener --- src/io/index.ts | 1 + src/io/openBrowser.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 src/io/openBrowser.ts diff --git a/src/io/index.ts b/src/io/index.ts index 23eb4a03d..f0f256508 100644 --- a/src/io/index.ts +++ b/src/io/index.ts @@ -37,6 +37,7 @@ export { export type { AppIO, ReadWriteJson } from "./types"; export { warn } from "./warn"; export { checkPort, type PortChecker } from "./port"; +export { openBrowser, type BrowserOpener } from "./openBrowser"; export { startHttpServer, type HttpRequest, diff --git a/src/io/openBrowser.ts b/src/io/openBrowser.ts new file mode 100644 index 000000000..3b837713c --- /dev/null +++ b/src/io/openBrowser.ts @@ -0,0 +1,26 @@ +import { spawn } from "node:child_process"; + +export type BrowserOpener = (url: string) => Promise; + +/** + * Open a URL in the user's default browser, best-effort: failures resolve + * quietly because the URL is always printed as well — a machine without a + * browser association must not fail `project dev`. + */ +export const openBrowser: BrowserOpener = (url) => { + const [command, args] = + process.platform === "darwin" + ? ["open", [url]] + : process.platform === "win32" + ? ["cmd", ["/c", "start", "", url]] + : ["xdg-open", [url]]; + + return new Promise((resolve) => { + const child = spawn(command as string, args as string[], { stdio: "ignore", detached: true }); + child.on("error", () => resolve()); + child.on("spawn", () => { + child.unref(); + resolve(); + }); + }); +}; From 8493669fe469d3d5eba910bf4147d1dc8fff31ec Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Mon, 17 Aug 2026 14:56:57 -0400 Subject: [PATCH 11/16] feat(dev): add the Agent Inspector HTTP server createInspectorHandler composes a pure request handler for the Inspector SPA over consumer-owned dependency interfaces (supervisor, traces, static assets, project, optional AWS capabilities). Ports the reference web-ui server's security model (loopback Host check, origin allowlist, X-Agentcore-Local on POSTs, CORS preflight) and its wire contract: status, start, protocol-aware /invocations proxying (HTTP/A2A/AGUI SSE translation, deployed runtimes, harness invocations), local and deployed MCP proxying, A2A agent cards, local and CloudWatch traces, memory browsing/search, the project resource graph, and SPA static serving with an index.html fallback. AWS-backed routes degrade to 404 error envelopes when their capability is not wired. --- src/core/dev/inspector/api.ts | 379 ++++++++++++++++++++ src/core/dev/inspector/aws.ts | 151 ++++++++ src/core/dev/inspector/harness.ts | 89 +++++ src/core/dev/inspector/invocations.test.ts | 385 +++++++++++++++++++++ src/core/dev/inspector/invocations.ts | 371 ++++++++++++++++++++ src/core/dev/inspector/proxies.test.ts | 383 ++++++++++++++++++++ src/core/dev/inspector/proxies.ts | 124 +++++++ src/core/dev/inspector/resources.ts | 201 +++++++++++ src/core/dev/inspector/respond.ts | 99 ++++++ src/core/dev/inspector/server.test.ts | 334 ++++++++++++++++++ src/core/dev/inspector/server.ts | 218 ++++++++++++ src/core/dev/inspector/testkit.ts | 93 +++++ src/core/dev/inspector/types.ts | 167 +++++++++ 13 files changed, 2994 insertions(+) create mode 100644 src/core/dev/inspector/api.ts create mode 100644 src/core/dev/inspector/aws.ts create mode 100644 src/core/dev/inspector/harness.ts create mode 100644 src/core/dev/inspector/invocations.test.ts create mode 100644 src/core/dev/inspector/invocations.ts create mode 100644 src/core/dev/inspector/proxies.test.ts create mode 100644 src/core/dev/inspector/proxies.ts create mode 100644 src/core/dev/inspector/resources.ts create mode 100644 src/core/dev/inspector/respond.ts create mode 100644 src/core/dev/inspector/server.test.ts create mode 100644 src/core/dev/inspector/server.ts create mode 100644 src/core/dev/inspector/testkit.ts create mode 100644 src/core/dev/inspector/types.ts diff --git a/src/core/dev/inspector/api.ts b/src/core/dev/inspector/api.ts new file mode 100644 index 000000000..8fc0ece7c --- /dev/null +++ b/src/core/dev/inspector/api.ts @@ -0,0 +1,379 @@ +/** + * Wire types for the Agent Inspector HTTP API, ported from the original CLI's + * web-ui/api-types.ts. The prebuilt Inspector SPA depends on these exact field + * names — keep every shape byte-compatible with the reference when changing + * anything here. + */ + +// --------------------------------------------------------------------------- +// GET /api/status +// --------------------------------------------------------------------------- + +export interface StatusResponse { + mode: "dev"; + agents: StatusAgent[]; + harnesses: StatusHarness[]; + running: StatusRunningAgent[]; + errors: StatusAgentError[]; + /** Agent name to pre-select in the UI (set when --runtime is specified). */ + selectedAgent?: string; +} + +export interface StatusAgent { + name: string; + buildType: string; + protocol: string; +} + +export interface StatusRunningAgent { + name: string; + /** Port the agent is listening on. */ + port: number; +} + +export interface StatusAgentError { + name: string; + message: string; +} + +export interface StatusHarness { + name: string; +} + +// --------------------------------------------------------------------------- +// POST /api/start +// --------------------------------------------------------------------------- + +export interface StartRequest { + agentName: string; +} + +export interface StartResponse { + success: boolean; + name: string; + port: number; + error?: string; +} + +// --------------------------------------------------------------------------- +// POST /invocations +// --------------------------------------------------------------------------- + +export interface InvocationRequest { + agentName?: string; + targetName?: string; + prompt?: string; + sessionId?: string; + userId?: string; +} + +// --------------------------------------------------------------------------- +// GET /api/traces[/:traceId]?agentName=xxx +// --------------------------------------------------------------------------- + +export interface ListTracesResponse { + success: boolean; + traces?: unknown[]; + error?: string; +} + +export interface GetTraceResponse { + success: boolean; + resourceSpans?: unknown[]; + resourceLogs?: unknown[]; + error?: string; +} + +// --------------------------------------------------------------------------- +// GET /api/cloudwatch-traces[/:traceId]?agentName=xxx|harnessName=xxx +// --------------------------------------------------------------------------- + +export interface CloudWatchTraceEntry { + traceId: string; + timestamp: string; + sessionId?: string; + spanCount?: string; +} + +export interface ListCloudWatchTracesResponse { + success: boolean; + traces?: CloudWatchTraceEntry[]; + error?: string; +} + +export interface GetCloudWatchTraceResponse { + success: boolean; + records?: unknown[]; + spans?: unknown[]; + error?: string; +} + +// --------------------------------------------------------------------------- +// GET /api/memory + POST /api/memory/search +// --------------------------------------------------------------------------- + +/** + * Namespace selector shared by memory list and search. Exactly one of + * `namespace` (exact match) or `namespacePath` (hierarchical path prefix) + * must be provided. + */ +export type MemoryNamespaceSelector = + { namespace: string; namespacePath?: never } | { namespace?: never; namespacePath: string }; + +export type ListMemoryRecordsQuery = { + memoryName: string; + strategyId?: string; +} & MemoryNamespaceSelector; + +export type RetrieveMemoryRecordsRequest = { + memoryName: string; + searchQuery: string; + strategyId?: string; +} & MemoryNamespaceSelector; + +export interface MemoryRecordResponse { + memoryRecordId: string; + content: string | undefined; + memoryStrategyId: string; + namespaces: string[]; + createdAt: string; + score: number | undefined; + metadata: Record; +} + +export interface MemoryRecordsResponse { + success: boolean; + records?: MemoryRecordResponse[]; + nextToken?: string; + error?: string; +} + +// --------------------------------------------------------------------------- +// POST /api/mcp +// --------------------------------------------------------------------------- + +export interface McpProxyRequest { + agentName?: string; + targetName?: string; + body?: Record; + sessionId?: string; +} + +export interface McpProxyResponse { + success: true; + result: unknown; + sessionId?: string; +} + +// --------------------------------------------------------------------------- +// GET /api/a2a/agent-card?agentName=xxx +// --------------------------------------------------------------------------- + +export interface A2AAgentCardResponse { + success: true; + card: unknown; +} + +// --------------------------------------------------------------------------- +// Harness invocation (POST /invocations with harnessName) + +// POST /api/harness/tool-response +// --------------------------------------------------------------------------- + +/** + * Overrides forwarded verbatim to the harness invocation. The reference typed + * `model`, `skills`, and `tools` with AWS SDK shapes; the inspector only passes + * them through, so they stay opaque here. + */ +export interface HarnessInvocationOverrides { + model?: unknown; + systemPrompt?: string; + skills?: unknown[]; + actorId?: string; + maxIterations?: number; + maxTokens?: number; + timeoutSeconds?: number; + allowedTools?: string[]; + tools?: unknown[]; +} + +export interface HarnessMessage { + role: string; + content: Record[]; +} + +export interface HarnessToolResponseRequest { + harnessName: string; + sessionId: string; + messages: HarnessMessage[]; + harnessOverrides?: HarnessInvocationOverrides; +} + +// --------------------------------------------------------------------------- +// GET /api/resources +// --------------------------------------------------------------------------- + +export type ResourceDeploymentStatus = "deployed" | "local-only" | "pending-removal"; + +export interface DeployedAgentState { + runtimeId: string; + runtimeArn: string; + roleArn: string; +} + +export interface DeployedMemoryState { + memoryId: string; + memoryArn: string; +} + +export interface DeployedCredentialState { + credentialProviderArn: string; + clientSecretArn?: string; + callbackUrl?: string; +} + +export interface DeployedGatewayState { + gatewayId: string; + gatewayArn: string; + gatewayUrl?: string; +} + +export interface DeployedHarnessState { + harnessId: string; + harnessArn: string; +} + +export interface ResourcesResponse { + success: true; + project: string; + agents: ResourceAgent[]; + harnesses: ResourceHarness[]; + memories: ResourceMemory[]; + credentials: ResourceCredential[]; + gateways: ResourceGateway[]; + mcpRuntimeTools: ResourceMcpTool[]; + evaluators: ResourceEvaluator[]; + onlineEvalConfigs: ResourceOnlineEvalConfig[]; + policyEngines: ResourcePolicyEngine[]; + unassignedTargets: ResourceUnassignedTarget[]; + deploymentTargets: ResourceDeploymentTarget[]; +} + +export interface ResourceDeploymentTarget { + name: string; + region: string; + description?: string; +} + +export interface ResourceAgent { + name: string; + build: string; + entrypoint: string; + codeLocation: string; + runtimeVersion: string; + networkMode: string; + protocol: string; + envVars: string[]; + deploymentStatus?: ResourceDeploymentStatus; + deployed?: DeployedAgentState; + invocationUrl?: string; +} + +export interface ResourceHarness { + name: string; + /** @deprecated The reference derived this from the harness spec's model. */ + model: string; + tools: string[]; + deploymentStatus?: ResourceDeploymentStatus; + deployed?: DeployedHarnessState; +} + +export interface ResourceMemory { + name: string; + strategies: ResourceMemoryStrategy[]; + expiryDays: number | undefined; + deploymentStatus?: ResourceDeploymentStatus; + deployed?: DeployedMemoryState; +} + +export interface ResourceMemoryStrategy { + type: string; + /** Namespace templates, e.g. "/users/{actorId}/facts". */ + namespaceTemplates: string[]; +} + +export interface ResourceCredential { + name: string; + type: string; + deploymentStatus?: ResourceDeploymentStatus; + deployed?: DeployedCredentialState; +} + +export interface ResourceGateway { + name: string; + targets: ResourceGatewayTarget[]; + deploymentStatus?: ResourceDeploymentStatus; + deployed?: DeployedGatewayState; +} + +export interface ResourceGatewayTarget { + name: string; + targetType: string; +} + +export interface ResourceMcpTool { + name: string; + bindings: ResourceMcpToolBinding[]; + deploymentStatus?: ResourceDeploymentStatus; +} + +export interface ResourceMcpToolBinding { + runtimeName: string; + envVarName: string; +} + +export interface ResourceEvaluator { + name: string; + level: string; + description?: string; + configType: "llm-as-a-judge" | "code-based"; + deploymentStatus?: ResourceDeploymentStatus; +} + +export interface ResourceOnlineEvalConfig { + name: string; + agent?: string; + evaluators?: string[]; + insights?: string[]; + samplingRate: number; + description?: string; + logGroupNames?: string[]; + serviceNames?: string[]; + deploymentStatus?: ResourceDeploymentStatus; +} + +export interface ResourcePolicyEngine { + name: string; + description?: string; + policies: ResourcePolicy[]; + deploymentStatus?: ResourceDeploymentStatus; +} + +export interface ResourcePolicy { + name: string; + description?: string; + deploymentStatus?: ResourceDeploymentStatus; +} + +export interface ResourceUnassignedTarget { + name: string; + targetType: string; +} + +// --------------------------------------------------------------------------- +// Common error response (used by all endpoints on failure) +// --------------------------------------------------------------------------- + +export interface ApiErrorResponse { + success: false; + error: string; +} diff --git a/src/core/dev/inspector/aws.ts b/src/core/dev/inspector/aws.ts new file mode 100644 index 000000000..506ec600d --- /dev/null +++ b/src/core/dev/inspector/aws.ts @@ -0,0 +1,151 @@ +/** + * AWS-backed read routes: GET /api/memory, POST /api/memory/search, and + * GET /api/cloudwatch-traces[/:traceId]. Each answers 404 + * `{success:false, error:'... not available'}` when its capability was not + * injected, so the SPA degrades gracefully. Ported from the reference + * memory.ts / cloudwatch-traces.ts handlers. + */ +import type { HttpRequest, HttpResponse } from "../../../io/httpServer"; +import type { ListMemoryRecordsQuery, RetrieveMemoryRecordsRequest } from "./api"; +import { asString } from "./invocations"; +import { apiError, json, parseJsonBody, parseTimeParam } from "./respond"; +import type { InspectorDeps } from "./types"; + +const TRACE_ID_PATTERN = /^[a-fA-F0-9-]+$/; + +/** GET /api/memory?memoryName=xxx&(namespace=yyy|namespacePath=yyy)[&strategyId=zzz] */ +export async function handleListMemoryRecords( + deps: InspectorDeps, + url: URL, +): Promise { + const memory = deps.aws?.memory; + if (!memory) return apiError(404, "Memory browsing is not available"); + + const memoryName = url.searchParams.get("memoryName") ?? undefined; + const namespace = url.searchParams.get("namespace") ?? undefined; + const namespacePath = url.searchParams.get("namespacePath") ?? undefined; + const strategyId = url.searchParams.get("strategyId") ?? undefined; + + if (!memoryName) return apiError(400, "memoryName query parameter is required"); + if (namespace && namespacePath) { + return apiError(400, "'namespace' and 'namespacePath' query parameters are mutually exclusive"); + } + if (!namespace && !namespacePath) { + return apiError(400, "either 'namespace' or 'namespacePath' query parameter is required"); + } + + const query: ListMemoryRecordsQuery = { + memoryName, + strategyId, + ...(namespace ? { namespace } : { namespacePath: namespacePath! }), + }; + try { + const result = await memory.list(query); + return json(result.success ? 200 : 500, result); + } catch { + return apiError(500, "Failed to list memory records"); + } +} + +/** POST /api/memory/search — semantic search across memory records. */ +export async function handleRetrieveMemoryRecords( + deps: InspectorDeps, + request: HttpRequest, +): Promise { + const memory = deps.aws?.memory; + if (!memory) return apiError(404, "Memory search is not available"); + + const parsed = parseJsonBody(request.body); + const memoryName = asString(parsed?.memoryName); + const namespace = asString(parsed?.namespace); + const namespacePath = asString(parsed?.namespacePath); + const searchQuery = asString(parsed?.searchQuery); + const strategyId = asString(parsed?.strategyId); + + if (!memoryName) return apiError(400, "memoryName is required"); + if (namespace && namespacePath) { + return apiError(400, "'namespace' and 'namespacePath' request fields are mutually exclusive"); + } + if (!namespace && !namespacePath) { + return apiError(400, "either 'namespace' or 'namespacePath' request field is required"); + } + if (!searchQuery) return apiError(400, "searchQuery is required"); + + const searchRequest: RetrieveMemoryRecordsRequest = { + memoryName, + searchQuery, + strategyId, + ...(namespace ? { namespace } : { namespacePath: namespacePath! }), + }; + try { + const result = await memory.search(searchRequest); + return json(result.success ? 200 : 500, result); + } catch { + return apiError(500, "Failed to search memory records"); + } +} + +/** GET /api/cloudwatch-traces?agentName=xxx|harnessName=xxx — list recent traces. */ +export async function handleListCloudWatchTraces( + deps: InspectorDeps, + url: URL, +): Promise { + const cloudwatch = deps.aws?.cloudwatchTraces; + if (!cloudwatch) return apiError(404, "CloudWatch traces are not available"); + + const selector = parseTraceSelector(url); + if ("error" in selector) return selector.error; + + try { + const result = await cloudwatch.list(selector); + return json(result.success ? 200 : 500, result); + } catch { + return apiError(500, "Failed to list CloudWatch traces"); + } +} + +/** GET /api/cloudwatch-traces/:traceId?agentName=xxx|harnessName=xxx — full trace data. */ +export async function handleGetCloudWatchTrace( + deps: InspectorDeps, + url: URL, +): Promise { + const cloudwatch = deps.aws?.cloudwatchTraces; + if (!cloudwatch) return apiError(404, "CloudWatch traces are not available"); + + const traceId = url.pathname.replace("/api/cloudwatch-traces/", ""); + if (!traceId) return apiError(400, "traceId is required in the URL path"); + if (!TRACE_ID_PATTERN.test(traceId)) return apiError(400, "Invalid trace ID format"); + + const selector = parseTraceSelector(url); + if ("error" in selector) return selector.error; + + try { + const result = await cloudwatch.get({ ...selector, traceId }); + return json(result.success ? 200 : 500, result); + } catch { + return apiError(500, "Failed to get CloudWatch trace"); + } +} + +function parseTraceSelector( + url: URL, +): + | { agentName?: string; harnessName?: string; startTime?: number; endTime?: number } + | { error: HttpResponse } { + const agentName = url.searchParams.get("agentName") ?? undefined; + const harnessName = url.searchParams.get("harnessName") ?? undefined; + + if (!agentName && !harnessName) { + return { error: apiError(400, "Either agentName or harnessName query parameter is required") }; + } + if (agentName && harnessName) { + return { error: apiError(400, "Provide either agentName or harnessName, not both") }; + } + + const startTime = parseTimeParam(url, "startTime"); + if (startTime.error) return { error: startTime.error }; + const endTime = parseTimeParam(url, "endTime"); + if (endTime.error) return { error: endTime.error }; + + return { agentName, harnessName, startTime: startTime.value, endTime: endTime.value }; +} diff --git a/src/core/dev/inspector/harness.ts b/src/core/dev/inspector/harness.ts new file mode 100644 index 000000000..3d2eea751 --- /dev/null +++ b/src/core/dev/inspector/harness.ts @@ -0,0 +1,89 @@ +/** + * Deployed-harness invocation routes: POST /invocations with a harnessName in + * the body, and POST /api/harness/tool-response. Both stream harness events + * back as `data: ` SSE frames, per the reference harness handlers. + */ +import { randomUUID } from "node:crypto"; +import type { HttpRequest, HttpResponse } from "../../../io/httpServer"; +import type { HarnessInvocationOverrides, HarnessMessage } from "./api"; +import { asString } from "./invocations"; +import { apiError, errorMessage, parseJsonBody, sse, sseEvent } from "./respond"; +import type { InspectorDeps, InspectorHarness } from "./types"; + +/** POST /invocations with `harnessName` — invoke a deployed harness with one user prompt. */ +export async function handleHarnessInvocation( + deps: InspectorDeps, + body: Record, +): Promise { + const harness = deps.aws?.harness; + if (!harness) return apiError(404, "Harness invocation is not available"); + + const harnessName = asString(body.harnessName); + if (!harnessName) return apiError(400, "harnessName is required"); + const prompt = asString(body.prompt); + if (!prompt) return apiError(400, "prompt is required"); + + const sessionId = asString(body.sessionId) || randomUUID(); + return invokeHarness(harness, { + harnessName, + sessionId, + messages: [{ role: "user", content: [{ text: prompt }] }], + userId: asString(body.userId), + overrides: body.harnessOverrides as HarnessInvocationOverrides | undefined, + }); +} + +/** POST /api/harness/tool-response — continue a harness session with tool results. */ +export async function handleHarnessToolResponse( + deps: InspectorDeps, + request: HttpRequest, +): Promise { + const harness = deps.aws?.harness; + if (!harness) return apiError(404, "Harness invocation is not available"); + + const raw = parseJsonBody(request.body); + if (!raw) return apiError(400, "Invalid JSON"); + const harnessName = asString(raw.harnessName); + if (!harnessName) return apiError(400, "harnessName is required"); + if (!Array.isArray(raw.messages)) return apiError(400, "messages array is required"); + const sessionId = asString(raw.sessionId); + if (!sessionId) return apiError(400, "sessionId is required"); + + return invokeHarness(harness, { + harnessName, + sessionId, + messages: raw.messages as HarnessMessage[], + overrides: raw.harnessOverrides as HarnessInvocationOverrides | undefined, + }); +} + +async function invokeHarness( + harness: InspectorHarness, + request: { + harnessName: string; + sessionId: string; + messages: HarnessMessage[]; + userId?: string; + overrides?: HarnessInvocationOverrides; + }, +): Promise { + let stream: AsyncIterable; + try { + stream = await harness.invoke(request); + } catch (error) { + // Rejected before any event streamed (e.g. unknown harness): a JSON error + // is still possible, matching the reference's pre-stream 404. + return apiError(404, errorMessage(error)); + } + return sse(frameHarnessStream(stream), request.sessionId); +} + +async function* frameHarnessStream( + stream: AsyncIterable, +): AsyncGenerator { + try { + for await (const event of stream) yield sseEvent(event); + } catch (error) { + yield sseEvent({ type: "error", errorType: "invocationError", message: errorMessage(error) }); + } +} diff --git a/src/core/dev/inspector/invocations.test.ts b/src/core/dev/inspector/invocations.test.ts new file mode 100644 index 000000000..35783a57e --- /dev/null +++ b/src/core/dev/inspector/invocations.test.ts @@ -0,0 +1,385 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import type { HttpRequest } from "../../../io/httpServer"; +import { ServerFarm, fakeSupervisor, post, runningAgent, sseBody } from "./testkit"; +import type { InspectorDeps } from "./types"; + +const farm = new ServerFarm(); +afterEach(() => farm.close()); + +/** An inspector whose "orders" agent is the given stub server. */ +async function inspectorWithAgent( + agent: { port: number }, + protocol = "HTTP", + extra: Partial = {}, +) { + const supervisor = fakeSupervisor({ agents: [runningAgent("orders", agent.port, protocol)] }); + return farm.inspector({ supervisor, ...extra }); +} + +describe("POST /invocations (HTTP agents)", () => { + test("proxies to the agent and normalizes SSE events to plain text", async () => { + const seen: HttpRequest[] = []; + const agent = await farm.serve((request) => { + seen.push(request); + return { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + body: sseBody([ + '{"text":"Hel"}', + '{"event":{"contentBlockDelta":{"delta":{"text":"lo"}}}}', + ]), + }; + }); + const { url } = await inspectorWithAgent(agent); + + const response = await post(url, "/invocations", { + agentName: "orders", + prompt: "hi", + sessionId: "session-1", + userId: "user-1", + }); + + expect(response.status).toBe(200); + expect(response.headers.get("x-session-id")).toBe("session-1"); + expect(await response.text()).toBe('data: "Hel"\n\ndata: "lo"\n\n'); + expect(seen[0]?.url).toBe("/invocations"); + expect(seen[0]?.headers["x-amzn-bedrock-agentcore-runtime-session-id"]).toBe("session-1"); + expect(seen[0]?.headers["x-amzn-bedrock-agentcore-runtime-user-id"]).toBe("user-1"); + expect(seen[0]?.body.toString()).toContain('"prompt":"hi"'); + }); + + test("SSE error events are forwarded as error frames", async () => { + const agent = await farm.serve(() => ({ + status: 200, + headers: { "Content-Type": "text/event-stream" }, + body: sseBody(['{"error":"model exploded"}']), + })); + const { url } = await inspectorWithAgent(agent); + const response = await post(url, "/invocations", { agentName: "orders", prompt: "hi" }); + expect(await response.text()).toBe('data: {"error":"model exploded"}\n\n'); + }); + + test("non-SSE responses pass through untouched", async () => { + const agent = await farm.serve(() => ({ + status: 200, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ result: "done" }), + })); + const { url } = await inspectorWithAgent(agent); + const response = await post(url, "/invocations", { agentName: "orders", prompt: "hi" }); + expect(response.headers.get("content-type")).toBe("application/json"); + expect(await response.json()).toEqual({ result: "done" }); + }); + + test("falls back to the first running agent when agentName is absent", async () => { + const agent = await farm.serve(() => ({ + status: 200, + headers: { "Content-Type": "application/json" }, + body: '{"ok":true}', + })); + const { url } = await inspectorWithAgent(agent); + const response = await post(url, "/invocations", { prompt: "hi" }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true }); + }); + + test("answers 409 when no agent is running", async () => { + const { url } = await farm.inspector({ supervisor: fakeSupervisor() }); + const response = await post(url, "/invocations", { prompt: "hi" }); + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ + success: false, + error: "No agent is running. Call POST /api/start first.", + }); + }); + + test("answers 502 when the agent connection fails", async () => { + // Nothing listens on port 1, so the proxy target refuses connections. + const { url } = await inspectorWithAgent({ port: 1 }); + const response = await post(url, "/invocations", { agentName: "orders", prompt: "hi" }); + expect(response.status).toBe(502); + expect(((await response.json()) as { error: string }).error).toStartWith("Agent server error:"); + }); +}); + +describe("POST /invocations (A2A agents)", () => { + test("translates the prompt to JSON-RPC message/stream and reduces events to text", async () => { + const seen: HttpRequest[] = []; + const agent = await farm.serve((request) => { + seen.push(request); + return { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + body: sseBody([ + JSON.stringify({ + result: { + kind: "status-update", + status: { state: "working", message: { parts: [{ kind: "text", text: "Hel" }] } }, + }, + }), + JSON.stringify({ + result: { + kind: "artifact-update", + artifact: { parts: [{ kind: "text", text: "Hello (final)" }] }, + }, + }), + ]), + }; + }); + const { url } = await inspectorWithAgent(agent, "A2A"); + + const response = await post(url, "/invocations", { + agentName: "orders", + prompt: "hi", + sessionId: "ctx-1", + }); + + expect(response.status).toBe(200); + expect(response.headers.get("x-session-id")).toBe("ctx-1"); + // The artifact-update is skipped because text already streamed via status-update. + expect(await response.text()).toBe('data: "Hel"\n\n'); + const rpc = JSON.parse(seen[0]!.body.toString()) as { + method: string; + params: { message: { contextId?: string; parts: { kind: string; text: string }[] } }; + }; + expect(seen[0]?.url).toBe("/"); + expect(rpc.method).toBe("message/stream"); + expect(rpc.params.message.contextId).toBe("ctx-1"); + expect(rpc.params.message.parts).toEqual([{ kind: "text", text: "hi" }]); + }); + + test("non-streaming JSON-RPC results become a single SSE text event", async () => { + const agent = await farm.serve(() => ({ + status: 200, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + result: { artifacts: [{ parts: [{ kind: "text", text: "answer" }] }] }, + }), + })); + const { url } = await inspectorWithAgent(agent, "A2A"); + const response = await post(url, "/invocations", { agentName: "orders", prompt: "hi" }); + expect(response.headers.get("content-type")).toBe("text/event-stream"); + expect(await response.text()).toBe('data: "answer"\n\n'); + }); + + test("requires a prompt", async () => { + const agent = await farm.serve(() => ({ status: 200 })); + const { url } = await inspectorWithAgent(agent, "A2A"); + const response = await post(url, "/invocations", { agentName: "orders" }); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ success: false, error: "prompt is required" }); + }); +}); + +describe("POST /invocations (AGUI agents)", () => { + test("translates the prompt to RunAgentInput and passes the SSE through raw", async () => { + const seen: HttpRequest[] = []; + const agent = await farm.serve((request) => { + seen.push(request); + return { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + body: sseBody(['{"type":"TEXT_MESSAGE_CONTENT","delta":"raw"}']), + }; + }); + const { url } = await inspectorWithAgent(agent, "AGUI"); + + const response = await post(url, "/invocations", { + agentName: "orders", + prompt: "hi", + sessionId: "thread-1", + }); + + expect(response.status).toBe(200); + expect(response.headers.get("x-session-id")).toBe("thread-1"); + expect(await response.text()).toBe('data: {"type":"TEXT_MESSAGE_CONTENT","delta":"raw"}\n\n'); + const input = JSON.parse(seen[0]!.body.toString()) as { + threadId: string; + messages: { role: string; content: string }[]; + tools: unknown[]; + }; + expect(seen[0]?.url).toBe("/invocations"); + expect(input.threadId).toBe("thread-1"); + expect(input.messages).toMatchObject([{ role: "user", content: "hi" }]); + expect(input.tools).toEqual([]); + }); +}); + +describe("POST /invocations?target=deployed", () => { + test("streams the deployed invocation as SSE data frames", async () => { + const calls: unknown[] = []; + const { url } = await farm.inspector({ + supervisor: fakeSupervisor(), + aws: { + invokeDeployed: async (request) => { + calls.push(request); + return { + sessionId: "aws-session", + stream: (async function* () { + yield "Hel"; + yield { text: "lo" }; + })(), + }; + }, + }, + }); + + const response = await post(url, "/invocations?target=deployed", { + agentName: "orders", + prompt: "hi", + sessionId: "session-1", + }); + + expect(response.status).toBe(200); + expect(response.headers.get("x-session-id")).toBe("aws-session"); + expect(await response.text()).toBe('data: "Hel"\n\ndata: {"text":"lo"}\n\n'); + expect(calls).toEqual([ + { + agentName: "orders", + targetName: undefined, + prompt: "hi", + sessionId: "session-1", + userId: undefined, + }, + ]); + }); + + test("a stream failure becomes an SSE error frame", async () => { + const { url } = await farm.inspector({ + supervisor: fakeSupervisor(), + aws: { + invokeDeployed: async () => ({ + stream: (async function* () { + yield "partial"; + throw new Error("throttled"); + })(), + }), + }, + }); + const response = await post(url, "/invocations?target=deployed", { prompt: "hi" }); + expect(await response.text()).toBe('data: "partial"\n\ndata: {"error":"throttled"}\n\n'); + }); + + test("answers 404 when deployed invocation is not wired", async () => { + const { url } = await farm.inspector({ supervisor: fakeSupervisor() }); + const response = await post(url, "/invocations?target=deployed", { prompt: "hi" }); + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ + success: false, + error: "Deployed invocation is not available", + }); + }); +}); + +describe("harness invocations", () => { + const harnessDeps = (events: unknown[] = [{ type: "text", text: "hi" }]) => { + const calls: unknown[] = []; + const deps: InspectorDeps = { + supervisor: fakeSupervisor(), + aws: { + harness: { + invoke: async (request) => { + calls.push(request); + if (request.harnessName === "ghost") throw new Error('Harness "ghost" not found'); + return (async function* () { + yield* events; + })(); + }, + }, + }, + }; + return { deps, calls }; + }; + + test("a harnessName in the /invocations body routes to the harness and streams SSE", async () => { + const { deps, calls } = harnessDeps(); + const { url } = await farm.inspector(deps); + const response = await post(url, "/invocations", { + harnessName: "support", + prompt: "help", + sessionId: "hs-1", + }); + expect(response.status).toBe(200); + expect(response.headers.get("x-session-id")).toBe("hs-1"); + expect(await response.text()).toBe('data: {"type":"text","text":"hi"}\n\n'); + expect(calls).toMatchObject([ + { + harnessName: "support", + sessionId: "hs-1", + messages: [{ role: "user", content: [{ text: "help" }] }], + }, + ]); + }); + + test("an unknown harness yields a 404 error envelope", async () => { + const { deps } = harnessDeps(); + const { url } = await farm.inspector(deps); + const response = await post(url, "/invocations", { harnessName: "ghost", prompt: "help" }); + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ success: false, error: 'Harness "ghost" not found' }); + }); + + test("harness invocation requires a prompt", async () => { + const { deps } = harnessDeps(); + const { url } = await farm.inspector(deps); + const response = await post(url, "/invocations", { harnessName: "support" }); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ success: false, error: "prompt is required" }); + }); + + test("answers 404 when harness invocation is not wired", async () => { + const { url } = await farm.inspector({ supervisor: fakeSupervisor() }); + const response = await post(url, "/invocations", { harnessName: "support", prompt: "help" }); + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ + success: false, + error: "Harness invocation is not available", + }); + }); + + test("POST /api/harness/tool-response continues the session with tool results", async () => { + const { deps, calls } = harnessDeps([{ type: "done" }]); + const { url } = await farm.inspector(deps); + const messages = [{ role: "user", content: [{ toolResult: { ok: true } }] }]; + const response = await post(url, "/api/harness/tool-response", { + harnessName: "support", + sessionId: "hs-1", + messages, + }); + expect(response.status).toBe(200); + expect(await response.text()).toBe('data: {"type":"done"}\n\n'); + expect(calls).toMatchObject([{ harnessName: "support", sessionId: "hs-1", messages }]); + }); + + test("tool-response requires a sessionId", async () => { + const { deps } = harnessDeps(); + const { url } = await farm.inspector(deps); + const response = await post(url, "/api/harness/tool-response", { + harnessName: "support", + messages: [], + }); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ success: false, error: "sessionId is required" }); + }); + + test("a harness stream failure becomes an SSE error event", async () => { + const deps: InspectorDeps = { + supervisor: fakeSupervisor(), + aws: { + harness: { + invoke: async () => + (async function* () { + yield { type: "text", text: "partial" }; + throw new Error("stream cut"); + })(), + }, + }, + }; + const { url } = await farm.inspector(deps); + const response = await post(url, "/invocations", { harnessName: "support", prompt: "help" }); + expect(await response.text()).toBe( + 'data: {"type":"text","text":"partial"}\n\n' + + 'data: {"type":"error","errorType":"invocationError","message":"stream cut"}\n\n', + ); + }); +}); diff --git a/src/core/dev/inspector/invocations.ts b/src/core/dev/inspector/invocations.ts new file mode 100644 index 000000000..069f6eebe --- /dev/null +++ b/src/core/dev/inspector/invocations.ts @@ -0,0 +1,371 @@ +/** + * POST /invocations — the protocol-aware proxy to a locally running agent, a + * deployed runtime (?target=deployed), or a deployed harness (harnessName in + * the body). Ported from the reference web-ui/handlers/invocations.ts; the SSE + * framing (`data: \n\n`) is part of the SPA wire contract. + */ +import { randomUUID } from "node:crypto"; +import type { HttpRequest, HttpResponse } from "../../../io/httpServer"; +import { handleHarnessInvocation } from "./harness"; +import { + apiError, + errorMessage, + iterateBody, + lines, + parseJsonBody, + sse, + sseEvent, +} from "./respond"; +import type { DeployedInvocation, InspectorDeps } from "./types"; + +export async function handleInvocations( + deps: InspectorDeps, + request: HttpRequest, + url: URL, + nextA2aId: () => number, +): Promise { + if (url.searchParams.get("target") === "deployed") { + return handleDeployedInvocation(deps, request); + } + + const parsed = parseJsonBody(request.body); + if (parsed?.harnessName) return handleHarnessInvocation(deps, parsed); + + const agentName = asString(parsed?.agentName); + const sessionId = parsed ? (asString(parsed.sessionId) ?? randomUUID()) : undefined; + const userId = asString(parsed?.userId); + + // Route to the named agent, falling back to the first running one. + let running = agentName ? deps.supervisor.running(agentName) : undefined; + if (!running) { + const first = deps.supervisor.snapshot().find((agent) => agent.phase === "running"); + if (first) running = deps.supervisor.running(first.name); + } + if (!running) return apiError(409, "No agent is running. Call POST /api/start first."); + + if (running.protocol === "A2A") { + return invokeA2aAgent(running.port, parsed, sessionId, nextA2aId); + } + if (running.protocol === "AGUI") { + return invokeAguiAgent(running.port, parsed, sessionId, userId); + } + return invokeHttpAgent(running.port, request.body, sessionId, userId); +} + +/** Forward to the agent's /invocations route, normalizing SSE events to plain text. */ +async function invokeHttpAgent( + port: number, + body: Buffer, + sessionId: string | undefined, + userId: string | undefined, +): Promise { + const headers: Record = { + "Content-Type": "application/json", + Accept: "text/event-stream, */*", + "x-amzn-bedrock-agentcore-runtime-session-id": sessionId ?? randomUUID(), + }; + if (userId) headers["x-amzn-bedrock-agentcore-runtime-user-id"] = userId; + + let agentResponse: Response; + try { + agentResponse = await fetch(`http://127.0.0.1:${port}/invocations`, { + method: "POST", + headers, + body: body.toString(), + }); + } catch (error) { + return apiError(502, `Agent server error: ${errorMessage(error)}`); + } + + const contentType = agentResponse.headers.get("content-type") ?? "text/plain"; + const responseHeaders: Record = { + "Content-Type": contentType, + ...(sessionId !== undefined && { "x-session-id": sessionId }), + }; + const stream = iterateBody(agentResponse.body); + return { + status: agentResponse.status, + headers: responseHeaders, + body: contentType.includes("text/event-stream") ? transformAgentSse(stream) : stream, + }; +} + +/** Re-frame each agent SSE line through {@link parseSseLine}, like the reference pipeSSETransformed. */ +async function* transformAgentSse( + stream: AsyncIterable, +): AsyncGenerator { + for await (const line of lines(stream)) { + const { content, error } = parseSseLine(line); + if (error) yield sseEvent({ error }); + else if (content) yield sseEvent(content); + } +} + +/** + * Extract displayable text from one agent SSE line. Handles plain strings, + * `{"text": ...}` events from the bedrock-agentcore runtime, `{"error": ...}` + * events, and ConverseStream-shaped content deltas. + */ +export function parseSseLine(line: string): { content: string | null; error: string | null } { + if (!line.startsWith("data: ")) return { content: null, error: null }; + const raw = line.slice(6); + try { + const parsed: unknown = JSON.parse(raw); + if (typeof parsed === "string") return { content: parsed, error: null }; + if (parsed && typeof parsed === "object") { + if ("error" in parsed) { + return { content: null, error: String((parsed as { error: unknown }).error) }; + } + if ("text" in parsed) { + return { content: String((parsed as { text: unknown }).text), error: null }; + } + } + const event = (parsed as { event?: { contentBlockDelta?: { delta?: { text?: string } } } }) + ?.event; + const text = event?.contentBlockDelta?.delta?.text; + if (typeof text === "string") return { content: text, error: null }; + } catch { + return { content: raw, error: null }; + } + return { content: null, error: null }; +} + +/** + * A2A agents speak JSON-RPC at their root path: translate the SPA's + * `{ prompt }` payload into `message/stream` and reduce the A2A event stream + * to the `data: "text"` frames the SPA's chat expects. + */ +async function invokeA2aAgent( + port: number, + body: Record | undefined, + sessionId: string | undefined, + nextA2aId: () => number, +): Promise { + const prompt = asString(body?.prompt); + if (!prompt) return apiError(400, "prompt is required"); + + const a2aBody = { + jsonrpc: "2.0", + id: nextA2aId(), + method: "message/stream", + params: { + message: { + messageId: randomUUID(), + role: "user", + parts: [{ kind: "text", text: prompt }], + ...(sessionId !== undefined && { contextId: sessionId }), + }, + }, + }; + + let agentResponse: Response; + try { + agentResponse = await fetch(`http://127.0.0.1:${port}/`, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "text/event-stream" }, + body: JSON.stringify(a2aBody), + }); + } catch (error) { + return apiError(502, `A2A agent error: ${errorMessage(error)}`); + } + if (!agentResponse.ok) return apiError(502, `A2A agent returned ${agentResponse.status}`); + + const contentType = agentResponse.headers.get("content-type") ?? ""; + if (contentType.includes("text/event-stream") && agentResponse.body) { + return sse(transformA2aSse(iterateBody(agentResponse.body)), sessionId); + } + + // Non-streaming fallback: extract text from the JSON-RPC result. + const responseText = await agentResponse.text(); + try { + const parsed = JSON.parse(responseText) as Record; + const result = parsed.result as Record | undefined; + const text = result + ? (extractTaskText(result) ?? JSON.stringify(result, null, 2)) + : responseText; + return { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + body: Buffer.from(sseEvent(text)), + }; + } catch { + return { status: 200, headers: { "Content-Type": "text/plain" }, body: responseText }; + } +} + +async function* transformA2aSse( + stream: AsyncIterable, +): AsyncGenerator { + let streamedFromStatus = false; + for await (const line of lines(stream)) { + if (!line.startsWith("data: ")) continue; + const data = line.slice(6).trim(); + if (!data) continue; + try { + const event = JSON.parse(data) as Record; + const text = extractSseEventText(event, streamedFromStatus); + if (text) { + if (isStatusUpdateEvent(event)) streamedFromStatus = true; + yield sseEvent(text); + } + } catch { + yield sseEvent(data); + } + } +} + +function isStatusUpdateEvent(event: Record): boolean { + const target = (event.result as Record) ?? event; + return target.kind === "status-update"; +} + +/** + * Extract displayable text from an A2A SSE event (artifact-update or + * status-update, optionally wrapped in a JSON-RPC result envelope). When + * `streamedFromStatus` is set, artifact-update text is skipped because the + * same content already streamed incrementally via status-update events. + */ +function extractSseEventText( + event: Record, + streamedFromStatus: boolean, +): string | null { + const target = (event.result as Record) ?? event; + const kind = target.kind as string | undefined; + + if (kind === "artifact-update") { + if (streamedFromStatus) return null; + const artifact = target.artifact as { parts?: A2aPart[] } | undefined; + return extractPartsText(artifact?.parts); + } + + if (kind === "status-update") { + const status = target.status as { message?: { parts?: A2aPart[] } } | undefined; + if (status?.message?.parts) return extractPartsText(status.message.parts); + return null; + } + + return extractTaskText(target); +} + +/** Extract text from a full A2A Task result (artifacts array and/or status message). */ +function extractTaskText(result: Record): string | null { + const artifacts = result.artifacts as { parts?: A2aPart[] }[] | undefined; + if (artifacts) { + const texts: string[] = []; + for (const artifact of artifacts) { + const text = extractPartsText(artifact.parts); + if (text) texts.push(text); + } + if (texts.length > 0) return texts.join("\n"); + } + + const status = result.status as { message?: { parts?: A2aPart[] } } | undefined; + if (status?.message?.parts) return extractPartsText(status.message.parts); + return null; +} + +type A2aPart = { kind?: string; type?: string; text?: string }; + +function extractPartsText(parts: A2aPart[] | undefined): string | null { + if (!parts) return null; + const texts: string[] = []; + for (const part of parts) { + if ((part.kind === "text" || part.type === "text") && part.text) texts.push(part.text); + } + return texts.length > 0 ? texts.join("") : null; +} + +/** + * AGUI agents expect a RunAgentInput body; translate the SPA's `{ prompt }` + * payload and pass the typed AG-UI SSE response through untouched. + */ +async function invokeAguiAgent( + port: number, + body: Record | undefined, + sessionId: string | undefined, + userId: string | undefined, +): Promise { + const prompt = asString(body?.prompt); + if (!prompt) return apiError(400, "prompt is required"); + + const aguiBody = JSON.stringify({ + threadId: sessionId ?? randomUUID(), + runId: randomUUID(), + messages: [{ id: randomUUID(), role: "user", content: prompt }], + tools: [], + context: [], + state: {}, + forwardedProps: {}, + }); + + const headers: Record = { + "Content-Type": "application/json", + Accept: "text/event-stream", + }; + if (sessionId) headers["x-amzn-bedrock-agentcore-runtime-session-id"] = sessionId; + if (userId) headers["x-amzn-bedrock-agentcore-runtime-user-id"] = userId; + + let agentResponse: Response; + try { + agentResponse = await fetch(`http://127.0.0.1:${port}/invocations`, { + method: "POST", + headers, + body: aguiBody, + }); + } catch (error) { + return apiError(502, `AGUI agent error: ${errorMessage(error)}`); + } + + return { + status: agentResponse.status, + headers: { + "Content-Type": agentResponse.headers.get("content-type") ?? "text/plain", + ...(sessionId !== undefined && { "x-session-id": sessionId }), + }, + body: iterateBody(agentResponse.body), + }; +} + +/** Invoke a deployed runtime via the injected AWS capability, SSE-framing its stream. */ +async function handleDeployedInvocation( + deps: InspectorDeps, + request: HttpRequest, +): Promise { + const aws = deps.aws; + if (!aws?.invokeDeployed) return apiError(404, "Deployed invocation is not available"); + + const parsed = parseJsonBody(request.body); + if (!parsed) return apiError(400, "Invalid JSON body"); + const prompt = asString(parsed.prompt); + if (!prompt) return apiError(400, "prompt is required"); + + const sessionId = asString(parsed.sessionId) ?? randomUUID(); + let invocation: DeployedInvocation; + try { + invocation = await aws.invokeDeployed({ + agentName: asString(parsed.agentName), + targetName: asString(parsed.targetName), + prompt, + sessionId, + userId: asString(parsed.userId), + }); + } catch (error) { + return apiError(502, `Invoke failed: ${errorMessage(error)}`); + } + + return sse(frameDeployedStream(invocation.stream), invocation.sessionId ?? sessionId); +} + +async function* frameDeployedStream( + stream: AsyncIterable, +): AsyncGenerator { + try { + for await (const chunk of stream) yield sseEvent(chunk); + } catch (error) { + yield sseEvent({ error: errorMessage(error) }); + } +} + +export function asString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} diff --git a/src/core/dev/inspector/proxies.test.ts b/src/core/dev/inspector/proxies.test.ts new file mode 100644 index 000000000..33336c7bd --- /dev/null +++ b/src/core/dev/inspector/proxies.test.ts @@ -0,0 +1,383 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import type { HttpRequest } from "../../../io/httpServer"; +import { ServerFarm, fakeSupervisor, get, post, runningAgent } from "./testkit"; +import type { InspectorDeps } from "./types"; + +const farm = new ServerFarm(); +afterEach(() => farm.close()); + +describe("POST /api/mcp (local)", () => { + test("forwards JSON-RPC to the agent's /mcp endpoint and passes the session id both ways", async () => { + const seen: HttpRequest[] = []; + const agent = await farm.serve((request) => { + seen.push(request); + return { + status: 200, + headers: { "Content-Type": "application/json", "Mcp-Session-Id": "mcp-2" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, result: { tools: [] } }), + }; + }); + const supervisor = fakeSupervisor({ agents: [runningAgent("orders", agent.port, "MCP")] }); + const { url } = await farm.inspector({ supervisor }); + + const response = await post(url, "/api/mcp", { + agentName: "orders", + sessionId: "mcp-1", + body: { jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }, + }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + success: true, + result: { jsonrpc: "2.0", id: 1, result: { tools: [] } }, + sessionId: "mcp-2", + }); + expect(seen[0]?.url).toBe("/mcp"); + expect(seen[0]?.headers["mcp-session-id"]).toBe("mcp-1"); + expect(JSON.parse(seen[0]!.body.toString())).toEqual({ + jsonrpc: "2.0", + id: 1, + method: "tools/list", + params: {}, + }); + }); + + test("requires agentName and body", async () => { + const { url } = await farm.inspector({ supervisor: fakeSupervisor() }); + expect(await (await post(url, "/api/mcp", { body: { method: "initialize" } })).json()).toEqual({ + success: false, + error: "agentName is required", + }); + expect(await (await post(url, "/api/mcp", { agentName: "orders" })).json()).toEqual({ + success: false, + error: "body is required", + }); + }); + + test("answers 400 when the agent is not running", async () => { + const { url } = await farm.inspector({ supervisor: fakeSupervisor() }); + const response = await post(url, "/api/mcp", { agentName: "orders", body: { method: "x" } }); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + success: false, + error: 'Agent "orders" is not running', + }); + }); +}); + +describe("POST /api/mcp?target=deployed", () => { + const mcpDeps = () => { + const calls: [string, unknown][] = []; + const deps: InspectorDeps = { + supervisor: fakeSupervisor(), + aws: { + mcp: { + initialize: async (args) => { + calls.push(["initialize", args]); + return { sessionId: "deployed-1" }; + }, + listTools: async (args) => { + calls.push(["listTools", args]); + return { tools: [{ name: "search" }] }; + }, + callTool: async (args) => { + calls.push(["callTool", args]); + return "tool output"; + }, + }, + }, + }; + return { deps, calls }; + }; + + test("initialize returns the deployed MCP session id", async () => { + const { deps, calls } = mcpDeps(); + const { url } = await farm.inspector(deps); + const response = await post(url, "/api/mcp?target=deployed", { + agentName: "orders", + body: { jsonrpc: "2.0", method: "initialize" }, + }); + expect(await response.json()).toEqual({ + success: true, + result: { jsonrpc: "2.0", result: {} }, + sessionId: "deployed-1", + }); + expect(calls).toEqual([ + ["initialize", { agentName: "orders", targetName: undefined, sessionId: undefined }], + ]); + }); + + test("tools/list and tools/call wrap the results in JSON-RPC envelopes", async () => { + const { deps } = mcpDeps(); + const { url } = await farm.inspector(deps); + + const list = await post(url, "/api/mcp?target=deployed", { + body: { method: "tools/list" }, + }); + expect(await list.json()).toEqual({ + success: true, + result: { jsonrpc: "2.0", result: { tools: [{ name: "search" }] } }, + }); + + const call = await post(url, "/api/mcp?target=deployed", { + body: { method: "tools/call", params: { name: "search", arguments: { q: "x" } } }, + }); + expect(await call.json()).toEqual({ + success: true, + result: { + jsonrpc: "2.0", + result: { content: [{ type: "text", text: "tool output" }] }, + }, + }); + }); + + test("rejects unsupported methods and missing tool names", async () => { + const { deps } = mcpDeps(); + const { url } = await farm.inspector(deps); + expect( + await (await post(url, "/api/mcp?target=deployed", { body: { method: "ping" } })).json(), + ).toEqual({ success: false, error: "Unsupported MCP method: ping" }); + expect( + await ( + await post(url, "/api/mcp?target=deployed", { body: { method: "tools/call", params: {} } }) + ).json(), + ).toEqual({ success: false, error: "tools/call requires params.name" }); + }); + + test("answers 404 when deployed MCP is not wired", async () => { + const { url } = await farm.inspector({ supervisor: fakeSupervisor() }); + const response = await post(url, "/api/mcp?target=deployed", { + body: { method: "initialize" }, + }); + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ + success: false, + error: "Deployed MCP is not available", + }); + }); +}); + +describe("GET /api/a2a/agent-card", () => { + test("fetches the card from the running agent's well-known route", async () => { + const agent = await farm.serve((request) => + request.url === "/.well-known/agent.json" + ? { + status: 200, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "orders", version: "1.0" }), + } + : { status: 404 }, + ); + const supervisor = fakeSupervisor({ agents: [runningAgent("orders", agent.port, "A2A")] }); + const { url } = await farm.inspector({ supervisor }); + + const response = await get(url, "/api/a2a/agent-card?agentName=orders"); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + success: true, + card: { name: "orders", version: "1.0" }, + }); + }); + + test("requires agentName and a running agent", async () => { + const { url } = await farm.inspector({ supervisor: fakeSupervisor() }); + expect(await (await get(url, "/api/a2a/agent-card")).json()).toEqual({ + success: false, + error: "agentName query parameter is required", + }); + expect(await (await get(url, "/api/a2a/agent-card?agentName=orders")).json()).toEqual({ + success: false, + error: 'Agent "orders" is not running', + }); + }); +}); + +describe("memory routes", () => { + const memoryDeps = () => { + const calls: [string, unknown][] = []; + const deps: InspectorDeps = { + supervisor: fakeSupervisor(), + aws: { + memory: { + list: async (args) => { + calls.push(["list", args]); + return { success: true, records: [] }; + }, + search: async (args) => { + calls.push(["search", args]); + return { success: true, records: [] }; + }, + }, + }, + }; + return { deps, calls }; + }; + + test("GET /api/memory lists records for a namespace", async () => { + const { deps, calls } = memoryDeps(); + const { url } = await farm.inspector(deps); + const response = await get(url, "/api/memory?memoryName=recall&namespace=/users/u1/facts"); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ success: true, records: [] }); + expect(calls).toEqual([ + ["list", { memoryName: "recall", strategyId: undefined, namespace: "/users/u1/facts" }], + ]); + }); + + test("GET /api/memory validates the namespace selector", async () => { + const { deps } = memoryDeps(); + const { url } = await farm.inspector(deps); + expect(await (await get(url, "/api/memory?namespace=/x")).json()).toEqual({ + success: false, + error: "memoryName query parameter is required", + }); + expect( + await (await get(url, "/api/memory?memoryName=recall&namespace=/x&namespacePath=/y")).json(), + ).toEqual({ + success: false, + error: "'namespace' and 'namespacePath' query parameters are mutually exclusive", + }); + expect(await (await get(url, "/api/memory?memoryName=recall")).json()).toEqual({ + success: false, + error: "either 'namespace' or 'namespacePath' query parameter is required", + }); + }); + + test("POST /api/memory/search runs a semantic search", async () => { + const { deps, calls } = memoryDeps(); + const { url } = await farm.inspector(deps); + const response = await post(url, "/api/memory/search", { + memoryName: "recall", + namespacePath: "/users", + searchQuery: "favorite color", + }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ success: true, records: [] }); + expect(calls).toEqual([ + [ + "search", + { + memoryName: "recall", + searchQuery: "favorite color", + strategyId: undefined, + namespacePath: "/users", + }, + ], + ]); + }); + + test("POST /api/memory/search requires a searchQuery", async () => { + const { deps } = memoryDeps(); + const { url } = await farm.inspector(deps); + const response = await post(url, "/api/memory/search", { + memoryName: "recall", + namespace: "/users/u1/facts", + }); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ success: false, error: "searchQuery is required" }); + }); + + test("memory routes answer 404 when the capability is not wired", async () => { + const { url } = await farm.inspector({ supervisor: fakeSupervisor() }); + expect((await get(url, "/api/memory?memoryName=recall&namespace=/x")).status).toBe(404); + expect(await (await get(url, "/api/memory?memoryName=recall&namespace=/x")).json()).toEqual({ + success: false, + error: "Memory browsing is not available", + }); + expect( + await ( + await post(url, "/api/memory/search", { + memoryName: "recall", + namespace: "/x", + searchQuery: "q", + }) + ).json(), + ).toEqual({ success: false, error: "Memory search is not available" }); + }); +}); + +describe("cloudwatch trace routes", () => { + const cloudwatchDeps = () => { + const calls: [string, unknown][] = []; + const deps: InspectorDeps = { + supervisor: fakeSupervisor(), + aws: { + cloudwatchTraces: { + list: async (args) => { + calls.push(["list", args]); + return { success: true, traces: [{ traceId: "abc123", timestamp: "t" }] }; + }, + get: async (args) => { + calls.push(["get", args]); + return { success: true, records: [], spans: [] }; + }, + }, + }, + }; + return { deps, calls }; + }; + + test("lists traces for an agent with a time range", async () => { + const { deps, calls } = cloudwatchDeps(); + const { url } = await farm.inspector(deps); + const response = await get(url, "/api/cloudwatch-traces?agentName=orders&startTime=5"); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + success: true, + traces: [{ traceId: "abc123", timestamp: "t" }], + }); + expect(calls).toEqual([ + ["list", { agentName: "orders", harnessName: undefined, startTime: 5, endTime: undefined }], + ]); + }); + + test("requires exactly one of agentName or harnessName", async () => { + const { deps } = cloudwatchDeps(); + const { url } = await farm.inspector(deps); + expect(await (await get(url, "/api/cloudwatch-traces")).json()).toEqual({ + success: false, + error: "Either agentName or harnessName query parameter is required", + }); + expect( + await (await get(url, "/api/cloudwatch-traces?agentName=a&harnessName=h")).json(), + ).toEqual({ success: false, error: "Provide either agentName or harnessName, not both" }); + }); + + test("gets one trace by id", async () => { + const { deps, calls } = cloudwatchDeps(); + const { url } = await farm.inspector(deps); + const response = await get(url, "/api/cloudwatch-traces/1-abc-DEF?harnessName=support"); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ success: true, records: [], spans: [] }); + expect(calls).toEqual([ + [ + "get", + { + agentName: undefined, + harnessName: "support", + startTime: undefined, + endTime: undefined, + traceId: "1-abc-DEF", + }, + ], + ]); + }); + + test("rejects a non-hex trace id", async () => { + const { deps } = cloudwatchDeps(); + const { url } = await farm.inspector(deps); + const response = await get(url, "/api/cloudwatch-traces/..%2Fetc?agentName=orders"); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ success: false, error: "Invalid trace ID format" }); + }); + + test("answers 404 when the capability is not wired", async () => { + const { url } = await farm.inspector({ supervisor: fakeSupervisor() }); + const response = await get(url, "/api/cloudwatch-traces?agentName=orders"); + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ + success: false, + error: "CloudWatch traces are not available", + }); + }); +}); diff --git a/src/core/dev/inspector/proxies.ts b/src/core/dev/inspector/proxies.ts new file mode 100644 index 000000000..5abecb310 --- /dev/null +++ b/src/core/dev/inspector/proxies.ts @@ -0,0 +1,124 @@ +/** + * Thin proxies to a locally running agent: POST /api/mcp (JSON-RPC forward, + * with a deployed variant behind the injected AWS capability) and + * GET /api/a2a/agent-card. Ported from the reference mcp-proxy.ts / a2a-proxy.ts. + */ +import type { HttpRequest, HttpResponse } from "../../../io/httpServer"; +import { asString } from "./invocations"; +import { apiError, errorMessage, json, parseJsonBody } from "./respond"; +import type { InspectorDeps } from "./types"; + +/** POST /api/mcp — forward a JSON-RPC body to the agent's /mcp endpoint. */ +export async function handleMcpProxy( + deps: InspectorDeps, + request: HttpRequest, + url: URL, +): Promise { + if (url.searchParams.get("target") === "deployed") return handleDeployedMcp(deps, request); + + const parsed = parseJsonBody(request.body); + if (!parsed) return apiError(400, "Invalid JSON"); + const agentName = asString(parsed.agentName); + if (!agentName) return apiError(400, "agentName is required"); + const body = parsed.body; + if (!body || typeof body !== "object") return apiError(400, "body is required"); + const sessionId = asString(parsed.sessionId); + + const running = deps.supervisor.running(agentName); + if (!running) return apiError(400, `Agent "${agentName}" is not running`); + + let mcpResponse: Response; + try { + mcpResponse = await fetch(`http://127.0.0.1:${running.port}/mcp`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + ...(sessionId !== undefined && { "mcp-session-id": sessionId }), + }, + body: JSON.stringify(body), + }); + } catch (error) { + return apiError(502, `Failed to connect to MCP agent: ${errorMessage(error)}`); + } + if (!mcpResponse.ok) return apiError(502, `MCP server returned status ${mcpResponse.status}`); + + const responseText = await mcpResponse.text(); + const responseSessionId = mcpResponse.headers.get("mcp-session-id") ?? undefined; + let result: unknown; + try { + result = JSON.parse(responseText); + } catch { + result = responseText; + } + return json(200, { success: true, result, sessionId: responseSessionId }); +} + +/** POST /api/mcp?target=deployed — MCP JSON-RPC against a deployed runtime. */ +async function handleDeployedMcp(deps: InspectorDeps, request: HttpRequest): Promise { + const mcp = deps.aws?.mcp; + if (!mcp) return apiError(404, "Deployed MCP is not available"); + + const parsed = parseJsonBody(request.body); + if (!parsed) return apiError(400, "Invalid JSON"); + const body = parsed.body as Record | undefined; + if (!body || typeof body !== "object") return apiError(400, "body is required"); + + const target = { + agentName: asString(parsed.agentName), + targetName: asString(parsed.targetName), + sessionId: asString(parsed.sessionId), + }; + const method = asString(body.method); + + try { + if (method === "initialize") { + const { sessionId } = await mcp.initialize(target); + return json(200, { success: true, result: { jsonrpc: "2.0", result: {} }, sessionId }); + } + if (method === "tools/list") { + const { tools } = await mcp.listTools(target); + return json(200, { success: true, result: { jsonrpc: "2.0", result: { tools } } }); + } + if (method === "tools/call") { + const params = body.params as + { name?: string; arguments?: Record } | undefined; + if (!params?.name) return apiError(400, "tools/call requires params.name"); + const response = await mcp.callTool({ + ...target, + name: params.name, + arguments: params.arguments ?? {}, + }); + return json(200, { + success: true, + result: { jsonrpc: "2.0", result: { content: [{ type: "text", text: response }] } }, + }); + } + return apiError(400, `Unsupported MCP method: ${method}`); + } catch (error) { + return apiError(502, `MCP invoke failed: ${errorMessage(error)}`); + } +} + +/** GET /api/a2a/agent-card?agentName=xxx — fetch the running agent's A2A card. */ +export async function handleA2aAgentCard(deps: InspectorDeps, url: URL): Promise { + const agentName = url.searchParams.get("agentName") ?? undefined; + if (!agentName) return apiError(400, "agentName query parameter is required"); + + const running = deps.supervisor.running(agentName); + if (!running) return apiError(400, `Agent "${agentName}" is not running`); + + try { + const cardResponse = await fetch(`http://127.0.0.1:${running.port}/.well-known/agent.json`, { + method: "GET", + headers: { Accept: "application/json" }, + }); + if (!cardResponse.ok) { + return apiError(502, `Agent card not available (${cardResponse.status})`); + } + const card: unknown = await cardResponse.json(); + return json(200, { success: true, card }); + } catch (error) { + return apiError(502, `Failed to fetch agent card: ${errorMessage(error)}`); + } +} diff --git a/src/core/dev/inspector/resources.ts b/src/core/dev/inspector/resources.ts new file mode 100644 index 000000000..c8c66938a --- /dev/null +++ b/src/core/dev/inspector/resources.ts @@ -0,0 +1,201 @@ +/** + * GET /api/resources — the project resource graph the SPA's resources panel + * renders, built from the resolved project spec. When a deployed-state + * capability is injected, each resource gains a deploymentStatus and its + * deployed identifiers, including pending-removal entries that exist only in + * the deployed state. Resource types the reference exposed but this project + * schema lacks (aws-targets regions, per-harness model/tool specs) are emitted + * with their neutral defaults so the wire shape stays intact. + */ +import type { HttpResponse } from "../../../io/httpServer"; +import type { + ResourceAgent, + ResourceCredential, + ResourceGateway, + ResourceHarness, + ResourceMemory, + ResourcesResponse, +} from "./api"; +import { apiError, json } from "./respond"; +import type { InspectorDeps, InspectorDeployedResources } from "./types"; + +export async function handleResources(deps: InspectorDeps): Promise { + const project = deps.project; + if (!project) return apiError(404, "No agentcore project found"); + + try { + const deployed = deps.aws?.deployedState ? await deps.aws.deployedState() : undefined; + return json(200, buildResourcesResponse(project.name, project.spec, deployed)); + } catch { + return apiError(500, "Failed to read project configuration"); + } +} + +type Spec = NonNullable["spec"]; + +function buildResourcesResponse( + projectName: string, + spec: Spec, + deployed: InspectorDeployedResources | undefined, +): ResourcesResponse { + // Only report statuses when deployed state is known; otherwise omit them. + const status = deployed + ? (entry: unknown) => (entry ? ("deployed" as const) : ("local-only" as const)) + : () => undefined; + + const agents: ResourceAgent[] = spec.runtimes.map((runtime) => { + const deployedAgent = deployed?.runtimes?.[runtime.name]; + return { + name: runtime.name, + build: runtime.build, + entrypoint: runtime.entrypoint, + codeLocation: runtime.codeLocation, + runtimeVersion: runtime.runtimeVersion ?? "", + networkMode: runtime.networkMode ?? "PUBLIC", + protocol: runtime.protocol ?? "HTTP", + envVars: runtime.envVars?.map((envVar) => envVar.name) ?? [], + deploymentStatus: status(deployedAgent), + deployed: deployedAgent, + }; + }); + for (const [name, deployedAgent] of removedEntries(deployed?.runtimes, agents)) { + agents.push({ + name, + build: "", + entrypoint: "", + codeLocation: "", + runtimeVersion: "", + networkMode: "", + protocol: "", + envVars: [], + deploymentStatus: "pending-removal", + deployed: deployedAgent, + }); + } + + // Harness registry entries carry only name + path; model/tool details lived + // in per-harness spec files the reference read separately. + const harnesses: ResourceHarness[] = spec.harnesses.map((harness) => ({ + name: harness.name, + model: "", + tools: [], + deploymentStatus: status(deployed?.harnesses?.[harness.name]), + deployed: deployed?.harnesses?.[harness.name], + })); + for (const [name, deployedHarness] of removedEntries(deployed?.harnesses, harnesses)) { + harnesses.push({ + name, + model: "", + tools: [], + deploymentStatus: "pending-removal", + deployed: deployedHarness, + }); + } + + const memories: ResourceMemory[] = spec.memories.map((memory) => ({ + name: memory.name, + strategies: memory.strategies.map((strategy) => ({ + type: strategy.type, + namespaceTemplates: strategy.namespaceTemplates ?? strategy.namespaces ?? [], + })), + expiryDays: memory.eventExpiryDuration, + deploymentStatus: status(deployed?.memories?.[memory.name]), + deployed: deployed?.memories?.[memory.name], + })); + for (const [name, deployedMemory] of removedEntries(deployed?.memories, memories)) { + memories.push({ + name, + strategies: [], + expiryDays: undefined, + deploymentStatus: "pending-removal", + deployed: deployedMemory, + }); + } + + const credentials: ResourceCredential[] = spec.credentials.map((credential) => ({ + name: credential.name, + type: credential.authorizerType, + deploymentStatus: status(deployed?.credentials?.[credential.name]), + deployed: deployed?.credentials?.[credential.name], + })); + for (const [name, deployedCredential] of removedEntries(deployed?.credentials, credentials)) { + credentials.push({ + name, + type: "", + deploymentStatus: "pending-removal", + deployed: deployedCredential, + }); + } + + const gateways: ResourceGateway[] = spec.agentCoreGateways.map((gateway) => ({ + name: gateway.name, + targets: gateway.targets.map((target) => ({ + name: target.toolDefinitions?.[0]?.name ?? target.name, + targetType: target.targetType, + })), + deploymentStatus: status(deployed?.gateways?.[gateway.name]), + deployed: deployed?.gateways?.[gateway.name], + })); + for (const [name, deployedGateway] of removedEntries(deployed?.gateways, gateways)) { + gateways.push({ + name, + targets: [], + deploymentStatus: "pending-removal", + deployed: deployedGateway, + }); + } + + return { + success: true, + project: projectName, + agents, + harnesses, + memories, + credentials, + gateways, + mcpRuntimeTools: (spec.mcpRuntimeTools ?? []).map((tool) => ({ + name: tool.name, + bindings: tool.bindings ?? [], + })), + evaluators: spec.evaluators.map((evaluator) => ({ + name: evaluator.name, + level: evaluator.level, + description: evaluator.description, + configType: evaluator.config.codeBased ? "code-based" : "llm-as-a-judge", + })), + onlineEvalConfigs: spec.onlineEvalConfigs.map((config) => ({ + name: config.name, + agent: config.agent, + evaluators: config.evaluators, + insights: config.insights, + samplingRate: config.samplingRate, + description: config.description, + logGroupNames: config.logGroupNames, + serviceNames: config.serviceNames, + })), + policyEngines: spec.policyEngines.map((engine) => ({ + name: engine.name, + description: engine.description, + policies: engine.policies.map((policy) => ({ + name: policy.name, + description: policy.description, + })), + })), + unassignedTargets: (spec.unassignedTargets ?? []).map((target) => ({ + name: target.name, + targetType: target.targetType, + })), + // The project schema has no aws-targets equivalent yet. + deploymentTargets: [], + }; +} + +/** Deployed entries whose names no longer exist in the local spec. */ +function removedEntries( + deployedByName: Record | undefined, + local: { name: string }[], +): [string, T][] { + if (!deployedByName) return []; + const localNames = new Set(local.map((entry) => entry.name)); + return Object.entries(deployedByName).filter(([name]) => !localNames.has(name)); +} diff --git a/src/core/dev/inspector/respond.ts b/src/core/dev/inspector/respond.ts new file mode 100644 index 000000000..b4ff8af6b --- /dev/null +++ b/src/core/dev/inspector/respond.ts @@ -0,0 +1,99 @@ +/** Small response and parsing helpers shared by the Inspector route modules. */ +import type { HttpResponse } from "../../../io/httpServer"; + +export function json(status: number, body: unknown): HttpResponse { + return { + status, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }; +} + +export function apiError(status: number, error: string): HttpResponse { + return json(status, { success: false, error }); +} + +/** An SSE response streaming `body`, optionally echoing the session id back. */ +export function sse(body: AsyncIterable, sessionId?: string): HttpResponse { + return { + status: 200, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + ...(sessionId !== undefined && { "x-session-id": sessionId }), + }, + body, + }; +} + +/** One SSE event in the `data: \n\n` framing every Inspector stream uses. */ +export function sseEvent(payload: unknown): Uint8Array { + return new TextEncoder().encode(`data: ${JSON.stringify(payload)}\n\n`); +} + +/** Parse a JSON request body, or undefined when it is not valid JSON. */ +export function parseJsonBody(body: Buffer): Record | undefined { + try { + const parsed: unknown = JSON.parse(body.toString()); + if (parsed && typeof parsed === "object") return parsed as Record; + } catch { + // fall through + } + return undefined; +} + +/** + * Parse an optional epoch-milliseconds query parameter. Returns the value (or + * undefined when absent) or an error response matching the reference wording. + */ +export function parseTimeParam( + url: URL, + name: string, +): { value: number | undefined; error?: HttpResponse } { + const raw = url.searchParams.get(name); + if (raw === null) return { value: undefined }; + const value = Number(raw); + if (Number.isNaN(value)) { + return { + value: undefined, + error: apiError(400, `${name} must be a number (epoch milliseconds)`), + }; + } + return { value }; +} + +/** Iterate a fetch response body; a null body yields nothing. */ +export async function* iterateBody( + stream: ReadableStream | null, +): AsyncGenerator { + if (!stream) return; + const reader = stream.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) return; + yield value; + } + } finally { + reader.releaseLock(); + } +} + +/** Split a byte stream into text lines, flushing any trailing partial line. */ +export async function* lines(stream: AsyncIterable): AsyncGenerator { + const decoder = new TextDecoder(); + let buffer = ""; + for await (const chunk of stream) { + buffer += decoder.decode(chunk, { stream: true }); + const parts = buffer.split("\n"); + buffer = parts.pop() ?? ""; + yield* parts; + } + buffer += decoder.decode(); + if (buffer) yield buffer; +} + +export function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/core/dev/inspector/server.test.ts b/src/core/dev/inspector/server.test.ts new file mode 100644 index 000000000..ca5b1ccb5 --- /dev/null +++ b/src/core/dev/inspector/server.test.ts @@ -0,0 +1,334 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import type { Project } from "../../../handlers/project/types"; +import { ProjectSpecSchema } from "../../../projectSchemas/project"; +import { ServerFarm, fakeAssets, fakeSupervisor, get, post, runningAgent } from "./testkit"; +import type { InspectorDeps } from "./types"; + +const farm = new ServerFarm(); +afterEach(() => farm.close()); + +function deps(overrides: Partial = {}): InspectorDeps { + return { supervisor: fakeSupervisor(), ...overrides }; +} + +function project(): Project { + return { + name: "Demo", + rootPath: "/workspace/demo", + spec: ProjectSpecSchema.parse({ + name: "Demo", + version: 1, + managedBy: "CDK", + runtimes: [{ name: "orders", build: "CodeZip", entrypoint: "main.py", codeLocation: "app" }], + memories: [{ name: "recall", eventExpiryDuration: 30, strategies: [{ type: "SEMANTIC" }] }], + harnesses: [{ name: "support", path: "harness/support" }], + }), + }; +} + +describe("createInspectorHandler security", () => { + test("rejects requests with a non-loopback Host header", async () => { + const { url } = await farm.inspector(deps()); + const response = await get(url, "/api/status", { Host: "evil.example.com" }); + expect(response.status).toBe(403); + expect(await response.text()).toBe("Forbidden"); + }); + + test("rejects cross-origin requests from origins outside the allowlist", async () => { + const { url } = await farm.inspector(deps()); + const response = await get(url, "/api/status", { Origin: "https://evil.example.com" }); + expect(response.status).toBe(403); + }); + + test("allows the Vite dev-server origin and echoes it in CORS headers", async () => { + const { url } = await farm.inspector(deps()); + const response = await get(url, "/api/status", { Origin: "http://localhost:5173" }); + expect(response.status).toBe(200); + expect(response.headers.get("access-control-allow-origin")).toBe("http://localhost:5173"); + expect(response.headers.get("access-control-expose-headers")).toBe( + "Mcp-Session-Id, x-session-id", + ); + }); + + test("answers CORS preflight with 204 and the allow headers", async () => { + const { url } = await farm.inspector(deps()); + const response = await fetch(`${url}/api/start`, { + method: "OPTIONS", + headers: { Origin: "http://localhost:5173" }, + }); + expect(response.status).toBe(204); + expect(response.headers.get("access-control-allow-methods")).toBe("GET, POST, OPTIONS"); + expect(response.headers.get("access-control-allow-headers")).toBe( + "Content-Type, X-Agentcore-Local, Mcp-Session-Id", + ); + }); + + test("rejects POSTs missing the X-Agentcore-Local header", async () => { + const { url } = await farm.inspector(deps()); + const response = await fetch(`${url}/api/start`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ agentName: "orders" }), + }); + expect(response.status).toBe(403); + expect(await response.text()).toBe("Forbidden: missing X-Agentcore-Local header"); + }); +}); + +describe("GET /api/status", () => { + test("reports agents, running ports, errors, and harness names", async () => { + const supervisor = fakeSupervisor({ + agents: [ + runningAgent("orders", 9001), + { + name: "billing", + buildType: "Container", + protocol: "MCP", + phase: "failed", + error: "boom", + }, + ], + }); + const { url } = await farm.inspector( + deps({ supervisor, project: project(), selectedAgent: "orders" }), + ); + + const response = await get(url, "/api/status"); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + mode: "dev", + agents: [ + { name: "orders", buildType: "CodeZip", protocol: "HTTP" }, + { name: "billing", buildType: "Container", protocol: "MCP" }, + ], + harnesses: [{ name: "support" }], + running: [{ name: "orders", port: 9001 }], + errors: [{ name: "billing", message: "boom" }], + selectedAgent: "orders", + }); + }); +}); + +describe("POST /api/start", () => { + test("starts the agent and returns its port", async () => { + const started: string[] = []; + const supervisor = fakeSupervisor({ + start: async (name) => { + started.push(name); + return { name, port: 9101 }; + }, + }); + const { url } = await farm.inspector(deps({ supervisor })); + + const response = await post(url, "/api/start", { agentName: "orders" }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ success: true, name: "orders", port: 9101 }); + expect(started).toEqual(["orders"]); + }); + + test("an unknown agent yields a 404 error envelope", async () => { + const { url } = await farm.inspector(deps()); + const response = await post(url, "/api/start", { agentName: "ghost" }); + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ + success: false, + name: "ghost", + port: 0, + error: "Agent 'ghost' was not found.", + }); + }); + + test("a startup failure yields a 500 error envelope", async () => { + const supervisor = fakeSupervisor({ + start: () => Promise.reject(new Error("port exhausted")), + }); + const { url } = await farm.inspector(deps({ supervisor })); + const response = await post(url, "/api/start", { agentName: "orders" }); + expect(response.status).toBe(500); + expect(await response.json()).toMatchObject({ success: false, error: "port exhausted" }); + }); + + test("requires agentName", async () => { + const { url } = await farm.inspector(deps()); + const response = await post(url, "/api/start", {}); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ success: false, error: "agentName is required" }); + }); +}); + +describe("GET /api/traces", () => { + const traces = () => { + const calls: unknown[] = []; + return { + calls, + source: { + list: async (options?: unknown) => { + calls.push(options); + return [{ traceId: "abc", spanCount: "2" }]; + }, + get: async (traceId: string) => + traceId === "abc" ? { resourceSpans: [{ spans: [] }], resourceLogs: [] } : undefined, + }, + }; + }; + + test("lists traces filtered by agentName and time range", async () => { + const fake = traces(); + const { url } = await farm.inspector(deps({ traces: fake.source })); + const response = await get(url, "/api/traces?agentName=orders&startTime=100&endTime=200"); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + success: true, + traces: [{ traceId: "abc", spanCount: "2" }], + }); + expect(fake.calls).toEqual([{ serviceName: "orders", startTime: 100, endTime: 200 }]); + }); + + test("rejects a non-numeric startTime", async () => { + const { url } = await farm.inspector(deps({ traces: traces().source })); + const response = await get(url, "/api/traces?startTime=yesterday"); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + success: false, + error: "startTime must be a number (epoch milliseconds)", + }); + }); + + test("returns one trace's spans and logs by id", async () => { + const { url } = await farm.inspector(deps({ traces: traces().source })); + const response = await get(url, "/api/traces/abc"); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + success: true, + resourceSpans: [{ spans: [] }], + resourceLogs: [], + }); + }); + + test("an unknown trace id yields 404", async () => { + const { url } = await farm.inspector(deps({ traces: traces().source })); + const response = await get(url, "/api/traces/missing"); + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ success: false, error: "Trace not found" }); + }); + + test("answers 404 when the trace source is absent", async () => { + const { url } = await farm.inspector(deps()); + const response = await get(url, "/api/traces"); + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ success: false, error: "Traces are not available" }); + }); +}); + +describe("GET /api/resources", () => { + test("builds the resource graph from the project spec", async () => { + const { url } = await farm.inspector(deps({ project: project() })); + const response = await get(url, "/api/resources"); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + success: true, + project: "Demo", + agents: [ + { + name: "orders", + build: "CodeZip", + entrypoint: "main.py", + codeLocation: "app", + runtimeVersion: "", + networkMode: "PUBLIC", + protocol: "HTTP", + envVars: [], + }, + ], + harnesses: [{ name: "support", model: "", tools: [] }], + memories: [ + { + name: "recall", + strategies: [{ type: "SEMANTIC", namespaceTemplates: [] }], + expiryDays: 30, + }, + ], + credentials: [], + gateways: [], + mcpRuntimeTools: [], + evaluators: [], + onlineEvalConfigs: [], + policyEngines: [], + unassignedTargets: [], + deploymentTargets: [], + }); + }); + + test("merges deployed state and reports pending removals", async () => { + const { url } = await farm.inspector( + deps({ + project: project(), + aws: { + deployedState: async () => ({ + runtimes: { + orders: { runtimeId: "r-1", runtimeArn: "arn:r-1", roleArn: "arn:role" }, + legacy: { runtimeId: "r-9", runtimeArn: "arn:r-9", roleArn: "arn:role" }, + }, + }), + }, + }), + ); + + const body = (await (await get(url, "/api/resources")).json()) as { + agents: { name: string; deploymentStatus?: string; deployed?: { runtimeId: string } }[]; + memories: { deploymentStatus?: string }[]; + }; + expect(body.agents).toMatchObject([ + { name: "orders", deploymentStatus: "deployed", deployed: { runtimeId: "r-1" } }, + { name: "legacy", deploymentStatus: "pending-removal", deployed: { runtimeId: "r-9" } }, + ]); + expect(body.memories[0]?.deploymentStatus).toBe("local-only"); + }); + + test("answers 404 outside a project", async () => { + const { url } = await farm.inspector(deps()); + const response = await get(url, "/api/resources"); + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ + success: false, + error: "No agentcore project found", + }); + }); +}); + +describe("static SPA serving", () => { + const assets = () => + fakeAssets({ + "/index.html": { body: "spa", contentType: "text/html" }, + "/assets/app.js": { body: "console.log(1)", contentType: "application/javascript" }, + }); + + test("serves an existing asset with its content type", async () => { + const { url } = await farm.inspector(deps({ assets: assets() })); + const response = await get(url, "/assets/app.js"); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("application/javascript"); + expect(await response.text()).toBe("console.log(1)"); + }); + + test("falls back to index.html for client-side routes, with a CSP header", async () => { + const { url } = await farm.inspector(deps({ assets: assets() })); + const response = await get(url, "/chat/session-1"); + expect(response.status).toBe(200); + expect(await response.text()).toBe("spa"); + expect(response.headers.get("content-security-policy")).toContain("script-src 'self'"); + }); + + test("unknown /api paths get a JSON 404 instead of the SPA", async () => { + const { url } = await farm.inspector(deps({ assets: assets() })); + const response = await get(url, "/api/nope"); + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ success: false, error: "Not Found" }); + }); + + test("404s when no assets are available", async () => { + const { url } = await farm.inspector(deps()); + const response = await get(url, "/"); + expect(response.status).toBe(404); + }); +}); diff --git a/src/core/dev/inspector/server.ts b/src/core/dev/inspector/server.ts new file mode 100644 index 000000000..2cf92ae0d --- /dev/null +++ b/src/core/dev/inspector/server.ts @@ -0,0 +1,218 @@ +/** + * The Agent Inspector request handler: a pure request → response function the + * dev command composes with io/startHttpServer. It ports the reference + * WebUIServer's security model (DNS-rebinding Host check, server-side origin + * allowlist, X-Agentcore-Local on POSTs, CORS preflight) and routing, serving + * the prebuilt SPA plus the /api and /invocations routes the SPA calls. + */ +import { ResourceNotFoundError } from "../../../errors"; +import type { HttpRequest, HttpRequestHandler, HttpResponse } from "../../../io/httpServer"; +import type { StatusResponse } from "./api"; +import { + handleGetCloudWatchTrace, + handleListCloudWatchTraces, + handleListMemoryRecords, + handleRetrieveMemoryRecords, +} from "./aws"; +import { handleHarnessToolResponse } from "./harness"; +import { asString, handleInvocations } from "./invocations"; +import { handleA2aAgentCard, handleMcpProxy } from "./proxies"; +import { handleResources } from "./resources"; +import { apiError, errorMessage, json, parseJsonBody, parseTimeParam } from "./respond"; +import type { InspectorDeps } from "./types"; + +/** Origins the Vite dev server uses for the frontend HMR workflow. */ +const DEV_SERVER_ORIGINS = ["http://localhost:5173", "http://127.0.0.1:5173"]; + +/** CSP for served HTML, blocking inline-script injection from agent responses. */ +const CSP_HEADER = + "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; font-src 'self' data:"; + +export function createInspectorHandler(deps: InspectorDeps): HttpRequestHandler { + // JSON-RPC ids for A2A message/stream requests, monotonic per handler. + let a2aRequestId = 1; + const nextA2aId = () => a2aRequestId++; + + return async (request) => { + // DNS rebinding protection — a custom domain resolving to 127.0.0.1 would + // bypass origin checks, so only loopback Host headers are accepted. + const host = request.headers.host ?? ""; + const hostname = host.replace(/:\d+$/, ""); + if (hostname !== "localhost" && hostname !== "127.0.0.1") { + return forbidden("Forbidden"); + } + + // Server-side origin validation — CORS headers alone only stop the browser + // from reading responses; the request's side effects (starting agents, + // invoking with AWS credentials) must be blocked before any handler runs. + const origin = asString(request.headers.origin); + const allowedOrigins = [`http://${host}`, ...DEV_SERVER_ORIGINS]; + if (origin && !allowedOrigins.includes(origin)) return forbidden("Forbidden"); + const cors = corsHeaders(origin, allowedOrigins); + + if (request.method === "OPTIONS") return { status: 204, headers: cors }; + + // Require a custom header on all POSTs: it forces a CORS preflight (which + // the origin check blocks cross-origin), closing the simple-form-POST gap. + if (request.method === "POST" && !request.headers["x-agentcore-local"]) { + return withHeaders(forbidden("Forbidden: missing X-Agentcore-Local header"), cors); + } + + const response = await route(deps, request, nextA2aId); + return withHeaders(response, cors); + }; +} + +async function route( + deps: InspectorDeps, + request: HttpRequest, + nextA2aId: () => number, +): Promise { + const url = new URL(request.url, "http://localhost"); + const { pathname } = url; + const { method } = request; + + if (method === "GET" && pathname === "/api/status") return handleStatus(deps); + if (method === "GET" && pathname === "/api/resources") return handleResources(deps); + if (method === "GET" && pathname.startsWith("/api/traces/")) return handleGetTrace(deps, url); + if (method === "GET" && pathname.startsWith("/api/traces")) return handleListTraces(deps, url); + if (method === "GET" && pathname.startsWith("/api/cloudwatch-traces/")) { + return handleGetCloudWatchTrace(deps, url); + } + if (method === "GET" && pathname.startsWith("/api/cloudwatch-traces")) { + return handleListCloudWatchTraces(deps, url); + } + if (method === "POST" && pathname === "/api/start") return handleStart(deps, request); + if (method === "POST" && pathname === "/api/harness/tool-response") { + return handleHarnessToolResponse(deps, request); + } + if (method === "POST" && pathname === "/invocations") { + return handleInvocations(deps, request, url, nextA2aId); + } + if (method === "POST" && pathname === "/api/mcp") return handleMcpProxy(deps, request, url); + if (method === "GET" && pathname.startsWith("/api/a2a/agent-card")) { + return handleA2aAgentCard(deps, url); + } + if (method === "POST" && pathname === "/api/memory/search") { + return handleRetrieveMemoryRecords(deps, request); + } + if (method === "GET" && pathname.startsWith("/api/memory")) { + return handleListMemoryRecords(deps, url); + } + if (method === "GET" && !pathname.startsWith("/api/")) { + const asset = await serveAsset(deps, pathname); + if (asset) return asset; + } + return apiError(404, "Not Found"); +} + +/** GET /api/status — available agents, running ports, and per-agent errors. */ +function handleStatus(deps: InspectorDeps): HttpResponse { + const snapshot = deps.supervisor.snapshot(); + const status: StatusResponse = { + mode: "dev", + agents: snapshot.map(({ name, buildType, protocol }) => ({ name, buildType, protocol })), + harnesses: (deps.project?.spec.harnesses ?? []).map(({ name }) => ({ name })), + running: snapshot + .filter((agent) => agent.phase === "running" && agent.port !== undefined) + .map(({ name, port }) => ({ name, port: port! })), + errors: snapshot + .filter((agent) => agent.error !== undefined) + .map(({ name, error }) => ({ name, message: error! })), + selectedAgent: deps.selectedAgent, + }; + return json(200, status); +} + +/** POST /api/start — start an agent on demand; concurrent starts share one attempt. */ +async function handleStart(deps: InspectorDeps, request: HttpRequest): Promise { + const agentName = asString(parseJsonBody(request.body)?.agentName); + if (!agentName) return apiError(400, "agentName is required"); + + try { + const { name, port } = await deps.supervisor.start(agentName); + return json(200, { success: true, name, port }); + } catch (error) { + const status = error instanceof ResourceNotFoundError ? 404 : 500; + return json(status, { success: false, name: agentName, port: 0, error: errorMessage(error) }); + } +} + +/** GET /api/traces?agentName=xxx — recent local OTEL traces. */ +async function handleListTraces(deps: InspectorDeps, url: URL): Promise { + if (!deps.traces) return apiError(404, "Traces are not available"); + + const startTime = parseTimeParam(url, "startTime"); + if (startTime.error) return startTime.error; + const endTime = parseTimeParam(url, "endTime"); + if (endTime.error) return endTime.error; + + try { + const traces = await deps.traces.list({ + serviceName: url.searchParams.get("agentName") ?? undefined, + startTime: startTime.value, + endTime: endTime.value, + }); + return json(200, { success: true, traces }); + } catch { + return apiError(500, "Failed to list traces"); + } +} + +/** GET /api/traces/:traceId — full span and log data for one local trace. */ +async function handleGetTrace(deps: InspectorDeps, url: URL): Promise { + if (!deps.traces) return apiError(404, "Traces are not available"); + + const traceId = url.pathname.replace("/api/traces/", ""); + if (!traceId) return apiError(400, "traceId is required in the URL path"); + + const startTime = parseTimeParam(url, "startTime"); + if (startTime.error) return startTime.error; + const endTime = parseTimeParam(url, "endTime"); + if (endTime.error) return endTime.error; + + try { + const detail = await deps.traces.get(traceId); + if (!detail) return apiError(404, "Trace not found"); + return json(200, { success: true, ...detail }); + } catch { + return apiError(500, "Failed to get trace"); + } +} + +/** Serve a static SPA asset, falling back to index.html for client-side routes. */ +async function serveAsset( + deps: InspectorDeps, + pathname: string, +): Promise { + if (!deps.assets) return undefined; + const asset = (await deps.assets.read(pathname)) ?? (await deps.assets.read("/index.html")); + if (!asset) return undefined; + return { + status: 200, + headers: { + "Content-Type": asset.contentType, + ...(asset.contentType.includes("text/html") && { "Content-Security-Policy": CSP_HEADER }), + }, + body: Buffer.from(asset.body), + }; +} + +function forbidden(message: string): HttpResponse { + return { status: 403, headers: { "Content-Type": "text/plain" }, body: message }; +} + +function corsHeaders(origin: string | undefined, allowedOrigins: string[]): Record { + const allowedOrigin = origin && allowedOrigins.includes(origin) ? origin : allowedOrigins[0]!; + return { + "Access-Control-Allow-Origin": allowedOrigin, + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, X-Agentcore-Local, Mcp-Session-Id", + "Access-Control-Expose-Headers": "Mcp-Session-Id, x-session-id", + Vary: "Origin", + }; +} + +function withHeaders(response: HttpResponse, headers: Record): HttpResponse { + return { ...response, headers: { ...headers, ...response.headers } }; +} diff --git a/src/core/dev/inspector/testkit.ts b/src/core/dev/inspector/testkit.ts new file mode 100644 index 000000000..c7dde319a --- /dev/null +++ b/src/core/dev/inspector/testkit.ts @@ -0,0 +1,93 @@ +/** + * Test support for the Inspector server suites: fakes for every injected + * dependency and helpers that run the real handler behind a real loopback + * HTTP server. Imported only by the colocated *.test.ts files. + */ +import { ResourceNotFoundError } from "../../../errors"; +import { + startHttpServer, + type HttpRequestHandler, + type HttpServerHandle, +} from "../../../io/httpServer"; +import { createInspectorHandler } from "./server"; +import type { InspectorAgentStatus, InspectorDeps, InspectorSupervisor } from "./types"; + +export interface FakeSupervisorOptions { + agents?: InspectorAgentStatus[]; + start?: (name: string) => Promise<{ name: string; port: number }>; +} + +export function fakeSupervisor(options: FakeSupervisorOptions = {}): InspectorSupervisor { + const agents = options.agents ?? []; + return { + snapshot: () => agents, + start: + options.start ?? + ((name) => Promise.reject(new ResourceNotFoundError(`Agent '${name}' was not found.`))), + running: (name) => { + const agent = agents.find((entry) => entry.name === name); + if (agent?.phase !== "running" || agent.port === undefined) return undefined; + return { port: agent.port, protocol: agent.protocol }; + }, + }; +} + +export function runningAgent(name: string, port: number, protocol = "HTTP"): InspectorAgentStatus { + return { name, buildType: "CodeZip", protocol, phase: "running", port }; +} + +/** In-memory SPA assets keyed by path (e.g. "/index.html"). */ +export function fakeAssets(files: Record) { + return { + read: async (path: string) => { + const file = files[path]; + if (!file) return undefined; + return { body: new TextEncoder().encode(file.body), contentType: file.contentType }; + }, + }; +} + +/** Tracks started servers so a test can close them all in afterEach. */ +export class ServerFarm { + private readonly handles: HttpServerHandle[] = []; + + /** Start the Inspector itself over the given deps. */ + async inspector(deps: InspectorDeps): Promise<{ url: string; port: number }> { + return this.serve(createInspectorHandler(deps)); + } + + /** Start a stub server (e.g. a fake agent) with an arbitrary handler. */ + async serve(handler: HttpRequestHandler): Promise<{ url: string; port: number }> { + const handle = await startHttpServer(handler); + this.handles.push(handle); + return { url: `http://127.0.0.1:${handle.port}`, port: handle.port }; + } + + async close(): Promise { + await Promise.all(this.handles.splice(0).map((handle) => handle.close())); + } +} + +/** GET with a loopback Host header (fetch sets it from the URL automatically). */ +export function get(base: string, path: string, headers: Record = {}) { + return fetch(`${base}${path}`, { headers }); +} + +/** POST JSON with the X-Agentcore-Local header the server requires. */ +export function post( + base: string, + path: string, + body: unknown, + headers: Record = {}, +) { + return fetch(`${base}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json", "X-Agentcore-Local": "1", ...headers }, + body: JSON.stringify(body), + }); +} + +/** An SSE body chunked as the fake agent would produce it. */ +export function sseBody(events: string[]): string { + return events.map((event) => `data: ${event}\n\n`).join(""); +} diff --git a/src/core/dev/inspector/types.ts b/src/core/dev/inspector/types.ts new file mode 100644 index 000000000..1c75c9ddc --- /dev/null +++ b/src/core/dev/inspector/types.ts @@ -0,0 +1,167 @@ +/** + * Dependency interfaces for the Inspector HTTP server, owned by the consumer + * (this package) per the codebase's dependency-inversion rule. Each interface + * is exactly as wide as the routes that use it; the dev handler wires real + * implementations (DevSupervisor, InspectorTraceSource, AWS clients) at the + * edge, and tests inject fakes. + */ +import type { Project } from "../../../handlers/project/types"; +import type { + DeployedCredentialState, + DeployedGatewayState, + DeployedHarnessState, + DeployedMemoryState, + DeployedAgentState, + HarnessInvocationOverrides, + HarnessMessage, + ListMemoryRecordsQuery, + MemoryRecordsResponse, + RetrieveMemoryRecordsRequest, +} from "./api"; + +/** One managed agent's state, as reported by GET /api/status. */ +export interface InspectorAgentStatus { + name: string; + buildType: string; + protocol: string; + phase: "idle" | "starting" | "running" | "failed"; + port?: number; + error?: string; +} + +/** The slice of the dev supervisor the Inspector routes need. */ +export interface InspectorSupervisor { + snapshot(): InspectorAgentStatus[]; + /** Start an agent by name, resolving once it accepts connections. */ + start(name: string): Promise<{ name: string; port: number }>; + /** The port and protocol of a running agent, for proxying requests to it. */ + running(name: string): { port: number; protocol: string } | undefined; +} + +/** Local OTEL trace reads backing GET /api/traces[/:id]. */ +export interface InspectorTraces { + list(options?: { + serviceName?: string; + startTime?: number; + endTime?: number; + }): Promise; + get( + traceId: string, + ): Promise<{ resourceSpans?: unknown[]; resourceLogs?: unknown[] } | undefined>; +} + +/** Static SPA assets served for non-API GETs. */ +export interface InspectorAssets { + /** The asset at `path` (e.g. "/index.html"), or undefined when absent. */ + read(path: string): Promise<{ body: Uint8Array; contentType: string } | undefined>; +} + +/** A streaming invocation of a deployed AgentCore runtime (?target=deployed). */ +export interface DeployedInvocation { + sessionId?: string; + /** Chunks are SSE-framed by the server as `data: ` events. */ + stream: AsyncIterable; +} + +/** MCP JSON-RPC operations against a deployed runtime (POST /api/mcp?target=deployed). */ +export interface InspectorDeployedMcp { + initialize(args: { + agentName?: string; + targetName?: string; + sessionId?: string; + }): Promise<{ sessionId?: string }>; + listTools(args: { + agentName?: string; + targetName?: string; + sessionId?: string; + }): Promise<{ tools: unknown[] }>; + callTool(args: { + agentName?: string; + targetName?: string; + sessionId?: string; + name: string; + arguments: Record; + }): Promise; +} + +/** Memory record reads backing GET /api/memory and POST /api/memory/search. */ +export interface InspectorMemory { + list(args: ListMemoryRecordsQuery): Promise; + search(args: RetrieveMemoryRecordsRequest): Promise; +} + +/** CloudWatch trace reads backing GET /api/cloudwatch-traces[/:id]. */ +export interface InspectorCloudWatchTraces { + list(args: { + agentName?: string; + harnessName?: string; + startTime?: number; + endTime?: number; + }): Promise<{ success: boolean; traces?: unknown[]; error?: string }>; + get(args: { + agentName?: string; + harnessName?: string; + traceId: string; + startTime?: number; + endTime?: number; + }): Promise<{ success: boolean; records?: unknown[]; spans?: unknown[]; error?: string }>; +} + +/** Deployed harness invocations, streamed back to the SPA as SSE. */ +export interface InspectorHarness { + /** + * Resolves to the event stream once the invocation is accepted; a rejection + * before that (e.g. unknown harness) becomes a JSON error response, while + * stream errors become SSE error events. + */ + invoke(request: { + harnessName: string; + sessionId: string; + messages: HarnessMessage[]; + userId?: string; + overrides?: HarnessInvocationOverrides; + }): Promise>; +} + +/** Deployed resource state merged into GET /api/resources when available. */ +export interface InspectorDeployedResources { + runtimes?: Record; + harnesses?: Record; + memories?: Record; + credentials?: Record; + gateways?: Record; +} + +/** + * AWS-backed capabilities. Every member is optional: when one is absent its + * routes answer 404 `{success:false, error:'... not available'}` and the SPA + * degrades gracefully. + */ +export interface InspectorAws { + /** Invoke a deployed runtime (POST /invocations?target=deployed). */ + invokeDeployed?(request: { + agentName?: string; + targetName?: string; + prompt: string; + sessionId?: string; + userId?: string; + }): Promise; + mcp?: InspectorDeployedMcp; + memory?: InspectorMemory; + cloudwatchTraces?: InspectorCloudWatchTraces; + harness?: InspectorHarness; + /** Deployed resource state for GET /api/resources. */ + deployedState?(): Promise; +} + +/** Everything the Inspector request handler is composed from. */ +export interface InspectorDeps { + supervisor: InspectorSupervisor; + traces?: InspectorTraces; + assets?: InspectorAssets; + /** The resolved project; absent outside a project (resource routes 404). */ + project?: Project; + /** Agent name to pre-select in the UI. */ + selectedAgent?: string; + aws?: InspectorAws; +} From 5918df3c83d7d8da29227480bc4c521d5a2a0234 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Mon, 17 Aug 2026 15:10:52 -0400 Subject: [PATCH 12/16] feat(project): open the Agent Inspector from project dev by default project dev now supervises every runtime and serves the Inspector UI (auto-opening the browser on interactive terminals only; --no-ui restores plain single-runtime streaming, --ui-port pins the UI port). Events from all agents render agent-attributed in human and NDJSON output. --- src/core/dev/otel/collector.ts | 11 +- src/handlers/project/dev/index.test.ts | 111 ++++++++++++- src/handlers/project/dev/index.ts | 216 +++++++++++++++++++------ src/handlers/project/dev/types.ts | 11 ++ src/handlers/project/index.ts | 7 +- 5 files changed, 302 insertions(+), 54 deletions(-) diff --git a/src/core/dev/otel/collector.ts b/src/core/dev/otel/collector.ts index c19a3375a..660b42f5d 100644 --- a/src/core/dev/otel/collector.ts +++ b/src/core/dev/otel/collector.ts @@ -3,6 +3,7 @@ // 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 { InspectorTraceSource } from "../inspector/traces"; import { TraceStore } from "./store"; import type { OtlpPayload } from "./types"; @@ -37,6 +38,8 @@ export interface OtelCollector { port: number; /** Reads the traces this collector persists. */ store: TraceStore; + /** Inspector-shaped reads over the same store. */ + traces: InspectorTraceSource; /** 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. */ @@ -63,7 +66,13 @@ export async function startOtelCollector( signal: options.signal, }); - return { port: server.port, store, envVars: otelEnvVars(server.port), close: server.close }; + return { + port: server.port, + store, + traces: new InspectorTraceSource(store), + envVars: otelEnvVars(server.port), + close: server.close, + }; } async function route(request: HttpRequest, store: TraceStore): Promise { diff --git a/src/handlers/project/dev/index.test.ts b/src/handlers/project/dev/index.test.ts index 560efc46f..592d0e9f5 100644 --- a/src/handlers/project/dev/index.test.ts +++ b/src/handlers/project/dev/index.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"; import { join } from "node:path"; import type { ProjectRuntime } from "../../../projectSchemas/runtime"; import { InputValidationError, ResourceNotFoundError } from "../../../errors"; -import type { PortChecker } from "../../../io"; +import type { HttpRequestHandler, PortChecker } from "../../../io"; import { ProjectKey, ValueContext } from "../../../router"; import { testIO } from "../../../testing"; import { JsonRendererKey } from "../../../tui"; @@ -51,6 +51,7 @@ function fakeCollector() { OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "http://127.0.0.1:43180/v1/traces", OTEL_EXPORTER_OTLP_PROTOCOL: "http/protobuf", }, + traces: { list: async () => [], get: async () => undefined }, close: async () => { state.closed++; }, @@ -64,6 +65,7 @@ function fakeCollector() { type HarnessOptions = { project?: Project; + tty?: boolean; codeZip?: ReturnType; container?: ReturnType; checkPort?: PortChecker; @@ -73,6 +75,8 @@ type HarnessOptions = { function harness(options: HarnessOptions = {}) { const io = testIO(); + const ui = { starts: [] as { port?: number }[], opened: [] as string[], closed: 0 }; + let capturedHandler: HttpRequestHandler | undefined; const codeZip = options.codeZip ?? captureRunner(); const container = options.container ?? captureRunner(); const collector = fakeCollector(); @@ -88,6 +92,21 @@ function harness(options: HarnessOptions = {}) { }), checkPort: options.checkPort ?? (async () => true), startTraceCollector: collector.start, + startServer: async (handler, serverOptions) => { + capturedHandler = handler; + ui.starts.push({ port: serverOptions?.port }); + return { + port: serverOptions?.port ?? 8081, + close: async () => { + ui.closed++; + }, + }; + }, + openBrowser: async (url) => { + ui.opened.push(url); + }, + inspectorAssets: { read: async () => undefined }, + isInteractive: () => options.tty ?? false, }); const ctx = ValueContext.EmptyContext() .withValue(ProjectKey, options.project ?? project(runtime())) @@ -104,8 +123,10 @@ function harness(options: HarnessOptions = {}) { collector, environmentInputs, io, - run: (flags: { agent?: string; port?: number; traces?: boolean } = {}) => - handler.handle(ctx, { traces: true, ...flags }, {}), + ui, + inspectorHandler: () => capturedHandler, + run: (flags: { agent?: string; port?: number; traces?: boolean; ui?: boolean } = {}) => + handler.handle(ctx, { traces: true, ui: false, ...flags }, {}), }; } @@ -225,6 +246,90 @@ describe("project dev trace collection", () => { }); }); +describe("project dev Inspector UI mode", () => { + // Wraps the run promise in an object so awaiting this helper does not + // flatten it into "wait for the whole dev command to exit". + async function runUi(subject: ReturnType, flags: Record = {}) { + const pending = subject.run({ ui: true, ...flags } as Parameters[0]); + pending.catch(() => undefined); + await Bun.sleep(5); // let the handler start the UI server and block on events + return { pending }; + } + + test("starts the Inspector, prints the URL, and opens the browser on a TTY", async () => { + const subject = harness({ tty: true }); + const { pending } = await runUi(subject); + + expect(subject.ui.starts).toEqual([{ port: 8081 }]); + expect(subject.io.stderr()).toContain("Agent Inspector running at http://127.0.0.1:8081"); + expect(subject.ui.opened).toEqual(["http://127.0.0.1:8081"]); + + process.emit("SIGINT", "SIGINT"); + await pending.catch(() => undefined); + expect(subject.collector.state.closed).toBe(1); + }); + + test("never opens a browser without a TTY or in JSON mode", async () => { + for (const options of [{}, { tty: true, json: true }] as const) { + const subject = harness(options); + const { pending } = await runUi(subject); + expect(subject.ui.opened).toEqual([]); + process.emit("SIGINT", "SIGINT"); + await pending.catch(() => undefined); + } + }); + + test("serves the Inspector API: status lists every runtime, none started", async () => { + const subject = harness({ + project: project(runtime("orders"), runtime("support", "Container")), + }); + const { pending } = await runUi(subject); + + const response = await subject.inspectorHandler()!({ + method: "GET", + url: "/api/status", + headers: { host: "127.0.0.1:8081" }, + body: Buffer.alloc(0), + }); + const status = JSON.parse(String(response.body)) as { + agents: { name: string }[]; + running: unknown[]; + }; + expect(status.agents.map((agent) => agent.name)).toEqual(["orders", "support"]); + expect(status.running).toEqual([]); + expect(subject.codeZip.inputs).toHaveLength(0); + + process.emit("SIGINT", "SIGINT"); + await pending.catch(() => undefined); + }); + + test("an explicit --ui-port that is taken fails fast", async () => { + const subject = harness({ checkPort: async () => false }); + await expect(subject.run({ ui: true, "ui-port": 9999 } as never)).rejects.toThrow( + "Port 9999 is already in use", + ); + }); + + test("--agent narrows the supervised set", async () => { + const subject = harness({ + project: project(runtime("orders"), runtime("support", "Container")), + }); + const { pending } = await runUi(subject, { agent: "support" }); + + const response = await subject.inspectorHandler()!({ + method: "GET", + url: "/api/status", + headers: { host: "127.0.0.1:8081" }, + body: Buffer.alloc(0), + }); + const status = JSON.parse(String(response.body)) as { agents: { name: string }[] }; + expect(status.agents.map((agent) => agent.name)).toEqual(["support"]); + + process.emit("SIGINT", "SIGINT"); + await pending.catch(() => undefined); + }); +}); + test("project dev renders human and NDJSON output", async () => { const events: DevEvent[] = [ { type: "status", message: "Starting" }, diff --git a/src/handlers/project/dev/index.ts b/src/handlers/project/dev/index.ts index 59a153e94..af3c8e2d1 100644 --- a/src/handlers/project/dev/index.ts +++ b/src/handlers/project/dev/index.ts @@ -1,14 +1,17 @@ import { join } from "node:path"; import z from "zod"; +import { createInspectorHandler } from "../../../core/dev/inspector/server"; +import type { InspectorDeps } from "../../../core/dev/inspector/types"; import { rewriteOtelEndpointForContainer } from "../../../core/dev/otel/collector"; -import { resolveDevPort } from "../../../core/dev/port"; +import { PortInUseError, resolveDevPort } from "../../../core/dev/port"; +import { DevSupervisor } from "../../../core/dev/supervisor"; import type { ProjectRuntime } from "../../../projectSchemas/runtime"; import { CommandInterruptedError, InputValidationError, ResourceNotFoundError, } from "../../../errors"; -import type { AppIO, PortChecker } from "../../../io"; +import type { AppIO, BrowserOpener, PortChecker, startHttpServer } from "../../../io"; import { createHandler, flag, ProjectKey } from "../../../router"; import { JsonRendererKey, type JsonRenderer } from "../../../tui"; import { JsonKey, RegionKey } from "../../keys"; @@ -16,12 +19,20 @@ import type { Project } from "../types"; import type { DevEnvironmentLoader } from "./environment"; import type { DevEvent, DevRunner, DevTraceCollector, DevTraceCollectorStarter } from "./types"; +const UI_DEFAULT_PORT = 8081; +const UI_PORT_ATTEMPTS = 100; + export type DevProjectHandlerConfig = { io: AppIO; runners: { CodeZip: DevRunner; Container: DevRunner }; loadDevEnvironment: DevEnvironmentLoader; checkPort: PortChecker; startTraceCollector: DevTraceCollectorStarter; + startServer: typeof startHttpServer; + openBrowser: BrowserOpener; + inspectorAssets: InspectorDeps["assets"]; + /** Whether the command runs on an interactive terminal (gates browser auto-open). */ + isInteractive: () => boolean; }; /** Env for a spawned agent so its OTEL SDK reports to the collector as this runtime. */ @@ -33,36 +44,41 @@ function otelEnvForRuntime( return runtime.build === "Container" ? rewriteOtelEndpointForContainer(env) : env; } -function selectRuntime(project: Project, name?: string): ProjectRuntime { +function selectRuntimes(project: Project, name?: string): ProjectRuntime[] { if (project.spec.runtimes.length === 0) { throw new InputValidationError( "This project has no runtimes. Add a runtime to agentcore/agentcore.json and retry.", ); } - const available = project.spec.runtimes.map(({ name }) => name).join(", "); + if (!name) return project.spec.runtimes; - if (name) { - const runtime = project.spec.runtimes.find((candidate) => candidate.name === name); - if (runtime) return runtime; - throw new ResourceNotFoundError( - `Runtime '${name}' was not found. Available runtimes: ${available}.`, - ); - } + const runtime = project.spec.runtimes.find((candidate) => candidate.name === name); + if (runtime) return [runtime]; + const available = project.spec.runtimes.map((candidate) => candidate.name).join(", "); + throw new ResourceNotFoundError( + `Runtime '${name}' was not found. Available runtimes: ${available}.`, + ); +} - if (project.spec.runtimes.length === 1) return project.spec.runtimes[0]!; +/** In --no-ui mode exactly one runtime streams to the terminal, as before. */ +function selectSingleRuntime(project: Project, name?: string): ProjectRuntime { + const runtimes = selectRuntimes(project, name); + if (runtimes.length === 1) return runtimes[0]!; + const available = runtimes.map(({ name: runtimeName }) => runtimeName).join(", "); throw new InputValidationError( `Multiple runtimes found. Use --agent to select one. Available runtimes: ${available}.`, ); } -function renderEvent(io: AppIO, event: DevEvent, json?: JsonRenderer): void { +function renderEvent(io: AppIO, event: DevEvent, json?: JsonRenderer, agent?: string): void { if (json) { - json.renderJsonLine(event); + json.renderJsonLine(agent === undefined ? event : { agent, ...event }); return; } const output = event.type === "stdout" ? io.stdout : io.stderr; - output.write(`${event.type === "status" ? event.message : event.line}\n`); + const line = event.type === "status" ? event.message : event.line; + output.write(agent === undefined ? `${line}\n` : `[${agent}] ${line}\n`); } export const createDevProjectHandler = (config: DevProjectHandlerConfig) => @@ -77,6 +93,12 @@ export const createDevProjectHandler = (config: DevProjectHandlerConfig) => z.coerce.number().int().min(1).max(65535).optional(), ), flag("traces", "disable local OTEL trace collection", z.boolean().default(true)), + flag("ui", "run without the Agent Inspector web UI", z.boolean().default(true)), + flag( + "ui-port", + "port for the Agent Inspector web UI", + z.coerce.number().int().min(1).max(65535).optional(), + ), ], handle: async (ctx, flags) => { const controller = new AbortController(); @@ -92,39 +114,20 @@ export const createDevProjectHandler = (config: DevProjectHandlerConfig) => 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(); + const region = ctx.require(RegionKey); + const runtimes = flags.ui + ? selectRuntimes(project, flags.agent) + : [selectSingleRuntime(project, flags.agent)]; - let otelEnv: Record = {}; - if (flags.traces && (runtime.instrumentation?.enableOtel ?? true)) { + if ( + flags.traces && + runtimes.some((runtime) => 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, { @@ -136,16 +139,73 @@ export const createDevProjectHandler = (config: DevProjectHandlerConfig) => } controller.signal.throwIfAborted(); - const runner = config.runners[runtime.build]; - for await (const event of runner.run({ - runtime, + const environment = async (runtime: ProjectRuntime): Promise> => { + const { env } = await config.loadDevEnvironment({ + projectRoot: project.rootPath, + runtime, + region, + }); + const otel = + collector && (runtime.instrumentation?.enableOtel ?? true) + ? otelEnvForRuntime(collector, runtime) + : {}; + return { ...env, ...otel }; + }; + + if (!flags.ui) { + await runWithoutUi( + config, + runtimes[0]!, + project, + flags.port, + environment, + controller, + json, + ); + return; + } + + const supervisor = new DevSupervisor({ + runtimes, projectRoot: project.rootPath, - port: devPort.port, - env: { ...env, ...otelEnv }, + runners: config.runners, + environment, + resolvePort: async (runtime) => + ( + await resolveDevPort( + runtime.protocol, + runtimes.length === 1 ? flags.port : undefined, + config.checkPort, + controller.signal, + ) + ).port, signal: controller.signal, - })) { - renderEvent(config.io, event, json); + }); + + const uiPort = await resolveUiPort(flags["ui-port"], config.checkPort, controller.signal); + const server = await config.startServer( + createInspectorHandler({ + supervisor, + traces: collector?.traces, + assets: config.inspectorAssets, + project, + selectedAgent: flags.agent, + }), + { port: uiPort, signal: controller.signal }, + ); + + const url = `http://127.0.0.1:${server.port}`; + renderEvent( + config.io, + { type: "status", message: `Agent Inspector running at ${url}` }, + json, + ); + if (config.isInteractive() && !json) await config.openBrowser(url); + + for await (const { agent, event } of supervisor.events()) { + renderEvent(config.io, event, json, agent); } + controller.signal.throwIfAborted(); } catch (error) { if (!controller.signal.aborted) throw error; throw new CommandInterruptedError(error, true); @@ -155,3 +215,61 @@ export const createDevProjectHandler = (config: DevProjectHandlerConfig) => } }, }); + +/** The pre-Inspector behavior: run one runtime and stream its output directly. */ +async function runWithoutUi( + config: DevProjectHandlerConfig, + runtime: ProjectRuntime, + project: Project, + explicitPort: number | undefined, + environment: (runtime: ProjectRuntime) => Promise>, + controller: AbortController, + json?: JsonRenderer, +): Promise { + const devPort = await resolveDevPort( + runtime.protocol, + explicitPort, + 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 environment(runtime); + controller.signal.throwIfAborted(); + + const runner = config.runners[runtime.build]; + for await (const event of runner.run({ + runtime, + projectRoot: project.rootPath, + port: devPort.port, + env, + signal: controller.signal, + })) { + renderEvent(config.io, event, json); + } +} + +/** The Inspector UI binds 8081 or the next free port; an explicit port must be free. */ +async function resolveUiPort( + explicitPort: number | undefined, + checkPort: PortChecker, + signal: AbortSignal, +): Promise { + if (explicitPort !== undefined) { + if (await checkPort(explicitPort, signal)) return explicitPort; + throw new PortInUseError(explicitPort); + } + for (let port = UI_DEFAULT_PORT; port < UI_DEFAULT_PORT + UI_PORT_ATTEMPTS; port++) { + if (await checkPort(port, signal)) return port; + } + throw new PortInUseError(UI_DEFAULT_PORT); +} diff --git a/src/handlers/project/dev/types.ts b/src/handlers/project/dev/types.ts index 289c1b5c9..06235d028 100644 --- a/src/handlers/project/dev/types.ts +++ b/src/handlers/project/dev/types.ts @@ -22,6 +22,17 @@ export interface DevTraceCollector { port: number; /** Environment variables that point an agent's OTEL SDK at the receiver. */ envVars: Record; + /** Reads the collected traces, shaped for the Inspector frontend. */ + traces: { + list(options?: { + serviceName?: string; + startTime?: number; + endTime?: number; + }): Promise; + get( + traceId: string, + ): Promise<{ resourceSpans?: unknown[]; resourceLogs?: unknown[] } | undefined>; + }; close(): Promise; } diff --git a/src/handlers/project/index.ts b/src/handlers/project/index.ts index f5ad74a26..449c164c0 100644 --- a/src/handlers/project/index.ts +++ b/src/handlers/project/index.ts @@ -1,8 +1,9 @@ import { Router } from "../../router"; -import { checkPort, type AppIO } from "../../io"; +import { checkPort, openBrowser, startHttpServer, type AppIO } from "../../io"; import { CodeZipDevRunner } from "../../core/dev/codezip"; import { ContainerDevRunner } from "../../core/dev/container"; import { startOtelCollector } from "../../core/dev/otel/collector"; +import { InspectorAssets } from "../../core/dev/inspectorAssets"; import { withProject } from "../../middleware"; import { createCreateProjectHandler } from "./create"; import { createRemoveProjectHandler } from "./remove"; @@ -38,6 +39,10 @@ export function createProjectHandler(config: ProjectHandlerConfig): Router { loadDevEnvironment, checkPort, startTraceCollector: startOtelCollector, + startServer: startHttpServer, + openBrowser, + inspectorAssets: new InspectorAssets(), + isInteractive: () => process.stdout.isTTY === true, }), ), ); From db84811b551a38f0de04bc0c01a0411f54fce2ed Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Mon, 17 Aug 2026 15:17:41 -0400 Subject: [PATCH 13/16] refactor(dev): drop unread per-agent controller field --- src/core/dev/supervisor.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/core/dev/supervisor.ts b/src/core/dev/supervisor.ts index ef575b32f..d4109bc67 100644 --- a/src/core/dev/supervisor.ts +++ b/src/core/dev/supervisor.ts @@ -39,7 +39,6 @@ type AgentEntry = { port?: number; error?: string; starting?: Promise<{ name: string; port: number }>; - controller?: AbortController; }; /** @@ -139,7 +138,6 @@ export class DevSupervisor { // Ctrl-C must tear down every running child. The pump removes it on exit. this.config.signal.addEventListener("abort", onParentAbort, { once: true }); const unchain = () => this.config.signal.removeEventListener("abort", onParentAbort); - entry.controller = controller; try { const port = await this.config.resolvePort(entry.runtime); From 4a0fa063a636d760187390b4d3dd06b351f75aa7 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Mon, 17 Aug 2026 16:12:42 -0400 Subject: [PATCH 14/16] feat(dev): reload agents on agentcore.json edits; request-path efficiency Watch the project config so the Inspector's agent list follows edits without a restart (running agents survive removal until they stop). Cache decoded SPA assets outside SPA-dev mode, skip body buffering on GET/HEAD, and drain the supervisor's event queue in batches. --- src/core/dev/inspectorAssets.ts | 13 +++++++++-- src/core/dev/supervisor.test.ts | 16 +++++++++++++ src/core/dev/supervisor.ts | 21 ++++++++++++++++- src/handlers/project/dev/index.test.ts | 31 ++++++++++++++++++++++++++ src/handlers/project/dev/index.ts | 30 ++++++++++++++++++++++++- src/handlers/project/index.ts | 8 ++++++- src/io/httpServer.ts | 5 ++++- src/io/index.ts | 1 + src/io/watchFile.ts | 29 ++++++++++++++++++++++++ 9 files changed, 148 insertions(+), 6 deletions(-) create mode 100644 src/io/watchFile.ts diff --git a/src/core/dev/inspectorAssets.ts b/src/core/dev/inspectorAssets.ts index 041bb62a5..26e577dfe 100644 --- a/src/core/dev/inspectorAssets.ts +++ b/src/core/dev/inspectorAssets.ts @@ -30,6 +30,7 @@ export type InspectorAssetReader = (assetPath: string) => Promise(); constructor(options: { source?: AssetSource; overrideDir?: string } = {}) { this.source = options.source ?? defaultSource(); @@ -42,6 +43,7 @@ export class InspectorAssets { const contentType = CONTENT_TYPES[extname(relative)] ?? "text/plain; charset=utf-8"; if (this.overrideDir) { + // SPA development iterates on these files — never cache. try { return { body: await readFile(join(this.overrideDir, relative)), contentType }; } catch { @@ -49,10 +51,15 @@ export class InspectorAssets { } } + const cached = this.cache.get(relative); + if (cached) return cached; + try { // Staged files carry a neutral `.asset` suffix (see scripts/build.ts). const text = await this.source.read(`agent-inspector/${relative}.asset`); - return { body: new TextEncoder().encode(text), contentType }; + const asset = { body: new TextEncoder().encode(text), contentType }; + this.cache.set(relative, asset); + return asset; } catch { // Not staged (running from source before a build) — fall through. } @@ -60,7 +67,9 @@ export class InspectorAssets { const packaged = packagedAssetsDir(); if (!packaged) return undefined; try { - return { body: await readFile(join(packaged, relative)), contentType }; + const asset = { body: await readFile(join(packaged, relative)), contentType }; + this.cache.set(relative, asset); + return asset; } catch { return undefined; } diff --git a/src/core/dev/supervisor.test.ts b/src/core/dev/supervisor.test.ts index 85153f49f..bcb67ba57 100644 --- a/src/core/dev/supervisor.test.ts +++ b/src/core/dev/supervisor.test.ts @@ -204,6 +204,22 @@ describe("DevSupervisor", () => { }); }); + test("setRuntimes adds, updates, and drops agents without touching running ones", async () => { + const { supervisor, controller } = harness(); + await supervisor.start("orders"); + + supervisor.setRuntimes([runtime("orders"), runtime("payments")]); + expect(supervisor.snapshot().map(({ name, phase }) => ({ name, phase }))).toEqual([ + { name: "orders", phase: "running" }, + { name: "payments", phase: "idle" }, + ]); + + // A running agent survives removal from the config until it stops. + supervisor.setRuntimes([runtime("payments")]); + expect(supervisor.snapshot().map(({ name }) => name)).toEqual(["orders", "payments"]); + controller.abort(); + }); + test("aborting the parent signal stops running agents and ends the stream", async () => { const codeZip = serverRunner(); const { supervisor, controller } = harness({ codeZip }); diff --git a/src/core/dev/supervisor.ts b/src/core/dev/supervisor.ts index d4109bc67..386262b4f 100644 --- a/src/core/dev/supervisor.ts +++ b/src/core/dev/supervisor.ts @@ -62,6 +62,25 @@ export class DevSupervisor { config.signal.addEventListener("abort", () => this.wake?.(), { once: true }); } + /** + * Replace the managed runtime set after a config change: new runtimes join + * idle, edited definitions apply on the next start, and removed runtimes + * drop unless they are currently starting or running. + */ + public setRuntimes(runtimes: ProjectRuntime[]): void { + const names = new Set(runtimes.map((runtime) => runtime.name)); + for (const runtime of runtimes) { + const existing = this.agents.get(runtime.name); + if (existing) existing.runtime = runtime; + else this.agents.set(runtime.name, { runtime, phase: "idle" }); + } + for (const [name, entry] of this.agents) { + if (!names.has(name) && entry.phase !== "running" && entry.phase !== "starting") { + this.agents.delete(name); + } + } + } + /** Current phase, port, and last error of every managed agent. */ public snapshot(): AgentStatus[] { return [...this.agents.values()].map(({ runtime, phase, port, error }) => ({ @@ -113,7 +132,7 @@ export class DevSupervisor { */ public async *events(): AsyncGenerator { while (true) { - while (this.queue.length > 0) yield this.queue.shift()!; + for (const event of this.queue.splice(0)) yield event; if (this.config.signal.aborted) return; await new Promise((resolve) => { this.wake = resolve; diff --git a/src/handlers/project/dev/index.test.ts b/src/handlers/project/dev/index.test.ts index 592d0e9f5..d1db20eb4 100644 --- a/src/handlers/project/dev/index.test.ts +++ b/src/handlers/project/dev/index.test.ts @@ -66,6 +66,7 @@ function fakeCollector() { type HarnessOptions = { project?: Project; tty?: boolean; + reloadedRuntimes?: ProjectRuntime[]; codeZip?: ReturnType; container?: ReturnType; checkPort?: PortChecker; @@ -76,6 +77,7 @@ type HarnessOptions = { function harness(options: HarnessOptions = {}) { const io = testIO(); const ui = { starts: [] as { port?: number }[], opened: [] as string[], closed: 0 }; + const watchers: { path: string; onChange: () => void }[] = []; let capturedHandler: HttpRequestHandler | undefined; const codeZip = options.codeZip ?? captureRunner(); const container = options.container ?? captureRunner(); @@ -107,6 +109,10 @@ function harness(options: HarnessOptions = {}) { }, inspectorAssets: { read: async () => undefined }, isInteractive: () => options.tty ?? false, + watchFile: (path, onChange) => { + watchers.push({ path, onChange }); + }, + reloadRuntimes: async () => options.reloadedRuntimes ?? [], }); const ctx = ValueContext.EmptyContext() .withValue(ProjectKey, options.project ?? project(runtime())) @@ -124,6 +130,7 @@ function harness(options: HarnessOptions = {}) { environmentInputs, io, ui, + watchers, inspectorHandler: () => capturedHandler, run: (flags: { agent?: string; port?: number; traces?: boolean; ui?: boolean } = {}) => handler.handle(ctx, { traces: true, ui: false, ...flags }, {}), @@ -303,6 +310,30 @@ describe("project dev Inspector UI mode", () => { await pending.catch(() => undefined); }); + test("agentcore.json edits reload the supervised agents", async () => { + const subject = harness({ reloadedRuntimes: [runtime("orders"), runtime("payments")] }); + const { pending } = await runUi(subject); + + expect(subject.watchers[0]?.path).toBe( + join("/workspace/project", "agentcore", "agentcore.json"), + ); + subject.watchers[0]!.onChange(); + await Bun.sleep(5); + + const response = await subject.inspectorHandler()!({ + method: "GET", + url: "/api/status", + headers: { host: "127.0.0.1:8081" }, + body: Buffer.alloc(0), + }); + const status = JSON.parse(String(response.body)) as { agents: { name: string }[] }; + expect(status.agents.map((agent) => agent.name)).toEqual(["orders", "payments"]); + expect(subject.io.stderr()).toContain("Reloaded agents from agentcore.json."); + + process.emit("SIGINT", "SIGINT"); + await pending.catch(() => undefined); + }); + test("an explicit --ui-port that is taken fails fast", async () => { const subject = harness({ checkPort: async () => false }); await expect(subject.run({ ui: true, "ui-port": 9999 } as never)).rejects.toThrow( diff --git a/src/handlers/project/dev/index.ts b/src/handlers/project/dev/index.ts index af3c8e2d1..cc14015b1 100644 --- a/src/handlers/project/dev/index.ts +++ b/src/handlers/project/dev/index.ts @@ -11,7 +11,7 @@ import { InputValidationError, ResourceNotFoundError, } from "../../../errors"; -import type { AppIO, BrowserOpener, PortChecker, startHttpServer } from "../../../io"; +import type { AppIO, BrowserOpener, FileWatcher, PortChecker, startHttpServer } from "../../../io"; import { createHandler, flag, ProjectKey } from "../../../router"; import { JsonRendererKey, type JsonRenderer } from "../../../tui"; import { JsonKey, RegionKey } from "../../keys"; @@ -33,6 +33,10 @@ export type DevProjectHandlerConfig = { inspectorAssets: InspectorDeps["assets"]; /** Whether the command runs on an interactive terminal (gates browser auto-open). */ isInteractive: () => boolean; + /** Watches agentcore.json so the Inspector reflects config edits live. */ + watchFile: FileWatcher; + /** Re-reads the project's runtime definitions after a config change. */ + reloadRuntimes: (projectRoot: string) => Promise; }; /** Env for a spawned agent so its OTEL SDK reports to the collector as this runtime. */ @@ -194,6 +198,30 @@ export const createDevProjectHandler = (config: DevProjectHandlerConfig) => { port: uiPort, signal: controller.signal }, ); + const configPath = join(project.rootPath, "agentcore", "agentcore.json"); + config.watchFile( + configPath, + () => + void config + .reloadRuntimes(project.rootPath) + .then((reloaded) => { + supervisor.setRuntimes( + flags.agent + ? reloaded.filter((runtime) => runtime.name === flags.agent) + : reloaded, + ); + renderEvent( + config.io, + { type: "status", message: "Reloaded agents from agentcore.json." }, + json, + ); + }) + .catch(() => { + // A half-saved config parses on the next change event. + }), + controller.signal, + ); + const url = `http://127.0.0.1:${server.port}`; renderEvent( config.io, diff --git a/src/handlers/project/index.ts b/src/handlers/project/index.ts index 449c164c0..5755c7692 100644 --- a/src/handlers/project/index.ts +++ b/src/handlers/project/index.ts @@ -1,5 +1,5 @@ import { Router } from "../../router"; -import { checkPort, openBrowser, startHttpServer, type AppIO } from "../../io"; +import { checkPort, openBrowser, startHttpServer, watchFile, type AppIO } from "../../io"; import { CodeZipDevRunner } from "../../core/dev/codezip"; import { ContainerDevRunner } from "../../core/dev/container"; import { startOtelCollector } from "../../core/dev/otel/collector"; @@ -43,6 +43,12 @@ export function createProjectHandler(config: ProjectHandlerConfig): Router { openBrowser, inspectorAssets: new InspectorAssets(), isInteractive: () => process.stdout.isTTY === true, + watchFile, + reloadRuntimes: async (projectRoot) => { + const project = await config.projectManager.resolve({ filePath: projectRoot }); + if (!project) throw new Error("project configuration is currently unreadable"); + return project.spec.runtimes; + }, }), ), ); diff --git a/src/io/httpServer.ts b/src/io/httpServer.ts index fc25e7572..c7453e7ce 100644 --- a/src/io/httpServer.ts +++ b/src/io/httpServer.ts @@ -68,7 +68,10 @@ async function respond( ): Promise { let body: Buffer; try { - body = await readBody(request); + body = + request.method === "GET" || request.method === "HEAD" + ? Buffer.alloc(0) + : await readBody(request); } catch (error) { const status = error instanceof BodyTooLargeError ? 413 : 400; response.writeHead(status).end(); diff --git a/src/io/index.ts b/src/io/index.ts index f0f256508..c196b6624 100644 --- a/src/io/index.ts +++ b/src/io/index.ts @@ -38,6 +38,7 @@ export type { AppIO, ReadWriteJson } from "./types"; export { warn } from "./warn"; export { checkPort, type PortChecker } from "./port"; export { openBrowser, type BrowserOpener } from "./openBrowser"; +export { watchFile, type FileWatcher } from "./watchFile"; export { startHttpServer, type HttpRequest, diff --git a/src/io/watchFile.ts b/src/io/watchFile.ts new file mode 100644 index 000000000..593a41a4c --- /dev/null +++ b/src/io/watchFile.ts @@ -0,0 +1,29 @@ +import { watch } from "node:fs"; + +export type FileWatcher = (path: string, onChange: () => void, signal: AbortSignal) => void; + +/** + * Watch one file, debouncing the editor's burst of change events into a single + * callback. Watching stops when the signal aborts; a missing file or an + * unsupported platform fails quietly — watching is a convenience, never load-bearing. + */ +export const watchFile: FileWatcher = (path, onChange, signal, debounceMs = 150) => { + let timer: ReturnType | undefined; + try { + const watcher = watch(path, () => { + clearTimeout(timer); + timer = setTimeout(onChange, debounceMs); + }); + watcher.on("error", () => watcher.close()); + signal.addEventListener( + "abort", + () => { + clearTimeout(timer); + watcher.close(); + }, + { once: true }, + ); + } catch { + // Watching is best-effort. + } +}; From 56c734591bdd31d60fd23ee203076c815ed55c54 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Mon, 17 Aug 2026 16:15:02 -0400 Subject: [PATCH 15/16] refactor(dev): drop the Inspector's unwired AWS-backed routes project deploy is still a stub on the refactor branch, so nothing can ever inject the aws capabilities: the deployed invocation/MCP, harness, memory, CloudWatch trace, and deployed-state resource routes were unreachable dead surface that always answered 404. Remove them and their dep interfaces, wire types, and tests; unknown /api paths keep returning the same JSON 404 the SPA already handles. They return with real wiring when deploy lands. --- src/core/dev/inspector/api.ts | 146 ----------- src/core/dev/inspector/aws.ts | 151 ----------- src/core/dev/inspector/harness.ts | 89 ------- src/core/dev/inspector/invocations.test.ts | 179 ------------- src/core/dev/inspector/invocations.ts | 57 +---- src/core/dev/inspector/proxies.test.ts | 283 --------------------- src/core/dev/inspector/proxies.ts | 55 +--- src/core/dev/inspector/resources.ts | 181 +++---------- src/core/dev/inspector/server.test.ts | 26 -- src/core/dev/inspector/server.ts | 26 +- src/core/dev/inspector/types.ts | 115 +-------- 11 files changed, 46 insertions(+), 1262 deletions(-) delete mode 100644 src/core/dev/inspector/aws.ts delete mode 100644 src/core/dev/inspector/harness.ts diff --git a/src/core/dev/inspector/api.ts b/src/core/dev/inspector/api.ts index 8fc0ece7c..da9521975 100644 --- a/src/core/dev/inspector/api.ts +++ b/src/core/dev/inspector/api.ts @@ -61,7 +61,6 @@ export interface StartResponse { export interface InvocationRequest { agentName?: string; - targetName?: string; prompt?: string; sessionId?: string; userId?: string; @@ -84,77 +83,12 @@ export interface GetTraceResponse { error?: string; } -// --------------------------------------------------------------------------- -// GET /api/cloudwatch-traces[/:traceId]?agentName=xxx|harnessName=xxx -// --------------------------------------------------------------------------- - -export interface CloudWatchTraceEntry { - traceId: string; - timestamp: string; - sessionId?: string; - spanCount?: string; -} - -export interface ListCloudWatchTracesResponse { - success: boolean; - traces?: CloudWatchTraceEntry[]; - error?: string; -} - -export interface GetCloudWatchTraceResponse { - success: boolean; - records?: unknown[]; - spans?: unknown[]; - error?: string; -} - -// --------------------------------------------------------------------------- -// GET /api/memory + POST /api/memory/search -// --------------------------------------------------------------------------- - -/** - * Namespace selector shared by memory list and search. Exactly one of - * `namespace` (exact match) or `namespacePath` (hierarchical path prefix) - * must be provided. - */ -export type MemoryNamespaceSelector = - { namespace: string; namespacePath?: never } | { namespace?: never; namespacePath: string }; - -export type ListMemoryRecordsQuery = { - memoryName: string; - strategyId?: string; -} & MemoryNamespaceSelector; - -export type RetrieveMemoryRecordsRequest = { - memoryName: string; - searchQuery: string; - strategyId?: string; -} & MemoryNamespaceSelector; - -export interface MemoryRecordResponse { - memoryRecordId: string; - content: string | undefined; - memoryStrategyId: string; - namespaces: string[]; - createdAt: string; - score: number | undefined; - metadata: Record; -} - -export interface MemoryRecordsResponse { - success: boolean; - records?: MemoryRecordResponse[]; - nextToken?: string; - error?: string; -} - // --------------------------------------------------------------------------- // POST /api/mcp // --------------------------------------------------------------------------- export interface McpProxyRequest { agentName?: string; - targetName?: string; body?: Record; sessionId?: string; } @@ -174,74 +108,10 @@ export interface A2AAgentCardResponse { card: unknown; } -// --------------------------------------------------------------------------- -// Harness invocation (POST /invocations with harnessName) + -// POST /api/harness/tool-response -// --------------------------------------------------------------------------- - -/** - * Overrides forwarded verbatim to the harness invocation. The reference typed - * `model`, `skills`, and `tools` with AWS SDK shapes; the inspector only passes - * them through, so they stay opaque here. - */ -export interface HarnessInvocationOverrides { - model?: unknown; - systemPrompt?: string; - skills?: unknown[]; - actorId?: string; - maxIterations?: number; - maxTokens?: number; - timeoutSeconds?: number; - allowedTools?: string[]; - tools?: unknown[]; -} - -export interface HarnessMessage { - role: string; - content: Record[]; -} - -export interface HarnessToolResponseRequest { - harnessName: string; - sessionId: string; - messages: HarnessMessage[]; - harnessOverrides?: HarnessInvocationOverrides; -} - // --------------------------------------------------------------------------- // GET /api/resources // --------------------------------------------------------------------------- -export type ResourceDeploymentStatus = "deployed" | "local-only" | "pending-removal"; - -export interface DeployedAgentState { - runtimeId: string; - runtimeArn: string; - roleArn: string; -} - -export interface DeployedMemoryState { - memoryId: string; - memoryArn: string; -} - -export interface DeployedCredentialState { - credentialProviderArn: string; - clientSecretArn?: string; - callbackUrl?: string; -} - -export interface DeployedGatewayState { - gatewayId: string; - gatewayArn: string; - gatewayUrl?: string; -} - -export interface DeployedHarnessState { - harnessId: string; - harnessArn: string; -} - export interface ResourcesResponse { success: true; project: string; @@ -273,9 +143,6 @@ export interface ResourceAgent { networkMode: string; protocol: string; envVars: string[]; - deploymentStatus?: ResourceDeploymentStatus; - deployed?: DeployedAgentState; - invocationUrl?: string; } export interface ResourceHarness { @@ -283,16 +150,12 @@ export interface ResourceHarness { /** @deprecated The reference derived this from the harness spec's model. */ model: string; tools: string[]; - deploymentStatus?: ResourceDeploymentStatus; - deployed?: DeployedHarnessState; } export interface ResourceMemory { name: string; strategies: ResourceMemoryStrategy[]; expiryDays: number | undefined; - deploymentStatus?: ResourceDeploymentStatus; - deployed?: DeployedMemoryState; } export interface ResourceMemoryStrategy { @@ -304,15 +167,11 @@ export interface ResourceMemoryStrategy { export interface ResourceCredential { name: string; type: string; - deploymentStatus?: ResourceDeploymentStatus; - deployed?: DeployedCredentialState; } export interface ResourceGateway { name: string; targets: ResourceGatewayTarget[]; - deploymentStatus?: ResourceDeploymentStatus; - deployed?: DeployedGatewayState; } export interface ResourceGatewayTarget { @@ -323,7 +182,6 @@ export interface ResourceGatewayTarget { export interface ResourceMcpTool { name: string; bindings: ResourceMcpToolBinding[]; - deploymentStatus?: ResourceDeploymentStatus; } export interface ResourceMcpToolBinding { @@ -336,7 +194,6 @@ export interface ResourceEvaluator { level: string; description?: string; configType: "llm-as-a-judge" | "code-based"; - deploymentStatus?: ResourceDeploymentStatus; } export interface ResourceOnlineEvalConfig { @@ -348,20 +205,17 @@ export interface ResourceOnlineEvalConfig { description?: string; logGroupNames?: string[]; serviceNames?: string[]; - deploymentStatus?: ResourceDeploymentStatus; } export interface ResourcePolicyEngine { name: string; description?: string; policies: ResourcePolicy[]; - deploymentStatus?: ResourceDeploymentStatus; } export interface ResourcePolicy { name: string; description?: string; - deploymentStatus?: ResourceDeploymentStatus; } export interface ResourceUnassignedTarget { diff --git a/src/core/dev/inspector/aws.ts b/src/core/dev/inspector/aws.ts deleted file mode 100644 index 506ec600d..000000000 --- a/src/core/dev/inspector/aws.ts +++ /dev/null @@ -1,151 +0,0 @@ -/** - * AWS-backed read routes: GET /api/memory, POST /api/memory/search, and - * GET /api/cloudwatch-traces[/:traceId]. Each answers 404 - * `{success:false, error:'... not available'}` when its capability was not - * injected, so the SPA degrades gracefully. Ported from the reference - * memory.ts / cloudwatch-traces.ts handlers. - */ -import type { HttpRequest, HttpResponse } from "../../../io/httpServer"; -import type { ListMemoryRecordsQuery, RetrieveMemoryRecordsRequest } from "./api"; -import { asString } from "./invocations"; -import { apiError, json, parseJsonBody, parseTimeParam } from "./respond"; -import type { InspectorDeps } from "./types"; - -const TRACE_ID_PATTERN = /^[a-fA-F0-9-]+$/; - -/** GET /api/memory?memoryName=xxx&(namespace=yyy|namespacePath=yyy)[&strategyId=zzz] */ -export async function handleListMemoryRecords( - deps: InspectorDeps, - url: URL, -): Promise { - const memory = deps.aws?.memory; - if (!memory) return apiError(404, "Memory browsing is not available"); - - const memoryName = url.searchParams.get("memoryName") ?? undefined; - const namespace = url.searchParams.get("namespace") ?? undefined; - const namespacePath = url.searchParams.get("namespacePath") ?? undefined; - const strategyId = url.searchParams.get("strategyId") ?? undefined; - - if (!memoryName) return apiError(400, "memoryName query parameter is required"); - if (namespace && namespacePath) { - return apiError(400, "'namespace' and 'namespacePath' query parameters are mutually exclusive"); - } - if (!namespace && !namespacePath) { - return apiError(400, "either 'namespace' or 'namespacePath' query parameter is required"); - } - - const query: ListMemoryRecordsQuery = { - memoryName, - strategyId, - ...(namespace ? { namespace } : { namespacePath: namespacePath! }), - }; - try { - const result = await memory.list(query); - return json(result.success ? 200 : 500, result); - } catch { - return apiError(500, "Failed to list memory records"); - } -} - -/** POST /api/memory/search — semantic search across memory records. */ -export async function handleRetrieveMemoryRecords( - deps: InspectorDeps, - request: HttpRequest, -): Promise { - const memory = deps.aws?.memory; - if (!memory) return apiError(404, "Memory search is not available"); - - const parsed = parseJsonBody(request.body); - const memoryName = asString(parsed?.memoryName); - const namespace = asString(parsed?.namespace); - const namespacePath = asString(parsed?.namespacePath); - const searchQuery = asString(parsed?.searchQuery); - const strategyId = asString(parsed?.strategyId); - - if (!memoryName) return apiError(400, "memoryName is required"); - if (namespace && namespacePath) { - return apiError(400, "'namespace' and 'namespacePath' request fields are mutually exclusive"); - } - if (!namespace && !namespacePath) { - return apiError(400, "either 'namespace' or 'namespacePath' request field is required"); - } - if (!searchQuery) return apiError(400, "searchQuery is required"); - - const searchRequest: RetrieveMemoryRecordsRequest = { - memoryName, - searchQuery, - strategyId, - ...(namespace ? { namespace } : { namespacePath: namespacePath! }), - }; - try { - const result = await memory.search(searchRequest); - return json(result.success ? 200 : 500, result); - } catch { - return apiError(500, "Failed to search memory records"); - } -} - -/** GET /api/cloudwatch-traces?agentName=xxx|harnessName=xxx — list recent traces. */ -export async function handleListCloudWatchTraces( - deps: InspectorDeps, - url: URL, -): Promise { - const cloudwatch = deps.aws?.cloudwatchTraces; - if (!cloudwatch) return apiError(404, "CloudWatch traces are not available"); - - const selector = parseTraceSelector(url); - if ("error" in selector) return selector.error; - - try { - const result = await cloudwatch.list(selector); - return json(result.success ? 200 : 500, result); - } catch { - return apiError(500, "Failed to list CloudWatch traces"); - } -} - -/** GET /api/cloudwatch-traces/:traceId?agentName=xxx|harnessName=xxx — full trace data. */ -export async function handleGetCloudWatchTrace( - deps: InspectorDeps, - url: URL, -): Promise { - const cloudwatch = deps.aws?.cloudwatchTraces; - if (!cloudwatch) return apiError(404, "CloudWatch traces are not available"); - - const traceId = url.pathname.replace("/api/cloudwatch-traces/", ""); - if (!traceId) return apiError(400, "traceId is required in the URL path"); - if (!TRACE_ID_PATTERN.test(traceId)) return apiError(400, "Invalid trace ID format"); - - const selector = parseTraceSelector(url); - if ("error" in selector) return selector.error; - - try { - const result = await cloudwatch.get({ ...selector, traceId }); - return json(result.success ? 200 : 500, result); - } catch { - return apiError(500, "Failed to get CloudWatch trace"); - } -} - -function parseTraceSelector( - url: URL, -): - | { agentName?: string; harnessName?: string; startTime?: number; endTime?: number } - | { error: HttpResponse } { - const agentName = url.searchParams.get("agentName") ?? undefined; - const harnessName = url.searchParams.get("harnessName") ?? undefined; - - if (!agentName && !harnessName) { - return { error: apiError(400, "Either agentName or harnessName query parameter is required") }; - } - if (agentName && harnessName) { - return { error: apiError(400, "Provide either agentName or harnessName, not both") }; - } - - const startTime = parseTimeParam(url, "startTime"); - if (startTime.error) return { error: startTime.error }; - const endTime = parseTimeParam(url, "endTime"); - if (endTime.error) return { error: endTime.error }; - - return { agentName, harnessName, startTime: startTime.value, endTime: endTime.value }; -} diff --git a/src/core/dev/inspector/harness.ts b/src/core/dev/inspector/harness.ts deleted file mode 100644 index 3d2eea751..000000000 --- a/src/core/dev/inspector/harness.ts +++ /dev/null @@ -1,89 +0,0 @@ -/** - * Deployed-harness invocation routes: POST /invocations with a harnessName in - * the body, and POST /api/harness/tool-response. Both stream harness events - * back as `data: ` SSE frames, per the reference harness handlers. - */ -import { randomUUID } from "node:crypto"; -import type { HttpRequest, HttpResponse } from "../../../io/httpServer"; -import type { HarnessInvocationOverrides, HarnessMessage } from "./api"; -import { asString } from "./invocations"; -import { apiError, errorMessage, parseJsonBody, sse, sseEvent } from "./respond"; -import type { InspectorDeps, InspectorHarness } from "./types"; - -/** POST /invocations with `harnessName` — invoke a deployed harness with one user prompt. */ -export async function handleHarnessInvocation( - deps: InspectorDeps, - body: Record, -): Promise { - const harness = deps.aws?.harness; - if (!harness) return apiError(404, "Harness invocation is not available"); - - const harnessName = asString(body.harnessName); - if (!harnessName) return apiError(400, "harnessName is required"); - const prompt = asString(body.prompt); - if (!prompt) return apiError(400, "prompt is required"); - - const sessionId = asString(body.sessionId) || randomUUID(); - return invokeHarness(harness, { - harnessName, - sessionId, - messages: [{ role: "user", content: [{ text: prompt }] }], - userId: asString(body.userId), - overrides: body.harnessOverrides as HarnessInvocationOverrides | undefined, - }); -} - -/** POST /api/harness/tool-response — continue a harness session with tool results. */ -export async function handleHarnessToolResponse( - deps: InspectorDeps, - request: HttpRequest, -): Promise { - const harness = deps.aws?.harness; - if (!harness) return apiError(404, "Harness invocation is not available"); - - const raw = parseJsonBody(request.body); - if (!raw) return apiError(400, "Invalid JSON"); - const harnessName = asString(raw.harnessName); - if (!harnessName) return apiError(400, "harnessName is required"); - if (!Array.isArray(raw.messages)) return apiError(400, "messages array is required"); - const sessionId = asString(raw.sessionId); - if (!sessionId) return apiError(400, "sessionId is required"); - - return invokeHarness(harness, { - harnessName, - sessionId, - messages: raw.messages as HarnessMessage[], - overrides: raw.harnessOverrides as HarnessInvocationOverrides | undefined, - }); -} - -async function invokeHarness( - harness: InspectorHarness, - request: { - harnessName: string; - sessionId: string; - messages: HarnessMessage[]; - userId?: string; - overrides?: HarnessInvocationOverrides; - }, -): Promise { - let stream: AsyncIterable; - try { - stream = await harness.invoke(request); - } catch (error) { - // Rejected before any event streamed (e.g. unknown harness): a JSON error - // is still possible, matching the reference's pre-stream 404. - return apiError(404, errorMessage(error)); - } - return sse(frameHarnessStream(stream), request.sessionId); -} - -async function* frameHarnessStream( - stream: AsyncIterable, -): AsyncGenerator { - try { - for await (const event of stream) yield sseEvent(event); - } catch (error) { - yield sseEvent({ type: "error", errorType: "invocationError", message: errorMessage(error) }); - } -} diff --git a/src/core/dev/inspector/invocations.test.ts b/src/core/dev/inspector/invocations.test.ts index 35783a57e..6a33dd20e 100644 --- a/src/core/dev/inspector/invocations.test.ts +++ b/src/core/dev/inspector/invocations.test.ts @@ -204,182 +204,3 @@ describe("POST /invocations (AGUI agents)", () => { expect(input.tools).toEqual([]); }); }); - -describe("POST /invocations?target=deployed", () => { - test("streams the deployed invocation as SSE data frames", async () => { - const calls: unknown[] = []; - const { url } = await farm.inspector({ - supervisor: fakeSupervisor(), - aws: { - invokeDeployed: async (request) => { - calls.push(request); - return { - sessionId: "aws-session", - stream: (async function* () { - yield "Hel"; - yield { text: "lo" }; - })(), - }; - }, - }, - }); - - const response = await post(url, "/invocations?target=deployed", { - agentName: "orders", - prompt: "hi", - sessionId: "session-1", - }); - - expect(response.status).toBe(200); - expect(response.headers.get("x-session-id")).toBe("aws-session"); - expect(await response.text()).toBe('data: "Hel"\n\ndata: {"text":"lo"}\n\n'); - expect(calls).toEqual([ - { - agentName: "orders", - targetName: undefined, - prompt: "hi", - sessionId: "session-1", - userId: undefined, - }, - ]); - }); - - test("a stream failure becomes an SSE error frame", async () => { - const { url } = await farm.inspector({ - supervisor: fakeSupervisor(), - aws: { - invokeDeployed: async () => ({ - stream: (async function* () { - yield "partial"; - throw new Error("throttled"); - })(), - }), - }, - }); - const response = await post(url, "/invocations?target=deployed", { prompt: "hi" }); - expect(await response.text()).toBe('data: "partial"\n\ndata: {"error":"throttled"}\n\n'); - }); - - test("answers 404 when deployed invocation is not wired", async () => { - const { url } = await farm.inspector({ supervisor: fakeSupervisor() }); - const response = await post(url, "/invocations?target=deployed", { prompt: "hi" }); - expect(response.status).toBe(404); - expect(await response.json()).toEqual({ - success: false, - error: "Deployed invocation is not available", - }); - }); -}); - -describe("harness invocations", () => { - const harnessDeps = (events: unknown[] = [{ type: "text", text: "hi" }]) => { - const calls: unknown[] = []; - const deps: InspectorDeps = { - supervisor: fakeSupervisor(), - aws: { - harness: { - invoke: async (request) => { - calls.push(request); - if (request.harnessName === "ghost") throw new Error('Harness "ghost" not found'); - return (async function* () { - yield* events; - })(); - }, - }, - }, - }; - return { deps, calls }; - }; - - test("a harnessName in the /invocations body routes to the harness and streams SSE", async () => { - const { deps, calls } = harnessDeps(); - const { url } = await farm.inspector(deps); - const response = await post(url, "/invocations", { - harnessName: "support", - prompt: "help", - sessionId: "hs-1", - }); - expect(response.status).toBe(200); - expect(response.headers.get("x-session-id")).toBe("hs-1"); - expect(await response.text()).toBe('data: {"type":"text","text":"hi"}\n\n'); - expect(calls).toMatchObject([ - { - harnessName: "support", - sessionId: "hs-1", - messages: [{ role: "user", content: [{ text: "help" }] }], - }, - ]); - }); - - test("an unknown harness yields a 404 error envelope", async () => { - const { deps } = harnessDeps(); - const { url } = await farm.inspector(deps); - const response = await post(url, "/invocations", { harnessName: "ghost", prompt: "help" }); - expect(response.status).toBe(404); - expect(await response.json()).toEqual({ success: false, error: 'Harness "ghost" not found' }); - }); - - test("harness invocation requires a prompt", async () => { - const { deps } = harnessDeps(); - const { url } = await farm.inspector(deps); - const response = await post(url, "/invocations", { harnessName: "support" }); - expect(response.status).toBe(400); - expect(await response.json()).toEqual({ success: false, error: "prompt is required" }); - }); - - test("answers 404 when harness invocation is not wired", async () => { - const { url } = await farm.inspector({ supervisor: fakeSupervisor() }); - const response = await post(url, "/invocations", { harnessName: "support", prompt: "help" }); - expect(response.status).toBe(404); - expect(await response.json()).toEqual({ - success: false, - error: "Harness invocation is not available", - }); - }); - - test("POST /api/harness/tool-response continues the session with tool results", async () => { - const { deps, calls } = harnessDeps([{ type: "done" }]); - const { url } = await farm.inspector(deps); - const messages = [{ role: "user", content: [{ toolResult: { ok: true } }] }]; - const response = await post(url, "/api/harness/tool-response", { - harnessName: "support", - sessionId: "hs-1", - messages, - }); - expect(response.status).toBe(200); - expect(await response.text()).toBe('data: {"type":"done"}\n\n'); - expect(calls).toMatchObject([{ harnessName: "support", sessionId: "hs-1", messages }]); - }); - - test("tool-response requires a sessionId", async () => { - const { deps } = harnessDeps(); - const { url } = await farm.inspector(deps); - const response = await post(url, "/api/harness/tool-response", { - harnessName: "support", - messages: [], - }); - expect(response.status).toBe(400); - expect(await response.json()).toEqual({ success: false, error: "sessionId is required" }); - }); - - test("a harness stream failure becomes an SSE error event", async () => { - const deps: InspectorDeps = { - supervisor: fakeSupervisor(), - aws: { - harness: { - invoke: async () => - (async function* () { - yield { type: "text", text: "partial" }; - throw new Error("stream cut"); - })(), - }, - }, - }; - const { url } = await farm.inspector(deps); - const response = await post(url, "/invocations", { harnessName: "support", prompt: "help" }); - expect(await response.text()).toBe( - 'data: {"type":"text","text":"partial"}\n\n' + - 'data: {"type":"error","errorType":"invocationError","message":"stream cut"}\n\n', - ); - }); -}); diff --git a/src/core/dev/inspector/invocations.ts b/src/core/dev/inspector/invocations.ts index 069f6eebe..b17784893 100644 --- a/src/core/dev/inspector/invocations.ts +++ b/src/core/dev/inspector/invocations.ts @@ -1,12 +1,10 @@ /** - * POST /invocations — the protocol-aware proxy to a locally running agent, a - * deployed runtime (?target=deployed), or a deployed harness (harnessName in - * the body). Ported from the reference web-ui/handlers/invocations.ts; the SSE - * framing (`data: \n\n`) is part of the SPA wire contract. + * POST /invocations — the protocol-aware proxy to a locally running agent. + * Ported from the reference web-ui/handlers/invocations.ts; the SSE framing + * (`data: \n\n`) is part of the SPA wire contract. */ import { randomUUID } from "node:crypto"; import type { HttpRequest, HttpResponse } from "../../../io/httpServer"; -import { handleHarnessInvocation } from "./harness"; import { apiError, errorMessage, @@ -16,21 +14,14 @@ import { sse, sseEvent, } from "./respond"; -import type { DeployedInvocation, InspectorDeps } from "./types"; +import type { InspectorDeps } from "./types"; export async function handleInvocations( deps: InspectorDeps, request: HttpRequest, - url: URL, nextA2aId: () => number, ): Promise { - if (url.searchParams.get("target") === "deployed") { - return handleDeployedInvocation(deps, request); - } - const parsed = parseJsonBody(request.body); - if (parsed?.harnessName) return handleHarnessInvocation(deps, parsed); - const agentName = asString(parsed?.agentName); const sessionId = parsed ? (asString(parsed.sessionId) ?? randomUUID()) : undefined; const userId = asString(parsed?.userId); @@ -326,46 +317,6 @@ async function invokeAguiAgent( }; } -/** Invoke a deployed runtime via the injected AWS capability, SSE-framing its stream. */ -async function handleDeployedInvocation( - deps: InspectorDeps, - request: HttpRequest, -): Promise { - const aws = deps.aws; - if (!aws?.invokeDeployed) return apiError(404, "Deployed invocation is not available"); - - const parsed = parseJsonBody(request.body); - if (!parsed) return apiError(400, "Invalid JSON body"); - const prompt = asString(parsed.prompt); - if (!prompt) return apiError(400, "prompt is required"); - - const sessionId = asString(parsed.sessionId) ?? randomUUID(); - let invocation: DeployedInvocation; - try { - invocation = await aws.invokeDeployed({ - agentName: asString(parsed.agentName), - targetName: asString(parsed.targetName), - prompt, - sessionId, - userId: asString(parsed.userId), - }); - } catch (error) { - return apiError(502, `Invoke failed: ${errorMessage(error)}`); - } - - return sse(frameDeployedStream(invocation.stream), invocation.sessionId ?? sessionId); -} - -async function* frameDeployedStream( - stream: AsyncIterable, -): AsyncGenerator { - try { - for await (const chunk of stream) yield sseEvent(chunk); - } catch (error) { - yield sseEvent({ error: errorMessage(error) }); - } -} - export function asString(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } diff --git a/src/core/dev/inspector/proxies.test.ts b/src/core/dev/inspector/proxies.test.ts index 33336c7bd..c4b8cd3c1 100644 --- a/src/core/dev/inspector/proxies.test.ts +++ b/src/core/dev/inspector/proxies.test.ts @@ -1,7 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test"; import type { HttpRequest } from "../../../io/httpServer"; import { ServerFarm, fakeSupervisor, get, post, runningAgent } from "./testkit"; -import type { InspectorDeps } from "./types"; const farm = new ServerFarm(); afterEach(() => farm.close()); @@ -65,98 +64,6 @@ describe("POST /api/mcp (local)", () => { }); }); -describe("POST /api/mcp?target=deployed", () => { - const mcpDeps = () => { - const calls: [string, unknown][] = []; - const deps: InspectorDeps = { - supervisor: fakeSupervisor(), - aws: { - mcp: { - initialize: async (args) => { - calls.push(["initialize", args]); - return { sessionId: "deployed-1" }; - }, - listTools: async (args) => { - calls.push(["listTools", args]); - return { tools: [{ name: "search" }] }; - }, - callTool: async (args) => { - calls.push(["callTool", args]); - return "tool output"; - }, - }, - }, - }; - return { deps, calls }; - }; - - test("initialize returns the deployed MCP session id", async () => { - const { deps, calls } = mcpDeps(); - const { url } = await farm.inspector(deps); - const response = await post(url, "/api/mcp?target=deployed", { - agentName: "orders", - body: { jsonrpc: "2.0", method: "initialize" }, - }); - expect(await response.json()).toEqual({ - success: true, - result: { jsonrpc: "2.0", result: {} }, - sessionId: "deployed-1", - }); - expect(calls).toEqual([ - ["initialize", { agentName: "orders", targetName: undefined, sessionId: undefined }], - ]); - }); - - test("tools/list and tools/call wrap the results in JSON-RPC envelopes", async () => { - const { deps } = mcpDeps(); - const { url } = await farm.inspector(deps); - - const list = await post(url, "/api/mcp?target=deployed", { - body: { method: "tools/list" }, - }); - expect(await list.json()).toEqual({ - success: true, - result: { jsonrpc: "2.0", result: { tools: [{ name: "search" }] } }, - }); - - const call = await post(url, "/api/mcp?target=deployed", { - body: { method: "tools/call", params: { name: "search", arguments: { q: "x" } } }, - }); - expect(await call.json()).toEqual({ - success: true, - result: { - jsonrpc: "2.0", - result: { content: [{ type: "text", text: "tool output" }] }, - }, - }); - }); - - test("rejects unsupported methods and missing tool names", async () => { - const { deps } = mcpDeps(); - const { url } = await farm.inspector(deps); - expect( - await (await post(url, "/api/mcp?target=deployed", { body: { method: "ping" } })).json(), - ).toEqual({ success: false, error: "Unsupported MCP method: ping" }); - expect( - await ( - await post(url, "/api/mcp?target=deployed", { body: { method: "tools/call", params: {} } }) - ).json(), - ).toEqual({ success: false, error: "tools/call requires params.name" }); - }); - - test("answers 404 when deployed MCP is not wired", async () => { - const { url } = await farm.inspector({ supervisor: fakeSupervisor() }); - const response = await post(url, "/api/mcp?target=deployed", { - body: { method: "initialize" }, - }); - expect(response.status).toBe(404); - expect(await response.json()).toEqual({ - success: false, - error: "Deployed MCP is not available", - }); - }); -}); - describe("GET /api/a2a/agent-card", () => { test("fetches the card from the running agent's well-known route", async () => { const agent = await farm.serve((request) => @@ -191,193 +98,3 @@ describe("GET /api/a2a/agent-card", () => { }); }); }); - -describe("memory routes", () => { - const memoryDeps = () => { - const calls: [string, unknown][] = []; - const deps: InspectorDeps = { - supervisor: fakeSupervisor(), - aws: { - memory: { - list: async (args) => { - calls.push(["list", args]); - return { success: true, records: [] }; - }, - search: async (args) => { - calls.push(["search", args]); - return { success: true, records: [] }; - }, - }, - }, - }; - return { deps, calls }; - }; - - test("GET /api/memory lists records for a namespace", async () => { - const { deps, calls } = memoryDeps(); - const { url } = await farm.inspector(deps); - const response = await get(url, "/api/memory?memoryName=recall&namespace=/users/u1/facts"); - expect(response.status).toBe(200); - expect(await response.json()).toEqual({ success: true, records: [] }); - expect(calls).toEqual([ - ["list", { memoryName: "recall", strategyId: undefined, namespace: "/users/u1/facts" }], - ]); - }); - - test("GET /api/memory validates the namespace selector", async () => { - const { deps } = memoryDeps(); - const { url } = await farm.inspector(deps); - expect(await (await get(url, "/api/memory?namespace=/x")).json()).toEqual({ - success: false, - error: "memoryName query parameter is required", - }); - expect( - await (await get(url, "/api/memory?memoryName=recall&namespace=/x&namespacePath=/y")).json(), - ).toEqual({ - success: false, - error: "'namespace' and 'namespacePath' query parameters are mutually exclusive", - }); - expect(await (await get(url, "/api/memory?memoryName=recall")).json()).toEqual({ - success: false, - error: "either 'namespace' or 'namespacePath' query parameter is required", - }); - }); - - test("POST /api/memory/search runs a semantic search", async () => { - const { deps, calls } = memoryDeps(); - const { url } = await farm.inspector(deps); - const response = await post(url, "/api/memory/search", { - memoryName: "recall", - namespacePath: "/users", - searchQuery: "favorite color", - }); - expect(response.status).toBe(200); - expect(await response.json()).toEqual({ success: true, records: [] }); - expect(calls).toEqual([ - [ - "search", - { - memoryName: "recall", - searchQuery: "favorite color", - strategyId: undefined, - namespacePath: "/users", - }, - ], - ]); - }); - - test("POST /api/memory/search requires a searchQuery", async () => { - const { deps } = memoryDeps(); - const { url } = await farm.inspector(deps); - const response = await post(url, "/api/memory/search", { - memoryName: "recall", - namespace: "/users/u1/facts", - }); - expect(response.status).toBe(400); - expect(await response.json()).toEqual({ success: false, error: "searchQuery is required" }); - }); - - test("memory routes answer 404 when the capability is not wired", async () => { - const { url } = await farm.inspector({ supervisor: fakeSupervisor() }); - expect((await get(url, "/api/memory?memoryName=recall&namespace=/x")).status).toBe(404); - expect(await (await get(url, "/api/memory?memoryName=recall&namespace=/x")).json()).toEqual({ - success: false, - error: "Memory browsing is not available", - }); - expect( - await ( - await post(url, "/api/memory/search", { - memoryName: "recall", - namespace: "/x", - searchQuery: "q", - }) - ).json(), - ).toEqual({ success: false, error: "Memory search is not available" }); - }); -}); - -describe("cloudwatch trace routes", () => { - const cloudwatchDeps = () => { - const calls: [string, unknown][] = []; - const deps: InspectorDeps = { - supervisor: fakeSupervisor(), - aws: { - cloudwatchTraces: { - list: async (args) => { - calls.push(["list", args]); - return { success: true, traces: [{ traceId: "abc123", timestamp: "t" }] }; - }, - get: async (args) => { - calls.push(["get", args]); - return { success: true, records: [], spans: [] }; - }, - }, - }, - }; - return { deps, calls }; - }; - - test("lists traces for an agent with a time range", async () => { - const { deps, calls } = cloudwatchDeps(); - const { url } = await farm.inspector(deps); - const response = await get(url, "/api/cloudwatch-traces?agentName=orders&startTime=5"); - expect(response.status).toBe(200); - expect(await response.json()).toEqual({ - success: true, - traces: [{ traceId: "abc123", timestamp: "t" }], - }); - expect(calls).toEqual([ - ["list", { agentName: "orders", harnessName: undefined, startTime: 5, endTime: undefined }], - ]); - }); - - test("requires exactly one of agentName or harnessName", async () => { - const { deps } = cloudwatchDeps(); - const { url } = await farm.inspector(deps); - expect(await (await get(url, "/api/cloudwatch-traces")).json()).toEqual({ - success: false, - error: "Either agentName or harnessName query parameter is required", - }); - expect( - await (await get(url, "/api/cloudwatch-traces?agentName=a&harnessName=h")).json(), - ).toEqual({ success: false, error: "Provide either agentName or harnessName, not both" }); - }); - - test("gets one trace by id", async () => { - const { deps, calls } = cloudwatchDeps(); - const { url } = await farm.inspector(deps); - const response = await get(url, "/api/cloudwatch-traces/1-abc-DEF?harnessName=support"); - expect(response.status).toBe(200); - expect(await response.json()).toEqual({ success: true, records: [], spans: [] }); - expect(calls).toEqual([ - [ - "get", - { - agentName: undefined, - harnessName: "support", - startTime: undefined, - endTime: undefined, - traceId: "1-abc-DEF", - }, - ], - ]); - }); - - test("rejects a non-hex trace id", async () => { - const { deps } = cloudwatchDeps(); - const { url } = await farm.inspector(deps); - const response = await get(url, "/api/cloudwatch-traces/..%2Fetc?agentName=orders"); - expect(response.status).toBe(400); - expect(await response.json()).toEqual({ success: false, error: "Invalid trace ID format" }); - }); - - test("answers 404 when the capability is not wired", async () => { - const { url } = await farm.inspector({ supervisor: fakeSupervisor() }); - const response = await get(url, "/api/cloudwatch-traces?agentName=orders"); - expect(response.status).toBe(404); - expect(await response.json()).toEqual({ - success: false, - error: "CloudWatch traces are not available", - }); - }); -}); diff --git a/src/core/dev/inspector/proxies.ts b/src/core/dev/inspector/proxies.ts index 5abecb310..7d92cef32 100644 --- a/src/core/dev/inspector/proxies.ts +++ b/src/core/dev/inspector/proxies.ts @@ -1,7 +1,7 @@ /** - * Thin proxies to a locally running agent: POST /api/mcp (JSON-RPC forward, - * with a deployed variant behind the injected AWS capability) and - * GET /api/a2a/agent-card. Ported from the reference mcp-proxy.ts / a2a-proxy.ts. + * Thin proxies to a locally running agent: POST /api/mcp (JSON-RPC forward) + * and GET /api/a2a/agent-card. Ported from the reference mcp-proxy.ts / + * a2a-proxy.ts. */ import type { HttpRequest, HttpResponse } from "../../../io/httpServer"; import { asString } from "./invocations"; @@ -12,10 +12,7 @@ import type { InspectorDeps } from "./types"; export async function handleMcpProxy( deps: InspectorDeps, request: HttpRequest, - url: URL, ): Promise { - if (url.searchParams.get("target") === "deployed") return handleDeployedMcp(deps, request); - const parsed = parseJsonBody(request.body); if (!parsed) return apiError(400, "Invalid JSON"); const agentName = asString(parsed.agentName); @@ -54,52 +51,6 @@ export async function handleMcpProxy( return json(200, { success: true, result, sessionId: responseSessionId }); } -/** POST /api/mcp?target=deployed — MCP JSON-RPC against a deployed runtime. */ -async function handleDeployedMcp(deps: InspectorDeps, request: HttpRequest): Promise { - const mcp = deps.aws?.mcp; - if (!mcp) return apiError(404, "Deployed MCP is not available"); - - const parsed = parseJsonBody(request.body); - if (!parsed) return apiError(400, "Invalid JSON"); - const body = parsed.body as Record | undefined; - if (!body || typeof body !== "object") return apiError(400, "body is required"); - - const target = { - agentName: asString(parsed.agentName), - targetName: asString(parsed.targetName), - sessionId: asString(parsed.sessionId), - }; - const method = asString(body.method); - - try { - if (method === "initialize") { - const { sessionId } = await mcp.initialize(target); - return json(200, { success: true, result: { jsonrpc: "2.0", result: {} }, sessionId }); - } - if (method === "tools/list") { - const { tools } = await mcp.listTools(target); - return json(200, { success: true, result: { jsonrpc: "2.0", result: { tools } } }); - } - if (method === "tools/call") { - const params = body.params as - { name?: string; arguments?: Record } | undefined; - if (!params?.name) return apiError(400, "tools/call requires params.name"); - const response = await mcp.callTool({ - ...target, - name: params.name, - arguments: params.arguments ?? {}, - }); - return json(200, { - success: true, - result: { jsonrpc: "2.0", result: { content: [{ type: "text", text: response }] } }, - }); - } - return apiError(400, `Unsupported MCP method: ${method}`); - } catch (error) { - return apiError(502, `MCP invoke failed: ${errorMessage(error)}`); - } -} - /** GET /api/a2a/agent-card?agentName=xxx — fetch the running agent's A2A card. */ export async function handleA2aAgentCard(deps: InspectorDeps, url: URL): Promise { const agentName = url.searchParams.get("agentName") ?? undefined; diff --git a/src/core/dev/inspector/resources.ts b/src/core/dev/inspector/resources.ts index c8c66938a..251da57c3 100644 --- a/src/core/dev/inspector/resources.ts +++ b/src/core/dev/inspector/resources.ts @@ -1,51 +1,25 @@ /** * GET /api/resources — the project resource graph the SPA's resources panel - * renders, built from the resolved project spec. When a deployed-state - * capability is injected, each resource gains a deploymentStatus and its - * deployed identifiers, including pending-removal entries that exist only in - * the deployed state. Resource types the reference exposed but this project - * schema lacks (aws-targets regions, per-harness model/tool specs) are emitted - * with their neutral defaults so the wire shape stays intact. + * renders, built from the resolved project spec. Resource details the + * reference exposed but this project schema lacks (aws-targets regions, + * per-harness model/tool specs, deployed state) are emitted with their + * neutral defaults so the wire shape stays intact; deployed state returns + * with real deploy wiring. */ import type { HttpResponse } from "../../../io/httpServer"; -import type { - ResourceAgent, - ResourceCredential, - ResourceGateway, - ResourceHarness, - ResourceMemory, - ResourcesResponse, -} from "./api"; +import type { ResourcesResponse } from "./api"; import { apiError, json } from "./respond"; -import type { InspectorDeps, InspectorDeployedResources } from "./types"; +import type { InspectorDeps } from "./types"; -export async function handleResources(deps: InspectorDeps): Promise { +export function handleResources(deps: InspectorDeps): HttpResponse { const project = deps.project; if (!project) return apiError(404, "No agentcore project found"); - try { - const deployed = deps.aws?.deployedState ? await deps.aws.deployedState() : undefined; - return json(200, buildResourcesResponse(project.name, project.spec, deployed)); - } catch { - return apiError(500, "Failed to read project configuration"); - } -} - -type Spec = NonNullable["spec"]; - -function buildResourcesResponse( - projectName: string, - spec: Spec, - deployed: InspectorDeployedResources | undefined, -): ResourcesResponse { - // Only report statuses when deployed state is known; otherwise omit them. - const status = deployed - ? (entry: unknown) => (entry ? ("deployed" as const) : ("local-only" as const)) - : () => undefined; - - const agents: ResourceAgent[] = spec.runtimes.map((runtime) => { - const deployedAgent = deployed?.runtimes?.[runtime.name]; - return { + const spec = project.spec; + const resources: ResourcesResponse = { + success: true, + project: project.name, + agents: spec.runtimes.map((runtime) => ({ name: runtime.name, build: runtime.build, entrypoint: runtime.entrypoint, @@ -54,105 +28,29 @@ function buildResourcesResponse( networkMode: runtime.networkMode ?? "PUBLIC", protocol: runtime.protocol ?? "HTTP", envVars: runtime.envVars?.map((envVar) => envVar.name) ?? [], - deploymentStatus: status(deployedAgent), - deployed: deployedAgent, - }; - }); - for (const [name, deployedAgent] of removedEntries(deployed?.runtimes, agents)) { - agents.push({ - name, - build: "", - entrypoint: "", - codeLocation: "", - runtimeVersion: "", - networkMode: "", - protocol: "", - envVars: [], - deploymentStatus: "pending-removal", - deployed: deployedAgent, - }); - } - - // Harness registry entries carry only name + path; model/tool details lived - // in per-harness spec files the reference read separately. - const harnesses: ResourceHarness[] = spec.harnesses.map((harness) => ({ - name: harness.name, - model: "", - tools: [], - deploymentStatus: status(deployed?.harnesses?.[harness.name]), - deployed: deployed?.harnesses?.[harness.name], - })); - for (const [name, deployedHarness] of removedEntries(deployed?.harnesses, harnesses)) { - harnesses.push({ - name, - model: "", - tools: [], - deploymentStatus: "pending-removal", - deployed: deployedHarness, - }); - } - - const memories: ResourceMemory[] = spec.memories.map((memory) => ({ - name: memory.name, - strategies: memory.strategies.map((strategy) => ({ - type: strategy.type, - namespaceTemplates: strategy.namespaceTemplates ?? strategy.namespaces ?? [], })), - expiryDays: memory.eventExpiryDuration, - deploymentStatus: status(deployed?.memories?.[memory.name]), - deployed: deployed?.memories?.[memory.name], - })); - for (const [name, deployedMemory] of removedEntries(deployed?.memories, memories)) { - memories.push({ - name, - strategies: [], - expiryDays: undefined, - deploymentStatus: "pending-removal", - deployed: deployedMemory, - }); - } - - const credentials: ResourceCredential[] = spec.credentials.map((credential) => ({ - name: credential.name, - type: credential.authorizerType, - deploymentStatus: status(deployed?.credentials?.[credential.name]), - deployed: deployed?.credentials?.[credential.name], - })); - for (const [name, deployedCredential] of removedEntries(deployed?.credentials, credentials)) { - credentials.push({ - name, - type: "", - deploymentStatus: "pending-removal", - deployed: deployedCredential, - }); - } - - const gateways: ResourceGateway[] = spec.agentCoreGateways.map((gateway) => ({ - name: gateway.name, - targets: gateway.targets.map((target) => ({ - name: target.toolDefinitions?.[0]?.name ?? target.name, - targetType: target.targetType, + // Harness registry entries carry only name + path; model/tool details + // lived in per-harness spec files the reference read separately. + harnesses: spec.harnesses.map((harness) => ({ name: harness.name, model: "", tools: [] })), + memories: spec.memories.map((memory) => ({ + name: memory.name, + strategies: memory.strategies.map((strategy) => ({ + type: strategy.type, + namespaceTemplates: strategy.namespaceTemplates ?? strategy.namespaces ?? [], + })), + expiryDays: memory.eventExpiryDuration, + })), + credentials: spec.credentials.map((credential) => ({ + name: credential.name, + type: credential.authorizerType, + })), + gateways: spec.agentCoreGateways.map((gateway) => ({ + name: gateway.name, + targets: gateway.targets.map((target) => ({ + name: target.toolDefinitions?.[0]?.name ?? target.name, + targetType: target.targetType, + })), })), - deploymentStatus: status(deployed?.gateways?.[gateway.name]), - deployed: deployed?.gateways?.[gateway.name], - })); - for (const [name, deployedGateway] of removedEntries(deployed?.gateways, gateways)) { - gateways.push({ - name, - targets: [], - deploymentStatus: "pending-removal", - deployed: deployedGateway, - }); - } - - return { - success: true, - project: projectName, - agents, - harnesses, - memories, - credentials, - gateways, mcpRuntimeTools: (spec.mcpRuntimeTools ?? []).map((tool) => ({ name: tool.name, bindings: tool.bindings ?? [], @@ -188,14 +86,5 @@ function buildResourcesResponse( // The project schema has no aws-targets equivalent yet. deploymentTargets: [], }; -} - -/** Deployed entries whose names no longer exist in the local spec. */ -function removedEntries( - deployedByName: Record | undefined, - local: { name: string }[], -): [string, T][] { - if (!deployedByName) return []; - const localNames = new Set(local.map((entry) => entry.name)); - return Object.entries(deployedByName).filter(([name]) => !localNames.has(name)); + return json(200, resources); } diff --git a/src/core/dev/inspector/server.test.ts b/src/core/dev/inspector/server.test.ts index ca5b1ccb5..558263a8b 100644 --- a/src/core/dev/inspector/server.test.ts +++ b/src/core/dev/inspector/server.test.ts @@ -259,32 +259,6 @@ describe("GET /api/resources", () => { }); }); - test("merges deployed state and reports pending removals", async () => { - const { url } = await farm.inspector( - deps({ - project: project(), - aws: { - deployedState: async () => ({ - runtimes: { - orders: { runtimeId: "r-1", runtimeArn: "arn:r-1", roleArn: "arn:role" }, - legacy: { runtimeId: "r-9", runtimeArn: "arn:r-9", roleArn: "arn:role" }, - }, - }), - }, - }), - ); - - const body = (await (await get(url, "/api/resources")).json()) as { - agents: { name: string; deploymentStatus?: string; deployed?: { runtimeId: string } }[]; - memories: { deploymentStatus?: string }[]; - }; - expect(body.agents).toMatchObject([ - { name: "orders", deploymentStatus: "deployed", deployed: { runtimeId: "r-1" } }, - { name: "legacy", deploymentStatus: "pending-removal", deployed: { runtimeId: "r-9" } }, - ]); - expect(body.memories[0]?.deploymentStatus).toBe("local-only"); - }); - test("answers 404 outside a project", async () => { const { url } = await farm.inspector(deps()); const response = await get(url, "/api/resources"); diff --git a/src/core/dev/inspector/server.ts b/src/core/dev/inspector/server.ts index 2cf92ae0d..8349f7af7 100644 --- a/src/core/dev/inspector/server.ts +++ b/src/core/dev/inspector/server.ts @@ -8,13 +8,6 @@ import { ResourceNotFoundError } from "../../../errors"; import type { HttpRequest, HttpRequestHandler, HttpResponse } from "../../../io/httpServer"; import type { StatusResponse } from "./api"; -import { - handleGetCloudWatchTrace, - handleListCloudWatchTraces, - handleListMemoryRecords, - handleRetrieveMemoryRecords, -} from "./aws"; -import { handleHarnessToolResponse } from "./harness"; import { asString, handleInvocations } from "./invocations"; import { handleA2aAgentCard, handleMcpProxy } from "./proxies"; import { handleResources } from "./resources"; @@ -76,29 +69,14 @@ async function route( if (method === "GET" && pathname === "/api/resources") return handleResources(deps); if (method === "GET" && pathname.startsWith("/api/traces/")) return handleGetTrace(deps, url); if (method === "GET" && pathname.startsWith("/api/traces")) return handleListTraces(deps, url); - if (method === "GET" && pathname.startsWith("/api/cloudwatch-traces/")) { - return handleGetCloudWatchTrace(deps, url); - } - if (method === "GET" && pathname.startsWith("/api/cloudwatch-traces")) { - return handleListCloudWatchTraces(deps, url); - } if (method === "POST" && pathname === "/api/start") return handleStart(deps, request); - if (method === "POST" && pathname === "/api/harness/tool-response") { - return handleHarnessToolResponse(deps, request); - } if (method === "POST" && pathname === "/invocations") { - return handleInvocations(deps, request, url, nextA2aId); + return handleInvocations(deps, request, nextA2aId); } - if (method === "POST" && pathname === "/api/mcp") return handleMcpProxy(deps, request, url); + if (method === "POST" && pathname === "/api/mcp") return handleMcpProxy(deps, request); if (method === "GET" && pathname.startsWith("/api/a2a/agent-card")) { return handleA2aAgentCard(deps, url); } - if (method === "POST" && pathname === "/api/memory/search") { - return handleRetrieveMemoryRecords(deps, request); - } - if (method === "GET" && pathname.startsWith("/api/memory")) { - return handleListMemoryRecords(deps, url); - } if (method === "GET" && !pathname.startsWith("/api/")) { const asset = await serveAsset(deps, pathname); if (asset) return asset; diff --git a/src/core/dev/inspector/types.ts b/src/core/dev/inspector/types.ts index 1c75c9ddc..e5a35cb3c 100644 --- a/src/core/dev/inspector/types.ts +++ b/src/core/dev/inspector/types.ts @@ -2,22 +2,10 @@ * Dependency interfaces for the Inspector HTTP server, owned by the consumer * (this package) per the codebase's dependency-inversion rule. Each interface * is exactly as wide as the routes that use it; the dev handler wires real - * implementations (DevSupervisor, InspectorTraceSource, AWS clients) at the - * edge, and tests inject fakes. + * implementations (DevSupervisor, InspectorTraceSource, bundled assets) at + * the edge, and tests inject fakes. */ import type { Project } from "../../../handlers/project/types"; -import type { - DeployedCredentialState, - DeployedGatewayState, - DeployedHarnessState, - DeployedMemoryState, - DeployedAgentState, - HarnessInvocationOverrides, - HarnessMessage, - ListMemoryRecordsQuery, - MemoryRecordsResponse, - RetrieveMemoryRecordsRequest, -} from "./api"; /** One managed agent's state, as reported by GET /api/status. */ export interface InspectorAgentStatus { @@ -56,104 +44,6 @@ export interface InspectorAssets { read(path: string): Promise<{ body: Uint8Array; contentType: string } | undefined>; } -/** A streaming invocation of a deployed AgentCore runtime (?target=deployed). */ -export interface DeployedInvocation { - sessionId?: string; - /** Chunks are SSE-framed by the server as `data: ` events. */ - stream: AsyncIterable; -} - -/** MCP JSON-RPC operations against a deployed runtime (POST /api/mcp?target=deployed). */ -export interface InspectorDeployedMcp { - initialize(args: { - agentName?: string; - targetName?: string; - sessionId?: string; - }): Promise<{ sessionId?: string }>; - listTools(args: { - agentName?: string; - targetName?: string; - sessionId?: string; - }): Promise<{ tools: unknown[] }>; - callTool(args: { - agentName?: string; - targetName?: string; - sessionId?: string; - name: string; - arguments: Record; - }): Promise; -} - -/** Memory record reads backing GET /api/memory and POST /api/memory/search. */ -export interface InspectorMemory { - list(args: ListMemoryRecordsQuery): Promise; - search(args: RetrieveMemoryRecordsRequest): Promise; -} - -/** CloudWatch trace reads backing GET /api/cloudwatch-traces[/:id]. */ -export interface InspectorCloudWatchTraces { - list(args: { - agentName?: string; - harnessName?: string; - startTime?: number; - endTime?: number; - }): Promise<{ success: boolean; traces?: unknown[]; error?: string }>; - get(args: { - agentName?: string; - harnessName?: string; - traceId: string; - startTime?: number; - endTime?: number; - }): Promise<{ success: boolean; records?: unknown[]; spans?: unknown[]; error?: string }>; -} - -/** Deployed harness invocations, streamed back to the SPA as SSE. */ -export interface InspectorHarness { - /** - * Resolves to the event stream once the invocation is accepted; a rejection - * before that (e.g. unknown harness) becomes a JSON error response, while - * stream errors become SSE error events. - */ - invoke(request: { - harnessName: string; - sessionId: string; - messages: HarnessMessage[]; - userId?: string; - overrides?: HarnessInvocationOverrides; - }): Promise>; -} - -/** Deployed resource state merged into GET /api/resources when available. */ -export interface InspectorDeployedResources { - runtimes?: Record; - harnesses?: Record; - memories?: Record; - credentials?: Record; - gateways?: Record; -} - -/** - * AWS-backed capabilities. Every member is optional: when one is absent its - * routes answer 404 `{success:false, error:'... not available'}` and the SPA - * degrades gracefully. - */ -export interface InspectorAws { - /** Invoke a deployed runtime (POST /invocations?target=deployed). */ - invokeDeployed?(request: { - agentName?: string; - targetName?: string; - prompt: string; - sessionId?: string; - userId?: string; - }): Promise; - mcp?: InspectorDeployedMcp; - memory?: InspectorMemory; - cloudwatchTraces?: InspectorCloudWatchTraces; - harness?: InspectorHarness; - /** Deployed resource state for GET /api/resources. */ - deployedState?(): Promise; -} - /** Everything the Inspector request handler is composed from. */ export interface InspectorDeps { supervisor: InspectorSupervisor; @@ -163,5 +53,4 @@ export interface InspectorDeps { project?: Project; /** Agent name to pre-select in the UI. */ selectedAgent?: string; - aws?: InspectorAws; } From 937d318ab98b3c9b3208083b240a62a135095ff2 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Tue, 18 Aug 2026 14:13:07 -0400 Subject: [PATCH 16/16] refactor(dev): drop the Inspector's unreferenced wire-type module Only StatusResponse and ResourcesResponse were referenced, each annotating a literal that already expresses the exact SPA wire shape; the other 15 interfaces had no references at all. The literals stay the documented contract. --- src/core/dev/inspector/api.ts | 233 ---------------------------- src/core/dev/inspector/resources.ts | 4 +- src/core/dev/inspector/server.ts | 4 +- 3 files changed, 4 insertions(+), 237 deletions(-) delete mode 100644 src/core/dev/inspector/api.ts diff --git a/src/core/dev/inspector/api.ts b/src/core/dev/inspector/api.ts deleted file mode 100644 index da9521975..000000000 --- a/src/core/dev/inspector/api.ts +++ /dev/null @@ -1,233 +0,0 @@ -/** - * Wire types for the Agent Inspector HTTP API, ported from the original CLI's - * web-ui/api-types.ts. The prebuilt Inspector SPA depends on these exact field - * names — keep every shape byte-compatible with the reference when changing - * anything here. - */ - -// --------------------------------------------------------------------------- -// GET /api/status -// --------------------------------------------------------------------------- - -export interface StatusResponse { - mode: "dev"; - agents: StatusAgent[]; - harnesses: StatusHarness[]; - running: StatusRunningAgent[]; - errors: StatusAgentError[]; - /** Agent name to pre-select in the UI (set when --runtime is specified). */ - selectedAgent?: string; -} - -export interface StatusAgent { - name: string; - buildType: string; - protocol: string; -} - -export interface StatusRunningAgent { - name: string; - /** Port the agent is listening on. */ - port: number; -} - -export interface StatusAgentError { - name: string; - message: string; -} - -export interface StatusHarness { - name: string; -} - -// --------------------------------------------------------------------------- -// POST /api/start -// --------------------------------------------------------------------------- - -export interface StartRequest { - agentName: string; -} - -export interface StartResponse { - success: boolean; - name: string; - port: number; - error?: string; -} - -// --------------------------------------------------------------------------- -// POST /invocations -// --------------------------------------------------------------------------- - -export interface InvocationRequest { - agentName?: string; - prompt?: string; - sessionId?: string; - userId?: string; -} - -// --------------------------------------------------------------------------- -// GET /api/traces[/:traceId]?agentName=xxx -// --------------------------------------------------------------------------- - -export interface ListTracesResponse { - success: boolean; - traces?: unknown[]; - error?: string; -} - -export interface GetTraceResponse { - success: boolean; - resourceSpans?: unknown[]; - resourceLogs?: unknown[]; - error?: string; -} - -// --------------------------------------------------------------------------- -// POST /api/mcp -// --------------------------------------------------------------------------- - -export interface McpProxyRequest { - agentName?: string; - body?: Record; - sessionId?: string; -} - -export interface McpProxyResponse { - success: true; - result: unknown; - sessionId?: string; -} - -// --------------------------------------------------------------------------- -// GET /api/a2a/agent-card?agentName=xxx -// --------------------------------------------------------------------------- - -export interface A2AAgentCardResponse { - success: true; - card: unknown; -} - -// --------------------------------------------------------------------------- -// GET /api/resources -// --------------------------------------------------------------------------- - -export interface ResourcesResponse { - success: true; - project: string; - agents: ResourceAgent[]; - harnesses: ResourceHarness[]; - memories: ResourceMemory[]; - credentials: ResourceCredential[]; - gateways: ResourceGateway[]; - mcpRuntimeTools: ResourceMcpTool[]; - evaluators: ResourceEvaluator[]; - onlineEvalConfigs: ResourceOnlineEvalConfig[]; - policyEngines: ResourcePolicyEngine[]; - unassignedTargets: ResourceUnassignedTarget[]; - deploymentTargets: ResourceDeploymentTarget[]; -} - -export interface ResourceDeploymentTarget { - name: string; - region: string; - description?: string; -} - -export interface ResourceAgent { - name: string; - build: string; - entrypoint: string; - codeLocation: string; - runtimeVersion: string; - networkMode: string; - protocol: string; - envVars: string[]; -} - -export interface ResourceHarness { - name: string; - /** @deprecated The reference derived this from the harness spec's model. */ - model: string; - tools: string[]; -} - -export interface ResourceMemory { - name: string; - strategies: ResourceMemoryStrategy[]; - expiryDays: number | undefined; -} - -export interface ResourceMemoryStrategy { - type: string; - /** Namespace templates, e.g. "/users/{actorId}/facts". */ - namespaceTemplates: string[]; -} - -export interface ResourceCredential { - name: string; - type: string; -} - -export interface ResourceGateway { - name: string; - targets: ResourceGatewayTarget[]; -} - -export interface ResourceGatewayTarget { - name: string; - targetType: string; -} - -export interface ResourceMcpTool { - name: string; - bindings: ResourceMcpToolBinding[]; -} - -export interface ResourceMcpToolBinding { - runtimeName: string; - envVarName: string; -} - -export interface ResourceEvaluator { - name: string; - level: string; - description?: string; - configType: "llm-as-a-judge" | "code-based"; -} - -export interface ResourceOnlineEvalConfig { - name: string; - agent?: string; - evaluators?: string[]; - insights?: string[]; - samplingRate: number; - description?: string; - logGroupNames?: string[]; - serviceNames?: string[]; -} - -export interface ResourcePolicyEngine { - name: string; - description?: string; - policies: ResourcePolicy[]; -} - -export interface ResourcePolicy { - name: string; - description?: string; -} - -export interface ResourceUnassignedTarget { - name: string; - targetType: string; -} - -// --------------------------------------------------------------------------- -// Common error response (used by all endpoints on failure) -// --------------------------------------------------------------------------- - -export interface ApiErrorResponse { - success: false; - error: string; -} diff --git a/src/core/dev/inspector/resources.ts b/src/core/dev/inspector/resources.ts index 251da57c3..d35e1ffe3 100644 --- a/src/core/dev/inspector/resources.ts +++ b/src/core/dev/inspector/resources.ts @@ -7,7 +7,6 @@ * with real deploy wiring. */ import type { HttpResponse } from "../../../io/httpServer"; -import type { ResourcesResponse } from "./api"; import { apiError, json } from "./respond"; import type { InspectorDeps } from "./types"; @@ -16,7 +15,8 @@ export function handleResources(deps: InspectorDeps): HttpResponse { if (!project) return apiError(404, "No agentcore project found"); const spec = project.spec; - const resources: ResourcesResponse = { + // The literal is the wire contract — the SPA depends on these exact field names. + const resources = { success: true, project: project.name, agents: spec.runtimes.map((runtime) => ({ diff --git a/src/core/dev/inspector/server.ts b/src/core/dev/inspector/server.ts index 8349f7af7..d49753027 100644 --- a/src/core/dev/inspector/server.ts +++ b/src/core/dev/inspector/server.ts @@ -7,7 +7,6 @@ */ import { ResourceNotFoundError } from "../../../errors"; import type { HttpRequest, HttpRequestHandler, HttpResponse } from "../../../io/httpServer"; -import type { StatusResponse } from "./api"; import { asString, handleInvocations } from "./invocations"; import { handleA2aAgentCard, handleMcpProxy } from "./proxies"; import { handleResources } from "./resources"; @@ -87,7 +86,8 @@ async function route( /** GET /api/status — available agents, running ports, and per-agent errors. */ function handleStatus(deps: InspectorDeps): HttpResponse { const snapshot = deps.supervisor.snapshot(); - const status: StatusResponse = { + // The literal is the wire contract — the SPA depends on these exact field names. + const status = { mode: "dev", agents: snapshot.map(({ name, buildType, protocol }) => ({ name, buildType, protocol })), harnesses: (deps.project?.spec.harnesses ?? []).map(({ name }) => ({ name })),