@fastagent-sh/fastagent 0.15.0 → 0.16.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 +13 -3
- package/dist/channels/agentcore-limits.d.ts +7 -0
- package/dist/channels/agentcore-limits.js +9 -0
- package/dist/channels/agentcore-state.d.ts +83 -0
- package/dist/channels/agentcore-state.js +258 -0
- package/dist/channels/agentcore.d.ts +98 -0
- package/dist/channels/agentcore.js +260 -0
- package/dist/channels/busy.d.ts +23 -0
- package/dist/channels/busy.js +53 -0
- package/dist/channels/context-buffer.d.ts +5 -0
- package/dist/channels/context-buffer.js +5 -0
- package/dist/channels/feishu/context-buffer.d.ts +13 -8
- package/dist/channels/feishu/context-buffer.js +60 -6
- package/dist/channels/feishu/feishu-api.js +4 -1
- package/dist/channels/feishu/feishu.d.ts +0 -12
- package/dist/channels/feishu/feishu.js +143 -54
- package/dist/channels/feishu/invoke-turn.js +35 -18
- package/dist/channels/feishu/model.d.ts +0 -1
- package/dist/channels/feishu/normalize.js +0 -1
- package/dist/channels/feishu/parse.d.ts +21 -7
- package/dist/channels/feishu/parse.js +24 -7
- package/dist/channels/feishu/preview.js +3 -2
- package/dist/channels/feishu/scaffold/channel.ts +9 -8
- package/dist/channels/feishu/scaffold/feishu-send.ts +6 -4
- package/dist/channels/feishu/setup-mode.d.ts +30 -1
- package/dist/channels/feishu/setup-mode.js +27 -1
- package/dist/channels/github/github.js +8 -1
- package/dist/channels/http.js +1 -1
- package/dist/channels/lark/scaffold/channel.ts +9 -8
- package/dist/channels/lark/scaffold/lark-send.ts +6 -4
- package/dist/channels/preview-kit.d.ts +7 -1
- package/dist/channels/preview-kit.js +3 -2
- package/dist/channels/slack/parse.d.ts +16 -1
- package/dist/channels/slack/parse.js +46 -3
- package/dist/channels/slack/preview.d.ts +1 -2
- package/dist/channels/slack/preview.js +68 -24
- package/dist/channels/slack/scaffold/channel.ts +5 -5
- package/dist/channels/slack/slack-api.d.ts +3 -23
- package/dist/channels/slack/slack-api.js +6 -22
- package/dist/channels/slack/slack.d.ts +13 -20
- package/dist/channels/slack/slack.js +95 -50
- package/dist/channels/state.d.ts +11 -4
- package/dist/channels/state.js +19 -12
- package/dist/channels/tasks.d.ts +0 -6
- package/dist/channels/tasks.js +16 -1
- package/dist/channels/telegram/parse.d.ts +0 -7
- package/dist/channels/telegram/parse.js +4 -2
- package/dist/channels/telegram/scaffold/telegram-send.ts +6 -3
- package/dist/channels/telegram/telegram.js +1 -1
- package/dist/channels/text.d.ts +14 -0
- package/dist/channels/text.js +14 -0
- package/dist/channels/thread-participants.d.ts +21 -0
- package/dist/channels/thread-participants.js +132 -0
- package/dist/channels/turn-queue.js +7 -0
- package/dist/cli/add-feishu.d.ts +7 -4
- package/dist/cli/add-feishu.js +57 -37
- package/dist/cli/add-slack.d.ts +2 -1
- package/dist/cli/add-slack.js +6 -11
- package/dist/cli/commands/add.js +50 -51
- package/dist/cli/commands/attach.js +8 -4
- package/dist/cli/commands/chat.js +8 -8
- package/dist/cli/commands/deploy.d.ts +1 -1
- package/dist/cli/commands/deploy.js +323 -77
- package/dist/cli/commands/dev.js +24 -22
- package/dist/cli/commands/fire.js +15 -16
- package/dist/cli/commands/info.js +36 -29
- package/dist/cli/commands/init.d.ts +1 -1
- package/dist/cli/commands/init.js +65 -53
- package/dist/cli/commands/invoke.js +9 -6
- package/dist/cli/commands/login.js +35 -21
- package/dist/cli/commands/schedule.js +6 -8
- package/dist/cli/commands/start.js +93 -37
- package/dist/cli/commands/tool.js +22 -18
- package/dist/cli/fail.d.ts +17 -0
- package/dist/cli/fail.js +24 -0
- package/dist/cli/program.js +57 -36
- package/dist/cli/serve.d.ts +26 -6
- package/dist/cli/serve.js +62 -15
- package/dist/cli/shared.d.ts +15 -2
- package/dist/cli/shared.js +30 -18
- package/dist/deploy/agentcore/plan.d.ts +117 -0
- package/dist/deploy/agentcore/plan.js +721 -0
- package/dist/deploy/agentcore/run.d.ts +73 -0
- package/dist/deploy/agentcore/run.js +412 -0
- package/dist/deploy/agentcore/zip.d.ts +17 -0
- package/dist/deploy/agentcore/zip.js +68 -0
- package/dist/deploy/container.d.ts +26 -25
- package/dist/deploy/container.js +93 -89
- package/dist/deploy/docker/plan.d.ts +1 -1
- package/dist/deploy/docker/plan.js +12 -17
- package/dist/deploy/fly/plan.d.ts +2 -0
- package/dist/deploy/fly/plan.js +27 -19
- package/dist/deploy/fly/run.d.ts +12 -1
- package/dist/deploy/fly/run.js +36 -2
- package/dist/deploy/preflight.d.ts +11 -5
- package/dist/deploy/preflight.js +217 -65
- package/dist/deploy/railway/plan.d.ts +7 -0
- package/dist/deploy/railway/plan.js +41 -16
- package/dist/deploy/railway/run.d.ts +8 -1
- package/dist/deploy/railway/run.js +7 -2
- package/dist/deploy/runner.d.ts +5 -2
- package/dist/deploy/runner.js +9 -3
- package/dist/dev-supervisor.d.ts +11 -8
- package/dist/dev-supervisor.js +53 -51
- package/dist/engines/pi/auth.d.ts +8 -7
- package/dist/engines/pi/auth.js +12 -10
- package/dist/engines/pi/channel.d.ts +1 -1
- package/dist/engines/pi/channel.js +5 -5
- package/dist/engines/pi/chat.js +2 -2
- package/dist/engines/pi/config.d.ts +6 -46
- package/dist/engines/pi/config.js +21 -108
- package/dist/engines/pi/create.d.ts +8 -8
- package/dist/engines/pi/create.js +13 -12
- package/dist/engines/pi/definition.d.ts +7 -26
- package/dist/engines/pi/definition.js +8 -54
- package/dist/engines/pi/login.d.ts +1 -1
- package/dist/engines/pi/models.d.ts +3 -3
- package/dist/engines/pi/models.js +1 -1
- package/dist/engines/pi/{workspace.d.ts → open.d.ts} +29 -23
- package/dist/engines/pi/{workspace.js → open.js} +27 -29
- package/dist/engines/pi/session-builder.d.ts +2 -2
- package/dist/engines/pi/session-builder.js +11 -11
- package/dist/engines/pi/tool.js +4 -0
- package/dist/env.d.ts +16 -4
- package/dist/env.js +43 -5
- package/dist/host/node.d.ts +2 -2
- package/dist/loader.d.ts +2 -2
- package/dist/loader.js +3 -3
- package/dist/log.d.ts +1 -1
- package/dist/log.js +1 -1
- package/dist/paths.d.ts +138 -0
- package/dist/paths.js +326 -0
- package/dist/pi.d.ts +1 -1
- package/dist/pi.js +2 -2
- package/dist/runtime.d.ts +7 -5
- package/dist/runtime.js +2 -2
- package/dist/scaffold/add-channel.d.ts +7 -3
- package/dist/scaffold/add-channel.js +55 -29
- package/dist/scaffold/init.d.ts +32 -41
- package/dist/scaffold/init.js +161 -185
- package/dist/scaffold/templates/env.example +15 -6
- package/dist/scaffold/templates/fastagent.config.mjs +1 -1
- package/dist/scaffold/templates/gitignore +14 -6
- package/dist/scaffold/templates/persona.md +4 -2
- package/dist/scaffold/templates/secrets.gitignore +5 -0
- package/dist/scaffold/templates.d.ts +1 -7
- package/dist/scaffold/templates.js +3 -25
- package/dist/scaffold/vendor-skill.d.ts +2 -2
- package/dist/scaffold/vendor-skill.js +13 -13
- package/dist/schedule/discover.js +4 -4
- package/dist/schedule/scheduler.d.ts +40 -1
- package/dist/schedule/scheduler.js +89 -56
- package/dist/schedule/state.js +1 -1
- package/dist/schedule/wake-alarm.d.ts +47 -0
- package/dist/schedule/wake-alarm.js +136 -0
- package/dist/schedule/wakeups.d.ts +1 -0
- package/dist/schedule/wakeups.js +18 -0
- package/dist/tunnel.d.ts +3 -3
- package/dist/tunnel.js +7 -7
- package/package.json +4 -1
- package/dist/channels/feishu/owned-threads.d.ts +0 -7
- package/dist/channels/feishu/owned-threads.js +0 -47
- package/dist/channels/slack/owned-threads.d.ts +0 -6
- package/dist/channels/slack/owned-threads.js +0 -43
- package/dist/scaffold/templates/gitignore.agentdir-root +0 -5
- package/dist/scaffold/templates/gitignore.kit +0 -2
- package/dist/workspace.d.ts +0 -9
- package/dist/workspace.js +0 -45
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `fastagent deploy agentcore --run` — drive the AWS CLI + Docker to completion. The middle of the
|
|
3
|
+
* deploy the plain runbook hands to the operator; `--run` executes it so a coding agent runs ONE
|
|
4
|
+
* command. Idempotent (ECR check-then-act; `cloudformation deploy` converges the stack) and
|
|
5
|
+
* resumable: it STOPS at a human gate with one actionable line and a non-zero exit.
|
|
6
|
+
*
|
|
7
|
+
* TWO runners, one seam ({@link CliRunner}): `aws` (identity, ECR, CloudFormation) and `docker`
|
|
8
|
+
* (buildx). AgentCore is the ONE host whose image builds on the operator's machine — the platform
|
|
9
|
+
* requires linux/arm64 in the account's ECR and has no remote builder — so a missing Docker/buildx
|
|
10
|
+
* is a first-class gate, not an incidental failure.
|
|
11
|
+
*
|
|
12
|
+
* Secrets ride CloudFormation NoEcho parameters. `--parameter-overrides` on argv would put the
|
|
13
|
+
* values in the process listing (the same reason Fly imports secrets over stdin), so they go through
|
|
14
|
+
* a caller-provided temp parameters file (`file://…`, mode 0600, deleted by the caller) — the write
|
|
15
|
+
* is injected to keep this module pure and the security-sensitive wiring testable.
|
|
16
|
+
*/
|
|
17
|
+
import type { RegistrationOutcome } from "../../channels/registration.ts";
|
|
18
|
+
import type { ChannelKind } from "../../scaffold/add-channel.ts";
|
|
19
|
+
import type { CliRunner } from "../runner.ts";
|
|
20
|
+
export interface AgentcoreRunPlan {
|
|
21
|
+
/** The base name — stack `fastagent-<name>`, ECR repo `fastagent/<name>`. */
|
|
22
|
+
name: string;
|
|
23
|
+
/** Template path relative to the run cwd (kit layout: `agent/agentcore.template.yaml`). */
|
|
24
|
+
templatePath: string;
|
|
25
|
+
/** Dockerfile path for `-f` (kit layout only; the default context Dockerfile otherwise). */
|
|
26
|
+
dockerfilePath?: string;
|
|
27
|
+
/** Image tag for this deploy — the CALLER mints it unique (a timestamp): CloudFormation only rolls
|
|
28
|
+
* the runtime when the ImageUri value changes, so a reused tag would deploy nothing. */
|
|
29
|
+
tag: string;
|
|
30
|
+
/** AWS region from the caller's environment (AWS_REGION/AWS_DEFAULT_REGION), else resolved via
|
|
31
|
+
* `aws configure get region` — an unset region is a gate (the ECR registry hostname needs it). */
|
|
32
|
+
region?: string;
|
|
33
|
+
/** Secret env-var name → value (model key or FASTAGENT_AUTH_SEED + channel secrets). Mapped to the
|
|
34
|
+
* template's parameter names via {@link cfnParamName}; delivered via the params file, never argv. */
|
|
35
|
+
secrets: Record<string, string>;
|
|
36
|
+
/** Required secret names with NO local value — gated before any side effect. */
|
|
37
|
+
missingSecrets: string[];
|
|
38
|
+
channels: ChannelKind[];
|
|
39
|
+
/** Whether the topology includes the forwarder Lambda (route channels / schedules / selfSchedule).
|
|
40
|
+
* It owns the state snapshot's presigned URLs, so its absence means an invoke-only deployment
|
|
41
|
+
* with no cross-deploy state to keep. */
|
|
42
|
+
needsForwarder: boolean;
|
|
43
|
+
}
|
|
44
|
+
export type AgentcoreRunOutcome = {
|
|
45
|
+
ok: true;
|
|
46
|
+
runtimeArn: string;
|
|
47
|
+
url?: string;
|
|
48
|
+
} | {
|
|
49
|
+
ok: false;
|
|
50
|
+
gate: string;
|
|
51
|
+
};
|
|
52
|
+
/** Stack outputs (`describe-stacks --query "Stacks[0].Outputs"`) → { OutputKey: OutputValue }. */
|
|
53
|
+
export declare function parseStackOutputs(stdout: string): Record<string, string>;
|
|
54
|
+
/** The `--parameter-overrides file://` payload: a JSON array of "Key=Value" strings. The auth seed
|
|
55
|
+
* is CHUNKED across FastagentAuthSeed(2…) — AgentCore env values cap at 2048 chars and a real OAuth
|
|
56
|
+
* auth.json's base64 exceeds it; `start` reassembles (collectAuthSeed). Every chunk is emitted on
|
|
57
|
+
* every deploy, including empty trailing chunks, so CloudFormation cannot retain stale values. */
|
|
58
|
+
export declare function paramsFileContent(imageUri: string, secrets: Record<string, string>, forwarder?: {
|
|
59
|
+
bucket: string;
|
|
60
|
+
key: string;
|
|
61
|
+
}): string;
|
|
62
|
+
export interface CheckpointReply {
|
|
63
|
+
written: boolean;
|
|
64
|
+
reason?: string;
|
|
65
|
+
}
|
|
66
|
+
/** Parse the runtime's checkpoint acknowledgement without treating malformed output as success. */
|
|
67
|
+
export declare function parseCheckpointReply(stdout: string): CheckpointReply | undefined;
|
|
68
|
+
/**
|
|
69
|
+
* Run the deploy through `aws` + `docker`. `log` reports progress; the injected registrars perform
|
|
70
|
+
* post-deploy webhook steps from the builder machine against the forwarder's Function URL. Every
|
|
71
|
+
* gate is fail-visible; `writeSecretFile` is the caller's 0600-temp-file seam (see the header).
|
|
72
|
+
*/
|
|
73
|
+
export declare function deployAgentcoreRun(plan: AgentcoreRunPlan, aws: CliRunner, docker: CliRunner, log: (msg: string) => void, writeSecretFile: (content: string) => Promise<string>, writeForwarderZip: (bytes: Uint8Array) => Promise<string>, registerTelegram: (baseUrl: string) => Promise<RegistrationOutcome>, registerFeishu?: (baseUrl: string, kind: "feishu" | "lark") => Promise<RegistrationOutcome>, registerSlack?: (baseUrl: string) => Promise<RegistrationOutcome>): Promise<AgentcoreRunOutcome>;
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
import { registrationGate } from "../registration-gate.js";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { Buffer } from "node:buffer";
|
|
4
|
+
import { AUTH_SEED_CHUNK_SIZE, AUTH_SEED_MAX_CHUNKS, cfnParamName, forwarderSource, ingressSessionId, stateBucketName, } from "./plan.js";
|
|
5
|
+
import { zipSingleFile } from "./zip.js";
|
|
6
|
+
/** Stack outputs (`describe-stacks --query "Stacks[0].Outputs"`) → { OutputKey: OutputValue }. */
|
|
7
|
+
export function parseStackOutputs(stdout) {
|
|
8
|
+
try {
|
|
9
|
+
const arr = JSON.parse(stdout);
|
|
10
|
+
if (!Array.isArray(arr))
|
|
11
|
+
return {};
|
|
12
|
+
const out = {};
|
|
13
|
+
for (const o of arr) {
|
|
14
|
+
if (typeof o?.OutputKey === "string" && typeof o?.OutputValue === "string")
|
|
15
|
+
out[o.OutputKey] = o.OutputValue;
|
|
16
|
+
}
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return {};
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** The `--parameter-overrides file://` payload: a JSON array of "Key=Value" strings. The auth seed
|
|
24
|
+
* is CHUNKED across FastagentAuthSeed(2…) — AgentCore env values cap at 2048 chars and a real OAuth
|
|
25
|
+
* auth.json's base64 exceeds it; `start` reassembles (collectAuthSeed). Every chunk is emitted on
|
|
26
|
+
* every deploy, including empty trailing chunks, so CloudFormation cannot retain stale values. */
|
|
27
|
+
export function paramsFileContent(imageUri, secrets, forwarder) {
|
|
28
|
+
const params = [`ImageUri=${imageUri}`];
|
|
29
|
+
if (forwarder)
|
|
30
|
+
params.push(`StateBucket=${forwarder.bucket}`, `ForwarderS3Key=${forwarder.key}`);
|
|
31
|
+
for (const [k, v] of Object.entries(secrets)) {
|
|
32
|
+
if (k !== "FASTAGENT_AUTH_SEED")
|
|
33
|
+
params.push(`${cfnParamName(k)}=${v}`);
|
|
34
|
+
}
|
|
35
|
+
const seed = secrets.FASTAGENT_AUTH_SEED ?? "";
|
|
36
|
+
for (let i = 0; i < AUTH_SEED_MAX_CHUNKS; i++) {
|
|
37
|
+
const param = i === 0 ? "FastagentAuthSeed" : `FastagentAuthSeed${i + 1}`;
|
|
38
|
+
params.push(`${param}=${seed.slice(i * AUTH_SEED_CHUNK_SIZE, (i + 1) * AUTH_SEED_CHUNK_SIZE)}`);
|
|
39
|
+
}
|
|
40
|
+
return `${JSON.stringify(params)}\n`;
|
|
41
|
+
}
|
|
42
|
+
/** Parse the runtime's checkpoint acknowledgement without treating malformed output as success. */
|
|
43
|
+
export function parseCheckpointReply(stdout) {
|
|
44
|
+
try {
|
|
45
|
+
const parsed = JSON.parse(stdout.trim());
|
|
46
|
+
if (parsed === null ||
|
|
47
|
+
typeof parsed !== "object" ||
|
|
48
|
+
!("written" in parsed) ||
|
|
49
|
+
typeof parsed.written !== "boolean") {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
written: parsed.written,
|
|
54
|
+
reason: "reason" in parsed && typeof parsed.reason === "string" ? parsed.reason : undefined,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Run the deploy through `aws` + `docker`. `log` reports progress; the injected registrars perform
|
|
63
|
+
* post-deploy webhook steps from the builder machine against the forwarder's Function URL. Every
|
|
64
|
+
* gate is fail-visible; `writeSecretFile` is the caller's 0600-temp-file seam (see the header).
|
|
65
|
+
*/
|
|
66
|
+
export async function deployAgentcoreRun(plan, aws, docker, log, writeSecretFile, writeForwarderZip, registerTelegram, registerFeishu, registerSlack) {
|
|
67
|
+
const gate = (g) => ({ ok: false, gate: g });
|
|
68
|
+
const stack = `fastagent-${plan.name}`;
|
|
69
|
+
const repo = `fastagent/${plan.name}`;
|
|
70
|
+
// 1. Identity + region — the two facts everything downstream (registry hostname, stack region)
|
|
71
|
+
// hangs on. `sts get-caller-identity` succeeds with any working credential source.
|
|
72
|
+
const identity = await aws(["sts", "get-caller-identity", "--output", "json"], { capture: true });
|
|
73
|
+
if (identity.code === 127) {
|
|
74
|
+
return gate("aws CLI not found — install AWS CLI v2: https://docs.aws.amazon.com/cli/, then re-run");
|
|
75
|
+
}
|
|
76
|
+
if (identity.code !== 0) {
|
|
77
|
+
return gate("no working AWS credentials — run `aws configure` (or set AWS_ACCESS_KEY_ID/…), then re-run");
|
|
78
|
+
}
|
|
79
|
+
let account;
|
|
80
|
+
try {
|
|
81
|
+
const parsed = JSON.parse(identity.stdout);
|
|
82
|
+
if (typeof parsed.Account !== "string")
|
|
83
|
+
throw new Error("no Account");
|
|
84
|
+
account = parsed.Account;
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return gate("could not read the account id from `aws sts get-caller-identity` — see the output above");
|
|
88
|
+
}
|
|
89
|
+
let region = plan.region;
|
|
90
|
+
if (!region) {
|
|
91
|
+
const fromConfig = await aws(["configure", "get", "region"], { capture: true });
|
|
92
|
+
region = fromConfig.stdout.trim() || undefined;
|
|
93
|
+
}
|
|
94
|
+
if (!region) {
|
|
95
|
+
return gate("no AWS region configured — set AWS_REGION (or `aws configure set region <region>`), then re-run");
|
|
96
|
+
}
|
|
97
|
+
// 2. Docker + buildx — this host builds LOCALLY (linux/arm64 into the account's ECR; AgentCore has
|
|
98
|
+
// no remote builder), so their absence is a first-class gate with the install pointer. ANY
|
|
99
|
+
// non-zero gates BEFORE side effects: `docker version` with the daemon down exits non-127, and
|
|
100
|
+
// letting it through would create the ECR repo and then fail the build with a generic error.
|
|
101
|
+
const dockerVersion = await docker(["version"], { capture: true });
|
|
102
|
+
if (dockerVersion.code === 127) {
|
|
103
|
+
return gate("docker not found — install Docker (https://docs.docker.com/get-docker/), then re-run");
|
|
104
|
+
}
|
|
105
|
+
if (dockerVersion.code !== 0) {
|
|
106
|
+
return gate("docker daemon not reachable — start Docker Desktop (or fix your docker context), then re-run");
|
|
107
|
+
}
|
|
108
|
+
if ((await docker(["buildx", "version"], { capture: true })).code !== 0) {
|
|
109
|
+
return gate("docker buildx not available — the image must be linux/arm64 (cross-built); install buildx, then re-run");
|
|
110
|
+
}
|
|
111
|
+
// 3. Gate missing required secret VALUES before any side effect (no half-created infra).
|
|
112
|
+
if (plan.missingSecrets.length > 0) {
|
|
113
|
+
return gate(`no local value for: ${plan.missingSecrets.join(", ")} — set them in .env (or the environment) and re-run`);
|
|
114
|
+
}
|
|
115
|
+
// 3b. AgentCore env values cap at 2048 chars. The auth seed is chunked (paramsFileContent) up to
|
|
116
|
+
// its ceiling; any OTHER oversized value has no chunk lane — gate it instead of a cryptic
|
|
117
|
+
// CloudFormation "maxLength" failure mid-deploy.
|
|
118
|
+
const seed = plan.secrets.FASTAGENT_AUTH_SEED;
|
|
119
|
+
if (seed && seed.length > AUTH_SEED_CHUNK_SIZE * AUTH_SEED_MAX_CHUNKS) {
|
|
120
|
+
return gate(`your auth.json is too large to carry (${seed.length} chars base64 > ${AUTH_SEED_CHUNK_SIZE * AUTH_SEED_MAX_CHUNKS}) — ` +
|
|
121
|
+
`slim it (keep only the model's credential), or set a provider API key in .env instead`);
|
|
122
|
+
}
|
|
123
|
+
for (const [k, v] of Object.entries(plan.secrets)) {
|
|
124
|
+
if (k !== "FASTAGENT_AUTH_SEED" && v.length > 2048) {
|
|
125
|
+
return gate(`secret ${k} is ${v.length} chars — AgentCore environment values cap at 2048; shorten it`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// 4. ECR repository — check-then-act. A FAILED describe that isn't "not found" would misreport the
|
|
129
|
+
// create, but ECR's not-found also exits non-zero — so try describe, and on failure attempt the
|
|
130
|
+
// create; a create failing for a REAL reason (permissions) still gates with its own message.
|
|
131
|
+
const registry = `${account}.dkr.ecr.${region}.amazonaws.com`;
|
|
132
|
+
const image = `${registry}/${repo}:${plan.tag}`;
|
|
133
|
+
const described = await aws(["ecr", "describe-repositories", "--repository-names", repo], { capture: true });
|
|
134
|
+
if (described.code === 0) {
|
|
135
|
+
log(`ECR repository ${repo} exists — skipping create`);
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
log(`creating ECR repository ${repo}…`);
|
|
139
|
+
if ((await aws(["ecr", "create-repository", "--repository-name", repo])).code !== 0) {
|
|
140
|
+
return gate("`aws ecr create-repository` failed — see the output above; fix and re-run");
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// 4b. The deployment bucket + the forwarder package. The bucket is created OUTSIDE the stack, on
|
|
144
|
+
// purpose and unlike everything else here: it holds the agent's STATE SNAPSHOT, and AgentCore
|
|
145
|
+
// wipes the /mnt/state mount on every runtime version update (i.e. every deploy). Keeping it
|
|
146
|
+
// out of CloudFormation means a `delete-stack` — or a rolled-back create — cannot take the
|
|
147
|
+
// agent's sessions, channel state and pending wake-ups with it.
|
|
148
|
+
let forwarderParams;
|
|
149
|
+
if (plan.needsForwarder) {
|
|
150
|
+
const bucket = stateBucketName(plan.name, account);
|
|
151
|
+
if ((await aws(["s3api", "head-bucket", "--bucket", bucket], { capture: true })).code !== 0) {
|
|
152
|
+
log(`creating deployment bucket ${bucket}…`);
|
|
153
|
+
// us-east-1 is the ONE region that must not carry a LocationConstraint (the API rejects it).
|
|
154
|
+
const createArgs = ["s3api", "create-bucket", "--bucket", bucket];
|
|
155
|
+
if (region !== "us-east-1")
|
|
156
|
+
createArgs.push("--create-bucket-configuration", `LocationConstraint=${region}`);
|
|
157
|
+
if ((await aws(createArgs)).code !== 0) {
|
|
158
|
+
return gate(`\`aws s3api create-bucket --bucket ${bucket}\` failed — see the output above; fix and re-run`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// CONVERGE the properties on EVERY deploy, not just at creation. They are what makes the bucket
|
|
162
|
+
// safe (nothing public) and recoverable (a bad write is not the end of the agent's memory); doing
|
|
163
|
+
// them only in the create branch means a run that failed halfway leaves a bucket that looks
|
|
164
|
+
// finished forever after, and ignoring the exit codes means "deployed" would be reported over a
|
|
165
|
+
// world-readable or unversioned store of the agent's credentials.
|
|
166
|
+
const converge = [
|
|
167
|
+
{
|
|
168
|
+
label: "block public access",
|
|
169
|
+
args: [
|
|
170
|
+
"s3api",
|
|
171
|
+
"put-public-access-block",
|
|
172
|
+
"--bucket",
|
|
173
|
+
bucket,
|
|
174
|
+
"--public-access-block-configuration",
|
|
175
|
+
"BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true",
|
|
176
|
+
],
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
label: "enable versioning",
|
|
180
|
+
args: ["s3api", "put-bucket-versioning", "--bucket", bucket, "--versioning-configuration", "Status=Enabled"],
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
label: "set the snapshot lifecycle",
|
|
184
|
+
args: [
|
|
185
|
+
"s3api",
|
|
186
|
+
"put-bucket-lifecycle-configuration",
|
|
187
|
+
"--bucket",
|
|
188
|
+
bucket,
|
|
189
|
+
"--lifecycle-configuration",
|
|
190
|
+
JSON.stringify({
|
|
191
|
+
Rules: [
|
|
192
|
+
{
|
|
193
|
+
ID: "fastagent-expire-old-snapshots",
|
|
194
|
+
Status: "Enabled",
|
|
195
|
+
Filter: { Prefix: "state/" },
|
|
196
|
+
NoncurrentVersionExpiration: { NoncurrentDays: 7 },
|
|
197
|
+
},
|
|
198
|
+
],
|
|
199
|
+
}),
|
|
200
|
+
],
|
|
201
|
+
},
|
|
202
|
+
];
|
|
203
|
+
for (const step of converge) {
|
|
204
|
+
// `capture` keeps the CLI's JSON off the deploy log: the exit code is the signal, and a failure
|
|
205
|
+
// gates with the step's name below.
|
|
206
|
+
if ((await aws(step.args, { capture: true })).code !== 0) {
|
|
207
|
+
return gate(`could not ${step.label} on ${bucket} — refusing to store agent state in it; fix and re-run`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
// Content-hashed key: CloudFormation rolls the function only when a parameter VALUE changes, so
|
|
211
|
+
// identical source must map to an identical key (hence the deterministic zip) and changed source
|
|
212
|
+
// to a new one.
|
|
213
|
+
const zip = zipSingleFile("index.js", Buffer.from(forwarderSource()));
|
|
214
|
+
const key = `forwarder/${createHash("sha256").update(zip).digest("hex").slice(0, 16)}.zip`;
|
|
215
|
+
const zipPath = await writeForwarderZip(zip);
|
|
216
|
+
if ((await aws(["s3", "cp", zipPath, `s3://${bucket}/${key}`])).code !== 0) {
|
|
217
|
+
return gate("uploading the forwarder package to S3 failed — see the output above; fix and re-run");
|
|
218
|
+
}
|
|
219
|
+
forwarderParams = { bucket, key };
|
|
220
|
+
}
|
|
221
|
+
// 5. Registry login — the password flows stdout→stdin between the two runners, never argv.
|
|
222
|
+
const password = await aws(["ecr", "get-login-password"], { capture: true });
|
|
223
|
+
if (password.code !== 0)
|
|
224
|
+
return gate("`aws ecr get-login-password` failed — see the output above");
|
|
225
|
+
if ((await docker(["login", "--username", "AWS", "--password-stdin", registry], { input: password.stdout })).code !== 0) {
|
|
226
|
+
return gate("`docker login` to ECR failed — see the output above");
|
|
227
|
+
}
|
|
228
|
+
// 6. Build (linux/arm64) + push in one step.
|
|
229
|
+
log(`building + pushing ${image} (linux/arm64)…`);
|
|
230
|
+
const buildArgs = ["buildx", "build", "--platform", "linux/arm64", "-t", image, "--push"];
|
|
231
|
+
if (plan.dockerfilePath)
|
|
232
|
+
buildArgs.push("-f", plan.dockerfilePath);
|
|
233
|
+
buildArgs.push(".");
|
|
234
|
+
if ((await docker(buildArgs)).code !== 0) {
|
|
235
|
+
return gate("`docker buildx build` failed — see the output above; fix and re-run");
|
|
236
|
+
}
|
|
237
|
+
// 7. Deploy the stack. Secret values ride the temp params file (file://), never argv.
|
|
238
|
+
// --no-fail-on-empty-changeset: a re-run whose only change already applied must not gate.
|
|
239
|
+
// Self-heal the one un-resumable state first: a FAILED first create leaves the stack in
|
|
240
|
+
// ROLLBACK_COMPLETE, which CloudFormation refuses to update — without this, "fix and re-run"
|
|
241
|
+
// (our own gate advice) would dead-end on a different error. Nothing real is lost by deleting:
|
|
242
|
+
// a ROLLBACK_COMPLETE stack holds no live resources.
|
|
243
|
+
const status = await aws([
|
|
244
|
+
"cloudformation",
|
|
245
|
+
"describe-stacks",
|
|
246
|
+
"--stack-name",
|
|
247
|
+
stack,
|
|
248
|
+
"--query",
|
|
249
|
+
"Stacks[0].StackStatus",
|
|
250
|
+
"--output",
|
|
251
|
+
"text",
|
|
252
|
+
], { capture: true });
|
|
253
|
+
if (status.code === 0 && status.stdout.trim() === "ROLLBACK_COMPLETE") {
|
|
254
|
+
log(`stack ${stack} is ROLLBACK_COMPLETE (a failed first create) — deleting it before re-creating…`);
|
|
255
|
+
if ((await aws(["cloudformation", "delete-stack", "--stack-name", stack])).code !== 0) {
|
|
256
|
+
return gate("`aws cloudformation delete-stack` failed — see the output above");
|
|
257
|
+
}
|
|
258
|
+
if ((await aws(["cloudformation", "wait", "stack-delete-complete", "--stack-name", stack])).code !== 0) {
|
|
259
|
+
return gate("waiting for the stack delete failed — see the output above; re-run once it is gone");
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
log(`deploying stack ${stack}…`);
|
|
263
|
+
const paramsPath = await writeSecretFile(paramsFileContent(image, plan.secrets, forwarderParams));
|
|
264
|
+
const deployed = await aws([
|
|
265
|
+
"cloudformation",
|
|
266
|
+
"deploy",
|
|
267
|
+
"--stack-name",
|
|
268
|
+
stack,
|
|
269
|
+
"--template-file",
|
|
270
|
+
plan.templatePath,
|
|
271
|
+
"--capabilities",
|
|
272
|
+
"CAPABILITY_IAM",
|
|
273
|
+
"--no-fail-on-empty-changeset",
|
|
274
|
+
"--parameter-overrides",
|
|
275
|
+
`file://${paramsPath}`,
|
|
276
|
+
]);
|
|
277
|
+
if (deployed.code !== 0) {
|
|
278
|
+
return gate("`aws cloudformation deploy` failed — inspect the stack events " +
|
|
279
|
+
`(aws cloudformation describe-stack-events --stack-name ${stack}), fix, and re-run`);
|
|
280
|
+
}
|
|
281
|
+
// 8. Outputs — the runtime ARN (the data plane) and the forwarder URL (the webhook surface).
|
|
282
|
+
const outputsQuery = await aws(["cloudformation", "describe-stacks", "--stack-name", stack, "--query", "Stacks[0].Outputs", "--output", "json"], { capture: true });
|
|
283
|
+
if (outputsQuery.code !== 0)
|
|
284
|
+
return gate("`aws cloudformation describe-stacks` failed — see the output above");
|
|
285
|
+
const outputs = parseStackOutputs(outputsQuery.stdout);
|
|
286
|
+
const runtimeArn = outputs.RuntimeArn;
|
|
287
|
+
if (!runtimeArn)
|
|
288
|
+
return gate("stack has no RuntimeArn output — was the template edited? Regenerate with --force");
|
|
289
|
+
const url = outputs.ForwarderUrl?.replace(/\/$/, ""); // registrars append /<path>; no double slash
|
|
290
|
+
// 8b. Restart the ingress session so the new image serves IMMEDIATELY. A live session keeps its
|
|
291
|
+
// old compute until the idle timeout or the max compute lifetime (8 h) — without this, a
|
|
292
|
+
// redeploy "succeeds" while an actively-chatting session keeps answering from the PREVIOUS
|
|
293
|
+
// image (the exact silent trap the first real deploy hit). Failure is advisory, never a gate:
|
|
294
|
+
// on a first deploy the session does not exist yet, and the stop is an immediacy optimization
|
|
295
|
+
// — the platform's reclaim gets there eventually. An in-flight turn on the old compute is cut;
|
|
296
|
+
// the checkpoint above is what lets a replaying channel re-run it. Only when a forwarder exists
|
|
297
|
+
// (the ingress session is the forwarder's session; pure-invoke deployments have none).
|
|
298
|
+
// Keyed on the FORWARDER: every current forwarder has a callback URL for state-capability refresh,
|
|
299
|
+
// and every forwarder topology has an ingress session whose next event would otherwise land on compute still
|
|
300
|
+
// running the previous image.
|
|
301
|
+
if (plan.needsForwarder) {
|
|
302
|
+
// CHECKPOINT FIRST. The stop cuts whatever turn is running, and that turn's durable intent was
|
|
303
|
+
// written to a mount the version update erases — so without this flush "replay re-runs it" would
|
|
304
|
+
// be false: the intent never reaches S3 and the message is simply gone. Best-effort: a session
|
|
305
|
+
// that is not up has nothing to lose, and a failure here must not block the (already applied)
|
|
306
|
+
// deploy — it only downgrades the promise, so say so.
|
|
307
|
+
const checkpointPayloadPath = await writeSecretFile(`${JSON.stringify({ kind: "checkpoint", auth: plan.secrets.FASTAGENT_INGRESS_SECRET })}\n`);
|
|
308
|
+
const checkpoint = await aws([
|
|
309
|
+
"bedrock-agentcore",
|
|
310
|
+
"invoke-agent-runtime",
|
|
311
|
+
"--agent-runtime-arn",
|
|
312
|
+
runtimeArn,
|
|
313
|
+
"--runtime-session-id",
|
|
314
|
+
ingressSessionId(plan.name),
|
|
315
|
+
"--payload",
|
|
316
|
+
`file://${checkpointPayloadPath}`,
|
|
317
|
+
"--cli-binary-format",
|
|
318
|
+
"raw-in-base64-out",
|
|
319
|
+
"/dev/stdout",
|
|
320
|
+
], { capture: true, captureStderr: true });
|
|
321
|
+
// Report what the container ACTUALLY did. This line is the only signal an operator has about
|
|
322
|
+
// whether an in-flight turn survived the deploy, so a blanket "checkpointed" — printed even when
|
|
323
|
+
// nothing was written — would be worse than no line at all.
|
|
324
|
+
if (checkpoint.code !== 0) {
|
|
325
|
+
log("note: could not reach the ingress session to checkpoint — if a turn was in flight it is lost " +
|
|
326
|
+
"rather than replayed (see the output above)");
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
const reply = parseCheckpointReply(checkpoint.stdout);
|
|
330
|
+
if (reply?.written) {
|
|
331
|
+
log("checkpointed the ingress session (an interrupted turn can be replayed)");
|
|
332
|
+
}
|
|
333
|
+
else if (reply) {
|
|
334
|
+
// The ordinary case: the session was already idle-reclaimed, so its snapshot was written when
|
|
335
|
+
// it settled and there is nothing in flight to lose.
|
|
336
|
+
log(`note: nothing to checkpoint${reply.reason ? ` — ${reply.reason}` : " (no session was running)"}`);
|
|
337
|
+
}
|
|
338
|
+
else {
|
|
339
|
+
log("warn: ingress session returned an invalid checkpoint response — could not verify the state snapshot");
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
log("stopping the ingress session so the new image serves immediately…");
|
|
343
|
+
const stopCommand = [
|
|
344
|
+
"bedrock-agentcore",
|
|
345
|
+
"stop-runtime-session",
|
|
346
|
+
"--agent-runtime-arn",
|
|
347
|
+
runtimeArn,
|
|
348
|
+
"--runtime-session-id",
|
|
349
|
+
ingressSessionId(plan.name),
|
|
350
|
+
];
|
|
351
|
+
const stopped = await aws(stopCommand, { capture: true, captureStderr: true });
|
|
352
|
+
if (stopped.code !== 0) {
|
|
353
|
+
// Classify, don't guess: "no session yet" (first deploy — expected, quiet note) vs a REAL stop
|
|
354
|
+
// failure (permissions/CLI/network — the old image may keep serving, say so loudly with the
|
|
355
|
+
// manual command). Not a gate: the deploy itself succeeded, and stop is an immediacy
|
|
356
|
+
// optimization — the platform's reclaim converges regardless.
|
|
357
|
+
const stderr = stopped.stderr ?? "";
|
|
358
|
+
if (/ResourceNotFound|not\s*found|does not exist/i.test(stderr)) {
|
|
359
|
+
log("note: no ingress session to stop (first deploy, or already reclaimed)");
|
|
360
|
+
}
|
|
361
|
+
else {
|
|
362
|
+
log(`warn: could not stop the ingress session — an ACTIVE session may keep serving the PREVIOUS ` +
|
|
363
|
+
`image until reclaimed (idle timeout / 8 h ceiling). Stop it manually: aws ${stopCommand.join(" ")}`);
|
|
364
|
+
const firstLine = stderr.trim().split("\n")[0];
|
|
365
|
+
if (firstLine)
|
|
366
|
+
log(`warn: ${firstLine}`);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
// 9. Post-deploy webhook registration — same registrar seam as every host, pointed at the
|
|
371
|
+
// forwarder's Function URL. Gate policy is the shared registration-gate kernel.
|
|
372
|
+
if (plan.channels.length > 0 && !url) {
|
|
373
|
+
return gate("channels are declared but the stack has no ForwarderUrl output — regenerate the template with --force");
|
|
374
|
+
}
|
|
375
|
+
const reg = registrationGate(log, "re-run to retry registration (steps already done are skipped)");
|
|
376
|
+
if (url) {
|
|
377
|
+
if (plan.channels.includes("telegram")) {
|
|
378
|
+
log("registering telegram webhook…");
|
|
379
|
+
reg.track("telegram", await registerTelegram(url));
|
|
380
|
+
}
|
|
381
|
+
if (plan.channels.includes("github")) {
|
|
382
|
+
log(`github: set the webhook in the repo (Settings → Webhooks) → ${url}/webhook`);
|
|
383
|
+
reg.track("github", "manual"); // always a human step — re-surfaced after the registrar output
|
|
384
|
+
}
|
|
385
|
+
if (plan.channels.includes("slack")) {
|
|
386
|
+
if (registerSlack) {
|
|
387
|
+
log("registering slack event URL…");
|
|
388
|
+
reg.track("slack", await registerSlack(url));
|
|
389
|
+
}
|
|
390
|
+
else {
|
|
391
|
+
log(`slack: set Event Subscriptions → Request URL → ${url}/slack`);
|
|
392
|
+
reg.track("slack", "manual");
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
for (const kind of ["feishu", "lark"]) {
|
|
396
|
+
if (!plan.channels.includes(kind))
|
|
397
|
+
continue;
|
|
398
|
+
if (registerFeishu) {
|
|
399
|
+
log(`registering ${kind} event URL…`);
|
|
400
|
+
reg.track(kind, await registerFeishu(url, kind));
|
|
401
|
+
}
|
|
402
|
+
else {
|
|
403
|
+
log(`${kind}: set the event Request URL (developer console → Events & Callbacks) → ${url}/${kind}`);
|
|
404
|
+
reg.track(kind, "manual");
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
const registrationGateMsg = reg.gate();
|
|
409
|
+
if (registrationGateMsg)
|
|
410
|
+
return gate(registrationGateMsg);
|
|
411
|
+
return { ok: true, runtimeArn, url };
|
|
412
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A minimal, DETERMINISTIC single-file ZIP writer — just enough to package the forwarder Lambda.
|
|
3
|
+
*
|
|
4
|
+
* WHY NOT A DEPENDENCY: this runs in the deploy path of a tool whose whole promise is "no build
|
|
5
|
+
* step"; pulling an archiver in for ~40 lines of well-specified format would be the wrong trade.
|
|
6
|
+
* WHY NOT `zip(1)`: it is absent on plenty of machines (and on Windows), and its output embeds the
|
|
7
|
+
* current time — the forwarder's S3 key is CONTENT-HASHED, so identical source must produce
|
|
8
|
+
* identical bytes or every deploy would look like a code change to CloudFormation.
|
|
9
|
+
*
|
|
10
|
+
* STORE (no compression) on purpose: the payload is a few KB of JavaScript, and the format's stored
|
|
11
|
+
* form is trivially verifiable. Fixed 1980-01-01 timestamps keep the output byte-stable.
|
|
12
|
+
*/
|
|
13
|
+
import { Buffer } from "node:buffer";
|
|
14
|
+
/**
|
|
15
|
+
* Build a ZIP archive containing exactly one stored entry. Byte-identical for identical inputs.
|
|
16
|
+
*/
|
|
17
|
+
export declare function zipSingleFile(name: string, content: Buffer): Buffer;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A minimal, DETERMINISTIC single-file ZIP writer — just enough to package the forwarder Lambda.
|
|
3
|
+
*
|
|
4
|
+
* WHY NOT A DEPENDENCY: this runs in the deploy path of a tool whose whole promise is "no build
|
|
5
|
+
* step"; pulling an archiver in for ~40 lines of well-specified format would be the wrong trade.
|
|
6
|
+
* WHY NOT `zip(1)`: it is absent on plenty of machines (and on Windows), and its output embeds the
|
|
7
|
+
* current time — the forwarder's S3 key is CONTENT-HASHED, so identical source must produce
|
|
8
|
+
* identical bytes or every deploy would look like a code change to CloudFormation.
|
|
9
|
+
*
|
|
10
|
+
* STORE (no compression) on purpose: the payload is a few KB of JavaScript, and the format's stored
|
|
11
|
+
* form is trivially verifiable. Fixed 1980-01-01 timestamps keep the output byte-stable.
|
|
12
|
+
*/
|
|
13
|
+
import { Buffer } from "node:buffer";
|
|
14
|
+
import { crc32 } from "node:zlib";
|
|
15
|
+
/** MS-DOS epoch (1980-01-01 00:00:00) — the earliest the format can express, and a constant. */
|
|
16
|
+
const DOS_DATE = 0x0021;
|
|
17
|
+
const DOS_TIME = 0x0000;
|
|
18
|
+
/**
|
|
19
|
+
* Build a ZIP archive containing exactly one stored entry. Byte-identical for identical inputs.
|
|
20
|
+
*/
|
|
21
|
+
export function zipSingleFile(name, content) {
|
|
22
|
+
const nameBytes = Buffer.from(name, "utf8");
|
|
23
|
+
const sum = crc32(content);
|
|
24
|
+
const localHeader = Buffer.alloc(30);
|
|
25
|
+
localHeader.writeUInt32LE(0x04034b50, 0); // local file header signature
|
|
26
|
+
localHeader.writeUInt16LE(20, 4); // version needed to extract (2.0)
|
|
27
|
+
localHeader.writeUInt16LE(0, 6); // general purpose flags
|
|
28
|
+
localHeader.writeUInt16LE(0, 8); // method: 0 = stored
|
|
29
|
+
localHeader.writeUInt16LE(DOS_TIME, 10);
|
|
30
|
+
localHeader.writeUInt16LE(DOS_DATE, 12);
|
|
31
|
+
localHeader.writeUInt32LE(sum, 14);
|
|
32
|
+
localHeader.writeUInt32LE(content.byteLength, 18); // compressed size (= raw, stored)
|
|
33
|
+
localHeader.writeUInt32LE(content.byteLength, 22); // uncompressed size
|
|
34
|
+
localHeader.writeUInt16LE(nameBytes.byteLength, 26);
|
|
35
|
+
localHeader.writeUInt16LE(0, 28); // extra field length
|
|
36
|
+
const centralHeader = Buffer.alloc(46);
|
|
37
|
+
centralHeader.writeUInt32LE(0x02014b50, 0); // central directory header signature
|
|
38
|
+
centralHeader.writeUInt16LE(20, 4); // version made by
|
|
39
|
+
centralHeader.writeUInt16LE(20, 6); // version needed to extract
|
|
40
|
+
centralHeader.writeUInt16LE(0, 8);
|
|
41
|
+
centralHeader.writeUInt16LE(0, 10);
|
|
42
|
+
centralHeader.writeUInt16LE(DOS_TIME, 12);
|
|
43
|
+
centralHeader.writeUInt16LE(DOS_DATE, 14);
|
|
44
|
+
centralHeader.writeUInt32LE(sum, 16);
|
|
45
|
+
centralHeader.writeUInt32LE(content.byteLength, 20);
|
|
46
|
+
centralHeader.writeUInt32LE(content.byteLength, 24);
|
|
47
|
+
centralHeader.writeUInt16LE(nameBytes.byteLength, 28);
|
|
48
|
+
centralHeader.writeUInt16LE(0, 30); // extra field length
|
|
49
|
+
centralHeader.writeUInt16LE(0, 32); // file comment length
|
|
50
|
+
centralHeader.writeUInt16LE(0, 34); // disk number start
|
|
51
|
+
centralHeader.writeUInt16LE(0, 36); // internal attributes
|
|
52
|
+
// External attributes: regular file, rw-r--r-- in the high 16 bits. `>>> 0` because JS bitwise
|
|
53
|
+
// operators are 32-bit SIGNED — the shifted value is negative without it.
|
|
54
|
+
centralHeader.writeUInt32LE((0o100644 << 16) >>> 0, 38);
|
|
55
|
+
centralHeader.writeUInt32LE(0, 42); // local header offset (single entry: always 0)
|
|
56
|
+
const centralSize = centralHeader.byteLength + nameBytes.byteLength;
|
|
57
|
+
const centralOffset = localHeader.byteLength + nameBytes.byteLength + content.byteLength;
|
|
58
|
+
const end = Buffer.alloc(22);
|
|
59
|
+
end.writeUInt32LE(0x06054b50, 0); // end of central directory signature
|
|
60
|
+
end.writeUInt16LE(0, 4); // this disk
|
|
61
|
+
end.writeUInt16LE(0, 6); // disk with central directory
|
|
62
|
+
end.writeUInt16LE(1, 8); // entries on this disk
|
|
63
|
+
end.writeUInt16LE(1, 10); // total entries
|
|
64
|
+
end.writeUInt32LE(centralSize, 12);
|
|
65
|
+
end.writeUInt32LE(centralOffset, 16);
|
|
66
|
+
end.writeUInt16LE(0, 20); // comment length
|
|
67
|
+
return Buffer.concat([localHeader, nameBytes, content, centralHeader, nameBytes, end]);
|
|
68
|
+
}
|
|
@@ -1,9 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The portable container — Dockerfile + .dockerignore, host-neutral. Every deploy target ships these
|
|
3
|
-
* unchanged; only the host config (fly.toml / railway.json) and the CLI sequence differ. The image
|
|
4
|
-
* runs `fastagent start /app` on `$PORT` with state under `$FASTAGENT_STATE_DIR` — standard container
|
|
5
|
-
* conventions, no host coupling. Extracted from the Fly plan when Railway became the second target.
|
|
6
|
-
*/
|
|
7
1
|
export interface Artifact {
|
|
8
2
|
path: string;
|
|
9
3
|
content: string;
|
|
@@ -17,8 +11,10 @@ export declare const GENERATED_DOCKERFILE_MARKER = "# Generated by `fastagent de
|
|
|
17
11
|
* Both the "keep vs flag-as-stale" decision and the apt warning share this; a split would let them
|
|
18
12
|
* disagree on the same file (e.g. a marker not at byte 0). */
|
|
19
13
|
export declare function isGeneratedDockerfile(content: string): boolean;
|
|
14
|
+
/** Did fastagent generate this `.dockerignore`? (Same ownership rule as {@link isGeneratedDockerfile}.) */
|
|
15
|
+
export declare function isGeneratedDockerignore(content: string): boolean;
|
|
20
16
|
export interface ContainerInput {
|
|
21
|
-
/** Whether the
|
|
17
|
+
/** Whether the agent has a package.json (a code agent); else a pure markdown/skills agent. */
|
|
22
18
|
hasPackageJson: boolean;
|
|
23
19
|
/** The package manager the generated image targets: `bun` (packageManager: bun / a bun lockfile) gets an
|
|
24
20
|
* `oven/bun` base + `bun install` + `bun run fastagent`; otherwise npm on `node:22-slim`. */
|
|
@@ -32,25 +28,30 @@ export interface ContainerInput {
|
|
|
32
28
|
version: string;
|
|
33
29
|
/** Extra apt packages (fastagent.config deploy.apt) baked in for the agent's tools — git, ripgrep, …. */
|
|
34
30
|
apt?: string[];
|
|
35
|
-
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
*
|
|
42
|
-
|
|
43
|
-
|
|
31
|
+
/** Where deploy's artifacts and the agent's own files sit, relative to the BUILD CONTEXT (which is
|
|
32
|
+
* always the workspace): the agent directory's name plus a slash when it sits INSIDE the workspace,
|
|
33
|
+
* `""` when the agent IS the workspace. ONE derived value — an earlier shape branched on a `nested`
|
|
34
|
+
* boolean at fifteen separate sites, and each site was a chance for the two to disagree. */
|
|
35
|
+
agentPrefix: string;
|
|
36
|
+
/** Workspace-relative machinery paths the NAME-based excludes below would miss — a
|
|
37
|
+
* `FASTAGENT_SECRETS_DIR`/`FASTAGENT_STATE_DIR` pointed at an in-tree directory not called
|
|
38
|
+
* `.secrets`/`.state`. Preflight resolves them; excluding by PATH is what keeps `COPY . .` from
|
|
39
|
+
* baking a credential (or the build machine's sessions). */
|
|
40
|
+
machineryPaths?: string[];
|
|
41
|
+
/** Whether the baked workspace ships a `.git` (preflight fact). When true, preflight has already
|
|
42
|
+
* merged "git" into `apt` (the write-back loop needs history + binary together); the plans word
|
|
43
|
+
* their runbook's freshness/write-back guidance from the same fact. */
|
|
44
|
+
shipsGit?: boolean;
|
|
44
45
|
}
|
|
45
46
|
/**
|
|
46
|
-
* The Dockerfile + ignore artifacts — spread into any host's artifact list.
|
|
47
|
-
* ContainerInput.
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
* per-Dockerfile `
|
|
53
|
-
*
|
|
54
|
-
*
|
|
47
|
+
* The Dockerfile + ignore artifacts — spread into any host's artifact list. Everything is prefixed with
|
|
48
|
+
* {@link ContainerInput.agentPrefix}, so a NESTED agent's Dockerfile lands under `fastagent/` (never
|
|
49
|
+
* colliding with one the workspace already owns) while an agent that IS the workspace lands it at the root, where it IS
|
|
50
|
+
* the agent's own file. The ignore ships in TWO forms because context packing is host-CLI-owned and
|
|
51
|
+
* inconsistent: (1) a ROOT `.dockerignore` — the only form flyctl/railway's own context packers
|
|
52
|
+
* reliably read (kept if the workspace already has one — preflight then checks the machinery/secret
|
|
53
|
+
* excludes it must carry) — and (2) a per-Dockerfile `Dockerfile.dockerignore` for plain docker/buildx
|
|
54
|
+
* builds. When the agent sits inside the workspace that root file is the ONE write deploy makes outside the agent dir, and
|
|
55
|
+
* only at deploy time — without it the host CLI's packer would bake `.secrets/` into the image.
|
|
55
56
|
*/
|
|
56
57
|
export declare function containerArtifacts(input: ContainerInput): Artifact[];
|