@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
@@ -17,23 +17,9 @@
17
17
  * (one machine, the single-machine tier). Secrets go in via `secrets import` over stdin, so values
18
18
  * never land in argv/process listings.
19
19
  */
20
- import type { RegistrationOutcome } from "../../channels/registration.ts";
21
- import type { ChannelKind } from "../../scaffold/add-channel.ts";
20
+ import { type Registrars } from "../channel-ingress.ts";
21
+ import type { DeclaredChannel } from "../../channels/discover.ts";
22
22
  import type { CliRunner } from "../runner.ts";
23
- /**
24
- * The bytes to seed to the auth file, or undefined to leave it alone — the pure core of `start`'s
25
- * FASTAGENT_AUTH_SEED materialization. ABSENT-ONLY by design: a present file (a refreshed volume copy)
26
- * is never overwritten by the stale seed, so a box that ran its own OAuth refresh is not rolled back.
27
- */
28
- export declare function authSeedBytes(seed: string | undefined, fileExists: boolean): Buffer | undefined;
29
- /**
30
- * Collect the (possibly CHUNKED) auth seed from the environment: `FASTAGENT_AUTH_SEED` plus numbered
31
- * continuations (`_2`, `_3`, …) concatenated in order. Hosts whose env values carry a small max
32
- * length (AgentCore: 2048 chars — a real OAuth auth.json's base64 exceeds it) split the seed across
33
- * them at deploy time; single-var hosts (Fly/Railway) never set a continuation and are unchanged.
34
- * Collection stops at the first absent/empty continuation — the writer fills them contiguously.
35
- */
36
- export declare function collectAuthSeed(env: NodeJS.ProcessEnv): string | undefined;
37
23
  export interface FlyRunPlan {
38
24
  appName: string;
39
25
  region: string;
@@ -42,8 +28,8 @@ export interface FlyRunPlan {
42
28
  secrets: Record<string, string>;
43
29
  /** Required secret names with NO local value — the run gates on these before any side effect. */
44
30
  missingSecrets: string[];
45
- channels: ChannelKind[];
46
- longConnectionChannels?: string[];
31
+ /** Every declared channel and its ingress — the driver asks which of them have a webhook. */
32
+ channels: readonly DeclaredChannel[];
47
33
  /** fly.toml path passed to `fly deploy -c` (relative to the run cwd = the workspace root). */
48
34
  flyConfig: string;
49
35
  /** Dockerfile path passed explicitly (`fastagent/Dockerfile`, with the workspace as context —
@@ -58,9 +44,36 @@ export type FlyRunOutcome = {
58
44
  gate: string;
59
45
  };
60
46
  /**
61
- * Run the deploy through `fly`. `log` reports progress; the injected Telegram/Feishu/Slack registrars
62
- * perform post-deploy webhook steps from the builder machine (Slack's control credential never travels
63
- * to the host). Absent, the manual console
64
- * instruction is printed. Every gate is fail-visible.
47
+ * Which PUBLIC ingress families `fly ips list --json` shows asked per family, because "has an
48
+ * ingress address" is not the question the two allocate commands answer. An app holding only a `v6`
49
+ * passes the coarse test and still resolves `<app>.fly.dev` to an AAAA record alone, so an IPv4-only
50
+ * webhook sender (Telegram, GitHub) reproduces #425 against it.
51
+ *
52
+ * The list is EVERY assignment the app holds, and a Flycast or egress address carries a non-empty
53
+ * `Address` too — reading one as routable pre-empts flyctl's own first-deploy fallback, which returns
54
+ * early once any assignment exists.
55
+ *
56
+ * Output that is not a JSON array THROWS rather than reading as "no address": the caller turns that
57
+ * into a gate, because a list we cannot read is not a state we can act on.
58
+ */
59
+ export declare function ingressAddresses(stdout: string): {
60
+ v4: boolean;
61
+ v6: boolean;
62
+ };
63
+ /**
64
+ * Whether a `fly … list --json` array contains an object named `name` (Fly capitalizes `Name`; accept
65
+ * both). Exported for the same reason railway's parsers are: what it encodes is an assumption about
66
+ * another tool's output, and the live probe checks that assumption against the real `flyctl`.
67
+ *
68
+ * THROWS on output that is not a JSON array, for the same reason {@link ingressAddresses} does —
69
+ * `false` here means "the host does not have it", and answering that for a list nobody could read
70
+ * creates a SECOND volume, or in teardown destroys nothing at all.
71
+ */
72
+ export declare function listHasName(stdout: string, name: string): boolean;
73
+ /**
74
+ * Run the deploy through `fly`. `log` reports progress; the injected {@link Registrars} perform the
75
+ * post-deploy webhook steps from the builder machine (Slack's control credential never travels to the
76
+ * host). A registrar the caller did not wire becomes the printed manual step. Every gate is
77
+ * fail-visible.
65
78
  */
66
- export declare function deployFlyRun(plan: FlyRunPlan, fly: CliRunner, log: (msg: string) => void, registerTelegram: (baseUrl: string) => Promise<RegistrationOutcome>, registerFeishu?: (baseUrl: string, kind: "feishu" | "lark") => Promise<RegistrationOutcome>, registerSlack?: (baseUrl: string) => Promise<RegistrationOutcome>): Promise<FlyRunOutcome>;
79
+ export declare function deployFlyRun(plan: FlyRunPlan, fly: CliRunner, log: (msg: string) => void, registrars: Registrars): Promise<FlyRunOutcome>;
@@ -1,50 +1,96 @@
1
- import { registrationGate } from "../registration-gate.js";
2
1
  /**
3
- * The bytes to seed to the auth file, or undefined to leave it alone the pure core of `start`'s
4
- * FASTAGENT_AUTH_SEED materialization. ABSENT-ONLY by design: a present file (a refreshed volume copy)
5
- * is never overwritten by the stale seed, so a box that ran its own OAuth refresh is not rolled back.
2
+ * `fastagent deploy fly --run` drive flyctl to completion. The middle of the deploy (app / volume /
3
+ * secrets / deploy) that the plain runbook hands to the operator; `--run` executes it instead, so a
4
+ * coding agent runs ONE command. Idempotent (app/volume check-then-act; channel secrets come from the
5
+ * local env — NOT minted — so a re-run sets the same values) and resumable: it STOPS at a human gate
6
+ * (not logged in, a missing secret value, a taken app name, a failed webhook registration) with one actionable line and a non-zero
7
+ * exit, so the agent clears the gate and re-runs. A `generate` channel secret absent from `.env` is a
8
+ * gate too (`missingSecrets`), not a silent mint — fill it in `.env` (use the random string that
9
+ * `add <channel>` prints).
10
+ *
11
+ * flyctl is behind the shared {@link CliRunner} seam — production spawns `fly`, tests inject a fake that
12
+ * records the command sequence and scripts outputs. That seam is the benchmark: the agent's journey
13
+ * encoded as an asserted command sequence + gate behavior, validated without a real Fly account.
14
+ *
15
+ * Non-interactive incantations the agent would otherwise get wrong: `--remote-only` (build on Fly's
16
+ * builders — no local Docker in a sandbox), `--yes` (no prompts), `--ha=false` + the mounted volume
17
+ * (one machine, the single-machine tier). Secrets go in via `secrets import` over stdin, so values
18
+ * never land in argv/process listings.
6
19
  */
7
- export function authSeedBytes(seed, fileExists) {
8
- return !seed || fileExists ? undefined : Buffer.from(seed, "base64");
20
+ import { registerWebhooks } from "../channel-ingress.js";
21
+ /** The `Type` values that put an app on `https://<app>.fly.dev`, BY FAMILY — one entry per allocate
22
+ * command, because that is the granularity the answer is acted on at. The rest of flyctl's
23
+ * vocabulary — `private_v6` (Flycast), `egress_v4`, `egress_v6`, `egress_pair` — is internal or
24
+ * outbound. */
25
+ const INGRESS_TYPES = { v4: ["v4", "shared_v4"], v6: ["v6"] };
26
+ /**
27
+ * Which PUBLIC ingress families `fly ips list --json` shows — asked per family, because "has an
28
+ * ingress address" is not the question the two allocate commands answer. An app holding only a `v6`
29
+ * passes the coarse test and still resolves `<app>.fly.dev` to an AAAA record alone, so an IPv4-only
30
+ * webhook sender (Telegram, GitHub) reproduces #425 against it.
31
+ *
32
+ * The list is EVERY assignment the app holds, and a Flycast or egress address carries a non-empty
33
+ * `Address` too — reading one as routable pre-empts flyctl's own first-deploy fallback, which returns
34
+ * early once any assignment exists.
35
+ *
36
+ * Output that is not a JSON array THROWS rather than reading as "no address": the caller turns that
37
+ * into a gate, because a list we cannot read is not a state we can act on.
38
+ */
39
+ export function ingressAddresses(stdout) {
40
+ const entries = JSON.parse(stdout);
41
+ if (!Array.isArray(entries))
42
+ throw new Error(`expected a JSON array, got ${typeof entries}`);
43
+ const has = (types) => entries.some((entry) => typeof entry?.Address === "string" && entry.Address !== "" && types.includes(entry.Type));
44
+ return { v4: has(INGRESS_TYPES.v4), v6: has(INGRESS_TYPES.v6) };
9
45
  }
10
46
  /**
11
- * Collect the (possibly CHUNKED) auth seed from the environment: `FASTAGENT_AUTH_SEED` plus numbered
12
- * continuations (`_2`, `_3`, …) concatenated in order. Hosts whose env values carry a small max
13
- * length (AgentCore: 2048 chars a real OAuth auth.json's base64 exceeds it) split the seed across
14
- * them at deploy time; single-var hosts (Fly/Railway) never set a continuation and are unchanged.
15
- * Collection stops at the first absent/empty continuation the writer fills them contiguously.
47
+ * Whether a `fly list --json` array contains an object named `name` (Fly capitalizes `Name`; accept
48
+ * both). Exported for the same reason railway's parsers are: what it encodes is an assumption about
49
+ * another tool's output, and the live probe checks that assumption against the real `flyctl`.
50
+ *
51
+ * THROWS on output that is not a JSON array, for the same reason {@link ingressAddresses} does —
52
+ * `false` here means "the host does not have it", and answering that for a list nobody could read
53
+ * creates a SECOND volume, or in teardown destroys nothing at all.
16
54
  */
17
- export function collectAuthSeed(env) {
18
- const first = env.FASTAGENT_AUTH_SEED;
19
- if (!first)
20
- return undefined;
21
- let seed = first;
22
- for (let i = 2;; i++) {
23
- const part = env[`FASTAGENT_AUTH_SEED_${i}`];
24
- if (!part)
25
- break;
26
- seed += part;
27
- }
28
- return seed;
55
+ export function listHasName(stdout, name) {
56
+ const entries = JSON.parse(stdout);
57
+ if (!Array.isArray(entries))
58
+ throw new Error(`expected a JSON array, got ${typeof entries}`);
59
+ return entries.some((o) => o.Name === name || o.name === name);
29
60
  }
30
- /** Whether a `fly … list --json` array contains an object named `name` (Fly capitalizes `Name`; accept both). */
31
- function listHasName(stdout, name) {
61
+ /**
62
+ * A read-only `fly … list --json`, reduced to the question the next step asks of it — or the gate for
63
+ * a list we cannot act on. THE place the three answers stay three: the command failed, the output was
64
+ * unreadable, or the host gave a verdict. Collapsing the middle one into `false` is what shipped #425
65
+ * and what would have skipped a teardown.
66
+ */
67
+ async function readList(fly, args, read) {
68
+ // The command as RUN, not a restatement of it: a gate that tells the operator to run it themselves
69
+ // has to name one that works from their cwd, and `fly ips list --json` without `-a <app>` does not
70
+ // (fly.toml lives under the agent prefix, so flyctl finds no app).
71
+ const cmd = `fly ${args.join(" ")}`;
72
+ const result = await fly(args, { capture: true });
73
+ if (result.code !== 0)
74
+ return { gate: `\`${cmd}\` failed — see the flyctl output above; fix and re-run` };
32
75
  try {
33
- const arr = JSON.parse(stdout);
34
- return (Array.isArray(arr) &&
35
- arr.some((o) => o.Name === name || o.name === name));
76
+ return { value: read(result.stdout) };
36
77
  }
37
- catch {
38
- return false;
78
+ catch (error) {
79
+ // The one place a parse failure is allowed to stop being an exception: this IS the boundary that
80
+ // turns it into the operator's gate (printed + non-zero exit), never a default answer.
81
+ return {
82
+ gate: `\`${cmd}\` was unreadable (${error instanceof Error ? error.message : String(error)}) — ` +
83
+ `run it yourself and check the flyctl version; fix and re-run`,
84
+ };
39
85
  }
40
86
  }
41
87
  /**
42
- * Run the deploy through `fly`. `log` reports progress; the injected Telegram/Feishu/Slack registrars
43
- * perform post-deploy webhook steps from the builder machine (Slack's control credential never travels
44
- * to the host). Absent, the manual console
45
- * instruction is printed. Every gate is fail-visible.
88
+ * Run the deploy through `fly`. `log` reports progress; the injected {@link Registrars} perform the
89
+ * post-deploy webhook steps from the builder machine (Slack's control credential never travels to the
90
+ * host). A registrar the caller did not wire becomes the printed manual step. Every gate is
91
+ * fail-visible.
46
92
  */
47
- export async function deployFlyRun(plan, fly, log, registerTelegram, registerFeishu, registerSlack) {
93
+ export async function deployFlyRun(plan, fly, log, registrars) {
48
94
  const gate = (g) => ({ ok: false, gate: g });
49
95
  // 1. Auth is the one gate a coding agent can't clear itself (browser OAuth). `whoami` succeeds with
50
96
  // either an interactive login or FLY_API_TOKEN, so one check covers both.
@@ -55,12 +101,13 @@ export async function deployFlyRun(plan, fly, log, registerTelegram, registerFei
55
101
  if (plan.missingSecrets.length > 0) {
56
102
  return gate(`no local value for: ${plan.missingSecrets.join(", ")} — set them in .env (or the environment) and re-run`);
57
103
  }
58
- // 3. App — idempotent (create only if absent; a taken global name is a gate). A FAILED list is its own
59
- // gate: inferring "absent" from an errored query would then misreport the create as a name clash.
60
- const appsList = await fly(["apps", "list", "--json"], { capture: true });
61
- if (appsList.code !== 0)
62
- return gate("`fly apps list` failed — see the flyctl output above; fix and re-run");
63
- if (listHasName(appsList.stdout, plan.appName)) {
104
+ // 3. App — idempotent (create only if absent; a taken global name is a gate). An unusable list is its
105
+ // own gate ({@link readList}): inferring "absent" from a query nobody could read would then
106
+ // misreport the create as a name clash.
107
+ const appExists = await readList(fly, ["apps", "list", "--json"], (out) => listHasName(out, plan.appName));
108
+ if ("gate" in appExists)
109
+ return gate(appExists.gate);
110
+ if (appExists.value) {
64
111
  log(`app ${plan.appName} exists — skipping create`);
65
112
  }
66
113
  else {
@@ -72,10 +119,10 @@ export async function deployFlyRun(plan, fly, log, registerTelegram, registerFei
72
119
  }
73
120
  // 4. Volume — idempotent; region comes from fly.toml (must match the machine's region). A failed list
74
121
  // gates for the same reason as the app list above.
75
- const volList = await fly(["volumes", "list", "-a", plan.appName, "--json"], { capture: true });
76
- if (volList.code !== 0)
77
- return gate("`fly volumes list` failed — see the flyctl output above; fix and re-run");
78
- if (listHasName(volList.stdout, "data")) {
122
+ const volumeExists = await readList(fly, ["volumes", "list", "-a", plan.appName, "--json"], (out) => listHasName(out, "data"));
123
+ if ("gate" in volumeExists)
124
+ return gate(volumeExists.gate);
125
+ if (volumeExists.value) {
79
126
  log(`volume data exists — skipping create`);
80
127
  }
81
128
  else {
@@ -85,7 +132,33 @@ export async function deployFlyRun(plan, fly, log, registerTelegram, registerFei
85
132
  return gate("`fly volumes create` failed — see the flyctl output above");
86
133
  }
87
134
  }
88
- // 5. Secretsstaged (no deploy yet; we deploy with fly.toml next). Values over stdin, not argv.
135
+ // 5. Ingress address `[http_service]` in fly.toml DECLARES a service; it does not create an address
136
+ // to reach it on. `fly launch` allocates one as part of its flow, and this driver does not use it,
137
+ // so without this step the deploy succeeds, the machine serves, and `https://<app>.fly.dev` has no
138
+ // DNS record at all — reported as a healthy deploy, and handed to the webhook registrars as a URL
139
+ // they then fail to reach.
140
+ const addresses = await readList(fly, ["ips", "list", "-a", plan.appName, "--json"], ingressAddresses);
141
+ if ("gate" in addresses)
142
+ return gate(addresses.gate);
143
+ // Check-then-act PER FAMILY: an app that already holds one must still be given the other, or the
144
+ // gate between the two allocations below heals into a permanent half-state — the re-run the gate
145
+ // asks for sees the v4 it just made, skips, and reports success on an app with no v6 (and, the way
146
+ // that matters, no v4). v4 SHARED and v6 are both free; a dedicated v4 is billed, so it stays an
147
+ // operator decision — `fly ips allocate-v4 -a <app>` after the fact, which this step reads as v4.
148
+ for (const [family, allocate] of [
149
+ ["v4", ["ips", "allocate-v4", "--shared", "-a", plan.appName]],
150
+ ["v6", ["ips", "allocate-v6", "-a", plan.appName]],
151
+ ]) {
152
+ if (addresses.value[family]) {
153
+ log(`public ${family} address exists — skipping allocate`);
154
+ continue;
155
+ }
156
+ log(`allocating a public ${family} address…`);
157
+ if ((await fly([...allocate])).code !== 0) {
158
+ return gate(`\`fly ${allocate.join(" ")}\` failed — see the flyctl output above`);
159
+ }
160
+ }
161
+ // 6. Secrets — staged (no deploy yet; we deploy with fly.toml next). Values over stdin, not argv.
89
162
  const keys = Object.keys(plan.secrets);
90
163
  if (keys.length > 0) {
91
164
  log(`setting ${keys.length} secret(s): ${keys.join(", ")}`);
@@ -94,7 +167,7 @@ export async function deployFlyRun(plan, fly, log, registerTelegram, registerFei
94
167
  return gate("`fly secrets import` failed — see the flyctl output above");
95
168
  }
96
169
  }
97
- // 6. Deploy — remote builder (no local Docker), one machine. Context + Dockerfile are passed
170
+ // 7. Deploy — remote builder (no local Docker), one machine. Context + Dockerfile are passed
98
171
  // explicitly (the workspace root is the context; the Dockerfile lives under fastagent/).
99
172
  log("deploying (remote build)…");
100
173
  const deployArgs = [
@@ -113,42 +186,15 @@ export async function deployFlyRun(plan, fly, log, registerTelegram, registerFei
113
186
  if ((await fly(deployArgs)).code !== 0) {
114
187
  return gate("`fly deploy` failed — see the flyctl output above; fix and re-run");
115
188
  }
116
- // 7. Post-deploy webhook — telegram end-to-end (fastagent has the token + the live URL); github is a
117
- // repo-settings step only a human can do. Gate policy is the shared registration-gate kernel
118
- // (registrars report facts, it owns the policy); all channels are attempted first.
119
- const reg = registrationGate(log, "re-run to retry registration (steps already done are skipped)");
120
- if (plan.channels.includes("telegram")) {
121
- log("registering telegram webhook…");
122
- reg.track("telegram", await registerTelegram(`https://${plan.appName}.fly.dev`));
123
- }
124
- if (plan.channels.includes("github")) {
125
- log(`github: set the webhook in the repo (Settings → Webhooks) → https://${plan.appName}.fly.dev/webhook`);
126
- reg.track("github", "manual"); // always a human step — re-surface it after the registrar output
127
- }
128
- if (plan.channels.includes("slack")) {
129
- const baseUrl = `https://${plan.appName}.fly.dev`;
130
- if (registerSlack) {
131
- log("registering slack event URL…");
132
- reg.track("slack", await registerSlack(baseUrl));
133
- }
134
- else {
135
- log(`slack: set Event Subscriptions → Request URL → ${baseUrl}/slack`);
136
- reg.track("slack", "manual");
137
- }
138
- }
139
- for (const kind of ["feishu", "lark"]) {
140
- if (!plan.channels.includes(kind) || plan.longConnectionChannels?.includes(kind))
141
- continue;
142
- if (registerFeishu) {
143
- log(`registering ${kind} event URL…`);
144
- reg.track(kind, await registerFeishu(`https://${plan.appName}.fly.dev`, kind));
145
- }
146
- else {
147
- log(`${kind}: set the event Request URL in the developer console (Events & Callbacks) → https://${plan.appName}.fly.dev/${kind} (the app must be running when you save)`);
148
- reg.track(kind, "manual"); // no registrar wired — the console step above is the operator's
149
- }
150
- }
151
- const registrationGateMsg = reg.gate();
189
+ // 8. Post-deploy webhook — which channels, in what words, and the gate policy are all the shared
190
+ // kernel's; Fly contributes its deterministic URL and how to retry.
191
+ const registrationGateMsg = await registerWebhooks({
192
+ baseUrl: `https://${plan.appName}.fly.dev`,
193
+ channels: plan.channels,
194
+ registrars,
195
+ log,
196
+ retryHint: "re-run to retry registration (steps already done are skipped)",
197
+ });
152
198
  if (registrationGateMsg)
153
199
  return gate(registrationGateMsg);
154
200
  return { ok: true };
@@ -0,0 +1,5 @@
1
+ /** The deploy targets, as a value: the CLI's `<host>` choices and the host-only-flag table's
2
+ * exhaustiveness check both read it. Dependency-free, so `cli/program.ts` can import it at load
3
+ * time without pulling a command module. */
4
+ export declare const DEPLOY_HOSTS: readonly ["docker", "fly", "railway", "agentcore"];
5
+ export type DeployHost = (typeof DEPLOY_HOSTS)[number];
@@ -0,0 +1,4 @@
1
+ /** The deploy targets, as a value: the CLI's `<host>` choices and the host-only-flag table's
2
+ * exhaustiveness check both read it. Dependency-free, so `cli/program.ts` can import it at load
3
+ * time without pulling a command module. */
4
+ export const DEPLOY_HOSTS = ["docker", "fly", "railway", "agentcore"];
@@ -1,6 +1,6 @@
1
- import type { FastagentConfig } from "../engines/pi/config.ts";
1
+ import { type FastagentConfig } from "../engines/pi/config.ts";
2
2
  import { type ResolvedPlacement } from "../paths.ts";
3
- import { type ChannelKind } from "../scaffold/add-channel.ts";
3
+ import { type DeclaredChannel } from "../channels/discover.ts";
4
4
  import { type ContainerInput } from "./container.ts";
5
5
  /** A stderr line the CLI prints (`[fastagent] warn: …` / `[fastagent] note: …`). Host-neutral advisories. */
6
6
  interface DeployMessage {
@@ -10,11 +10,10 @@ interface DeployMessage {
10
10
  /** The resolved facts every host plan needs (the container shape, channels, model auth, ports/secrets). */
11
11
  interface DeployFacts {
12
12
  messages: DeployMessage[];
13
- channels: ChannelKind[];
14
- /** Every structurally detected HTTP-route channel basename, including custom channels. */
15
- routeChannels: string[];
16
- /** Every structurally detected long-connection channel basename, including custom channels. */
17
- longConnectionChannels: string[];
13
+ /** Every declared channel with the ingress its module shape says it has, custom ones included. The
14
+ * ONE channel fact the plans and drivers read: a consumer that needs a subset derives it (see
15
+ * {@link webhookKinds}) rather than receiving a list someone else already filtered. */
16
+ channels: DeclaredChannel[];
18
17
  /** Whether the agent has TIME triggers — `schedules/` files or `selfSchedule` (the wake tool). Cron/wake
19
18
  * has no external wake-up, so the deployment must keep one machine running: the fly plan forces
20
19
  * `min_machines_running=1`, the railway runbook forbids App Sleeping. */
@@ -14,15 +14,15 @@ import { basename, isAbsolute, join, relative, sep } from "node:path";
14
14
  import ignore from "ignore";
15
15
  import { classifyBind } from "../bind.js";
16
16
  import { resolveAuthPath } from "../engines/pi/config.js";
17
- import { resolveSecretsDir, resolveStateRoot } from "../paths.js";
17
+ import { resolveSecretsDir, resolveStateRoot, exists, readTextIfExists } from "../paths.js";
18
18
  import { inspectChannels } from "../channels/discover.js";
19
19
  import { discoverScheduleFiles } from "../schedule/discover.js";
20
20
  import { createPiModelRuntime, modelCredentialCarry, probeAuthSource } from "../engines/pi/models.js";
21
21
  import { CHANNEL_KINDS } from "../scaffold/add-channel.js";
22
- import { exists } from "../paths.js";
23
22
  import { detectRuntime, readPackageJson } from "../runtime.js";
24
23
  import { fastagentVersion } from "../version.js";
25
24
  import { isGeneratedDockerfile, isGeneratedDockerignore } from "./container.js";
25
+ import { CONTROL_TOKEN_ENV } from "../channels/control.js";
26
26
  import { isEnvKey } from "./secrets.js";
27
27
  /**
28
28
  * "Would docker's packer drop this path?" — built from a `.dockerignore`'s text via the `ignore`
@@ -74,17 +74,19 @@ export async function preflightDeploy(input) {
74
74
  messages.push({ level: "warn", text: modelIssue });
75
75
  }
76
76
  // The control plane on a deployed box: `start` honors `sessionControl: true`, so `/control/*`
77
- // (steer/abort/set_model) rides the PUBLIC host URL protected only by a per-boot bearer token
78
- // minted INSIDE the container (`<stateRoot>/control.json`), which external consumers cannot read.
79
- // Publicly reachable yet unusable is the worst of both; the tunnel path warns loudly and deploy
80
- // must not be the silent second way to break the loopback trust story.
77
+ // (steer, stop, rewrite or delete a session) rides the PUBLIC host URL, protected only by the bearer token. The token
78
+ // travels as a deploy secret (see extraSecrets below) so the caller has it; the reach still warrants
79
+ // a warning the tunnel path warns loudly and deploy must not be the silent second way to break the
80
+ // loopback trust story.
81
81
  if (config.sessionControl === true) {
82
82
  messages.push({
83
83
  level: "warn",
84
- text: `sessionControl: true — the deployed box serves /control/* (steer/abort/set_model) at its public URL, ` +
85
- `protected only by a per-boot token written inside the container. Read the TOKEN from ` +
86
- `<stateRoot>/control.json on the box (its url field is container-loopback pair the token with the ` +
87
- `public host URL: attach --url <public-url> --token …), or front the endpoint with real auth (design §14)`,
84
+ text: `sessionControl: true — the deployed box serves /control/* (steer, stop, rewrite or delete a session) at its public URL, ` +
85
+ `protected only by a bearer token. Set ${CONTROL_TOKEN_ENV} (listed with the other secrets) and give the ` +
86
+ `same value to callers: attach --url <public-url> --token …. Unset, the box mints its own per boot — ` +
87
+ `readable only by shelling in (\`docker compose exec\`/\`fly ssh console\`: <stateRoot>/control.json, whose ` +
88
+ `url field is container-loopback) and replaced on every restart. Front the endpoint with real auth for ` +
89
+ `anything wider (docs/design/session-control.md §14)`,
88
90
  });
89
91
  }
90
92
  // Known channel kinds only — a custom channel's secrets/webhook are unknown to us; note and let the
@@ -93,20 +95,18 @@ export async function preflightDeploy(input) {
93
95
  if (inspected.failures.length > 0) {
94
96
  throw new Error(`cannot inspect channel modules: ${inspected.failures.map((failure) => `${failure.label}: ${failure.message}`).join("; ")}`);
95
97
  }
96
- const discovered = inspected.channels;
97
- const channels = discovered.filter((c) => CHANNEL_KINDS.includes(c));
98
- const routeChannels = inspected.routeChannels;
99
- const longConnectionChannels = inspected.longConnectionChannels;
100
- for (const c of discovered) {
101
- if (channels.includes(c))
98
+ const channels = inspected.channels;
99
+ for (const { name, ingress } of channels) {
100
+ if (CHANNEL_KINDS.includes(name))
102
101
  continue;
103
102
  messages.push({
104
103
  level: "note",
105
- text: longConnectionChannels.includes(c)
106
- ? `long-connection channel "${c}" is custom — configure its secrets yourself; generated deploy plans keep the process running and skip webhook registration`
107
- : `route channel "${c}" is custom — configure its secrets and webhook yourself`,
104
+ text: ingress === "long-connection"
105
+ ? `long-connection channel "${name}" is custom — configure its secrets yourself; generated deploy plans keep the process running and skip webhook registration`
106
+ : `route channel "${name}" is custom — configure its secrets and webhook yourself`,
108
107
  });
109
108
  }
109
+ const longConnectionChannels = channels.filter((c) => c.ingress === "long-connection").map((c) => c.name);
110
110
  // Time triggers (static schedules or self-scheduling) need a machine kept running — unlike a webhook,
111
111
  // nothing external wakes a scale-to-zero box for a cron instant or a wake-up. The note is CONDITIONAL
112
112
  // ("the generated plan…"): in KEEP mode an existing fly.toml is not rewritten — the CLI warns separately
@@ -299,7 +299,7 @@ export async function preflightDeploy(input) {
299
299
  // `docker build` (so it is the file that actually decides `deploy docker`). Checking only the root one
300
300
  // left the credential gate not covering the path it was written for.
301
301
  for (const rel of [".dockerignore", `${agentPrefix}Dockerfile.dockerignore`]) {
302
- const kept = await readFile(join(workspace, rel), "utf8").catch(() => undefined);
302
+ const kept = await readTextIfExists(join(workspace, rel));
303
303
  if (kept === undefined)
304
304
  continue;
305
305
  // One WE generated is regenerated by this very run under --force, so checking the stale content on
@@ -405,7 +405,13 @@ export async function preflightDeploy(input) {
405
405
  }
406
406
  // What the agent declared it needs on the box (fastagent.config deploy.secrets) — carried like channel
407
407
  // secrets: listed in the runbook, set from the local env under --run, gated if a value is missing.
408
- const extraSecrets = config.deploy?.secrets ?? [];
408
+ const extraSecrets = [...(config.deploy?.secrets ?? [])];
409
+ // The plane's bearer token is the DEPLOYMENT's secret, not the container's: minted inside the box it
410
+ // is unreadable from outside and replaced on every restart, which is what makes a public /control/*
411
+ // unusable. Carried like any declared secret — listed in the runbook, taken from the local env under
412
+ // --run, gated when absent (never minted: a value minted per deploy rotates under its holder).
413
+ if (config.sessionControl === true)
414
+ extraSecrets.push(CONTROL_TOKEN_ENV);
409
415
  // deploy.apt only shapes the GENERATED Dockerfile. Warn ONLY when the kept Dockerfile is HAND-WRITTEN
410
416
  // (its apt won't include these) — a fastagent-generated one is handled by writeArtifacts. Don't suggest
411
417
  // --force here: it would overwrite the user's hand-written file.
@@ -423,8 +429,6 @@ export async function preflightDeploy(input) {
423
429
  ok: true,
424
430
  messages,
425
431
  channels,
426
- routeChannels,
427
- longConnectionChannels,
428
432
  hasTimeTriggers,
429
433
  modelAuth,
430
434
  modelKeyInDefinition,
@@ -21,7 +21,7 @@
21
21
  * the required-secret list. `railway.json`'s `healthcheckPath=/health` also fixes the "routed before the
22
22
  * server is listening" boot race Fly's deploy hit — Railway only routes once /health passes.
23
23
  */
24
- import type { ChannelKind } from "../../scaffold/add-channel.ts";
24
+ import type { DeclaredChannel } from "../../channels/discover.ts";
25
25
  import { type Artifact, type ContainerInput } from "../container.ts";
26
26
  export interface RailwayPlanInput extends ContainerInput {
27
27
  /** The service name to create (`railway add --service`). Railway service names are project-scoped, not
@@ -29,10 +29,9 @@ export interface RailwayPlanInput extends ContainerInput {
29
29
  serviceName: string;
30
30
  /** What satisfies model auth locally: an env-var name, an OAuth/stored label, or undefined. */
31
31
  modelAuth: string | undefined;
32
- /** Known first-party channelseach contributes its secret metadata + webhook step. */
33
- channels: ChannelKind[];
34
- /** All long-connection channel basenames, including custom channels no App Sleeping. */
35
- longConnectionChannels?: string[];
32
+ /** Every declared channel and its ingress the source of the secret list, the webhook steps, and
33
+ * whether App Sleeping must stay off for an outbound connection. */
34
+ channels: readonly DeclaredChannel[];
36
35
  /** Extra secret env-var names (fastagent.config deploy.secrets) — added to the runbook's secret list. */
37
36
  extraSecrets?: string[];
38
37
  /** Time triggers present (schedules/ or selfSchedule) — the runbook forbids App Sleeping: cron/wake has
@@ -51,6 +50,11 @@ export interface RailwayPlan {
51
50
  * config file's `dockerfilePath` spells it WITHOUT the slash (the config-as-code schema's own
52
51
  * convention); two mechanisms, two documented spellings, one fact each. */
53
52
  export declare const dockerfilePathVar: (prefix: string) => string;
53
+ /** The name this tool gives BOTH the project and the service, derived from the workspace directory.
54
+ * Railway names are project-scoped (not globally unique like a Fly app), so this only has to survive
55
+ * the command — slug anything that would break `railway add --service <name>`, and name the fallback
56
+ * rather than emitting an empty argument. Shared so the live probe tears down what the CLI creates. */
57
+ export declare function toRailwayName(basename: string): string;
54
58
  /** Did fastagent generate this `railway.json`? Unparseable or unmarked reads as the author's. */
55
59
  export declare function isGeneratedRailwayJson(content: string): boolean;
56
60
  /** Compute the Railway deploy plan from the resolved definition. */
@@ -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
  /** State root = the volume mount path, kept in lockstep. `/data` matches the Fly recipe. */
@@ -7,8 +8,13 @@ const MOUNT = "/data";
7
8
  * config file's `dockerfilePath` spells it WITHOUT the slash (the config-as-code schema's own
8
9
  * convention); two mechanisms, two documented spellings, one fact each. */
9
10
  export const dockerfilePathVar = (prefix) => `/${prefix}Dockerfile`;
10
- /** railway.json build/deploy only (Railway's config-as-code scope). No env/volume/sleeping here: those
11
- * are service settings the runbook applies via CLI. healthcheckPath gates routing on a live server. */
11
+ /** The name this tool gives BOTH the project and the service, derived from the workspace directory.
12
+ * Railway names are project-scoped (not globally unique like a Fly app), so this only has to survive
13
+ * the command — slug anything that would break `railway add --service <name>`, and name the fallback
14
+ * rather than emitting an empty argument. Shared so the live probe tears down what the CLI creates. */
15
+ export function toRailwayName(basename) {
16
+ return basename.replace(/[^a-zA-Z0-9-]+/g, "-").replace(/^-+|-+$/g, "") || "agent";
17
+ }
12
18
  /** railway.json is JSON, so its ownership marker is a KEY rather than a comment line. Railway ignores
13
19
  * unknown keys; the predicate below is what lets `--force` reset OUR file and keep a hand-written one. */
14
20
  const GENERATED_RAILWAY_KEY = "x-generated-by";
@@ -22,6 +28,8 @@ export function isGeneratedRailwayJson(content) {
22
28
  return false;
23
29
  }
24
30
  }
31
+ /** railway.json — build/deploy only (Railway's config-as-code scope). No env/volume/sleeping here: those
32
+ * are service settings the runbook applies via CLI. healthcheckPath gates routing on a live server. */
25
33
  function railwayJson(prefix) {
26
34
  return `${JSON.stringify({
27
35
  $schema: "https://railway.com/railway.schema.json",
@@ -45,7 +53,7 @@ export function planRailwayDeploy(input) {
45
53
  { path: configPath, content: railwayJson(input.agentPrefix) },
46
54
  ...containerArtifacts(input),
47
55
  ];
48
- const secrets = deploymentSecrets(modelAuth, channels, input.extraSecrets, input.longConnectionChannels);
56
+ const secrets = deploymentSecrets(modelAuth, channels, input.extraSecrets);
49
57
  const requiredSecrets = secrets.filter((secret) => secret.required);
50
58
  const optionalSecrets = secrets.filter((secret) => !secret.required);
51
59
  // Order matters, not cosmetics: `railway init` creates a PROJECT with no service, but the volume and
@@ -100,36 +108,20 @@ export function planRailwayDeploy(input) {
100
108
  }
101
109
  // The public URL is minted, not deterministic (unlike Fly's <app>.fly.dev) — ONE mint step, then each
102
110
  // channel's webhook uses that domain (mint once even when both channels are present).
103
- const hasFeishuCloudChannel = ["feishu", "lark"].some((kind) => channels.includes(kind));
104
- if (channels.includes("telegram") ||
105
- channels.includes("github") ||
106
- channels.includes("slack") ||
107
- hasFeishuCloudChannel) {
108
- runbook.push(``, `# Public URL — Railway mints a *.up.railway.app domain (NOT deterministic). Generate it, then read`, `# the printed https URL and use it as <your-domain> in the webhook step(s) below:`, `railway domain`);
109
- }
110
- if (channels.includes("telegram")) {
111
- runbook.push(`# Register the Telegram webhook (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://<your-domain>/telegram -d secret_token=<TELEGRAM_SECRET_TOKEN>`);
112
- }
113
- if (channels.includes("github")) {
114
- runbook.push(`# Set the GitHub webhook (repo Settings → Webhooks). Default route POST /webhook; if you remapped it`, `# in channels/github.ts, use your path:`, `# Payload URL = https://<your-domain>/webhook, content type application/json, secret = GITHUB_WEBHOOK_SECRET`);
115
- }
116
- if (channels.includes("slack")) {
117
- runbook.push(`# Set Slack Event Subscriptions → Request URL (default route POST /slack; the running service`, `# answers Slack's challenge), and match scopes/subscriptions to channels/slack.ts groupBehavior:`, `# Request URL = https://<your-domain>/slack`);
118
- }
119
- for (const kind of ["feishu", "lark"]) {
120
- if (!channels.includes(kind) || input.longConnectionChannels?.includes(kind))
121
- continue;
122
- const label = kind === "feishu" ? "Feishu" : "Lark";
123
- runbook.push(`# Set the ${label} event Request URL (developer console → Events & Callbacks). Default route`, `# POST /${kind}; the service must be RUNNING when you save (the console verifies with a challenge):`, `# Request URL = https://<your-domain>/${kind}`);
111
+ // Mint only when something below needs it: with nothing to point at the domain (no channels, or only
112
+ // long-connection ones), "use it in the step(s) below" would refer to steps that do not follow.
113
+ const steps = webhookRunbook(`https://<your-domain>`, channels);
114
+ if (steps.length > 0) {
115
+ runbook.push(``, `# Public URL — Railway mints a *.up.railway.app domain (NOT deterministic). Generate it, then read`, `# the printed https URL and use it as <your-domain> in the webhook step(s) below:`, `railway domain`, ...steps);
124
116
  }
125
117
  // Scale-to-zero: App Sleeping is dashboard-only (no CLI/API) — a manual step, not a generated setting.
126
118
  // A github channel should NOT enable it: fire-and-forget reviews have no replay (unlike Telegram's L1
127
119
  // turn store), so a sleep mid-review would drop it — the same floor the Fly plan enforces via config.
128
- runbook.push(``, channels.includes("github")
120
+ runbook.push(``, channels.some((channel) => channel.name === "github")
129
121
  ? `# Scale-to-zero: do NOT enable App Sleeping — github turns have no replay, a sleep mid-review is lost.`
130
122
  : input.hasTimeTriggers
131
123
  ? `# Scale-to-zero: do NOT enable App Sleeping — schedules/wake-ups have no external wake-up; a sleeping service sleeps through them.`
132
- : (input.longConnectionChannels?.length ?? 0) > 0
124
+ : channels.some((channel) => channel.ingress === "long-connection")
133
125
  ? `# Scale-to-zero: do NOT enable App Sleeping — a long-connection channel must remain connected.`
134
126
  : `# Scale-to-zero (optional, dashboard-only — no CLI/API): Settings → Deploy → Serverless → App Sleeping.`, `# Keep this a SINGLE service: the ${MOUNT} volume is tied to one service; extra replicas split state.`);
135
127
  return { artifacts, runbook };