@fastagent-sh/fastagent 0.18.0 → 0.20.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 (222) hide show
  1. package/README.md +25 -7
  2. package/dist/agent.d.ts +1 -1
  3. package/dist/agent.js +1 -1
  4. package/dist/atomic-write.d.ts +23 -0
  5. package/dist/atomic-write.js +53 -0
  6. package/dist/{host/node.d.ts → channel.d.ts} +24 -28
  7. package/dist/channel.js +1 -0
  8. package/dist/channels/agentcore-limits.d.ts +9 -0
  9. package/dist/channels/agentcore-limits.js +9 -0
  10. package/dist/channels/agentcore-service.d.ts +55 -0
  11. package/dist/channels/agentcore-service.js +107 -0
  12. package/dist/channels/agentcore.d.ts +22 -20
  13. package/dist/channels/agentcore.js +20 -11
  14. package/dist/channels/busy.d.ts +1 -1
  15. package/dist/channels/busy.js +1 -1
  16. package/dist/channels/control.d.ts +55 -7
  17. package/dist/channels/control.js +312 -67
  18. package/dist/channels/discover.d.ts +59 -0
  19. package/dist/{engines/pi/channel.js → channels/discover.js} +19 -34
  20. package/dist/channels/feishu/card.js +1 -1
  21. package/dist/channels/feishu/context-buffer.d.ts +1 -1
  22. package/dist/channels/feishu/context-buffer.js +4 -4
  23. package/dist/channels/feishu/feishu-api.js +6 -9
  24. package/dist/channels/feishu/feishu.d.ts +1 -1
  25. package/dist/channels/feishu/feishu.js +41 -24
  26. package/dist/channels/feishu/invoke-turn.d.ts +1 -1
  27. package/dist/channels/feishu/invoke-turn.js +3 -4
  28. package/dist/channels/feishu/preview.d.ts +1 -1
  29. package/dist/channels/feishu/preview.js +7 -3
  30. package/dist/channels/feishu/ws-ingress.d.ts +1 -1
  31. package/dist/channels/github/github.d.ts +1 -1
  32. package/dist/channels/http.d.ts +11 -7
  33. package/dist/channels/http.js +1 -109
  34. package/dist/channels/kit/attachment-path.d.ts +12 -0
  35. package/dist/channels/kit/attachment-path.js +43 -0
  36. package/dist/channels/{context-buffer.d.ts → kit/context-buffer.d.ts} +9 -0
  37. package/dist/channels/{context-buffer.js → kit/context-buffer.js} +13 -2
  38. package/dist/channels/{invoke-turn-kit.d.ts → kit/invoke-turn-kit.d.ts} +14 -3
  39. package/dist/channels/{invoke-turn-kit.js → kit/invoke-turn-kit.js} +17 -6
  40. package/dist/channels/{preview-kit.d.ts → kit/preview-kit.d.ts} +3 -1
  41. package/dist/channels/{seen.js → kit/seen.js} +1 -1
  42. package/dist/channels/kit/signature.d.ts +12 -0
  43. package/dist/channels/kit/signature.js +17 -0
  44. package/dist/channels/{state.js → kit/state.js} +4 -7
  45. package/dist/channels/{stop-command.d.ts → kit/stop-command.d.ts} +1 -1
  46. package/dist/channels/{stop-command.js → kit/stop-command.js} +5 -5
  47. package/dist/channels/{tasks.d.ts → kit/tasks.d.ts} +1 -1
  48. package/dist/channels/{tasks.js → kit/tasks.js} +11 -5
  49. package/dist/channels/{thread-participants.js → kit/thread-participants.js} +1 -1
  50. package/dist/channels/{turn-queue.js → kit/turn-queue.js} +2 -2
  51. package/dist/channels/{turn-store.d.ts → kit/turn-store.d.ts} +19 -3
  52. package/dist/channels/{turn-store.js → kit/turn-store.js} +30 -3
  53. package/dist/channels/lark/lark.d.ts +1 -1
  54. package/dist/channels/serve.d.ts +73 -0
  55. package/dist/channels/serve.js +243 -0
  56. package/dist/channels/slack/bot-auth.js +3 -14
  57. package/dist/channels/slack/context-buffer.d.ts +2 -2
  58. package/dist/channels/slack/context-buffer.js +2 -2
  59. package/dist/channels/slack/invoke-turn.d.ts +2 -2
  60. package/dist/channels/slack/invoke-turn.js +13 -6
  61. package/dist/channels/slack/onboard.js +5 -5
  62. package/dist/channels/slack/onboarding-state.d.ts +7 -3
  63. package/dist/channels/slack/onboarding-state.js +13 -20
  64. package/dist/channels/slack/parse.js +2 -2
  65. package/dist/channels/slack/preview.d.ts +1 -1
  66. package/dist/channels/slack/preview.js +11 -3
  67. package/dist/channels/slack/register-webhook.js +1 -1
  68. package/dist/channels/slack/scaffold/slack-send.ts +2 -17
  69. package/dist/channels/slack/slack-api.js +23 -55
  70. package/dist/channels/slack/slack.d.ts +1 -1
  71. package/dist/channels/slack/slack.js +20 -20
  72. package/dist/channels/slack/welcomed.js +1 -1
  73. package/dist/channels/telegram/context-buffer.d.ts +2 -2
  74. package/dist/channels/telegram/context-buffer.js +2 -2
  75. package/dist/channels/telegram/invoke-turn.d.ts +1 -1
  76. package/dist/channels/telegram/invoke-turn.js +1 -1
  77. package/dist/channels/telegram/parse.d.ts +21 -1
  78. package/dist/channels/telegram/parse.js +67 -13
  79. package/dist/channels/telegram/preview.d.ts +1 -1
  80. package/dist/channels/telegram/preview.js +6 -2
  81. package/dist/channels/telegram/scaffold/channel.ts +7 -3
  82. package/dist/channels/telegram/telegram-api.js +5 -6
  83. package/dist/channels/telegram/telegram.d.ts +3 -3
  84. package/dist/channels/telegram/telegram.js +60 -72
  85. package/dist/channels/telegram/turn-store.d.ts +2 -2
  86. package/dist/channels/telegram/turn-store.js +2 -2
  87. package/dist/cli/add-slack.js +6 -5
  88. package/dist/cli/commands/add.js +1 -5
  89. package/dist/cli/commands/attach.d.ts +2 -4
  90. package/dist/cli/commands/attach.js +17 -15
  91. package/dist/cli/commands/deploy.d.ts +50 -5
  92. package/dist/cli/commands/deploy.js +183 -134
  93. package/dist/cli/commands/dev.js +25 -40
  94. package/dist/cli/commands/fire.js +1 -1
  95. package/dist/cli/commands/info.js +7 -5
  96. package/dist/cli/commands/init.js +1 -2
  97. package/dist/cli/commands/schedule.js +1 -1
  98. package/dist/cli/commands/start.js +80 -121
  99. package/dist/cli/commands/tool.js +5 -7
  100. package/dist/cli/program.js +1 -1
  101. package/dist/cli/serve.d.ts +22 -70
  102. package/dist/cli/serve.js +66 -257
  103. package/dist/cli/shared.d.ts +40 -10
  104. package/dist/cli/shared.js +53 -6
  105. package/dist/core.d.ts +3 -5
  106. package/dist/core.js +7 -5
  107. package/dist/deploy/agentcore/logs.d.ts +2 -2
  108. package/dist/deploy/agentcore/logs.js +2 -2
  109. package/dist/deploy/agentcore/plan.d.ts +12 -10
  110. package/dist/deploy/agentcore/plan.js +39 -31
  111. package/dist/deploy/agentcore/run.d.ts +5 -4
  112. package/dist/deploy/agentcore/run.js +13 -36
  113. package/dist/deploy/channel-ingress.d.ts +73 -0
  114. package/dist/deploy/channel-ingress.js +101 -0
  115. package/dist/deploy/docker/plan.d.ts +3 -7
  116. package/dist/deploy/docker/plan.js +4 -15
  117. package/dist/deploy/docker/run.d.ts +18 -1
  118. package/dist/deploy/docker/run.js +10 -0
  119. package/dist/deploy/fly/plan.d.ts +4 -5
  120. package/dist/deploy/fly/plan.js +7 -22
  121. package/dist/deploy/fly/run.d.ts +9 -23
  122. package/dist/deploy/fly/run.js +32 -68
  123. package/dist/deploy/preflight.d.ts +6 -7
  124. package/dist/deploy/preflight.js +27 -23
  125. package/dist/deploy/railway/plan.d.ts +4 -5
  126. package/dist/deploy/railway/plan.js +11 -26
  127. package/dist/deploy/railway/run.d.ts +9 -9
  128. package/dist/deploy/railway/run.js +40 -40
  129. package/dist/deploy/runner.js +22 -2
  130. package/dist/deploy/secrets.d.ts +18 -9
  131. package/dist/deploy/secrets.js +64 -12
  132. package/dist/dev-supervisor.js +11 -6
  133. package/dist/engines/pi/agent-session-factory.d.ts +104 -0
  134. package/dist/engines/pi/agent-session-factory.js +295 -0
  135. package/dist/engines/pi/auth.js +43 -43
  136. package/dist/engines/pi/config.d.ts +5 -5
  137. package/dist/engines/pi/config.js +1 -1
  138. package/dist/engines/pi/create.d.ts +67 -42
  139. package/dist/engines/pi/create.js +142 -72
  140. package/dist/engines/pi/definition.d.ts +42 -8
  141. package/dist/engines/pi/definition.js +114 -11
  142. package/dist/engines/pi/invoke-session.d.ts +22 -19
  143. package/dist/engines/pi/invoke-session.js +286 -100
  144. package/dist/engines/pi/login.js +39 -20
  145. package/dist/engines/pi/models.d.ts +14 -0
  146. package/dist/engines/pi/models.js +9 -1
  147. package/dist/engines/pi/open.d.ts +10 -8
  148. package/dist/engines/pi/open.js +21 -11
  149. package/dist/engines/pi/report.d.ts +0 -4
  150. package/dist/engines/pi/report.js +0 -7
  151. package/dist/engines/pi/search-tools.js +1 -1
  152. package/dist/engines/pi/service.d.ts +23 -0
  153. package/dist/engines/pi/service.js +27 -0
  154. package/dist/engines/pi/session-builder.js +87 -118
  155. package/dist/engines/pi/session-control.d.ts +28 -14
  156. package/dist/engines/pi/session-control.js +570 -438
  157. package/dist/engines/pi/session-inheritance.d.ts +30 -0
  158. package/dist/engines/pi/session-inheritance.js +261 -0
  159. package/dist/engines/pi/session-markers.d.ts +48 -0
  160. package/dist/engines/pi/session-markers.js +59 -0
  161. package/dist/engines/pi/session-settings.d.ts +15 -3
  162. package/dist/engines/pi/session-settings.js +22 -1
  163. package/dist/engines/pi/session-store.d.ts +140 -0
  164. package/dist/engines/pi/session-store.js +666 -0
  165. package/dist/engines/pi/tool-context.d.ts +36 -13
  166. package/dist/engines/pi/tool-context.js +51 -4
  167. package/dist/engines/pi/tool.d.ts +5 -5
  168. package/dist/engines/pi/tool.js +3 -3
  169. package/dist/engines/pi/turn-kit.d.ts +32 -6
  170. package/dist/engines/pi/turn-kit.js +28 -1
  171. package/dist/engines/pi/wake-tool.js +1 -1
  172. package/dist/env.js +1 -2
  173. package/dist/index.d.ts +1 -0
  174. package/dist/index.js +1 -0
  175. package/dist/loader.d.ts +49 -7
  176. package/dist/loader.js +79 -18
  177. package/dist/log.d.ts +3 -0
  178. package/dist/log.js +5 -1
  179. package/dist/node.d.ts +16 -0
  180. package/dist/node.js +20 -0
  181. package/dist/paths.d.ts +26 -0
  182. package/dist/paths.js +37 -2
  183. package/dist/pi.d.ts +14 -8
  184. package/dist/pi.js +6 -5
  185. package/dist/scaffold/add-channel.js +2 -2
  186. package/dist/scaffold/init.js +7 -2
  187. package/dist/scaffold/templates/persona.md +2 -2
  188. package/dist/scaffold/templates/tools/fetch-url.ts +0 -2
  189. package/dist/schedule/discover.js +3 -15
  190. package/dist/schedule/state.js +4 -9
  191. package/dist/schedule/wake-alarm.d.ts +13 -7
  192. package/dist/schedule/wake-alarm.js +88 -46
  193. package/dist/schedule/wakeups.d.ts +2 -2
  194. package/dist/schedule/wakeups.js +11 -8
  195. package/dist/service.d.ts +157 -0
  196. package/dist/service.js +376 -0
  197. package/dist/session-remote.d.ts +8 -3
  198. package/dist/session-remote.js +162 -92
  199. package/dist/session.d.ts +229 -93
  200. package/dist/session.js +61 -23
  201. package/dist/telegram.d.ts +1 -1
  202. package/dist/telegram.js +1 -1
  203. package/dist/tunnel.d.ts +18 -3
  204. package/dist/tunnel.js +32 -40
  205. package/package.json +14 -5
  206. package/dist/engines/pi/channel.d.ts +0 -37
  207. package/dist/engines/pi/harness.d.ts +0 -115
  208. package/dist/engines/pi/harness.js +0 -154
  209. package/dist/engines/pi/invoke.d.ts +0 -60
  210. package/dist/engines/pi/invoke.js +0 -459
  211. package/dist/engines/pi/read-image.d.ts +0 -4
  212. package/dist/engines/pi/read-image.js +0 -62
  213. package/dist/engines/pi/sessions.d.ts +0 -60
  214. package/dist/engines/pi/sessions.js +0 -464
  215. package/dist/host/node.js +0 -52
  216. /package/dist/channels/{preview-kit.js → kit/preview-kit.js} +0 -0
  217. /package/dist/channels/{seen.d.ts → kit/seen.d.ts} +0 -0
  218. /package/dist/channels/{state.d.ts → kit/state.d.ts} +0 -0
  219. /package/dist/channels/{text.d.ts → kit/text.d.ts} +0 -0
  220. /package/dist/channels/{text.js → kit/text.js} +0 -0
  221. /package/dist/channels/{thread-participants.d.ts → kit/thread-participants.d.ts} +0 -0
  222. /package/dist/channels/{turn-queue.d.ts → kit/turn-queue.d.ts} +0 -0
