@fastagent-sh/fastagent 0.19.0 → 0.21.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 (212) hide show
  1. package/README.md +5 -1
  2. package/dist/atomic-write.d.ts +11 -7
  3. package/dist/atomic-write.js +16 -11
  4. package/dist/channels/agentcore-limits.d.ts +9 -0
  5. package/dist/channels/agentcore-limits.js +9 -0
  6. package/dist/channels/agentcore-protocol.d.ts +112 -0
  7. package/dist/channels/agentcore-protocol.js +22 -0
  8. package/dist/channels/agentcore-service.d.ts +12 -15
  9. package/dist/channels/agentcore-service.js +15 -24
  10. package/dist/channels/agentcore-state.d.ts +5 -11
  11. package/dist/channels/agentcore-state.js +4 -1
  12. package/dist/channels/agentcore.d.ts +9 -83
  13. package/dist/channels/agentcore.js +101 -93
  14. package/dist/channels/control.d.ts +50 -12
  15. package/dist/channels/control.js +251 -157
  16. package/dist/channels/discover.d.ts +26 -5
  17. package/dist/channels/discover.js +13 -26
  18. package/dist/channels/feishu/context-buffer.d.ts +6 -0
  19. package/dist/channels/feishu/context-buffer.js +0 -38
  20. package/dist/channels/feishu/crypto.d.ts +0 -2
  21. package/dist/channels/feishu/crypto.js +3 -8
  22. package/dist/channels/feishu/feishu-api.js +7 -12
  23. package/dist/channels/feishu/feishu.js +86 -128
  24. package/dist/channels/feishu/parse.d.ts +4 -3
  25. package/dist/channels/feishu/parse.js +5 -4
  26. package/dist/channels/feishu/register-webhook.d.ts +5 -5
  27. package/dist/channels/feishu/register-webhook.js +43 -57
  28. package/dist/channels/feishu/scaffold/feishu-send.ts +12 -67
  29. package/dist/channels/feishu/setup-mode.d.ts +30 -0
  30. package/dist/channels/feishu/setup-mode.js +26 -0
  31. package/dist/channels/feishu/shared-api.d.ts +10 -0
  32. package/dist/channels/feishu/shared-api.js +38 -0
  33. package/dist/channels/http.d.ts +0 -8
  34. package/dist/channels/http.js +4 -56
  35. package/dist/channels/kit/attachment-path.d.ts +12 -0
  36. package/dist/channels/kit/attachment-path.js +43 -0
  37. package/dist/channels/kit/context-buffer.d.ts +9 -0
  38. package/dist/channels/kit/context-buffer.js +11 -0
  39. package/dist/channels/kit/signature.d.ts +12 -0
  40. package/dist/channels/kit/signature.js +17 -0
  41. package/dist/channels/kit/stop-command.js +3 -3
  42. package/dist/channels/kit/tasks.d.ts +1 -1
  43. package/dist/channels/kit/tasks.js +10 -4
  44. package/dist/channels/kit/turn-runner.d.ts +59 -0
  45. package/dist/channels/kit/turn-runner.js +84 -0
  46. package/dist/channels/kit/turn-store.d.ts +19 -3
  47. package/dist/channels/kit/turn-store.js +29 -2
  48. package/dist/channels/lark/scaffold/lark-send.ts +12 -67
  49. package/dist/channels/registration.d.ts +36 -1
  50. package/dist/channels/registration.js +57 -1
  51. package/dist/channels/secret.d.ts +1 -0
  52. package/dist/channels/secret.js +16 -0
  53. package/dist/channels/slack/config-api.d.ts +35 -3
  54. package/dist/channels/slack/config-api.js +51 -9
  55. package/dist/channels/slack/invoke-turn.d.ts +1 -1
  56. package/dist/channels/slack/invoke-turn.js +5 -1
  57. package/dist/channels/slack/manifest.js +5 -1
  58. package/dist/channels/slack/onboard.d.ts +5 -5
  59. package/dist/channels/slack/onboard.js +44 -23
  60. package/dist/channels/slack/onboarding-state.d.ts +7 -3
  61. package/dist/channels/slack/onboarding-state.js +13 -20
  62. package/dist/channels/slack/register-webhook.d.ts +3 -2
  63. package/dist/channels/slack/register-webhook.js +35 -15
  64. package/dist/channels/slack/scaffold/channel.ts +3 -10
  65. package/dist/channels/slack/scaffold/slack-send.ts +18 -141
  66. package/dist/channels/slack/shared-api.d.ts +10 -0
  67. package/dist/channels/slack/shared-api.js +34 -0
  68. package/dist/channels/slack/slack-api.d.ts +20 -2
  69. package/dist/channels/slack/slack-api.js +100 -73
  70. package/dist/channels/slack/slack.d.ts +0 -10
  71. package/dist/channels/slack/slack.js +68 -107
  72. package/dist/channels/sse.d.ts +4 -0
  73. package/dist/channels/sse.js +66 -0
  74. package/dist/channels/telegram/parse.d.ts +21 -1
  75. package/dist/channels/telegram/parse.js +65 -11
  76. package/dist/channels/telegram/register-webhook.d.ts +6 -9
  77. package/dist/channels/telegram/register-webhook.js +44 -42
  78. package/dist/channels/telegram/scaffold/channel.ts +7 -3
  79. package/dist/channels/telegram/telegram-api.js +5 -6
  80. package/dist/channels/telegram/telegram.d.ts +2 -2
  81. package/dist/channels/telegram/telegram.js +97 -204
  82. package/dist/channels/wait-health.js +7 -4
  83. package/dist/cli/add-feishu.js +3 -10
  84. package/dist/cli/add-slack.js +13 -25
  85. package/dist/cli/commands/add.d.ts +0 -1
  86. package/dist/cli/commands/add.js +22 -35
  87. package/dist/cli/commands/attach.d.ts +2 -4
  88. package/dist/cli/commands/attach.js +17 -15
  89. package/dist/cli/commands/chat.js +6 -12
  90. package/dist/cli/commands/deploy/agentcore.d.ts +2 -0
  91. package/dist/cli/commands/deploy/agentcore.js +178 -0
  92. package/dist/cli/commands/deploy/docker.d.ts +2 -0
  93. package/dist/cli/commands/deploy/docker.js +119 -0
  94. package/dist/cli/commands/deploy/fly.d.ts +2 -0
  95. package/dist/cli/commands/deploy/fly.js +131 -0
  96. package/dist/cli/commands/deploy/railway.d.ts +2 -0
  97. package/dist/cli/commands/deploy/railway.js +71 -0
  98. package/dist/cli/commands/deploy/shared.d.ts +114 -0
  99. package/dist/cli/commands/deploy/shared.js +124 -0
  100. package/dist/cli/commands/deploy.d.ts +38 -25
  101. package/dist/cli/commands/deploy.js +101 -631
  102. package/dist/cli/commands/dev.js +20 -76
  103. package/dist/cli/commands/fire.js +6 -12
  104. package/dist/cli/commands/info.js +1 -1
  105. package/dist/cli/commands/init.js +1 -2
  106. package/dist/cli/commands/invoke.js +4 -11
  107. package/dist/cli/commands/schedule.js +1 -1
  108. package/dist/cli/commands/start.js +41 -80
  109. package/dist/cli/commands/tool.js +3 -8
  110. package/dist/cli/kernel.d.ts +0 -2
  111. package/dist/cli/kernel.js +0 -2
  112. package/dist/cli/program.js +7 -12
  113. package/dist/cli/serve.d.ts +42 -34
  114. package/dist/cli/serve.js +107 -28
  115. package/dist/cli/shared.d.ts +52 -27
  116. package/dist/cli/shared.js +69 -6
  117. package/dist/deploy/agentcore/forwarder.js +250 -0
  118. package/dist/deploy/agentcore/logs.d.ts +2 -2
  119. package/dist/deploy/agentcore/logs.js +2 -2
  120. package/dist/deploy/agentcore/plan.d.ts +34 -11
  121. package/dist/deploy/agentcore/plan.js +52 -259
  122. package/dist/deploy/agentcore/run.d.ts +9 -24
  123. package/dist/deploy/agentcore/run.js +35 -43
  124. package/dist/deploy/channel-ingress.d.ts +73 -0
  125. package/dist/deploy/channel-ingress.js +101 -0
  126. package/dist/deploy/docker/plan.d.ts +3 -7
  127. package/dist/deploy/docker/plan.js +4 -15
  128. package/dist/deploy/docker/run.d.ts +35 -4
  129. package/dist/deploy/docker/run.js +40 -8
  130. package/dist/deploy/fly/plan.d.ts +4 -5
  131. package/dist/deploy/fly/plan.js +14 -22
  132. package/dist/deploy/fly/run.d.ts +36 -23
  133. package/dist/deploy/fly/run.js +129 -83
  134. package/dist/deploy/hosts.d.ts +5 -0
  135. package/dist/deploy/hosts.js +4 -0
  136. package/dist/deploy/preflight.d.ts +6 -7
  137. package/dist/deploy/preflight.js +27 -23
  138. package/dist/deploy/railway/plan.d.ts +9 -5
  139. package/dist/deploy/railway/plan.js +18 -26
  140. package/dist/deploy/railway/run.d.ts +11 -10
  141. package/dist/deploy/railway/run.js +44 -43
  142. package/dist/deploy/runner.js +22 -2
  143. package/dist/deploy/secrets.d.ts +18 -9
  144. package/dist/deploy/secrets.js +63 -28
  145. package/dist/dev-supervisor.js +3 -4
  146. package/dist/engines/pi/agent-session-factory.d.ts +37 -17
  147. package/dist/engines/pi/agent-session-factory.js +111 -106
  148. package/dist/engines/pi/auth.js +43 -43
  149. package/dist/engines/pi/config.d.ts +2 -2
  150. package/dist/engines/pi/create.d.ts +22 -27
  151. package/dist/engines/pi/create.js +59 -74
  152. package/dist/engines/pi/definition.d.ts +1 -1
  153. package/dist/engines/pi/definition.js +11 -12
  154. package/dist/engines/pi/invoke-session.js +13 -30
  155. package/dist/engines/pi/login.js +32 -16
  156. package/dist/engines/pi/open.d.ts +3 -4
  157. package/dist/engines/pi/open.js +32 -42
  158. package/dist/engines/pi/retry-event.d.ts +6 -0
  159. package/dist/engines/pi/retry-event.js +15 -0
  160. package/dist/engines/pi/search-tools.js +1 -1
  161. package/dist/engines/pi/service.d.ts +1 -1
  162. package/dist/engines/pi/service.js +8 -0
  163. package/dist/engines/pi/session-builder.js +26 -140
  164. package/dist/engines/pi/session-control.d.ts +27 -22
  165. package/dist/engines/pi/session-control.js +551 -490
  166. package/dist/engines/pi/session-inheritance.d.ts +8 -22
  167. package/dist/engines/pi/session-inheritance.js +95 -76
  168. package/dist/engines/pi/session-markers.d.ts +48 -0
  169. package/dist/engines/pi/session-markers.js +59 -0
  170. package/dist/engines/pi/session-settings.d.ts +5 -5
  171. package/dist/engines/pi/session-settings.js +8 -5
  172. package/dist/engines/pi/session-store.d.ts +91 -26
  173. package/dist/engines/pi/session-store.js +413 -82
  174. package/dist/engines/pi/tool-context.d.ts +24 -11
  175. package/dist/engines/pi/tool-context.js +29 -4
  176. package/dist/engines/pi/tool.d.ts +6 -9
  177. package/dist/engines/pi/tool.js +3 -2
  178. package/dist/env.js +1 -2
  179. package/dist/feishu.d.ts +1 -0
  180. package/dist/feishu.js +1 -0
  181. package/dist/lark.d.ts +1 -0
  182. package/dist/lark.js +1 -0
  183. package/dist/loader.d.ts +51 -7
  184. package/dist/loader.js +84 -18
  185. package/dist/log.d.ts +9 -17
  186. package/dist/log.js +25 -30
  187. package/dist/paths.d.ts +26 -3
  188. package/dist/paths.js +43 -5
  189. package/dist/scaffold/add-channel.d.ts +6 -1
  190. package/dist/scaffold/add-channel.js +50 -67
  191. package/dist/scaffold/init.js +7 -2
  192. package/dist/scaffold/templates/tools/fetch-url.ts +0 -2
  193. package/dist/schedule/discover.js +3 -15
  194. package/dist/schedule/wake-alarm.d.ts +14 -19
  195. package/dist/schedule/wake-alarm.js +89 -48
  196. package/dist/schedule/wakeups.d.ts +1 -1
  197. package/dist/schedule/wakeups.js +10 -7
  198. package/dist/service.d.ts +24 -24
  199. package/dist/service.js +33 -77
  200. package/dist/session-remote.d.ts +12 -7
  201. package/dist/session-remote.js +185 -118
  202. package/dist/session.d.ts +227 -93
  203. package/dist/session.js +61 -23
  204. package/dist/slack.d.ts +2 -0
  205. package/dist/slack.js +1 -0
  206. package/dist/telegram.d.ts +1 -1
  207. package/dist/telegram.js +1 -1
  208. package/dist/tunnel.d.ts +34 -9
  209. package/dist/tunnel.js +83 -50
  210. package/package.json +5 -4
  211. package/dist/channels/slack/bot-auth.d.ts +0 -15
  212. package/dist/channels/slack/bot-auth.js +0 -135
