@fastagent-sh/fastagent 0.21.1 → 0.22.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -12
- package/dist/agent.d.ts +24 -37
- package/dist/agent.js +7 -15
- package/dist/atomic-write.d.ts +13 -17
- package/dist/atomic-write.js +18 -29
- package/dist/bind.d.ts +12 -22
- package/dist/bind.js +18 -27
- package/dist/channel.d.ts +11 -33
- package/dist/channels/agentcore-limits.d.ts +2 -13
- package/dist/channels/agentcore-limits.js +2 -13
- package/dist/channels/agentcore-protocol.d.ts +67 -58
- package/dist/channels/agentcore-protocol.js +23 -15
- package/dist/channels/agentcore-service.d.ts +19 -33
- package/dist/channels/agentcore-service.js +176 -59
- package/dist/channels/agentcore.d.ts +39 -33
- package/dist/channels/agentcore.js +90 -224
- package/dist/channels/body.d.ts +15 -2
- package/dist/channels/body.js +24 -2
- package/dist/channels/busy.d.ts +1 -21
- package/dist/channels/busy.js +2 -39
- package/dist/channels/control.d.ts +17 -62
- package/dist/channels/control.js +74 -182
- package/dist/channels/define-channel.d.ts +39 -0
- package/dist/channels/define-channel.js +9 -0
- package/dist/channels/discover.d.ts +10 -31
- package/dist/channels/discover.js +37 -31
- package/dist/channels/feishu/bootstrap-token.d.ts +1 -8
- package/dist/channels/feishu/bootstrap-token.js +7 -23
- package/dist/channels/feishu/card.d.ts +14 -28
- package/dist/channels/feishu/card.js +16 -30
- package/dist/channels/feishu/cloud.d.ts +1 -5
- package/dist/channels/feishu/context-buffer.d.ts +4 -23
- package/dist/channels/feishu/context-buffer.js +8 -31
- package/dist/channels/feishu/crypto.d.ts +10 -5
- package/dist/channels/feishu/crypto.js +12 -19
- package/dist/channels/feishu/feishu-api.d.ts +2 -1
- package/dist/channels/feishu/feishu-api.js +5 -4
- package/dist/channels/feishu/feishu.d.ts +4 -10
- package/dist/channels/feishu/feishu.js +113 -227
- package/dist/channels/feishu/invoke-turn.d.ts +5 -4
- package/dist/channels/feishu/invoke-turn.js +36 -54
- package/dist/channels/feishu/model.d.ts +4 -21
- package/dist/channels/feishu/model.js +1 -8
- package/dist/channels/feishu/normalize.d.ts +2 -10
- package/dist/channels/feishu/normalize.js +7 -42
- package/dist/channels/feishu/parse.d.ts +9 -29
- package/dist/channels/feishu/parse.js +8 -26
- package/dist/channels/feishu/preview.d.ts +12 -14
- package/dist/channels/feishu/preview.js +108 -207
- package/dist/channels/feishu/register-app.d.ts +8 -23
- package/dist/channels/feishu/register-app.js +7 -20
- package/dist/channels/feishu/register-webhook.d.ts +2 -9
- package/dist/channels/feishu/register-webhook.js +9 -33
- package/dist/channels/feishu/scaffold/channel.ts +27 -19
- package/dist/channels/feishu/scaffold/channel.websocket.ts +31 -0
- package/dist/channels/feishu/setup-mode.d.ts +11 -36
- package/dist/channels/feishu/setup-mode.js +7 -26
- package/dist/channels/feishu/ws-ingress.d.ts +1 -2
- package/dist/channels/feishu/ws-ingress.js +6 -8
- package/dist/channels/http.d.ts +15 -15
- package/dist/channels/http.js +19 -9
- package/dist/channels/kit/attachment-path.d.ts +4 -5
- package/dist/channels/kit/attachment-path.js +10 -34
- package/dist/channels/kit/context-buffer.d.ts +14 -21
- package/dist/channels/kit/context-buffer.js +25 -38
- package/dist/channels/kit/delivery.d.ts +35 -0
- package/dist/channels/kit/delivery.js +129 -0
- package/dist/channels/kit/event-stream.d.ts +5 -0
- package/dist/channels/kit/event-stream.js +29 -0
- package/dist/channels/kit/invoke-turn-kit.d.ts +53 -26
- package/dist/channels/kit/invoke-turn-kit.js +89 -32
- package/dist/channels/kit/preview-kit.d.ts +18 -72
- package/dist/channels/kit/preview-kit.js +22 -88
- package/dist/channels/kit/signature.d.ts +3 -9
- package/dist/channels/kit/signature.js +3 -9
- package/dist/channels/kit/state.d.ts +1 -2
- package/dist/channels/kit/state.js +5 -14
- package/dist/channels/kit/stop-command.d.ts +1 -2
- package/dist/channels/kit/stop-command.js +6 -9
- package/dist/channels/kit/tasks.d.ts +1 -1
- package/dist/channels/kit/tasks.js +8 -21
- package/dist/channels/kit/text.d.ts +2 -16
- package/dist/channels/kit/text.js +23 -31
- package/dist/channels/kit/thread-participants.d.ts +4 -17
- package/dist/channels/kit/thread-participants.js +8 -55
- package/dist/channels/kit/transport.d.ts +26 -0
- package/dist/channels/kit/transport.js +20 -0
- package/dist/channels/kit/turn-queue.d.ts +7 -11
- package/dist/channels/kit/turn-queue.js +21 -39
- package/dist/channels/kit/turn-runner.d.ts +32 -18
- package/dist/channels/kit/turn-runner.js +98 -53
- package/dist/channels/kit/turn-store.d.ts +32 -32
- package/dist/channels/kit/turn-store.js +45 -88
- package/dist/channels/lark/lark.d.ts +1 -5
- package/dist/channels/lark/onboard.d.ts +3 -12
- package/dist/channels/lark/onboard.js +3 -13
- package/dist/channels/lark/scaffold/channel.ts +27 -19
- package/dist/channels/lark/scaffold/channel.websocket.ts +31 -0
- package/dist/channels/registration.d.ts +9 -35
- package/dist/channels/registration.js +6 -37
- package/dist/channels/secret.js +2 -5
- package/dist/channels/serve.d.ts +47 -44
- package/dist/channels/serve.js +165 -101
- package/dist/channels/slack/config-api.d.ts +4 -27
- package/dist/channels/slack/config-api.js +5 -34
- package/dist/channels/slack/context-buffer.d.ts +2 -3
- package/dist/channels/slack/context-buffer.js +5 -5
- package/dist/channels/slack/invoke-turn.d.ts +3 -1
- package/dist/channels/slack/invoke-turn.js +25 -34
- package/dist/channels/slack/manifest.js +1 -3
- package/dist/channels/slack/model.d.ts +1 -2
- package/dist/channels/slack/model.js +1 -1
- package/dist/channels/slack/onboard.d.ts +4 -3
- package/dist/channels/slack/onboard.js +8 -11
- package/dist/channels/slack/onboarding-state.d.ts +3 -5
- package/dist/channels/slack/onboarding-state.js +9 -6
- package/dist/channels/slack/parse.d.ts +4 -13
- package/dist/channels/slack/parse.js +7 -28
- package/dist/channels/slack/preview.d.ts +12 -2
- package/dist/channels/slack/preview.js +292 -269
- package/dist/channels/slack/reaction.d.ts +11 -3
- package/dist/channels/slack/reaction.js +19 -8
- package/dist/channels/slack/register-webhook.js +4 -7
- package/dist/channels/slack/scaffold/channel.ts +20 -13
- package/dist/channels/slack/scaffold/slack-send.ts +2 -2
- package/dist/channels/slack/setup-server.d.ts +1 -5
- package/dist/channels/slack/setup-server.js +1 -5
- package/dist/channels/slack/shared-api.d.ts +1 -4
- package/dist/channels/slack/shared-api.js +2 -13
- package/dist/channels/slack/slack-api.d.ts +2 -1
- package/dist/channels/slack/slack-api.js +6 -4
- package/dist/channels/slack/slack.d.ts +11 -14
- package/dist/channels/slack/slack.js +121 -122
- package/dist/channels/sse.d.ts +1 -1
- package/dist/channels/sse.js +1 -1
- package/dist/channels/telegram/context-buffer.d.ts +7 -22
- package/dist/channels/telegram/context-buffer.js +6 -23
- package/dist/channels/telegram/invoke-turn.d.ts +7 -7
- package/dist/channels/telegram/invoke-turn.js +24 -43
- package/dist/channels/telegram/parse.d.ts +24 -51
- package/dist/channels/telegram/parse.js +32 -84
- package/dist/channels/telegram/preview.d.ts +10 -1
- package/dist/channels/telegram/preview.js +79 -100
- package/dist/channels/telegram/register-webhook.js +5 -23
- package/dist/channels/telegram/scaffold/channel.ts +25 -18
- package/dist/channels/telegram/scaffold/telegram-send.ts +6 -4
- package/dist/channels/telegram/telegram-api.d.ts +4 -3
- package/dist/channels/telegram/telegram-api.js +9 -7
- package/dist/channels/telegram/telegram.d.ts +4 -18
- package/dist/channels/telegram/telegram.js +38 -94
- package/dist/channels/telegram/turn-store.d.ts +11 -14
- package/dist/channels/telegram/turn-store.js +8 -6
- package/dist/channels/wait-health.d.ts +4 -6
- package/dist/channels/wait-health.js +10 -14
- package/dist/cli/add-feishu.d.ts +2 -12
- package/dist/cli/add-feishu.js +24 -71
- package/dist/cli/add-slack.d.ts +5 -3
- package/dist/cli/add-slack.js +53 -76
- package/dist/cli/auth-view.d.ts +2 -16
- package/dist/cli/auth-view.js +2 -16
- package/dist/cli/commands/add.js +25 -40
- package/dist/cli/commands/chat.d.ts +0 -1
- package/dist/cli/commands/chat.js +5 -6
- package/dist/cli/commands/deploy/agentcore.js +60 -65
- package/dist/cli/commands/deploy/docker.js +50 -23
- package/dist/cli/commands/deploy/fly.js +23 -61
- package/dist/cli/commands/deploy/railway.js +23 -18
- package/dist/cli/commands/deploy/shared.d.ts +38 -57
- package/dist/cli/commands/deploy/shared.js +40 -59
- package/dist/cli/commands/deploy.d.ts +10 -27
- package/dist/cli/commands/deploy.js +38 -60
- package/dist/cli/commands/destroy.d.ts +4 -0
- package/dist/cli/commands/destroy.js +56 -0
- package/dist/cli/commands/dev.d.ts +2 -1
- package/dist/cli/commands/dev.js +20 -23
- package/dist/cli/commands/info.d.ts +0 -2
- package/dist/cli/commands/info.js +83 -49
- package/dist/cli/commands/init.d.ts +1 -2
- package/dist/cli/commands/init.js +16 -55
- package/dist/cli/commands/invoke.d.ts +0 -1
- package/dist/cli/commands/invoke.js +5 -7
- package/dist/cli/commands/login.d.ts +2 -1
- package/dist/cli/commands/login.js +33 -38
- package/dist/cli/commands/logs.js +3 -4
- package/dist/cli/commands/models.js +1 -5
- package/dist/cli/commands/routine-run.d.ts +6 -0
- package/dist/cli/commands/routine-run.js +47 -0
- package/dist/cli/commands/routine.d.ts +25 -0
- package/dist/cli/commands/routine.js +122 -0
- package/dist/cli/commands/start.d.ts +31 -3
- package/dist/cli/commands/start.js +131 -81
- package/dist/cli/commands/tool.js +26 -12
- package/dist/cli/fail.d.ts +13 -23
- package/dist/cli/fail.js +20 -23
- package/dist/cli/invoke-stream.d.ts +1 -8
- package/dist/cli/kernel.d.ts +6 -38
- package/dist/cli/kernel.js +14 -77
- package/dist/cli/models-view.d.ts +1 -9
- package/dist/cli/models-view.js +11 -22
- package/dist/cli/program.d.ts +5 -6
- package/dist/cli/program.js +120 -157
- package/dist/cli/serve.d.ts +57 -41
- package/dist/cli/serve.js +94 -121
- package/dist/cli/shared.d.ts +15 -61
- package/dist/cli/shared.js +55 -181
- package/dist/cli.js +1 -8
- package/dist/collect.d.ts +6 -0
- package/dist/collect.js +6 -0
- package/dist/core.d.ts +4 -1
- package/dist/core.js +9 -7
- package/dist/declared-secrets.d.ts +55 -0
- package/dist/declared-secrets.js +88 -0
- package/dist/deploy/agentcore/aws-cli.d.ts +91 -0
- package/dist/deploy/agentcore/aws-cli.js +64 -0
- package/dist/deploy/agentcore/destroy.d.ts +51 -0
- package/dist/deploy/agentcore/destroy.js +273 -0
- package/dist/deploy/agentcore/forwarder.js +28 -79
- package/dist/deploy/agentcore/logs.d.ts +2 -18
- package/dist/deploy/agentcore/logs.js +27 -51
- package/dist/deploy/agentcore/plan.d.ts +85 -105
- package/dist/deploy/agentcore/plan.js +152 -314
- package/dist/deploy/agentcore/run.d.ts +28 -28
- package/dist/deploy/agentcore/run.js +227 -306
- package/dist/deploy/agentcore/zip.d.ts +2 -15
- package/dist/deploy/agentcore/zip.js +3 -17
- package/dist/deploy/build-context.d.ts +21 -0
- package/dist/deploy/build-context.js +137 -0
- package/dist/deploy/channel-ingress.d.ts +26 -39
- package/dist/deploy/channel-ingress.js +57 -28
- package/dist/deploy/container.d.ts +20 -34
- package/dist/deploy/container.js +49 -100
- package/dist/deploy/docker/plan.d.ts +12 -11
- package/dist/deploy/docker/plan.js +43 -24
- package/dist/deploy/docker/run.d.ts +22 -25
- package/dist/deploy/docker/run.js +39 -37
- package/dist/deploy/fly/plan.d.ts +15 -44
- package/dist/deploy/fly/plan.js +41 -73
- package/dist/deploy/fly/run.d.ts +16 -51
- package/dist/deploy/fly/run.js +42 -95
- package/dist/deploy/hosts.d.ts +24 -3
- package/dist/deploy/hosts.js +24 -3
- package/dist/deploy/preflight.d.ts +47 -41
- package/dist/deploy/preflight.js +282 -383
- package/dist/deploy/railway/plan.d.ts +16 -42
- package/dist/deploy/railway/plan.js +42 -60
- package/dist/deploy/railway/run.d.ts +30 -68
- package/dist/deploy/railway/run.js +86 -100
- package/dist/deploy/registration-gate.d.ts +6 -13
- package/dist/deploy/residency.d.ts +37 -0
- package/dist/deploy/residency.js +24 -0
- package/dist/deploy/runner.d.ts +10 -9
- package/dist/deploy/runner.js +7 -18
- package/dist/deploy/secrets.d.ts +40 -41
- package/dist/deploy/secrets.js +117 -103
- package/dist/deploy/workspace.d.ts +50 -0
- package/dist/deploy/workspace.js +197 -0
- package/dist/dev-supervisor.d.ts +1 -9
- package/dist/dev-supervisor.js +21 -64
- package/dist/effect-port.d.ts +50 -0
- package/dist/effect-port.js +77 -0
- package/dist/engines/pi/agent-session-factory.d.ts +50 -69
- package/dist/engines/pi/agent-session-factory.js +249 -146
- package/dist/engines/pi/auth.d.ts +15 -11
- package/dist/engines/pi/auth.js +96 -99
- package/dist/engines/pi/chat.d.ts +1 -5
- package/dist/engines/pi/chat.js +2 -14
- package/dist/engines/pi/config.d.ts +53 -51
- package/dist/engines/pi/config.js +83 -92
- package/dist/engines/pi/create.d.ts +60 -75
- package/dist/engines/pi/create.js +108 -141
- package/dist/engines/pi/definition.d.ts +14 -58
- package/dist/engines/pi/definition.js +21 -85
- package/dist/engines/pi/invoke-session.d.ts +5 -25
- package/dist/engines/pi/invoke-session.js +233 -244
- package/dist/engines/pi/login.d.ts +95 -35
- package/dist/engines/pi/login.js +259 -107
- package/dist/engines/pi/machine.d.ts +31 -0
- package/dist/engines/pi/machine.js +106 -0
- package/dist/engines/pi/models.d.ts +70 -89
- package/dist/engines/pi/models.js +188 -81
- package/dist/engines/pi/open.d.ts +65 -53
- package/dist/engines/pi/open.js +129 -69
- package/dist/engines/pi/report.d.ts +3 -12
- package/dist/engines/pi/report.js +2 -16
- package/dist/engines/pi/search-tools.d.ts +1 -9
- package/dist/engines/pi/search-tools.js +26 -57
- package/dist/engines/pi/service.d.ts +2 -16
- package/dist/engines/pi/service.js +2 -16
- package/dist/engines/pi/session-builder.d.ts +5 -9
- package/dist/engines/pi/session-builder.js +44 -82
- package/dist/engines/pi/session-control.js +286 -269
- package/dist/engines/pi/session-effects.d.ts +14 -0
- package/dist/engines/pi/session-effects.js +30 -0
- package/dist/engines/pi/session-inheritance.d.ts +9 -18
- package/dist/engines/pi/session-inheritance.js +103 -160
- package/dist/engines/pi/session-markers.d.ts +31 -30
- package/dist/engines/pi/session-markers.js +26 -25
- package/dist/engines/pi/session-settings.d.ts +6 -22
- package/dist/engines/pi/session-settings.js +10 -21
- package/dist/engines/pi/session-store.d.ts +26 -92
- package/dist/engines/pi/session-store.js +96 -310
- package/dist/engines/pi/tool-context.d.ts +17 -45
- package/dist/engines/pi/tool-context.js +7 -26
- package/dist/engines/pi/tool.d.ts +45 -36
- package/dist/engines/pi/tool.js +43 -57
- package/dist/engines/pi/turn-kit.d.ts +14 -52
- package/dist/engines/pi/turn-kit.js +18 -67
- package/dist/engines/pi/wake-tool.d.ts +2 -10
- package/dist/engines/pi/wake-tool.js +7 -27
- package/dist/env.d.ts +27 -28
- package/dist/env.js +45 -50
- package/dist/index.js +0 -2
- package/dist/lark.d.ts +1 -2
- package/dist/lark.js +1 -2
- package/dist/loader.d.ts +14 -55
- package/dist/loader.js +24 -43
- package/dist/log.d.ts +2 -12
- package/dist/log.js +3 -24
- package/dist/node.d.ts +2 -15
- package/dist/node.js +2 -18
- package/dist/observe.d.ts +2 -8
- package/dist/open-url.d.ts +1 -1
- package/dist/open-url.js +1 -1
- package/dist/paths.d.ts +70 -134
- package/dist/paths.js +165 -232
- package/dist/pi.d.ts +4 -6
- package/dist/pi.js +3 -3
- package/dist/proxy.d.ts +16 -6
- package/dist/proxy.js +52 -16
- package/dist/runtime.d.ts +18 -12
- package/dist/runtime.js +18 -11
- package/dist/scaffold/add-channel.d.ts +14 -31
- package/dist/scaffold/add-channel.js +48 -112
- package/dist/scaffold/init.d.ts +9 -31
- package/dist/scaffold/init.js +45 -138
- package/dist/scaffold/templates/env.example +6 -4
- package/dist/scaffold/templates/fastagent.config.ts +36 -0
- package/dist/scaffold/templates.d.ts +3 -3
- package/dist/scaffold/templates.js +5 -8
- package/dist/scaffold/vendor-skill.d.ts +2 -5
- package/dist/scaffold/vendor-skill.js +19 -29
- package/dist/schedule/cron.d.ts +9 -5
- package/dist/schedule/cron.js +9 -11
- package/dist/schedule/discover.d.ts +12 -12
- package/dist/schedule/discover.js +43 -34
- package/dist/schedule/routine.d.ts +43 -0
- package/dist/schedule/routine.js +32 -0
- package/dist/schedule/run.d.ts +55 -0
- package/dist/schedule/run.js +146 -0
- package/dist/schedule/scheduler.d.ts +44 -55
- package/dist/schedule/scheduler.js +291 -265
- package/dist/schedule/state.d.ts +105 -10
- package/dist/schedule/state.js +293 -19
- package/dist/schedule/wake-alarm.d.ts +10 -23
- package/dist/schedule/wake-alarm.js +91 -121
- package/dist/schedule/wakeups.d.ts +23 -39
- package/dist/schedule/wakeups.js +33 -75
- package/dist/secrets-gate.d.ts +32 -0
- package/dist/secrets-gate.js +33 -0
- package/dist/service.d.ts +137 -98
- package/dist/service.js +250 -252
- package/dist/session-remote.d.ts +33 -44
- package/dist/session-remote.js +203 -192
- package/dist/session.d.ts +166 -229
- package/dist/session.js +33 -55
- package/dist/tunnel.d.ts +8 -36
- package/dist/tunnel.js +24 -65
- package/dist/version.d.ts +2 -2
- package/dist/version.js +2 -2
- package/package.json +10 -16
- package/dist/channels/agentcore-state.d.ts +0 -77
- package/dist/channels/agentcore-state.js +0 -276
- package/dist/channels/github/github.d.ts +0 -31
- package/dist/channels/github/github.js +0 -89
- package/dist/channels/github/scaffold/channel.ts +0 -25
- package/dist/cli/commands/attach.d.ts +0 -108
- package/dist/cli/commands/attach.js +0 -637
- package/dist/cli/commands/fire.d.ts +0 -7
- package/dist/cli/commands/fire.js +0 -38
- package/dist/cli/commands/schedule.d.ts +0 -12
- package/dist/cli/commands/schedule.js +0 -87
- package/dist/github.d.ts +0 -3
- package/dist/github.js +0 -2
- package/dist/scaffold/templates/fastagent.config.mjs +0 -23
- package/dist/schedule/audit.d.ts +0 -18
- package/dist/schedule/audit.js +0 -57
- package/dist/schedule/schedule.d.ts +0 -35
- package/dist/schedule/schedule.js +0 -23
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ONE AWS CLI invocation, and what its result MEANS.
|
|
3
|
+
*
|
|
4
|
+
* The CLI says everything through an exit code plus prose on a stream the caller must opt into capturing — and
|
|
5
|
+
* the three answers a teardown needs are not separable from the code alone: **it is there**, **it is gone**,
|
|
6
|
+
* **I could not find out**. The third one is not a detail. "Gone" ends the command successfully; "could not
|
|
7
|
+
* find out" must stop it or report a leak, because the resource may well still be running and billing.
|
|
8
|
+
*
|
|
9
|
+
* WHY THIS IS A MODULE and not an expression at each call site: `destroy.ts` had eleven of those call sites,
|
|
10
|
+
* and five review rounds found the same defect at a new one each time — a denial read as an absence (three
|
|
11
|
+
* separate rounds, four sites), an empty paginated listing read as broken JSON, and gates that said "see the
|
|
12
|
+
* output above" while the flags they passed had piped that output away. One requirement, N places, every time.
|
|
13
|
+
*
|
|
14
|
+
* So the four decisions that kept being re-derived live here, once:
|
|
15
|
+
*
|
|
16
|
+
* 1. **The stdio flags.** No call site has to know `spawnRunner`'s mapping (`capture`/`captureStderr` true means
|
|
17
|
+
* pipe, i.e. NOT the terminal). Getting that wrong is what made "see the output above" point at a blank
|
|
18
|
+
* screen for seven gates.
|
|
19
|
+
* 2. **{@link ABSENT}** — the one description of what "already gone" looks like.
|
|
20
|
+
* 3. **The text travels with the failure.** A caller that cannot reach what AWS said cannot write a message
|
|
21
|
+
* that omits it; `AccessDeniedException` vs `ThrottlingException` vs `ExpiredToken` are different operator
|
|
22
|
+
* actions, and that prose is the only thing telling them apart.
|
|
23
|
+
* 4. **What "no results" actually looks like**, which is not what a fake suggested. MEASURED against AWS CLI
|
|
24
|
+
* 2.36.47 (ap-southeast-1, 2026-09-22): an empty bucket answers
|
|
25
|
+
* `{"RequestCharged": null, "Prefix": ""}`, `scheduler list-schedules` with no match answers
|
|
26
|
+
* `{"Schedules": []}`, and `describe-log-groups --query` with no match answers `[]`. None of them prints
|
|
27
|
+
* nothing — so empty stdout is output we could not read, and {@link awsList} says so rather than reporting
|
|
28
|
+
* an empty list.
|
|
29
|
+
*
|
|
30
|
+
* WHAT DOES NOT GO THROUGH IT: a call whose output an operator watches (`docker buildx build`, `s3 cp`,
|
|
31
|
+
* `cloudformation deploy`) keeps its streams on the terminal — capturing a multi-minute arm64 build to replay it
|
|
32
|
+
* at the end is a regression, and it is why `run.ts` can say "see the output above" where `destroy.ts` cannot.
|
|
33
|
+
* That is a stdio choice, not a second classifier: those calls ask "did this work", never "is it there".
|
|
34
|
+
* Nor does `aws configure get region`, whose nonzero exit means "not set" and whose only consumer is the next
|
|
35
|
+
* line's "no region configured" gate.
|
|
36
|
+
*
|
|
37
|
+
* What is deliberately NOT here: what to DO about each answer. Whether an unreadable read aborts the teardown
|
|
38
|
+
* or degrades it is a per-resource policy (the stack's probe decides the report's honesty; a log-group listing
|
|
39
|
+
* only supplies names), and that belongs at the call site where the reason is visible.
|
|
40
|
+
*/
|
|
41
|
+
import type { CliRunner } from "../runner.ts";
|
|
42
|
+
/**
|
|
43
|
+
* A read's answer. `unreadable` carries `said` (what AWS printed) and `code`, which only the very first call of
|
|
44
|
+
* a command needs — `127` is "the aws CLI is not installed" rather than anything about the resource.
|
|
45
|
+
*/
|
|
46
|
+
type AwsRead<T> = {
|
|
47
|
+
ok: T;
|
|
48
|
+
} | {
|
|
49
|
+
absent: true;
|
|
50
|
+
} | {
|
|
51
|
+
unreadable: string;
|
|
52
|
+
code: number;
|
|
53
|
+
};
|
|
54
|
+
/** A write's answer. `absent` is the goal state of a teardown, not a failure. */
|
|
55
|
+
type AwsWrite = {
|
|
56
|
+
done: true;
|
|
57
|
+
} | {
|
|
58
|
+
absent: true;
|
|
59
|
+
} | {
|
|
60
|
+
refused: string;
|
|
61
|
+
};
|
|
62
|
+
export interface AwsCli {
|
|
63
|
+
/**
|
|
64
|
+
* Run a read and classify it. `parse` turns stdout into the value the caller wanted and returns `undefined`
|
|
65
|
+
* for output it cannot read — which becomes `unreadable`, never a thrown `SyntaxError`: an exception escaping
|
|
66
|
+
* here reaches the operator as a Node stack trace, since `src/cli.ts` has no catch-all.
|
|
67
|
+
*/
|
|
68
|
+
read<T>(args: string[], parse: (stdout: string) => T | undefined): Promise<AwsRead<T>>;
|
|
69
|
+
/** A read whose only question is whether the thing is there. */
|
|
70
|
+
present(args: string[]): Promise<AwsRead<true>>;
|
|
71
|
+
write(args: string[]): Promise<AwsWrite>;
|
|
72
|
+
}
|
|
73
|
+
export declare function awsCli(aws: CliRunner): AwsCli;
|
|
74
|
+
/**
|
|
75
|
+
* A JSON read: `pick` says what it wants out of the parsed document, and this owns the two answers it must not
|
|
76
|
+
* produce.
|
|
77
|
+
*
|
|
78
|
+
* NOT AN EXCEPTION — `JSON.parse` on output the CLI never promised threw
|
|
79
|
+
* `SyntaxError: Unexpected end of JSON input` out of a command whose CLI has no catch-all, i.e. a Node stack
|
|
80
|
+
* trace where one actionable line belonged.
|
|
81
|
+
*
|
|
82
|
+
* AND NOT AN EMPTY LIST. The AWS CLI prints a real document for a list with no results (header, point 4), so empty
|
|
83
|
+
* stdout is output we failed to read, never "there is nothing there". It needs no branch of its own:
|
|
84
|
+
* `JSON.parse("")` throws like any other unreadable output, and {@link awsCli} names it `(no output)`.
|
|
85
|
+
*
|
|
86
|
+
* ONE FUNCTION for lists and single values.
|
|
87
|
+
*/
|
|
88
|
+
export declare function awsJson<T>(pick: (parsed: unknown) => T | undefined): (stdout: string) => T | undefined;
|
|
89
|
+
/** `logGroups[].logGroupName` as names — the shape both `logs` and `destroy` ask CloudWatch for. */
|
|
90
|
+
export declare const parseLogGroupNames: (stdout: string) => string[] | undefined;
|
|
91
|
+
export {};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/** The most of AWS's own words any message carries. */
|
|
2
|
+
const SAID_LIMIT = 300;
|
|
3
|
+
/** An AWS CLI failure that means the thing is already gone, which is what a teardown is trying to achieve. */
|
|
4
|
+
const ABSENT = /does not exist|NotFound|not found|NoSuchBucket|NoSuchEntity|ResourceNotFoundException/i;
|
|
5
|
+
export function awsCli(aws) {
|
|
6
|
+
const read = async (args, parse) => {
|
|
7
|
+
const { code, stdout, stderr } = await aws(args, { capture: true, captureStderr: true });
|
|
8
|
+
// `(no output)` because a message ending in a colon and nothing else tells an operator less than the fact
|
|
9
|
+
// that AWS printed nothing at all — which, given point 4, is itself the anomaly.
|
|
10
|
+
const said = ((stderr ?? "").trim() || stdout.trim()).slice(0, SAID_LIMIT) || "(no output)";
|
|
11
|
+
if (code === 127) {
|
|
12
|
+
return { unreadable: "aws CLI not found — install AWS CLI v2: https://docs.aws.amazon.com/cli/", code };
|
|
13
|
+
}
|
|
14
|
+
if (code !== 0)
|
|
15
|
+
return ABSENT.test(stderr ?? "") ? { absent: true } : { unreadable: said, code };
|
|
16
|
+
const parsed = parse(stdout);
|
|
17
|
+
return parsed === undefined ? { unreadable: said, code } : { ok: parsed };
|
|
18
|
+
};
|
|
19
|
+
return {
|
|
20
|
+
read,
|
|
21
|
+
present: (args) => read(args, () => true),
|
|
22
|
+
write: async (args) => {
|
|
23
|
+
// CAPTURED, both streams. Every write that goes through here is a short API call whose success output is
|
|
24
|
+
// noise (`stop-runtime-session` answers with its session JSON) — the long ones an operator watches are
|
|
25
|
+
// exactly the ones that do not go through this module, see the header.
|
|
26
|
+
const { code, stderr } = await aws(args, { capture: true, captureStderr: true });
|
|
27
|
+
if (code === 0)
|
|
28
|
+
return { done: true };
|
|
29
|
+
if (ABSENT.test(stderr ?? ""))
|
|
30
|
+
return { absent: true };
|
|
31
|
+
return { refused: (stderr ?? "").trim().slice(0, SAID_LIMIT) };
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* A JSON read: `pick` says what it wants out of the parsed document, and this owns the two answers it must not
|
|
37
|
+
* produce.
|
|
38
|
+
*
|
|
39
|
+
* NOT AN EXCEPTION — `JSON.parse` on output the CLI never promised threw
|
|
40
|
+
* `SyntaxError: Unexpected end of JSON input` out of a command whose CLI has no catch-all, i.e. a Node stack
|
|
41
|
+
* trace where one actionable line belonged.
|
|
42
|
+
*
|
|
43
|
+
* AND NOT AN EMPTY LIST. The AWS CLI prints a real document for a list with no results (header, point 4), so empty
|
|
44
|
+
* stdout is output we failed to read, never "there is nothing there". It needs no branch of its own:
|
|
45
|
+
* `JSON.parse("")` throws like any other unreadable output, and {@link awsCli} names it `(no output)`.
|
|
46
|
+
*
|
|
47
|
+
* ONE FUNCTION for lists and single values.
|
|
48
|
+
*/
|
|
49
|
+
export function awsJson(pick) {
|
|
50
|
+
return (stdout) => {
|
|
51
|
+
try {
|
|
52
|
+
return pick(JSON.parse(stdout));
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/** `logGroups[].logGroupName` as names — the shape both `logs` and `destroy` ask CloudWatch for. */
|
|
60
|
+
export const parseLogGroupNames = awsJson((parsed) => {
|
|
61
|
+
if (!Array.isArray(parsed) || !parsed.every((v) => typeof v === "string"))
|
|
62
|
+
throw new Error("not a name list");
|
|
63
|
+
return parsed;
|
|
64
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `destroy agentcore`: removing what a deploy created, INCLUDING the parts the stack does not own.
|
|
3
|
+
*
|
|
4
|
+
* `aws cloudformation delete-stack` is the obvious move and it leaves three of the four behind, each for its own
|
|
5
|
+
* reason:
|
|
6
|
+
*
|
|
7
|
+
* - the S3 artifact bucket and the ECR repository must exist BEFORE the stack (it reads the forwarder's zip and
|
|
8
|
+
* the runtime's image from them), so they cannot be stack resources;
|
|
9
|
+
* - the forwarder's log group is created by AWS on the Lambda's first WRITE, so no template ever mentions it —
|
|
10
|
+
* and the deploy put a 14-day retention on it, i.e. it holds data;
|
|
11
|
+
* - a wake alarm is minted at RUNTIME by the container. `ActionAfterCompletion: DELETE` only runs once one
|
|
12
|
+
* FIRES, so anything stopped between the wake call and the fire leaves a schedule that retries into a deleted
|
|
13
|
+
* Lambda for weeks.
|
|
14
|
+
*
|
|
15
|
+
* This file is the RESOURCE LIST, the order the dependencies demand, and one policy decision per resource for
|
|
16
|
+
* what to do when AWS cannot answer. What an AWS answer MEANS — there, gone, or unreadable — belongs to
|
|
17
|
+
* {@link awsCli} and is not re-decided here; that repetition is what eleven call sites of it cost.
|
|
18
|
+
*
|
|
19
|
+
* Every deletion is ATTEMPTED even after an earlier one fails, and "already gone" is the goal state rather than
|
|
20
|
+
* an error — a teardown that stops at the first miss is one that cannot finish a half-deleted deployment. The
|
|
21
|
+
* exception is the stack, whose wait gates the rest: the image and the forwarder package below it are what a
|
|
22
|
+
* retry needs.
|
|
23
|
+
*/
|
|
24
|
+
import type { CliRunner } from "../runner.ts";
|
|
25
|
+
export interface AgentcoreDestroyPlan {
|
|
26
|
+
/** Deployment base name — stack `fastagent-<name>`, bucket `fa-<name>-<account>`, repo `fastagent/<name>`. */
|
|
27
|
+
name: string;
|
|
28
|
+
/** Without it, report what is out there and delete nothing. */
|
|
29
|
+
run: boolean;
|
|
30
|
+
}
|
|
31
|
+
export type AgentcoreDestroyOutcome =
|
|
32
|
+
/** `found` is what the reads saw; `removed` what the writes did; `kept` what this command refused to delete. */
|
|
33
|
+
{
|
|
34
|
+
ok: true;
|
|
35
|
+
found: string[];
|
|
36
|
+
removed: string[];
|
|
37
|
+
kept: string[];
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The same three ride along on a failure: a half-finished teardown is exactly when "what is out there" and
|
|
41
|
+
* "what is already gone" matter most, and the kept bucket is this command's only deliberate decision — none
|
|
42
|
+
* of them may vanish from the report because something else failed.
|
|
43
|
+
*/
|
|
44
|
+
| {
|
|
45
|
+
ok: false;
|
|
46
|
+
gate: string;
|
|
47
|
+
found: string[];
|
|
48
|
+
removed: string[];
|
|
49
|
+
kept: string[];
|
|
50
|
+
};
|
|
51
|
+
export declare function destroyAgentcoreDeployment(plan: AgentcoreDestroyPlan, runner: CliRunner, announce?: (message: string) => void): Promise<AgentcoreDestroyOutcome>;
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { awsCli, awsJson, parseLogGroupNames } from "./aws-cli.js";
|
|
2
|
+
import { agentcoreRepoName, agentcoreStackName, deploymentBucketName, forwarderLogGroup, runtimeLogGroupPrefix, toRuntimeName, wakeAlarmPrefix, } from "./plan.js";
|
|
3
|
+
/** S3 deletes at most 1000 keys per call. */
|
|
4
|
+
const DELETE_BATCH = 1000;
|
|
5
|
+
/**
|
|
6
|
+
* Every object AND delete marker in the bucket — both, because a versioned bucket refuses `delete-bucket` while
|
|
7
|
+
* either is non-empty, which is how a hand-cleanup of this exact account hit
|
|
8
|
+
* `BucketNotEmpty ... You must delete all versions in the bucket`.
|
|
9
|
+
*
|
|
10
|
+
* AN EMPTY BUCKET is an ordinary answer, and a real one: `create-bucket` happens before the upload into it, and
|
|
11
|
+
* a previous destroy that got through `delete-objects` and failed on `delete-bucket` leaves exactly one — the
|
|
12
|
+
* half-deleted deployment this command exists to finish. AWS says it with a document that simply has neither
|
|
13
|
+
* key (measured: `{"RequestCharged": null, "Prefix": ""}`), which is why `??` here and not a special case.
|
|
14
|
+
*/
|
|
15
|
+
const parseVersions = awsJson((parsed) => {
|
|
16
|
+
const { Versions, DeleteMarkers } = parsed;
|
|
17
|
+
return [...(Versions ?? []), ...(DeleteMarkers ?? [])];
|
|
18
|
+
});
|
|
19
|
+
const parseScheduleNames = awsJson((parsed) => (parsed.Schedules ?? []).flatMap((s) => typeof s.Name === "string" ? [s.Name] : []));
|
|
20
|
+
const parseAccountId = awsJson((parsed) => {
|
|
21
|
+
const { Account } = parsed;
|
|
22
|
+
return typeof Account === "string" ? Account : undefined;
|
|
23
|
+
});
|
|
24
|
+
export async function destroyAgentcoreDeployment(plan, runner, announce = () => { }) {
|
|
25
|
+
const aws = awsCli(runner);
|
|
26
|
+
const gate = (g, parts = {}) => ({
|
|
27
|
+
ok: false,
|
|
28
|
+
gate: g,
|
|
29
|
+
found: parts.found ?? [],
|
|
30
|
+
removed: parts.removed ?? [],
|
|
31
|
+
kept: parts.kept ?? [],
|
|
32
|
+
});
|
|
33
|
+
const stack = agentcoreStackName(plan.name);
|
|
34
|
+
const repo = agentcoreRepoName(plan.name);
|
|
35
|
+
const identity = await aws.read(["sts", "get-caller-identity", "--output", "json"], parseAccountId);
|
|
36
|
+
if (!("ok" in identity)) {
|
|
37
|
+
if ("absent" in identity)
|
|
38
|
+
return gate("`aws sts get-caller-identity` found no caller — check your profile");
|
|
39
|
+
if (identity.code === 127)
|
|
40
|
+
return gate(`${identity.unreadable}, then re-run`);
|
|
41
|
+
// A MISSING REGION FAILS HERE TOO, and blaming the credentials sends the operator to fix the wrong thing.
|
|
42
|
+
return gate(/region/i.test(identity.unreadable)
|
|
43
|
+
? "no AWS region configured — set AWS_REGION (or `aws configure set region <region>`), then re-run"
|
|
44
|
+
: `no working AWS credentials — run \`aws configure\` (or set AWS_ACCESS_KEY_ID/…), then re-run: ${identity.unreadable}`);
|
|
45
|
+
}
|
|
46
|
+
const account = identity.ok;
|
|
47
|
+
const bucket = deploymentBucketName(plan.name, account);
|
|
48
|
+
// EVERY RESOURCE BELOW IS REGIONAL, and none of them says so. A default profile pointing somewhere other than
|
|
49
|
+
// the deploy's region answers "nothing in this account", which an operator reads as "already clean" — so the
|
|
50
|
+
// region is resolved and REPORTED the way `run.ts` does it, and its absence is its own gate rather than a
|
|
51
|
+
// credential complaint. `aws configure get region` because the CLI does not take it from `AWS_REGION`.
|
|
52
|
+
const configured = await aws.read(["configure", "get", "region"], (stdout) => stdout.trim());
|
|
53
|
+
const fromConfig = "ok" in configured ? configured.ok : "";
|
|
54
|
+
const region = process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? fromConfig;
|
|
55
|
+
if (!region) {
|
|
56
|
+
return gate("no AWS region configured — set AWS_REGION (or `aws configure set region <region>`), then re-run");
|
|
57
|
+
}
|
|
58
|
+
// BEFORE THE READS, not after: every gate below names the account or the region, and the operator pointed at
|
|
59
|
+
// the wrong profile has to be able to see that from the line above the failure.
|
|
60
|
+
announce(`account ${account}, region ${region}`);
|
|
61
|
+
// ---- What is out there. Each read gets its own policy for "AWS could not answer". ----
|
|
62
|
+
// The alarm ids are minted inside the container, so the PREFIX is all we can ask for. Unreadable is a gate
|
|
63
|
+
// here: an alarm nobody deleted keeps firing at a Lambda that is about to stop existing.
|
|
64
|
+
const prefix = wakeAlarmPrefix(plan.name);
|
|
65
|
+
const listed = await aws.read(["scheduler", "list-schedules", "--name-prefix", prefix, "--output", "json"], parseScheduleNames);
|
|
66
|
+
if ("unreadable" in listed)
|
|
67
|
+
return gate(`could not list wake alarms under ${prefix}: ${listed.unreadable}`);
|
|
68
|
+
// THE FULL SHAPE, not just the prefix. The forwarder mints every alarm as prefix + sha256(wakeId)[:16]
|
|
69
|
+
// (plan.ts / forwarder.js), and a prefix alone is ambiguous between sibling agents: a workspace literally
|
|
70
|
+
// named `<name>-wk-abc` produces `fa-<name>-wk-abc-wk-<hash>`, which starts with THIS deployment's prefix.
|
|
71
|
+
// Deleting it would take a live deployment's pending wake-ups, and a wake-up is not re-created.
|
|
72
|
+
const isMintedAlarm = (alarm) => alarm.startsWith(prefix) && /^[0-9a-f]{16}$/.test(alarm.slice(prefix.length));
|
|
73
|
+
const alarms = ("ok" in listed ? listed.ok : []).filter(isMintedAlarm);
|
|
74
|
+
// A GATE for these three, because they decide whether the REPORT is true. `delete-stack` answers 0 for a
|
|
75
|
+
// stack that does not exist, so "deleted: stack X" for a stack nobody deployed — or "nothing left to delete"
|
|
76
|
+
// over a runtime that is still billing — is the false signal this repo refuses.
|
|
77
|
+
const stackRead = await aws.present(["cloudformation", "describe-stacks", "--stack-name", stack, "--output", "json"]);
|
|
78
|
+
if ("unreadable" in stackRead) {
|
|
79
|
+
return gate(`could not tell whether stack ${stack} exists in ${region}: ${stackRead.unreadable}`);
|
|
80
|
+
}
|
|
81
|
+
const stackExists = "ok" in stackRead;
|
|
82
|
+
const bucketRead = await aws.read(["s3api", "list-object-versions", "--bucket", bucket, "--output", "json"], parseVersions);
|
|
83
|
+
if ("unreadable" in bucketRead) {
|
|
84
|
+
return gate(`could not read the object listing for bucket ${bucket} in ${region}: ${bucketRead.unreadable}`);
|
|
85
|
+
}
|
|
86
|
+
const bucketExists = "ok" in bucketRead;
|
|
87
|
+
const keys = ("ok" in bucketRead ? bucketRead.ok : []).map((v) => v.Key);
|
|
88
|
+
// A bucket that holds anything but the forwarder's zips is NOT ours to delete. Today's deploy puts nothing
|
|
89
|
+
// else there (agent state lives on the AgentCore storage mount), but an older one kept `state/snapshot.json.gz`
|
|
90
|
+
// in it, and in this account those snapshots were the only copy left of two retired agents.
|
|
91
|
+
const foreign = [...new Set(keys.filter((key) => !key.startsWith("forwarder/")))];
|
|
92
|
+
const repoRead = await aws.present(["ecr", "describe-repositories", "--repository-names", repo, "--output", "json"]);
|
|
93
|
+
if ("unreadable" in repoRead) {
|
|
94
|
+
return gate(`could not tell whether repository ${repo} exists in ${region}: ${repoRead.unreadable}`);
|
|
95
|
+
}
|
|
96
|
+
const repoExists = "ok" in repoRead;
|
|
97
|
+
// BOTH LOG GROUPS. AWS creates each on the first WRITE, so neither is a stack resource — same reason, twice —
|
|
98
|
+
// and the runtime's holds the agent's own stdout/stderr, i.e. what it said in every conversation. `deploy`'s
|
|
99
|
+
// runbook already knows there are two: it sets a retention on each.
|
|
100
|
+
//
|
|
101
|
+
// THE RUNTIME'S GROUP IS NAMED WITHOUT THE STACK. MEASURED (ap-southeast-1, 2026-09-22): a deploy of
|
|
102
|
+
// `destroy-probe-yehg` produced `/aws/bedrock-agentcore/runtimes/destroy_probe_yehg-6oR1zq7pBZ-DEFAULT`, so
|
|
103
|
+
// the id is `<AgentRuntimeName>-<suffix>` and the name is `toRuntimeName(plan.name)`, the same derivation the
|
|
104
|
+
// template deployed. That account also held ELEVEN of these groups from past deployments whose every other
|
|
105
|
+
// resource was long gone — this is the leak, not a hypothetical one. Reading the name out of the
|
|
106
|
+
// stack's `RuntimeArn` instead meant that the operator whose first move was `aws cloudformation delete-stack`
|
|
107
|
+
// (which `docs/deploy.md` says it will be) got a clean-looking teardown with the group holding every line the
|
|
108
|
+
// agent ever printed still sitting there. The trailing `-` keeps `probe-` off `probe2-…`.
|
|
109
|
+
const forwarderGroup = forwarderLogGroup(plan.name);
|
|
110
|
+
const runtimePrefix = runtimeLogGroupPrefix(toRuntimeName(plan.name));
|
|
111
|
+
const logGroupsUnder = async (prefix) => aws.read([
|
|
112
|
+
"logs",
|
|
113
|
+
"describe-log-groups",
|
|
114
|
+
"--log-group-name-prefix",
|
|
115
|
+
prefix,
|
|
116
|
+
"--query",
|
|
117
|
+
"logGroups[].logGroupName",
|
|
118
|
+
"--output",
|
|
119
|
+
"json",
|
|
120
|
+
], parseLogGroupNames);
|
|
121
|
+
const forwarderRead = await logGroupsUnder(forwarderGroup);
|
|
122
|
+
const runtimeRead = await logGroupsUnder(runtimePrefix);
|
|
123
|
+
// NOT A GATE, unlike the three above: these two only supply NAMES, and refusing to delete anything because a
|
|
124
|
+
// listing failed turned a role without `logs:DescribeLogGroups` — or one `ThrottlingException` — into a
|
|
125
|
+
// permanent no-op with a Bedrock runtime and a Lambda billing behind it.
|
|
126
|
+
//
|
|
127
|
+
// FAILURE vs WARNING is decided by whether the resource SURVIVES it, not by whether a call failed. The
|
|
128
|
+
// forwarder's group is named exactly, so a `--run` deletes it by name and the failed listing changed nothing
|
|
129
|
+
// — reporting `1 resource(s) survived` for a teardown that removed everything is the same false signal as the
|
|
130
|
+
// reverse, and `test/live/env.ts` treats it as a leak. In the inventory, where no delete follows, the same
|
|
131
|
+
// unreadable listing DOES mean the answer is incomplete.
|
|
132
|
+
const failures = [];
|
|
133
|
+
const warnings = [];
|
|
134
|
+
const named = (read) => ("ok" in read ? read.ok : []);
|
|
135
|
+
if ("unreadable" in forwarderRead) {
|
|
136
|
+
(plan.run ? warnings : failures).push(plan.run
|
|
137
|
+
? `could not list log groups under ${forwarderGroup} (${forwarderRead.unreadable}) — deleted it by name instead`
|
|
138
|
+
: `listing log groups under ${forwarderGroup} (${forwarderRead.unreadable}) — cannot confirm whether it exists`);
|
|
139
|
+
}
|
|
140
|
+
if ("unreadable" in runtimeRead) {
|
|
141
|
+
// A failure in BOTH modes — this one carries a suffix only AWS knows, so nothing downstream can address it
|
|
142
|
+
// — but an inventory deletes nothing, so saying it "was NOT deleted" there names the wrong problem.
|
|
143
|
+
failures.push(`listing log groups under ${runtimePrefix} (${runtimeRead.unreadable}) — the runtime's log group holds ` +
|
|
144
|
+
(plan.run ? `the agent's stdout and was NOT deleted` : `the agent's stdout and cannot be named`));
|
|
145
|
+
}
|
|
146
|
+
for (const warning of warnings)
|
|
147
|
+
announce(warning);
|
|
148
|
+
const listedGroups = [...named(forwarderRead).filter((name) => name === forwarderGroup), ...named(runtimeRead)];
|
|
149
|
+
const logGroups = "unreadable" in forwarderRead ? [forwarderGroup, ...listedGroups] : listedGroups;
|
|
150
|
+
const found = [];
|
|
151
|
+
if (alarms.length > 0)
|
|
152
|
+
found.push(`${alarms.length} wake alarm(s) under ${prefix}`);
|
|
153
|
+
if (stackExists)
|
|
154
|
+
found.push(`stack ${stack}`);
|
|
155
|
+
if (bucketExists)
|
|
156
|
+
found.push(`bucket ${bucket} (${keys.length} object version(s))`);
|
|
157
|
+
if (repoExists)
|
|
158
|
+
found.push(`repository ${repo}`);
|
|
159
|
+
for (const name of listedGroups)
|
|
160
|
+
found.push(`log group ${name}`);
|
|
161
|
+
// The one thing this command refuses to delete.
|
|
162
|
+
const kept = foreign.length > 0
|
|
163
|
+
? [
|
|
164
|
+
`bucket ${bucket} — it holds ${foreign.join(", ")}, which no deploy of this version writes. Read it, ` +
|
|
165
|
+
`then \`aws s3 rb s3://${bucket} --force\` if you want it gone`,
|
|
166
|
+
]
|
|
167
|
+
: [];
|
|
168
|
+
if (!plan.run) {
|
|
169
|
+
return failures.length > 0
|
|
170
|
+
? gate(`${failures.length} read(s) failed:\n ${failures.join("\n ")}`, { found, kept })
|
|
171
|
+
: { ok: true, found, removed: [], kept };
|
|
172
|
+
}
|
|
173
|
+
// ---- The deletions, in the order the dependencies demand. ----
|
|
174
|
+
const removed = [];
|
|
175
|
+
/** One deletion and its bookkeeping: what got deleted, and what refused to be. */
|
|
176
|
+
const attempt = async (label, args) => {
|
|
177
|
+
const result = await aws.write(args);
|
|
178
|
+
if ("done" in result) {
|
|
179
|
+
removed.push(label);
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
if ("absent" in result)
|
|
183
|
+
return true; // already gone: the goal state
|
|
184
|
+
failures.push(`${label}: ${result.refused}`);
|
|
185
|
+
return false;
|
|
186
|
+
};
|
|
187
|
+
const swept = new Set();
|
|
188
|
+
const sweepAlarms = async (names) => {
|
|
189
|
+
for (const alarm of names.filter((name) => !swept.has(name))) {
|
|
190
|
+
swept.add(alarm);
|
|
191
|
+
await attempt(`wake alarm ${alarm}`, ["scheduler", "delete-schedule", "--name", alarm]);
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
await sweepAlarms(alarms);
|
|
195
|
+
if (stackExists) {
|
|
196
|
+
announce(`deleting stack ${stack} (this waits for CloudFormation)…`);
|
|
197
|
+
const asked = await aws.write(["cloudformation", "delete-stack", "--stack-name", stack]);
|
|
198
|
+
if ("refused" in asked) {
|
|
199
|
+
return gate(`stack ${stack} refused to delete: ${asked.refused}\n` +
|
|
200
|
+
` the image and the forwarder package it was built from are untouched, so a retry has what it needs.`, { found, removed, kept });
|
|
201
|
+
}
|
|
202
|
+
// THE WAIT DECIDES whether anything below may run, AND whether the stack counts as deleted at all.
|
|
203
|
+
// `delete-stack` returning 0 only means CloudFormation accepted the request: a DELETE_FAILED stack still
|
|
204
|
+
// holds a billing Bedrock runtime and Lambda, and the image and the forwarder zip below are what it was
|
|
205
|
+
// created FROM — deleting those while it stands only makes the operator's retry worse. Recording it as
|
|
206
|
+
// removed before this point printed "deleted: stack X" directly above "did not finish deleting".
|
|
207
|
+
const waited = await aws.write(["cloudformation", "wait", "stack-delete-complete", "--stack-name", stack]);
|
|
208
|
+
if ("refused" in waited) {
|
|
209
|
+
return gate(`stack ${stack} did not finish deleting: ${waited.refused}\n` +
|
|
210
|
+
` its runtime and Lambda are still billing. The image and the forwarder package it was built ` +
|
|
211
|
+
`from are untouched, so a retry has what it needs.`, { found, removed, kept });
|
|
212
|
+
}
|
|
213
|
+
removed.push(`stack ${stack}`);
|
|
214
|
+
// AGAIN, NOW. The container served the whole deletion and holds `scheduler:CreateSchedule`, so a wake-up
|
|
215
|
+
// taken in those minutes minted an alarm after the first sweep read the list — an alarm whose target is the
|
|
216
|
+
// Lambda we just deleted, retrying into nothing for weeks.
|
|
217
|
+
const after = await aws.read(["scheduler", "list-schedules", "--name-prefix", prefix, "--output", "json"], parseScheduleNames);
|
|
218
|
+
if ("unreadable" in after) {
|
|
219
|
+
failures.push(`re-listing wake alarms under ${prefix}: ${after.unreadable}`);
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
await sweepAlarms(("ok" in after ? after.ok : []).filter(isMintedAlarm));
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (bucketExists && foreign.length === 0)
|
|
226
|
+
await purgeBucket(bucket, aws, attempt, failures);
|
|
227
|
+
if (repoExists) {
|
|
228
|
+
await attempt(`repository ${repo}`, ["ecr", "delete-repository", "--repository-name", repo, "--force"]);
|
|
229
|
+
}
|
|
230
|
+
for (const name of logGroups) {
|
|
231
|
+
await attempt(`log group ${name}`, ["logs", "delete-log-group", "--log-group-name", name]);
|
|
232
|
+
}
|
|
233
|
+
if (failures.length > 0) {
|
|
234
|
+
return gate(`${failures.length} resource(s) survived:\n ${failures.join("\n ")}`, { found, removed, kept });
|
|
235
|
+
}
|
|
236
|
+
return { ok: true, found, removed, kept };
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Empty the bucket, then delete it. The emptying is not optional even for a bucket that was never versioned:
|
|
240
|
+
* `delete-bucket` refuses a non-empty one, and the forwarder's zips are always in there.
|
|
241
|
+
*/
|
|
242
|
+
async function purgeBucket(bucket, aws, attempt, failures) {
|
|
243
|
+
// RE-READ rather than reusing the inventory: minutes of `stack-delete-complete` sit between the two. And the
|
|
244
|
+
// same three answers as everywhere — a failed read must not be reported as `nothing left to delete`.
|
|
245
|
+
const listed = await aws.read(["s3api", "list-object-versions", "--bucket", bucket, "--output", "json"], parseVersions);
|
|
246
|
+
if ("absent" in listed)
|
|
247
|
+
return; // gone between the inventory and now: the goal state
|
|
248
|
+
if ("unreadable" in listed) {
|
|
249
|
+
failures.push(`bucket ${bucket}: could not list its objects, so it was left in place (${listed.unreadable})`);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
// THE SAME RULE ON WHAT IT JUST READ. The decision to delete this bucket was made against the inventory,
|
|
253
|
+
// minutes of `stack-delete-complete` ago, and this listing is the one whose contents actually get deleted.
|
|
254
|
+
// Nothing in the deployment can write here in that window (the template grants the runtime and the forwarder
|
|
255
|
+
// no `s3:*`), so this catches a person or another tool — and a batch delete is not reversible.
|
|
256
|
+
const foreign = [...new Set(listed.ok.map((v) => v.Key).filter((key) => !key.startsWith("forwarder/")))];
|
|
257
|
+
if (foreign.length > 0) {
|
|
258
|
+
failures.push(`bucket ${bucket} — ${foreign.join(", ")} appeared in it since the inventory, so it was left in place`);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
for (let at = 0; at < listed.ok.length; at += DELETE_BATCH) {
|
|
262
|
+
const batch = listed.ok.slice(at, at + DELETE_BATCH).map((v) => ({ Key: v.Key, VersionId: v.VersionId }));
|
|
263
|
+
await attempt(`${batch.length} object version(s) in ${bucket}`, [
|
|
264
|
+
"s3api",
|
|
265
|
+
"delete-objects",
|
|
266
|
+
"--bucket",
|
|
267
|
+
bucket,
|
|
268
|
+
"--delete",
|
|
269
|
+
JSON.stringify({ Objects: batch, Quiet: true }),
|
|
270
|
+
]);
|
|
271
|
+
}
|
|
272
|
+
await attempt(`bucket ${bucket}`, ["s3api", "delete-bucket", "--bucket", bucket]);
|
|
273
|
+
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// what runs (and what the manual runbook zips) — not an input you can change.
|
|
7
7
|
// Webhooks (Function URL) and EventBridge Scheduler fires are forwarded as envelopes to the
|
|
8
8
|
// AgentCore Runtime over SigV4 InvokeAgentRuntime, all on ONE fixed ingress session (fastagent
|
|
9
|
-
// channel state is single-writer; one session = at most one microVM).
|
|
9
|
+
// channel state is single-writer; one session = at most one microVM). This
|
|
10
10
|
// Lambda also OWNS the wake alarms: the container POSTs its pending wake-ups to /__fastagent/
|
|
11
11
|
// wake-alarm (shared secret) and each becomes a self-deleting one-shot EventBridge schedule that
|
|
12
12
|
// pokes this Lambda — which wakes the container, whose wake pump fires the due entry.
|
|
@@ -17,13 +17,6 @@ const { BedrockAgentCoreClient, InvokeAgentRuntimeCommand } = require("@aws-sdk/
|
|
|
17
17
|
const client = new BedrockAgentCoreClient({});
|
|
18
18
|
let ownUrl; // self-resolved once per cold start; rides on every envelope for the wake-alarm callback
|
|
19
19
|
|
|
20
|
-
// Presigned S3 URLs for the container's state snapshot. AgentCore wipes the /mnt/state mount on
|
|
21
|
-
// every runtime version update (= every deploy), so the durable copy lives in S3 — but the
|
|
22
|
-
// container is given NO AWS credentials by the platform, so the only reachable form is a URL that
|
|
23
|
-
// carries its own authorization. SigV4 query signing, node:crypto only (no SDK, nothing to install).
|
|
24
|
-
const enc = (s) => encodeURIComponent(s).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
25
|
-
const hmac = (key, data) => crypto.createHmac("sha256", key).update(data).digest();
|
|
26
|
-
|
|
27
20
|
// Every shared-secret gate on this public URL goes through here. Constant-time, and an unset
|
|
28
21
|
// expected secret NEVER matches — otherwise a topology that did not configure one would accept a
|
|
29
22
|
// request that sent none. Non-string input is coerced to "" first: Buffer.from(8) allocates eight
|
|
@@ -34,39 +27,8 @@ const secretEq = (given, expected) => {
|
|
|
34
27
|
return b.length > 0 && a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
35
28
|
};
|
|
36
29
|
|
|
37
|
-
function presign(method, seconds) {
|
|
38
|
-
const bucket = process.env.STATE_BUCKET,
|
|
39
|
-
key = process.env.STATE_KEY,
|
|
40
|
-
region = process.env.AWS_REGION;
|
|
41
|
-
const host = `${bucket}.s3.${region}.amazonaws.com`;
|
|
42
|
-
const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d+/, "");
|
|
43
|
-
const scope = `${stamp.slice(0, 8)}/${region}/s3/aws4_request`;
|
|
44
|
-
const pairs = [
|
|
45
|
-
["X-Amz-Algorithm", "AWS4-HMAC-SHA256"],
|
|
46
|
-
["X-Amz-Credential", `${process.env.AWS_ACCESS_KEY_ID}/${scope}`],
|
|
47
|
-
["X-Amz-Date", stamp],
|
|
48
|
-
["X-Amz-Expires", String(seconds)],
|
|
49
|
-
["X-Amz-SignedHeaders", "host"],
|
|
50
|
-
];
|
|
51
|
-
if (process.env.AWS_SESSION_TOKEN) pairs.push(["X-Amz-Security-Token", process.env.AWS_SESSION_TOKEN]);
|
|
52
|
-
// The canonical query must be byte-identical to the one on the wire — build it ONCE, reuse below.
|
|
53
|
-
const query = pairs
|
|
54
|
-
.map(([k, v]) => [enc(k), enc(v)])
|
|
55
|
-
.sort((a, b) => (a[0] < b[0] ? -1 : 1))
|
|
56
|
-
.map((p) => p.join("="))
|
|
57
|
-
.join("&");
|
|
58
|
-
const path = `/${key.split("/").map(enc).join("/")}`;
|
|
59
|
-
const canonical = [method, path, query, `host:${host}\n`, "host", "UNSIGNED-PAYLOAD"].join("\n");
|
|
60
|
-
const sts = ["AWS4-HMAC-SHA256", stamp, scope, crypto.createHash("sha256").update(canonical).digest("hex")].join(
|
|
61
|
-
"\n",
|
|
62
|
-
);
|
|
63
|
-
let k = hmac(`AWS4${process.env.AWS_SECRET_ACCESS_KEY}`, stamp.slice(0, 8));
|
|
64
|
-
for (const part of [region, "s3", "aws4_request"]) k = hmac(k, part);
|
|
65
|
-
return `https://${host}${path}?${query}&X-Amz-Signature=${hmac(k, sts).toString("hex")}`;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
30
|
async function invoke(envelope) {
|
|
69
|
-
if (
|
|
31
|
+
if (process.env.WAKE_SECRET && !ownUrl) {
|
|
70
32
|
const { LambdaClient, GetFunctionUrlConfigCommand } = require("@aws-sdk/client-lambda");
|
|
71
33
|
ownUrl = (
|
|
72
34
|
await new LambdaClient({}).send(
|
|
@@ -77,22 +39,6 @@ async function invoke(envelope) {
|
|
|
77
39
|
if (ownUrl) envelope.wake = { url: ownUrl };
|
|
78
40
|
// Authenticates this envelope as coming from the forwarder (see the template's FastagentIngressSecret).
|
|
79
41
|
envelope.auth = process.env.INGRESS_SECRET;
|
|
80
|
-
// Keep each capability short-lived. Function-URL deployments also carry an authenticated refresh
|
|
81
|
-
// endpoint, so a background turn settling hours after its webhook never depends on the temporary
|
|
82
|
-
// Lambda credentials that signed the original pair still being alive.
|
|
83
|
-
if (process.env.STATE_BUCKET)
|
|
84
|
-
envelope.state = {
|
|
85
|
-
getUrl: presign("GET", 3600),
|
|
86
|
-
putUrl: presign("PUT", 3600),
|
|
87
|
-
...(ownUrl && process.env.STATE_REFRESH_SECRET
|
|
88
|
-
? {
|
|
89
|
-
refresh: {
|
|
90
|
-
url: `${ownUrl.replace(/\/$/, "")}/__fastagent/state-urls`,
|
|
91
|
-
auth: process.env.STATE_REFRESH_SECRET,
|
|
92
|
-
},
|
|
93
|
-
}
|
|
94
|
-
: {}),
|
|
95
|
-
};
|
|
96
42
|
const res = await client.send(
|
|
97
43
|
new InvokeAgentRuntimeCommand({
|
|
98
44
|
agentRuntimeArn: process.env.RUNTIME_ARN,
|
|
@@ -151,35 +97,43 @@ async function syncAlarms(alarms, ctx) {
|
|
|
151
97
|
return failed;
|
|
152
98
|
}
|
|
153
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Invoke the container, and leave ONE line under `label` whichever way it ends — including the way that never came
|
|
102
|
+
* back at all.
|
|
103
|
+
*
|
|
104
|
+
* Lambda already logs an unhandled throw, so the failure is not invisible; what it is not is ATTRIBUTABLE. An
|
|
105
|
+
* operator (or a probe) filtering this log group for `routine-fire <name>` sees nothing and concludes the clock
|
|
106
|
+
* never fired, when what actually happened is that the clock fired and the call died. Those need different fixes
|
|
107
|
+
* and looked identical from outside — that mistake cost a real debugging session.
|
|
108
|
+
*/
|
|
109
|
+
async function invokeLogged(label, envelope) {
|
|
110
|
+
try {
|
|
111
|
+
return await invoke(envelope);
|
|
112
|
+
} catch (e) {
|
|
113
|
+
console.log(`${label}: invoke failed: ${e?.message || e}`);
|
|
114
|
+
throw e; // the miss still lands as a Lambda error, which is what makes EventBridge retry it
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
154
118
|
exports.handler = async (event, ctx) => {
|
|
155
119
|
// EventBridge wake-up poke: the invocation itself wakes the container; its pump does the rest.
|
|
156
120
|
if (event?.wakePoke) {
|
|
157
|
-
const r = await
|
|
121
|
+
const r = await invokeLogged("wake-poke", { kind: "wake-poke" });
|
|
158
122
|
console.log(`wake-poke: ${r.status}`);
|
|
159
123
|
return { status: r.status };
|
|
160
124
|
}
|
|
161
125
|
// EventBridge Scheduler fire — throw on failure so the miss lands in CloudWatch, never silently.
|
|
162
126
|
if (event?.scheduleFire) {
|
|
163
|
-
const { name,
|
|
164
|
-
const
|
|
127
|
+
const { name, occurrence } = event.scheduleFire;
|
|
128
|
+
const label = `routine-fire ${name} (${occurrence})`;
|
|
129
|
+
const r = await invokeLogged(label, { kind: "routine-fire", name, occurrence });
|
|
165
130
|
const out = r.body.toString();
|
|
166
|
-
console.log(
|
|
167
|
-
if (r.status >= 400) throw new Error(`
|
|
131
|
+
console.log(`${label}: ${r.status} ${out}`);
|
|
132
|
+
if (r.status >= 400) throw new Error(`routine-fire ${name} failed: ${r.status} ${out}`);
|
|
168
133
|
return { status: r.status };
|
|
169
134
|
}
|
|
170
135
|
const http = event?.requestContext?.http;
|
|
171
136
|
if (!http) throw new Error("unrecognized event shape");
|
|
172
|
-
// Refresh the snapshot capabilities with THIS Lambda invocation's current temporary credentials.
|
|
173
|
-
// The container may settle long after the webhook Lambda (and its credentials) expired.
|
|
174
|
-
if (event.rawPath === "/__fastagent/state-urls") {
|
|
175
|
-
const req = JSON.parse(event.isBase64Encoded ? Buffer.from(event.body, "base64").toString() : event.body || "{}");
|
|
176
|
-
if (!secretEq(req.auth, process.env.STATE_REFRESH_SECRET)) return { statusCode: 403, body: "forbidden\n" };
|
|
177
|
-
return {
|
|
178
|
-
statusCode: 200,
|
|
179
|
-
headers: { "content-type": "application/json" },
|
|
180
|
-
body: JSON.stringify({ getUrl: presign("GET", 3600), putUrl: presign("PUT", 3600) }),
|
|
181
|
-
};
|
|
182
|
-
}
|
|
183
137
|
// The container's wake-alarm callback (reserved path, shared secret) — handled HERE, never forwarded.
|
|
184
138
|
if (event.rawPath === "/__fastagent/wake-alarm") {
|
|
185
139
|
const req = JSON.parse(event.isBase64Encoded ? Buffer.from(event.body, "base64").toString() : event.body || "{}");
|
|
@@ -190,13 +144,8 @@ exports.handler = async (event, ctx) => {
|
|
|
190
144
|
if (failed > 0) return { statusCode: 500, body: `${failed} alarm(s) failed\n` };
|
|
191
145
|
return { statusCode: 200, body: "ok\n" };
|
|
192
146
|
}
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
// them, and would make the runtime construct against a pre-restore mount) and pass its structured
|
|
196
|
-
// transport-200 verdict back VERBATIM. The ordinary webhook path below folds a non-200 transport
|
|
197
|
-
// into an opaque 502, which would strip exactly the diagnostics the probe exists to carry — and it
|
|
198
|
-
// sits BEFORE the WEBHOOKS_ENABLED gate so schedule-only topologies (whose URLs refuse ordinary
|
|
199
|
-
// public traffic) are probeable too.
|
|
147
|
+
// Preserve the trusted probe's structured verdict instead of folding its diagnostics into a 502.
|
|
148
|
+
// Schedule-only topologies also need this path, before the ordinary webhook gate.
|
|
200
149
|
if (event.rawPath === "/__fastagent/probe") {
|
|
201
150
|
const req = JSON.parse(event.isBase64Encoded ? Buffer.from(event.body, "base64").toString() : event.body || "{}");
|
|
202
151
|
if (!secretEq(req.auth, process.env.INGRESS_SECRET)) return { statusCode: 403, body: "forbidden\n" };
|