@fastagent-sh/fastagent 0.21.0 → 0.22.0

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 -43
  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 +209 -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 +153 -228
  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
package/dist/service.js CHANGED
@@ -1,50 +1,83 @@
1
- import { CONTROL_TOKEN_ENV, createControlPlane } from "./channels/control.js";
1
+ /** The product, as one call: an agent directory becomes a live service. */
2
+ import * as Cause from "effect/Cause";
3
+ import * as Effect from "effect/Effect";
4
+ import * as Exit from "effect/Exit";
5
+ import * as Fiber from "effect/Fiber";
6
+ import * as Scope from "effect/Scope";
7
+ import { createControlPlane } from "./channels/control.js";
2
8
  import { createInvokeHandler } from "./channels/http.js";
3
9
  import { text } from "./channels/respond.js";
4
- import { parseRouteKey, pathUnderPrefix, router } from "./channels/serve.js";
10
+ import { assertCorsOrigins, parseRouteKey, pathUnderPrefix, router } from "./channels/serve.js";
5
11
  import { loadChannels } from "./channels/discover.js";
6
- import { loadSchedules } from "./schedule/discover.js";
12
+ import { loadRoutines } from "./schedule/discover.js";
13
+ import { createRoutineListHandler, createRunHandler } from "./schedule/run.js";
7
14
  import { createScheduler } from "./schedule/scheduler.js";
8
15
  import { log } from "./log.js";
9
- import { reportModuleLoadFailures } from "./loader.js";
10
- /** Default wait for a channel's `closed` before reporting it stuck. A channel that ignores its
11
- * abort signal must not hang a caller's teardown — or, during a failed start, keep the original
12
- * error from arriving. The CLI passes a shorter one: its own forced exit must come AFTER this, or
13
- * the process leaves at 0 before the failure is known. */
16
+ import { refuseBrokenDeclarations } from "./loader.js";
17
+ import { gateSecrets } from "./secrets-gate.js";
18
+ /** Default wait for a channel's `closed` before reporting it stuck. */
14
19
  const CLOSE_DEADLINE_MS = 5_000;