@@ -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}`;
@@ -110,26 +111,10 @@ export function planFlyDeploy(input) {
110
111
  ? `# 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
112
  : `# 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
113
  }
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
- }
114
+ // The fastagent-only post-step: point each channel at the live URL. WHICH channels and in what words
115
+ // is the shared channel-ingress kernel's answer; Fly's contribution is that its URL is deterministic.
116
+ const steps = webhookRunbook(`https://${appName}.fly.dev`, channels);
117
+ const post = steps.length > 0 ? [`# After deploy:`, ...steps] : [];
133
118
  if (post.length > 0)
134
119
  runbook.push(``, ...post);
135
120
  // Single-machine tier: state lives on ONE volume tied to ONE machine. Scaling to multiple machines
@@ -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,9 @@ 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
+ * Run the deploy through `fly`. `log` reports progress; the injected {@link Registrars} perform the
48
+ * post-deploy webhook steps from the builder machine (Slack's control credential never travels to the
49
+ * host). A registrar the caller did not wire becomes the printed manual step. Every gate is
50
+ * fail-visible.
65
51
  */
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>;
52
+ export declare function deployFlyRun(plan: FlyRunPlan, fly: CliRunner, log: (msg: string) => void, registrars: Registrars): Promise<FlyRunOutcome>;
@@ -1,32 +1,23 @@
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");
9
- }
10
- /**
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.
16
- */
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;
29
- }
20
+ import { registerWebhooks } from "../channel-ingress.js";
30
21
  /** Whether a `fly … list --json` array contains an object named `name` (Fly capitalizes `Name`; accept both). */