@@ -1,4 +1,21 @@
1
- import { registrationGate } from "../registration-gate.js";
1
+ /**
2
+ * `fastagent deploy agentcore --run` — drive the AWS CLI + Docker to completion. The middle of the
3
+ * deploy the plain runbook hands to the operator; `--run` executes it so a coding agent runs ONE
4
+ * command. Idempotent (ECR check-then-act; `cloudformation deploy` converges the stack) and
5
+ * resumable: it STOPS at a human gate with one actionable line and a non-zero exit.
6
+ *
7
+ * TWO runners, one seam ({@link CliRunner}): `aws` (identity, ECR, CloudFormation) and `docker`
8
+ * (buildx). AgentCore is the ONE host whose image builds on the operator's machine — the platform
9
+ * requires linux/arm64 in the account's ECR and has no remote builder — so a missing Docker/buildx
10
+ * is a first-class gate, not an incidental failure.
11
+ *
12
+ * Secrets ride CloudFormation NoEcho parameters. `--parameter-overrides` on argv would put the
13
+ * values in the process listing (the same reason Fly imports secrets over stdin), so they go through
14
+ * a caller-provided temp parameters file (`file://…`, mode 0600, deleted by the caller) — the write
15
+ * is injected to keep this module pure and the security-sensitive wiring testable.
16
+ */
17
+ import { RESERVED_PATHS } from "../../channels/agentcore-protocol.js";
18
+ import { registerWebhooks } from "../channel-ingress.js";
2
19
  import { createHash } from "node:crypto";
