@fastagent-sh/fastagent 0.22.1 → 0.24.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/dist/agent.d.ts +10 -0
- package/dist/channels/agentcore-protocol.d.ts +2 -1
- package/dist/channels/agentcore-service.js +10 -18
- package/dist/channels/agentcore.js +6 -3
- package/dist/channels/control.js +23 -0
- package/dist/channels/kit/preview-kit.d.ts +6 -1
- package/dist/channels/kit/preview-kit.js +21 -4
- package/dist/channels/slack/config-api.d.ts +2 -2
- package/dist/channels/slack/config-api.js +2 -2
- package/dist/channels/slack/onboard.js +2 -2
- package/dist/channels/slack/preview.js +2 -1
- package/dist/channels/slack/register-webhook.js +2 -2
- package/dist/channels/slack/slack-api.js +4 -4
- package/dist/cli/add-feishu.js +4 -2
- package/dist/cli/auth-view.d.ts +15 -6
- package/dist/cli/auth-view.js +24 -8
- package/dist/cli/box-login.d.ts +33 -0
- package/dist/cli/box-login.js +162 -0
- package/dist/cli/commands/deploy/agentcore.js +48 -11
- package/dist/cli/commands/deploy/docker.js +24 -13
- package/dist/cli/commands/deploy/fly.js +30 -7
- package/dist/cli/commands/deploy/railway.js +21 -7
- package/dist/cli/commands/deploy/shared.d.ts +18 -17
- package/dist/cli/commands/deploy/shared.js +20 -21
- package/dist/cli/commands/destroy.js +2 -2
- package/dist/cli/commands/info.js +15 -12
- package/dist/cli/commands/invoke.js +2 -2
- package/dist/cli/commands/login.d.ts +7 -1
- package/dist/cli/commands/login.js +123 -10
- package/dist/cli/commands/logs.js +2 -2
- package/dist/cli/commands/models.d.ts +4 -1
- package/dist/cli/commands/models.js +37 -4
- package/dist/cli/commands/routine-run.js +2 -2
- package/dist/cli/commands/start.d.ts +0 -5
- package/dist/cli/commands/start.js +3 -13
- package/dist/cli/invoke-stream.js +14 -0
- package/dist/cli/kernel.d.ts +2 -0
- package/dist/cli/kernel.js +1 -1
- package/dist/cli/login-relay.d.ts +38 -0
- package/dist/cli/login-relay.js +121 -0
- package/dist/cli/program.js +46 -8
- package/dist/cli/shared.d.ts +6 -5
- package/dist/cli/shared.js +29 -36
- package/dist/deploy/agentcore/plan.d.ts +10 -11
- package/dist/deploy/agentcore/plan.js +8 -14
- package/dist/deploy/agentcore/run.d.ts +8 -1
- package/dist/deploy/agentcore/run.js +20 -14
- package/dist/deploy/agentcore/shell.d.ts +25 -0
- package/dist/deploy/agentcore/shell.js +202 -0
- package/dist/deploy/box-shell.d.ts +43 -0
- package/dist/deploy/box-shell.js +28 -0
- package/dist/deploy/build-context.js +16 -7
- package/dist/deploy/channel-ingress.d.ts +20 -1
- package/dist/deploy/channel-ingress.js +34 -7
- package/dist/deploy/container.d.ts +14 -0
- package/dist/deploy/container.js +45 -1
- package/dist/deploy/docker/plan.d.ts +7 -3
- package/dist/deploy/docker/plan.js +9 -14
- package/dist/deploy/docker/run.d.ts +14 -7
- package/dist/deploy/docker/run.js +58 -35
- package/dist/deploy/fly/plan.d.ts +6 -3
- package/dist/deploy/fly/plan.js +6 -8
- package/dist/deploy/fly/run.d.ts +8 -2
- package/dist/deploy/fly/run.js +15 -25
- package/dist/deploy/preflight.d.ts +9 -7
- package/dist/deploy/preflight.js +92 -20
- package/dist/deploy/railway/plan.d.ts +6 -3
- package/dist/deploy/railway/plan.js +7 -9
- package/dist/deploy/railway/run.d.ts +10 -6
- package/dist/deploy/railway/run.js +29 -13
- package/dist/deploy/runner.d.ts +16 -0
- package/dist/deploy/runner.js +31 -0
- package/dist/deploy/secrets.d.ts +4 -13
- package/dist/deploy/secrets.js +8 -27
- package/dist/dev-supervisor.js +7 -4
- package/dist/engines/pi/agent-models.d.ts +83 -0
- package/dist/engines/pi/agent-models.js +120 -0
- package/dist/engines/pi/agent-session-factory.d.ts +13 -13
- package/dist/engines/pi/agent-session-factory.js +132 -159
- package/dist/engines/pi/auth.d.ts +56 -3
- package/dist/engines/pi/auth.js +68 -109
- package/dist/engines/pi/config.d.ts +0 -25
- package/dist/engines/pi/config.js +3 -37
- package/dist/engines/pi/create.d.ts +34 -21
- package/dist/engines/pi/create.js +90 -57
- package/dist/engines/pi/entry-images.d.ts +14 -0
- package/dist/engines/pi/entry-images.js +23 -0
- package/dist/engines/pi/invoke-session.js +88 -20
- package/dist/engines/pi/locked-file.d.ts +9 -0
- package/dist/engines/pi/locked-file.js +102 -0
- package/dist/engines/pi/login.d.ts +27 -14
- package/dist/engines/pi/login.js +41 -18
- package/dist/engines/pi/machine.d.ts +23 -0
- package/dist/engines/pi/machine.js +24 -2
- package/dist/engines/pi/models.d.ts +120 -36
- package/dist/engines/pi/models.js +316 -66
- package/dist/engines/pi/open.d.ts +65 -20
- package/dist/engines/pi/open.js +88 -34
- package/dist/engines/pi/report.d.ts +3 -0
- package/dist/engines/pi/report.js +11 -0
- package/dist/engines/pi/service.d.ts +2 -2
- package/dist/engines/pi/service.js +1 -0
- package/dist/engines/pi/session-builder.d.ts +3 -2
- package/dist/engines/pi/session-builder.js +30 -76
- package/dist/engines/pi/session-control.d.ts +4 -4
- package/dist/engines/pi/session-control.js +86 -43
- package/dist/engines/pi/session-settings.d.ts +9 -0
- package/dist/engines/pi/session-settings.js +17 -0
- package/dist/engines/pi/session-store.d.ts +2 -2
- package/dist/engines/pi/tool-context.d.ts +3 -5
- package/dist/engines/pi/tool-context.js +4 -16
- package/dist/engines/pi/tool.d.ts +32 -21
- package/dist/engines/pi/tool.js +21 -25
- package/dist/engines/pi/turn-kit.d.ts +18 -9
- package/dist/engines/pi/turn-kit.js +115 -32
- package/dist/open-url.d.ts +15 -1
- package/dist/open-url.js +35 -3
- package/dist/paths.d.ts +4 -2
- package/dist/paths.js +6 -2
- package/dist/pi.d.ts +5 -3
- package/dist/pi.js +3 -2
- package/dist/service.d.ts +5 -8
- package/dist/session-remote.js +21 -0
- package/dist/session.d.ts +88 -15
- package/dist/session.js +5 -1
- package/package.json +6 -5
- package/dist/engines/pi/search-tools.d.ts +0 -4
- package/dist/engines/pi/search-tools.js +0 -109
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A running box's owner-authenticated shell, as a byte channel: what `fastagent login --deployment` speaks through.
|
|
3
|
+
* Host-neutral: Docker, Fly and Railway open it by running their own CLI (`processShell`); AgentCore opens a
|
|
4
|
+
* WebSocket (`agentcore/shell.ts`). Whoever opens one only writes bytes in and reads bytes out.
|
|
5
|
+
*/
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
/** A host CLI that runs a command on the box itself: `bin ...args(command)`, run from `cwd`. */
|
|
8
|
+
export function processShell(bin, args, cwd) {
|
|
9
|
+
return {
|
|
10
|
+
async open(command) {
|
|
11
|
+
const child = spawn(bin, args(command), { cwd, stdio: ["pipe", "pipe", "inherit"] });
|
|
12
|
+
// The box may exit before reading an answer; its result (or its absence) is what gets reported, not the EPIPE.
|
|
13
|
+
child.stdin.on("error", () => { });
|
|
14
|
+
const closed = new Promise((resolve) => {
|
|
15
|
+
child.on("error", (error) => resolve({ opened: false, error: `could not run ${bin}: ${error.message}` }));
|
|
16
|
+
child.on("close", (code, signal) => resolve({ opened: true, how: signal ? `signal ${signal}` : `exit ${code}` }));
|
|
17
|
+
});
|
|
18
|
+
return { output: child.stdout, input: child.stdin, closed };
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The command that logs a deployment in, as every message and runbook names it: with the provider when it is known,
|
|
24
|
+
* because the model needs THAT one, and a menu invites another.
|
|
25
|
+
*/
|
|
26
|
+
export function deploymentLoginCommand(host, provider) {
|
|
27
|
+
return `fastagent login ${provider ? `${provider} ` : ""}--deployment ${host}`;
|
|
28
|
+
}
|
|
@@ -1,8 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* What a deploy's BUILD CONTEXT (the workspace) holds that must not ship, and whether an ignore file the author kept
|
|
3
|
-
* still keeps it out. A kept workspace-root `.dockerignore` silently replaces the generated one's protections, so the
|
|
4
|
-
* pre-flight asks it the same questions the generated one answers by construction.
|
|
5
|
-
*/
|
|
6
1
|
import { readdir } from "node:fs/promises";
|
|
7
2
|
import { basename, isAbsolute, join, relative, sep } from "node:path";
|
|
8
3
|
import ignore from "ignore";
|
|
@@ -29,6 +24,20 @@ function dockerignoreMatcher(text) {
|
|
|
29
24
|
const matcher = ignore({ ignorecase: false }).add(anchored);
|
|
30
25
|
return (path) => matcher.ignores(path);
|
|
31
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* A directory's entries, or none when it does not exist. Any other failure throws: the leak scan would otherwise pass
|
|
29
|
+
* on a directory it never read, and a credential inside it would ship without a word.
|
|
30
|
+
*/
|
|
31
|
+
async function entriesIfExists(path) {
|
|
32
|
+
try {
|
|
33
|
+
return await readdir(path, { withFileTypes: true });
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
if (error.code === "ENOENT")
|
|
37
|
+
return [];
|
|
38
|
+
throw new Error(`cannot read ${path} to check what the image would ship: ${error.message}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
32
41
|
export async function buildContextPaths(workspace, agentDir, agentPrefix, authPath) {
|
|
33
42
|
const inContext = (p) => {
|
|
34
43
|
const rel = relative(workspace, p);
|
|
@@ -55,7 +64,7 @@ export async function buildContextPaths(workspace, agentDir, agentPrefix, authPa
|
|
|
55
64
|
const stateShips = stateRel !== undefined && (await exists(join(workspace, stateRel))) ? stateRel : undefined;
|
|
56
65
|
// The `.env` family at the two levels fastagent is RESPONSIBLE for: the agent dir and the workspace root.
|
|
57
66
|
const dotEnvFiles = async (relDir) => {
|
|
58
|
-
const names = await
|
|
67
|
+
const names = (await entriesIfExists(join(workspace, relDir || "."))).map((entry) => entry.name);
|
|
59
68
|
// POSIX separators, like every other context-relative path here (`inContext`).
|
|
60
69
|
return names
|
|
61
70
|
.filter((n) => (n === ".env" || n.startsWith(".env.")) && n !== ".env.example")
|
|
@@ -65,7 +74,7 @@ export async function buildContextPaths(workspace, agentDir, agentPrefix, authPa
|
|
|
65
74
|
// Everything ACTUALLY inside the secrets dir, minus the two tracked scaffolds the image ships on purpose (they
|
|
66
75
|
// carry no values; the generated ignore re-includes them by name).
|
|
67
76
|
const secretDirFiles = async (dirRel) => {
|
|
68
|
-
const entries = await
|
|
77
|
+
const entries = await entriesIfExists(join(workspace, dirRel));
|
|
69
78
|
const files = [];
|
|
70
79
|
for (const entry of entries) {
|
|
71
80
|
if (entry.name === ".gitignore" || entry.name === ".env.example")
|
|
@@ -37,7 +37,8 @@ export type PublicHealthProbe = (healthUrl: string) => Promise<boolean>;
|
|
|
37
37
|
* report success. The channels that DO verify (Slack/Feishu challenge) would fail here too, but as "registration
|
|
38
38
|
* failed" — a diagnosis that hides the actual cause.
|
|
39
39
|
*
|
|
40
|
-
* Asked ONLY when this deployment actually has a webhook to point
|
|
40
|
+
* Asked ONLY when this deployment actually has a webhook to point, or a login on the box follows (the box must
|
|
41
|
+
* have prepared its workspace and installed the CLI the login runs). A definition with neither (schedules only, the
|
|
41
42
|
* built-in `POST /invoke`, or long-connection channels) needs no inbound reachability at all, and demanding it would
|
|
42
43
|
* invent a failure for a deploy run from a network that cannot reach the platform's edge.
|
|
43
44
|
*/
|
|
@@ -48,7 +49,25 @@ export declare function publicHealthGate(input: {
|
|
|
48
49
|
/** How THIS host is inspected and re-run — the only per-host words in the gate. */
|
|
49
50
|
inspectHint: string;
|
|
50
51
|
probe?: PublicHealthProbe;
|
|
52
|
+
/** The login that follows on the box (`fastagent login --deployment <host>`), when one does. */
|
|
53
|
+
login?: string;
|
|
51
54
|
}): Promise<string | undefined>;
|
|
55
|
+
/**
|
|
56
|
+
* The gate when the login on the box did not happen. It stops the run BEFORE registration (a channel pointed at a box
|
|
57
|
+
* with no model credential fails every message), so the login it asks for does not register anything by itself: with
|
|
58
|
+
* a webhook to point, the refusal says what comes after the login, and prints the lines to do it by hand when the
|
|
59
|
+
* URL is known.
|
|
60
|
+
*/
|
|
61
|
+
export declare function loginGate(input: {
|
|
62
|
+
/** What the login reported, ending with the command to run. */
|
|
63
|
+
notLoggedIn: string;
|
|
64
|
+
channels: readonly DeclaredChannel[];
|
|
65
|
+
log: (msg: string) => void;
|
|
66
|
+
/** What THIS host does about its webhooks once the box is logged in — the only per-host words in the gate. */
|
|
67
|
+
afterLogin: string;
|
|
68
|
+
/** Where the channels point; unset when the host has not minted it yet (Docker's tunnel is read after). */
|
|
69
|
+
baseUrl?: string;
|
|
70
|
+
}): string;
|
|
52
71
|
/** {@link pointChannelsAt} plus the shared gate policy, for a command that EXITS. */
|
|
53
72
|
export declare function registerWebhooks(input: {
|
|
54
73
|
baseUrl: string;
|
|
@@ -85,24 +85,51 @@ const WAIT_NOTICE_MS = 15_000;
|
|
|
85
85
|
* report success. The channels that DO verify (Slack/Feishu challenge) would fail here too, but as "registration
|
|
86
86
|
* failed" — a diagnosis that hides the actual cause.
|
|
87
87
|
*
|
|
88
|
-
* Asked ONLY when this deployment actually has a webhook to point
|
|
88
|
+
* Asked ONLY when this deployment actually has a webhook to point, or a login on the box follows (the box must
|
|
89
|
+
* have prepared its workspace and installed the CLI the login runs). A definition with neither (schedules only, the
|
|
89
90
|
* built-in `POST /invoke`, or long-connection channels) needs no inbound reachability at all, and demanding it would
|
|
90
91
|
* invent a failure for a deploy run from a network that cannot reach the platform's edge.
|
|
91
92
|
*/
|
|
92
93
|
export async function publicHealthGate(input) {
|
|
93
|
-
if (webhookKinds(input.channels).length === 0)
|
|
94
|
+
if (webhookKinds(input.channels).length === 0 && input.login === undefined)
|
|
94
95
|
return undefined;
|
|
95
96
|
const healthUrl = `${input.baseUrl}/health`;
|
|
96
97
|
input.log(`waiting for ${healthUrl} (up to ${PUBLIC_HEALTH_TIMEOUT_MS / 1000}s)…`);
|
|
97
98
|
const probe = input.probe ?? ((url) => waitForHealth(url, PUBLIC_HEALTH_TIMEOUT_MS, 500, announce(input.log)));
|
|
98
99
|
if (await probe(healthUrl))
|
|
99
100
|
return undefined;
|
|
100
|
-
// The infrastructure is up and only
|
|
101
|
-
// `re-run` as the only way out (a Fly re-run repeats the remote build).
|
|
102
|
-
|
|
101
|
+
// The infrastructure is up and only activation is missing, so hand over the manual route rather than leaving
|
|
102
|
+
// `re-run` as the only way out (a Fly re-run repeats the remote build). The login comes first: a channel pointed at
|
|
103
|
+
// a box with no model credential fails every message.
|
|
104
|
+
const steps = [
|
|
105
|
+
...(input.login !== undefined ? [`# First, log the box in: ${input.login}`] : []),
|
|
106
|
+
...webhookRunbook(input.baseUrl, input.channels),
|
|
107
|
+
];
|
|
108
|
+
for (const line of steps)
|
|
109
|
+
input.log(line);
|
|
110
|
+
const byHand = webhookKinds(input.channels).length > 0
|
|
111
|
+
? `To finish by hand once it answers, use the lines above.`
|
|
112
|
+
: `Once it answers, log it in: ${input.login}.`;
|
|
113
|
+
return (`the deployed agent did not become healthy at ${healthUrl}, so it was not activated — ${input.inspectHint}. ` +
|
|
114
|
+
byHand);
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* The gate when the login on the box did not happen. It stops the run BEFORE registration (a channel pointed at a box
|
|
118
|
+
* with no model credential fails every message), so the login it asks for does not register anything by itself: with
|
|
119
|
+
* a webhook to point, the refusal says what comes after the login, and prints the lines to do it by hand when the
|
|
120
|
+
* URL is known.
|
|
121
|
+
*/
|
|
122
|
+
export function loginGate(input) {
|
|
123
|
+
if (webhookKinds(input.channels).length === 0)
|
|
124
|
+
return input.notLoggedIn;
|
|
125
|
+
const byHand = input.baseUrl === undefined ? [] : webhookRunbook(input.baseUrl, input.channels);
|
|
126
|
+
for (const line of byHand)
|
|
103
127
|
input.log(line);
|
|
104
|
-
|
|
105
|
-
|
|
128
|
+
// "No webhook was registered" would be about THIS run only: one an earlier deploy registered still points here.
|
|
129
|
+
return (`${input.notLoggedIn}. This deploy registered no webhook, and logging in registers none; any an earlier deploy ` +
|
|
130
|
+
`registered still points here, and every message it brings fails until the box is logged in. Once it is, ` +
|
|
131
|
+
`${input.afterLogin}` +
|
|
132
|
+
(byHand.length > 0 ? `. The lines above point the channels by hand.` : ``));
|
|
106
133
|
}
|
|
107
134
|
/**
|
|
108
135
|
* Keep the wait visible. This occupies `waitForHealth`'s liveness slot without answering it: neither Fly nor Railway
|
|
@@ -2,6 +2,20 @@ export interface Artifact {
|
|
|
2
2
|
path: string;
|
|
3
3
|
content: string;
|
|
4
4
|
}
|
|
5
|
+
/**
|
|
6
|
+
* The shell command that runs `fastagent login --stdio <args>` inside a running generated image, as the server there
|
|
7
|
+
* would see it: in the deployed agent directory, with the storage and credential paths resolved by the same defaults
|
|
8
|
+
* `prepareStartWorkspace` applies. A host's own shell does not reliably hand those over — its session may not start
|
|
9
|
+
* in the server's directory, and the platform's variables are the only ones it inherits. `storage` names the root
|
|
10
|
+
* outright for a shell that inherits none of them. The CLI is the one the server runs: the agent's own install when
|
|
11
|
+
* it has one (under Bun when the image is Bun's), else the pinned global.
|
|
12
|
+
*
|
|
13
|
+
* That own install may predate `login --stdio` (an agent pinned to an older release, deployed by a newer CLI). It then
|
|
14
|
+
* refuses the flag as a usage error, exit 2, which a `login --stdio` that ran never gives (it answers 0 or 1 after its
|
|
15
|
+
* result line), so the command answers for it: one result line naming the mismatch, instead of a shell that ended
|
|
16
|
+
* with nothing to say. No quote or backtick in it: hosts wrap this whole line in `sh -c '…'`.
|
|
17
|
+
*/
|
|
18
|
+
export declare function boxLoginCommand(agent: string, args: readonly string[], storage?: string): string;
|
|
5
19
|
/** Fixed first-line prefix of a fastagent-generated Dockerfile. */
|
|
6
20
|
export declare const GENERATED_DOCKERFILE_MARKER = "# Generated by `fastagent deploy`";
|
|
7
21
|
/** The ONE predicate for "did fastagent generate this Dockerfile?". */
|
package/dist/deploy/container.js
CHANGED
|
@@ -1,6 +1,50 @@
|
|
|
1
1
|
/** The portable container — Dockerfile + .dockerignore, host-neutral. */
|
|
2
2
|
import { SECRETS_DIRNAME, STATE_DIRNAME } from "../paths.js";
|
|
3
3
|
import { RELEASE_FILE, parseDeploymentRelease } from "./workspace.js";
|
|
4
|
+
/** The result line for a box CLI that refused `login --stdio` as an unknown option (see {@link boxLoginCommand}). */
|
|
5
|
+
const STDIO_PREDATED = JSON.stringify({
|
|
6
|
+
type: "result",
|
|
7
|
+
ok: false,
|
|
8
|
+
reason: "failed",
|
|
9
|
+
message: "the fastagent installed in the agent on the box predates login --stdio: align the @fastagent-sh/fastagent " +
|
|
10
|
+
"version in the agent package.json with the CLI that deploys it, then redeploy",
|
|
11
|
+
}).replaceAll('"', '\\"');
|
|
12
|
+
/** Where a generated image keeps its storage (`FASTAGENT_STORAGE_DIR`); every host mounts its volume here. */
|
|
13
|
+
const STORAGE_DIR = "/data";
|
|
14
|
+
/**
|
|
15
|
+
* The shell command that runs `fastagent login --stdio <args>` inside a running generated image, as the server there
|
|
16
|
+
* would see it: in the deployed agent directory, with the storage and credential paths resolved by the same defaults
|
|
17
|
+
* `prepareStartWorkspace` applies. A host's own shell does not reliably hand those over — its session may not start
|
|
18
|
+
* in the server's directory, and the platform's variables are the only ones it inherits. `storage` names the root
|
|
19
|
+
* outright for a shell that inherits none of them. The CLI is the one the server runs: the agent's own install when
|
|
20
|
+
* it has one (under Bun when the image is Bun's), else the pinned global.
|
|
21
|
+
*
|
|
22
|
+
* That own install may predate `login --stdio` (an agent pinned to an older release, deployed by a newer CLI). It then
|
|
23
|
+
* refuses the flag as a usage error, exit 2, which a `login --stdio` that ran never gives (it answers 0 or 1 after its
|
|
24
|
+
* result line), so the command answers for it: one result line naming the mismatch, instead of a shell that ended
|
|
25
|
+
* with nothing to say. No quote or backtick in it: hosts wrap this whole line in `sh -c '…'`.
|
|
26
|
+
*/
|
|
27
|
+
export function boxLoginCommand(agent, args, storage) {
|
|
28
|
+
for (const value of [agent, ...args, ...(storage ? [storage] : [])]) {
|
|
29
|
+
if (!/^[\w.@/-]+$/.test(value))
|
|
30
|
+
throw new Error(`cannot pass ${JSON.stringify(value)} to a shell on the box`);
|
|
31
|
+
}
|
|
32
|
+
return [
|
|
33
|
+
...(storage
|
|
34
|
+
? [`root="${storage}"`, `export FASTAGENT_SECRETS_DIR="$root/.secrets"`]
|
|
35
|
+
: [
|
|
36
|
+
`root="\${FASTAGENT_STORAGE_DIR:-${STORAGE_DIR}}"`,
|
|
37
|
+
`export FASTAGENT_SECRETS_DIR="\${FASTAGENT_SECRETS_DIR:-$root/.secrets}"`,
|
|
38
|
+
]),
|
|
39
|
+
`cd "$root/base/${agent}" || exit 1`,
|
|
40
|
+
`f=fastagent`,
|
|
41
|
+
`if [ -x node_modules/.bin/fastagent ]; then f=node_modules/.bin/fastagent; if command -v bun >/dev/null; then f="bun run fastagent"; fi; fi`,
|
|
42
|
+
`$f login --stdio ${args.join(" ")}`,
|
|
43
|
+
`code=$?`,
|
|
44
|
+
`if [ $code -eq 2 ]; then printf "%s\\n" "${STDIO_PREDATED}"; fi`,
|
|
45
|
+
`exit $code`,
|
|
46
|
+
].join("; ");
|
|
47
|
+
}
|
|
4
48
|
/** Fixed first-line prefix of a fastagent-generated Dockerfile. */
|
|
5
49
|
export const GENERATED_DOCKERFILE_MARKER = "# Generated by `fastagent deploy`";
|
|
6
50
|
/** The ONE predicate for "did fastagent generate this Dockerfile?". */
|
|
@@ -41,7 +85,7 @@ function dockerfile(input) {
|
|
|
41
85
|
// PIN the agent into the image.
|
|
42
86
|
const pin = `ENV FASTAGENT_AGENT=${dir}\n`;
|
|
43
87
|
// The caches stay off the volume: they are rebuildable, and a host mount is the slow disk.
|
|
44
|
-
const deployment = `ENV FASTAGENT_RELEASE_FILE=/app/${into(RELEASE_FILE)}\nENV FASTAGENT_STORAGE_DIR
|
|
88
|
+
const deployment = `ENV FASTAGENT_RELEASE_FILE=/app/${into(RELEASE_FILE)}\nENV FASTAGENT_STORAGE_DIR=${STORAGE_DIR}\nENV npm_config_cache=/tmp/fastagent/npm\nENV BUN_INSTALL_CACHE_DIR=/tmp/fastagent/bun\n`;
|
|
45
89
|
// No package.json → pure markdown/skills agent: install the pinned CLI GLOBALLY and run `fastagent` from PATH.
|
|
46
90
|
if (!input.hasPackageJson) {
|
|
47
91
|
return `${GENERATED_DOCKERFILE_MARKER}. ${layoutNote}
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
/** `fastagent deploy docker` — the local-Docker plan. */
|
|
2
2
|
import type { DeclaredChannel } from "../../channels/discover.ts";
|
|
3
3
|
import { type Artifact, type ContainerInput } from "../container.ts";
|
|
4
|
-
import {
|
|
4
|
+
import type { DeploymentSecret } from "../secrets.ts";
|
|
5
5
|
export interface DockerPlanInput extends ContainerInput {
|
|
6
6
|
/** Stable Compose project name, sanitized by {@link toDockerProjectName}. */
|
|
7
7
|
projectName: string;
|
|
8
8
|
/** Container + default host port. */
|
|
9
9
|
port: number;
|
|
10
|
-
/**
|
|
11
|
-
|
|
10
|
+
/**
|
|
11
|
+
* The provider the deployment logs in to itself (`fastagent login --deployment`): its credential is not a variable
|
|
12
|
+
* the plan carries.
|
|
13
|
+
*/
|
|
14
|
+
boxLogin?: string;
|
|
12
15
|
/** Every declared channel and its ingress — the source of the ingress note. */
|
|
13
16
|
channels: readonly DeclaredChannel[];
|
|
14
17
|
/** Generate an optional Cloudflare Quick Tunnel service in Compose. */
|
|
@@ -29,6 +32,7 @@ export interface DockerPlan {
|
|
|
29
32
|
/** Ordered local build/run/operate instructions. */
|
|
30
33
|
runbook: string[];
|
|
31
34
|
}
|
|
35
|
+
export declare const DOCKER_COMPOSE_FILE = "fastagent.compose.yml";
|
|
32
36
|
export declare const MIN_DOCKER_COMPOSE_VERSION = "2.3.3";
|
|
33
37
|
/** Pinned: generated deployment artifacts must not silently move when Cloudflare publishes `latest`. */
|
|
34
38
|
export declare const CLOUDFLARED_IMAGE = "cloudflare/cloudflared:2026.7.1";
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { webhookPaths } from "../channel-ingress.js";
|
|
2
2
|
import { containerArtifacts } from "../container.js";
|
|
3
|
-
import {
|
|
3
|
+
import { deploymentLoginCommand } from "../box-shell.js";
|
|
4
4
|
import { SECRETS_DIRNAME } from "../../paths.js";
|
|
5
5
|
const MOUNT = "/data";
|
|
6
|
-
const DOCKER_COMPOSE_FILE = "fastagent.compose.yml";
|
|
6
|
+
export const DOCKER_COMPOSE_FILE = "fastagent.compose.yml";
|
|
7
7
|
const GENERATED_COMPOSE_MARKER = "# Generated by `fastagent deploy docker`";
|
|
8
8
|
export const MIN_DOCKER_COMPOSE_VERSION = "2.3.3";
|
|
9
9
|
/** Pinned: generated deployment artifacts must not silently move when Cloudflare publishes `latest`. */
|
|
@@ -81,17 +81,12 @@ services:
|
|
|
81
81
|
- ${SECRETS_DIRNAME}/.env
|
|
82
82
|
environment:
|
|
83
83
|
PORT: "${input.port}"
|
|
84
|
-
# Machinery on the ONE state volume: mutable state and (
|
|
84
|
+
# Machinery on the ONE state volume: mutable state and credentials (logged in on the box). Pinned AFTER
|
|
85
85
|
# env_file so a local path in that file (the scaffold lists these) cannot send the container's state, sessions
|
|
86
86
|
# or credentials somewhere outside the volume — or at a host path that does not exist here at all.
|
|
87
87
|
FASTAGENT_STATE_DIR: "${MOUNT}/.state"
|
|
88
88
|
FASTAGENT_SECRETS_DIR: "${MOUNT}/.secrets"
|
|
89
89
|
FASTAGENT_AUTH_PATH: "${MOUNT}/.secrets/auth.json"
|
|
90
|
-
# The ONE value that is not in the value file: \`--run\` mints it from the local auth.json. Left as a seam in
|
|
91
|
-
# the committed topology so a hand-run \`up\` can supply it the same way — from the ENVIRONMENT of that \`up\`.
|
|
92
|
-
# Writing this one key into the value file does nothing: \`environment:\` is applied after \`env_file\`, so the
|
|
93
|
-
# line below would blank it and the container would start without seeding auth.json.
|
|
94
|
-
FASTAGENT_AUTH_SEED: "${composeInterpolation("FASTAGENT_AUTH_SEED")}"
|
|
95
90
|
volumes:
|
|
96
91
|
- state:${MOUNT}
|
|
97
92
|
# The agent's log is where a failure's reason is recorded (a scheduled fire logs the detail; what the turn
|
|
@@ -126,13 +121,13 @@ export function planDockerDeploy(input) {
|
|
|
126
121
|
if (secrets.length > 0) {
|
|
127
122
|
runbook.push(`# Environment: the container reads ${input.valueFile} whole (Compose \`env_file\`) — that file declares the`, `# deployed environment (in CI, write it before running the command):`, ...secrets.map((secret) => `# ${secret.name}: ${secret.hint}`));
|
|
128
123
|
}
|
|
129
|
-
if (!isEnvKey(input.modelAuth)) {
|
|
130
|
-
runbook.push(input.modelAuth === undefined
|
|
131
|
-
? `# Model auth: none found locally — run \`fastagent login\` or set a provider API key before --run.`
|
|
132
|
-
: `# Model auth: local source is "${input.modelAuth}" — \`--run\` carries auth.json as FASTAGENT_AUTH_SEED.`, `# For a manual Compose run, provide a provider API key or set FASTAGENT_AUTH_SEED yourself.`);
|
|
133
|
-
}
|
|
134
124
|
runbook.push(`# Run from the WORKSPACE ROOT (the directory containing ${input.agentPrefix}).`, `# The build bakes the whole directory as the agent's workspace; only the agent's own`, `# dependencies (${input.agentPrefix}package.json) are installed.`);
|
|
135
|
-
runbook.push(``, `# Before building a new definition release, run \`fastagent deploy docker\` to refresh its manifest.`, `# The Compose volume at ${MOUNT} retains the workspace, state and credentials.`, `${compose} up -d --build`, `curl --fail http://127.0.0.1:${input.port}/health`,
|
|
125
|
+
runbook.push(``, `# Before building a new definition release, run \`fastagent deploy docker\` to refresh its manifest.`, `# The Compose volume at ${MOUNT} retains the workspace, state and credentials.`, `${compose} up -d --build`, `curl --fail http://127.0.0.1:${input.port}/health`, ...(input.boxLogin
|
|
126
|
+
? [
|
|
127
|
+
`# Model auth: once it is up, the deployment logs in to ${input.boxLogin} itself:`,
|
|
128
|
+
`${deploymentLoginCommand("docker", input.boxLogin)}`,
|
|
129
|
+
]
|
|
130
|
+
: []), ``, `# Operate it:`, `${compose} logs -f agent`, `${compose} ps`, `${compose} down # stops containers; keeps the state volume`, `# ${compose} down -v # DESTRUCTIVE: deletes workspace, credentials and all agent state`);
|
|
136
131
|
if (input.tunnel) {
|
|
137
132
|
runbook.push(``, `# Quick Tunnel: \`--run\` starts the Compose tunnel service and reads its ephemeral public URL.`, `# Telegram/Feishu/Lark and locally onboarded Slack auto-register; manual Slack prints its console URL. Re-run after the`, `# tunnel container/Docker daemon restarts: a new Quick Tunnel URL must replace the old webhook URL.`, `${compose} logs -f tunnel`);
|
|
138
133
|
}
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
/** `fastagent deploy docker --run` — reconcile the generated/user-owned Compose application locally. */
|
|
2
|
+
import type { BoxLoginStep } from "../box-shell.ts";
|
|
3
|
+
import type { DeclaredChannel } from "../../channels/discover.ts";
|
|
1
4
|
import type { RegistrationOutcome } from "../../channels/registration.ts";
|
|
2
5
|
import type { CliRunner } from "../runner.ts";
|
|
3
6
|
export interface DockerRunPlan {
|
|
@@ -6,21 +9,25 @@ export interface DockerRunPlan {
|
|
|
6
9
|
/** Container port from config; used to ask Compose for the effective published host port. */
|
|
7
10
|
port: number;
|
|
8
11
|
/**
|
|
9
|
-
* What `--run` assembled. The container reads the value file itself through the generated `env_file`, so
|
|
10
|
-
*
|
|
11
|
-
* reported to the operator and otherwise unused.
|
|
12
|
+
* What `--run` assembled. The container reads the value file itself through the generated `env_file`, so nothing
|
|
13
|
+
* travels through this process: the names are reported to the operator and otherwise unused.
|
|
12
14
|
*/
|
|
13
15
|
secrets: Record<string, string>;
|
|
14
16
|
/** Declared names the value file supplies no value for — the run gates on these before any side effect. */
|
|
15
17
|
missingSecrets: string[];
|
|
16
18
|
/** That value file, workspace-relative, so the gate names the file this deploy actually read. */
|
|
17
19
|
valueFile: string;
|
|
18
|
-
/** Neither an env-key credential nor a readable auth.json is available. */
|
|
19
|
-
needsModelCredential: boolean;
|
|
20
20
|
/** Register the deployment's webhooks against the tunnel URL, reporting what each registrar answered. */
|
|
21
21
|
announce: DockerAnnounce;
|
|
22
|
+
/** Every declared channel and its ingress: which of them a failed login leaves unregistered. */
|
|
23
|
+
channels: readonly DeclaredChannel[];
|
|
22
24
|
/** `--tunnel` was requested for this run; a kept Compose file must actually contain that service. */
|
|
23
25
|
requireTunnel: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Log the box in (`fastagent login --deployment`) once it is up and before any webhook is pointed at it: a channel pointed at
|
|
28
|
+
* a box with no model credential answers every message with a failure. Resolves a gate line, or undefined.
|
|
29
|
+
*/
|
|
30
|
+
boxLogin?: BoxLoginStep;
|
|
24
31
|
}
|
|
25
32
|
export type DockerRunOutcome = {
|
|
26
33
|
ok: true;
|
|
@@ -52,14 +59,14 @@ export interface ComposeTunnel {
|
|
|
52
59
|
url: string;
|
|
53
60
|
connected: boolean;
|
|
54
61
|
}
|
|
55
|
-
export type DockerTunnelUrlProbe = (docker: CliRunner, composeFile: string
|
|
62
|
+
export type DockerTunnelUrlProbe = (docker: CliRunner, composeFile: string) => Promise<ComposeTunnel | undefined>;
|
|
56
63
|
/** Resolve Docker Compose's `host:port` output to a loopback URL (safe for 0.0.0.0/[::] bindings too). */
|
|
57
64
|
export declare function localUrlFromComposePort(stdout: string): string | undefined;
|
|
58
65
|
/**
|
|
59
66
|
* Poll the detached cloudflared service's logs until its Quick Tunnel URL is assigned AND the tunnel reports an edge
|
|
60
67
|
* connection.
|
|
61
68
|
*/
|
|
62
|
-
export declare function waitForComposeTunnelUrl(docker: CliRunner, composeFile: string,
|
|
69
|
+
export declare function waitForComposeTunnelUrl(docker: CliRunner, composeFile: string, options?: {
|
|
63
70
|
attempts?: number;
|
|
64
71
|
intervalMs?: number;
|
|
65
72
|
sleep?: (ms: number) => Promise<void>;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
/** `fastagent deploy docker --run` — reconcile the generated/user-owned Compose application locally. */
|
|
2
1
|
import { waitForHealth } from "../../channels/wait-health.js";
|
|
2
|
+
import { loginGate } from "../channel-ingress.js";
|
|
3
3
|
import { TUNNEL_DNS_LAG_MS, hasTunnelConnection, parseTunnelUrl } from "../../tunnel.js";
|
|
4
4
|
import { registrationGate } from "../registration-gate.js";
|
|
5
5
|
import { MIN_DOCKER_COMPOSE_VERSION } from "./plan.js";
|
|
@@ -23,12 +23,12 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
23
23
|
* Poll the detached cloudflared service's logs until its Quick Tunnel URL is assigned AND the tunnel reports an edge
|
|
24
24
|
* connection.
|
|
25
25
|
*/
|
|
26
|
-
export async function waitForComposeTunnelUrl(docker, composeFile,
|
|
26
|
+
export async function waitForComposeTunnelUrl(docker, composeFile, options = {}) {
|
|
27
27
|
const compose = ["compose", "-f", composeFile];
|
|
28
28
|
const attempts = options.attempts ?? 60;
|
|
29
29
|
let assigned;
|
|
30
30
|
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
31
|
-
const logs = await docker([...compose, "logs", "--no-color", "tunnel"], { capture: true
|
|
31
|
+
const logs = await docker([...compose, "logs", "--no-color", "tunnel"], { capture: true });
|
|
32
32
|
if (logs.code === 0) {
|
|
33
33
|
assigned ??= parseTunnelUrl(logs.stdout);
|
|
34
34
|
if (assigned && hasTunnelConnection(logs.stdout)) {
|
|
@@ -41,14 +41,27 @@ export async function waitForComposeTunnelUrl(docker, composeFile, env, options
|
|
|
41
41
|
}
|
|
42
42
|
return assigned ? { url: assigned, connected: false } : undefined;
|
|
43
43
|
}
|
|
44
|
-
const defaultTunnelUrlProbe = (docker, composeFile
|
|
44
|
+
const defaultTunnelUrlProbe = (docker, composeFile) => waitForComposeTunnelUrl(docker, composeFile);
|
|
45
|
+
/**
|
|
46
|
+
* `/health` asked from inside the agent container, for a topology that publishes no host port. The image always has
|
|
47
|
+
* node or bun (the generated Dockerfile's base), and both have `fetch`; nothing else is assumed installed.
|
|
48
|
+
*/
|
|
49
|
+
async function containerHealthy(docker, compose, port, stillStarting) {
|
|
50
|
+
const probe = `r=$(command -v node || command -v bun) && "$r" -e ` +
|
|
51
|
+
`"fetch('http://127.0.0.1:${port}/health').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"`;
|
|
52
|
+
const deadline = Date.now() + 180_000; // the same first-boot budget as the published-port probe
|
|
53
|
+
while (Date.now() < deadline) {
|
|
54
|
+
if ((await docker([...compose, "exec", "-T", "agent", "sh", "-c", probe], { capture: true })).code === 0)
|
|
55
|
+
return true;
|
|
56
|
+
if (!(await stillStarting()))
|
|
57
|
+
return false;
|
|
58
|
+
await sleep(1_000);
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
45
62
|
export async function deployDockerRun(plan, docker, log, healthProbe = defaultHealthProbe, tunnelUrlProbe = defaultTunnelUrlProbe) {
|
|
46
63
|
const gate = (message) => ({ ok: false, gate: message });
|
|
47
64
|
const compose = ["compose", "-f", plan.composeFile];
|
|
48
|
-
// The ONE value that is not in the value file, so the ONE that has to cross this process. Set even when absent, so
|
|
49
|
-
// a same-named variable in the builder's shell cannot interpolate into the container in its place — `spawnRunner`
|
|
50
|
-
// merges over `process.env`.
|
|
51
|
-
const env = { FASTAGENT_AUTH_SEED: plan.secrets.FASTAGENT_AUTH_SEED ?? "" };
|
|
52
65
|
// CLI/plugin gate first: unlike a daemon error, spawn ENOENT becomes 127 at the shared runner seam.
|
|
53
66
|
const version = await docker(["compose", "version"], { capture: true });
|
|
54
67
|
if (version.code === 127) {
|
|
@@ -57,17 +70,14 @@ export async function deployDockerRun(plan, docker, log, healthProbe = defaultHe
|
|
|
57
70
|
if (version.code !== 0) {
|
|
58
71
|
return gate("Docker Compose plugin is unavailable — install/enable `docker compose`, then re-run");
|
|
59
72
|
}
|
|
60
|
-
//
|
|
61
|
-
if (plan.needsModelCredential) {
|
|
62
|
-
return gate("no model credential — run `fastagent login`, or set a provider API key in .env, then re-run");
|
|
63
|
-
}
|
|
73
|
+
// The values gate precedes the first side effect (build/create).
|
|
64
74
|
const missingValues = missingValuesGate(plan.missingSecrets, plan.valueFile);
|
|
65
75
|
if (missingValues)
|
|
66
76
|
return gate(missingValues);
|
|
67
77
|
// Name what the container must find, and WHERE — the generated Compose reads the value file itself, so this run
|
|
68
|
-
// hands Compose nothing
|
|
69
|
-
//
|
|
70
|
-
const secretNames = Object.keys(plan.secrets)
|
|
78
|
+
// hands Compose nothing. Saying "passing N secrets to Compose" would be false, and doubly so on a hand-owned
|
|
79
|
+
// Compose file that has no `env_file` entry at all.
|
|
80
|
+
const secretNames = Object.keys(plan.secrets);
|
|
71
81
|
if (secretNames.length > 0) {
|
|
72
82
|
log(`${secretNames.length} value(s) the container reads from ${plan.valueFile}: ${secretNames.join(", ")}`);
|
|
73
83
|
}
|
|
@@ -75,7 +85,7 @@ export async function deployDockerRun(plan, docker, log, healthProbe = defaultHe
|
|
|
75
85
|
return gate("Docker daemon is unavailable — start Docker Engine/Desktop, then re-run");
|
|
76
86
|
}
|
|
77
87
|
// The file on disk is authoritative.
|
|
78
|
-
const configured = await docker([...compose, "config", "--services"], { capture: true
|
|
88
|
+
const configured = await docker([...compose, "config", "--services"], { capture: true });
|
|
79
89
|
if (configured.code !== 0) {
|
|
80
90
|
return gate(`could not load ${plan.composeFile} — generated files require Docker Compose >= ` +
|
|
81
91
|
`${MIN_DOCKER_COMPOSE_VERSION}; upgrade Compose or fix the file, then re-run`);
|
|
@@ -92,19 +102,16 @@ export async function deployDockerRun(plan, docker, log, healthProbe = defaultHe
|
|
|
92
102
|
// Quick Tunnel logs are the control-plane output (the assigned URL).
|
|
93
103
|
if (hasTunnel) {
|
|
94
104
|
log("recreating the ephemeral tunnel service…");
|
|
95
|
-
if ((await docker([...compose, "rm", "-s", "-f", "tunnel"]
|
|
105
|
+
if ((await docker([...compose, "rm", "-s", "-f", "tunnel"])).code !== 0) {
|
|
96
106
|
return gate(`could not recreate the tunnel service — inspect \`docker compose -f ${plan.composeFile} ps\``);
|
|
97
107
|
}
|
|
98
108
|
}
|
|
99
109
|
log(`building and reconciling ${plan.composeFile}…`);
|
|
100
|
-
if ((await docker([...compose, "up", "-d", "--build"]
|
|
110
|
+
if ((await docker([...compose, "up", "-d", "--build"])).code !== 0) {
|
|
101
111
|
return gate(`\`docker compose up\` failed — see the Docker output above; fix ${plan.composeFile} and re-run`);
|
|
102
112
|
}
|
|
103
113
|
// Detached `up` can return 0 just before a bad command exits.
|
|
104
|
-
const running = await docker([...compose, "ps", "--status", "running", "--services"], {
|
|
105
|
-
capture: true,
|
|
106
|
-
env,
|
|
107
|
-
});
|
|
114
|
+
const running = await docker([...compose, "ps", "--status", "running", "--services"], { capture: true });
|
|
108
115
|
const runningServices = running.stdout.split(/\s+/).filter(Boolean);
|
|
109
116
|
if (running.code !== 0 || !runningServices.includes("agent")) {
|
|
110
117
|
return gate(`the Compose service "agent" is not running — inspect with \`docker compose -f ${plan.composeFile} logs agent\``);
|
|
@@ -113,31 +120,47 @@ export async function deployDockerRun(plan, docker, log, healthProbe = defaultHe
|
|
|
113
120
|
return gate(`the Compose service "tunnel" is not running — inspect with \`docker compose -f ${plan.composeFile} logs tunnel\``);
|
|
114
121
|
}
|
|
115
122
|
// A user-owned topology may deliberately remove the host port and expose only through its own ingress.
|
|
116
|
-
const published = await docker([...compose, "port", "agent", String(plan.port)], { capture: true
|
|
123
|
+
const published = await docker([...compose, "port", "agent", String(plan.port)], { capture: true });
|
|
117
124
|
const url = published.code === 0 ? localUrlFromComposePort(published.stdout) : undefined;
|
|
118
|
-
|
|
125
|
+
// Throttled, and an unreadable answer reads as "still starting": the health poll runs twice a second, and a
|
|
126
|
+
// `compose ps` per poll would cost more than the wait it shortens.
|
|
127
|
+
let lastCheck = Date.now();
|
|
128
|
+
const stillStarting = async () => {
|
|
129
|
+
if (Date.now() - lastCheck < 5_000)
|
|
130
|
+
return true;
|
|
131
|
+
lastCheck = Date.now();
|
|
132
|
+
const ps = await docker([...compose, "ps", "--status", "running", "--services"], { capture: true });
|
|
133
|
+
return ps.code !== 0 || ps.stdout.split(/\s+/).includes("agent");
|
|
134
|
+
};
|
|
135
|
+
if (!url && plan.boxLogin) {
|
|
136
|
+
// The login runs in the prepared workspace, with the CLI the first boot installs: it needs the box READY, and
|
|
137
|
+
// with no published port the only place to ask `/health` is inside the container.
|
|
138
|
+
log("agent is running (no host-published port found); waiting for /health inside the container before login…");
|
|
139
|
+
if (!(await containerHealthy(docker, compose, plan.port, stillStarting))) {
|
|
140
|
+
return gate(`agent did not become healthy inside the container — inspect \`docker compose -f ${plan.composeFile} logs agent\``);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
else if (!url) {
|
|
119
144
|
log("agent is running (no host-published port found; using the Compose ingress readiness floor)");
|
|
120
145
|
}
|
|
121
146
|
else {
|
|
122
147
|
const healthUrl = `${url}/health`;
|
|
123
|
-
// Throttled, and an unreadable answer reads as "still starting": the health poll runs twice a second, and a
|
|
124
|
-
// `compose ps` per poll would cost more than the wait it shortens.
|
|
125
|
-
let lastCheck = Date.now();
|
|
126
|
-
const stillStarting = async () => {
|
|
127
|
-
if (Date.now() - lastCheck < 5_000)
|
|
128
|
-
return true;
|
|
129
|
-
lastCheck = Date.now();
|
|
130
|
-
const ps = await docker([...compose, "ps", "--status", "running", "--services"], { capture: true, env });
|
|
131
|
-
return ps.code !== 0 || ps.stdout.split(/\s+/).includes("agent");
|
|
132
|
-
};
|
|
133
148
|
if (!(await healthProbe(healthUrl, stillStarting))) {
|
|
134
149
|
return gate(`agent did not become healthy at ${healthUrl} — inspect \`docker compose -f ${plan.composeFile} logs agent\``);
|
|
135
150
|
}
|
|
136
151
|
}
|
|
152
|
+
// `url` travels with this gate: Compose is up, and the operator needs to know where.
|
|
153
|
+
const notLoggedIn = await plan.boxLogin?.run();
|
|
154
|
+
if (notLoggedIn) {
|
|
155
|
+
// Only a tunnel is ours to announce; without one, the webhooks were never this run's to register.
|
|
156
|
+
const afterLogin = "re-run `fastagent deploy docker --run` (it keeps the login and announces the tunnel)";
|
|
157
|
+
const channels = hasTunnel ? plan.channels : [];
|
|
158
|
+
return { ok: false, gate: loginGate({ notLoggedIn, channels, log, afterLogin }), url };
|
|
159
|
+
}
|
|
137
160
|
if (!hasTunnel)
|
|
138
161
|
return { ok: true, url };
|
|
139
162
|
log("waiting for the Compose tunnel service to publish its Quick Tunnel URL…");
|
|
140
|
-
const tunnel = await tunnelUrlProbe(docker, plan.composeFile
|
|
163
|
+
const tunnel = await tunnelUrlProbe(docker, plan.composeFile);
|
|
141
164
|
if (!tunnel) {
|
|
142
165
|
return gate(`tunnel did not publish a Quick Tunnel URL — inspect \`docker compose -f ${plan.composeFile} logs tunnel\``);
|
|
143
166
|
}
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
/** `fastagent deploy fly` — the Fly.io deploy PLAN, computed from the resolved definition. */
|
|
2
2
|
import type { DeclaredChannel } from "../../channels/discover.ts";
|
|
3
3
|
import { type Artifact, type ContainerInput } from "../container.ts";
|
|
4
|
-
import {
|
|
4
|
+
import type { DeploymentSecret } from "../secrets.ts";
|
|
5
5
|
export interface FlyPlanInput extends ContainerInput {
|
|
6
6
|
/** Fly app name — globally unique, lowercase; the CLI sanitizes it from the dir basename. */
|
|
7
7
|
appName: string;
|
|
8
8
|
/** The port the app listens on (config.http.port ?? 8787); fly.toml routes to it. */
|
|
9
9
|
port: number;
|
|
10
|
-
/**
|
|
11
|
-
|
|
10
|
+
/**
|
|
11
|
+
* The provider the deployment logs in to itself (`fastagent login --deployment`): its credential is not a variable
|
|
12
|
+
* the plan carries.
|
|
13
|
+
*/
|
|
14
|
+
boxLogin?: string;
|
|
12
15
|
/**
|
|
13
16
|
* Every declared channel and its ingress — the source of the webhook steps, and whether a machine
|
|
14
17
|
* must stay up for an outbound connection.
|
package/dist/deploy/fly/plan.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { webhookRunbook } from "../channel-ingress.js";
|
|
2
2
|
import { containerArtifacts } from "../container.js";
|
|
3
|
+
import { deploymentLoginCommand } from "../box-shell.js";
|
|
3
4
|
import { CRON_CAN_BE_EXTERNAL, WAKEUPS_WHEN_ASLEEP, residencyFor } from "../residency.js";
|
|
4
|
-
import { isEnvKey } from "../secrets.js";
|
|
5
5
|
function flyToml(appName, port, residency) {
|
|
6
6
|
// WHY is residency.ts's to decide; the SETTING and its wording are Fly's. Anything else is an edit to the
|
|
7
7
|
// generated file, which is the artifact's job.
|
|
@@ -26,7 +26,7 @@ primary_region = "iad" # set your region (list: \`fly platform regions\`)
|
|
|
26
26
|
|
|
27
27
|
[env]
|
|
28
28
|
FASTAGENT_STATE_DIR = "/data/.state" # mutable machine state — sessions, channel state, schedule
|
|
29
|
-
FASTAGENT_SECRETS_DIR = "/data/.secrets" #
|
|
29
|
+
FASTAGENT_SECRETS_DIR = "/data/.secrets" # credentials the box logged in with — must persist across restarts
|
|
30
30
|
PORT = "${port}"
|
|
31
31
|
|
|
32
32
|
[http_service]
|
|
@@ -54,7 +54,7 @@ export function isGeneratedFlyToml(content) {
|
|
|
54
54
|
}
|
|
55
55
|
/** Compute the Fly deploy plan from the resolved definition. */
|
|
56
56
|
export function planFlyDeploy(input) {
|
|
57
|
-
const { appName, port,
|
|
57
|
+
const { appName, port, channels } = input;
|
|
58
58
|
// Artifacts sit under the agent prefix, so they never touch the workspace's own deploy files.
|
|
59
59
|
const flyTomlPath = `${input.agentPrefix}fly.toml`;
|
|
60
60
|
const artifacts = [
|
|
@@ -92,11 +92,9 @@ export function planFlyDeploy(input) {
|
|
|
92
92
|
runbook.push(``, `# The volume keeps /data/base (including uncommitted work), .state and .secrets across restarts and deploys.`, `# Each generated release replaces only /data/base/${input.agentPrefix}; other workspace files are initialized once.`, input.shipsGit
|
|
93
93
|
? `# Git is available for optional collaboration. Verify uploaded history with \`git status\`; clone if the host stripped it.`
|
|
94
94
|
: `# To use Git for collaboration, add deploy: { apt: ["git"] }. Storage durability does not require Git.`);
|
|
95
|
-
//
|
|
96
|
-
if (
|
|
97
|
-
runbook.push(``,
|
|
98
|
-
? `# Model auth: none found at the local auth path — a global \`fastagent login\` isn't read here; set FASTAGENT_AUTH_PATH (e.g. ~/.fastagent/.secrets/auth.json), or \`--run\` carries it automatically.`
|
|
99
|
-
: `# 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.`);
|
|
95
|
+
// The credential is created on the box, never carried: the box is then the only holder of its grant.
|
|
96
|
+
if (input.boxLogin) {
|
|
97
|
+
runbook.push(``, `# Model auth: once it is up, the deployment logs in to ${input.boxLogin} itself (from this workspace):`, `${deploymentLoginCommand("fly", input.boxLogin)}`);
|
|
100
98
|
}
|
|
101
99
|
// The fastagent-only post-step: point each channel at the live URL.
|
|
102
100
|
const steps = webhookRunbook(`https://${appName}.fly.dev`, channels);
|