31
22
  function listHasName(stdout, name) {
32
23
  try {
@@ -39,12 +30,12 @@ function listHasName(stdout, name) {
39
30
  }
40
31
  }
41
32
  /**
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.
33
+ * Run the deploy through `fly`. `log` reports progress; the injected {@link Registrars} perform the
34
+ * post-deploy webhook steps from the builder machine (Slack's control credential never travels to the
35
+ * host). A registrar the caller did not wire becomes the printed manual step. Every gate is
36
+ * fail-visible.
46
37
  */
47
- export async function deployFlyRun(plan, fly, log, registerTelegram, registerFeishu, registerSlack) {
38
+ export async function deployFlyRun(plan, fly, log, registrars) {
48
39
  const gate = (g) => ({ ok: false, gate: g });
49
40
  // 1. Auth is the one gate a coding agent can't clear itself (browser OAuth). `whoami` succeeds with
50
41
  // either an interactive login or FLY_API_TOKEN, so one check covers both.
@@ -113,42 +104,15 @@ export async function deployFlyRun(plan, fly, log, registerTelegram, registerFei
113
104
  if ((await fly(deployArgs)).code !== 0) {
114
105
  return gate("`fly deploy` failed — see the flyctl output above; fix and re-run");
115
106
  }
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();
107
+ // 7. Post-deploy webhook — which channels, in what words, and the gate policy are all the shared
108
+ // kernel's; Fly contributes its deterministic URL and how to retry.
109
+ const registrationGateMsg = await registerWebhooks({
110
+ baseUrl: `https://${plan.appName}.fly.dev`,
111
+ channels: plan.channels,
112
+ registrars,
113
+ log,
114
+ retryHint: "re-run to retry registration (steps already done are skipped)",
115
+ });
152
116
  if (registrationGateMsg)
153
117
  return gate(registrationGateMsg);
154
118
  return { ok: true };
@@ -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";
18
- import { inspectChannels } from "../engines/pi/channel.js";
17
+ import { resolveSecretsDir, resolveStateRoot, exists } from "../paths.js";
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 (design §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
@@ -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
@@ -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,6 @@ 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. */
12
11
  /** railway.json is JSON, so its ownership marker is a KEY rather than a comment line. Railway ignores
13
12
  * unknown keys; the predicate below is what lets `--force` reset OUR file and keep a hand-written one. */
14
13
  const GENERATED_RAILWAY_KEY = "x-generated-by";
@@ -22,6 +21,8 @@ export function isGeneratedRailwayJson(content) {
22
21
  return false;
23
22
  }
24
23
  }
24
+ /** railway.json — build/deploy only (Railway's config-as-code scope). No env/volume/sleeping here: those
25
+ * are service settings the runbook applies via CLI. healthcheckPath gates routing on a live server. */
25
26
  function railwayJson(prefix) {
26
27
  return `${JSON.stringify({
27
28
  $schema: "https://railway.com/railway.schema.json",
@@ -45,7 +46,7 @@ export function planRailwayDeploy(input) {
45
46
  { path: configPath, content: railwayJson(input.agentPrefix) },
46
47
  ...containerArtifacts(input),
47
48
  ];
48
- const secrets = deploymentSecrets(modelAuth, channels, input.extraSecrets, input.longConnectionChannels);
49
+ const secrets = deploymentSecrets(modelAuth, channels, input.extraSecrets);
49
50
  const requiredSecrets = secrets.filter((secret) => secret.required);
50
51
  const optionalSecrets = secrets.filter((secret) => !secret.required);
51
52
  // Order matters, not cosmetics: `railway init` creates a PROJECT with no service, but the volume and
@@ -100,36 +101,20 @@ export function planRailwayDeploy(input) {
100
101
  }
101
102
  // The public URL is minted, not deterministic (unlike Fly's <app>.fly.dev) — ONE mint step, then each
102
103
  // 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}`);
104
+ // Mint only when something below needs it: with nothing to point at the domain (no channels, or only
105
+ // long-connection ones), "use it in the step(s) below" would refer to steps that do not follow.
106
+ const steps = webhookRunbook(`https://<your-domain>`, channels);
107
+ if (steps.length > 0) {
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`, ...steps);
124
109
  }
125
110
  // Scale-to-zero: App Sleeping is dashboard-only (no CLI/API) — a manual step, not a generated setting.
126
111
  // A github channel should NOT enable it: fire-and-forget reviews have no replay (unlike Telegram's L1
127
112
  // 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")
113
+ runbook.push(``, channels.some((channel) => channel.name === "github")
129
114
  ? `# Scale-to-zero: do NOT enable App Sleeping — github turns have no replay, a sleep mid-review is lost.`
130
115
  : input.hasTimeTriggers
131
116
  ? `# 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
117
+ : channels.some((channel) => channel.ingress === "long-connection")
133
118
  ? `# Scale-to-zero: do NOT enable App Sleeping — a long-connection channel must remain connected.`
134
119
  : `# 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
120
  return { artifacts, runbook };
@@ -23,8 +23,8 @@
23
23
  * no bulk stdin import like Fly's `secrets import`). Auth needs an ACCOUNT credential (login or
24
24
  * `RAILWAY_API_KEY`), not a project token: `init` creates a project that a project token can't predate.
25
25
  */
26
- import type { RegistrationOutcome } from "../../channels/registration.ts";
27
- import type { ChannelKind } from "../../scaffold/add-channel.ts";
26
+ import { type Registrars } from "../channel-ingress.ts";
27
+ import type { DeclaredChannel } from "../../channels/discover.ts";
28
28
  import type { CliRunner } from "../runner.ts";
29
29
  export interface RailwayRunPlan {
30
30
  /** Names both the project (`railway init --name`) and the service (`railway add --service`). Railway
@@ -38,8 +38,8 @@ export interface RailwayRunPlan {
38
38
  secrets: Record<string, string>;
39
39
  /** Required secret names with NO local value — the run gates on these before any side effect. */
40
40
  missingSecrets: string[];
41
- channels: ChannelKind[];
42
- longConnectionChannels?: string[];
41
+ /** Every declared channel and its ingress — the driver asks which of them have a webhook. */
42
+ channels: readonly DeclaredChannel[];
43
43
  /** Opt-in (CLI `--into-linked`) to provision INTO the project this directory is already linked to. Off
44
44
  * by default so `--run` only creates on an unlinked dir and never deploys into a pre-existing (possibly
45
45
  * unrelated/production) project; the flag is the operator's explicit "yes, this project". */
@@ -84,9 +84,9 @@ export declare function parseDomainUrl(stdout: string): string | undefined;
84
84
  */
85
85
  export declare function parseHasVolume(stdout: string, mountPath: string): boolean;
86
86
  /**
87
- * Run the deploy through `railway`. `log` reports progress; the injected Telegram/Feishu/Slack
88
- * registrars perform post-deploy webhook steps from the builder machine (Slack's control credential
89
- * never travels to the host). Absent, the manual console
90
- * instruction is printed. Every gate is fail-visible.
87
+ * Run the deploy through `railway`. `log` reports progress; the injected {@link Registrars} perform
88
+ * the post-deploy webhook steps from the builder machine (Slack's control credential never travels to
89
+ * the host). A registrar the caller did not wire becomes the printed manual step. Every gate is
90
+ * fail-visible.
91
91
  */
92
- export declare function deployRailwayRun(plan: RailwayRunPlan, railway: CliRunner, log: (msg: string) => void, registerTelegram: (baseUrl: string) => Promise<RegistrationOutcome>, registerFeishu?: (baseUrl: string, kind: "feishu" | "lark") => Promise<RegistrationOutcome>, registerSlack?: (baseUrl: string) => Promise<RegistrationOutcome>): Promise<RailwayRunOutcome>;
92
+ export declare function deployRailwayRun(plan: RailwayRunPlan, railway: CliRunner, log: (msg: string) => void, registrars: Registrars): Promise<RailwayRunOutcome>;
@@ -1,4 +1,29 @@
1
- import { registrationGate } from "../registration-gate.js";
1
+ /**
2
+ * `fastagent deploy railway --run` — drive the Railway CLI to completion. The middle of the deploy the
3
+ * plain runbook hands to the operator; `--run` executes it so a coding agent runs ONE command.
4
+ *
5
+ * Railway's model forces differences from the Fly runner (fly/run.ts), all validated against CLI 5.15.0:
6
+ *
7
+ * - **`--run` PROVISIONS a project and only runs on an UNLINKED directory** — so it can never deploy
8
+ * into a project it didn't create. `railway init` isn't check-then-act (it ALWAYS makes a new project)
9
+ * and `railway status` exits 0 even when unlinked (linkedness is read from stdout: JSON when linked,
10
+ * empty when not). We do NOT track ownership: a pre-existing link is refused (Railway has no globally
11
+ * unique name to give free identity like Fly's app name, and synthesizing one — a machine-local marker
12
+ * — was a large, bug-prone premature optimization). The only "yes, this project" signal is the operator's
13
+ * explicit `--into-linked`, which provisions INTO the linked project; a routine redeploy is just
14
+ * `railway up`. The volume is check-then-act, so `--into-linked` (or a create that failed at volume-add) still gets it.
15
+ * - **Every command needs `--service` explicitly** to stay non-interactive (a bare command prompts to
16
+ * pick a service). `railway volume add` is the exception — it has NO `--service` flag and rides the
17
+ * linked service, so the service must be created (and thus linked) first.
18
+ * - **The public URL is minted, not deterministic** — bare `railway domain --json` returns the service
19
+ * domain (minting one if absent), read for the webhook. We avoid the newer `domain list` subcommand:
20
+ * on a CLI without it, `railway domain list` registers a bogus custom domain named "list" (destructive).
21
+ *
22
+ * Secrets go in one-per-`variable set --stdin` (value on stdin, never argv/process listing — Railway has
23
+ * no bulk stdin import like Fly's `secrets import`). Auth needs an ACCOUNT credential (login or
24
+ * `RAILWAY_API_KEY`), not a project token: `init` creates a project that a project token can't predate.
25
+ */
26
+ import { registerWebhooks } from "../channel-ingress.js";
2
27
  /**
3
28
  * Whether `railway status --json` shows a linked project: non-empty stdout. Unlinked prints its message
4
29
  * to stderr and leaves stdout EMPTY (the exit code is 0 either way, so it can't be the signal). ANY
@@ -62,12 +87,12 @@ export function parseHasVolume(stdout, mountPath) {
62
87
  return jsonStrings(stdout).includes(mountPath);
63
88
  }
64
89
  /**
65
- * Run the deploy through `railway`. `log` reports progress; the injected Telegram/Feishu/Slack
66
- * registrars perform post-deploy webhook steps from the builder machine (Slack's control credential
67
- * never travels to the host). Absent, the manual console
68
- * instruction is printed. Every gate is fail-visible.
90
+ * Run the deploy through `railway`. `log` reports progress; the injected {@link Registrars} perform
91
+ * the post-deploy webhook steps from the builder machine (Slack's control credential never travels to
92
+ * the host). A registrar the caller did not wire becomes the printed manual step. Every gate is
93
+ * fail-visible.
69
94
  */
70
- export async function deployRailwayRun(plan, railway, log, registerTelegram, registerFeishu, registerSlack) {
95
+ export async function deployRailwayRun(plan, railway, log, registrars) {
71
96
  const gate = (g) => ({ ok: false, gate: g });
72
97
  // Every --service below targets plan.name — the name this tool gives BOTH the project and the service
73
98
  // (`init --name` + `add --service`). On a fresh create they match; on `--into-linked` into a hand-made
@@ -173,40 +198,15 @@ export async function deployRailwayRun(plan, railway, log, registerTelegram, reg
173
198
  if (!url) {
174
199
  return gate("couldn't read a domain from `railway domain` — run `railway domain` manually, then set any webhook");
175
200
  }
176
- // 7. Post-deploy webhook — gate policy is the shared registration-gate kernel (registrars report
177
- // facts, it owns the policy); all channels are attempted first.
178
- const reg = registrationGate(log, "re-run with --into-linked to retry registration");
179
- if (plan.channels.includes("telegram")) {
180
- log("registering telegram webhook…");
181
- reg.track("telegram", await registerTelegram(url));
182
- }
183
- if (plan.channels.includes("github")) {
184
- log(`github: set the webhook in the repo (Settings → Webhooks) → ${url}/webhook`);
185
- reg.track("github", "manual"); // always a human step — re-surface it after the registrar output
186
- }
187
- if (plan.channels.includes("slack")) {
188
- if (registerSlack) {
189
- log("registering slack event URL…");
190
- reg.track("slack", await registerSlack(url));
191
- }
192
- else {
193
- log(`slack: set Event Subscriptions → Request URL → ${url}/slack`);
194
- reg.track("slack", "manual");
195
- }
196
- }
197
- for (const kind of ["feishu", "lark"]) {
198
- if (!plan.channels.includes(kind) || plan.longConnectionChannels?.includes(kind))
199
- continue;
200
- if (registerFeishu) {
201
- log(`registering ${kind} event URL…`);
202
- reg.track(kind, await registerFeishu(url, kind));
203
- }
204
- else {
205
- log(`${kind}: set the event Request URL in the developer console (Events & Callbacks) → ${url}/${kind} (the service must be running when you save)`);
206
- reg.track(kind, "manual"); // no registrar wired — the console step above is the operator's
207
- }
208
- }
209
- const registrationGateMsg = reg.gate();
201
+ // 7. Post-deploy webhook — which channels, in what words, and the gate policy are all the shared
202
+ // kernel's; Railway contributes the minted URL and how to retry.
203
+ const registrationGateMsg = await registerWebhooks({
204
+ baseUrl: url,
205
+ channels: plan.channels,
206
+ registrars,
207
+ log,
208
+ retryHint: "re-run with --into-linked to retry registration",
209
+ });
210
210
  if (registrationGateMsg)
211
211
  return gate(registrationGateMsg);
212
212
  return { ok: true, url };