@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.
Files changed (388) hide show
  1. package/README.md +9 -12
  2. package/dist/agent.d.ts +24 -37
  3. package/dist/agent.js +7 -15
  4. package/dist/atomic-write.d.ts +13 -17
  5. package/dist/atomic-write.js +18 -29
  6. package/dist/bind.d.ts +12 -22
  7. package/dist/bind.js +18 -27
  8. package/dist/channel.d.ts +11 -33
  9. package/dist/channels/agentcore-limits.d.ts +2 -13
  10. package/dist/channels/agentcore-limits.js +2 -13
  11. package/dist/channels/agentcore-protocol.d.ts +67 -58
  12. package/dist/channels/agentcore-protocol.js +23 -15
  13. package/dist/channels/agentcore-service.d.ts +19 -33
  14. package/dist/channels/agentcore-service.js +176 -59
  15. package/dist/channels/agentcore.d.ts +39 -33
  16. package/dist/channels/agentcore.js +90 -224
  17. package/dist/channels/body.d.ts +15 -2
  18. package/dist/channels/body.js +24 -2
  19. package/dist/channels/busy.d.ts +1 -21
  20. package/dist/channels/busy.js +2 -39
  21. package/dist/channels/control.d.ts +17 -62
  22. package/dist/channels/control.js +74 -182
  23. package/dist/channels/define-channel.d.ts +39 -0
  24. package/dist/channels/define-channel.js +9 -0
  25. package/dist/channels/discover.d.ts +10 -31
  26. package/dist/channels/discover.js +37 -31
  27. package/dist/channels/feishu/bootstrap-token.d.ts +1 -8
  28. package/dist/channels/feishu/bootstrap-token.js +7 -23
  29. package/dist/channels/feishu/card.d.ts +14 -28
  30. package/dist/channels/feishu/card.js +16 -30
  31. package/dist/channels/feishu/cloud.d.ts +1 -5
  32. package/dist/channels/feishu/context-buffer.d.ts +4 -23
  33. package/dist/channels/feishu/context-buffer.js +8 -31
  34. package/dist/channels/feishu/crypto.d.ts +10 -5
  35. package/dist/channels/feishu/crypto.js +12 -19
  36. package/dist/channels/feishu/feishu-api.d.ts +2 -1
  37. package/dist/channels/feishu/feishu-api.js +5 -4
  38. package/dist/channels/feishu/feishu.d.ts +4 -10
  39. package/dist/channels/feishu/feishu.js +113 -227
  40. package/dist/channels/feishu/invoke-turn.d.ts +5 -4
  41. package/dist/channels/feishu/invoke-turn.js +36 -54
  42. package/dist/channels/feishu/model.d.ts +4 -21
  43. package/dist/channels/feishu/model.js +1 -8
  44. package/dist/channels/feishu/normalize.d.ts +2 -10
  45. package/dist/channels/feishu/normalize.js +7 -42
  46. package/dist/channels/feishu/parse.d.ts +9 -29
  47. package/dist/channels/feishu/parse.js +8 -26
  48. package/dist/channels/feishu/preview.d.ts +12 -14
  49. package/dist/channels/feishu/preview.js +108 -207
  50. package/dist/channels/feishu/register-app.d.ts +8 -23
  51. package/dist/channels/feishu/register-app.js +7 -20
  52. package/dist/channels/feishu/register-webhook.d.ts +2 -9
  53. package/dist/channels/feishu/register-webhook.js +9 -33
  54. package/dist/channels/feishu/scaffold/channel.ts +27 -19
  55. package/dist/channels/feishu/scaffold/channel.websocket.ts +31 -0
  56. package/dist/channels/feishu/setup-mode.d.ts +11 -36
  57. package/dist/channels/feishu/setup-mode.js +7 -26
  58. package/dist/channels/feishu/ws-ingress.d.ts +1 -2
  59. package/dist/channels/feishu/ws-ingress.js +6 -8
  60. package/dist/channels/http.d.ts +15 -15
  61. package/dist/channels/http.js +19 -9
  62. package/dist/channels/kit/attachment-path.d.ts +4 -5
  63. package/dist/channels/kit/attachment-path.js +10 -34
  64. package/dist/channels/kit/context-buffer.d.ts +14 -21
  65. package/dist/channels/kit/context-buffer.js +25 -38
  66. package/dist/channels/kit/delivery.d.ts +35 -0
  67. package/dist/channels/kit/delivery.js +129 -0
  68. package/dist/channels/kit/event-stream.d.ts +5 -0
  69. package/dist/channels/kit/event-stream.js +29 -0
  70. package/dist/channels/kit/invoke-turn-kit.d.ts +53 -26
  71. package/dist/channels/kit/invoke-turn-kit.js +89 -32
  72. package/dist/channels/kit/preview-kit.d.ts +18 -72
  73. package/dist/channels/kit/preview-kit.js +22 -88
  74. package/dist/channels/kit/signature.d.ts +3 -9
  75. package/dist/channels/kit/signature.js +3 -9
  76. package/dist/channels/kit/state.d.ts +1 -2
  77. package/dist/channels/kit/state.js +5 -14
  78. package/dist/channels/kit/stop-command.d.ts +1 -2
  79. package/dist/channels/kit/stop-command.js +6 -9
  80. package/dist/channels/kit/tasks.d.ts +1 -1
  81. package/dist/channels/kit/tasks.js +8 -21
  82. package/dist/channels/kit/text.d.ts +2 -16
  83. package/dist/channels/kit/text.js +23 -31
  84. package/dist/channels/kit/thread-participants.d.ts +4 -17
  85. package/dist/channels/kit/thread-participants.js +8 -55
  86. package/dist/channels/kit/transport.d.ts +26 -0
  87. package/dist/channels/kit/transport.js +20 -0
  88. package/dist/channels/kit/turn-queue.d.ts +7 -11
  89. package/dist/channels/kit/turn-queue.js +21 -39
  90. package/dist/channels/kit/turn-runner.d.ts +32 -18
  91. package/dist/channels/kit/turn-runner.js +98 -53
  92. package/dist/channels/kit/turn-store.d.ts +32 -32
  93. package/dist/channels/kit/turn-store.js +45 -88
  94. package/dist/channels/lark/lark.d.ts +1 -5
  95. package/dist/channels/lark/onboard.d.ts +3 -12
  96. package/dist/channels/lark/onboard.js +3 -13
  97. package/dist/channels/lark/scaffold/channel.ts +27 -19
  98. package/dist/channels/lark/scaffold/channel.websocket.ts +31 -0
  99. package/dist/channels/registration.d.ts +9 -35
  100. package/dist/channels/registration.js +6 -37
  101. package/dist/channels/secret.js +2 -5
  102. package/dist/channels/serve.d.ts +47 -44
  103. package/dist/channels/serve.js +165 -101
  104. package/dist/channels/slack/config-api.d.ts +4 -27
  105. package/dist/channels/slack/config-api.js +5 -34
  106. package/dist/channels/slack/context-buffer.d.ts +2 -3
  107. package/dist/channels/slack/context-buffer.js +5 -5
  108. package/dist/channels/slack/invoke-turn.d.ts +3 -1
  109. package/dist/channels/slack/invoke-turn.js +25 -34
  110. package/dist/channels/slack/manifest.js +1 -3
  111. package/dist/channels/slack/model.d.ts +1 -2
  112. package/dist/channels/slack/model.js +1 -1
  113. package/dist/channels/slack/onboard.d.ts +4 -3
  114. package/dist/channels/slack/onboard.js +8 -11
  115. package/dist/channels/slack/onboarding-state.d.ts +3 -5
  116. package/dist/channels/slack/onboarding-state.js +9 -6
  117. package/dist/channels/slack/parse.d.ts +4 -13
  118. package/dist/channels/slack/parse.js +7 -28
  119. package/dist/channels/slack/preview.d.ts +12 -2
  120. package/dist/channels/slack/preview.js +292 -269
  121. package/dist/channels/slack/reaction.d.ts +11 -3
  122. package/dist/channels/slack/reaction.js +19 -8
  123. package/dist/channels/slack/register-webhook.js +4 -7
  124. package/dist/channels/slack/scaffold/channel.ts +20 -13
  125. package/dist/channels/slack/scaffold/slack-send.ts +2 -2
  126. package/dist/channels/slack/setup-server.d.ts +1 -5
  127. package/dist/channels/slack/setup-server.js +1 -5
  128. package/dist/channels/slack/shared-api.d.ts +1 -4
  129. package/dist/channels/slack/shared-api.js +2 -13
  130. package/dist/channels/slack/slack-api.d.ts +2 -1
  131. package/dist/channels/slack/slack-api.js +6 -4
  132. package/dist/channels/slack/slack.d.ts +11 -14
  133. package/dist/channels/slack/slack.js +121 -122
  134. package/dist/channels/sse.d.ts +1 -1
  135. package/dist/channels/sse.js +1 -1
  136. package/dist/channels/telegram/context-buffer.d.ts +7 -22
  137. package/dist/channels/telegram/context-buffer.js +6 -23
  138. package/dist/channels/telegram/invoke-turn.d.ts +7 -7
  139. package/dist/channels/telegram/invoke-turn.js +24 -43
  140. package/dist/channels/telegram/parse.d.ts +24 -51
  141. package/dist/channels/telegram/parse.js +32 -84
  142. package/dist/channels/telegram/preview.d.ts +10 -1
  143. package/dist/channels/telegram/preview.js +79 -100
  144. package/dist/channels/telegram/register-webhook.js +5 -23
  145. package/dist/channels/telegram/scaffold/channel.ts +25 -18
  146. package/dist/channels/telegram/scaffold/telegram-send.ts +6 -4
  147. package/dist/channels/telegram/telegram-api.d.ts +4 -3
  148. package/dist/channels/telegram/telegram-api.js +9 -7
  149. package/dist/channels/telegram/telegram.d.ts +4 -18
  150. package/dist/channels/telegram/telegram.js +38 -94
  151. package/dist/channels/telegram/turn-store.d.ts +11 -14
  152. package/dist/channels/telegram/turn-store.js +8 -6
  153. package/dist/channels/wait-health.d.ts +4 -6
  154. package/dist/channels/wait-health.js +10 -14
  155. package/dist/cli/add-feishu.d.ts +2 -12
  156. package/dist/cli/add-feishu.js +24 -71
  157. package/dist/cli/add-slack.d.ts +5 -3
  158. package/dist/cli/add-slack.js +53 -76
  159. package/dist/cli/auth-view.d.ts +2 -16
  160. package/dist/cli/auth-view.js +2 -16
  161. package/dist/cli/commands/add.js +25 -40
  162. package/dist/cli/commands/chat.d.ts +0 -1
  163. package/dist/cli/commands/chat.js +5 -6
  164. package/dist/cli/commands/deploy/agentcore.js +60 -65
  165. package/dist/cli/commands/deploy/docker.js +50 -23
  166. package/dist/cli/commands/deploy/fly.js +23 -61
  167. package/dist/cli/commands/deploy/railway.js +23 -18
  168. package/dist/cli/commands/deploy/shared.d.ts +38 -57
  169. package/dist/cli/commands/deploy/shared.js +40 -59
  170. package/dist/cli/commands/deploy.d.ts +10 -27
  171. package/dist/cli/commands/deploy.js +38 -60
  172. package/dist/cli/commands/destroy.d.ts +4 -0
  173. package/dist/cli/commands/destroy.js +56 -0
  174. package/dist/cli/commands/dev.d.ts +2 -1
  175. package/dist/cli/commands/dev.js +20 -23
  176. package/dist/cli/commands/info.d.ts +0 -2
  177. package/dist/cli/commands/info.js +83 -49
  178. package/dist/cli/commands/init.d.ts +1 -2
  179. package/dist/cli/commands/init.js +16 -55
  180. package/dist/cli/commands/invoke.d.ts +0 -1
  181. package/dist/cli/commands/invoke.js +5 -7
  182. package/dist/cli/commands/login.d.ts +2 -1
  183. package/dist/cli/commands/login.js +33 -38
  184. package/dist/cli/commands/logs.js +3 -4
  185. package/dist/cli/commands/models.js +1 -5
  186. package/dist/cli/commands/routine-run.d.ts +6 -0
  187. package/dist/cli/commands/routine-run.js +47 -0
  188. package/dist/cli/commands/routine.d.ts +25 -0
  189. package/dist/cli/commands/routine.js +122 -0
  190. package/dist/cli/commands/start.d.ts +31 -3
  191. package/dist/cli/commands/start.js +131 -81
  192. package/dist/cli/commands/tool.js +26 -12
  193. package/dist/cli/fail.d.ts +13 -23
  194. package/dist/cli/fail.js +20 -23
  195. package/dist/cli/invoke-stream.d.ts +1 -8
  196. package/dist/cli/kernel.d.ts +6 -38
  197. package/dist/cli/kernel.js +14 -77
  198. package/dist/cli/models-view.d.ts +1 -9
  199. package/dist/cli/models-view.js +11 -22
  200. package/dist/cli/program.d.ts +5 -6
  201. package/dist/cli/program.js +120 -157
  202. package/dist/cli/serve.d.ts +57 -41
  203. package/dist/cli/serve.js +94 -121
  204. package/dist/cli/shared.d.ts +15 -61
  205. package/dist/cli/shared.js +55 -181
  206. package/dist/cli.js +1 -8
  207. package/dist/collect.d.ts +6 -0
  208. package/dist/collect.js +6 -0
  209. package/dist/core.d.ts +4 -1
  210. package/dist/core.js +9 -7
  211. package/dist/declared-secrets.d.ts +55 -0
  212. package/dist/declared-secrets.js +88 -0
  213. package/dist/deploy/agentcore/aws-cli.d.ts +91 -0
  214. package/dist/deploy/agentcore/aws-cli.js +64 -0
  215. package/dist/deploy/agentcore/destroy.d.ts +51 -0
  216. package/dist/deploy/agentcore/destroy.js +273 -0
  217. package/dist/deploy/agentcore/forwarder.js +28 -79
  218. package/dist/deploy/agentcore/logs.d.ts +2 -18
  219. package/dist/deploy/agentcore/logs.js +27 -51
  220. package/dist/deploy/agentcore/plan.d.ts +85 -105
  221. package/dist/deploy/agentcore/plan.js +152 -314
  222. package/dist/deploy/agentcore/run.d.ts +28 -28
  223. package/dist/deploy/agentcore/run.js +227 -306
  224. package/dist/deploy/agentcore/zip.d.ts +2 -15
  225. package/dist/deploy/agentcore/zip.js +3 -17
  226. package/dist/deploy/build-context.d.ts +21 -0
  227. package/dist/deploy/build-context.js +137 -0
  228. package/dist/deploy/channel-ingress.d.ts +26 -39
  229. package/dist/deploy/channel-ingress.js +57 -28
  230. package/dist/deploy/container.d.ts +20 -34
  231. package/dist/deploy/container.js +49 -100
  232. package/dist/deploy/docker/plan.d.ts +12 -11
  233. package/dist/deploy/docker/plan.js +43 -24
  234. package/dist/deploy/docker/run.d.ts +22 -25
  235. package/dist/deploy/docker/run.js +39 -37
  236. package/dist/deploy/fly/plan.d.ts +15 -44
  237. package/dist/deploy/fly/plan.js +41 -73
  238. package/dist/deploy/fly/run.d.ts +16 -51
  239. package/dist/deploy/fly/run.js +42 -95
  240. package/dist/deploy/hosts.d.ts +24 -3
  241. package/dist/deploy/hosts.js +24 -3
  242. package/dist/deploy/preflight.d.ts +47 -41
  243. package/dist/deploy/preflight.js +282 -383
  244. package/dist/deploy/railway/plan.d.ts +16 -42
  245. package/dist/deploy/railway/plan.js +42 -60
  246. package/dist/deploy/railway/run.d.ts +30 -68
  247. package/dist/deploy/railway/run.js +86 -100
  248. package/dist/deploy/registration-gate.d.ts +6 -13
  249. package/dist/deploy/residency.d.ts +37 -0
  250. package/dist/deploy/residency.js +24 -0
  251. package/dist/deploy/runner.d.ts +10 -9
  252. package/dist/deploy/runner.js +7 -18
  253. package/dist/deploy/secrets.d.ts +40 -41
  254. package/dist/deploy/secrets.js +117 -103
  255. package/dist/deploy/workspace.d.ts +50 -0
  256. package/dist/deploy/workspace.js +197 -0
  257. package/dist/dev-supervisor.d.ts +1 -9
  258. package/dist/dev-supervisor.js +21 -64
  259. package/dist/effect-port.d.ts +50 -0
  260. package/dist/effect-port.js +77 -0
  261. package/dist/engines/pi/agent-session-factory.d.ts +50 -69
  262. package/dist/engines/pi/agent-session-factory.js +249 -146
  263. package/dist/engines/pi/auth.d.ts +15 -11
  264. package/dist/engines/pi/auth.js +96 -99
  265. package/dist/engines/pi/chat.d.ts +1 -5
  266. package/dist/engines/pi/chat.js +2 -14
  267. package/dist/engines/pi/config.d.ts +53 -51
  268. package/dist/engines/pi/config.js +83 -92
  269. package/dist/engines/pi/create.d.ts +60 -75
  270. package/dist/engines/pi/create.js +108 -141
  271. package/dist/engines/pi/definition.d.ts +14 -58
  272. package/dist/engines/pi/definition.js +21 -85
  273. package/dist/engines/pi/invoke-session.d.ts +5 -25
  274. package/dist/engines/pi/invoke-session.js +233 -244
  275. package/dist/engines/pi/login.d.ts +95 -35
  276. package/dist/engines/pi/login.js +259 -107
  277. package/dist/engines/pi/machine.d.ts +31 -0
  278. package/dist/engines/pi/machine.js +106 -0
  279. package/dist/engines/pi/models.d.ts +70 -89
  280. package/dist/engines/pi/models.js +188 -81
  281. package/dist/engines/pi/open.d.ts +65 -53
  282. package/dist/engines/pi/open.js +129 -69
  283. package/dist/engines/pi/report.d.ts +3 -12
  284. package/dist/engines/pi/report.js +2 -16
  285. package/dist/engines/pi/search-tools.d.ts +1 -9
  286. package/dist/engines/pi/search-tools.js +26 -57
  287. package/dist/engines/pi/service.d.ts +2 -16
  288. package/dist/engines/pi/service.js +2 -16
  289. package/dist/engines/pi/session-builder.d.ts +5 -9
  290. package/dist/engines/pi/session-builder.js +44 -82
  291. package/dist/engines/pi/session-control.js +286 -269
  292. package/dist/engines/pi/session-effects.d.ts +14 -0
  293. package/dist/engines/pi/session-effects.js +30 -0
  294. package/dist/engines/pi/session-inheritance.d.ts +9 -18
  295. package/dist/engines/pi/session-inheritance.js +103 -160
  296. package/dist/engines/pi/session-markers.d.ts +31 -30
  297. package/dist/engines/pi/session-markers.js +26 -25
  298. package/dist/engines/pi/session-settings.d.ts +6 -22
  299. package/dist/engines/pi/session-settings.js +10 -21
  300. package/dist/engines/pi/session-store.d.ts +26 -92
  301. package/dist/engines/pi/session-store.js +96 -310
  302. package/dist/engines/pi/tool-context.d.ts +17 -45
  303. package/dist/engines/pi/tool-context.js +7 -26
  304. package/dist/engines/pi/tool.d.ts +45 -36
  305. package/dist/engines/pi/tool.js +43 -57
  306. package/dist/engines/pi/turn-kit.d.ts +14 -52
  307. package/dist/engines/pi/turn-kit.js +18 -67
  308. package/dist/engines/pi/wake-tool.d.ts +2 -10
  309. package/dist/engines/pi/wake-tool.js +7 -27
  310. package/dist/env.d.ts +27 -28
  311. package/dist/env.js +45 -50
  312. package/dist/index.js +0 -2
  313. package/dist/lark.d.ts +1 -2
  314. package/dist/lark.js +1 -2
  315. package/dist/loader.d.ts +14 -55
  316. package/dist/loader.js +24 -43
  317. package/dist/log.d.ts +2 -12
  318. package/dist/log.js +3 -24
  319. package/dist/node.d.ts +2 -15
  320. package/dist/node.js +2 -18
  321. package/dist/observe.d.ts +2 -8
  322. package/dist/open-url.d.ts +1 -1
  323. package/dist/open-url.js +1 -1
  324. package/dist/paths.d.ts +70 -134
  325. package/dist/paths.js +165 -232
  326. package/dist/pi.d.ts +4 -6
  327. package/dist/pi.js +3 -3
  328. package/dist/proxy.d.ts +16 -6
  329. package/dist/proxy.js +52 -16
  330. package/dist/runtime.d.ts +18 -12
  331. package/dist/runtime.js +18 -11
  332. package/dist/scaffold/add-channel.d.ts +14 -31
  333. package/dist/scaffold/add-channel.js +48 -112
  334. package/dist/scaffold/init.d.ts +9 -31
  335. package/dist/scaffold/init.js +45 -138
  336. package/dist/scaffold/templates/env.example +6 -4
  337. package/dist/scaffold/templates/fastagent.config.ts +36 -0
  338. package/dist/scaffold/templates.d.ts +3 -3
  339. package/dist/scaffold/templates.js +5 -8
  340. package/dist/scaffold/vendor-skill.d.ts +2 -5
  341. package/dist/scaffold/vendor-skill.js +19 -29
  342. package/dist/schedule/cron.d.ts +9 -5
  343. package/dist/schedule/cron.js +9 -11
  344. package/dist/schedule/discover.d.ts +12 -12
  345. package/dist/schedule/discover.js +43 -34
  346. package/dist/schedule/routine.d.ts +43 -0
  347. package/dist/schedule/routine.js +32 -0
  348. package/dist/schedule/run.d.ts +55 -0
  349. package/dist/schedule/run.js +146 -0
  350. package/dist/schedule/scheduler.d.ts +44 -55
  351. package/dist/schedule/scheduler.js +291 -265
  352. package/dist/schedule/state.d.ts +105 -10
  353. package/dist/schedule/state.js +293 -19
  354. package/dist/schedule/wake-alarm.d.ts +10 -23
  355. package/dist/schedule/wake-alarm.js +91 -121
  356. package/dist/schedule/wakeups.d.ts +23 -39
  357. package/dist/schedule/wakeups.js +33 -75
  358. package/dist/secrets-gate.d.ts +32 -0
  359. package/dist/secrets-gate.js +33 -0
  360. package/dist/service.d.ts +137 -98
  361. package/dist/service.js +250 -252
  362. package/dist/session-remote.d.ts +33 -44
  363. package/dist/session-remote.js +203 -192
  364. package/dist/session.d.ts +166 -229
  365. package/dist/session.js +33 -55
  366. package/dist/tunnel.d.ts +8 -36
  367. package/dist/tunnel.js +24 -65
  368. package/dist/version.d.ts +2 -2
  369. package/dist/version.js +2 -2
  370. package/package.json +10 -16
  371. package/dist/channels/agentcore-state.d.ts +0 -77
  372. package/dist/channels/agentcore-state.js +0 -276
  373. package/dist/channels/github/github.d.ts +0 -31
  374. package/dist/channels/github/github.js +0 -89
  375. package/dist/channels/github/scaffold/channel.ts +0 -25
  376. package/dist/cli/commands/attach.d.ts +0 -108
  377. package/dist/cli/commands/attach.js +0 -637
  378. package/dist/cli/commands/fire.d.ts +0 -7
  379. package/dist/cli/commands/fire.js +0 -38
  380. package/dist/cli/commands/schedule.d.ts +0 -12
  381. package/dist/cli/commands/schedule.js +0 -87
  382. package/dist/github.d.ts +0 -3
  383. package/dist/github.js +0 -2
  384. package/dist/scaffold/templates/fastagent.config.mjs +0 -23
  385. package/dist/schedule/audit.d.ts +0 -18
  386. package/dist/schedule/audit.js +0 -57
  387. package/dist/schedule/schedule.d.ts +0 -35
  388. 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). With selfSchedule, this
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 ((process.env.WAKE_SECRET || process.env.STATE_REFRESH_SECRET) && !ownUrl) {
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 invoke({ kind: "wake-poke" });
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, slot } = event.scheduleFire;
164
- const r = await invoke({ kind: "schedule-fire", name, slot });
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(`schedule-fire ${name} (${slot}): ${r.status} ${out}`);
167
- if (r.status >= 400) throw new Error(`schedule-fire ${name} failed: ${r.status} ${out}`);
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
- // The deploy driver's probe (reserved path, ingress secret): wake the runtime through the SAME
194
- // trusted envelope pipeline (state URLs included — a direct InvokeAgentRuntime call could not mint
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" };