@fastagent-sh/fastagent 0.20.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/channels/agentcore-protocol.d.ts +112 -0
- package/dist/channels/agentcore-protocol.js +22 -0
- package/dist/channels/agentcore-service.d.ts +0 -4
- package/dist/channels/agentcore-service.js +1 -6
- package/dist/channels/agentcore-state.d.ts +5 -11
- package/dist/channels/agentcore-state.js +4 -1
- package/dist/channels/agentcore.d.ts +1 -67
- package/dist/channels/agentcore.js +90 -89
- package/dist/channels/control.d.ts +1 -1
- package/dist/channels/control.js +13 -62
- package/dist/channels/discover.d.ts +0 -1
- package/dist/channels/discover.js +1 -3
- 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 +2 -4
- package/dist/channels/feishu/feishu.js +56 -115
- 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/turn-runner.d.ts +59 -0
- package/dist/channels/kit/turn-runner.js +84 -0
- 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/manifest.js +5 -1
- package/dist/channels/slack/onboard.d.ts +5 -5
- package/dist/channels/slack/onboard.js +41 -20
- package/dist/channels/slack/register-webhook.d.ts +3 -2
- package/dist/channels/slack/register-webhook.js +34 -14
- package/dist/channels/slack/scaffold/channel.ts +3 -10
- package/dist/channels/slack/scaffold/slack-send.ts +18 -126
- 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 +81 -22
- package/dist/channels/slack/slack.d.ts +0 -10
- package/dist/channels/slack/slack.js +60 -99
- package/dist/channels/sse.d.ts +4 -0
- package/dist/channels/sse.js +66 -0
- package/dist/channels/telegram/register-webhook.d.ts +6 -9
- package/dist/channels/telegram/register-webhook.js +44 -42
- package/dist/channels/telegram/telegram.js +47 -142
- package/dist/channels/wait-health.js +7 -4
- package/dist/cli/add-feishu.js +3 -10
- package/dist/cli/add-slack.js +7 -20
- package/dist/cli/commands/add.d.ts +0 -1
- package/dist/cli/commands/add.js +22 -31
- 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 +17 -49
- package/dist/cli/commands/deploy.js +39 -618
- package/dist/cli/commands/dev.js +17 -46
- package/dist/cli/commands/fire.js +6 -12
- package/dist/cli/commands/info.js +2 -1
- package/dist/cli/commands/invoke.js +4 -11
- package/dist/cli/commands/schedule.js +1 -1
- package/dist/cli/commands/start.js +17 -46
- 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 +36 -19
- package/dist/cli/serve.js +91 -7
- package/dist/cli/shared.d.ts +13 -18
- package/dist/cli/shared.js +20 -4
- package/dist/deploy/agentcore/forwarder.js +250 -0
- package/dist/deploy/agentcore/plan.d.ts +23 -2
- package/dist/deploy/agentcore/plan.js +29 -244
- package/dist/deploy/agentcore/run.d.ts +4 -20
- package/dist/deploy/agentcore/run.js +22 -7
- package/dist/deploy/channel-ingress.js +1 -1
- package/dist/deploy/docker/run.d.ts +17 -3
- package/dist/deploy/docker/run.js +30 -8
- package/dist/deploy/fly/plan.js +7 -0
- package/dist/deploy/fly/run.d.ts +27 -0
- package/dist/deploy/fly/run.js +102 -20
- package/dist/deploy/hosts.d.ts +5 -0
- package/dist/deploy/hosts.js +4 -0
- package/dist/deploy/preflight.js +3 -3
- package/dist/deploy/railway/plan.d.ts +5 -0
- package/dist/deploy/railway/plan.js +7 -0
- package/dist/deploy/railway/run.d.ts +2 -1
- package/dist/deploy/railway/run.js +5 -4
- package/dist/deploy/secrets.js +0 -17
- package/dist/engines/pi/agent-session-factory.d.ts +37 -17
- package/dist/engines/pi/agent-session-factory.js +109 -85
- package/dist/engines/pi/config.d.ts +1 -1
- package/dist/engines/pi/create.d.ts +22 -39
- package/dist/engines/pi/create.js +58 -73
- package/dist/engines/pi/definition.js +8 -8
- package/dist/engines/pi/invoke-session.js +13 -30
- package/dist/engines/pi/open.d.ts +2 -2
- package/dist/engines/pi/open.js +31 -41
- package/dist/engines/pi/retry-event.d.ts +6 -0
- package/dist/engines/pi/retry-event.js +15 -0
- package/dist/engines/pi/session-builder.js +26 -113
- package/dist/engines/pi/session-control.d.ts +10 -18
- package/dist/engines/pi/session-control.js +47 -81
- package/dist/engines/pi/session-settings.d.ts +1 -1
- package/dist/engines/pi/session-settings.js +1 -1
- package/dist/engines/pi/session-store.d.ts +12 -17
- package/dist/engines/pi/session-store.js +18 -40
- package/dist/engines/pi/tool-context.d.ts +3 -3
- package/dist/engines/pi/tool.d.ts +6 -9
- package/dist/engines/pi/tool.js +1 -0
- 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 +2 -0
- package/dist/loader.js +5 -0
- package/dist/log.d.ts +9 -17
- package/dist/log.js +25 -30
- package/dist/paths.d.ts +7 -3
- package/dist/paths.js +17 -4
- package/dist/scaffold/add-channel.d.ts +6 -1
- package/dist/scaffold/add-channel.js +48 -65
- package/dist/schedule/wake-alarm.d.ts +1 -12
- package/dist/schedule/wake-alarm.js +2 -3
- package/dist/service.d.ts +23 -22
- package/dist/service.js +7 -73
- package/dist/session-remote.d.ts +5 -5
- package/dist/session-remote.js +25 -28
- package/dist/session.d.ts +1 -1
- package/dist/slack.d.ts +2 -0
- package/dist/slack.js +1 -0
- package/dist/tunnel.d.ts +16 -6
- package/dist/tunnel.js +53 -12
- package/package.json +5 -4
- package/dist/channels/slack/bot-auth.d.ts +0 -15
- package/dist/channels/slack/bot-auth.js +0 -135
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `deploy agentcore`: one CloudFormation stack (runtime + forwarder Lambda + EventBridge schedules);
|
|
3
|
+
* no public URL and no resident process — see deploy/agentcore/plan.ts for the topology decisions.
|
|
4
|
+
*/
|
|
5
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { basename, join } from "node:path";
|
|
8
|
+
import { MAX_WEBHOOK_BODY_BYTES } from "../../../channels/agentcore-limits.js";
|
|
9
|
+
import { FORWARDER_FILE, TEMPLATE_FILE, agentcoreName, isGeneratedAgentcoreTemplate, planAgentcoreDeploy, } from "../../../deploy/agentcore/plan.js";
|
|
10
|
+
import { deployAgentcoreRun } from "../../../deploy/agentcore/run.js";
|
|
11
|
+
import { spawnRunner } from "../../../deploy/runner.js";
|
|
12
|
+
import { exists } from "../../../paths.js";
|
|
13
|
+
import { loadSchedules } from "../../../schedule/discover.js";
|
|
14
|
+
import { failStartup } from "../../fail.js";
|
|
15
|
+
import { carryCredentials, gateOnModelCredential, registrarsFor } from "./shared.js";
|
|
16
|
+
/** A copy/paste-safe POSIX shell argument for the command hints deploy prints. */
|
|
17
|
+
function shellArg(value) {
|
|
18
|
+
return /^[A-Za-z0-9_./:@%+=,-]+$/.test(value) ? value : `'${value.replaceAll("'", `'"'"'`)}'`;
|
|
19
|
+
}
|
|
20
|
+
export const agentcoreHost = {
|
|
21
|
+
isOurs: (path, content) => path.endsWith(TEMPLATE_FILE) && isGeneratedAgentcoreTemplate(content),
|
|
22
|
+
async deploy(ctx) {
|
|
23
|
+
const { opts, agentDir, workspace, config, channels, longConnectionChannels, pre, write } = ctx;
|
|
24
|
+
const { modelAuth, modelKeyInDefinition, authPath, container, extraSecrets } = pre;
|
|
25
|
+
// Long-connection channels are STRUCTURALLY unsupported: the connection is the ingress, and a
|
|
26
|
+
// reclaimed session has nothing to wake it — events in the gap are silently lost. `--run` gates
|
|
27
|
+
// (deploying a channel that can't connect); generate-only warns and prints the runbook.
|
|
28
|
+
if (longConnectionChannels.length > 0) {
|
|
29
|
+
const msg = `long-connection channel (${longConnectionChannels.map((c) => c.name).join(", ")}) cannot run on AgentCore — there is no ` +
|
|
30
|
+
`resident process to hold the connection, and nothing wakes a reclaimed session. Switch the channel ` +
|
|
31
|
+
`to webhook mode (its events then ride the forwarder like every other channel).`;
|
|
32
|
+
if (opts.run)
|
|
33
|
+
failStartup(new Error(`deploy stopped: ${msg}`));
|
|
34
|
+
console.error(`[fastagent] warn: ${msg}`);
|
|
35
|
+
}
|
|
36
|
+
// selfSchedule is fully supported: pending wake-ups are mirrored into one-shot EventBridge
|
|
37
|
+
// schedules via the forwarder (the wake-alarm mechanism — see deploy/agentcore/plan.ts).
|
|
38
|
+
// Schedules feed EventBridge rules — parsed facts (cron/tz), not just file names, so a bad file
|
|
39
|
+
// must surface here (a schedule silently missing its rule would never fire).
|
|
40
|
+
const loaded = await loadSchedules(agentDir).catch(failStartup);
|
|
41
|
+
if (loaded.failures.length > 0) {
|
|
42
|
+
failStartup(new Error(`deploy stopped: cannot load schedules: ${loaded.failures.map((x) => `${x.label}: ${x.message}`).join("; ")}`));
|
|
43
|
+
}
|
|
44
|
+
const acName = agentcoreName(basename(workspace));
|
|
45
|
+
// Every derived AWS name embeds acName; the tightest ceiling is the Lambda function name
|
|
46
|
+
// (`fastagent-<name>-forwarder` ≤ 64 chars). Gate the base instead of silently truncating —
|
|
47
|
+
// truncation would break the redeploy identity (a renamed stack starts blank state).
|
|
48
|
+
if (acName.length > 40) {
|
|
49
|
+
failStartup(new Error(`deploy stopped: the directory name maps to "${acName}" (${acName.length} chars) — AWS resource ` +
|
|
50
|
+
`names derived from it exceed their limits past 40 chars. Deploy from a shorter directory name.`));
|
|
51
|
+
}
|
|
52
|
+
const plan = planAgentcoreDeploy({
|
|
53
|
+
name: acName,
|
|
54
|
+
modelAuth,
|
|
55
|
+
channels,
|
|
56
|
+
extraSecrets,
|
|
57
|
+
schedules: loaded.schedules.map((s) => ({ name: s.name, cron: s.cron, tz: s.tz })),
|
|
58
|
+
selfSchedule: !!config.selfSchedule,
|
|
59
|
+
...container,
|
|
60
|
+
});
|
|
61
|
+
for (const u of plan.untranslatableSchedules) {
|
|
62
|
+
// Same discipline as Fly's kept-toml time-trigger gate: a deploy whose schedule silently never
|
|
63
|
+
// fires is worse than a stopped deploy — nothing fails visibly when the instant passes.
|
|
64
|
+
const msg = `schedule "${u.name}" cannot be expressed as an EventBridge rule — ${u.reason}`;
|
|
65
|
+
if (opts.run)
|
|
66
|
+
failStartup(new Error(`deploy stopped: ${msg}`));
|
|
67
|
+
console.error(`[fastagent] warn: ${msg} — it will NOT fire on this deployment`);
|
|
68
|
+
}
|
|
69
|
+
// Host capability limit, stated at plan time. GitHub's own webhook contract is 25 MiB, but a
|
|
70
|
+
// Lambda Function URL request caps at 6 MB — so on this host a large payload cannot arrive at
|
|
71
|
+
// all. Better a sentence here than an opaque 502 the first time someone pushes a big diff.
|
|
72
|
+
if (channels.some((channel) => channel.name === "github")) {
|
|
73
|
+
console.error(`[fastagent] note: on AgentCore a webhook body is capped at ~${Math.round(MAX_WEBHOOK_BODY_BYTES / (1 << 20))} MiB ` +
|
|
74
|
+
`(the forwarder's Function URL limit); the GitHub channel accepts 25 MiB on a resident host, so the largest ` +
|
|
75
|
+
`payloads are rejected here rather than delivered`);
|
|
76
|
+
}
|
|
77
|
+
// The template IS the topology (EventBridge rules, wake wiring, secrets) — a kept generated
|
|
78
|
+
// template that no longer matches the definition would deploy a stack silently missing the
|
|
79
|
+
// difference (a new schedule with no rule never fires: the exact miss the gate above stops).
|
|
80
|
+
// A hand-written template (marker removed) is the operator's own — kept, never gated.
|
|
81
|
+
const templateArtifact = plan.artifacts.find((a) => a.path.endsWith(TEMPLATE_FILE));
|
|
82
|
+
const templateHome = join(workspace, templateArtifact?.path ?? TEMPLATE_FILE);
|
|
83
|
+
if (!opts.force && templateArtifact && (await exists(templateHome))) {
|
|
84
|
+
const existing = await readFile(templateHome, "utf8");
|
|
85
|
+
if (isGeneratedAgentcoreTemplate(existing) && existing !== templateArtifact.content) {
|
|
86
|
+
const msg = `${templateArtifact.path} no longer matches this definition (channels/schedules/selfSchedule changed) — ` +
|
|
87
|
+
`the kept template would silently drop the difference. Pass --force to regenerate (hand edits are lost), ` +
|
|
88
|
+
`or delete the file.`;
|
|
89
|
+
if (opts.run)
|
|
90
|
+
failStartup(new Error(`deploy stopped: ${msg}`));
|
|
91
|
+
console.error(`[fastagent] warn: ${msg}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
await write(plan.artifacts, {
|
|
95
|
+
force: !!opts.force,
|
|
96
|
+
alwaysWrite: [`${container.agentPrefix}${FORWARDER_FILE}`],
|
|
97
|
+
});
|
|
98
|
+
if (opts.run) {
|
|
99
|
+
return runDeployAgentcore({
|
|
100
|
+
agentDir,
|
|
101
|
+
workspace,
|
|
102
|
+
agentPrefix: container.agentPrefix,
|
|
103
|
+
name: acName,
|
|
104
|
+
modelAuth,
|
|
105
|
+
modelKeyInDefinition,
|
|
106
|
+
authPath,
|
|
107
|
+
channels,
|
|
108
|
+
extraSecrets,
|
|
109
|
+
topology: plan.topology,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
console.log(plan.runbook.join("\n"));
|
|
113
|
+
return;
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
/**
|
|
117
|
+
* `deploy agentcore --run`: drive aws + docker to completion. Mirrors the fly driver — same
|
|
118
|
+
* credential carry via {@link carryCredentials}, same runner seam (spawned `aws` + `docker`, cwd = the
|
|
119
|
+
* workspace so the build context is the agent). The AgentCore-specific sequence (identity → buildx →
|
|
120
|
+
* ECR → CloudFormation → outputs → webhooks) lives in {@link deployAgentcoreRun}; the params temp
|
|
121
|
+
* file (secret values off argv) is created here — 0600, removed after the run either way.
|
|
122
|
+
*/
|
|
123
|
+
async function runDeployAgentcore(params) {
|
|
124
|
+
const { agentDir, workspace, agentPrefix, name, channels, topology } = params;
|
|
125
|
+
const { secrets, missingSecrets, needsModelCredential } = await carryCredentials(params);
|
|
126
|
+
// The wake-alarm shared secret (container ↔ forwarder). Minted fresh each run — both sides receive
|
|
127
|
+
// the SAME parameter, so rotation is atomic; it never needs to be remembered locally.
|
|
128
|
+
if (topology.wakeAlarms)
|
|
129
|
+
secrets.FASTAGENT_WAKE_SECRET = crypto.randomUUID();
|
|
130
|
+
// The forwarder→runtime ingress secret: what makes an envelope the forwarder's rather than any IAM
|
|
131
|
+
// principal's. Minted fresh each run — both sides receive the SAME parameter, so rotation is atomic.
|
|
132
|
+
if (topology.forwarder)
|
|
133
|
+
secrets.FASTAGENT_INGRESS_SECRET = crypto.randomUUID();
|
|
134
|
+
gateOnModelCredential(needsModelCredential);
|
|
135
|
+
// The params temp dir holds the ONE file carrying secret values (file:// parameter-overrides —
|
|
136
|
+
// never argv); 0700/0600 and removed after the run, success or gate.
|
|
137
|
+
const paramsDir = await mkdtemp(join(tmpdir(), "fastagent-agentcore-"));
|
|
138
|
+
try {
|
|
139
|
+
const outcome = await deployAgentcoreRun({
|
|
140
|
+
name,
|
|
141
|
+
templatePath: `${agentPrefix}${TEMPLATE_FILE}`,
|
|
142
|
+
dockerfilePath: agentPrefix ? `${agentPrefix}Dockerfile` : undefined,
|
|
143
|
+
tag: new Date()
|
|
144
|
+
.toISOString()
|
|
145
|
+
.replace(/[-:.TZ]/g, "")
|
|
146
|
+
.slice(0, 14),
|
|
147
|
+
region: process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION,
|
|
148
|
+
secrets,
|
|
149
|
+
missingSecrets,
|
|
150
|
+
channels,
|
|
151
|
+
topology,
|
|
152
|
+
}, spawnRunner("aws", workspace), spawnRunner("docker", workspace), (m) => console.error(`[fastagent] ${m}`), async (content) => {
|
|
153
|
+
const path = join(paramsDir, "params.json");
|
|
154
|
+
await writeFile(path, content, { mode: 0o600 });
|
|
155
|
+
return path;
|
|
156
|
+
}, async (bytes) => {
|
|
157
|
+
const path = join(paramsDir, "forwarder.zip");
|
|
158
|
+
await writeFile(path, bytes);
|
|
159
|
+
return path;
|
|
160
|
+
}, registrarsFor(agentDir));
|
|
161
|
+
if (!outcome.ok)
|
|
162
|
+
failStartup(new Error(`deploy stopped: ${outcome.gate}`));
|
|
163
|
+
console.error(`[fastagent] deployed → ${outcome.runtimeArn}`);
|
|
164
|
+
if (outcome.url)
|
|
165
|
+
console.error(`[fastagent] webhook ingress → ${outcome.url}`);
|
|
166
|
+
const logsDir = shellArg(workspace);
|
|
167
|
+
console.error(`[fastagent] runtime logs → fastagent logs agentcore ${logsDir} --follow`);
|
|
168
|
+
if (topology.forwarder) {
|
|
169
|
+
console.error(`[fastagent] forwarder logs → fastagent logs agentcore ${logsDir} --source forwarder --follow`);
|
|
170
|
+
}
|
|
171
|
+
console.error(`[fastagent] invoke: aws bedrock-agentcore invoke-agent-runtime --agent-runtime-arn ${outcome.runtimeArn} \\\n` +
|
|
172
|
+
` --runtime-session-id "my-conversation-000000000000000000" \\\n` +
|
|
173
|
+
` --payload '{"kind":"invoke","session":"cli","text":"hello"}' --cli-binary-format raw-in-base64-out /dev/stdout`);
|
|
174
|
+
}
|
|
175
|
+
finally {
|
|
176
|
+
await rm(paramsDir, { recursive: true, force: true });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `deploy docker`: one app service + loopback port + state volume, as a user-owned Compose file.
|
|
3
|
+
* `--tunnel` shapes the generated topology with an optional Quick Tunnel service; `--run` alone
|
|
4
|
+
* decides whether Docker receives side effects.
|
|
5
|
+
*/
|
|
6
|
+
import { basename, join } from "node:path";
|
|
7
|
+
import { webhookPaths } from "../../../deploy/channel-ingress.js";
|
|
8
|
+
import { composeHasTunnelService, isGeneratedCompose, planDockerDeploy, toDockerProjectName, } from "../../../deploy/docker/plan.js";
|
|
9
|
+
import { deployDockerRun } from "../../../deploy/docker/run.js";
|
|
10
|
+
import { spawnRunner } from "../../../deploy/runner.js";
|
|
11
|
+
import { openExternalUrl } from "../../../open-url.js";
|
|
12
|
+
import { readTextIfExists, resolveStateRoot } from "../../../paths.js";
|
|
13
|
+
import { announceWebhooks } from "../../../tunnel.js";
|
|
14
|
+
import { failStartup } from "../../fail.js";
|
|
15
|
+
import { carryCredentials } from "./shared.js";
|
|
16
|
+
export const dockerHost = {
|
|
17
|
+
isOurs: (path, content) => path.endsWith("fastagent.compose.yml") && isGeneratedCompose(content),
|
|
18
|
+
async deploy(ctx) {
|
|
19
|
+
const { opts, agentDir, workspace, channels, webhookChannels, pre, write } = ctx;
|
|
20
|
+
const { modelAuth, modelKeyInDefinition, authPath, container, port, extraSecrets } = pre;
|
|
21
|
+
const hasDeclaredChannels = channels.length > 0;
|
|
22
|
+
const projectName = toDockerProjectName(basename(workspace));
|
|
23
|
+
const dockerPlan = (tunnel) => planDockerDeploy({
|
|
24
|
+
projectName,
|
|
25
|
+
port,
|
|
26
|
+
modelAuth,
|
|
27
|
+
channels,
|
|
28
|
+
tunnel,
|
|
29
|
+
extraSecrets,
|
|
30
|
+
...container,
|
|
31
|
+
});
|
|
32
|
+
const requestedTunnel = !!opts.tunnel && (!hasDeclaredChannels || webhookChannels.length > 0);
|
|
33
|
+
if (opts.tunnel && hasDeclaredChannels && webhookChannels.length === 0) {
|
|
34
|
+
console.error(`[fastagent] note: --tunnel skipped — every channel uses a long connection`);
|
|
35
|
+
}
|
|
36
|
+
let plan = dockerPlan(requestedTunnel);
|
|
37
|
+
// An existing Compose file is authoritative: shape its comparison/runbook from the topology on disk,
|
|
38
|
+
// regardless of the current flag. `--force` is the explicit reset to the requested generated shape.
|
|
39
|
+
const composeFile = join(workspace, plan.composePath);
|
|
40
|
+
let keptWithoutRequestedTunnel = false;
|
|
41
|
+
// Same ownership question as fly.toml: a hand-owned compose file survives --force, so the plan must
|
|
42
|
+
// describe the topology that will actually be there.
|
|
43
|
+
const composeText = await readTextIfExists(composeFile).catch(failStartup);
|
|
44
|
+
if (composeText !== undefined && (!opts.force || !isGeneratedCompose(composeText))) {
|
|
45
|
+
const existingHasTunnel = composeHasTunnelService(composeText);
|
|
46
|
+
plan = dockerPlan(existingHasTunnel);
|
|
47
|
+
keptWithoutRequestedTunnel = requestedTunnel && !existingHasTunnel;
|
|
48
|
+
}
|
|
49
|
+
await write(plan.artifacts, { force: !!opts.force });
|
|
50
|
+
if (opts.run) {
|
|
51
|
+
return runDeployDocker({
|
|
52
|
+
agentDir,
|
|
53
|
+
workspace,
|
|
54
|
+
composeFile: plan.composePath,
|
|
55
|
+
port,
|
|
56
|
+
requireTunnel: requestedTunnel,
|
|
57
|
+
modelAuth,
|
|
58
|
+
modelKeyInDefinition,
|
|
59
|
+
authPath,
|
|
60
|
+
channels,
|
|
61
|
+
extraSecrets,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
if (keptWithoutRequestedTunnel) {
|
|
65
|
+
console.error(`[fastagent] warn: --tunnel was requested but kept ${plan.composePath} has no "tunnel" service — ` +
|
|
66
|
+
`edit it, delete it and regenerate, or pass --force`);
|
|
67
|
+
}
|
|
68
|
+
console.log(plan.runbook.join("\n"));
|
|
69
|
+
return;
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* `deploy docker --run`: carry local credentials into Compose's child environment, then reconcile the
|
|
74
|
+
* user-owned local topology. Docker owns container/network/volume lifecycle. A Compose tunnel service,
|
|
75
|
+
* when present, yields an ephemeral URL that reuses the same webhook announcer as `dev --tunnel`.
|
|
76
|
+
*/
|
|
77
|
+
async function runDeployDocker(params) {
|
|
78
|
+
const { agentDir, workspace, composeFile, port, requireTunnel, channels } = params;
|
|
79
|
+
const { secrets, missingSecrets, needsModelCredential } = await carryCredentials(params);
|
|
80
|
+
const outcome = await deployDockerRun({
|
|
81
|
+
composeFile,
|
|
82
|
+
port,
|
|
83
|
+
secrets,
|
|
84
|
+
missingSecrets,
|
|
85
|
+
needsModelCredential,
|
|
86
|
+
requireTunnel,
|
|
87
|
+
announce: (tunnelUrl) => announceWebhooks(agentDir, tunnelUrl, channels, {
|
|
88
|
+
openUrl: openExternalUrl,
|
|
89
|
+
stateRoot: resolveStateRoot(agentDir),
|
|
90
|
+
}),
|
|
91
|
+
}, spawnRunner("docker", workspace), (message) => console.error(`[fastagent] ${message}`));
|
|
92
|
+
const compose = `docker compose -f ${composeFile}`;
|
|
93
|
+
// Compose reached "up" iff the driver could report where it is. The gates before that point
|
|
94
|
+
// (no Docker CLI, no daemon, missing secrets) must not be preceded by `docker compose logs`
|
|
95
|
+
// instructions for containers that do not exist; the health-check gate names its own logs command.
|
|
96
|
+
const isUp = outcome.ok || outcome.url !== undefined || outcome.tunnelUrl !== undefined;
|
|
97
|
+
if (outcome.url)
|
|
98
|
+
console.error(`[fastagent] running → ${outcome.url}`);
|
|
99
|
+
if (isUp) {
|
|
100
|
+
console.error(`[fastagent] logs: ${compose} logs -f agent`);
|
|
101
|
+
console.error(`[fastagent] stop: ${compose} down (state volume is kept)`);
|
|
102
|
+
}
|
|
103
|
+
// BEFORE failStartup: a registration gate says "re-run this deploy", and a re-run rebuilds the
|
|
104
|
+
// tunnel service — the operator needs to know the URL will be a different one, or they will read
|
|
105
|
+
// the retry as re-registering the same address.
|
|
106
|
+
if (outcome.tunnelUrl) {
|
|
107
|
+
console.error(`[fastagent] note: Quick Tunnel URLs are ephemeral — after the tunnel container/Docker daemon ` +
|
|
108
|
+
`restarts, re-run this deploy so webhooks receive the new URL`);
|
|
109
|
+
}
|
|
110
|
+
if (!outcome.ok)
|
|
111
|
+
failStartup(new Error(`deploy stopped: ${outcome.gate}`));
|
|
112
|
+
if (outcome.tunnelUrl)
|
|
113
|
+
return;
|
|
114
|
+
const paths = webhookPaths(channels);
|
|
115
|
+
if (paths.length > 0) {
|
|
116
|
+
console.error(`[fastagent] note: public ingress is operator-owned — configure your tunnel/proxy, then wire the ` +
|
|
117
|
+
`default webhook path(s): ${paths.join(", ")} (or your remapped channel routes)`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `deploy fly`: fly.toml + a state volume. KEEP (no --force): an existing fly.toml is authoritative;
|
|
3
|
+
* --force: the template is. `--run` drives flyctl to completion (idempotent, resumable).
|
|
4
|
+
*/
|
|
5
|
+
import { readFile } from "node:fs/promises";
|
|
6
|
+
import { basename, join } from "node:path";
|
|
7
|
+
import { isGeneratedFlyToml, parseFlyAppName, parseFlyMinMachines, parseFlyRegion, planFlyDeploy, toFlyAppName, } from "../../../deploy/fly/plan.js";
|
|
8
|
+
import { deployFlyRun } from "../../../deploy/fly/run.js";
|
|
9
|
+
import { spawnRunner } from "../../../deploy/runner.js";
|
|
10
|
+
import { readTextIfExists } from "../../../paths.js";
|
|
11
|
+
import { failStartup } from "../../fail.js";
|
|
12
|
+
import { carryCredentials, gateOnModelCredential, registrarsFor } from "./shared.js";
|
|
13
|
+
export const flyHost = {
|
|
14
|
+
isOurs: (path, content) => path.endsWith("fly.toml") && isGeneratedFlyToml(content),
|
|
15
|
+
async deploy(ctx) {
|
|
16
|
+
const { opts, agentDir, workspace, channels, longConnectionChannels, pre, write } = ctx;
|
|
17
|
+
const { hasTimeTriggers, modelAuth, modelKeyInDefinition, authPath, container, port, extraSecrets } = pre;
|
|
18
|
+
// The replay floor that makes scale-to-zero safe is Telegram-only (its L1 turn store). GitHub turns
|
|
19
|
+
// are fire-and-forget (no replay), so the generated fly.toml keeps one machine running for them —
|
|
20
|
+
// a note, not a warn, since the plan already did the safe thing (definition-aware autostop).
|
|
21
|
+
if (channels.some((channel) => channel.name === "github")) {
|
|
22
|
+
console.error(`[fastagent] note: github turns have no replay — the generated fly.toml uses min_machines_running=1 ` +
|
|
23
|
+
`(no scale-to-zero) so autostop can't drop an in-flight review. Set it to 0 to accept that trade.`);
|
|
24
|
+
}
|
|
25
|
+
// Two consistent modes. KEEP (no --force): an existing fly.toml is authoritative — not rewritten,
|
|
26
|
+
// and the runbook reads its `app=` (Fly app names are globally unique, so the basename guess may be
|
|
27
|
+
// taken and the user renamed it). --force: the template is authoritative — the WHOLE fly.toml resets
|
|
28
|
+
// (app→basename, region→iad, vm→defaults), so we do NOT round-trip `app` and warn that hand edits go.
|
|
29
|
+
// fly.toml lives in the agent dir (fastagent/fly.toml) — the workspace's own
|
|
30
|
+
// fly.toml (if any) belongs to the host's product deploy and is never read or written here.
|
|
31
|
+
const flyTomlPath = join(agentDir, "fly.toml");
|
|
32
|
+
const flyToml = await readTextIfExists(flyTomlPath).catch(failStartup);
|
|
33
|
+
// Every decision below turns on ONE question — will `writeArtifacts` keep this file? — and the answer is
|
|
34
|
+
// OWNERSHIP, not the flag: `--force` resets a fly.toml we generated and leaves a hand-written one alone.
|
|
35
|
+
// Keying these on `--force` alone meant a forced deploy of a hand-owned fly.toml took the app name from
|
|
36
|
+
// the directory basename (shipping that file at a DIFFERENT app than it declares) and skipped the
|
|
37
|
+
// scale-to-zero gate below (a schedules deploy that silently sleeps). Read once, decide once.
|
|
38
|
+
const flyTomlKept = flyToml !== undefined && (!opts.force || !isGeneratedFlyToml(flyToml));
|
|
39
|
+
const keptApp = flyTomlKept ? parseFlyAppName(flyToml) : undefined;
|
|
40
|
+
const appName = keptApp ?? toFlyAppName(basename(workspace));
|
|
41
|
+
if (keptApp)
|
|
42
|
+
console.error(`[fastagent] app: ${keptApp} (from fly.toml)`);
|
|
43
|
+
if (flyToml !== undefined && !flyTomlKept) {
|
|
44
|
+
console.error(`[fastagent] warn: --force resets fly.toml to defaults (app, region, vm) — re-apply any hand edits`);
|
|
45
|
+
}
|
|
46
|
+
// Autostop flags shape the GENERATED fly.toml only. In KEEP mode (fly.toml exists, no --force) it is
|
|
47
|
+
// not rewritten, so the flags would silently do nothing — surface that instead of a confusing no-op.
|
|
48
|
+
if (flyTomlKept && (opts.stop || opts.scaleToZero === false)) {
|
|
49
|
+
console.error(`[fastagent] warn: --stop/--no-scale-to-zero only shape a freshly generated fly.toml — yours exists and ` +
|
|
50
|
+
`was kept. Edit auto_stop_machines/min_machines_running in fly.toml, or pass --force to regenerate.`);
|
|
51
|
+
}
|
|
52
|
+
// KEEP mode + time triggers: the kept fly.toml may still scale to zero — which would sleep through every
|
|
53
|
+
// cron instant / wake-up. The generated plan can't fix a kept file, so surface it instead of the preflight
|
|
54
|
+
// note silently not applying (the author who deployed FIRST and added schedules LATER hits exactly this).
|
|
55
|
+
// Under `--run` this is a GATE (same discipline as the model-travel gate): a full deploy whose schedules
|
|
56
|
+
// silently never fire is worse than a crash-loop — nothing fails visibly when a cron instant passes on a
|
|
57
|
+
// sleeping machine, and unlike github's min=0 there is no legitimate trade to accept here.
|
|
58
|
+
if (flyTomlKept && (hasTimeTriggers || longConnectionChannels.length > 0)) {
|
|
59
|
+
const min = parseFlyMinMachines(flyToml);
|
|
60
|
+
if ((min ?? 0) === 0) {
|
|
61
|
+
// undefined = the line is absent — Fly's platform default for min_machines_running is 0, so a
|
|
62
|
+
// hand-written fly.toml without the line scales to zero exactly like an explicit 0.
|
|
63
|
+
const reason = hasTimeTriggers
|
|
64
|
+
? `schedules/self-scheduling need a running machine (no external wake-up)`
|
|
65
|
+
: `long-connection channel (${longConnectionChannels.map((c) => c.name).join(", ")}) needs an always-on outbound connection`;
|
|
66
|
+
const msg = `your kept fly.toml scales to zero (min_machines_running = ${min ?? "absent → platform default 0"}), but ` +
|
|
67
|
+
`${reason}. Set min_machines_running = 1, or pass --force to regenerate.`;
|
|
68
|
+
if (opts.run)
|
|
69
|
+
failStartup(new Error(`deploy stopped: ${msg}`));
|
|
70
|
+
console.error(`[fastagent] warn: ${msg}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const plan = planFlyDeploy({
|
|
74
|
+
appName,
|
|
75
|
+
port,
|
|
76
|
+
modelAuth,
|
|
77
|
+
channels,
|
|
78
|
+
extraSecrets,
|
|
79
|
+
hasTimeTriggers,
|
|
80
|
+
...container,
|
|
81
|
+
autostop: opts.stop ? "stop" : "suspend",
|
|
82
|
+
scaleToZero: opts.scaleToZero !== false,
|
|
83
|
+
});
|
|
84
|
+
await write(plan.artifacts, { force: !!opts.force });
|
|
85
|
+
if (opts.run) {
|
|
86
|
+
return runDeployFly({
|
|
87
|
+
agentDir,
|
|
88
|
+
workspace,
|
|
89
|
+
agentPrefix: container.agentPrefix,
|
|
90
|
+
appName,
|
|
91
|
+
modelAuth,
|
|
92
|
+
modelKeyInDefinition,
|
|
93
|
+
authPath,
|
|
94
|
+
channels,
|
|
95
|
+
flyTomlPath,
|
|
96
|
+
extraSecrets,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
console.log(plan.runbook.join("\n"));
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
/**
|
|
103
|
+
* `deploy fly --run`: drive flyctl to completion (idempotent, resumable). Gathers the secret VALUES
|
|
104
|
+
* from the local env — the model key (env auth) or the whole auth.json as a `FASTAGENT_AUTH_SEED` seed
|
|
105
|
+
* (OAuth/stored auth: the deployed box materializes it onto the /data volume on first boot, so a
|
|
106
|
+
* personal deploy runs on the SAME subscription) plus channel secrets — then runs the flyctl steps
|
|
107
|
+
* behind the shared {@link spawnRunner} seam (spawned `fly`, cwd = the workspace so the build context is the whole workspace).
|
|
108
|
+
*/
|
|
109
|
+
async function runDeployFly(params) {
|
|
110
|
+
const { agentDir, workspace, agentPrefix, appName, channels, flyTomlPath } = params;
|
|
111
|
+
const fly = spawnRunner("fly", workspace);
|
|
112
|
+
// Fail fast if flyctl is absent (spawn ENOENT → 127), with the install link — not a confusing auth gate.
|
|
113
|
+
if ((await fly(["version"], { capture: true })).code === 127) {
|
|
114
|
+
failStartup(new Error(`flyctl not found — install it: https://fly.io/docs/flyctl/install, then re-run`));
|
|
115
|
+
}
|
|
116
|
+
const region = parseFlyRegion(await readFile(flyTomlPath, "utf8")) ?? "iad";
|
|
117
|
+
const { secrets, missingSecrets, needsModelCredential } = await carryCredentials(params);
|
|
118
|
+
gateOnModelCredential(needsModelCredential);
|
|
119
|
+
const outcome = await deployFlyRun({
|
|
120
|
+
appName,
|
|
121
|
+
region,
|
|
122
|
+
secrets,
|
|
123
|
+
missingSecrets,
|
|
124
|
+
channels,
|
|
125
|
+
flyConfig: `${agentPrefix}fly.toml`,
|
|
126
|
+
dockerfile: `${agentPrefix}Dockerfile`,
|
|
127
|
+
}, fly, (m) => console.error(`[fastagent] ${m}`), registrarsFor(agentDir));
|
|
128
|
+
if (!outcome.ok)
|
|
129
|
+
failStartup(new Error(`deploy stopped: ${outcome.gate}`));
|
|
130
|
+
console.error(`[fastagent] deployed → https://${appName}.fly.dev`);
|
|
131
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `deploy railway`: a thin config file, scale-to-zero is a manual dashboard step, the URL is minted
|
|
3
|
+
* (see planRailwayDeploy). `--run` drives the railway CLI to completion; otherwise print the runbook.
|
|
4
|
+
*/
|
|
5
|
+
import { basename } from "node:path";
|
|
6
|
+
import { dockerfilePathVar, isGeneratedRailwayJson, planRailwayDeploy, toRailwayName, } from "../../../deploy/railway/plan.js";
|
|
7
|
+
import { deployRailwayRun } from "../../../deploy/railway/run.js";
|
|
8
|
+
import { spawnRunner } from "../../../deploy/runner.js";
|
|
9
|
+
import { failStartup } from "../../fail.js";
|
|
10
|
+
import { carryCredentials, gateOnModelCredential, registrarsFor } from "./shared.js";
|
|
11
|
+
export const railwayHost = {
|
|
12
|
+
isOurs: (path, content) => path.endsWith("railway.json") && isGeneratedRailwayJson(content),
|
|
13
|
+
async deploy(ctx) {
|
|
14
|
+
const { opts, agentDir, workspace, pre, channels, write } = ctx;
|
|
15
|
+
const { hasTimeTriggers, modelAuth, modelKeyInDefinition, authPath, container, extraSecrets } = pre;
|
|
16
|
+
const serviceName = toRailwayName(basename(workspace));
|
|
17
|
+
const plan = planRailwayDeploy({
|
|
18
|
+
serviceName,
|
|
19
|
+
modelAuth,
|
|
20
|
+
channels,
|
|
21
|
+
extraSecrets,
|
|
22
|
+
hasTimeTriggers,
|
|
23
|
+
...container,
|
|
24
|
+
});
|
|
25
|
+
await write(plan.artifacts, { force: !!opts.force });
|
|
26
|
+
if (opts.run) {
|
|
27
|
+
// The BUILD entry is guaranteed by the RAILWAY_DOCKERFILE_PATH service variable the runner sets
|
|
28
|
+
// (Railway's documented non-root-Dockerfile route), and Railway's default restart policy already
|
|
29
|
+
// equals the file's ON_FAILURE — the dashboard-only Config-as-code pointer only adds the /health
|
|
30
|
+
// deploy gate (boot-crash visibility), so it is an OPTIONAL note, not a gate.
|
|
31
|
+
console.error(`[fastagent] note: optional — point the service at fastagent/railway.json (Service → Settings → ` +
|
|
32
|
+
`Config-as-code, dashboard-only) so the /health healthcheck marks a boot-crashing deploy as FAILED; ` +
|
|
33
|
+
`the build already uses fastagent/Dockerfile via the RAILWAY_DOCKERFILE_PATH variable`);
|
|
34
|
+
return runDeployRailway({
|
|
35
|
+
agentDir,
|
|
36
|
+
workspace,
|
|
37
|
+
name: serviceName,
|
|
38
|
+
modelAuth,
|
|
39
|
+
modelKeyInDefinition,
|
|
40
|
+
authPath,
|
|
41
|
+
channels,
|
|
42
|
+
extraSecrets,
|
|
43
|
+
intoLinked: !!opts.intoLinked,
|
|
44
|
+
dockerfilePath: dockerfilePathVar(pre.container.agentPrefix),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
console.log(plan.runbook.join("\n"));
|
|
48
|
+
return;
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* `deploy railway --run`: drive the railway CLI to completion. Mirrors the fly driver — same
|
|
53
|
+
* credential carry (env key OR the OAuth auth.json as `FASTAGENT_AUTH_SEED`) via {@link carryCredentials},
|
|
54
|
+
* same runner seam (spawned `railway`, cwd = the workspace so `railway up`'s upload is the whole workspace). The
|
|
55
|
+
* Railway-specific sequence (linked-check → init/add/volume when fresh → variables → up → domain →
|
|
56
|
+
* webhook) lives in {@link deployRailwayRun}; see there for why Railway differs from Fly.
|
|
57
|
+
*/
|
|
58
|
+
async function runDeployRailway(params) {
|
|
59
|
+
const { agentDir, workspace, name, channels, intoLinked, dockerfilePath } = params;
|
|
60
|
+
const railway = spawnRunner("railway", workspace);
|
|
61
|
+
// Fail fast if the railway CLI is absent (spawn ENOENT → 127), with the install link.
|
|
62
|
+
if ((await railway(["--version"], { capture: true })).code === 127) {
|
|
63
|
+
failStartup(new Error(`railway CLI not found — install it: https://docs.railway.com/guides/cli, then re-run`));
|
|
64
|
+
}
|
|
65
|
+
const { secrets, missingSecrets, needsModelCredential } = await carryCredentials(params);
|
|
66
|
+
gateOnModelCredential(needsModelCredential);
|
|
67
|
+
const outcome = await deployRailwayRun({ name, mountPath: "/data", secrets, missingSecrets, channels, intoLinked, dockerfilePath }, railway, (m) => console.error(`[fastagent] ${m}`), registrarsFor(agentDir));
|
|
68
|
+
if (!outcome.ok)
|
|
69
|
+
failStartup(new Error(`deploy stopped: ${outcome.gate}`));
|
|
70
|
+
console.error(`[fastagent] deployed → ${outcome.url}`);
|
|
71
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { DeclaredChannel } from "../../../channels/discover.ts";
|
|
2
|
+
import type { Registrars } from "../../../deploy/channel-ingress.ts";
|
|
3
|
+
import type { DeployPreflight } from "../../../deploy/preflight.ts";
|
|
4
|
+
import type { FastagentConfig } from "../../../engines/pi/config.ts";
|
|
5
|
+
export interface DeployOptions {
|
|
6
|
+
run?: boolean;
|
|
7
|
+
tunnel?: boolean;
|
|
8
|
+
force?: boolean;
|
|
9
|
+
stop?: boolean;
|
|
10
|
+
/** false ⇔ `--no-scale-to-zero`. */
|
|
11
|
+
scaleToZero?: boolean;
|
|
12
|
+
intoLinked?: boolean;
|
|
13
|
+
model?: string;
|
|
14
|
+
authPath?: string;
|
|
15
|
+
/** false ⇔ `--no-input`. */
|
|
16
|
+
input?: boolean;
|
|
17
|
+
}
|
|
18
|
+
/** What the dispatcher resolved before handing off: the placement, the flags, the config and the
|
|
19
|
+
* host-neutral pre-flight, plus the channel lists every host asks about. */
|
|
20
|
+
interface DeployContext {
|
|
21
|
+
opts: DeployOptions;
|
|
22
|
+
agentDir: string;
|
|
23
|
+
workspace: string;
|
|
24
|
+
config: FastagentConfig;
|
|
25
|
+
pre: Extract<DeployPreflight, {
|
|
26
|
+
ok: true;
|
|
27
|
+
}>;
|
|
28
|
+
channels: readonly DeclaredChannel[];
|
|
29
|
+
webhookChannels: readonly DeclaredChannel[];
|
|
30
|
+
longConnectionChannels: readonly DeclaredChannel[];
|
|
31
|
+
/** Write this host's planned artifacts into the workspace under the ownership rule. The dispatcher
|
|
32
|
+
* binds {@link HostDeploy.isOurs} — a host cannot name a predicate here, because every host's has
|
|
33
|
+
* the same type: passing a neighbour's, or forgetting a new host's, type-checks and then silently
|
|
34
|
+
* turns `--force` into a no-op for that host's own artifact. */
|
|
35
|
+
write(artifacts: {
|
|
36
|
+
path: string;
|
|
37
|
+
content: string;
|
|
38
|
+
}[], options: {
|
|
39
|
+
force: boolean;
|
|
40
|
+
alwaysWrite?: string[];
|
|
41
|
+
}): Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
/** One deploy target, as the dispatcher sees it. A new host is a `deploy/<host>/` (plan + driver)
|
|
44
|
+
* plus one of these beside it, and a row in `HOSTS` (cli/commands/deploy.ts). */
|
|
45
|
+
export interface HostDeploy {
|
|
46
|
+
/** Did this host generate the file at `path`? Only the host's OWN artifacts — the container's
|
|
47
|
+
* (Dockerfile, .dockerignore) are answered by {@link writeArtifacts} itself. Read only through the
|
|
48
|
+
* {@link DeployContext.write} the dispatcher binds from it. */
|
|
49
|
+
isOurs(path: string, content: string): boolean;
|
|
50
|
+
/** Plan the artifacts from the pre-flight facts and what is on disk, write them, then either drive
|
|
51
|
+
* the host CLI (`--run`) or print the runbook. Exits through `failStartup` on a gate. */
|
|
52
|
+
deploy(ctx: DeployContext): Promise<void>;
|
|
53
|
+
}
|
|
54
|
+
/** The registrars every host driver gets. One wiring: a channel's credentials are the channel's, not
|
|
55
|
+
* the host's, so which of them can run end-to-end never varies by deployment target.
|
|
56
|
+
*
|
|
57
|
+
* All three get {@link DEPLOY_REGISTRATION_ATTEMPTS} rather than the default: a host CLI returns
|
|
58
|
+
* before the deployment answers, and a registration that gives up first GATES the deploy — reporting
|
|
59
|
+
* a working deployment as one to re-run. `dev --tunnel` keeps the shorter default; it is a resident
|
|
60
|
+
* process whose URL is live when it is printed, as does `deploy docker --run` — it reuses that same
|
|
61
|
+
* announcer, having already health-probed the container and waited for the Quick Tunnel URL.
|
|
62
|
+
*
|
|
63
|
+
* Accepted cost: pointChannelsAt registers serially, so a deployment whose channels are ALL
|
|
64
|
+
* unreachable spends the budget once per channel (3 x 180s) before it gates. Registering in parallel
|
|
65
|
+
* would not shorten a single failing channel, and the failing case is the one nobody is waiting on. */
|
|
66
|
+
export declare function registrarsFor(agentDir: string): Registrars;
|
|
67
|
+
/**
|
|
68
|
+
* The `--run` credential carry, for every host: the local model credential (an env key, or the whole
|
|
69
|
+
* auth.json as a `FASTAGENT_AUTH_SEED`) plus channel secrets. Four drivers assembled this identically.
|
|
70
|
+
*/
|
|
71
|
+
export declare function carryCredentials(params: {
|
|
72
|
+
modelAuth: string | undefined;
|
|
73
|
+
modelKeyInDefinition: boolean;
|
|
74
|
+
authPath: string;
|
|
75
|
+
channels: readonly DeclaredChannel[];
|
|
76
|
+
extraSecrets: string[];
|
|
77
|
+
}): Promise<{
|
|
78
|
+
secrets: Record<string, string>;
|
|
79
|
+
missingSecrets: string[];
|
|
80
|
+
needsModelCredential: boolean;
|
|
81
|
+
}>;
|
|
82
|
+
/** Gate a `--run` that has no model credential to carry. Its remediation is `fastagent login`, which
|
|
83
|
+
* is why it is separate from `missingSecrets` (a `.env` fix) rather than folded into it. Docker states
|
|
84
|
+
* the same gate inside its driver, where it belongs after the daemon check. */
|
|
85
|
+
export declare function gateOnModelCredential(needsModelCredential: boolean): void;
|
|
86
|
+
/**
|
|
87
|
+
* Write each generated artifact under `target`, under ONE ownership rule: **deploy only ever overwrites
|
|
88
|
+
* its own output.** Each generated file opens with a marker, so a file on disk is either OURS (a previous
|
|
89
|
+
* `deploy` wrote it) or the author's — and `--force` means "my generated artifact is authoritative",
|
|
90
|
+
* which never licenses clobbering a file we did not write. To hand a path back to fastagent, delete it.
|
|
91
|
+
*
|
|
92
|
+
* That single rule replaced a second mechanism (a per-host list of paths exempt from `--force`), which
|
|
93
|
+
* only described one shape: with the agent inside the workspace, the root `.dockerignore` is the
|
|
94
|
+
* WORKSPACE's file and was listed; with the agent flat, the list was empty — so `--force` would have
|
|
95
|
+
* overwritten a hand-written root `Dockerfile` in a repository `init --flat` had explicitly adopted.
|
|
96
|
+
* Ownership is a property of the file, not of where the agent sits.
|
|
97
|
+
*
|
|
98
|
+
* A KEPT file of OURS that no longer matches what deploy would generate now (config/channel/lockfile/
|
|
99
|
+
* version drift — or the author's edits, which we cannot tell apart) is flagged stale; `--force`
|
|
100
|
+
* regenerates that one.
|
|
101
|
+
*
|
|
102
|
+
* Exported for its own test — this is a four-branch state machine over (exists, ours, force) that used
|
|
103
|
+
* to be proven by spawning the CLI eight times, which is command LOGIC re-run through a subprocess
|
|
104
|
+
* (see vitest.config.ts) and the suite's slowest test.
|
|
105
|
+
*/
|
|
106
|
+
export declare function writeArtifacts(target: string, artifacts: {
|
|
107
|
+
path: string;
|
|
108
|
+
content: string;
|
|
109
|
+
}[], options: {
|
|
110
|
+
force: boolean;
|
|
111
|
+
alwaysWrite?: string[];
|
|
112
|
+
isOurs: HostDeploy["isOurs"];
|
|
113
|
+
}): Promise<void>;
|
|
114
|
+
export {};
|