3
20
  import { Buffer } from "node:buffer";
4
21
  import { AUTH_SEED_CHUNK_SIZE, AUTH_SEED_MAX_CHUNKS, cfnParamName, forwarderSource, ingressSessionId, stateBucketName, } from "./plan.js";
@@ -8,7 +25,7 @@ import { zipSingleFile } from "./zip.js";
8
25
  const PROBE_TIMEOUT_MS = 120_000;
9
26
  const PROBE_INTERVAL_MS = 3_000;
10
27
  /**
11
- * Drive the forwarder's reserved `/__fastagent/probe` path until it answers, and read the runtime's
28
+ * Drive the forwarder's reserved probe path until it answers, and read the runtime's
12
29
  * STRUCTURED verdict. The path answers on every forwarder topology (a schedule-only URL refuses
13
30
  * ordinary public traffic, so a plain `GET /health` would 404 there), and the verdict rides a
14
31
  * transport-200 JSON body `{ ok, error? }` — the ordinary webhook relay folds a non-200 transport
@@ -123,7 +140,7 @@ export function parseCheckpointReply(stdout) {
123
140
  * post-deploy webhook steps from the builder machine against the forwarder's Function URL. Every
124
141
  * gate is fail-visible; `writeSecretFile` is the caller's 0600-temp-file seam (see the header).
125
142
  */
