@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.
- package/README.md +5 -1
- package/dist/atomic-write.d.ts +11 -7
- package/dist/atomic-write.js +16 -11
- package/dist/channels/agentcore-limits.d.ts +9 -0
- package/dist/channels/agentcore-limits.js +9 -0
- package/dist/channels/agentcore-protocol.d.ts +112 -0
- package/dist/channels/agentcore-protocol.js +22 -0
- package/dist/channels/agentcore-service.d.ts +12 -15
- package/dist/channels/agentcore-service.js +15 -24
- package/dist/channels/agentcore-state.d.ts +5 -11
- package/dist/channels/agentcore-state.js +4 -1
- package/dist/channels/agentcore.d.ts +9 -83
- package/dist/channels/agentcore.js +101 -93
- package/dist/channels/control.d.ts +50 -12
- package/dist/channels/control.js +251 -157
- package/dist/channels/discover.d.ts +26 -5
- package/dist/channels/discover.js +13 -26
- package/dist/channels/feishu/context-buffer.d.ts +6 -0
- package/dist/channels/feishu/context-buffer.js +0 -38
- package/dist/channels/feishu/crypto.d.ts +0 -2
- package/dist/channels/feishu/crypto.js +3 -8
- package/dist/channels/feishu/feishu-api.js +7 -12
- package/dist/channels/feishu/feishu.js +86 -128
- package/dist/channels/feishu/parse.d.ts +4 -3
- package/dist/channels/feishu/parse.js +5 -4
- package/dist/channels/feishu/register-webhook.d.ts +5 -5
- package/dist/channels/feishu/register-webhook.js +43 -57
- package/dist/channels/feishu/scaffold/feishu-send.ts +12 -67
- package/dist/channels/feishu/setup-mode.d.ts +30 -0
- package/dist/channels/feishu/setup-mode.js +26 -0
- package/dist/channels/feishu/shared-api.d.ts +10 -0
- package/dist/channels/feishu/shared-api.js +38 -0
- package/dist/channels/http.d.ts +0 -8
- package/dist/channels/http.js +4 -56
- package/dist/channels/kit/attachment-path.d.ts +12 -0
- package/dist/channels/kit/attachment-path.js +43 -0
- package/dist/channels/kit/context-buffer.d.ts +9 -0
- package/dist/channels/kit/context-buffer.js +11 -0
- package/dist/channels/kit/signature.d.ts +12 -0
- package/dist/channels/kit/signature.js +17 -0
- package/dist/channels/kit/stop-command.js +3 -3
- package/dist/channels/kit/tasks.d.ts +1 -1
- package/dist/channels/kit/tasks.js +10 -4
- package/dist/channels/kit/turn-runner.d.ts +59 -0
- package/dist/channels/kit/turn-runner.js +84 -0
- package/dist/channels/kit/turn-store.d.ts +19 -3
- package/dist/channels/kit/turn-store.js +29 -2
- package/dist/channels/lark/scaffold/lark-send.ts +12 -67
- package/dist/channels/registration.d.ts +36 -1
- package/dist/channels/registration.js +57 -1
- package/dist/channels/secret.d.ts +1 -0
- package/dist/channels/secret.js +16 -0
- package/dist/channels/slack/config-api.d.ts +35 -3
- package/dist/channels/slack/config-api.js +51 -9
- package/dist/channels/slack/invoke-turn.d.ts +1 -1
- package/dist/channels/slack/invoke-turn.js +5 -1
- package/dist/channels/slack/manifest.js +5 -1
- package/dist/channels/slack/onboard.d.ts +5 -5
- package/dist/channels/slack/onboard.js +44 -23
- package/dist/channels/slack/onboarding-state.d.ts +7 -3
- package/dist/channels/slack/onboarding-state.js +13 -20
- package/dist/channels/slack/register-webhook.d.ts +3 -2
- package/dist/channels/slack/register-webhook.js +35 -15
- package/dist/channels/slack/scaffold/channel.ts +3 -10
- package/dist/channels/slack/scaffold/slack-send.ts +18 -141
- package/dist/channels/slack/shared-api.d.ts +10 -0
- package/dist/channels/slack/shared-api.js +34 -0
- package/dist/channels/slack/slack-api.d.ts +20 -2
- package/dist/channels/slack/slack-api.js +100 -73
- package/dist/channels/slack/slack.d.ts +0 -10
- package/dist/channels/slack/slack.js +68 -107
- package/dist/channels/sse.d.ts +4 -0
- package/dist/channels/sse.js +66 -0
- package/dist/channels/telegram/parse.d.ts +21 -1
- package/dist/channels/telegram/parse.js +65 -11
- package/dist/channels/telegram/register-webhook.d.ts +6 -9
- package/dist/channels/telegram/register-webhook.js +44 -42
- package/dist/channels/telegram/scaffold/channel.ts +7 -3
- package/dist/channels/telegram/telegram-api.js +5 -6
- package/dist/channels/telegram/telegram.d.ts +2 -2
- package/dist/channels/telegram/telegram.js +97 -204
- package/dist/channels/wait-health.js +7 -4
- package/dist/cli/add-feishu.js +3 -10
- package/dist/cli/add-slack.js +13 -25
- package/dist/cli/commands/add.d.ts +0 -1
- package/dist/cli/commands/add.js +22 -35
- package/dist/cli/commands/attach.d.ts +2 -4
- package/dist/cli/commands/attach.js +17 -15
- package/dist/cli/commands/chat.js +6 -12
- package/dist/cli/commands/deploy/agentcore.d.ts +2 -0
- package/dist/cli/commands/deploy/agentcore.js +178 -0
- package/dist/cli/commands/deploy/docker.d.ts +2 -0
- package/dist/cli/commands/deploy/docker.js +119 -0
- package/dist/cli/commands/deploy/fly.d.ts +2 -0
- package/dist/cli/commands/deploy/fly.js +131 -0
- package/dist/cli/commands/deploy/railway.d.ts +2 -0
- package/dist/cli/commands/deploy/railway.js +71 -0
- package/dist/cli/commands/deploy/shared.d.ts +114 -0
- package/dist/cli/commands/deploy/shared.js +124 -0
- package/dist/cli/commands/deploy.d.ts +38 -25
- package/dist/cli/commands/deploy.js +101 -631
- package/dist/cli/commands/dev.js +20 -76
- package/dist/cli/commands/fire.js +6 -12
- package/dist/cli/commands/info.js +1 -1
- package/dist/cli/commands/init.js +1 -2
- package/dist/cli/commands/invoke.js +4 -11
- package/dist/cli/commands/schedule.js +1 -1
- package/dist/cli/commands/start.js +41 -80
- package/dist/cli/commands/tool.js +3 -8
- package/dist/cli/kernel.d.ts +0 -2
- package/dist/cli/kernel.js +0 -2
- package/dist/cli/program.js +7 -12
- package/dist/cli/serve.d.ts +42 -34
- package/dist/cli/serve.js +107 -28
- package/dist/cli/shared.d.ts +52 -27
- package/dist/cli/shared.js +69 -6
- package/dist/deploy/agentcore/forwarder.js +250 -0
- package/dist/deploy/agentcore/logs.d.ts +2 -2
- package/dist/deploy/agentcore/logs.js +2 -2
- package/dist/deploy/agentcore/plan.d.ts +34 -11
- package/dist/deploy/agentcore/plan.js +52 -259
- package/dist/deploy/agentcore/run.d.ts +9 -24
- package/dist/deploy/agentcore/run.js +35 -43
- package/dist/deploy/channel-ingress.d.ts +73 -0
- package/dist/deploy/channel-ingress.js +101 -0
- package/dist/deploy/docker/plan.d.ts +3 -7
- package/dist/deploy/docker/plan.js +4 -15
- package/dist/deploy/docker/run.d.ts +35 -4
- package/dist/deploy/docker/run.js +40 -8
- package/dist/deploy/fly/plan.d.ts +4 -5
- package/dist/deploy/fly/plan.js +14 -22
- package/dist/deploy/fly/run.d.ts +36 -23
- package/dist/deploy/fly/run.js +129 -83
- package/dist/deploy/hosts.d.ts +5 -0
- package/dist/deploy/hosts.js +4 -0
- package/dist/deploy/preflight.d.ts +6 -7
- package/dist/deploy/preflight.js +27 -23
- package/dist/deploy/railway/plan.d.ts +9 -5
- package/dist/deploy/railway/plan.js +18 -26
- package/dist/deploy/railway/run.d.ts +11 -10
- package/dist/deploy/railway/run.js +44 -43
- package/dist/deploy/runner.js +22 -2
- package/dist/deploy/secrets.d.ts +18 -9
- package/dist/deploy/secrets.js +63 -28
- package/dist/dev-supervisor.js +3 -4
- package/dist/engines/pi/agent-session-factory.d.ts +37 -17
- package/dist/engines/pi/agent-session-factory.js +111 -106
- package/dist/engines/pi/auth.js +43 -43
- package/dist/engines/pi/config.d.ts +2 -2
- package/dist/engines/pi/create.d.ts +22 -27
- package/dist/engines/pi/create.js +59 -74
- package/dist/engines/pi/definition.d.ts +1 -1
- package/dist/engines/pi/definition.js +11 -12
- package/dist/engines/pi/invoke-session.js +13 -30
- package/dist/engines/pi/login.js +32 -16
- package/dist/engines/pi/open.d.ts +3 -4
- package/dist/engines/pi/open.js +32 -42
- package/dist/engines/pi/retry-event.d.ts +6 -0
- package/dist/engines/pi/retry-event.js +15 -0
- package/dist/engines/pi/search-tools.js +1 -1
- package/dist/engines/pi/service.d.ts +1 -1
- package/dist/engines/pi/service.js +8 -0
- package/dist/engines/pi/session-builder.js +26 -140
- package/dist/engines/pi/session-control.d.ts +27 -22
- package/dist/engines/pi/session-control.js +551 -490
- package/dist/engines/pi/session-inheritance.d.ts +8 -22
- package/dist/engines/pi/session-inheritance.js +95 -76
- package/dist/engines/pi/session-markers.d.ts +48 -0
- package/dist/engines/pi/session-markers.js +59 -0
- package/dist/engines/pi/session-settings.d.ts +5 -5
- package/dist/engines/pi/session-settings.js +8 -5
- package/dist/engines/pi/session-store.d.ts +91 -26
- package/dist/engines/pi/session-store.js +413 -82
- package/dist/engines/pi/tool-context.d.ts +24 -11
- package/dist/engines/pi/tool-context.js +29 -4
- package/dist/engines/pi/tool.d.ts +6 -9
- package/dist/engines/pi/tool.js +3 -2
- package/dist/env.js +1 -2
- package/dist/feishu.d.ts +1 -0
- package/dist/feishu.js +1 -0
- package/dist/lark.d.ts +1 -0
- package/dist/lark.js +1 -0
- package/dist/loader.d.ts +51 -7
- package/dist/loader.js +84 -18
- package/dist/log.d.ts +9 -17
- package/dist/log.js +25 -30
- package/dist/paths.d.ts +26 -3
- package/dist/paths.js +43 -5
- package/dist/scaffold/add-channel.d.ts +6 -1
- package/dist/scaffold/add-channel.js +50 -67
- package/dist/scaffold/init.js +7 -2
- package/dist/scaffold/templates/tools/fetch-url.ts +0 -2
- package/dist/schedule/discover.js +3 -15
- package/dist/schedule/wake-alarm.d.ts +14 -19
- package/dist/schedule/wake-alarm.js +89 -48
- package/dist/schedule/wakeups.d.ts +1 -1
- package/dist/schedule/wakeups.js +10 -7
- package/dist/service.d.ts +24 -24
- package/dist/service.js +33 -77
- package/dist/session-remote.d.ts +12 -7
- package/dist/session-remote.js +185 -118
- package/dist/session.d.ts +227 -93
- package/dist/session.js +61 -23
- package/dist/slack.d.ts +2 -0
- package/dist/slack.js +1 -0
- package/dist/telegram.d.ts +1 -1
- package/dist/telegram.js +1 -1
- package/dist/tunnel.d.ts +34 -9
- package/dist/tunnel.js +83 -50
- package/package.json +5 -4
- package/dist/channels/slack/bot-auth.d.ts +0 -15
- package/dist/channels/slack/bot-auth.js +0 -135
package/dist/deploy/fly/run.d.ts
CHANGED
|
@@ -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
|
|
21
|
-
import type {
|
|
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
|
-
|
|
46
|
-
|
|
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
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
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,
|
|
79
|
+
export declare function deployFlyRun(plan: FlyRunPlan, fly: CliRunner, log: (msg: string) => void, registrars: Registrars): Promise<FlyRunOutcome>;
|
package/dist/deploy/fly/run.js
CHANGED
|
@@ -1,50 +1,96 @@
|
|
|
1
|
-
import { registrationGate } from "../registration-gate.js";
|
|
2
1
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
|
|
8
|
-
|
|
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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
|
18
|
-
const
|
|
19
|
-
if (!
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
/**
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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,
|
|
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).
|
|
59
|
-
// gate: inferring "absent" from
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
76
|
-
if (
|
|
77
|
-
return gate(
|
|
78
|
-
if (
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
//
|
|
117
|
-
//
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
|
1
|
+
import { type FastagentConfig } from "../engines/pi/config.ts";
|
|
2
2
|
import { type ResolvedPlacement } from "../paths.ts";
|
|
3
|
-
import { type
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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. */
|
package/dist/deploy/preflight.js
CHANGED
|
@@ -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
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
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
|
|
85
|
-
`protected only by a
|
|
86
|
-
|
|
87
|
-
`
|
|
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
|
|
97
|
-
const
|
|
98
|
-
|
|
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:
|
|
106
|
-
? `long-connection channel "${
|
|
107
|
-
: `route channel "${
|
|
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
|
|
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 {
|
|
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
|
-
/**
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
/**
|
|
11
|
-
* are
|
|
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
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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.
|
|
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
|
-
: (
|
|
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 };
|