15
- /** Settle when every connection has closed, or when the deadline passes. Reports the ones that did
16
- * NOT settle — named individually, so a single stuck channel is not reported as all of them. */
17
- async function closeWithin(runs, names, deadlineMs) {
18
- const pending = new Set(runs.map((_, i) => i));
19
- const failures = [];
20
- const tracked = runs.map((run, i) => run.closed.then(() => {
21
- pending.delete(i);
22
- }, (error) => {
23
- pending.delete(i);
24
- failures.push(error);
25
- }));
26
- let timer;
27
- try {
28
- await Promise.race([
29
- Promise.all(tracked),
30
- // NOT unref'd: this timer is the thing being awaited, and an unref'd one lets the loop go
31
- // idle with nothing left to advance it. Cleared below so a prompt close does not hold the
32
- // process for the rest of the deadline.
33
- new Promise((resolve) => {
34
- timer = setTimeout(resolve, deadlineMs);
35
- }),
36
- ]);
37
- }
38
- finally {
39
- clearTimeout(timer);
40
- }
41
- return { stuck: [...pending].map((i) => names[i] ?? "channel"), failures };
20
+ /** One deadline for all connections; a failed close must not hide a sibling that is still running. */
21
+ function closeWithin(runs, names, deadlineMs) {
22
+ return Effect.gen(function* () {
23
+ const pending = new Set(runs.map((_, i) => i));
24
+ const failures = [];
25
+ yield* Effect.forEach(runs, (run, i) => Effect.tryPromise({ try: () => run.closed, catch: (error) => error }).pipe(Effect.match({
26
+ onSuccess: () => {
27
+ pending.delete(i);
28
+ },
29
+ onFailure: (error) => {
30
+ pending.delete(i);
31
+ failures.push(error);
32
+ },
33
+ })), { concurrency: "unbounded", discard: true }).pipe(Effect.timeoutOrElse({ duration: deadlineMs, orElse: () => Effect.void }));
34
+ if (pending.size > 0) {
35
+ const stuck = [...pending].map((i) => names[i] ?? "channel");
36
+ return yield* Effect.fail(new Error(`long connection(s) did not stop within ${deadlineMs}ms: ${stuck.join(", ")}`));
37
+ }
38
+ if (failures.length > 0) {
39
+ return yield* Effect.fail(failures.length === 1 ? failures[0] : new AggregateError(failures, "long connections failed to close"));
40
+ }
41
+ });
42
+ }
43
+ /** The port a serve binds when neither a flag, `PORT` (start only) nor `http.port` names one. */
44
+ export const DEFAULT_HTTP_PORT = 8787;
45
+ /**
46
+ * Does this surface serve `POST /run`, given a routine to run? It FOLLOWS `invoke` unless `run` says otherwise (see
47
+ * {@link HttpSurface.run}). ONE function because the assembly mounts the route and `preflightDeploy` names it; a
48
+ * default spelled twice is a default that moves once.
49
+ */
50
+ export function shouldServeRun(http) {
51
+ return http?.run ?? http?.invoke !== false;
42
52
  }
43
53
  /**
44
- * The surface this deployment serves: default `GET /health` plus discovered channels, or the default
45
- * POST `/invoke` only when neither a route nor a long-connection channel was declared.
54
+ * What an anonymous caller of a port can do, worst first — the ONE wording the startup report and deploy's pre-flight
55
+ * both print, each from its own facts (what mounted vs what will mount).
46
56
  */
47
- export async function routesFor(agentDir, agent, stateRoot, control, options = {}) {
57
+ export function describeAnonymousSurface(open) {
58
+ return [
59
+ ...(open.invoke ? ["POST /invoke (run a turn with this agent's tools)"] : []),
60
+ ...(open.run ? ["POST /run (run any routine this agent declares; GET /routines lists them)"] : []),
61
+ ...(open.controlPrefix ? [`${open.controlPrefix}/* (read, steer or delete any session)`] : []),
62
+ ];
63
+ }
64
+ /**
65
+ * The surface this deployment serves: the DATA plane (`POST /invoke`), `GET /health`, and the discovered channels.
66
+ *
67
+ * `/invoke` is the framework's interface, so it is served whatever channels a definition declares (unless
68
+ * `http.invoke: false`). It is RESERVED for the same reason `/control/*` is: a channel taking that path would silently replace the one route every client,
69
+ * every doc and the startup line all name. `/health` stays overridable — a probe is the deployment's to shape.
70
+ */
71
+ export async function routesFor(agentDir, agent, stateRoot, control,
72
+ /**
73
+ * `http.invoke: false` withholds the data plane. Two callers, two reasons: the AgentCore adapter serves the
74
+ * Runtime's `/invocations` contract instead, and an author sets it to leave the channels' signature checks as the
75
+ * only way into a public port.
76
+ *
77
+ * `routines` mounts `POST /run` and `GET /routines` (per {@link shouldServeRun}). Passed in rather than loaded here
78
+ * so the routes and the resident clock cannot disagree about which routines exist (`loadServingRoutines`).
79
+ */
80
+ options = {}) {
48
81
  const { routes, longConnections, routeChannels, collisions, failures } = await loadChannels(agentDir, {
49
82
  agent,
50
83
  stateRoot,
@@ -53,40 +86,62 @@ export async function routesFor(agentDir, agent, stateRoot, control, options = {
53
86
  for (const c of collisions) {
54
87
  log.warn(`[fastagent] channel route "${c.route}" (${c.source}) collides with an earlier channel — not mounted`);
55
88
  }
56
- reportModuleLoadFailures(failures);
57
- if (failures.length > 0 || collisions.length > 0) {
58
- throw new Error(`channel setup is invalid (${failures.length} load failure(s), ${collisions.length} route collision(s)) — ` +
59
- `fix it, or rename an intentionally disabled file to *.disabled`);
89
+ refuseBrokenDeclarations(failures);
90
+ if (collisions.length > 0) {
91
+ throw new Error(`${collisions.length} channel route collision(s) — two channels cannot serve one route`);
60
92
  }
61
- const builtinInvoke = options.builtinInvoke !== false && Object.keys(routes).length === 0 && longConnections.length === 0;
62
- const channels = builtinInvoke ? { "POST /invoke": createInvokeHandler(agent) } : routes;
63
- const healthCovered = Object.keys(channels).some((key) => {
93
+ /**
94
+ * Which channel key already serves this path for the method a built-in would answer — the CHANNEL's spelling, not
95
+ * ours. A channel may write the method-less `"/invoke"`, and an error naming `"POST /invoke"` sends its author
96
+ * grepping their own file for a string that is not in it.
97
+ */
98
+ const covered = (path, method) => Object.keys(routes).find((key) => {
64
99
  const entry = parseRouteKey(key);
65
- return entry.path === "/health" && (entry.method === undefined || entry.method === "GET");
100
+ return entry.path === path && (entry.method === undefined || entry.method === method);
66
101
  });
67
102
  let ready = longConnections.length === 0;
68
103
  const health = () => (ready ? text("ok\n", 200) : text("starting\n", 503));
104
+ // AgentCore serves the data plane through the Runtime's own `/invocations` contract, so the adapter opts out — and
105
+ // with no `/invoke` of ours on that surface there is nothing to reserve, which is why the refusal is in here.
106
+ const unverified = { ...(covered("/health", "GET") ? {} : { "GET /health": health }) };
107
+ if (options.http?.invoke !== false)
108
+ unverified["POST /invoke"] = createInvokeHandler(agent);
109
+ // `POST /run` exists only where there is something to run, and `GET /routines` exactly where that route does:
110
+ // it is the catalogue OF that route, so listing names nobody can use would be a catalogue of nothing. Both ride
111
+ // this table for the reason the table exists: they authenticate nobody, so they inherit the JSON body gate, the
112
+ // cross-origin policy, the reserved path and the startup report's account of what is open.
113
+ if (shouldServeRun(options.http)) {
114
+ const routines = options.routines ?? [];
115
+ const run = createRunHandler({ agent, routines });
116
+ if (run)
117
+ unverified["POST /run"] = run;
118
+ const list = createRoutineListHandler({ routines });
119
+ if (list)
120
+ unverified["GET /routines"] = list;
121
+ }
122
+ // ONE rule over the whole table, so the next route we add is reserved by existing here rather than by someone
123
+ // remembering to write a second check for it. `/health` is exempt by construction: it is only in `ours` when no
124
+ // channel already serves it, because a probe is the deployment's to shape.
125
+ const taken = Object.keys(unverified).flatMap((key) => {
126
+ const entry = parseRouteKey(key);
127
+ const channelKey = covered(entry.path, entry.method ?? "");
128
+ return channelKey === undefined ? [] : [{ channelKey, key }];
129
+ });
130
+ if (taken.length > 0) {
131
+ throw new Error(`channel route(s) ${taken.map((t) => `"${t.channelKey}"`).join(", ")} take a path this serve answers on ` +
132
+ `itself (${taken.map((t) => `"${t.key}"`).join(", ")}) — rename the channel route`);
133
+ }
69
134
  return {
70
- routes: healthCovered ? channels : { "GET /health": health, ...channels },
135
+ unverified,
136
+ selfVerifying: routes,
71
137
  longConnections,
72
138
  routeChannels,
73
- builtinInvoke,
74
139
  setReady(value) {
75
140
  ready = value;
76
141
  },
77
142
  };
78
143
  }
79
- /**
80
- * Refuse channel routes the control plane would swallow.
81
- *
82
- * `router` refuses this too, and would catch it a moment later; this exists for the sentence, not
83
- * the check — an author who lands a route under `/control` needs to hear about `sessionControl`,
84
- * which the host has no way to mention. Both ask {@link pathUnderPrefix}, so there is one rule with
85
- * two wordings, not two rules.
86
- *
87
- * Called from BOTH mount points: here at boot, and again on agentcore's lazy path, whose channels
88
- * load after this ran against an empty base.
89
- */
144
+ /** Refuse channel routes the control plane would swallow. */
90
145
  export function assertNoControlPlaneCollision(channelRoutes, plane) {
91
146
  const collisions = Object.keys(channelRoutes).filter((key) => pathUnderPrefix(parseRouteKey(key).path, plane.prefix));
92
147
  if (collisions.length > 0) {
@@ -94,217 +149,160 @@ export function assertNoControlPlaneCollision(channelRoutes, plane) {
94
149
  `rename the channel route or disable sessionControl in fastagent.config`);
95
150
  }
96
151
  }
97
- export function mountSessionControl(routes, control, options = {}) {
152
+ export function mountSessionControl(routes, control) {
98
153
  if (!control)
99
154
  return { routes, mounts: [] };
100
- // WHO OWNS the secret. Per-boot mint is right locally: discovery is `control.json` and its file
101
- // permissions, which works because both holders share a filesystem. A deployment removes that
102
- // premise — a token minted in the container is replaced every restart and reachable only by shelling
103
- // in — so there the deployer mints it and injects it here, like the wake/ingress secrets.
104
- // Trimmed on read, like `.env` values already are: a token pasted from a dashboard with a trailing
105
- // newline would otherwise become the box's token verbatim, and every caller holding the clean value
106
- // gets a bare 401 — the undiagnosable symptom, one character wide.
107
- const injected = process.env[CONTROL_TOKEN_ENV]?.trim();
108
- // SET BUT EMPTY is the deployed default, not an edge case: the generated Compose topology writes
109
- // every secret as `NAME: "${NAME:-}"`, so an operator who skipped this one lands here. Falling back
110
- // silently would leave exactly the symptom the injection exists to remove — a token the caller does
111
- // not have — with nothing in the log to tell it apart from a deployment that never asked.
112
- if (injected === "") {
113
- log.warn(`[fastagent] ${CONTROL_TOKEN_ENV} is set but empty — minting a per-boot token instead; callers holding ` +
114
- "the deploy-time value will get 401 (set it, or read the minted one from control.json on the box)");
115
- }
116
- else if (injected !== undefined && injected.length < 16) {
117
- // Length is a crude proxy for entropy — sixteen `a`s pass. It is aimed at `changeme`, which this
118
- // change makes newly dangerous: the plane went from unusable-in-a-deployment to usable by whoever
119
- // holds this string, and the empty case is the only other thing that says anything.
120
- log.warn(`[fastagent] ${CONTROL_TOKEN_ENV} is ${injected.length} characters — it is the ONLY thing between ` +
121
- "/control/* (steer, stop, rewrite a session) and anyone who can reach the port; use a random value (uuidgen)");
122
- }
123
- const token = injected || crypto.randomUUID();
124
- const plane = createControlPlane(control, { token, agent: options.agent });
155
+ const plane = createControlPlane(control);
125
156
  assertNoControlPlaneCollision(routes, plane);
126
- return { routes, mounts: [plane], control: { token, prefix: plane.prefix } };
157
+ return { routes, mounts: [plane], controlPrefix: plane.prefix };
127
158
  }
128
159
  /**
129
- * Load and start the agent's `schedules/` — a time-trigger firing the agent on each cron. Starts iff
130
- * there are static schedules OR `selfSchedule` is on. Best-effort stop on process signals. Returns the
131
- * loaded schedules so a serving surface that needs them (the AgentCore adapter's fire binding) shares
132
- * ONE load instead of re-discovering. `externalClock` (AgentCore) arms no resident cron timers.
160
+ * Load the agent's `routines/`, gated.
161
+ *
162
+ * Separate from {@link startSchedules} because two things need the SAME list and one of them runs first: the
163
+ * `POST /run` route is built with the routes (so a channel cannot take that path unnoticed), and the resident
164
+ * clock starts later, inside the service's scope. Loading twice would let them disagree about which routines exist.
165
+ */
166
+ export async function loadServingRoutines(agentDir) {
167
+ // Thrown, not exited on: this runs inside an embedder's app as well as the CLI, and a library that calls
168
+ // process.exit takes a decision (degrade? retry? stop?) that belongs to its host.
169
+ const { routines, secrets, failures } = await loadRoutines(agentDir);
170
+ // The routines' half of the serving-path gate (the opener does the tools', loadChannels the
171
+ // channels'): a routine reads its env at IMPORT time, so an unset declared value has already
172
+ // produced a broken prompt — refusing here is the last point where that is a startup failure.
173
+ gateSecrets({ declared: secrets, failures });
174
+ refuseBrokenDeclarations(failures);
175
+ return routines;
176
+ }
177
+ /**
178
+ * Start the clock over routines {@link loadServingRoutines} already gated — on every serve, routines or not: the
179
+ * agent's own wake-ups are a default capability, and this is what fires them.
133
180
  */
134
- export async function startSchedules(agentDir, agent, stateRoot, selfSchedule, options = {}) {
135
- // Thrown, not exited on: this runs inside an embedder's app as well as the CLI, and a library
136
- // that calls process.exit takes a decision (degrade? retry? stop?) that belongs to its host. The
137
- // CLI catches at its own boundary.
138
- const { schedules, failures } = await loadSchedules(agentDir);
139
- reportModuleLoadFailures(failures);
140
- if (schedules.length === 0 && !selfSchedule)
141
- return { schedules, stop: () => { } };
142
- const scheduler = createScheduler({ agent, stateRoot, schedules, externalClock: options.externalClock });
181
+ export function startSchedules(agent, stateRoot, routines, options = {}) {
182
+ const scheduler = Effect.runSync(createScheduler({ agent, stateRoot, routines, externalClock: options.externalClock }));
143
183
  scheduler.start();
144
- if (schedules.length > 0) {
145
- log.info(`[fastagent] schedules: ${schedules.map((s) => s.name).join(", ")}${options.externalClock ? " (external clock — no resident cron timers)" : ""}`);
184
+ if (routines.length > 0) {
185
+ log.info(`[fastagent] routines: ${routines.map((r) => r.name).join(", ")}${options.externalClock ? " (external clock — no resident cron timers)" : ""}`);
146
186
  }
147
- // Returned rather than bound to process signals here: this runs inside an embedder's app as well
148
- // as the CLI, and a library that installs SIGINT handlers is deciding something that is not its
149
- // to decide. `runStart`/`runDev` wire it to their own shutdown.
150
- return { schedules, stop: () => scheduler.stop() };
187
+ return { routines, stop: () => scheduler.stop() };
151
188
  }
152
189
  /**
153
- * The assembly itself, over an already-opened directory: channels, the control plane, schedules and
154
- * long connections, composed into one handler.
155
- *
156
- * {@link createAgentService} is this plus opening the directory. `dev`/`start` open separately — their
157
- * startup report needs the opened values before anything mounts — and then arrive here, so there is
158
- * one assembly rather than one per caller.
190
+ * The assembly itself, over an already-opened directory: channels, the control plane, routines and long connections,
191
+ * composed into one handler.
159
192
  */
160
193
  export async function mountAgentService(opened, options = {}) {
161
194
  const { agentDir, workspace, stateRoot, sessionControl } = opened;
162
- // Wrapped BEFORE anything consumes it: routes, the control plane and schedules must all drive the
163
- // same agent, so this is a hook rather than something a caller applies afterwards.
195
+ // Wrapped BEFORE anything consumes it: routes, the control plane and routines must all drive the same agent, so
196
+ // this is a hook rather than something a caller applies afterwards.
164
197
  const agent = options.wrapAgent?.(opened.agent) ?? opened.agent;
165
198
  const closeTimeoutMs = options.closeTimeoutMs ?? CLOSE_DEADLINE_MS;
166
- const routed = await routesFor(agentDir, agent, stateRoot, sessionControl, { builtinInvoke: true });
167
- const withControl = mountSessionControl(routed.routes, sessionControl, { agent });
168
- // Composed BEFORE anything starts. `router` re-validates what `loadChannels` and the control
169
- // mount already checked, so on THIS path it should not fail — but "should not" is not an ordering
170
- // guarantee, and a throw after the scheduler ticks and channels dial would leave both running
171
- // with no service for the caller to close. Free to order correctly; expensive to discover later.
172
- const handler = router(withControl.routes, withControl.mounts);
173
- const scheduled = await startSchedules(agentDir, agent, stateRoot, opened.selfSchedule);
174
- const abort = new AbortController();
175
- // A connection that drops while others are still dialling must not be undone by their later
176
- // readiness: the service is missing a declared channel from that moment on, whatever else arrives.
177
- let dropped = false;
178
- const onClosed = options.onChannelClosed ??
179
- ((name, error) => log.error(`[fastagent] long connection ${name} ${error === undefined ? "closed" : `failed: ${String(error)}`}`));
180
- const runs = [];
181
- // A FUNCTION declaration, not a const: `close()` detaches this listener, and a rollback can call
182
- // `close()` before this point is reached — a `const` would be in its temporal dead zone there, so
183
- // the cleanup would throw a ReferenceError and silently skip everything after it.
184
- //
185
- // Detached because a caller that closes services itself while holding one long-lived signal would
186
- // otherwise accumulate listeners, each pinning a whole service through its closure. The signal
187
- // path has no caller awaiting the promise, so a failure to stop is reported rather than left as an
188
- // unhandled rejection — in an embedded library, potentially the host's exit.
189
- function onAbort() {
190
- void close().catch((error) => log.error(`[fastagent] service close failed: ${String(error)}`));
191
- }
192
- let closing;
193
- const close = () => {
194
- // Awaits the connections rather than only signalling them: `close()` promises they are stopped,
195
- // and a caller tearing down a test or a request-scoped service needs that to be true on return.
196
- closing ??= (async () => {
197
- abort.abort();
198
- scheduled.stop();
199
- options.signal?.removeEventListener("abort", onAbort);
200
- // A failure to stop is the caller's to know about — swallowing it would let `close()` report
201
- // success over a channel still holding on. Bounded, because a channel that ignores its abort
202
- // signal must not hang the teardown either.
203
- const { stuck, failures } = await closeWithin(runs, routed.longConnections.map((c) => c.name), closeTimeoutMs);
204
- if (stuck.length > 0) {
205
- throw new Error(`long connection(s) did not stop within ${closeTimeoutMs}ms: ${stuck.join(", ")}`);
206
- }
207
- if (failures.length > 0) {
208
- throw failures.length === 1 ? failures[0] : new AggregateError(failures, "long connections failed to close");
209
- }
210
- })();
211
- return closing;
212
- };
213
- // Rollback IS close(), plus keeping the original error: a failure to clean up is the aftermath,
214
- // and replacing the reason the caller needs with it hides the actual cause.
215
- const rollback = async (error) => {
216
- await close().catch((closeError) => log.error(`[fastagent] cleanup after a failed start also failed: ${String(closeError)}`));
217
- throw error;
218
- };
219
- // Rolled back on failure: a connection that throws while the ones before it are open, and the
220
- // scheduler already ticking, would otherwise leave both running behind a rejected open().
221
- for (const connection of routed.longConnections) {
222
- let run;
223
- try {
224
- run = connection.connect(abort.signal);
225
- }
226
- catch (error) {
227
- return rollback(error);
228
- }
229
- if (typeof run?.ready?.then !== "function" || typeof run?.closed?.then !== "function") {
230
- return rollback(new Error(`${connection.name} connect(signal) must return { ready: Promise, closed: Promise }`));
231
- }
232
- void run.closed.then(() => {
233
- if (abort.signal.aborted)
234
- return;
235
- // A channel that dies leaves the service serving something it no longer has.
236
- dropped = true;
237
- routed.setReady(false);
238
- onClosed(connection.name);
239
- }, (error) => {
199
+ // The embedder's way in, checked like the config file's: `allowedOrigin` compares exact strings, so a stray
200
+ // trailing slash would refuse the front end with nothing naming the rule.
201
+ const corsOrigins = opened.http?.cors;
202
+ if (corsOrigins)
203
+ assertCorsOrigins(corsOrigins, "mountAgentService: http.cors");
204
+ // Loaded BEFORE the routes, because `POST /run` is one of them and the resident clock below must run over the
205
+ // same list — two loads would be two answers to "which routines exist".
206
+ const routines = await loadServingRoutines(agentDir);
207
+ const routed = await routesFor(agentDir, agent, stateRoot, sessionControl, { http: opened.http, routines });
208
+ const withControl = mountSessionControl(routed.selfVerifying, opened.publishControl ? sessionControl : undefined);
209
+ // Composed BEFORE anything starts.
210
+ const handler = router({
211
+ unverified: routed.unverified,
212
+ selfVerifying: withControl.routes,
213
+ mounts: withControl.mounts,
214
+ ...(corsOrigins ? { corsOrigins } : {}),
215
+ });
216
+ return Effect.runPromise(Effect.gen(function* () {
217
+ const lifetime = yield* Scope.make();
218
+ const abort = new AbortController();
219
+ const runs = [];
220
+ const names = routed.longConnections.map((c) => c.name);
221
+ // A connection lost during startup must not be hidden by a sibling becoming ready later.
222
+ let dropped = false;
223
+ const onClosed = options.onChannelClosed ??
224
+ ((name, error) => log.error(`[fastagent] long connection ${name} ${error === undefined ? "closed" : `failed: ${String(error)}`}`));
225
+ const notifyClosed = (name, error) => {
240
226
  if (abort.signal.aborted)
241
227
  return;
242
228
  dropped = true;
243
229
  routed.setReady(false);
244
- onClosed(connection.name, error);
245
- });
246
- runs.push(run);
247
- }
248
- if (options.signal?.aborted)
249
- await close();
250
- else
251
- options.signal?.addEventListener("abort", onAbort, { once: true });
252
- // Health answers 503 until EVERY long connection is up, so a load balancer does not route into a
253
- // service whose socket-mode channels are still dialling. An abort before that settles `ready` as
254
- // cancellation, not readiness — a service being torn down must not report itself healthy.
255
- const ready = (async () => {
256
- try {
257
- await Promise.all(runs.map(async (run, i) => {
258
- const name = routed.longConnections[i]?.name ?? "channel";
259
- // Raced against `closed`, because the contract puts a terminal failure THERE: a channel
260
- // that dies dialling may leave `ready` pending forever, and waiting on it alone hangs
261
- // startup with no diagnosis.
262
- await Promise.race([
263
- run.ready,
264
- run.closed.then(() => Promise.reject(new Error(`${name} closed before it was ready`)), (error) => Promise.reject(new Error(`${name} failed before it was ready: ${String(error)}`))),
265
- ]);
266
- if (!abort.signal.aborted)
267
- log.info(`[fastagent] long connection ready: ${name}`);
230
+ onClosed(name, error);
231
+ };
232
+ // Scope.close alone does not join concurrent closes or retain their failure.
233
+ const shutdown = yield* Effect.cached(Effect.gen(function* () {
234
+ abort.abort();
235
+ yield* Scope.close(lifetime, Exit.void);
236
+ }));
237
+ const close = () => Effect.runPromise(shutdown);
238
+ const rollback = shutdown.pipe(Effect.catchCause((cause) => Effect.sync(() => log.error(`[fastagent] cleanup after a failed start also failed: ${String(Cause.squash(cause))}`))));
239
+ const onAbort = () => {
240
+ void close().catch((error) => log.error(`[fastagent] service close failed: ${String(error)}`));
241
+ };
242
+ return yield* Effect.gen(function* () {
243
+ // Registered first, so subscriptions and schedule timers stop before waiting for transports.
244
+ yield* Effect.addFinalizer(() => closeWithin(runs, names, closeTimeoutMs).pipe(Effect.orDie));
245
+ const scheduled = yield* Effect.acquireRelease(Effect.try({
246
+ try: () => startSchedules(agent, stateRoot, routines),
247
+ catch: (error) => error,
248
+ }), (scheduled) => Effect.sync(scheduled.stop));
249
+ const readiness = yield* Effect.forEach(routed.longConnections, (connection) => Effect.gen(function* () {
250
+ const run = yield* Effect.try({
251
+ try: () => connection.connect(abort.signal),
252
+ catch: (error) => error,
253
+ });
254
+ if (typeof run?.ready?.then !== "function" || typeof run?.closed?.then !== "function") {
255
+ return yield* Effect.fail(new Error(`${connection.name} connect(signal) must return { ready: Promise, closed: Promise }`));
256
+ }
257
+ runs.push(run);
258
+ const closed = Effect.tryPromise({ try: () => run.closed, catch: (error) => error });
259
+ yield* closed.pipe(Effect.match({
260
+ onSuccess: () => notifyClosed(connection.name),
261
+ onFailure: (error) => notifyClosed(connection.name, error),
262
+ }), Effect.catchCause((cause) => Effect.sync(() => log.error(`[fastagent] channel closure callback failed: ${String(Cause.squash(cause))}`))), Effect.forkScoped({ startImmediately: true }));
263
+ // Observe ready immediately, including when a later connect() throws and mount rolls back.
264
+ return yield* Effect.raceFirst(Effect.tryPromise({ try: () => run.ready, catch: (error) => error }), closed.pipe(Effect.matchEffect({
265
+ onSuccess: () => Effect.fail(new Error(`${connection.name} closed before it was ready`)),
266
+ onFailure: (error) => Effect.fail(new Error(`${connection.name} failed before it was ready: ${String(error)}`)),
267
+ }))).pipe(Effect.tap(() => Effect.sync(() => {
268
+ if (!abort.signal.aborted)
269
+ log.info(`[fastagent] long connection ready: ${connection.name}`);
270
+ })), Effect.forkScoped({ startImmediately: true }));
268
271
  }));
269
- }
270
- catch (error) {
271
- // A connection that cannot come up is a startup failure, not a degraded service: tear the rest
272
- // down before rejecting, so nothing is left running behind a caller that saw an error. A
273
- // cleanup that ALSO fails is logged, never rethrown — it would replace the reason the caller
274
- // actually needs with the aftermath of it.
275
- await close().catch((closeError) => log.error(`[fastagent] cleanup after a failed start also failed: ${String(closeError)}`));
276
- throw error;
277
- }
278
- // A `ready` that settles because the service was CLOSED is cancellation, not readiness — the
279
- // contract lets a connection resolve it on abort. Returning normally would tell a caller its
280
- // channels are up while the service is shut and health says 503.
281
- if (abort.signal.aborted)
282
- throw new Error("service closed before it became ready");
283
- // A drop DURING startup fails it. `dropped` is only reachable here from the startup window —
284
- // after this line `ready` has settled — and resolving while health is permanently 503 would
285
- // hand the caller two contradictory answers about the same surface.
286
- if (dropped) {
287
- await close();
288
- throw new Error("a long connection closed before startup completed");
289
- }
290
- routed.setReady(true);
291
- })();
292
- // Observed here so a rejection is never unhandled; every caller still sees it through `ready`.
293
- ready.catch(() => { });
294
- return {
295
- handler,
296
- agent,
297
- routes: withControl.routes,
298
- agentDir,
299
- workspace,
300
- channels: {
301
- routes: routed.routeChannels,
302
- longConnections: routed.longConnections.map((c) => c.name),
303
- builtinInvoke: routed.builtinInvoke,
304
- },
305
- schedules: scheduled.schedules,
306
- ready,
307
- ...(withControl.control ? { control: withControl.control } : {}),
308
- close,
309
- };
272
+ if (options.signal?.aborted)
273
+ yield* shutdown;
274
+ else
275
+ yield* Effect.acquireRelease(Effect.sync(() => options.signal?.addEventListener("abort", onAbort, { once: true })), () => Effect.sync(() => options.signal?.removeEventListener("abort", onAbort)));
276
+ // The public readiness waiter lives outside the scope it may roll back.
277
+ const ready = Effect.runPromise(Effect.gen(function* () {
278
+ yield* Effect.forEach(readiness, Fiber.join, { concurrency: "unbounded", discard: true });
279
+ if (abort.signal.aborted)
280
+ return yield* Effect.fail(new Error("service closed before it became ready"));
281
+ if (dropped)
282
+ return yield* Effect.fail(new Error("a long connection closed before startup completed"));
283
+ routed.setReady(true);
284
+ }).pipe(Effect.catchCause((cause) => abort.signal.aborted
285
+ ? Effect.fail(new Error("service closed before it became ready"))
286
+ : Effect.failCause(cause)), Effect.onError(() => rollback)));
287
+ // The rejection is still delivered to callers that await ready.
288
+ ready.catch(() => { });
289
+ return {
290
+ handler,
291
+ agent,
292
+ routes: { ...routed.unverified, ...withControl.routes },
293
+ agentDir,
294
+ workspace,
295
+ channels: {
296
+ routes: routed.routeChannels,
297
+ longConnections: names,
298
+ },
299
+ unverifiedRoutes: Object.keys(routed.unverified),
300
+ ...(corsOrigins ? { corsOrigins } : {}),
301
+ routines: scheduled.routines,
302
+ ready,
303
+ ...(withControl.controlPrefix ? { controlPrefix: withControl.controlPrefix } : {}),
304
+ close,
305
+ };
306
+ }).pipe(Scope.provide(lifetime), Effect.onError(() => rollback));
307
+ }));
310
308
  }