126
- export async function deployAgentcoreRun(plan, aws, docker, log, writeSecretFile, writeForwarderZip, registerTelegram, registerFeishu, registerSlack,
143
+ export async function deployAgentcoreRun(plan, aws, docker, log, writeSecretFile, writeForwarderZip, registrars,
127
144
  /** Injected in tests; the probe itself stays inside the run so no deploy can skip it. */
128
145
  probe = {}) {
129
146
  const gate = (g) => ({ ok: false, gate: g });
@@ -208,7 +225,7 @@ probe = {}) {
208
225
  // out of CloudFormation means a `delete-stack` — or a rolled-back create — cannot take the
209
226
  // agent's sessions, channel state and pending wake-ups with it.
210
227
  let forwarderParams;
211
- if (plan.needsForwarder) {
228
+ if (plan.topology.forwarder) {
212
229
  const bucket = stateBucketName(plan.name, account);
213
230
  if ((await aws(["s3api", "head-bucket", "--bucket", bucket], { capture: true })).code !== 0) {
214
231
  log(`creating deployment bucket ${bucket}…`);
@@ -360,7 +377,7 @@ probe = {}) {
360
377
  // Keyed on the FORWARDER: every current forwarder has a callback URL for state-capability refresh,
361
378
  // and every forwarder topology has an ingress session whose next event would otherwise land on compute still
362
379
  // running the previous image.
363
- if (plan.needsForwarder) {
380
+ if (plan.topology.forwarder) {
364
381
  // CHECKPOINT FIRST. The stop cuts whatever turn is running, and that turn's durable intent was
365
382
  // written to a mount the version update erases — so without this flush "replay re-runs it" would
366
383
  // be false: the intent never reaches S3 and the message is simply gone. Best-effort: a session
@@ -437,9 +454,7 @@ probe = {}) {
437
454
  // check (there is no boot-time failStartup on this host). A missing output means an edited
438
455
  // template; skipping the probe silently would let such a deploy report success unverified.
439
456
  // Only a pure-invoke deployment (no forwarder) legitimately has no URL and nothing to probe.
440
- // `channels.length` is belt-and-braces: the planner derives needsForwarder FROM the channel
441
- // list, but this gate must not silently trust that invariant across callers.
442
- if ((plan.needsForwarder || plan.channels.length > 0) && !url) {
457
+ if (plan.topology.forwarder && !url) {
443
458
  return gate("this deployment needs the forwarder but the stack has no ForwarderUrl output — regenerate the " +
444
459
  "template with --force");
445
460
  }
@@ -450,47 +465,24 @@ probe = {}) {
450
465
  // unrestorable snapshot surfaces AT DEPLOY TIME with the runtime's own error text.
451
466
  if (url) {
452
467
  log("probing the deployed runtime (state restore + channel construction)…");
453
- const verdict = await probeRuntime(`${url}/__fastagent/probe`, plan.secrets.FASTAGENT_INGRESS_SECRET ?? "", probe.fetchImpl ?? fetch, probe.timeoutMs, probe.intervalMs);
468
+ const verdict = await probeRuntime(`${url}${RESERVED_PATHS.probe}`, plan.secrets.FASTAGENT_INGRESS_SECRET ?? "", probe.fetchImpl ?? fetch, probe.timeoutMs, probe.intervalMs);
454
469
  if (!verdict.ok)
455
470
  return gate(verdict.gate);
456
471
  log("runtime verified (state restored, channels constructed)");
457
472
  }
458
473
  // 9. Post-deploy webhook registration — same registrar seam as every host, pointed at the
459
474
  // forwarder's Function URL. Gate policy is the shared registration-gate kernel.
460
- const reg = registrationGate(log, "re-run to retry registration (steps already done are skipped)");
461
- if (url) {
462
- if (plan.channels.includes("telegram")) {
463
- log("registering telegram webhook…");
464
- reg.track("telegram", await registerTelegram(url));
465
- }
466
- if (plan.channels.includes("github")) {
467
- log(`github: set the webhook in the repo (Settings → Webhooks) → ${url}/webhook`);
468
- reg.track("github", "manual"); // always a human step re-surfaced after the registrar output
469
- }
470
- if (plan.channels.includes("slack")) {
471
- if (registerSlack) {
472
- log("registering slack event URL…");
473
- reg.track("slack", await registerSlack(url));
474
- }
475
- else {
476
- log(`slack: set Event Subscriptions → Request URL → ${url}/slack`);
477
- reg.track("slack", "manual");
478
- }
479
- }
480
- for (const kind of ["feishu", "lark"]) {
481
- if (!plan.channels.includes(kind))
482
- continue;
483
- if (registerFeishu) {
484
- log(`registering ${kind} event URL…`);
485
- reg.track(kind, await registerFeishu(url, kind));
486
- }
487
- else {
488
- log(`${kind}: set the event Request URL (developer console → Events & Callbacks) → ${url}/${kind}`);
489
- reg.track(kind, "manual");
490
- }
491
- }
492
- }
493
- const registrationGateMsg = reg.gate();
475
+ // No long-connection channels reach here: `deploy.ts` gates them before the driver runs (AgentCore
476
+ // has no resident process to hold a connection), so the deploy's channels are all webhook ones.
477
+ const registrationGateMsg = url
478
+ ? await registerWebhooks({
479
+ baseUrl: url,
480
+ channels: plan.channels,
481
+ registrars,
482
+ log,
483
+ retryHint: "re-run to retry registration (steps already done are skipped)",
484
+ })
485
+ : undefined;
494
486
  if (registrationGateMsg)
495
487
  return gate(registrationGateMsg);
496
488
  return { ok: true, runtimeArn, url };
@@ -0,0 +1,73 @@
1
+ /**
2
+ * HOW A RUNNING CHANNEL IS REACHED — the URL-neutral half of "point this channel at the agent": its
3
+ * default route, whether anything can set that URL end-to-end, and the words an operator needs when
4
+ * nothing can. Only the base URL varies, which is the argument every function here takes — a deploy
5
+ * host's (`<app>.fly.dev`, a minted Railway domain, a Function URL) or `dev --tunnel`'s Quick Tunnel.
6
+ * It lives under `deploy/` because that is where its answers are mostly consumed and where the gate
7
+ * policy it composes with lives; the serving path reads the same answers through `tunnel.ts`.
8
+ *
9
+ * It exists because that knowledge belongs to the CHANNEL and was written per HOST: three runbook
10
+ * plans, three `--run` drivers, a path table in the docker plan and the `--tunnel` announcer each
11
+ * hand-wrote the same five-branch if-chain. A rule spelled seven times drifts, and it did — the
12
+ * long-connection exception reached only the feishu/lark branches, so a long-connection Telegram
13
+ * deploy printed `setWebhook` in its runbook, which makes `getUpdates` return 409 and stops the
14
+ * channel the operator just deployed.
15
+ *
16
+ * Every function here takes the {@link DeclaredChannel} list and filters it ITSELF. Taking a
17
+ * pre-filtered list would put the rule back at the call sites, which is where it drifted from: the
18
+ * `--tunnel` announcer trusted its caller that way and had a default that passed every channel,
19
+ * long-connection ones included.
20
+ *
21
+ * A host adds its base URL and its own asides; it does not restate which channels have a webhook.
22
+ */
23
+ import type { DeclaredChannel } from "../channels/discover.ts";
24
+ import type { RegistrationOutcome } from "../channels/registration.ts";
25
+ import type { ChannelKind } from "../scaffold/add-channel.ts";
26
+ /** The registrars a host can drive. `telegram` is always available (fastagent holds the token and the
27
+ * URL); the others are optional so a caller without their credentials falls back to a manual step. */
28
+ export interface Registrars {
29
+ telegram: (baseUrl: string) => Promise<RegistrationOutcome>;
30
+ slack?: (baseUrl: string) => Promise<RegistrationOutcome>;
31
+ feishu?: (baseUrl: string, kind: "feishu" | "lark") => Promise<RegistrationOutcome>;
32
+ }
33
+ /**
34
+ * The first-party channels this deployment must point at a URL: webhook ingress, and a kind this tool
35
+ * knows how to instruct. ONE answer for every host and every path — the runbooks, the `--run`
36
+ * drivers, the docker ingress note, `--tunnel`. A custom channel is skipped here and reported by the
37
+ * pre-flight instead: its URL is its author's to set.
38
+ */
39
+ export declare function webhookKinds(channels: readonly DeclaredChannel[]): ChannelKind[];
40
+ /** Default route keys for {@link webhookKinds}, in the same order (the docker plan's ingress note). */
41
+ export declare function webhookPaths(channels: readonly DeclaredChannel[]): string[];
42
+ /** The runbook block for every channel that needs a URL set by hand, `baseUrl` spelled the host's way
43
+ * (a literal `https://app.fly.dev`, or a placeholder like `<your-domain>` the operator fills in). */
44
+ export declare function webhookRunbook(baseUrl: string, channels: readonly DeclaredChannel[]): string[];
45
+ /**
46
+ * Point every channel that has a webhook at `baseUrl`, reporting what each one ended as. All channels
47
+ * are attempted — one failure does not skip the rest — and a channel whose registrar the caller did
48
+ * not wire reports `manual` with the operator's instruction. The two callers differ in what they do
49
+ * with the outcomes, not in how they are produced: a deploy gates on them, a long-running serve
50
+ * cannot ({@link registerWebhooks} is the gating half).
51
+ */
52
+ export declare function pointChannelsAt(input: {
53
+ baseUrl: string;
54
+ channels: readonly DeclaredChannel[];
55
+ registrars: Registrars;
56
+ log: (msg: string) => void;
57
+ }): Promise<{
58
+ kind: ChannelKind;
59
+ outcome: RegistrationOutcome;
60
+ }[]>;
61
+ /**
62
+ * {@link pointChannelsAt} plus the shared gate policy, for a command that EXITS: an exit 0 claims the
63
+ * deployment is reachable, so a failed registration has to become a non-zero one. Returns the gate
64
+ * message, or undefined when nothing gates.
65
+ */
66
+ export declare function registerWebhooks(input: {
67
+ baseUrl: string;
68
+ channels: readonly DeclaredChannel[];
69
+ registrars: Registrars;
70
+ log: (msg: string) => void;
71
+ /** How THIS host retries — the only per-host words in the gate. */
72
+ retryHint: string;
73
+ }): Promise<string | undefined>;
@@ -0,0 +1,101 @@
1
+ import { registrationGate } from "./registration-gate.js";
2
+ const feishuCloud = (kind, label) => ({
3
+ path: `/${kind}`,
4
+ register: (r, baseUrl) => r.feishu?.(baseUrl, kind),
5
+ manual: (baseUrl) => `${kind}: set the event Request URL in the developer console (Events & Callbacks) → ${baseUrl}/${kind} (the app must be running when you save)`,
6
+ runbook: (baseUrl) => [
7
+ `# Set the ${label} event Request URL (developer console → Events & Callbacks). Default route`,
8
+ `# POST /${kind}; the app must be RUNNING when you save (the console verifies with a challenge):`,
9
+ `# Request URL = ${baseUrl}/${kind}`,
10
+ ],
11
+ });
12
+ /** Declaration order — the order every runbook and every driver reports in. */
13
+ const INGRESS = {
14
+ telegram: {
15
+ path: "/telegram",
16
+ register: (r, baseUrl) => r.telegram(baseUrl),
17
+ manual: (baseUrl) => `telegram: set the webhook → ${baseUrl}/telegram (secret_token = TELEGRAM_SECRET_TOKEN)`,
18
+ runbook: (baseUrl) => [
19
+ `# Register the Telegram webhook. The path assumes the default route (POST /telegram); if you`,
20
+ `# remapped it in channels/telegram.ts, use your path. secret_token MUST equal TELEGRAM_SECRET_TOKEN:`,
21
+ `curl "https://api.telegram.org/bot<TELEGRAM_BOT_TOKEN>/setWebhook" \\`,
22
+ ` -d url=${baseUrl}/telegram -d secret_token=<TELEGRAM_SECRET_TOKEN>`,
23
+ ],
24
+ },
25
+ github: {
26
+ path: "/webhook",
27
+ // The env-var name is the part a first-time operator cannot guess, and this line is all `--tunnel`
28
+ // prints — there is no runbook beside it to carry the detail.
29
+ manual: (baseUrl) => `github: set the webhook in the repo (Settings → Webhooks) → ${baseUrl}/webhook (content type application/json, secret = GITHUB_WEBHOOK_SECRET)`,
30
+ runbook: (baseUrl) => [
31
+ `# Set the GitHub webhook (repo Settings → Webhooks). Default route POST /webhook; if you remapped`,
32
+ `# it in channels/github.ts, use your path:`,
33
+ `# Payload URL = ${baseUrl}/webhook, content type application/json, secret = GITHUB_WEBHOOK_SECRET`,
34
+ ],
35
+ },
36
+ slack: {
37
+ path: "/slack",
38
+ register: (r, baseUrl) => r.slack?.(baseUrl),
39
+ manual: (baseUrl) => `slack: set Event Subscriptions → Request URL → ${baseUrl}/slack`,
40
+ runbook: (baseUrl) => [
41
+ `# Set Slack Event Subscriptions → Request URL (default route POST /slack; the running service`,
42
+ `# answers Slack's challenge), and match scopes/subscriptions to the app \`add slack --group-behavior\` created:`,
43
+ `# Request URL = ${baseUrl}/slack`,
44
+ ],
45
+ },
46
+ feishu: feishuCloud("feishu", "Feishu"),
47
+ lark: feishuCloud("lark", "Lark"),
48
+ };
49
+ /**
50
+ * The first-party channels this deployment must point at a URL: webhook ingress, and a kind this tool
51
+ * knows how to instruct. ONE answer for every host and every path — the runbooks, the `--run`
52
+ * drivers, the docker ingress note, `--tunnel`. A custom channel is skipped here and reported by the
53
+ * pre-flight instead: its URL is its author's to set.
54
+ */
55
+ export function webhookKinds(channels) {
56
+ const declared = new Set(channels.filter((c) => c.ingress === "webhook").map((c) => c.name));
57
+ return Object.keys(INGRESS).filter((kind) => declared.has(kind));
58
+ }
59
+ /** Default route keys for {@link webhookKinds}, in the same order (the docker plan's ingress note). */
60
+ export function webhookPaths(channels) {
61
+ return webhookKinds(channels).map((kind) => INGRESS[kind].path);
62
+ }
63
+ /** The runbook block for every channel that needs a URL set by hand, `baseUrl` spelled the host's way
64
+ * (a literal `https://app.fly.dev`, or a placeholder like `<your-domain>` the operator fills in). */
65
+ export function webhookRunbook(baseUrl, channels) {
66
+ return webhookKinds(channels).flatMap((kind) => INGRESS[kind].runbook(baseUrl));
67
+ }
68
+ /**
69
+ * Point every channel that has a webhook at `baseUrl`, reporting what each one ended as. All channels
70
+ * are attempted — one failure does not skip the rest — and a channel whose registrar the caller did
71
+ * not wire reports `manual` with the operator's instruction. The two callers differ in what they do
72
+ * with the outcomes, not in how they are produced: a deploy gates on them, a long-running serve
73
+ * cannot ({@link registerWebhooks} is the gating half).
74
+ */
75
+ export async function pointChannelsAt(input) {
76
+ const outcomes = [];
77
+ for (const kind of webhookKinds(input.channels)) {
78
+ const ingress = INGRESS[kind];
79
+ // Calling IS the question: a channel whose registrar the caller did not wire returns undefined.
80
+ const running = ingress.register?.(input.registrars, input.baseUrl);
81
+ if (!running) {
82
+ input.log(ingress.manual(input.baseUrl));
83
+ outcomes.push({ kind, outcome: "manual" }); // a human's step — re-surfaced after registrar output
84
+ continue;
85
+ }
86
+ input.log(`registering ${kind} webhook…`);
87
+ outcomes.push({ kind, outcome: await running });
88
+ }
89
+ return outcomes;
90
+ }
91
+ /**
92
+ * {@link pointChannelsAt} plus the shared gate policy, for a command that EXITS: an exit 0 claims the
93
+ * deployment is reachable, so a failed registration has to become a non-zero one. Returns the gate
94
+ * message, or undefined when nothing gates.
95
+ */
96
+ export async function registerWebhooks(input) {
97
+ const reg = registrationGate(input.log, input.retryHint);
98
+ for (const { kind, outcome } of await pointChannelsAt(input))
99
+ reg.track(kind, outcome);
100
+ return reg.gate();
101
+ }
@@ -4,7 +4,7 @@
4
4
  * process, its loopback port, exact environment-variable names, and one persistent state volume.
5
5
  * `--tunnel` can add an ephemeral Cloudflare Quick Tunnel service; durable ingress remains operator-owned.
6
6
  */
7
- import type { ChannelKind } from "../../scaffold/add-channel.ts";
7
+ import type { DeclaredChannel } from "../../channels/discover.ts";
8
8
  import { type Artifact, type ContainerInput } from "../container.ts";
9
9
  export interface DockerPlanInput extends ContainerInput {
10
10
  /** Stable Compose project name, sanitized by {@link toDockerProjectName}. */
@@ -13,10 +13,8 @@ export interface DockerPlanInput extends ContainerInput {
13
13
  port: number;
14
14
  /** What satisfies model auth locally: an env-var name, an OAuth/stored label, or undefined. */
15
15
  modelAuth: string | undefined;
16
- /** Known channels contribute their environment-variable names and webhook registration. */
17
- channels: ChannelKind[];
18
- /** All long-connection channel basenames, including custom channels. */
19
- longConnectionChannels?: string[];
16
+ /** Every declared channel and its ingress — the source of the secret list and the ingress note. */
17
+ channels: readonly DeclaredChannel[];
20
18
  /** Generate an optional Cloudflare Quick Tunnel service in Compose. Generation only; `--run` starts it. */
21
19
  tunnel: boolean;
22
20
  /** Extra environment-variable names declared in config.deploy.secrets. */
@@ -39,7 +37,5 @@ export declare function isGeneratedCompose(content: string): boolean;
39
37
  export declare function composeHasTunnelService(content: string): boolean;
40
38
  /** Compose project names are lowercase [a-z0-9_-] and must start with an alphanumeric character. */
41
39
  export declare function toDockerProjectName(directoryName: string): string;
42
- /** Default first-party webhook paths. Workspace glue may remap them, so guidance labels them defaults. */
43
- export declare function dockerWebhookPaths(channels: ChannelKind[]): string[];
44
40
  /** Compute local-Docker artifacts + the runbook; no Docker process is touched here. */
45
41
  export declare function planDockerDeploy(input: DockerPlanInput): DockerPlan;
@@ -1,3 +1,4 @@
1
+ import { webhookPaths } from "../channel-ingress.js";
1
2
  import { containerArtifacts } from "../container.js";
2
3
  import { deploymentSecrets, isEnvKey } from "../secrets.js";
3
4
  const MOUNT = "/data";
@@ -24,23 +25,12 @@ export function toDockerProjectName(directoryName) {
24
25
  .replace(/[-_]+$/g, "");
25
26
  return `fastagent-${slug || "agent"}`;
26
27
  }
27
- /** Default first-party webhook paths. Workspace glue may remap them, so guidance labels them defaults. */
28
- export function dockerWebhookPaths(channels) {
29
- const path = {
30
- github: "/webhook",
31
- telegram: "/telegram",
32
- slack: "/slack",
33
- feishu: "/feishu",
34
- lark: "/lark",
35
- };
36
- return channels.map((kind) => path[kind]);
37
- }
38
28
  /** `${NAME:-}` without making JavaScript treat it as interpolation. */
39
29
  function composeInterpolation(name) {
40
30
  return `\${${name}:-}`;
41
31
  }
42
32
  function composeYaml(input) {
43
- const secrets = deploymentSecrets(input.modelAuth, input.channels, input.extraSecrets, input.longConnectionChannels);
33
+ const secrets = deploymentSecrets(input.modelAuth, input.channels, input.extraSecrets);
44
34
  // Always leave the auth-seed seam in the committed topology. `--run` uses it for OAuth/stored auth;
45
35
  // it is empty otherwise. Values never land in this file — Compose interpolates them at invocation.
46
36
  const envNames = [...new Set([...secrets.map((secret) => secret.name), "FASTAGENT_AUTH_SEED"])];
@@ -100,11 +90,10 @@ export function planDockerDeploy(input) {
100
90
  const composePath = `${input.agentPrefix}${DOCKER_COMPOSE_FILE}`;
101
91
  const artifacts = [{ path: composePath, content: composeYaml(input) }, ...containerArtifacts(input)];
102
92
  const compose = `docker compose -f ${composePath}`;
103
- const secrets = deploymentSecrets(input.modelAuth, input.channels, input.extraSecrets, input.longConnectionChannels);
93
+ const secrets = deploymentSecrets(input.modelAuth, input.channels, input.extraSecrets);
104
94
  const required = secrets.filter((secret) => secret.required);
105
95
  const optional = secrets.filter((secret) => !secret.required);
106
- const routeChannels = input.channels.filter((kind) => !input.longConnectionChannels?.includes(kind));
107
- const paths = dockerWebhookPaths(routeChannels);
96
+ const paths = webhookPaths(input.channels);
108
97
  const runbook = [
109
98
  `# Run FastAgent in local Docker. ${composePath} / Dockerfile(.dockerignore) are generated above.`,
110
99
  `# Existing artifacts are authoritative and kept; edit Dockerfile or ${composePath} freely.`,
@@ -1,3 +1,4 @@
1
+ import type { RegistrationOutcome } from "../../channels/registration.ts";
1
2
  import type { CliRunner } from "../runner.ts";
2
3
  export interface DockerRunPlan {
3
4
  /** Compose file relative to the runner cwd (the workspace root). */
@@ -10,6 +11,10 @@ export interface DockerRunPlan {
10
11
  missingSecrets: string[];
11
12
  /** Neither an env-key credential nor a readable auth.json is available. */
12
13
  needsModelCredential: boolean;
14
+ /** Register the deployment's webhooks against the tunnel URL, reporting what each registrar
15
+ * answered. REQUIRED, not optional: an absent one would delete this whole step in silence, which
16
+ * is the failure this driver's gate exists to end. A topology with no tunnel never calls it. */
17
+ announce: DockerAnnounce;
13
18
  /** `--tunnel` was requested for this run; a kept Compose file must actually contain that service. */
14
19
  requireTunnel: boolean;
15
20
  }
@@ -17,20 +22,45 @@ export type DockerRunOutcome = {
17
22
  ok: true;
18
23
  url?: string;
19
24
  tunnelUrl?: string;
20
- } | {
25
+ }
26
+ /** `url`/`tunnelUrl` travel with a gate too: Compose is up, so the operator still needs to know
27
+ * where it is and what to re-run. */
28
+ | {
21
29
  ok: false;
22
30
  gate: string;
31
+ url?: string;
32
+ tunnelUrl?: string;
23
33
  };
34
+ /** Register the deployment's webhooks against its public URL, reporting what each registrar
35
+ * answered. Injected so the driver stays free of channel specifics — and so a test can fail one. */
36
+ type DockerAnnounce = (baseUrl: string) => Promise<{
37
+ kind: string;
38
+ outcome: RegistrationOutcome;
39
+ }[]>;
24
40
  export type DockerHealthProbe = (healthUrl: string) => Promise<boolean>;
25
- export type DockerTunnelUrlProbe = (docker: CliRunner, composeFile: string, env: NodeJS.ProcessEnv) => Promise<string | undefined>;
41
+ /** A published Quick Tunnel URL, and whether its tunnel ever reported an edge connection. Both, because
42
+ * a URL that never connected still gets served (retrying meets the same network) and the operator has
43
+ * to be told which of the two they are looking at. */
44
+ export interface ComposeTunnel {
45
+ url: string;
46
+ connected: boolean;
47
+ }
48
+ export type DockerTunnelUrlProbe = (docker: CliRunner, composeFile: string, env: NodeJS.ProcessEnv) => Promise<ComposeTunnel | undefined>;
26
49
  /** Resolve Docker Compose's `host:port` output to a loopback URL (safe for 0.0.0.0/[::] bindings too). */
27
50
  export declare function localUrlFromComposePort(stdout: string): string | undefined;
28
- /** Poll the detached cloudflared service's logs until its assigned Quick Tunnel URL appears. */
51
+ /**
52
+ * Poll the detached cloudflared service's logs until its Quick Tunnel URL is assigned AND the tunnel
53
+ * reports an edge connection. Waiting for the second is the same requirement `startCloudflareTunnel`
54
+ * has and for the same reason — the hostname does not exist until then (#435), and this driver hands
55
+ * the URL straight to `announce`. Returns a URL that never connected rather than nothing: the
56
+ * registrars downstream report their own outcome, and a gate saying "no URL" would misname it — but it
57
+ * says WHICH it is returning, because the caller owes the operator that sentence.
58
+ */
29
59
  export declare function waitForComposeTunnelUrl(docker: CliRunner, composeFile: string, env: NodeJS.ProcessEnv, options?: {
30
60
  attempts?: number;
31
61
  intervalMs?: number;
32
62
  sleep?: (ms: number) => Promise<void>;
33
- }): Promise<string | undefined>;
63
+ }): Promise<ComposeTunnel | undefined>;
34
64
  /**
35
65
  * Drive Docker Compose. A custom Compose file remains authoritative: the driver invokes it as-is and
36
66
  * only assumes the generated service contract (`agent`, config's container port) for optional URL/
@@ -38,3 +68,4 @@ export declare function waitForComposeTunnelUrl(docker: CliRunner, composeFile:
38
68
  * service is still success (an operator-owned sidecar/reverse proxy may be its only ingress).
39
69
  */
40
70
  export declare function deployDockerRun(plan: DockerRunPlan, docker: CliRunner, log: (message: string) => void, healthProbe?: DockerHealthProbe, tunnelUrlProbe?: DockerTunnelUrlProbe): Promise<DockerRunOutcome>;
71
+ export {};
@@ -4,7 +4,8 @@
4
4
  * carry through the child environment (never argv), and a readiness check on the published loopback port.
5
5
  */
6
6
  import { waitForHealth } from "../../channels/wait-health.js";
7
- import { parseTunnelUrl } from "../../tunnel.js";
7
+ import { TUNNEL_DNS_LAG_MS, hasTunnelConnection, parseTunnelUrl } from "../../tunnel.js";
8
+ import { registrationGate } from "../registration-gate.js";
8
9
  import { MIN_DOCKER_COMPOSE_VERSION } from "./plan.js";
9
10
  /** Resolve Docker Compose's `host:port` output to a loopback URL (safe for 0.0.0.0/[::] bindings too). */
10
11
  export function localUrlFromComposePort(stdout) {
@@ -17,19 +18,31 @@ export function localUrlFromComposePort(stdout) {
17
18
  }
18
19
  const defaultHealthProbe = (healthUrl) => waitForHealth(healthUrl, 30_000, 500);
19
20
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
20
- /** Poll the detached cloudflared service's logs until its assigned Quick Tunnel URL appears. */
21
+ /**
22
+ * Poll the detached cloudflared service's logs until its Quick Tunnel URL is assigned AND the tunnel
23
+ * reports an edge connection. Waiting for the second is the same requirement `startCloudflareTunnel`
24
+ * has and for the same reason — the hostname does not exist until then (#435), and this driver hands
25
+ * the URL straight to `announce`. Returns a URL that never connected rather than nothing: the
26
+ * registrars downstream report their own outcome, and a gate saying "no URL" would misname it — but it
27
+ * says WHICH it is returning, because the caller owes the operator that sentence.
28
+ */
21
29
  export async function waitForComposeTunnelUrl(docker, composeFile, env, options = {}) {
22
30
  const compose = ["compose", "-f", composeFile];
23
31
  const attempts = options.attempts ?? 60;
32
+ let assigned;
24
33
  for (let attempt = 0; attempt < attempts; attempt++) {
25
34
  const logs = await docker([...compose, "logs", "--no-color", "tunnel"], { capture: true, env });
26
- const url = logs.code === 0 ? parseTunnelUrl(logs.stdout) : undefined;
27
- if (url)
28
- return url;
35
+ if (logs.code === 0) {
36
+ assigned ??= parseTunnelUrl(logs.stdout);
37
+ if (assigned && hasTunnelConnection(logs.stdout)) {
38
+ await (options.sleep ?? sleep)(TUNNEL_DNS_LAG_MS);
39
+ return { url: assigned, connected: true };
40
+ }
41
+ }
29
42
  if (attempt + 1 < attempts)
30
43
  await (options.sleep ?? sleep)(options.intervalMs ?? 500);
31
44
  }
32
- return undefined;
45
+ return assigned ? { url: assigned, connected: false } : undefined;
33
46
  }
34
47
  const defaultTunnelUrlProbe = (docker, composeFile, env) => waitForComposeTunnelUrl(docker, composeFile, env);
35
48
  /**
@@ -118,9 +131,28 @@ export async function deployDockerRun(plan, docker, log, healthProbe = defaultHe
118
131
  if (!hasTunnel)
119
132
  return { ok: true, url };
120
133
  log("waiting for the Compose tunnel service to publish its Quick Tunnel URL…");
121
- const tunnelUrl = await tunnelUrlProbe(docker, plan.composeFile, env);
122
- if (!tunnelUrl) {
134
+ const tunnel = await tunnelUrlProbe(docker, plan.composeFile, env);
135
+ if (!tunnel) {
123
136
  return gate(`tunnel did not publish a Quick Tunnel URL — inspect \`docker compose -f ${plan.composeFile} logs tunnel\``);
124
137
  }
138
+ // The same sentence `startCloudflareTunnel` prints for the same state, and needed MORE here: this
139
+ // cloudflared runs in a container, so the author cannot see the logs that would explain the
140
+ // registration failures about to follow. Silence sends them to debug the platform for what is a
141
+ // local tunnel that never came up.
142
+ if (!tunnel.connected) {
143
+ log(`warn: the tunnel service never reported an edge connection for ${tunnel.url} — announcing it anyway. ` +
144
+ `Nothing reaches a tunnel that has not connected, so a webhook registration that cannot resolve the ` +
145
+ `host is this, not the platform. Inspect \`docker compose -f ${plan.composeFile} logs tunnel\`.`);
146
+ }
147
+ const tunnelUrl = tunnel.url;
148
+ // Registration lives HERE, like every other host's driver, and not at the CLI: this is the layer
149
+ // that owns the outcome, so it is the layer that can gate on one. While it sat above, docker was
150
+ // the one target whose `--run` could exit 0 with a webhook that never registered.
151
+ const reg = registrationGate(log, `re-run this deploy to retry registration (Compose is already up)`);
152
+ for (const { kind, outcome } of await plan.announce(tunnelUrl))
153
+ reg.track(kind, outcome);
154
+ const blocked = reg.gate();
155
+ if (blocked)
156
+ return { ok: false, gate: blocked, url, tunnelUrl };
125
157
  return { ok: true, url, tunnelUrl };
126
158
  }
@@ -16,7 +16,7 @@
16
16
  * snapshot is discarded replays on the next start (the Telegram L1 turn store) — at-least-once, the
17
17
  * documented floor. State on the /data volume survives stop/suspend on the same machine.
18
18
  */
19
- import type { ChannelKind } from "../../scaffold/add-channel.ts";
19
+ import type { DeclaredChannel } from "../../channels/discover.ts";
20
20
  import { type Artifact, type ContainerInput } from "../container.ts";
21
21
  export interface FlyPlanInput extends ContainerInput {
22
22
  /** Fly app name — globally unique, lowercase; the CLI sanitizes it from the dir basename. */
@@ -28,10 +28,9 @@ export interface FlyPlanInput extends ContainerInput {
28
28
  * `"OAuth"`/`"stored credential"` (a local login the server can't use), or undefined (unconfigured).
29
29
  */
30
30
  modelAuth: string | undefined;
31
- /** Known first-party channelseach contributes its secret metadata + webhook step. */
32
- channels: ChannelKind[];
33
- /** All long-connection channel basenames, including custom channels require one running machine. */
34
- longConnectionChannels?: string[];
31
+ /** Every declared channel and its ingress the source of the secret list, the webhook steps, and
32
+ * whether a machine must stay up for an outbound connection. */
33
+ channels: readonly DeclaredChannel[];
35
34
  /** Extra secret env-var names (fastagent.config deploy.secrets) — added to the runbook's secret list. */
36
35
  extraSecrets?: string[];
37
36
  /** `auto_stop_machines` — `"suspend"` (default, fast resume) or `"stop"` (cold start). CLI `--stop`. */
@@ -1,3 +1,4 @@
1
+ import { webhookRunbook } from "../channel-ingress.js";
1
2
  import { containerArtifacts } from "../container.js";
2
3
  import { deploymentSecrets, isEnvKey } from "../secrets.js";
3
4
  function flyToml(appName, port, hasGithub, autostop, scaleToZero, hasTimeTriggers, hasLongConnectionChannel) {
@@ -62,7 +63,7 @@ export function planFlyDeploy(input) {
62
63
  const artifacts = [
63
64
  {
64
65
  path: flyTomlPath,
65
- content: flyToml(appName, port, channels.includes("github"), input.autostop, input.scaleToZero, input.hasTimeTriggers, (input.longConnectionChannels?.length ?? 0) > 0),
66
+ content: flyToml(appName, port, channels.some((channel) => channel.name === "github"), input.autostop, input.scaleToZero, input.hasTimeTriggers, channels.some((channel) => channel.ingress === "long-connection")),
66
67
  },
67
68
  ...containerArtifacts(input),
68
69
  ];
@@ -70,7 +71,7 @@ export function planFlyDeploy(input) {
70
71
  // model key (when local auth is an env key) + every discovered channel's secrets. Names + hints as
71
72
  // COMMENT lines (a `#` inside a `\`-continued command would break the shell), then one flat, executable
72
73
  // `fly secrets set` the coding agent fills — `<value>` placeholders, never inline comments.
73
- const secrets = deploymentSecrets(modelAuth, channels, input.extraSecrets, input.longConnectionChannels);
74
+ const secrets = deploymentSecrets(modelAuth, channels, input.extraSecrets);
74
75
  const requiredSecrets = secrets.filter((secret) => secret.required);
75
76
  const optionalSecrets = secrets.filter((secret) => !secret.required);
76
77
  const deployCmd = `fly deploy . --config ${flyTomlPath} --dockerfile ${input.agentPrefix}Dockerfile --app ${appName}`;
@@ -86,6 +87,13 @@ export function planFlyDeploy(input) {
86
87
  `# <region> MUST equal primary_region in fly.toml (a volume in another region can't mount) — fly.toml`,
87
88
  `# is the single source for the region; skip this if the volume exists (fly volumes list --app ${appName}):`,
88
89
  `fly volumes create data --app ${appName} --region <region> --size 1`,
90
+ ``,
91
+ `# An address to be reached ON: [http_service] declares a service, it does not allocate an IP.`,
92
+ `# \`fly deploy\` allocates one on a FIRST deploy only, and merely WARNS when that fails — which`,
93
+ `# leaves the machine serving and https://${appName}.fly.dev with no DNS record at all. Both are`,
94
+ `# free; skip if \`fly ips list --app ${appName}\` already shows a v4/v6/shared_v4 address:`,
95
+ `fly ips allocate-v4 --shared --app ${appName}`,
96
+ `fly ips allocate-v6 --app ${appName}`,
89
97
  ];
90
98
  if (requiredSecrets.length > 0) {
91
99
  runbook.push(``, `# Required secrets (replace each <value>):`, ...requiredSecrets.map((s) => `# ${s.name}: ${s.hint}`), `fly secrets set --app ${appName} ${requiredSecrets.map((s) => `${s.name}=<value>`).join(" ")}`);
@@ -110,26 +118,10 @@ export function planFlyDeploy(input) {
110
118
  ? `# Model auth: none found at the local auth path — a global \`fastagent login\` isn't read here; pass --auth-path <file> (e.g. ~/.fastagent/.secrets/auth.json), or \`--run\` carries it automatically.`
111
119
  : `# Model auth: your local auth is "${modelAuth}" — the plan can't read its value to set as a secret.`, `# Set your provider API key as a Fly secret (fly secrets set KEY=...), OR place auth.json at /data/.secrets/ on the volume.`);
112
120
  }
113
- // The fastagent-only post-step: point each channel's webhook at the live URL. Only fastagent knows the routes.
114
- // The URLs below assume each channel's DEFAULT route key (POST /telegram, POST /webhook). Reading the
115
- // real key would mean executing the channel factory (getMe, state-dir creation) — wrong at plan time
116
- // so the runbook states the assumption instead of silently printing a stale path for remapped glue.
117
- const post = [];
118
- if (channels.includes("telegram")) {
119
- post.push(`# After deploy — register the Telegram webhook. The path assumes the default route (POST /telegram);`, `# if you remapped it in channels/telegram.ts, use your path. secret_token MUST equal TELEGRAM_SECRET_TOKEN:`, `curl "https://api.telegram.org/bot<TELEGRAM_BOT_TOKEN>/setWebhook" \\`, ` -d url=https://${appName}.fly.dev/telegram -d secret_token=<TELEGRAM_SECRET_TOKEN>`);
120
- }
121
- if (channels.includes("github")) {
122
- post.push(`# After deploy — set the GitHub webhook (repo Settings → Webhooks). Path assumes the default route`, `# (POST /webhook); if you remapped it in channels/github.ts, use your path:`, `# Payload URL = https://${appName}.fly.dev/webhook, content type application/json, secret = GITHUB_WEBHOOK_SECRET`);
123
- }
124
- if (channels.includes("slack")) {
125
- post.push(`# After deploy — set Slack Event Subscriptions → Request URL. Path assumes POST /slack;`, `# Slack verifies the running endpoint with a challenge:`, `# Request URL = https://${appName}.fly.dev/slack`, `# Ensure OAuth scopes + message.* subscriptions match the groupBehavior in channels/slack.ts.`);
126
- }
127
- for (const kind of ["feishu", "lark"]) {
128
- if (!channels.includes(kind) || input.longConnectionChannels?.includes(kind))
129
- continue;
130
- const label = kind === "feishu" ? "Feishu" : "Lark";
131
- post.push(`# After deploy — set the ${label} event Request URL (developer console → Events & Callbacks).`, `# Path assumes the default route (POST /${kind}); the app must be RUNNING when you save (the console`, `# verifies the URL with a challenge):`, `# Request URL = https://${appName}.fly.dev/${kind}`);
132
- }
121
+ // The fastagent-only post-step: point each channel at the live URL. WHICH channels and in what words
122
+ // is the shared channel-ingress kernel's answer; Fly's contribution is that its URL is deterministic.
123
+ const steps = webhookRunbook(`https://${appName}.fly.dev`, channels);
124
+ const post = steps.length > 0 ? [`# After deploy:`, ...steps] : [];
133
125
  if (post.length > 0)
134
126
  runbook.push(``, ...post);
135
127
  // Single-machine tier: state lives on ONE volume tied to ONE machine. Scaling to multiple machines