@fastagent-sh/fastagent 0.12.1 → 0.14.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 +6 -4
- package/dist/channels/feishu/bootstrap-token.d.ts +42 -0
- package/dist/channels/feishu/bootstrap-token.js +94 -0
- package/dist/channels/feishu/card.d.ts +32 -0
- package/dist/channels/feishu/card.js +66 -0
- package/dist/channels/feishu/cloud.d.ts +17 -0
- package/dist/channels/feishu/cloud.js +19 -0
- package/dist/channels/feishu/context-buffer.d.ts +46 -0
- package/dist/channels/feishu/context-buffer.js +133 -0
- package/dist/channels/feishu/crypto.d.ts +15 -0
- package/dist/channels/feishu/crypto.js +43 -0
- package/dist/channels/feishu/feishu-api.d.ts +108 -0
- package/dist/channels/feishu/feishu-api.js +325 -0
- package/dist/channels/feishu/feishu.d.ts +46 -0
- package/dist/channels/feishu/feishu.js +472 -0
- package/dist/channels/feishu/invoke-turn.d.ts +65 -0
- package/dist/channels/feishu/invoke-turn.js +157 -0
- package/dist/channels/feishu/model.d.ts +97 -0
- package/dist/channels/feishu/model.js +9 -0
- package/dist/channels/feishu/normalize.d.ts +22 -0
- package/dist/channels/feishu/normalize.js +132 -0
- package/dist/channels/feishu/owned-threads.d.ts +11 -0
- package/dist/channels/feishu/owned-threads.js +47 -0
- package/dist/channels/feishu/parse.d.ts +43 -0
- package/dist/channels/feishu/parse.js +65 -0
- package/dist/channels/feishu/preview.d.ts +36 -0
- package/dist/channels/feishu/preview.js +387 -0
- package/dist/channels/feishu/register-app.d.ts +70 -0
- package/dist/channels/feishu/register-app.js +141 -0
- package/dist/channels/feishu/register-webhook.d.ts +22 -0
- package/dist/channels/feishu/register-webhook.js +106 -0
- package/dist/channels/feishu/scaffold/channel.ts +41 -0
- package/dist/channels/feishu/scaffold/feishu-send.ts +87 -0
- package/dist/channels/feishu/seen.d.ts +5 -0
- package/dist/channels/feishu/seen.js +47 -0
- package/dist/channels/feishu/text.d.ts +13 -0
- package/dist/channels/feishu/text.js +63 -0
- package/dist/channels/lark/lark.d.ts +15 -0
- package/dist/channels/lark/lark.js +10 -0
- package/dist/channels/lark/onboard.d.ts +39 -0
- package/dist/channels/lark/onboard.js +58 -0
- package/dist/channels/lark/scaffold/channel.ts +39 -0
- package/dist/channels/lark/scaffold/lark-send.ts +87 -0
- package/dist/channels/registration.d.ts +15 -0
- package/dist/channels/registration.js +1 -0
- package/dist/channels/{telegram/state.js → state.js} +6 -4
- package/dist/channels/telegram/context-buffer.js +1 -1
- package/dist/channels/telegram/register-webhook.d.ts +4 -1
- package/dist/channels/telegram/register-webhook.js +17 -26
- package/dist/channels/telegram/telegram.js +2 -2
- package/dist/channels/telegram/turn-store.d.ts +8 -21
- package/dist/channels/telegram/turn-store.js +11 -130
- package/dist/channels/{telegram/turn-queue.js → turn-queue.js} +3 -3
- package/dist/channels/turn-store.d.ts +42 -0
- package/dist/channels/turn-store.js +139 -0
- package/dist/channels/wait-health.d.ts +6 -0
- package/dist/channels/wait-health.js +27 -0
- package/dist/cli/commands/add.d.ts +9 -0
- package/dist/cli/commands/add.js +142 -0
- package/dist/cli/commands/chat.d.ts +3 -0
- package/dist/cli/commands/chat.js +16 -0
- package/dist/cli/commands/deploy.d.ts +13 -0
- package/dist/cli/commands/deploy.js +338 -0
- package/dist/cli/commands/dev.d.ts +11 -0
- package/dist/cli/commands/dev.js +76 -0
- package/dist/cli/commands/fire.d.ts +7 -0
- package/dist/cli/commands/fire.js +45 -0
- package/dist/cli/commands/info.d.ts +7 -0
- package/dist/cli/commands/info.js +108 -0
- package/dist/cli/commands/init.d.ts +8 -0
- package/dist/cli/commands/init.js +81 -0
- package/dist/cli/commands/invoke.d.ts +7 -0
- package/dist/cli/commands/invoke.js +29 -0
- package/dist/cli/commands/login.d.ts +6 -0
- package/dist/cli/commands/login.js +63 -0
- package/dist/cli/commands/models.d.ts +1 -0
- package/dist/cli/commands/models.js +15 -0
- package/dist/cli/commands/schedule.d.ts +12 -0
- package/dist/cli/commands/schedule.js +89 -0
- package/dist/cli/commands/start.d.ts +10 -0
- package/dist/cli/commands/start.js +90 -0
- package/dist/cli/commands/tool.d.ts +1 -0
- package/dist/cli/commands/tool.js +37 -0
- package/dist/cli/fail.d.ts +19 -0
- package/dist/cli/fail.js +32 -0
- package/dist/cli/kernel.d.ts +89 -0
- package/dist/cli/kernel.js +190 -0
- package/dist/cli/program.d.ts +11 -0
- package/dist/cli/program.js +421 -0
- package/dist/cli/serve.d.ts +28 -0
- package/dist/cli/serve.js +90 -0
- package/dist/cli/shared.d.ts +24 -0
- package/dist/cli/shared.js +116 -0
- package/dist/cli-add-feishu.d.ts +8 -0
- package/dist/cli-add-feishu.js +223 -0
- package/dist/cli.js +8 -1284
- package/dist/deploy/container.js +10 -6
- package/dist/deploy/docker/plan.d.ts +45 -0
- package/dist/deploy/docker/plan.js +139 -0
- package/dist/deploy/docker/run.d.ts +40 -0
- package/dist/deploy/docker/run.js +126 -0
- package/dist/deploy/fly/plan.d.ts +1 -1
- package/dist/deploy/fly/plan.js +15 -4
- package/dist/deploy/fly/run.d.ts +7 -4
- package/dist/deploy/fly/run.js +26 -5
- package/dist/deploy/preflight.js +4 -3
- package/dist/deploy/railway/plan.d.ts +1 -1
- package/dist/deploy/railway/plan.js +17 -5
- package/dist/deploy/railway/run.d.ts +6 -3
- package/dist/deploy/railway/run.js +26 -4
- package/dist/deploy/registration-gate.d.ts +20 -0
- package/dist/deploy/registration-gate.js +20 -0
- package/dist/deploy/runner.d.ts +4 -1
- package/dist/deploy/runner.js +1 -0
- package/dist/deploy/secrets.d.ts +10 -9
- package/dist/deploy/secrets.js +15 -14
- package/dist/dev-supervisor.js +2 -1
- package/dist/engines/pi/auth.js +160 -46
- package/dist/engines/pi/chat.js +80 -7
- package/dist/engines/pi/config.d.ts +12 -3
- package/dist/engines/pi/config.js +16 -1
- package/dist/engines/pi/create.d.ts +14 -6
- package/dist/engines/pi/create.js +52 -16
- package/dist/engines/pi/harness.d.ts +16 -1
- package/dist/engines/pi/harness.js +77 -1
- package/dist/engines/pi/invoke.d.ts +1 -1
- package/dist/engines/pi/invoke.js +37 -2
- package/dist/engines/pi/login.js +1 -1
- package/dist/engines/pi/search-tools.d.ts +10 -0
- package/dist/engines/pi/search-tools.js +138 -0
- package/dist/engines/pi/tool-context.d.ts +28 -0
- package/dist/engines/pi/tool-context.js +8 -0
- package/dist/engines/pi/tool.d.ts +32 -1
- package/dist/engines/pi/tool.js +42 -1
- package/dist/engines/pi/workspace.d.ts +4 -1
- package/dist/engines/pi/workspace.js +3 -1
- package/dist/feishu.d.ts +2 -0
- package/dist/feishu.js +2 -0
- package/dist/lark.d.ts +3 -0
- package/dist/lark.js +3 -0
- package/dist/open-url.d.ts +2 -0
- package/dist/open-url.js +6 -0
- package/dist/pi.d.ts +2 -1
- package/dist/scaffold/add-channel.d.ts +9 -5
- package/dist/scaffold/add-channel.js +75 -7
- package/dist/scaffold/templates/fastagent.config.mjs +1 -0
- package/dist/tunnel.d.ts +9 -6
- package/dist/tunnel.js +48 -31
- package/package.json +18 -4
- /package/dist/channels/{telegram/state.d.ts → state.d.ts} +0 -0
- /package/dist/channels/{telegram/turn-queue.d.ts → turn-queue.d.ts} +0 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `fastagent add <channel>|skill` — scaffold channel glue (`channels/<kind>.ts`) or vendor an Agent
|
|
3
|
+
* Skills skill. feishu/lark additionally CREATE OR RESUME the platform app (cli-add-feishu.ts).
|
|
4
|
+
*/
|
|
5
|
+
import { randomBytes } from "node:crypto";
|
|
6
|
+
import { join, relative, resolve } from "node:path";
|
|
7
|
+
import { onboardFeishuCloudApp } from "../../cli-add-feishu.js";
|
|
8
|
+
import { loadConfig, resolveAgentDir } from "../../engines/pi/config.js";
|
|
9
|
+
import { detectRuntime, readPackageJson } from "../../runtime.js";
|
|
10
|
+
import { appendChannelDotEnv, appendChannelEnv, assertChannelReady, channelExists, channelSetup, scaffoldChannel, } from "../../scaffold/add-channel.js";
|
|
11
|
+
import { vendorSkill } from "../../scaffold/vendor-skill.js";
|
|
12
|
+
import { loadRootIgnore } from "../../workspace.js";
|
|
13
|
+
import { failStartup, failUsage } from "../fail.js";
|
|
14
|
+
/** `fastagent add <kind> [dir]`: scaffold `channels/<kind>.ts` — the adapter import plus a starter `on()`. */
|
|
15
|
+
export async function runAddChannel(channelKind, dirArg, opts) {
|
|
16
|
+
const target = resolve(dirArg);
|
|
17
|
+
// App creation is not a flag — it is what `add feishu` IS (the scan-to-create flow is the default
|
|
18
|
+
// and only path there). The retired --create-app spelling gets a pointer, not silence.
|
|
19
|
+
if (opts.createApp) {
|
|
20
|
+
if (channelKind === "feishu") {
|
|
21
|
+
console.error(`[fastagent] note: --create-app is retired — \`add feishu\` creates the app by default`);
|
|
22
|
+
}
|
|
23
|
+
else if (channelKind === "lark") {
|
|
24
|
+
failStartup(new Error("--create-app is retired — `add lark` now opens the developer console and guides credential setup by default"));
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
failStartup(new Error("--create-app is retired — app creation is the default behavior of `add feishu`"));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
// The channel (glue + companion tool) is agent surface — it lands in agentDir (config.agentDir, or
|
|
31
|
+
// target when flat), the same place dev/start discover channels/. .env(.example) and the secret
|
|
32
|
+
// hygiene stay at the run root, where .env is actually read.
|
|
33
|
+
const { config: addConfig } = await loadConfig(target).catch(failStartup);
|
|
34
|
+
const channelHome = resolveAgentDir(target, addConfig);
|
|
35
|
+
// Preconditions before the write, so a refusal is side-effect-free. feishu/lark are exceptions:
|
|
36
|
+
// their add is scaffold + ONBOARD THE APP, so an existing scaffold skips the write and continues (a
|
|
37
|
+
// failed or cancelled scan/paste flow must be re-runnable without hand-deleting glue); never touch it.
|
|
38
|
+
const file = join(channelHome, "channels", `${channelKind}.ts`);
|
|
39
|
+
if (await channelExists(channelHome, channelKind).catch(failStartup)) {
|
|
40
|
+
if (channelKind !== "feishu" && channelKind !== "lark") {
|
|
41
|
+
failStartup(new Error(`${relative(target, file)} already exists — edit it, or remove it to re-scaffold`));
|
|
42
|
+
}
|
|
43
|
+
console.error(`[fastagent] ${relative(target, file)} already exists — keeping it`);
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
await assertChannelReady(channelHome).catch(failStartup);
|
|
47
|
+
await scaffoldChannel(channelHome, channelKind).catch(failStartup);
|
|
48
|
+
console.error(`[fastagent] created ${relative(target, file)}`);
|
|
49
|
+
}
|
|
50
|
+
if (await appendChannelEnv(target, channelKind).catch(failStartup)) {
|
|
51
|
+
console.error(`[fastagent] added ${channelKind} env vars to .env.example`);
|
|
52
|
+
}
|
|
53
|
+
// Secret hygiene: a channel's GENERATED secret (a random string the user contributes nothing to) is
|
|
54
|
+
// written into `.env` — but only when `.env` is already gitignored: the CLI must never materialize a
|
|
55
|
+
// secret into a committable file. Warn, not refuse, when it is exposed — channel glue may read a real
|
|
56
|
+
// env var instead.
|
|
57
|
+
const envIgnored = (await loadRootIgnore(target).catch(failStartup))?.ignores(".env") ?? false;
|
|
58
|
+
if (!envIgnored) {
|
|
59
|
+
console.error(`[fastagent] warn: .env is not gitignored — a deploy that copies the directory would ship a secret placed there; add .env to .gitignore/.fastagentignore, or use a real env var`);
|
|
60
|
+
}
|
|
61
|
+
// `add feishu`/`add lark` = scaffold + CREATE OR RESUME the app (cli-add-feishu.ts): feishu persists
|
|
62
|
+
// its irreversible App ID/Secret boundary internally; lark returns guided credentials for the
|
|
63
|
+
// generic .env write below.
|
|
64
|
+
let created;
|
|
65
|
+
if (channelKind === "feishu" || channelKind === "lark") {
|
|
66
|
+
created = await onboardFeishuCloudApp(target, channelKind, envIgnored).catch(failStartup);
|
|
67
|
+
}
|
|
68
|
+
const { env, steps } = channelSetup(channelKind);
|
|
69
|
+
const generated = Object.fromEntries(env.filter((e) => e.generate).map((e) => [e.name, randomBytes(24).toString("hex")]));
|
|
70
|
+
// Kind-neutral: every channel's generated secrets get the same treatment (github's webhook secret is
|
|
71
|
+
// the same class of value as telegram's); guided Lark credentials ride the same write as overwrites.
|
|
72
|
+
// Feishu's irreversible credentials were already staged inside cli-add-feishu.ts before bootstrap.
|
|
73
|
+
const dotEnv = envIgnored
|
|
74
|
+
? await appendChannelDotEnv(target, channelKind, { ...generated, ...created }, Object.keys(created ?? {})).catch(failStartup)
|
|
75
|
+
: undefined;
|
|
76
|
+
if (dotEnv && dotEnv.written.length > 0) {
|
|
77
|
+
console.error(`[fastagent] wrote ${dotEnv.written.join(", ")} to .env`);
|
|
78
|
+
}
|
|
79
|
+
const install = detectRuntime(channelHome, await readPackageJson(channelHome)).runtime === "bun" ? "bun install" : "npm install";
|
|
80
|
+
// The kit's manifest lives in channelHome (agentDir when set) — point the install there, not the run root.
|
|
81
|
+
const installCmd = channelHome === target ? install : `(cd ${relative(target, channelHome)} && ${install})`;
|
|
82
|
+
console.error(` next steps:`);
|
|
83
|
+
console.error(` ${installCmd} # if @fastagent-sh/fastagent is not installed yet`);
|
|
84
|
+
for (const e of env) {
|
|
85
|
+
if (dotEnv?.alreadySet.includes(e.name))
|
|
86
|
+
continue; // the user already has it — nothing to do
|
|
87
|
+
if (dotEnv?.written.includes(e.name)) {
|
|
88
|
+
// Written, but its hint may still carry an action (github: paste the same value into the webhook
|
|
89
|
+
// UI) — keep the variable visible instead of silently absorbing it.
|
|
90
|
+
console.error(` ${e.name} — ${e.generate ? "generated and " : ""}written to .env # ${e.hint}`);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const value = e.generate ? `=${generated[e.name]}` : "";
|
|
94
|
+
const action = e.required ? "set" : "optionally set";
|
|
95
|
+
console.error(` ${action} ${e.name}${value} in .env${envIgnored ? " (gitignored)" : ""} # ${e.hint}`);
|
|
96
|
+
}
|
|
97
|
+
// Steps carry `{channel}`/`{tools}` path placeholders (their filenames are the scaffold's private
|
|
98
|
+
// knowledge) — resolve them to the real workspace-relative locations (agentDir-aware) here.
|
|
99
|
+
const kitPrefix = channelHome === target ? "" : `${relative(target, channelHome)}/`;
|
|
100
|
+
for (const s of steps) {
|
|
101
|
+
console.error(` ${s.replace("{channel}", relative(target, file)).replace("{tools}", `${kitPrefix}tools`)}`);
|
|
102
|
+
}
|
|
103
|
+
if (channelKind !== "lark") {
|
|
104
|
+
console.error(` fastagent dev --tunnel # serve locally + a public URL, auto-registering the webhook`);
|
|
105
|
+
}
|
|
106
|
+
// The app-creation flow leaves keep-alive sockets behind (platform API fetches, the throwaway tunnel's
|
|
107
|
+
// health probes) that would hold the event loop open for a while — the work is done, exit crisply.
|
|
108
|
+
process.exit(0);
|
|
109
|
+
}
|
|
110
|
+
/** `fastagent add skill <source> [dir]`: vendor an Agent Skills skill into <dir>/skills/<name>/. */
|
|
111
|
+
export async function runAddSkill(source, dirArg, opts) {
|
|
112
|
+
const target = resolve(dirArg);
|
|
113
|
+
if (!source) {
|
|
114
|
+
// A missing source is a usage error (exit 2), but the guide is worth more than a bare
|
|
115
|
+
// missing-argument line — the common path (writing your own skill) needs no command at all.
|
|
116
|
+
failUsage(`add a skill — two ways:\n` +
|
|
117
|
+
` 1. write your own (vibe): create skills/<name>/SKILL.md with name + description\n` +
|
|
118
|
+
` frontmatter; it's auto-discovered. No command needed — this is the common path.\n` +
|
|
119
|
+
` 2. vendor an existing Agent Skills skill (copied in, git-tracked):\n` +
|
|
120
|
+
` fastagent add skill <source> [dir]\n` +
|
|
121
|
+
` source: a git ref (owner/repo/path, github default), a local path (./x, /abs), or a\n` +
|
|
122
|
+
` bare name found in your global skill dirs (~/.agents/skills, ~/.pi/agent/skills)\n` +
|
|
123
|
+
` --update overwrites an existing skill (re-fetch from source); review with git diff`);
|
|
124
|
+
}
|
|
125
|
+
// Skills are agent surface — vendored into agentDir/skills (config.agentDir, or target when flat).
|
|
126
|
+
const { config: skillConfig } = await loadConfig(target).catch(failStartup);
|
|
127
|
+
const skillHome = resolveAgentDir(target, skillConfig);
|
|
128
|
+
const { name, description, dest, hasScripts, diagnostics, overwritten } = await vendorSkill(skillHome, source, {
|
|
129
|
+
update: opts.update ?? false,
|
|
130
|
+
}).catch(failStartup);
|
|
131
|
+
console.error(`[fastagent] ${overwritten ? "updated" : "vendored"} skill "${name}" → ${dest}/`);
|
|
132
|
+
if (overwritten)
|
|
133
|
+
console.error(` overwrote it — \`git diff ${dest}\` to review, \`git checkout ${dest}\` to revert`);
|
|
134
|
+
if (description)
|
|
135
|
+
console.error(` ${description.length > 100 ? `${description.slice(0, 100)}…` : description}`);
|
|
136
|
+
for (const d of diagnostics)
|
|
137
|
+
console.error(` warn: ${d.message}`);
|
|
138
|
+
if (hasScripts) {
|
|
139
|
+
console.error(` warn: this skill ships scripts/ (executable code that runs in your agent) — review it before deploying`);
|
|
140
|
+
}
|
|
141
|
+
console.error(` next: mention "${name}" in persona.md so the model knows when to use it; then \`fastagent dev\``);
|
|
142
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** `fastagent chat [dir]`: open the SAME assembled agent in pi's interactive TUI. */
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { loadDotEnv } from "../../env.js";
|
|
4
|
+
import { installProxyFetch } from "../../proxy.js";
|
|
5
|
+
import { failStartup } from "../fail.js";
|
|
6
|
+
export async function runChat(dirArg, opts) {
|
|
7
|
+
const dir = resolve(dirArg);
|
|
8
|
+
loadDotEnv(dir);
|
|
9
|
+
installProxyFetch(); // model calls (and the login dialog) must go through the proxy too
|
|
10
|
+
// Run the chat process IN the workspace: pi resolves a session's cwd as `header.cwd ?? process.cwd()`,
|
|
11
|
+
// so aligning process.cwd() with the workspace keeps a cwd-less session on the workspace. `dir` is absolute.
|
|
12
|
+
process.chdir(dir);
|
|
13
|
+
// Lazy-import: chat pulls pi's interactive TUI module graph; headless start/dev never need it.
|
|
14
|
+
const { runPiChat } = await import("../../engines/pi/chat.js");
|
|
15
|
+
await runPiChat(dir, { model: opts.model }).catch(failStartup);
|
|
16
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type DeployHost = "docker" | "fly" | "railway";
|
|
2
|
+
export interface DeployOptions {
|
|
3
|
+
run?: boolean;
|
|
4
|
+
tunnel?: boolean;
|
|
5
|
+
force?: boolean;
|
|
6
|
+
stop?: boolean;
|
|
7
|
+
/** false ⇔ `--no-scale-to-zero`. */
|
|
8
|
+
scaleToZero?: boolean;
|
|
9
|
+
intoLinked?: boolean;
|
|
10
|
+
model?: string;
|
|
11
|
+
authPath?: string;
|
|
12
|
+
}
|
|
13
|
+
export declare function runDeploy(host: DeployHost, dirArg: string, opts: DeployOptions): Promise<void>;
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `fastagent deploy <host> [dir]`: generate host artifacts from the resolved definition and print an
|
|
3
|
+
* ordered deploy runbook. Host-scoped (`docker` | `fly` | `railway` — the extension seam). It does NOT
|
|
4
|
+
* run the host CLI by default: fastagent owns the definition-aware artifacts and precise runbook;
|
|
5
|
+
* Docker may opt into a generated ephemeral tunnel, while durable ingress stays operator-owned. The pre-flight
|
|
6
|
+
* (config/model/channels/container facts) is host-neutral; the host branch adds its config + run drive.
|
|
7
|
+
* Read-only on the definition; the only writes are generated artifacts (never clobbered without
|
|
8
|
+
* --force). `--run` drives the target CLI instead of printing.
|
|
9
|
+
*/
|
|
10
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
11
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
12
|
+
import { registerFeishuWebhook } from "../../channels/feishu/register-webhook.js";
|
|
13
|
+
import { registerTelegramWebhook } from "../../channels/telegram/register-webhook.js";
|
|
14
|
+
import { isGeneratedDockerfile } from "../../deploy/container.js";
|
|
15
|
+
import { composeHasTunnelService, dockerWebhookPaths, isGeneratedCompose, planDockerDeploy, toDockerProjectName, } from "../../deploy/docker/plan.js";
|
|
16
|
+
import { deployDockerRun } from "../../deploy/docker/run.js";
|
|
17
|
+
import { parseFlyAppName, parseFlyMinMachines, parseFlyRegion, planFlyDeploy, toFlyAppName, } from "../../deploy/fly/plan.js";
|
|
18
|
+
import { deployFlyRun } from "../../deploy/fly/run.js";
|
|
19
|
+
import { preflightDeploy } from "../../deploy/preflight.js";
|
|
20
|
+
import { planRailwayDeploy } from "../../deploy/railway/plan.js";
|
|
21
|
+
import { deployRailwayRun } from "../../deploy/railway/run.js";
|
|
22
|
+
import { spawnRunner } from "../../deploy/runner.js";
|
|
23
|
+
import { assembleSecrets } from "../../deploy/secrets.js";
|
|
24
|
+
import { loadDotEnv } from "../../env.js";
|
|
25
|
+
import { loadConfig, resolveAgentDir, resolveAuthPathOverride, resolveModelSpec } from "../../engines/pi/config.js";
|
|
26
|
+
import { installProxyFetch } from "../../proxy.js";
|
|
27
|
+
import { openExternalUrl } from "../../open-url.js";
|
|
28
|
+
import { exists } from "../../scaffold/init.js";
|
|
29
|
+
import { announceWebhooks } from "../../tunnel.js";
|
|
30
|
+
import { failStartup, failUsage } from "../fail.js";
|
|
31
|
+
export async function runDeploy(host, dirArg, opts) {
|
|
32
|
+
const target = resolve(dirArg);
|
|
33
|
+
if (opts.tunnel && host !== "docker") {
|
|
34
|
+
// A flag/host combination the parser cannot see (host is an argument) — usage class, exit 2.
|
|
35
|
+
failUsage(`deploy stopped: --tunnel is supported only by the local Docker target`);
|
|
36
|
+
}
|
|
37
|
+
loadDotEnv(target); // a custom provider/tool may read a key at config load
|
|
38
|
+
installProxyFetch(); // post-deploy channel API calls must honor HTTP(S)_PROXY under Node
|
|
39
|
+
const { config } = await loadConfig(target).catch(failStartup);
|
|
40
|
+
const agentDir = resolveAgentDir(target, config);
|
|
41
|
+
const modelSpec = resolveModelSpec(opts.model, config);
|
|
42
|
+
// The host-neutral pre-flight (model-travel gate, channel discovery, model-auth probe, container facts +
|
|
43
|
+
// their warnings) lives in deploy/preflight.ts — testable in isolation. The CLI prints its messages and
|
|
44
|
+
// stops on its gate; the host branch below adds only the host-specific artifacts + runbook + run drive.
|
|
45
|
+
const pre = await preflightDeploy({
|
|
46
|
+
target,
|
|
47
|
+
agentDir,
|
|
48
|
+
config,
|
|
49
|
+
modelSpec,
|
|
50
|
+
run: !!opts.run,
|
|
51
|
+
force: !!opts.force,
|
|
52
|
+
authPathOverride: resolveAuthPathOverride(opts.authPath),
|
|
53
|
+
}).catch(failStartup);
|
|
54
|
+
if (!pre.ok)
|
|
55
|
+
failStartup(new Error(`deploy stopped: ${pre.gate}`));
|
|
56
|
+
for (const m of pre.messages)
|
|
57
|
+
console.error(`[fastagent] ${m.level}: ${m.text}`);
|
|
58
|
+
const { channels, hasTimeTriggers, modelAuth, authPath, container, port, extraSecrets } = pre;
|
|
59
|
+
// Docker: one app service + loopback port + state volume. `--tunnel` shapes the generated topology
|
|
60
|
+
// with an optional Quick Tunnel service; `--run` alone decides whether Docker receives side effects.
|
|
61
|
+
if (host === "docker") {
|
|
62
|
+
if (opts.stop || opts.scaleToZero === false) {
|
|
63
|
+
console.error(`[fastagent] warn: --stop/--no-scale-to-zero are Fly-only — local Compose stays running`);
|
|
64
|
+
}
|
|
65
|
+
if (opts.intoLinked) {
|
|
66
|
+
console.error(`[fastagent] warn: --into-linked is Railway-only — ignored for local Docker`);
|
|
67
|
+
}
|
|
68
|
+
const projectName = toDockerProjectName(basename(target));
|
|
69
|
+
const dockerPlan = (tunnel) => planDockerDeploy({ projectName, port, modelAuth, channels, tunnel, extraSecrets, ...container });
|
|
70
|
+
const requestedTunnel = !!opts.tunnel;
|
|
71
|
+
let plan = dockerPlan(requestedTunnel);
|
|
72
|
+
// An existing Compose file is authoritative: shape its comparison/runbook from the topology on disk,
|
|
73
|
+
// regardless of the current flag. `--force` is the explicit reset to the requested generated shape.
|
|
74
|
+
const composeFile = join(target, plan.composePath);
|
|
75
|
+
let keptWithoutRequestedTunnel = false;
|
|
76
|
+
if (!opts.force && (await exists(composeFile))) {
|
|
77
|
+
const existingHasTunnel = composeHasTunnelService(await readFile(composeFile, "utf8"));
|
|
78
|
+
plan = dockerPlan(existingHasTunnel);
|
|
79
|
+
keptWithoutRequestedTunnel = requestedTunnel && !existingHasTunnel;
|
|
80
|
+
}
|
|
81
|
+
await writeArtifacts(target, plan.artifacts, {
|
|
82
|
+
force: !!opts.force,
|
|
83
|
+
neverForce: container.kitDir ? [".dockerignore"] : [],
|
|
84
|
+
});
|
|
85
|
+
if (opts.run) {
|
|
86
|
+
return runDeployDocker({
|
|
87
|
+
target,
|
|
88
|
+
agentDir,
|
|
89
|
+
composeFile: plan.composePath,
|
|
90
|
+
port,
|
|
91
|
+
requireTunnel: requestedTunnel,
|
|
92
|
+
modelAuth,
|
|
93
|
+
authPath,
|
|
94
|
+
channels,
|
|
95
|
+
extraSecrets,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
if (keptWithoutRequestedTunnel) {
|
|
99
|
+
console.error(`[fastagent] warn: --tunnel was requested but kept ${plan.composePath} has no "tunnel" service — ` +
|
|
100
|
+
`edit it, delete it and regenerate, or pass --force`);
|
|
101
|
+
}
|
|
102
|
+
console.log(plan.runbook.join("\n"));
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
// Railway: thin config file, scale-to-zero is a manual dashboard step, the URL is minted (see
|
|
106
|
+
// planRailwayDeploy). --run drives the railway CLI to completion; otherwise print the runbook.
|
|
107
|
+
if (host === "railway") {
|
|
108
|
+
if (opts.stop || opts.scaleToZero === false) {
|
|
109
|
+
console.error(`[fastagent] warn: --stop/--no-scale-to-zero are Fly-only — Railway's App Sleeping is a dashboard toggle ` +
|
|
110
|
+
`(the runbook states the manual step).`);
|
|
111
|
+
}
|
|
112
|
+
// Railway service names are project-scoped (not globally unique like a Fly app); slug the dir
|
|
113
|
+
// basename so a name with spaces/odd chars can't break the `railway add --service <name>` command.
|
|
114
|
+
const serviceName = basename(target)
|
|
115
|
+
.replace(/[^a-zA-Z0-9-]+/g, "-")
|
|
116
|
+
.replace(/^-+|-+$/g, "") || "agent";
|
|
117
|
+
const plan = planRailwayDeploy({ serviceName, modelAuth, channels, extraSecrets, hasTimeTriggers, ...container });
|
|
118
|
+
await writeArtifacts(target, plan.artifacts, {
|
|
119
|
+
force: !!opts.force,
|
|
120
|
+
neverForce: container.kitDir ? [".dockerignore"] : [],
|
|
121
|
+
});
|
|
122
|
+
if (opts.run) {
|
|
123
|
+
return runDeployRailway({
|
|
124
|
+
target,
|
|
125
|
+
name: serviceName,
|
|
126
|
+
modelAuth,
|
|
127
|
+
authPath,
|
|
128
|
+
channels,
|
|
129
|
+
extraSecrets,
|
|
130
|
+
intoLinked: !!opts.intoLinked,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
console.log(plan.runbook.join("\n"));
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
// host === "fly".
|
|
137
|
+
if (opts.intoLinked) {
|
|
138
|
+
console.error(`[fastagent] warn: --into-linked is railway-only (fly --run is idempotent — it reuses an existing app/volume)`);
|
|
139
|
+
}
|
|
140
|
+
// The replay floor that makes scale-to-zero safe is Telegram-only (its L1 turn store). GitHub turns
|
|
141
|
+
// are fire-and-forget (no replay), so the generated fly.toml keeps one machine running for them —
|
|
142
|
+
// a note, not a warn, since the plan already did the safe thing (definition-aware autostop).
|
|
143
|
+
if (channels.includes("github")) {
|
|
144
|
+
console.error(`[fastagent] note: github turns have no replay — the generated fly.toml uses min_machines_running=1 ` +
|
|
145
|
+
`(no scale-to-zero) so autostop can't drop an in-flight review. Set it to 0 to accept that trade.`);
|
|
146
|
+
}
|
|
147
|
+
// Two consistent modes. KEEP (no --force): an existing fly.toml is authoritative — not rewritten,
|
|
148
|
+
// and the runbook reads its `app=` (Fly app names are globally unique, so the basename guess may be
|
|
149
|
+
// taken and the user renamed it). --force: the template is authoritative — the WHOLE fly.toml resets
|
|
150
|
+
// (app→basename, region→iad, vm→defaults), so we do NOT round-trip `app` and warn that hand edits go.
|
|
151
|
+
// Kit layout: fly.toml lives under the kit (agent/fly.toml) — the host repo's own fly.toml (if any)
|
|
152
|
+
// belongs to the host's product deploy and is never read or written here.
|
|
153
|
+
const flyTomlPath = container.kitDir ? join(target, container.kitDir, "fly.toml") : join(target, "fly.toml");
|
|
154
|
+
const flyTomlExists = await exists(flyTomlPath);
|
|
155
|
+
const keptApp = flyTomlExists && !opts.force ? parseFlyAppName(await readFile(flyTomlPath, "utf8")) : undefined;
|
|
156
|
+
const appName = keptApp ?? toFlyAppName(basename(target));
|
|
157
|
+
if (keptApp)
|
|
158
|
+
console.error(`[fastagent] app: ${keptApp} (from fly.toml)`);
|
|
159
|
+
if (flyTomlExists && opts.force) {
|
|
160
|
+
console.error(`[fastagent] warn: --force resets fly.toml to defaults (app, region, vm) — re-apply any hand edits`);
|
|
161
|
+
}
|
|
162
|
+
// Autostop flags shape the GENERATED fly.toml only. In KEEP mode (fly.toml exists, no --force) it is
|
|
163
|
+
// not rewritten, so the flags would silently do nothing — surface that instead of a confusing no-op.
|
|
164
|
+
if (flyTomlExists && !opts.force && (opts.stop || opts.scaleToZero === false)) {
|
|
165
|
+
console.error(`[fastagent] warn: --stop/--no-scale-to-zero only shape a freshly generated fly.toml — yours exists and ` +
|
|
166
|
+
`was kept. Edit auto_stop_machines/min_machines_running in fly.toml, or pass --force to regenerate.`);
|
|
167
|
+
}
|
|
168
|
+
// KEEP mode + time triggers: the kept fly.toml may still scale to zero — which would sleep through every
|
|
169
|
+
// cron instant / wake-up. The generated plan can't fix a kept file, so surface it instead of the preflight
|
|
170
|
+
// note silently not applying (the author who deployed FIRST and added schedules LATER hits exactly this).
|
|
171
|
+
// Under `--run` this is a GATE (same discipline as the model-travel gate): a full deploy whose schedules
|
|
172
|
+
// silently never fire is worse than a crash-loop — nothing fails visibly when a cron instant passes on a
|
|
173
|
+
// sleeping machine, and unlike github's min=0 there is no legitimate trade to accept here.
|
|
174
|
+
if (flyTomlExists && !opts.force && hasTimeTriggers) {
|
|
175
|
+
const min = parseFlyMinMachines(await readFile(flyTomlPath, "utf8"));
|
|
176
|
+
if ((min ?? 0) === 0) {
|
|
177
|
+
// undefined = the line is absent — Fly's platform default for min_machines_running is 0, so a
|
|
178
|
+
// hand-written fly.toml without the line scales to zero exactly like an explicit 0.
|
|
179
|
+
const msg = `your kept fly.toml scales to zero (min_machines_running = ${min ?? "absent → platform default 0"}), but ` +
|
|
180
|
+
`schedules/self-scheduling need a running machine (no external wake-up). Set min_machines_running = 1, ` +
|
|
181
|
+
`or pass --force to regenerate.`;
|
|
182
|
+
if (opts.run)
|
|
183
|
+
failStartup(new Error(`deploy stopped: ${msg}`));
|
|
184
|
+
console.error(`[fastagent] warn: ${msg}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const plan = planFlyDeploy({
|
|
188
|
+
appName,
|
|
189
|
+
port,
|
|
190
|
+
modelAuth,
|
|
191
|
+
channels,
|
|
192
|
+
extraSecrets,
|
|
193
|
+
hasTimeTriggers,
|
|
194
|
+
...container,
|
|
195
|
+
autostop: opts.stop ? "stop" : "suspend",
|
|
196
|
+
scaleToZero: opts.scaleToZero !== false,
|
|
197
|
+
});
|
|
198
|
+
await writeArtifacts(target, plan.artifacts, {
|
|
199
|
+
force: !!opts.force,
|
|
200
|
+
neverForce: container.kitDir ? [".dockerignore"] : [],
|
|
201
|
+
});
|
|
202
|
+
if (opts.run)
|
|
203
|
+
return runDeployFly({ target, appName, modelAuth, authPath, channels, flyTomlPath, extraSecrets });
|
|
204
|
+
console.log(plan.runbook.join("\n"));
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Write each generated artifact. An existing file is KEPT unless --force — deploy NEVER clobbers a file
|
|
208
|
+
* without it (no silent data loss). A Dockerfile/Compose file WE generated is definition-derived, so a
|
|
209
|
+
* KEPT one that no longer matches what deploy would generate now (config/channel/lockfile/version drift —
|
|
210
|
+
* OR the user's own edits, which we can't distinguish) is flagged stale; --force regenerates it. A
|
|
211
|
+
* hand-written Dockerfile/Compose/ignore or fly.toml's app+region state is simply kept.
|
|
212
|
+
*/
|
|
213
|
+
async function writeArtifacts(target, artifacts, options) {
|
|
214
|
+
for (const a of artifacts) {
|
|
215
|
+
const abs = join(target, a.path);
|
|
216
|
+
// Host-owned paths (the root .dockerignore in the agentDir layout): --force means "MY generated
|
|
217
|
+
// artifact is authoritative", which never licenses clobbering the HOST's file — keep it always.
|
|
218
|
+
if (options.neverForce?.includes(a.path) && (await exists(abs))) {
|
|
219
|
+
console.error(`[fastagent] kept ${a.path} — the host repo's own file (never overwritten, even with --force); ` +
|
|
220
|
+
`see the preflight warnings for what it must contain`);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (!options.force && (await exists(abs))) {
|
|
224
|
+
const existing = await readFile(abs, "utf8");
|
|
225
|
+
const generatedDrift = (a.path.endsWith("Dockerfile") && isGeneratedDockerfile(existing)) ||
|
|
226
|
+
(a.path.endsWith("fastagent.compose.yml") && isGeneratedCompose(existing));
|
|
227
|
+
if (generatedDrift && existing !== a.content) {
|
|
228
|
+
console.error(`[fastagent] kept ${a.path} — it no longer matches what deploy would generate (config changed, or ` +
|
|
229
|
+
`you edited it); pass --force to regenerate.`);
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
console.error(`[fastagent] kept ${a.path} (exists — pass --force to overwrite)`);
|
|
233
|
+
}
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
await mkdir(dirname(abs), { recursive: true }); // kit-layout artifacts live under agent/
|
|
237
|
+
await writeFile(abs, a.content);
|
|
238
|
+
console.error(`[fastagent] wrote ${a.path}`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* `deploy docker --run`: carry local credentials into Compose's child environment, then reconcile the
|
|
243
|
+
* user-owned local topology. Docker owns container/network/volume lifecycle. A Compose tunnel service,
|
|
244
|
+
* when present, yields an ephemeral URL that reuses the same webhook announcer as `dev --tunnel`.
|
|
245
|
+
*/
|
|
246
|
+
async function runDeployDocker(params) {
|
|
247
|
+
const { target, agentDir, composeFile, port, requireTunnel, modelAuth, authPath, channels, extraSecrets } = params;
|
|
248
|
+
const { secrets, missingSecrets, needsModelCredential } = assembleSecrets({
|
|
249
|
+
modelAuth,
|
|
250
|
+
authFile: (await exists(authPath)) ? await readFile(authPath) : undefined,
|
|
251
|
+
channels,
|
|
252
|
+
extraSecrets,
|
|
253
|
+
env: process.env,
|
|
254
|
+
});
|
|
255
|
+
const outcome = await deployDockerRun({ composeFile, port, secrets, missingSecrets, needsModelCredential, requireTunnel }, spawnRunner("docker", target), (message) => console.error(`[fastagent] ${message}`));
|
|
256
|
+
if (!outcome.ok)
|
|
257
|
+
failStartup(new Error(`deploy stopped: ${outcome.gate}`));
|
|
258
|
+
const compose = `docker compose -f ${composeFile}`;
|
|
259
|
+
console.error(`[fastagent] running${outcome.url ? ` → ${outcome.url}` : ""}`);
|
|
260
|
+
console.error(`[fastagent] logs: ${compose} logs -f agent`);
|
|
261
|
+
console.error(`[fastagent] stop: ${compose} down (state volume is kept)`);
|
|
262
|
+
if (outcome.tunnelUrl) {
|
|
263
|
+
// Docker Desktop commonly injects a host proxy. The Quick Tunnel hostname may be resolvable only
|
|
264
|
+
// through it, exactly like provider/channel APIs; use the same Node dispatcher as dev/start/login.
|
|
265
|
+
installProxyFetch();
|
|
266
|
+
await announceWebhooks(agentDir, outcome.tunnelUrl, { openUrl: openExternalUrl });
|
|
267
|
+
console.error(`[fastagent] note: Quick Tunnel URLs are ephemeral — after the tunnel container/Docker daemon ` +
|
|
268
|
+
`restarts, re-run this deploy so webhooks receive the new URL`);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
const paths = dockerWebhookPaths(channels);
|
|
272
|
+
if (paths.length > 0) {
|
|
273
|
+
console.error(`[fastagent] note: public ingress is operator-owned — configure your tunnel/proxy, then wire the ` +
|
|
274
|
+
`default webhook path(s): ${paths.join(", ")} (or your remapped channel routes)`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* `deploy fly --run`: drive flyctl to completion (idempotent, resumable). Gathers the secret VALUES
|
|
279
|
+
* from the local env — the model key (env auth) or the whole auth.json as a `FASTAGENT_AUTH_SEED` seed
|
|
280
|
+
* (OAuth/stored auth: the deployed box materializes it onto the /data volume on first boot, so a
|
|
281
|
+
* personal deploy runs on the SAME subscription) plus channel secrets — then runs the flyctl steps
|
|
282
|
+
* behind the shared {@link spawnRunner} seam (spawned `fly`, cwd = the workspace so the build context is the agent).
|
|
283
|
+
*/
|
|
284
|
+
async function runDeployFly(params) {
|
|
285
|
+
const { target, appName, modelAuth, authPath, channels, flyTomlPath, extraSecrets } = params;
|
|
286
|
+
const fly = spawnRunner("fly", target);
|
|
287
|
+
// Fail fast if flyctl is absent (spawn ENOENT → 127), with the install link — not a confusing auth gate.
|
|
288
|
+
if ((await fly(["version"], { capture: true })).code === 127) {
|
|
289
|
+
failStartup(new Error(`flyctl not found — install it: https://fly.io/docs/flyctl/install, then re-run`));
|
|
290
|
+
}
|
|
291
|
+
const region = parseFlyRegion(await readFile(flyTomlPath, "utf8")) ?? "iad";
|
|
292
|
+
const { secrets, missingSecrets, needsModelCredential } = assembleSecrets({
|
|
293
|
+
modelAuth,
|
|
294
|
+
authFile: (await exists(authPath)) ? await readFile(authPath) : undefined,
|
|
295
|
+
channels,
|
|
296
|
+
extraSecrets,
|
|
297
|
+
env: process.env,
|
|
298
|
+
});
|
|
299
|
+
// Model credential has its OWN remediation (login), distinct from a missing secret's (.env) — gate it
|
|
300
|
+
// here, not through missingSecrets, so the message isn't a contradictory mash of both.
|
|
301
|
+
if (needsModelCredential) {
|
|
302
|
+
failStartup(new Error(`deploy stopped: no model credential — run \`fastagent login\`, or set a provider API key in .env, then re-run`));
|
|
303
|
+
}
|
|
304
|
+
const outcome = await deployFlyRun({ appName, region, secrets, missingSecrets, channels, flyConfig: "fly.toml" }, fly, (m) => console.error(`[fastagent] ${m}`), (baseUrl) => registerTelegramWebhook(baseUrl), (baseUrl, kind) => registerFeishuWebhook(baseUrl, kind));
|
|
305
|
+
if (!outcome.ok)
|
|
306
|
+
failStartup(new Error(`deploy stopped: ${outcome.gate}`));
|
|
307
|
+
console.error(`[fastagent] deployed → https://${appName}.fly.dev`);
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* `deploy railway --run`: drive the railway CLI to completion. Mirrors {@link runDeployFly} — same
|
|
311
|
+
* credential carry (env key OR the OAuth auth.json as `FASTAGENT_AUTH_SEED`) via {@link assembleSecrets},
|
|
312
|
+
* same runner seam (spawned `railway`, cwd = the workspace so `railway up`'s upload is the agent). The
|
|
313
|
+
* Railway-specific sequence (linked-check → init/add/volume when fresh → variables → up → domain →
|
|
314
|
+
* webhook) lives in {@link deployRailwayRun}; see there for why Railway differs from Fly.
|
|
315
|
+
*/
|
|
316
|
+
async function runDeployRailway(params) {
|
|
317
|
+
const { target, name, modelAuth, authPath, channels, extraSecrets, intoLinked } = params;
|
|
318
|
+
const railway = spawnRunner("railway", target);
|
|
319
|
+
// Fail fast if the railway CLI is absent (spawn ENOENT → 127), with the install link.
|
|
320
|
+
if ((await railway(["--version"], { capture: true })).code === 127) {
|
|
321
|
+
failStartup(new Error(`railway CLI not found — install it: https://docs.railway.com/guides/cli, then re-run`));
|
|
322
|
+
}
|
|
323
|
+
const { secrets, missingSecrets, needsModelCredential } = assembleSecrets({
|
|
324
|
+
modelAuth,
|
|
325
|
+
authFile: (await exists(authPath)) ? await readFile(authPath) : undefined,
|
|
326
|
+
channels,
|
|
327
|
+
extraSecrets,
|
|
328
|
+
env: process.env,
|
|
329
|
+
});
|
|
330
|
+
// Model credential has its OWN remediation (login), distinct from a missing secret's (.env).
|
|
331
|
+
if (needsModelCredential) {
|
|
332
|
+
failStartup(new Error(`deploy stopped: no model credential — run \`fastagent login\`, or set a provider API key in .env, then re-run`));
|
|
333
|
+
}
|
|
334
|
+
const outcome = await deployRailwayRun({ name, mountPath: "/data", secrets, missingSecrets, channels, intoLinked }, railway, (m) => console.error(`[fastagent] ${m}`), (baseUrl) => registerTelegramWebhook(baseUrl), (baseUrl, kind) => registerFeishuWebhook(baseUrl, kind));
|
|
335
|
+
if (!outcome.ok)
|
|
336
|
+
failStartup(new Error(`deploy stopped: ${outcome.gate}`));
|
|
337
|
+
console.error(`[fastagent] deployed → ${outcome.url}`);
|
|
338
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface DevOptions {
|
|
2
|
+
port?: string;
|
|
3
|
+
model?: string;
|
|
4
|
+
authPath?: string;
|
|
5
|
+
/** false ⇔ `--no-watch`. */
|
|
6
|
+
watch?: boolean;
|
|
7
|
+
tunnel?: boolean;
|
|
8
|
+
/** false ⇔ `--no-input`. */
|
|
9
|
+
input?: boolean;
|
|
10
|
+
}
|
|
11
|
+
export declare function runDev(dirArg: string, opts: DevOptions): Promise<void>;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `fastagent dev`: a SUPERVISOR that spawns a worker (this command with FASTAGENT_DEV_WORKER set) to
|
|
3
|
+
* assemble + serve, restarting it on workspace edits. A fresh process per reload means what is served
|
|
4
|
+
* is always the latest code, including modules a tool/config imports.
|
|
5
|
+
*/
|
|
6
|
+
import { resolve } from "node:path";
|
|
7
|
+
import { runDevSupervisor } from "../../dev-supervisor.js";
|
|
8
|
+
import { loadDotEnv } from "../../env.js";
|
|
9
|
+
import { resolveAuthPathOverride } from "../../engines/pi/config.js";
|
|
10
|
+
import { reportDefinitionWarnings, reportModuleLoadFailures, reportToolCollisions } from "../../engines/pi/report.js";
|
|
11
|
+
import { createPiAgentFromWorkspace } from "../../engines/pi/workspace.js";
|
|
12
|
+
import { log, setLogLevel } from "../../log.js";
|
|
13
|
+
import { logAgentLoop } from "../../observe.js";
|
|
14
|
+
import { installProxyFetch } from "../../proxy.js";
|
|
15
|
+
import { failStartup } from "../fail.js";
|
|
16
|
+
import { maybeTunnel, routesFor, serve, startSchedules } from "../serve.js";
|
|
17
|
+
import { parsePort, reportAuth, resolveFirstRunModel } from "../shared.js";
|
|
18
|
+
export async function runDev(dirArg, opts) {
|
|
19
|
+
const dir = resolve(dirArg);
|
|
20
|
+
setLogLevel("debug"); // dev posture: verbose, includes the debug turn trace (content) — supervisor and worker both
|
|
21
|
+
const isWorker = process.env.FASTAGENT_DEV_WORKER === "1";
|
|
22
|
+
// Pick a model interactively once, in the parent (both watch and --no-watch have a TTY); a spawned
|
|
23
|
+
// watch worker inherits the choice via FASTAGENT_MODEL, so it must not prompt again. Load .env and
|
|
24
|
+
// the proxy FIRST (as invoke/start do): the picker reads FASTAGENT_MODEL and provider keys from
|
|
25
|
+
// .env, and getAuth's OAuth refresh must go through HTTPS_PROXY. The worker re-loads both in serveOnce.
|
|
26
|
+
if (!isWorker) {
|
|
27
|
+
loadDotEnv(dir);
|
|
28
|
+
installProxyFetch();
|
|
29
|
+
await resolveFirstRunModel(dir, opts);
|
|
30
|
+
}
|
|
31
|
+
if (isWorker || opts.watch === false) {
|
|
32
|
+
await serveOnce(dir, opts);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
parsePort(opts.port, "--port", "flag"); // flag-shape check before spawning
|
|
36
|
+
await runDevSupervisor(dir, { tunnel: opts.tunnel ?? false });
|
|
37
|
+
}
|
|
38
|
+
/** Assemble the workspace agent and serve it once (the dev worker; also the --no-watch path). */
|
|
39
|
+
async function serveOnce(dir, opts) {
|
|
40
|
+
const portFlag = parsePort(opts.port, "--port", "flag");
|
|
41
|
+
loadDotEnv(dir);
|
|
42
|
+
installProxyFetch();
|
|
43
|
+
const a = await createPiAgentFromWorkspace(dir, {
|
|
44
|
+
model: opts.model,
|
|
45
|
+
authPath: resolveAuthPathOverride(opts.authPath),
|
|
46
|
+
serving: true, // long-running serve: the scheduler poller runs (wake mounts iff config.selfSchedule)
|
|
47
|
+
}).catch(failStartup);
|
|
48
|
+
log.info(`[fastagent] dir: ${dir}`);
|
|
49
|
+
if (a.agentDir !== dir)
|
|
50
|
+
log.info(`[fastagent] agent: ${a.agentDir}`);
|
|
51
|
+
log.info(`[fastagent] config: ${a.configPath ?? "(zero-config)"}`);
|
|
52
|
+
log.info(`[fastagent] model: ${a.modelSpec}${a.config.thinkingLevel ? ` (thinking: ${a.config.thinkingLevel})` : ""}`);
|
|
53
|
+
await reportAuth(a.modelSpec, a.authPath);
|
|
54
|
+
reportAgentsSkillsTools(a);
|
|
55
|
+
// Trace each turn's agent loop (tool calls + reply) to the log at debug level — shown in dev, gated
|
|
56
|
+
// out in start (level info), keeping end-user content out of production logs. Wired in both postures.
|
|
57
|
+
const traced = logAgentLoop(a.agent);
|
|
58
|
+
const routes = await routesFor(a.agentDir, traced, a.stateRoot).catch(failStartup);
|
|
59
|
+
await startSchedules(a.agentDir, traced, a.stateRoot, a.config.selfSchedule ?? false);
|
|
60
|
+
serve(routes, portFlag ?? a.config.http?.port ?? 8787, (p) => maybeTunnel(a.agentDir, p, opts.tunnel ?? false));
|
|
61
|
+
}
|
|
62
|
+
/** The agents/skills/tools/collisions report lines. */
|
|
63
|
+
function reportAgentsSkillsTools(a) {
|
|
64
|
+
log.info(`[fastagent] context: ${a.definition.contextFiles.map((f) => f.path).join(", ") || "(none)"}`);
|
|
65
|
+
if (a.definition.persona)
|
|
66
|
+
log.info(`[fastagent] persona: persona.md`);
|
|
67
|
+
log.info(`[fastagent] skills: ${a.definition.skills.map((s) => s.name).join(", ") || "(none)"}`);
|
|
68
|
+
if (a.toolNames.length > 0)
|
|
69
|
+
log.info(`[fastagent] tools: ${a.toolNames.join(", ")}`);
|
|
70
|
+
if (a.deferredToolNames.length > 0) {
|
|
71
|
+
log.info(`[fastagent] deferred: ${a.deferredToolNames.join(", ")} (activated via search_tools)`);
|
|
72
|
+
}
|
|
73
|
+
reportToolCollisions(a.toolCollisions);
|
|
74
|
+
reportModuleLoadFailures(a.toolFailures);
|
|
75
|
+
reportDefinitionWarnings(a.definition.collisions, a.definition.diagnostics);
|
|
76
|
+
}
|