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