@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
package/dist/deploy/preflight.js
CHANGED
|
@@ -9,23 +9,59 @@
|
|
|
9
9
|
* the run modules' {@link import("./fly/run.ts").FlyRunOutcome}: a model that won't travel is a GATE the
|
|
10
10
|
* CLI stops on, distinct from the advisory warnings/notes it prints and proceeds past.
|
|
11
11
|
*/
|
|
12
|
-
import { readFile } from "node:fs/promises";
|
|
13
|
-
import { join, relative, sep } from "node:path";
|
|
12
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
13
|
+
import { basename, isAbsolute, join, relative, sep } from "node:path";
|
|
14
|
+
import ignore from "ignore";
|
|
14
15
|
import { resolveAuthPath } from "../engines/pi/config.js";
|
|
16
|
+
import { resolveSecretsDir, resolveStateRoot } from "../paths.js";
|
|
15
17
|
import { inspectChannels } from "../engines/pi/channel.js";
|
|
16
18
|
import { discoverScheduleFiles } from "../schedule/discover.js";
|
|
17
19
|
import { createPiModels, probeAuthSource } from "../engines/pi/models.js";
|
|
18
20
|
import { CHANNEL_KINDS } from "../scaffold/add-channel.js";
|
|
19
|
-
import { exists } from "../
|
|
21
|
+
import { exists } from "../paths.js";
|
|
20
22
|
import { detectRuntime, readPackageJson } from "../runtime.js";
|
|
21
23
|
import { fastagentVersion } from "../version.js";
|
|
22
|
-
import { isGeneratedDockerfile } from "./container.js";
|
|
24
|
+
import { isGeneratedDockerfile, isGeneratedDockerignore } from "./container.js";
|
|
25
|
+
/**
|
|
26
|
+
* "Would docker's packer drop this path?" — built from a `.dockerignore`'s text via the `ignore`
|
|
27
|
+
* matcher (the same library the workspace ignore files use), so `!` negation and last-match-wins are
|
|
28
|
+
* the library's problem, not ours. Anchoring is normalized: dockerignore patterns are root-anchored
|
|
29
|
+
* while .gitignore's match at any depth, so a bare `foo` becomes `/foo` — without that, a root-only
|
|
30
|
+
* `.secrets` line would read as covering `fastagent/.secrets` and hand back a false all-clear on the
|
|
31
|
+
* exact check that guards credentials.
|
|
32
|
+
*
|
|
33
|
+
* Known dialect gap: `ignore` keeps git's rule that a path under an EXCLUDED directory cannot be
|
|
34
|
+
* re-included, which docker does not have — so an allowlist file (`*` + `!fastagent` + `!fastagent/**`)
|
|
35
|
+
* can read as excluding a path docker would ship. The callers below absorb that: the drop-the-agent
|
|
36
|
+
* gate requires the agent DIRECTORY itself to read as excluded too, which an allowlist re-includes.
|
|
37
|
+
*/
|
|
38
|
+
function dockerignoreMatcher(text) {
|
|
39
|
+
const anchored = text
|
|
40
|
+
.split("\n")
|
|
41
|
+
.map((raw) => {
|
|
42
|
+
const line = raw.trim();
|
|
43
|
+
if (line === "" || line.startsWith("#"))
|
|
44
|
+
return line;
|
|
45
|
+
const negated = line.startsWith("!");
|
|
46
|
+
const pattern = negated ? line.slice(1) : line;
|
|
47
|
+
if (pattern.startsWith("/") || pattern.startsWith("**/"))
|
|
48
|
+
return line;
|
|
49
|
+
return `${negated ? "!" : ""}/${pattern}`;
|
|
50
|
+
})
|
|
51
|
+
.join("\n");
|
|
52
|
+
const matcher = ignore({ ignorecase: false }).add(anchored);
|
|
53
|
+
return (path) => matcher.ignores(path);
|
|
54
|
+
}
|
|
23
55
|
/**
|
|
24
56
|
* Run the host-neutral pre-flight. Throws on a real fault (an unreadable channels/ dir, a throwing
|
|
25
57
|
* provider) — the CLI wraps the call in its `failStartup` so the fault surfaces and exits, never silently.
|
|
26
58
|
*/
|
|
27
59
|
export async function preflightDeploy(input) {
|
|
28
|
-
const {
|
|
60
|
+
const { placement: { agentDir, workspace }, config, modelSpec, run, force, externalClock, authPathFlag, } = input;
|
|
61
|
+
// The ONE derived placement fact every host plan needs: where the agent's files sit relative to the
|
|
62
|
+
// build context (the workspace). Nested → "fastagent/"; flat → "" (the agent IS the workspace root).
|
|
63
|
+
const nested = agentDir !== workspace;
|
|
64
|
+
const agentPrefix = nested ? `${basename(agentDir)}/` : "";
|
|
29
65
|
const messages = [];
|
|
30
66
|
// The deployed box resolves the model from fastagent.config.ts ONLY (in the image); a model set via
|
|
31
67
|
// env/flag/.env doesn't travel. `--run` would ship a known crash-loop — hard gate; generate-only warns.
|
|
@@ -74,14 +110,14 @@ export async function preflightDeploy(input) {
|
|
|
74
110
|
// ("the generated plan…"): in KEEP mode an existing fly.toml is not rewritten — the CLI warns separately
|
|
75
111
|
// when a kept fly.toml still scales to zero.
|
|
76
112
|
const hasTimeTriggers = (await discoverScheduleFiles(agentDir)).length > 0 || !!config.selfSchedule;
|
|
77
|
-
if (longConnectionChannels.length > 0) {
|
|
113
|
+
if (longConnectionChannels.length > 0 && !externalClock) {
|
|
78
114
|
messages.push({
|
|
79
115
|
level: "note",
|
|
80
116
|
text: `long-connection channel present (${longConnectionChannels.join(", ")}) — a GENERATED plan keeps one machine running ` +
|
|
81
117
|
`(an outbound connection cannot wake a scaled-to-zero service).`,
|
|
82
118
|
});
|
|
83
119
|
}
|
|
84
|
-
if (hasTimeTriggers) {
|
|
120
|
+
if (hasTimeTriggers && !externalClock) {
|
|
85
121
|
messages.push({
|
|
86
122
|
level: "note",
|
|
87
123
|
text: `schedules/self-scheduling present — a GENERATED plan keeps one machine running (cron/wake has ` +
|
|
@@ -90,46 +126,37 @@ export async function preflightDeploy(input) {
|
|
|
90
126
|
}
|
|
91
127
|
// Probe auth from the SAME project-level file the opener/login use — not the global default, which would
|
|
92
128
|
// miss a `fastagent login` credential and falsely report "none configured".
|
|
93
|
-
const authPath = resolveAuthPath(
|
|
129
|
+
const authPath = resolveAuthPath(agentDir, authPathFlag);
|
|
94
130
|
const modelAuth = modelSpec ? await probeAuthSource(createPiModels({ authPath }), modelSpec) : undefined;
|
|
95
|
-
// Container facts (shared by every host) + the warnings that follow.
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const
|
|
101
|
-
const factsDir = kitDir ? agentDir : target;
|
|
102
|
-
if (kitDir && run) {
|
|
103
|
-
// The repo-as-workspace deployment shape remains experimental for every target. Generation +
|
|
104
|
-
// runbook are supported; automated runners stay gated until an explicit end-to-end smoke validates
|
|
105
|
-
// context packing, ignore rules, installed deps, state, and write-back for this layout.
|
|
106
|
-
return {
|
|
107
|
-
ok: false,
|
|
108
|
-
gate: `--run is not yet supported for the agentDir layout — run the same deploy without --run and follow the printed runbook`,
|
|
109
|
-
};
|
|
110
|
-
}
|
|
111
|
-
const hasPackageJson = await exists(join(factsDir, "package.json"));
|
|
112
|
-
const pkg = await readPackageJson(factsDir);
|
|
113
|
-
const { runtime, bunVersion, hasLockfile } = detectRuntime(factsDir, pkg);
|
|
131
|
+
// Container facts (shared by every host) + the warnings that follow. The facts describe the AGENT —
|
|
132
|
+
// its package.json/runtime/lockfile drive the image's install step — never the workspace's (the bake
|
|
133
|
+
// ships the whole tree, but the workspace's own manifest belongs to its own deploy).
|
|
134
|
+
const hasPackageJson = await exists(join(agentDir, "package.json"));
|
|
135
|
+
const pkg = await readPackageJson(agentDir);
|
|
136
|
+
const { runtime, bunVersion, hasLockfile } = detectRuntime(agentDir, pkg);
|
|
114
137
|
const install = runtime === "bun" ? "bun install" : "npm install";
|
|
115
138
|
const runner = runtime === "bun" ? "bun run fastagent" : "./node_modules/.bin/fastagent";
|
|
116
139
|
const hasOtherLock = runtime === "node" &&
|
|
117
|
-
((await exists(join(
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
140
|
+
((await exists(join(agentDir, "pnpm-lock.yaml"))) || (await exists(join(agentDir, "yarn.lock"))));
|
|
141
|
+
// Does the baked workspace ship a `.git`? ONE fact driving both the image's git install (below)
|
|
142
|
+
// and the plans' runbook wording — the write-back loop needs the history AND the binary together.
|
|
143
|
+
const shipsGit = await exists(join(workspace, ".git"));
|
|
144
|
+
// After the facts: the deps sentence must match the agent's actual shape (a markdown-only agent has
|
|
145
|
+
// no package.json and installs nothing — the note must not point at a file that doesn't exist).
|
|
146
|
+
const deps = hasPackageJson
|
|
147
|
+
? `only the agent's deps (${agentPrefix}package.json) are installed${nested ? " — the workspace's own deps are the agent's runtime concern" : ""}`
|
|
148
|
+
: `the agent has no package.json, so no deps are installed (the pinned global CLI serves the directory)`;
|
|
149
|
+
const durability = shipsGit
|
|
150
|
+
? `Un-pushed changes on the box do not survive a redeploy; freshness and write-back run through git, ` +
|
|
151
|
+
`driven by the agent itself (persona owns the policy; GH_TOKEN etc. go in config.deploy.secrets)`
|
|
152
|
+
: `no .git here, so no history ships and the image does not install git — changes on the box are ` +
|
|
153
|
+
`ephemeral and do not survive a redeploy`;
|
|
154
|
+
messages.push({
|
|
155
|
+
level: "note",
|
|
156
|
+
text: `the whole directory is baked as the agent's workspace (WYSIWYG — what you see is what ships, ` +
|
|
157
|
+
`git or not, clean or not); ${deps}. ${durability}.`,
|
|
158
|
+
});
|
|
159
|
+
// A code agent with no lockfile builds via a non-frozen install (ranges resolve at build time) — not
|
|
133
160
|
// reproducible. A pnpm/yarn user gets an accurate message (their lockfile is ignored by the npm Dockerfile).
|
|
134
161
|
if (hasPackageJson && !hasLockfile) {
|
|
135
162
|
const lock = runtime === "bun" ? "bun.lock" : "package-lock.json";
|
|
@@ -142,7 +169,7 @@ export async function preflightDeploy(input) {
|
|
|
142
169
|
`Run \`${install}\` and commit the lockfile for pinned redeploys.`,
|
|
143
170
|
});
|
|
144
171
|
}
|
|
145
|
-
// The code-path Dockerfile runs `${runner}` — the
|
|
172
|
+
// The code-path Dockerfile runs `${runner}` — the agent's OWN local dependency, never the
|
|
146
173
|
// registry — so a package.json missing it means the container fails at start (no bin to run).
|
|
147
174
|
if (hasPackageJson && !("@fastagent-sh/fastagent" in { ...pkg.dependencies, ...pkg.devDependencies })) {
|
|
148
175
|
messages.push({
|
|
@@ -151,42 +178,167 @@ export async function preflightDeploy(input) {
|
|
|
151
178
|
`so the container fails at start. Add it to dependencies and re-run \`${install}\`.`,
|
|
152
179
|
});
|
|
153
180
|
}
|
|
154
|
-
// A
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
181
|
+
// A KEPT workspace-root .dockerignore silently replaces the generated one's protections — so ASK IT
|
|
182
|
+
// about the exact paths that matter (the generic "kept" line suggests --force, which never clobbers
|
|
183
|
+
// the workspace's own file). Two are GATES under --run, same discipline as the model-travel gate:
|
|
184
|
+
// dropping the agent dir ships a context with no persona/config (the box crash-loops), and an
|
|
185
|
+
// unexcluded secrets path BAKES CREDENTIALS INTO THE IMAGE. The other two are advisory: the build
|
|
186
|
+
// machine's node_modules (native binaries for YOUR OS) clobbering the image's, and an excluded .git
|
|
187
|
+
// killing the agent's pull/push loop (a legitimate slimming choice). Not force-gated — the file is
|
|
188
|
+
// kept even under --force.
|
|
189
|
+
// Which paths INSIDE the build context hold secrets — resolved, then made workspace-relative (the
|
|
190
|
+
// context root). An external secrets dir (a mounted volume) is outside the context: nothing to check
|
|
191
|
+
// and nothing to exclude. Also fed to the generated .dockerignore, so a custom in-tree dir is
|
|
192
|
+
// excluded by PATH even though its name is not `.secrets`.
|
|
193
|
+
const inContext = (p) => {
|
|
194
|
+
const rel = relative(workspace, p);
|
|
195
|
+
return rel === "" || rel.startsWith("..") || isAbsolute(rel) ? undefined : rel.split(sep).join("/");
|
|
196
|
+
};
|
|
197
|
+
// The secrets DIR is the unit, not the two filenames we happen to know: an atomic-write temp beside
|
|
198
|
+
// auth.json, a second key file, an editor backup of `.env` — all of it must stay out of the image, and
|
|
199
|
+
// `resolveSecretsDir` says as much ("everything fastagent manages that must never leave the machine").
|
|
200
|
+
// The auth path adds an entry only when an override puts it OUTSIDE that dir. An external secrets dir
|
|
201
|
+
// (the deployed posture: a mounted volume) is outside the context — nothing to check, nothing to
|
|
202
|
+
// exclude.
|
|
203
|
+
const secretsRel = inContext(resolveSecretsDir(agentDir));
|
|
204
|
+
const authRel = inContext(authPath);
|
|
205
|
+
const authElsewhere = authRel !== undefined && (secretsRel === undefined || !authRel.startsWith(`${secretsRel}/`));
|
|
206
|
+
const secretPaths = [...(secretsRel ? [secretsRel] : []), ...(authElsewhere ? [authRel] : [])];
|
|
207
|
+
// ONE rule for every checked path: a file that is not there cannot be baked, so gating on it would
|
|
208
|
+
// be a refusal about a spelling rather than about what would ship (an agent that has never run
|
|
209
|
+
// `login` has no auth.json). The generated .dockerignore still excludes them unconditionally —
|
|
210
|
+
// cheap, and correct the moment they appear.
|
|
211
|
+
const present = async (rels) => {
|
|
212
|
+
const found = [];
|
|
213
|
+
for (const rel of rels)
|
|
214
|
+
if (await exists(join(workspace, rel)))
|
|
215
|
+
found.push(rel);
|
|
216
|
+
return found;
|
|
217
|
+
};
|
|
218
|
+
// State gets the same treatment (a custom in-tree FASTAGENT_STATE_DIR is invisible to the
|
|
219
|
+
// name-based `**/.state`), at warn level: shipping stale sessions is waste, not a credential leak.
|
|
220
|
+
const stateRel = inContext(resolveStateRoot(agentDir));
|
|
221
|
+
// Existence gates the WARNING, never the generated exclude (same split as secretPaths vs
|
|
222
|
+
// leakCandidates): an agent that has never run has no `.state/`, so telling its author a kept ignore
|
|
223
|
+
// file fails to exclude one is a remark about a spelling — while the file we generate must still carry
|
|
224
|
+
// the line, since it is written once and correct the moment the directory appears.
|
|
225
|
+
const stateShips = stateRel !== undefined && (await exists(join(workspace, stateRel))) ? stateRel : undefined;
|
|
226
|
+
// The `.env` family at the two levels fastagent is RESPONSIBLE for: the agent dir and the workspace
|
|
227
|
+
// root. Asking only about a root-level `.env` missed both halves that matter — an `<agent>/.env`, the
|
|
228
|
+
// file habit puts there (env.ts warns about it by name), and the `.env.local` / `.env.production`
|
|
229
|
+
// spellings. DISCOVERED rather than spelled, so the existing rule still holds: only a file that is
|
|
230
|
+
// there can be baked, so only it is gated.
|
|
231
|
+
//
|
|
232
|
+
// Deliberately NOT the recursive `**/.env` the generated file carries: walking a whole monorepo for
|
|
233
|
+
// credential files is a secret scanner, not a placement pre-flight, and a bounded walk would be a
|
|
234
|
+
// heuristic pretending to be a guarantee. The division of responsibility is the honest one — the file
|
|
235
|
+
// WE generate covers every level; an author who keeps their own owns its coverage of their own tree,
|
|
236
|
+
// and this gate speaks only for the paths fastagent itself puts credentials in.
|
|
237
|
+
const dotEnvFiles = async (relDir) => {
|
|
238
|
+
const names = await readdir(join(workspace, relDir || ".")).catch(() => []);
|
|
239
|
+
// POSIX separators, like every other context-relative path here (`inContext`): these strings are
|
|
240
|
+
// matched against dockerignore patterns, and a Windows `fastagent\.env` would match none of them —
|
|
241
|
+
// silently turning the one check whose failure mode is "credentials in a published image" into a
|
|
242
|
+
// no-op.
|
|
243
|
+
return names
|
|
244
|
+
.filter((n) => (n === ".env" || n.startsWith(".env.")) && n !== ".env.example")
|
|
245
|
+
.map((n) => join(relDir, n).split(sep).join("/"));
|
|
246
|
+
};
|
|
247
|
+
const envFiles = (await Promise.all([...new Set(["", agentPrefix])].map(dotEnvFiles))).flat();
|
|
248
|
+
const leakCandidates = [...(await present(secretPaths)), ...envFiles];
|
|
249
|
+
// Same existence rule: a node_modules that is not there cannot be uploaded.
|
|
250
|
+
const depDirs = await present([...new Set([`${agentPrefix}node_modules`, "node_modules"])]);
|
|
251
|
+
const machineryPaths = [...secretPaths, ...(stateRel ? [stateRel] : [])];
|
|
252
|
+
// BOTH ignore files deploy emits get the same interrogation. The workspace-root one is what
|
|
253
|
+
// flyctl/railway's packers read; the per-Dockerfile one is what BuildKit PREFERS for a plain
|
|
254
|
+
// `docker build` (so it is the file that actually decides `deploy docker`). Checking only the root one
|
|
255
|
+
// left the credential gate not covering the path it was written for.
|
|
256
|
+
for (const rel of [".dockerignore", `${agentPrefix}Dockerfile.dockerignore`]) {
|
|
257
|
+
const kept = await readFile(join(workspace, rel), "utf8").catch(() => undefined);
|
|
258
|
+
if (kept === undefined)
|
|
259
|
+
continue;
|
|
260
|
+
// One WE generated is regenerated by this very run under --force, so checking the stale content on
|
|
261
|
+
// disk would gate a deploy on a file about to be replaced. Ours + --force: skip. Ours WITHOUT --force
|
|
262
|
+
// is still checked (it is what would ship), but the remedy differs: hand-adding lines to fastagent's
|
|
263
|
+
// own output is not the fix — regenerating it is.
|
|
264
|
+
const keptIsOurs = isGeneratedDockerignore(kept);
|
|
265
|
+
if (force && keptIsOurs)
|
|
266
|
+
continue;
|
|
267
|
+
const remedy = (lines) => keptIsOurs
|
|
268
|
+
? `Re-run with --force to regenerate it.`
|
|
269
|
+
: `Add ${lines.map((p) => `\`${p}\``).join(" and ")} before deploying (the same lines the generated ${rel} writes).`;
|
|
270
|
+
const excluded = dockerignoreMatcher(kept);
|
|
271
|
+
// Asked as a DIRECTORY (trailing slash), which is what it is. A bare-name test answers `false` for
|
|
272
|
+
// the `fastagent/` spelling — a directory-only pattern, and the one a hand-written ignore file is
|
|
273
|
+
// most likely to carry — so the agent would be dropped from the context with no warning at all. The
|
|
274
|
+
// pairing this replaced (the directory AND a file inside it) was dead weight rather than a
|
|
275
|
+
// safeguard: git's rule that a file under an excluded directory cannot be re-included is
|
|
276
|
+
// implemented by the matcher, so `excluded(dir)` already implies `excluded(dir/persona.md)` and the
|
|
277
|
+
// second test could never change the answer. What it was aimed at — an allowlist (`*` +
|
|
278
|
+
// `!fastagent` + `!fastagent/**`) that re-includes the agent — is handled by the first test alone,
|
|
279
|
+
// which reads `false` there, as it should.
|
|
280
|
+
if (nested && excluded(`${basename(agentDir)}/`)) {
|
|
281
|
+
const text = `your ${rel} (kept) excludes \`${basename(agentDir)}\` — the build context would ship WITHOUT the ` +
|
|
282
|
+
`agent entirely (the deployed box has no persona/config and crash-loops). Remove that rule ` +
|
|
283
|
+
`before deploying.`;
|
|
284
|
+
if (run)
|
|
285
|
+
return { ok: false, gate: text };
|
|
286
|
+
messages.push({ level: "warn", text });
|
|
287
|
+
}
|
|
288
|
+
// Resolved paths, not spellings: dockerignore patterns are root-anchored (unlike .gitignore), so a
|
|
289
|
+
// bare `.secrets` line does not cover `fastagent/.secrets` — and FASTAGENT_SECRETS_DIR /
|
|
290
|
+
// FASTAGENT_AUTH_PATH can put credentials anywhere in the baked tree, where the name-based excludes
|
|
291
|
+
// never reach. This is the one check whose failure mode is "credentials in a published image".
|
|
292
|
+
const leaks = leakCandidates.filter((p) => !excluded(p));
|
|
293
|
+
if (leaks.length > 0) {
|
|
294
|
+
const text = `your ${rel} (kept) does not exclude ${leaks.map((p) => `\`${p}\``).join(", ")} — the build ` +
|
|
295
|
+
`context would BAKE SECRETS INTO THE IMAGE. ${remedy(leaks.map((p) => `/${p}`))}`;
|
|
296
|
+
if (run)
|
|
297
|
+
return { ok: false, gate: text };
|
|
298
|
+
messages.push({ level: "warn", text });
|
|
299
|
+
}
|
|
300
|
+
if (stateShips && !excluded(`${stateShips}/sessions`)) {
|
|
163
301
|
messages.push({
|
|
164
302
|
level: "warn",
|
|
165
|
-
text: `your
|
|
166
|
-
`cannot commit/push the baked copy; it must \`git clone\` its repo in the workspace instead ` +
|
|
167
|
-
`(or remove the .git line).`,
|
|
303
|
+
text: `your ${rel} (kept) does not exclude \`${stateRel}\` — the build machine's sessions/channel state would ship in the image. ${remedy([`/${stateRel}`])}`,
|
|
168
304
|
});
|
|
169
305
|
}
|
|
170
|
-
|
|
306
|
+
// Both the agent's own node_modules and the workspace's: either would upload the build machine's
|
|
307
|
+
// deps (native binaries for YOUR OS) and clobber the image's freshly-installed ones. Named by the
|
|
308
|
+
// PATHS actually found unexcluded, like every other check here — not by a rule's spelling.
|
|
309
|
+
const unexcludedDeps = depDirs.filter((p) => !excluded(`${p}/.package-lock.json`));
|
|
310
|
+
if (unexcludedDeps.length > 0) {
|
|
171
311
|
messages.push({
|
|
172
312
|
level: "warn",
|
|
173
|
-
text: `your
|
|
174
|
-
`(native binaries for YOUR OS) would be uploaded and clobber the image's
|
|
175
|
-
`
|
|
313
|
+
text: `your ${rel} does not exclude ${unexcludedDeps.map((p) => `\`${p}\``).join(" or ")} — the ` +
|
|
314
|
+
`build machine's deps (native binaries for YOUR OS) would be uploaded and clobber the image's ` +
|
|
315
|
+
`freshly-installed ones. ${remedy(unexcludedDeps.map((p) => `/${p}`))}`,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
if (excluded(".git/HEAD")) {
|
|
319
|
+
messages.push({
|
|
320
|
+
level: "note",
|
|
321
|
+
text: `your ${rel} excludes .git — the baked copy ships WITHOUT history/remote, so the agent ` +
|
|
322
|
+
`cannot pull/commit/push it; it must \`git clone\` its repo in the workspace instead (or remove the .git line).`,
|
|
176
323
|
});
|
|
177
324
|
}
|
|
178
325
|
}
|
|
179
|
-
// Write-back mechanics are fastagent's (the policy is the persona's):
|
|
180
|
-
//
|
|
181
|
-
|
|
326
|
+
// Write-back mechanics are fastagent's (the policy is the persona's): the image carries the git
|
|
327
|
+
// BINARY iff the baked workspace ships a `.git` (history without the binary is a
|
|
328
|
+
// dead loop; the binary without history is dead weight). A non-git workspace that still needs git
|
|
329
|
+
// (the agent clones repos as its job) declares config.deploy.apt: ["git"] explicitly. Merged with
|
|
330
|
+
// (never duplicating) config.deploy.apt.
|
|
331
|
+
const apt = shipsGit ? [...new Set(["git", ...(config.deploy?.apt ?? [])])] : config.deploy?.apt;
|
|
182
332
|
const container = {
|
|
333
|
+
agentPrefix,
|
|
334
|
+
machineryPaths,
|
|
183
335
|
hasPackageJson,
|
|
184
336
|
runtime,
|
|
185
337
|
bunVersion,
|
|
186
338
|
hasLockfile,
|
|
187
339
|
version: await fastagentVersion(),
|
|
188
340
|
apt,
|
|
189
|
-
|
|
341
|
+
shipsGit,
|
|
190
342
|
};
|
|
191
343
|
const port = config.http?.port ?? 8787;
|
|
192
344
|
// What the agent declared it needs on the box (fastagent.config deploy.secrets) — carried like channel
|
|
@@ -195,7 +347,7 @@ export async function preflightDeploy(input) {
|
|
|
195
347
|
// deploy.apt only shapes the GENERATED Dockerfile. Warn ONLY when the kept Dockerfile is HAND-WRITTEN
|
|
196
348
|
// (its apt won't include these) — a fastagent-generated one is handled by writeArtifacts. Don't suggest
|
|
197
349
|
// --force here: it would overwrite the user's hand-written file.
|
|
198
|
-
const dockerfileHome =
|
|
350
|
+
const dockerfileHome = join(agentDir, "Dockerfile");
|
|
199
351
|
if (config.deploy?.apt?.length && !force && (await exists(dockerfileHome))) {
|
|
200
352
|
if (!isGeneratedDockerfile(await readFile(dockerfileHome, "utf8"))) {
|
|
201
353
|
messages.push({
|
|
@@ -46,5 +46,12 @@ export interface RailwayPlan {
|
|
|
46
46
|
/** The ordered, values-resolved deploy runbook — printed to stdout for the coding agent to execute. */
|
|
47
47
|
runbook: string[];
|
|
48
48
|
}
|
|
49
|
+
/** The `RAILWAY_DOCKERFILE_PATH` value for an agent under `prefix` — repo-root-anchored with a leading
|
|
50
|
+
* slash, the form Railway's builds/dockerfiles docs use for a Dockerfile in another directory. The
|
|
51
|
+
* config file's `dockerfilePath` spells it WITHOUT the slash (the config-as-code schema's own
|
|
52
|
+
* convention); two mechanisms, two documented spellings, one fact each. */
|
|
53
|
+
export declare const dockerfilePathVar: (prefix: string) => string;
|
|
54
|
+
/** Did fastagent generate this `railway.json`? Unparseable or unmarked reads as the author's. */
|
|
55
|
+
export declare function isGeneratedRailwayJson(content: string): boolean;
|
|
49
56
|
/** Compute the Railway deploy plan from the resolved definition. */
|
|
50
57
|
export declare function planRailwayDeploy(input: RailwayPlanInput): RailwayPlan;
|
|
@@ -2,25 +2,47 @@ import { containerArtifacts } from "../container.js";
|
|
|
2
2
|
import { deploymentSecrets, isEnvKey } from "../secrets.js";
|
|
3
3
|
/** State root = the volume mount path, kept in lockstep. `/data` matches the Fly recipe. */
|
|
4
4
|
const MOUNT = "/data";
|
|
5
|
+
/** The `RAILWAY_DOCKERFILE_PATH` value for an agent under `prefix` — repo-root-anchored with a leading
|
|
6
|
+
* slash, the form Railway's builds/dockerfiles docs use for a Dockerfile in another directory. The
|
|
7
|
+
* config file's `dockerfilePath` spells it WITHOUT the slash (the config-as-code schema's own
|
|
8
|
+
* convention); two mechanisms, two documented spellings, one fact each. */
|
|
9
|
+
export const dockerfilePathVar = (prefix) => `/${prefix}Dockerfile`;
|
|
5
10
|
/** railway.json — build/deploy only (Railway's config-as-code scope). No env/volume/sleeping here: those
|
|
6
11
|
* are service settings the runbook applies via CLI. healthcheckPath gates routing on a live server. */
|
|
7
|
-
|
|
12
|
+
/** railway.json is JSON, so its ownership marker is a KEY rather than a comment line. Railway ignores
|
|
13
|
+
* unknown keys; the predicate below is what lets `--force` reset OUR file and keep a hand-written one. */
|
|
14
|
+
const GENERATED_RAILWAY_KEY = "x-generated-by";
|
|
15
|
+
const GENERATED_RAILWAY_VALUE = "fastagent deploy railway";
|
|
16
|
+
/** Did fastagent generate this `railway.json`? Unparseable or unmarked reads as the author's. */
|
|
17
|
+
export function isGeneratedRailwayJson(content) {
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(content)[GENERATED_RAILWAY_KEY] === GENERATED_RAILWAY_VALUE;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function railwayJson(prefix) {
|
|
8
26
|
return `${JSON.stringify({
|
|
9
27
|
$schema: "https://railway.com/railway.schema.json",
|
|
10
|
-
|
|
11
|
-
|
|
28
|
+
[GENERATED_RAILWAY_KEY]: GENERATED_RAILWAY_VALUE,
|
|
29
|
+
// dockerfilePath is relative to the workspace root (`railway up`'s upload context).
|
|
30
|
+
build: { builder: "DOCKERFILE", dockerfilePath: `${prefix}Dockerfile` },
|
|
12
31
|
deploy: { healthcheckPath: "/health", restartPolicyType: "ON_FAILURE" },
|
|
13
32
|
}, null, 2)}\n`;
|
|
14
33
|
}
|
|
15
34
|
/** Compute the Railway deploy plan from the resolved definition. */
|
|
16
35
|
export function planRailwayDeploy(input) {
|
|
17
36
|
const { serviceName, modelAuth, channels } = input;
|
|
18
|
-
//
|
|
19
|
-
// railway.toml/json for the product). Railway reads config-as-code from the repo root by default
|
|
20
|
-
//
|
|
21
|
-
|
|
37
|
+
// railway.json is namespaced under the agent dir too (the workspace may carry its own
|
|
38
|
+
// railway.toml/json for the product). Railway reads config-as-code from the repo root by default and
|
|
39
|
+
// pointing it at a custom path is DASHBOARD-ONLY — so the BUILD entry travels as the scriptable
|
|
40
|
+
// RAILWAY_DOCKERFILE_PATH service variable instead (Railway's documented non-root-Dockerfile route),
|
|
41
|
+
// and the config-as-code pointer degrades to an OPTIONAL enhancement: the /health gate (Railway's
|
|
42
|
+
// default restart policy already matches the file's ON_FAILURE).
|
|
43
|
+
const configPath = `${input.agentPrefix}railway.json`;
|
|
22
44
|
const artifacts = [
|
|
23
|
-
{ path: configPath, content: railwayJson(input.
|
|
45
|
+
{ path: configPath, content: railwayJson(input.agentPrefix) },
|
|
24
46
|
...containerArtifacts(input),
|
|
25
47
|
];
|
|
26
48
|
const secrets = deploymentSecrets(modelAuth, channels, input.extraSecrets, input.longConnectionChannels);
|
|
@@ -47,11 +69,13 @@ export function planRailwayDeploy(input) {
|
|
|
47
69
|
`# the later commands resolve it without --service (--run passes --service to stay non-interactive).`,
|
|
48
70
|
`railway add --service ${serviceName}`,
|
|
49
71
|
``,
|
|
50
|
-
`# Persistent volume at ${MOUNT} — sessions,
|
|
72
|
+
`# Persistent volume at ${MOUNT} — .state (sessions, channel state) + .secrets (seeded auth).`,
|
|
51
73
|
`railway volume add --mount-path ${MOUNT}`,
|
|
52
74
|
``,
|
|
53
75
|
`# Variables — set BEFORE the first deploy so the box boots with them. Railway injects PORT itself.`,
|
|
54
|
-
|
|
76
|
+
`# RAILWAY_DOCKERFILE_PATH points the build at the agent's Dockerfile — a service variable,`,
|
|
77
|
+
`# Railway's documented route to a non-root Dockerfile (no dashboard step needed for the build).`,
|
|
78
|
+
`railway variables set FASTAGENT_STATE_DIR=${MOUNT}/.state FASTAGENT_SECRETS_DIR=${MOUNT}/.secrets RAILWAY_DOCKERFILE_PATH=${dockerfilePathVar(input.agentPrefix)}`,
|
|
55
79
|
];
|
|
56
80
|
if (requiredSecrets.length > 0) {
|
|
57
81
|
runbook.push(`# Required secrets:`, `# ${requiredSecrets.map((s) => `${s.name}: ${s.hint}`).join("\n# ")}`, `railway variables set ${requiredSecrets.map((s) => `${s.name}=<value>`).join(" ")}`);
|
|
@@ -63,15 +87,16 @@ export function planRailwayDeploy(input) {
|
|
|
63
87
|
// credential's value (OAuth or a stored key) to set it — same wording discipline as the Fly plan.
|
|
64
88
|
if (!isEnvKey(modelAuth)) {
|
|
65
89
|
runbook.push(modelAuth === undefined
|
|
66
|
-
? `# Model auth: none found at the local auth path — a global \`fastagent login\` isn't read here; pass --auth-path <file> (e.g. ~/.fastagent/auth.json), or \`--run\` carries it automatically.`
|
|
90
|
+
? `# Model auth: none found at the local auth path — a global \`fastagent login\` isn't read here; pass --auth-path <file> (e.g. ~/.fastagent/.secrets/auth.json), or \`--run\` carries it automatically.`
|
|
67
91
|
: `# Model auth: your local auth is "${modelAuth}" — the plan can't read its value to set as a variable.`, `# Set your provider API key as a variable (railway variables set KEY=...), OR place auth.json on the ${MOUNT} volume.`);
|
|
68
92
|
}
|
|
69
|
-
|
|
70
|
-
runbook.push(``, `# Repo-as-workspace: point the service at the kit's config file BEFORE the first deploy —`, `# dashboard-only, like App Sleeping (no CLI flag): Service → Settings → Config-as-code →`, `# set the file path to ${configPath}. Without it Railway would read the repo root's own config.`);
|
|
71
|
-
}
|
|
93
|
+
runbook.push(``, `# OPTIONAL — the build already uses the agent's Dockerfile via RAILWAY_DOCKERFILE_PATH (set above),`, `# and Railway's default restart policy equals what ${configPath} declares (ON_FAILURE).`, `# Pointing the service at ${configPath} (Service → Settings → Config-as-code — dashboard-only) adds`, `# the /health healthcheck gate: a boot-crashing deploy is marked FAILED instead of going live dead.`, `# (Zero-downtime switching doesn't apply either way — the ${MOUNT} volume allows one active deployment.)`);
|
|
72
94
|
runbook.push(``, `# Deploy — uploads this dir and builds the Dockerfile on Railway (no local Docker needed). This is`, `# also the ENTIRE redeploy: re-run \`railway up\` alone (the one-time setup above is not repeated).`, `railway up`);
|
|
73
|
-
if (input.
|
|
74
|
-
runbook.push(``, `#
|
|
95
|
+
if (input.shipsGit) {
|
|
96
|
+
runbook.push(``, `# The image is a WYSIWYG snapshot of this directory. Freshness/durability run through git, driven`, `# by the agent itself (pull to freshen, commit/push to write back; creds ride config.deploy.secrets;`, `# git is baked into the image). CAVEAT — \`railway up\` is known to strip .git from its upload:`, `# expect NO baked history on the box; the agent should \`git clone\` its repo in the workspace`, `# (same token) before making changes.`, `# Un-pushed changes on the box never survive a redeploy; durability lives in git.`);
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
runbook.push(``, `# The image is a WYSIWYG snapshot of this directory. No .git here, so no history ships and the`, `# generated image does not install git — changes on the box are ephemeral and never survive a`, `# redeploy. If the agent should clone/push repos as part of its work, add deploy: { apt: ["git"] }.`);
|
|
75
100
|
}
|
|
76
101
|
// The public URL is minted, not deterministic (unlike Fly's <app>.fly.dev) — ONE mint step, then each
|
|
77
102
|
// channel's webhook uses that domain (mint once even when both channels are present).
|
|
@@ -30,7 +30,8 @@ export interface RailwayRunPlan {
|
|
|
30
30
|
/** Names both the project (`railway init --name`) and the service (`railway add --service`). Railway
|
|
31
31
|
* names are project-scoped, not globally unique — the CLI derives it from the dir basename. */
|
|
32
32
|
name: string;
|
|
33
|
-
/** The volume mount path
|
|
33
|
+
/** The volume mount path; `FASTAGENT_STATE_DIR`/`FASTAGENT_SECRETS_DIR` are set to `.state`/`.secrets`
|
|
34
|
+
* under it (kept in lockstep). `/data`, matching the Fly recipe. */
|
|
34
35
|
mountPath: string;
|
|
35
36
|
/** `KEY=value` secrets set one-per-`variable set --stdin`: model key (env auth) or `FASTAGENT_AUTH_SEED`
|
|
36
37
|
* (file auth) + channel secrets. Never on argv. */
|
|
@@ -43,6 +44,12 @@ export interface RailwayRunPlan {
|
|
|
43
44
|
* by default so `--run` only creates on an unlinked dir and never deploys into a pre-existing (possibly
|
|
44
45
|
* unrelated/production) project; the flag is the operator's explicit "yes, this project". */
|
|
45
46
|
intoLinked: boolean;
|
|
47
|
+
/** `RAILWAY_DOCKERFILE_PATH` value (`/fastagent/Dockerfile`) — Railway's service-variable route to a
|
|
48
|
+
* non-root Dockerfile (builds/dockerfiles docs), set with the machinery variables BEFORE the first
|
|
49
|
+
* `up` so the build never falls back to auto-detecting the workspace root. The config-as-code file
|
|
50
|
+
* also carries the path, but pointing Railway at `fastagent/railway.json` is dashboard-only — the
|
|
51
|
+
* variable is the scriptable way. */
|
|
52
|
+
dockerfilePath: string;
|
|
46
53
|
}
|
|
47
54
|
/** Done (with the live URL), or a gate the operator must clear before re-running (printed + non-zero
|
|
48
55
|
* exit by the CLI). */
|
|
@@ -128,8 +128,13 @@ export async function deployRailwayRun(plan, railway, log, registerTelegram, reg
|
|
|
128
128
|
// volume (which has no --service and would otherwise attach to that service) — so the mismatch fails
|
|
129
129
|
// visibly with NO side effect. State root on argv (not secret); secrets one-per-`set --stdin` (value
|
|
130
130
|
// on stdin, never argv). Idempotent. (Order vs the volume is free — both just need to precede `up`.)
|
|
131
|
-
|
|
132
|
-
|
|
131
|
+
const machineryVars = [
|
|
132
|
+
`FASTAGENT_STATE_DIR=${plan.mountPath}/.state`,
|
|
133
|
+
`FASTAGENT_SECRETS_DIR=${plan.mountPath}/.secrets`,
|
|
134
|
+
`RAILWAY_DOCKERFILE_PATH=${plan.dockerfilePath}`,
|
|
135
|
+
];
|
|
136
|
+
log(`setting ${machineryVars.map((v) => v.split("=")[0]).join("/")} + ${Object.keys(plan.secrets).length} secret(s)…`);
|
|
137
|
+
if ((await railway(["variables", "set", ...machineryVars, ...svc])).code !== 0) {
|
|
133
138
|
return gate("`railway variables set` failed — see the railway output above");
|
|
134
139
|
}
|
|
135
140
|
for (const [k, v] of Object.entries(plan.secrets)) {
|
package/dist/deploy/runner.d.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
interface RunResult {
|
|
2
2
|
code: number;
|
|
3
|
-
/** Captured stdout (for `--json` queries); empty when the command streamed to the terminal.
|
|
4
|
-
* stderr is always inherited straight to the terminal, so it is not a field here. */
|
|
3
|
+
/** Captured stdout (for `--json` queries); empty when the command streamed to the terminal. */
|
|
5
4
|
stdout: string;
|
|
5
|
+
/** Captured stderr — ONLY when `captureStderr` was set (a caller that must CLASSIFY a failure, e.g.
|
|
6
|
+
* "not found" vs "denied"); otherwise undefined and stderr streams to the terminal as always. */
|
|
7
|
+
stderr?: string;
|
|
6
8
|
}
|
|
7
9
|
/** Run `bin args`: `capture` collects stdout (for `--json` queries), else the command streams to the
|
|
8
10
|
* terminal (create/deploy) and stdout is empty; `input` is fed to stdin (secrets over stdin, never argv).
|
|
@@ -10,6 +12,7 @@ interface RunResult {
|
|
|
10
12
|
* values in argv or mutating the long-lived CLI process. */
|
|
11
13
|
export type CliRunner = (args: string[], opts?: {
|
|
12
14
|
capture?: boolean;
|
|
15
|
+
captureStderr?: boolean;
|
|
13
16
|
input?: string;
|
|
14
17
|
env?: NodeJS.ProcessEnv;
|
|
15
18
|
}) => Promise<RunResult>;
|
package/dist/deploy/runner.js
CHANGED
|
@@ -14,13 +14,19 @@ export function spawnRunner(bin, cwd) {
|
|
|
14
14
|
const child = spawn(bin, args, {
|
|
15
15
|
cwd,
|
|
16
16
|
env: opts?.env ? { ...process.env, ...opts.env } : process.env,
|
|
17
|
-
stdio: [
|
|
17
|
+
stdio: [
|
|
18
|
+
opts?.input ? "pipe" : "inherit",
|
|
19
|
+
opts?.capture ? "pipe" : "inherit",
|
|
20
|
+
opts?.captureStderr ? "pipe" : "inherit",
|
|
21
|
+
],
|
|
18
22
|
});
|
|
19
23
|
let out = "";
|
|
24
|
+
let err = "";
|
|
20
25
|
child.stdout?.on("data", (d) => (out += String(d)));
|
|
26
|
+
child.stderr?.on("data", (d) => (err += String(d)));
|
|
21
27
|
if (opts?.input)
|
|
22
28
|
child.stdin?.end(opts.input);
|
|
23
|
-
child.on("close", (code) => res({ code: code ?? 1, stdout: out }));
|
|
24
|
-
child.on("error", () => res({ code: 127, stdout: "" })); // ENOENT
|
|
29
|
+
child.on("close", (code) => res({ code: code ?? 1, stdout: out, stderr: opts?.captureStderr ? err : undefined }));
|
|
30
|
+
child.on("error", () => res({ code: 127, stdout: "", stderr: opts?.captureStderr ? "" : undefined })); // ENOENT
|
|
25
31
|
});
|
|
26
32
|
}
|
package/dist/dev-supervisor.d.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
|
+
import { type ResolvedPlacement } from "./paths.ts";
|
|
1
2
|
/**
|
|
2
|
-
* chokidar `ignored` matcher for the narrow watch scope (true = ignore)
|
|
3
|
-
* the
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
3
|
+
* chokidar `ignored` matcher for the narrow watch scope (true = ignore), rooted at the AGENT DIR. When
|
|
4
|
+
* the agent sits INSIDE the workspace that means the surrounding tree never triggers a restart at all;
|
|
5
|
+
* when the agent IS the workspace the root is that tree, and the allowlist below is what keeps the
|
|
6
|
+
* author's own files out of scope. Ignoring a directory prunes the whole subtree, so everything outside it —
|
|
7
|
+
* `.state/` machine state, node_modules, .git, and any file/dir the agent writes as work product —
|
|
8
|
+
* costs no watchers and triggers no restarts. Helper code imported from OUTSIDE tools//channels/ is
|
|
9
|
+
* out of scope by design (keep it under tools/, or restart manually) — the startup log names the set.
|
|
7
10
|
*/
|
|
8
|
-
export declare function devWatchIgnored(
|
|
9
|
-
/** Spawn the dev worker and restart it on
|
|
10
|
-
export declare function runDevSupervisor(
|
|
11
|
+
export declare function devWatchIgnored(root: string, envFile: string): (path: string) => boolean;
|
|
12
|
+
/** Spawn the dev worker and restart it on agent-dir edits; supervise its lifecycle until the process exits. */
|
|
13
|
+
export declare function runDevSupervisor(placement: ResolvedPlacement, options?: {
|
|
11
14
|
tunnel?: boolean;
|
|
12
15
|
}): Promise<void>;
|