@fastagent-sh/fastagent 0.18.0 → 0.19.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 +25 -7
- package/dist/agent.d.ts +1 -1
- package/dist/agent.js +1 -1
- package/dist/atomic-write.d.ts +19 -0
- package/dist/atomic-write.js +48 -0
- package/dist/{host/node.d.ts → channel.d.ts} +24 -28
- package/dist/channel.js +1 -0
- package/dist/channels/agentcore-service.d.ts +54 -0
- package/dist/channels/agentcore-service.js +111 -0
- package/dist/channels/agentcore.d.ts +12 -2
- package/dist/channels/agentcore.js +6 -4
- package/dist/channels/busy.d.ts +1 -1
- package/dist/channels/busy.js +1 -1
- package/dist/channels/control.d.ts +16 -6
- package/dist/channels/control.js +106 -4
- package/dist/{engines/pi/channel.d.ts → channels/discover.d.ts} +2 -2
- package/dist/{engines/pi/channel.js → channels/discover.js} +8 -12
- package/dist/channels/feishu/card.js +1 -1
- package/dist/channels/feishu/context-buffer.d.ts +1 -1
- package/dist/channels/feishu/context-buffer.js +4 -4
- package/dist/channels/feishu/feishu-api.js +1 -1
- package/dist/channels/feishu/feishu.d.ts +1 -1
- package/dist/channels/feishu/feishu.js +7 -7
- package/dist/channels/feishu/invoke-turn.d.ts +1 -1
- package/dist/channels/feishu/invoke-turn.js +3 -4
- package/dist/channels/feishu/preview.d.ts +1 -1
- package/dist/channels/feishu/preview.js +7 -3
- package/dist/channels/feishu/ws-ingress.d.ts +1 -1
- package/dist/channels/github/github.d.ts +1 -1
- package/dist/channels/http.d.ts +11 -7
- package/dist/channels/http.js +1 -109
- package/dist/channels/{context-buffer.js → kit/context-buffer.js} +2 -2
- package/dist/channels/{invoke-turn-kit.d.ts → kit/invoke-turn-kit.d.ts} +14 -3
- package/dist/channels/{invoke-turn-kit.js → kit/invoke-turn-kit.js} +17 -6
- package/dist/channels/{preview-kit.d.ts → kit/preview-kit.d.ts} +3 -1
- package/dist/channels/{seen.js → kit/seen.js} +1 -1
- package/dist/channels/{state.js → kit/state.js} +4 -7
- package/dist/channels/{stop-command.d.ts → kit/stop-command.d.ts} +1 -1
- package/dist/channels/{stop-command.js → kit/stop-command.js} +2 -2
- package/dist/channels/{tasks.js → kit/tasks.js} +1 -1
- package/dist/channels/{thread-participants.js → kit/thread-participants.js} +1 -1
- package/dist/channels/{turn-queue.js → kit/turn-queue.js} +2 -2
- package/dist/channels/{turn-store.js → kit/turn-store.js} +1 -1
- package/dist/channels/lark/lark.d.ts +1 -1
- package/dist/channels/serve.d.ts +73 -0
- package/dist/channels/serve.js +243 -0
- package/dist/channels/slack/bot-auth.js +3 -14
- package/dist/channels/slack/context-buffer.d.ts +2 -2
- package/dist/channels/slack/context-buffer.js +2 -2
- package/dist/channels/slack/invoke-turn.d.ts +1 -1
- package/dist/channels/slack/invoke-turn.js +8 -5
- package/dist/channels/slack/parse.js +2 -2
- package/dist/channels/slack/preview.d.ts +1 -1
- package/dist/channels/slack/preview.js +11 -3
- package/dist/channels/slack/slack-api.js +1 -1
- package/dist/channels/slack/slack.d.ts +1 -1
- package/dist/channels/slack/slack.js +8 -8
- package/dist/channels/slack/welcomed.js +1 -1
- package/dist/channels/telegram/context-buffer.d.ts +2 -2
- package/dist/channels/telegram/context-buffer.js +2 -2
- package/dist/channels/telegram/invoke-turn.d.ts +1 -1
- package/dist/channels/telegram/invoke-turn.js +1 -1
- package/dist/channels/telegram/parse.js +2 -2
- package/dist/channels/telegram/preview.d.ts +1 -1
- package/dist/channels/telegram/preview.js +6 -2
- package/dist/channels/telegram/telegram.d.ts +1 -1
- package/dist/channels/telegram/telegram.js +3 -3
- package/dist/channels/telegram/turn-store.d.ts +2 -2
- package/dist/channels/telegram/turn-store.js +2 -2
- package/dist/cli/commands/add.js +1 -1
- package/dist/cli/commands/dev.js +25 -13
- package/dist/cli/commands/fire.js +1 -1
- package/dist/cli/commands/info.js +7 -4
- package/dist/cli/commands/schedule.js +1 -1
- package/dist/cli/commands/start.js +63 -94
- package/dist/cli/commands/tool.js +5 -7
- package/dist/cli/program.js +1 -1
- package/dist/cli/serve.d.ts +18 -57
- package/dist/cli/serve.js +57 -243
- package/dist/cli/shared.d.ts +1 -1
- package/dist/cli/shared.js +2 -2
- package/dist/core.d.ts +3 -5
- package/dist/core.js +7 -5
- package/dist/deploy/preflight.js +1 -1
- package/dist/dev-supervisor.js +9 -3
- package/dist/engines/pi/agent-session-factory.d.ts +104 -0
- package/dist/engines/pi/agent-session-factory.js +314 -0
- package/dist/engines/pi/config.d.ts +4 -4
- package/dist/engines/pi/config.js +1 -1
- package/dist/engines/pi/create.d.ts +56 -43
- package/dist/engines/pi/create.js +141 -71
- package/dist/engines/pi/definition.d.ts +41 -7
- package/dist/engines/pi/definition.js +115 -11
- package/dist/engines/pi/invoke-session.d.ts +22 -19
- package/dist/engines/pi/invoke-session.js +286 -100
- package/dist/engines/pi/login.js +7 -4
- package/dist/engines/pi/models.d.ts +14 -0
- package/dist/engines/pi/models.js +9 -1
- package/dist/engines/pi/open.d.ts +9 -6
- package/dist/engines/pi/open.js +20 -10
- package/dist/engines/pi/report.d.ts +0 -4
- package/dist/engines/pi/report.js +0 -7
- package/dist/engines/pi/service.d.ts +23 -0
- package/dist/engines/pi/service.js +19 -0
- package/dist/engines/pi/session-builder.js +84 -88
- package/dist/engines/pi/session-control.d.ts +10 -9
- package/dist/engines/pi/session-control.js +114 -77
- package/dist/engines/pi/session-inheritance.d.ts +44 -0
- package/dist/engines/pi/session-inheritance.js +242 -0
- package/dist/engines/pi/session-settings.d.ts +14 -2
- package/dist/engines/pi/session-settings.js +18 -0
- package/dist/engines/pi/session-store.d.ts +70 -0
- package/dist/engines/pi/session-store.js +313 -0
- package/dist/engines/pi/tool-context.d.ts +18 -8
- package/dist/engines/pi/tool-context.js +23 -1
- package/dist/engines/pi/tool.d.ts +5 -5
- package/dist/engines/pi/tool.js +1 -1
- package/dist/engines/pi/turn-kit.d.ts +32 -6
- package/dist/engines/pi/turn-kit.js +28 -1
- package/dist/engines/pi/wake-tool.js +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/log.d.ts +3 -0
- package/dist/log.js +5 -1
- package/dist/node.d.ts +16 -0
- package/dist/node.js +20 -0
- package/dist/paths.d.ts +7 -0
- package/dist/paths.js +10 -0
- package/dist/pi.d.ts +14 -8
- package/dist/pi.js +6 -5
- package/dist/scaffold/templates/persona.md +2 -2
- package/dist/schedule/state.js +4 -9
- package/dist/schedule/wakeups.d.ts +1 -1
- package/dist/schedule/wakeups.js +1 -1
- package/dist/service.d.ts +158 -0
- package/dist/service.js +354 -0
- package/dist/session-remote.d.ts +1 -1
- package/dist/session.d.ts +4 -2
- package/dist/session.js +1 -1
- package/package.json +14 -5
- package/dist/engines/pi/harness.d.ts +0 -115
- package/dist/engines/pi/harness.js +0 -154
- package/dist/engines/pi/invoke.d.ts +0 -60
- package/dist/engines/pi/invoke.js +0 -459
- package/dist/engines/pi/read-image.d.ts +0 -4
- package/dist/engines/pi/read-image.js +0 -62
- package/dist/engines/pi/sessions.d.ts +0 -60
- package/dist/engines/pi/sessions.js +0 -464
- package/dist/host/node.js +0 -52
- /package/dist/channels/{context-buffer.d.ts → kit/context-buffer.d.ts} +0 -0
- /package/dist/channels/{preview-kit.js → kit/preview-kit.js} +0 -0
- /package/dist/channels/{seen.d.ts → kit/seen.d.ts} +0 -0
- /package/dist/channels/{state.d.ts → kit/state.d.ts} +0 -0
- /package/dist/channels/{tasks.d.ts → kit/tasks.d.ts} +0 -0
- /package/dist/channels/{text.d.ts → kit/text.d.ts} +0 -0
- /package/dist/channels/{text.js → kit/text.js} +0 -0
- /package/dist/channels/{thread-participants.d.ts → kit/thread-participants.d.ts} +0 -0
- /package/dist/channels/{turn-queue.d.ts → kit/turn-queue.d.ts} +0 -0
- /package/dist/channels/{turn-store.d.ts → kit/turn-store.d.ts} +0 -0
package/dist/paths.js
CHANGED
|
@@ -330,3 +330,13 @@ export async function assertInsideAgentDir(agentDir, name) {
|
|
|
330
330
|
`use a real directory or a symlink that stays within it`);
|
|
331
331
|
}
|
|
332
332
|
}
|
|
333
|
+
/**
|
|
334
|
+
* Whether `targetPath` lives inside `baseDir` (same path counts). Used to ask "did an override move
|
|
335
|
+
* this OUT of the agent?" — the startup report's redeploy notes, `add`'s printed `.env` label, and the
|
|
336
|
+
* dev watcher's "your .env is not watched" warning all turn on that fact. Reporting only: fastagent
|
|
337
|
+
* does not act on where a user's paths point.
|
|
338
|
+
*/
|
|
339
|
+
export function isUnderDir(targetPath, baseDir) {
|
|
340
|
+
const rel = relative(baseDir, targetPath);
|
|
341
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
342
|
+
}
|
package/dist/pi.d.ts
CHANGED
|
@@ -1,19 +1,25 @@
|
|
|
1
1
|
export { createPiAgent, createPiAgentFromDefinition, type CreatePiAgentFromDefinitionOptions, type CreatePiAgentOptions, } from "./engines/pi/create.ts";
|
|
2
|
-
export { defineTool,
|
|
2
|
+
export { defineTool, type DefineToolOptions, type FastagentTool, type MountedTool, type ToolCollision, type ToolContext, } from "./engines/pi/tool.ts";
|
|
3
3
|
export type { ReadonlySessionManager, ToolActivation } from "./engines/pi/tool-context.ts";
|
|
4
4
|
export { z } from "zod";
|
|
5
|
-
export type { AgentTool, ExecutionEnv,
|
|
6
|
-
|
|
5
|
+
export type { AgentTool, ExecutionEnv, Skill, SkillDiagnostic } from "@earendil-works/pi-agent-core";
|
|
6
|
+
/**
|
|
7
|
+
* A conversation record, as the tool runtime and the control plane hold it. Its entries are pi's own
|
|
8
|
+
* — exported under a qualified name because `SessionEntry` in this package is the NEUTRAL one the
|
|
9
|
+
* control plane publishes (session.ts), and the two are different shapes.
|
|
10
|
+
*/
|
|
11
|
+
export type { SessionManager, SessionEntry as PiSessionEntry } from "@earendil-works/pi-coding-agent";
|
|
7
12
|
export { createPiAgentFromDir, type CreatePiAgentFromDirOptions, } from "./engines/pi/open.ts";
|
|
8
13
|
export type { LoadedDefinition, SkillCollision } from "./engines/pi/definition.ts";
|
|
9
|
-
export { defineConfig, listModels, resolveModel, type FastagentConfig } from "./engines/pi/config.ts";
|
|
10
|
-
export type { SessionObserver } from "./engines/pi/
|
|
14
|
+
export { defineConfig, listModels, resolveModel, type FastagentConfig, } from "./engines/pi/config.ts";
|
|
15
|
+
export type { SessionObserver } from "./engines/pi/turn-kit.ts";
|
|
11
16
|
export { inProcessLease, type Lease, type Release } from "./engines/pi/turn-kit.ts";
|
|
12
17
|
export { createPiSessionControl, type CreatePiSessionControlOptions, } from "./engines/pi/session-control.ts";
|
|
13
|
-
export type
|
|
14
|
-
export
|
|
18
|
+
export { piInMemorySessionRecordStore, piSessionRecordStore, type PiSessionRecordStore, } from "./engines/pi/session-store.ts";
|
|
19
|
+
export type { SessionInheritance } from "./engines/pi/session-inheritance.ts";
|
|
15
20
|
export { GLOBAL_AUTH_PATH, fastagentCredentialStore, type FastagentAuthOptions } from "./engines/pi/auth.ts";
|
|
16
21
|
export { createPiModels, probeAuthSource, type CreatePiModelsOptions } from "./engines/pi/models.ts";
|
|
17
22
|
export type { Models } from "@earendil-works/pi-ai";
|
|
18
|
-
export {
|
|
23
|
+
export type { Provider, ProviderAuth } from "@earendil-works/pi-ai";
|
|
19
24
|
export type { Model } from "@earendil-works/pi-ai";
|
|
25
|
+
export { createAgentService, type CreateAgentServiceOptions } from "./engines/pi/service.ts";
|
package/dist/pi.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
// The pi reference implementation: assembly, agent discovery, tools, config, models, auth, and state ports.
|
|
2
2
|
export { createPiAgent, createPiAgentFromDefinition, } from "./engines/pi/create.js";
|
|
3
|
-
export { defineTool,
|
|
3
|
+
export { defineTool, } from "./engines/pi/tool.js";
|
|
4
4
|
export { z } from "zod";
|
|
5
|
-
export { loadChannels } from "./engines/pi/channel.js";
|
|
6
5
|
export { createPiAgentFromDir, } from "./engines/pi/open.js";
|
|
7
|
-
export { defineConfig, listModels, resolveModel } from "./engines/pi/config.js";
|
|
6
|
+
export { defineConfig, listModels, resolveModel, } from "./engines/pi/config.js";
|
|
8
7
|
export { inProcessLease } from "./engines/pi/turn-kit.js";
|
|
9
8
|
export { createPiSessionControl, } from "./engines/pi/session-control.js";
|
|
10
|
-
export {
|
|
9
|
+
export { piInMemorySessionRecordStore, piSessionRecordStore, } from "./engines/pi/session-store.js";
|
|
11
10
|
export { GLOBAL_AUTH_PATH, fastagentCredentialStore } from "./engines/pi/auth.js";
|
|
12
11
|
export { createPiModels, probeAuthSource } from "./engines/pi/models.js";
|
|
13
|
-
|
|
12
|
+
// The product's one-call assembly: a directory becomes a live service. On the pi surface because
|
|
13
|
+
// it opens a directory the pi way (channels, schedules, the assembled agent).
|
|
14
|
+
export { createAgentService } from "./engines/pi/service.js";
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
You are this workspace's agent. This file is your identity — it overrides the engine's default identity line, and it is re-read every turn along with the rest of your definition (`skills/` — capabilities you load when a task calls for them; `tools/` — code tools your author added, in the same directory as this file). An edit to any of them takes effect on your next message, no restart.
|
|
4
4
|
|
|
5
|
-
Your definition is this directory: `persona.md`, `skills/`, `tools/`, and the config beside them. Your WORKSPACE is the directory you were started in — the project you work on
|
|
5
|
+
Your definition is this directory: `persona.md`, `skills/`, `tools/`, and the config beside them. Your WORKSPACE is the directory you were started in — the project you work on. It may be this same directory, or the one containing it; `fastagent info` prints both. Use only the tools actually listed in your system prompt. If the workspace has an `AGENTS.md`, it is project context — follow it without assuming a file tool is available.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
When your mounted tools allow it, you can improve yourself. When a task reveals something durable — a repeatable process, a standing preference, a hard-won fact — write it into your definition instead of losing it:
|
|
8
8
|
|
|
9
9
|
- A repeatable process or capability → a new skill beside this file: `skills/<name>/SKILL.md`. Only the `skills/` next to this file is scanned. Read `skills/writing-great-skills/SKILL.md` first; it is the guide to authoring skills well.
|
|
10
10
|
- A standing instruction or fact → edit this file.
|
package/dist/schedule/state.js
CHANGED
|
@@ -4,12 +4,10 @@
|
|
|
4
4
|
* - `fires.json` — schedule name → last-fired ISO (durability for the cron catch-up-once);
|
|
5
5
|
* - `wakeups.json` — the agent's pending self-scheduled one-shot wake-ups (wakeups.ts).
|
|
6
6
|
* No .gitignore is written here: the agent's own (scaffolded by `init`) excludes `.state/`.
|
|
7
|
-
*
|
|
8
|
-
* ponytail: this atomic read/write duplicates channels/telegram/state.ts's primitive (both KB-JSON
|
|
9
|
-
* tmp+rename). Extract a neutral src/state.ts and have both import it when a third consumer appears.
|
|
10
7
|
*/
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { writeFileAtomic } from "../atomic-write.js";
|
|
13
11
|
import { log } from "../log.js";
|
|
14
12
|
/** Path of a JSON file under `<stateRoot>/schedule/`. */
|
|
15
13
|
export function scheduleFile(stateRoot, name) {
|
|
@@ -42,10 +40,7 @@ export function readScheduleFile(path) {
|
|
|
42
40
|
}
|
|
43
41
|
}
|
|
44
42
|
export function writeScheduleFile(path, value) {
|
|
45
|
-
|
|
46
|
-
const tmp = `${path}.tmp`;
|
|
47
|
-
writeFileSync(tmp, JSON.stringify(value));
|
|
48
|
-
renameSync(tmp, path);
|
|
43
|
+
writeFileAtomic(path, JSON.stringify(value));
|
|
49
44
|
}
|
|
50
45
|
export function loadFires(stateRoot) {
|
|
51
46
|
const v = readScheduleFile(scheduleFile(stateRoot, "fires"));
|
|
@@ -73,7 +73,7 @@ export declare function takeFirstDueWakeup(stateRoot: string, now?: Date): Wakeu
|
|
|
73
73
|
* already advanced the entry, and its next occurrence comes by definition — a busy one is skipped + audited.
|
|
74
74
|
*
|
|
75
75
|
* Known residual: between the claim (removed from the store) and this re-add there is a microtask-scale
|
|
76
|
-
* window (the busy reject yields before any
|
|
76
|
+
* window (the busy reject yields before any engine IO) where an `unwake` for this id reports "not found"
|
|
77
77
|
* and the defer then resurrects it — the one-shot cousin of the recurring resurrection the advance-in-place
|
|
78
78
|
* claim eliminated. Accepted: closing it needs a claim-lease with expiry, disproportionate to the window.
|
|
79
79
|
*/
|
package/dist/schedule/wakeups.js
CHANGED
|
@@ -201,7 +201,7 @@ export function takeFirstDueWakeup(stateRoot, now = new Date()) {
|
|
|
201
201
|
* already advanced the entry, and its next occurrence comes by definition — a busy one is skipped + audited.
|
|
202
202
|
*
|
|
203
203
|
* Known residual: between the claim (removed from the store) and this re-add there is a microtask-scale
|
|
204
|
-
* window (the busy reject yields before any
|
|
204
|
+
* window (the busy reject yields before any engine IO) where an `unwake` for this id reports "not found"
|
|
205
205
|
* and the defer then resurrects it — the one-shot cousin of the recurring resurrection the advance-in-place
|
|
206
206
|
* claim eliminated. Accepted: closing it needs a claim-lease with expiry, disproportionate to the window.
|
|
207
207
|
*/
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type { Agent } from "./agent.ts";
|
|
2
|
+
import { type LoadedLongConnectionChannel } from "./channels/discover.ts";
|
|
3
|
+
import type { SessionControl } from "./session.ts";
|
|
4
|
+
import type { ChannelHandler, Routes } from "./channel.ts";
|
|
5
|
+
import { type PrefixMount } from "./channels/serve.ts";
|
|
6
|
+
import type { LoadedSchedule } from "./schedule/schedule.ts";
|
|
7
|
+
export interface ServingSurface {
|
|
8
|
+
routes: Routes;
|
|
9
|
+
/** Prefix-owning handlers mounted beside the routes (the session control plane). */
|
|
10
|
+
mounts?: readonly PrefixMount[];
|
|
11
|
+
longConnections: LoadedLongConnectionChannel[];
|
|
12
|
+
/** Route-channel basenames; the tunnel registers only this subset. */
|
|
13
|
+
routeChannels: string[];
|
|
14
|
+
builtinInvoke: boolean;
|
|
15
|
+
/** Marks the built-in health route ready after every long-connection channel first connects. */
|
|
16
|
+
/** Flip health between 200 and 503. Two-way on purpose: a long connection that dies after coming
|
|
17
|
+
* up leaves the surface serving something it no longer has, and a load balancer should hear it. */
|
|
18
|
+
setReady(value: boolean): void;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The surface this deployment serves: default `GET /health` plus discovered channels, or the default
|
|
22
|
+
* POST `/invoke` only when neither a route nor a long-connection channel was declared.
|
|
23
|
+
*/
|
|
24
|
+
export declare function routesFor(agentDir: string, agent: Agent, stateRoot: string, control: SessionControl | undefined, options?: {
|
|
25
|
+
builtinInvoke?: boolean;
|
|
26
|
+
}): Promise<ServingSurface>;
|
|
27
|
+
/**
|
|
28
|
+
* Refuse channel routes the control plane would swallow.
|
|
29
|
+
*
|
|
30
|
+
* `router` refuses this too, and would catch it a moment later; this exists for the sentence, not
|
|
31
|
+
* the check — an author who lands a route under `/control` needs to hear about `sessionControl`,
|
|
32
|
+
* which the host has no way to mention. Both ask {@link pathUnderPrefix}, so there is one rule with
|
|
33
|
+
* two wordings, not two rules.
|
|
34
|
+
*
|
|
35
|
+
* Called from BOTH mount points: here at boot, and again on agentcore's lazy path, whose channels
|
|
36
|
+
* load after this ran against an empty base.
|
|
37
|
+
*/
|
|
38
|
+
export declare function assertNoControlPlaneCollision(channelRoutes: Routes, plane: PrefixMount): void;
|
|
39
|
+
export declare function mountSessionControl(routes: Routes, control: SessionControl | undefined, stateRoot: string, options?: {
|
|
40
|
+
tunnel?: boolean;
|
|
41
|
+
agent?: Agent;
|
|
42
|
+
host?: string;
|
|
43
|
+
}): {
|
|
44
|
+
routes: Routes;
|
|
45
|
+
mounts: PrefixMount[];
|
|
46
|
+
/** The plane's bearer token and prefix — how an embedder distributes access without a discovery file. */
|
|
47
|
+
control?: {
|
|
48
|
+
token: string;
|
|
49
|
+
prefix: string;
|
|
50
|
+
};
|
|
51
|
+
/** Write the local discovery file; returns its removal. Installs no signal handlers. */
|
|
52
|
+
announce: (boundPort: number) => () => void;
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* Load and start the agent's `schedules/` — a time-trigger firing the agent on each cron. Starts iff
|
|
56
|
+
* there are static schedules OR `selfSchedule` is on. Best-effort stop on process signals. Returns the
|
|
57
|
+
* loaded schedules so a serving surface that needs them (the AgentCore adapter's fire binding) shares
|
|
58
|
+
* ONE load instead of re-discovering. `externalClock` (AgentCore) arms no resident cron timers.
|
|
59
|
+
*/
|
|
60
|
+
export declare function startSchedules(agentDir: string, agent: Agent, stateRoot: string, selfSchedule: boolean, options?: {
|
|
61
|
+
externalClock?: boolean;
|
|
62
|
+
}): Promise<{
|
|
63
|
+
schedules: LoadedSchedule[];
|
|
64
|
+
stop: () => void;
|
|
65
|
+
}>;
|
|
66
|
+
export interface AgentService {
|
|
67
|
+
/** The assembled Fetch handler: channel routes, the control plane, and health. Mount it wherever
|
|
68
|
+
* your host speaks `(Request) => Response`; `nodeListener` bridges it to Node's `(req, res)`. */
|
|
69
|
+
handler: ChannelHandler;
|
|
70
|
+
/** The agent behind it — invoke it directly when you also want a programmatic path. */
|
|
71
|
+
agent: Agent;
|
|
72
|
+
/** The literal routes `handler` was composed from — for a startup line naming what is served.
|
|
73
|
+
* Mounted prefixes are not here: nothing outside the assembly needed them, and a field kept for a
|
|
74
|
+
* hypothetical caller is a field nobody maintains. */
|
|
75
|
+
routes: Routes;
|
|
76
|
+
agentDir: string;
|
|
77
|
+
workspace: string;
|
|
78
|
+
/** What actually mounted, for a startup line: channel files serving routes, long connections, and
|
|
79
|
+
* whether the built-in `POST /invoke` fallback is one of the routes. That last one is a FACT of
|
|
80
|
+
* the assembly, not something to re-infer from a path — a channel may legally author
|
|
81
|
+
* `POST /invoke` with a protocol of its own. */
|
|
82
|
+
channels: {
|
|
83
|
+
routes: string[];
|
|
84
|
+
longConnections: string[];
|
|
85
|
+
builtinInvoke: boolean;
|
|
86
|
+
};
|
|
87
|
+
schedules: readonly LoadedSchedule[];
|
|
88
|
+
/** Settles when every long connection is up — immediately when there are none. REJECTS if one
|
|
89
|
+
* fails to come up, after closing the service: a host must not report itself serving while a
|
|
90
|
+
* declared channel is dead, and health answers 503 until this resolves. */
|
|
91
|
+
ready: Promise<void>;
|
|
92
|
+
/** The control plane's bearer token and prefix, when `sessionControl` is on — how an embedder
|
|
93
|
+
* hands access to a client without a discovery file. */
|
|
94
|
+
control?: {
|
|
95
|
+
token: string;
|
|
96
|
+
prefix: string;
|
|
97
|
+
};
|
|
98
|
+
/** Write `<stateRoot>/control.json` so a LOCAL client (`fastagent attach`) can find the plane,
|
|
99
|
+
* once the port is known. Optional: an embedder mounted inside a larger app has no port of its
|
|
100
|
+
* own to describe and uses {@link AgentService.control} instead.
|
|
101
|
+
*
|
|
102
|
+
* Removed by `close()`. Not by an `exit` handler: installing one is a decision about the whole
|
|
103
|
+
* process, which a mounted library does not get to make. A hard exit therefore leaves the file
|
|
104
|
+
* behind — advisory, overwritten by the next boot, and the client's own error stays honest. */
|
|
105
|
+
announce(boundPort: number): void;
|
|
106
|
+
/** Stop long connections and schedules. Idempotent; also runs when `options.signal` aborts. */
|
|
107
|
+
close(): Promise<void>;
|
|
108
|
+
}
|
|
109
|
+
/** What {@link mountAgentService} needs beyond an opened directory. */
|
|
110
|
+
export interface MountAgentServiceOptions {
|
|
111
|
+
/** Wrap the agent before anything consumes it — every consumer (routes, control plane, schedules)
|
|
112
|
+
* must get the SAME one, which is why this is a hook rather than the caller's own call. `dev`
|
|
113
|
+
* passes `logAgentLoop`. */
|
|
114
|
+
wrapAgent?: (agent: Agent) => Agent;
|
|
115
|
+
/** Passed through to the control plane mount: `--tunnel` widens its warning, `host` names the
|
|
116
|
+
* bind address in the discovery file. */
|
|
117
|
+
control?: {
|
|
118
|
+
tunnel?: boolean;
|
|
119
|
+
host?: string;
|
|
120
|
+
};
|
|
121
|
+
/** Aborting this closes the service, exactly like calling {@link AgentService.close}. */
|
|
122
|
+
signal?: AbortSignal;
|
|
123
|
+
/** Called when a long connection ends on its own — a dropped socket-mode channel, say. The CLI
|
|
124
|
+
* exits; an embedded host may prefer to log. Default: log an error. */
|
|
125
|
+
onChannelClosed?: (name: string, error?: unknown) => void;
|
|
126
|
+
/** How long `close()` waits for a channel to stop before reporting it stuck (default 5s). The CLI
|
|
127
|
+
* shortens it so its own forced exit lands after this answer, not before it. */
|
|
128
|
+
closeTimeoutMs?: number;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* What the assembly needs from an opened agent directory — the whole of it. Spelled as its own type
|
|
132
|
+
* rather than an engine's return shape: every field here is either the SPEC contract or a path, so
|
|
133
|
+
* an engine that is not pi can satisfy it without either side knowing about the other.
|
|
134
|
+
*/
|
|
135
|
+
export interface MountableAgent {
|
|
136
|
+
agent: Agent;
|
|
137
|
+
/** The definition dir: where channels/, tools/ and schedules/ are read from. */
|
|
138
|
+
agentDir: string;
|
|
139
|
+
/** The agent's cwd. */
|
|
140
|
+
workspace: string;
|
|
141
|
+
/** Where durable state lives (channel state, sessions, schedule fires). */
|
|
142
|
+
stateRoot: string;
|
|
143
|
+
/** Present iff this agent published a control plane. */
|
|
144
|
+
sessionControl?: SessionControl;
|
|
145
|
+
/** Whether the agent schedules its own follow-up turns. REQUIRED, not optional-with-a-default:
|
|
146
|
+
* an engine that forgot it would turn self-scheduling off silently, which is exactly the bug
|
|
147
|
+
* this type was introduced with. */
|
|
148
|
+
selfSchedule: boolean;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* The assembly itself, over an already-opened directory: channels, the control plane, schedules and
|
|
152
|
+
* long connections, composed into one handler.
|
|
153
|
+
*
|
|
154
|
+
* {@link createAgentService} is this plus opening the directory. `dev`/`start` open separately — their
|
|
155
|
+
* startup report needs the opened values before anything mounts — and then arrive here, so there is
|
|
156
|
+
* one assembly rather than one per caller.
|
|
157
|
+
*/
|
|
158
|
+
export declare function mountAgentService(opened: MountableAgent, options?: MountAgentServiceOptions): Promise<AgentService>;
|
package/dist/service.js
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The product, as one call: an agent directory becomes a live service.
|
|
3
|
+
*
|
|
4
|
+
* That phrase is the promise on the README, and until this existed only the CLI could keep it. The
|
|
5
|
+
* assembly parts live here too — `routesFor`, `mountSessionControl`, `startSchedules` — because a
|
|
6
|
+
* public entry may not reach into `cli/`: that directory decides process-level things (`fail.ts`
|
|
7
|
+
* calls `process.exit`) which a library mounted inside someone's app does not get to decide.
|
|
8
|
+
* Everything else was parts: assemble the agent, discover channels, mount the control plane, start
|
|
9
|
+
* schedules, open long connections, compose a router. An embedder had to know that list and get its
|
|
10
|
+
* order right, and getting it wrong is silent: a plane that 404s while advertising itself, a
|
|
11
|
+
* schedule that never fires.
|
|
12
|
+
*
|
|
13
|
+
* So the assembly lives here, and `dev`/`start` are callers. AgentCore is the one exception, and a
|
|
14
|
+
* substantive one: its channels load lazily after a state-snapshot restore, so it cannot use an
|
|
15
|
+
* assembly that discovers them eagerly (cli/commands/start.ts says so at the branch).
|
|
16
|
+
*/
|
|
17
|
+
import { mkdirSync, rmSync } from "node:fs";
|
|
18
|
+
import { writeFileAtomic } from "./atomic-write.js";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { classifyBind, clientHost } from "./bind.js";
|
|
21
|
+
import { createControlPlane } from "./channels/control.js";
|
|
22
|
+
import { createInvokeHandler } from "./channels/http.js";
|
|
23
|
+
import { text } from "./channels/respond.js";
|
|
24
|
+
import { parseRouteKey, pathUnderPrefix } from "./channels/serve.js";
|
|
25
|
+
import { loadChannels } from "./channels/discover.js";
|
|
26
|
+
import { loadSchedules } from "./schedule/discover.js";
|
|
27
|
+
import { createScheduler } from "./schedule/scheduler.js";
|
|
28
|
+
import { router } from "./channels/serve.js";
|
|
29
|
+
import { log, reportModuleLoadFailures } from "./log.js";
|
|
30
|
+
/** Default wait for a channel's `closed` before reporting it stuck. A channel that ignores its
|
|
31
|
+
* abort signal must not hang a caller's teardown — or, during a failed start, keep the original
|
|
32
|
+
* error from arriving. The CLI passes a shorter one: its own forced exit must come AFTER this, or
|
|
33
|
+
* the process leaves at 0 before the failure is known. */
|
|
34
|
+
const CLOSE_DEADLINE_MS = 5_000;
|
|
35
|
+
/** Settle when every connection has closed, or when the deadline passes. Reports the ones that did
|
|
36
|
+
* NOT settle — named individually, so a single stuck channel is not reported as all of them. */
|
|
37
|
+
async function closeWithin(runs, names, deadlineMs) {
|
|
38
|
+
const pending = new Set(runs.map((_, i) => i));
|
|
39
|
+
const failures = [];
|
|
40
|
+
const tracked = runs.map((run, i) => run.closed.then(() => {
|
|
41
|
+
pending.delete(i);
|
|
42
|
+
}, (error) => {
|
|
43
|
+
pending.delete(i);
|
|
44
|
+
failures.push(error);
|
|
45
|
+
}));
|
|
46
|
+
let timer;
|
|
47
|
+
try {
|
|
48
|
+
await Promise.race([
|
|
49
|
+
Promise.all(tracked),
|
|
50
|
+
// NOT unref'd: this timer is the thing being awaited, and an unref'd one lets the loop go
|
|
51
|
+
// idle with nothing left to advance it. Cleared below so a prompt close does not hold the
|
|
52
|
+
// process for the rest of the deadline.
|
|
53
|
+
new Promise((resolve) => {
|
|
54
|
+
timer = setTimeout(resolve, deadlineMs);
|
|
55
|
+
}),
|
|
56
|
+
]);
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
clearTimeout(timer);
|
|
60
|
+
}
|
|
61
|
+
return { stuck: [...pending].map((i) => names[i] ?? "channel"), failures };
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The surface this deployment serves: default `GET /health` plus discovered channels, or the default
|
|
65
|
+
* POST `/invoke` only when neither a route nor a long-connection channel was declared.
|
|
66
|
+
*/
|
|
67
|
+
export async function routesFor(agentDir, agent, stateRoot, control, options = {}) {
|
|
68
|
+
const { routes, longConnections, routeChannels, collisions, failures } = await loadChannels(agentDir, {
|
|
69
|
+
agent,
|
|
70
|
+
stateRoot,
|
|
71
|
+
control,
|
|
72
|
+
});
|
|
73
|
+
for (const c of collisions) {
|
|
74
|
+
console.error(`[fastagent] warn: channel route "${c.route}" (${c.source}) collides with an earlier channel — not mounted`);
|
|
75
|
+
}
|
|
76
|
+
reportModuleLoadFailures(failures);
|
|
77
|
+
if (failures.length > 0 || collisions.length > 0) {
|
|
78
|
+
throw new Error(`channel setup is invalid (${failures.length} load failure(s), ${collisions.length} route collision(s)) — ` +
|
|
79
|
+
`fix it, or rename an intentionally disabled file to *.disabled`);
|
|
80
|
+
}
|
|
81
|
+
const builtinInvoke = options.builtinInvoke !== false && Object.keys(routes).length === 0 && longConnections.length === 0;
|
|
82
|
+
const channels = builtinInvoke ? { "POST /invoke": createInvokeHandler(agent) } : routes;
|
|
83
|
+
const healthCovered = Object.keys(channels).some((key) => {
|
|
84
|
+
const entry = parseRouteKey(key);
|
|
85
|
+
return entry.path === "/health" && (entry.method === undefined || entry.method === "GET");
|
|
86
|
+
});
|
|
87
|
+
let ready = longConnections.length === 0;
|
|
88
|
+
const health = () => (ready ? text("ok\n", 200) : text("starting\n", 503));
|
|
89
|
+
return {
|
|
90
|
+
routes: healthCovered ? channels : { "GET /health": health, ...channels },
|
|
91
|
+
longConnections,
|
|
92
|
+
routeChannels,
|
|
93
|
+
builtinInvoke,
|
|
94
|
+
setReady(value) {
|
|
95
|
+
ready = value;
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Refuse channel routes the control plane would swallow.
|
|
101
|
+
*
|
|
102
|
+
* `router` refuses this too, and would catch it a moment later; this exists for the sentence, not
|
|
103
|
+
* the check — an author who lands a route under `/control` needs to hear about `sessionControl`,
|
|
104
|
+
* which the host has no way to mention. Both ask {@link pathUnderPrefix}, so there is one rule with
|
|
105
|
+
* two wordings, not two rules.
|
|
106
|
+
*
|
|
107
|
+
* Called from BOTH mount points: here at boot, and again on agentcore's lazy path, whose channels
|
|
108
|
+
* load after this ran against an empty base.
|
|
109
|
+
*/
|
|
110
|
+
export function assertNoControlPlaneCollision(channelRoutes, plane) {
|
|
111
|
+
const collisions = Object.keys(channelRoutes).filter((key) => pathUnderPrefix(parseRouteKey(key).path, plane.prefix));
|
|
112
|
+
if (collisions.length > 0) {
|
|
113
|
+
throw new Error(`channel route(s) ${collisions.map((key) => `"${key}"`).join(", ")} collide with the session control plane — ` +
|
|
114
|
+
`rename the channel route or disable sessionControl in fastagent.config`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
export function mountSessionControl(routes, control, stateRoot, options = {}) {
|
|
118
|
+
if (!control)
|
|
119
|
+
return { routes, mounts: [], announce: () => () => { } };
|
|
120
|
+
const token = crypto.randomUUID();
|
|
121
|
+
const plane = createControlPlane(control, { token, agent: options.agent });
|
|
122
|
+
assertNoControlPlaneCollision(routes, plane);
|
|
123
|
+
return {
|
|
124
|
+
routes,
|
|
125
|
+
mounts: [plane],
|
|
126
|
+
control: { token, prefix: plane.prefix },
|
|
127
|
+
// Writes the discovery file and hands back its removal. It installs NO signal handlers: a
|
|
128
|
+
// library mounted inside someone's app must not change how that app exits — the CLI wires the
|
|
129
|
+
// returned cleanup into its own shutdown, an embedder into `close()`.
|
|
130
|
+
announce: (boundPort) => {
|
|
131
|
+
mkdirSync(stateRoot, { recursive: true, mode: 0o700 });
|
|
132
|
+
const path = join(stateRoot, "control.json");
|
|
133
|
+
const url = `http://${clientHost(options.host)}:${boundPort}`;
|
|
134
|
+
writeFileAtomic(path, `${JSON.stringify({ url, token })}\n`, 0o600);
|
|
135
|
+
log.info(`[fastagent] session control on /control/* (token in ${path})`);
|
|
136
|
+
// LAN-reachable with the bearer token as the only protection — the tunnel and deploy paths
|
|
137
|
+
// warn loudly, and the LAN path must not be the silent third way past the local trust story.
|
|
138
|
+
// A loopback bind closes exactly that reach, so it earns silence.
|
|
139
|
+
const bind = classifyBind(options.host);
|
|
140
|
+
if (bind !== "loopback") {
|
|
141
|
+
log.warn(`[fastagent] the port binds ${bind === "wildcard" ? "all interfaces" : `${options.host} (off this machine)`}: ` +
|
|
142
|
+
"/control/* is reachable on your LAN, protected only by the bearer token — bind loopback " +
|
|
143
|
+
"(--bind 127.0.0.1), firewall the port, or wrap it for real exposure (docs: design §14)");
|
|
144
|
+
}
|
|
145
|
+
if (options.tunnel) {
|
|
146
|
+
// Local trust = the token + its file permissions; --tunnel takes the whole port PUBLIC.
|
|
147
|
+
log.warn("[fastagent] --tunnel exposes /control/* (steer/abort/set_model) at the public tunnel URL, " +
|
|
148
|
+
"protected ONLY by the bearer token — wrap it with real auth before sharing that URL (docs: design §14)");
|
|
149
|
+
}
|
|
150
|
+
// Removed on shutdown so a stale file cannot point a client at a dead port: `attach` then
|
|
151
|
+
// fails with "cannot read" (accurate) instead of a stale token's misleading 401/ECONNREFUSED.
|
|
152
|
+
return () => {
|
|
153
|
+
try {
|
|
154
|
+
rmSync(path, { force: true });
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
/* the file is advisory — shutdown must not fail on it */
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Load and start the agent's `schedules/` — a time-trigger firing the agent on each cron. Starts iff
|
|
165
|
+
* there are static schedules OR `selfSchedule` is on. Best-effort stop on process signals. Returns the
|
|
166
|
+
* loaded schedules so a serving surface that needs them (the AgentCore adapter's fire binding) shares
|
|
167
|
+
* ONE load instead of re-discovering. `externalClock` (AgentCore) arms no resident cron timers.
|
|
168
|
+
*/
|
|
169
|
+
export async function startSchedules(agentDir, agent, stateRoot, selfSchedule, options = {}) {
|
|
170
|
+
// Thrown, not exited on: this runs inside an embedder's app as well as the CLI, and a library
|
|
171
|
+
// that calls process.exit takes a decision (degrade? retry? stop?) that belongs to its host. The
|
|
172
|
+
// CLI catches at its own boundary.
|
|
173
|
+
const { schedules, failures } = await loadSchedules(agentDir);
|
|
174
|
+
reportModuleLoadFailures(failures);
|
|
175
|
+
if (schedules.length === 0 && !selfSchedule)
|
|
176
|
+
return { schedules, stop: () => { } };
|
|
177
|
+
const scheduler = createScheduler({ agent, stateRoot, schedules, externalClock: options.externalClock });
|
|
178
|
+
scheduler.start();
|
|
179
|
+
if (schedules.length > 0) {
|
|
180
|
+
log.info(`[fastagent] schedules: ${schedules.map((s) => s.name).join(", ")}${options.externalClock ? " (external clock — no resident cron timers)" : ""}`);
|
|
181
|
+
}
|
|
182
|
+
// Returned rather than bound to process signals here: this runs inside an embedder's app as well
|
|
183
|
+
// as the CLI, and a library that installs SIGINT handlers is deciding something that is not its
|
|
184
|
+
// to decide. `runStart`/`runDev` wire it to their own shutdown.
|
|
185
|
+
return { schedules, stop: () => scheduler.stop() };
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* The assembly itself, over an already-opened directory: channels, the control plane, schedules and
|
|
189
|
+
* long connections, composed into one handler.
|
|
190
|
+
*
|
|
191
|
+
* {@link createAgentService} is this plus opening the directory. `dev`/`start` open separately — their
|
|
192
|
+
* startup report needs the opened values before anything mounts — and then arrive here, so there is
|
|
193
|
+
* one assembly rather than one per caller.
|
|
194
|
+
*/
|
|
195
|
+
export async function mountAgentService(opened, options = {}) {
|
|
196
|
+
const { agentDir, workspace, stateRoot, sessionControl } = opened;
|
|
197
|
+
// Wrapped BEFORE anything consumes it: routes, the control plane and schedules must all drive the
|
|
198
|
+
// same agent, so this is a hook rather than something a caller applies afterwards.
|
|
199
|
+
const agent = options.wrapAgent?.(opened.agent) ?? opened.agent;
|
|
200
|
+
const closeTimeoutMs = options.closeTimeoutMs ?? CLOSE_DEADLINE_MS;
|
|
201
|
+
const routed = await routesFor(agentDir, agent, stateRoot, sessionControl, { builtinInvoke: true });
|
|
202
|
+
const withControl = mountSessionControl(routed.routes, sessionControl, stateRoot, {
|
|
203
|
+
agent,
|
|
204
|
+
...(options.control?.tunnel !== undefined ? { tunnel: options.control.tunnel } : {}),
|
|
205
|
+
...(options.control?.host !== undefined ? { host: options.control.host } : {}),
|
|
206
|
+
});
|
|
207
|
+
// Composed BEFORE anything starts. `router` re-validates what `loadChannels` and the control
|
|
208
|
+
// mount already checked, so on THIS path it should not fail — but "should not" is not an ordering
|
|
209
|
+
// guarantee, and a throw after the scheduler ticks and channels dial would leave both running
|
|
210
|
+
// with no service for the caller to close. Free to order correctly; expensive to discover later.
|
|
211
|
+
const handler = router(withControl.routes, withControl.mounts);
|
|
212
|
+
const scheduled = await startSchedules(agentDir, agent, stateRoot, opened.selfSchedule);
|
|
213
|
+
const abort = new AbortController();
|
|
214
|
+
let unannounce;
|
|
215
|
+
// A connection that drops while others are still dialling must not be undone by their later
|
|
216
|
+
// readiness: the service is missing a declared channel from that moment on, whatever else arrives.
|
|
217
|
+
let dropped = false;
|
|
218
|
+
const onClosed = options.onChannelClosed ??
|
|
219
|
+
((name, error) => log.error(`[fastagent] long connection ${name} ${error === undefined ? "closed" : `failed: ${String(error)}`}`));
|
|
220
|
+
const runs = [];
|
|
221
|
+
// A FUNCTION declaration, not a const: `close()` detaches this listener, and a rollback can call
|
|
222
|
+
// `close()` before this point is reached — a `const` would be in its temporal dead zone there, so
|
|
223
|
+
// the cleanup would throw a ReferenceError and silently skip everything after it.
|
|
224
|
+
//
|
|
225
|
+
// Detached because a caller that closes services itself while holding one long-lived signal would
|
|
226
|
+
// otherwise accumulate listeners, each pinning a whole service through its closure. The signal
|
|
227
|
+
// path has no caller awaiting the promise, so a failure to stop is reported rather than left as an
|
|
228
|
+
// unhandled rejection — in an embedded library, potentially the host's exit.
|
|
229
|
+
function onAbort() {
|
|
230
|
+
void close().catch((error) => log.error(`[fastagent] service close failed: ${String(error)}`));
|
|
231
|
+
}
|
|
232
|
+
let closing;
|
|
233
|
+
const close = () => {
|
|
234
|
+
// Awaits the connections rather than only signalling them: `close()` promises they are stopped,
|
|
235
|
+
// and a caller tearing down a test or a request-scoped service needs that to be true on return.
|
|
236
|
+
closing ??= (async () => {
|
|
237
|
+
abort.abort();
|
|
238
|
+
scheduled.stop();
|
|
239
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
240
|
+
unannounce?.(); // a stale discovery file would point a client at a dead port
|
|
241
|
+
// A failure to stop is the caller's to know about — swallowing it would let `close()` report
|
|
242
|
+
// success over a channel still holding on. Bounded, because a channel that ignores its abort
|
|
243
|
+
// signal must not hang the teardown either.
|
|
244
|
+
const { stuck, failures } = await closeWithin(runs, routed.longConnections.map((c) => c.name), closeTimeoutMs);
|
|
245
|
+
if (stuck.length > 0) {
|
|
246
|
+
throw new Error(`long connection(s) did not stop within ${closeTimeoutMs}ms: ${stuck.join(", ")}`);
|
|
247
|
+
}
|
|
248
|
+
if (failures.length > 0) {
|
|
249
|
+
throw failures.length === 1 ? failures[0] : new AggregateError(failures, "long connections failed to close");
|
|
250
|
+
}
|
|
251
|
+
})();
|
|
252
|
+
return closing;
|
|
253
|
+
};
|
|
254
|
+
// Rollback IS close(), plus keeping the original error: a failure to clean up is the aftermath,
|
|
255
|
+
// and replacing the reason the caller needs with it hides the actual cause.
|
|
256
|
+
const rollback = async (error) => {
|
|
257
|
+
await close().catch((closeError) => log.error(`[fastagent] cleanup after a failed start also failed: ${String(closeError)}`));
|
|
258
|
+
throw error;
|
|
259
|
+
};
|
|
260
|
+
// Rolled back on failure: a connection that throws while the ones before it are open, and the
|
|
261
|
+
// scheduler already ticking, would otherwise leave both running behind a rejected open().
|
|
262
|
+
for (const connection of routed.longConnections) {
|
|
263
|
+
let run;
|
|
264
|
+
try {
|
|
265
|
+
run = connection.connect(abort.signal);
|
|
266
|
+
}
|
|
267
|
+
catch (error) {
|
|
268
|
+
return rollback(error);
|
|
269
|
+
}
|
|
270
|
+
if (typeof run?.ready?.then !== "function" || typeof run?.closed?.then !== "function") {
|
|
271
|
+
return rollback(new Error(`${connection.name} connect(signal) must return { ready: Promise, closed: Promise }`));
|
|
272
|
+
}
|
|
273
|
+
void run.closed.then(() => {
|
|
274
|
+
if (abort.signal.aborted)
|
|
275
|
+
return;
|
|
276
|
+
// A channel that dies leaves the service serving something it no longer has.
|
|
277
|
+
dropped = true;
|
|
278
|
+
routed.setReady(false);
|
|
279
|
+
onClosed(connection.name);
|
|
280
|
+
}, (error) => {
|
|
281
|
+
if (abort.signal.aborted)
|
|
282
|
+
return;
|
|
283
|
+
dropped = true;
|
|
284
|
+
routed.setReady(false);
|
|
285
|
+
onClosed(connection.name, error);
|
|
286
|
+
});
|
|
287
|
+
runs.push(run);
|
|
288
|
+
}
|
|
289
|
+
if (options.signal?.aborted)
|
|
290
|
+
await close();
|
|
291
|
+
else
|
|
292
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
293
|
+
// Health answers 503 until EVERY long connection is up, so a load balancer does not route into a
|
|
294
|
+
// service whose socket-mode channels are still dialling. An abort before that settles `ready` as
|
|
295
|
+
// cancellation, not readiness — a service being torn down must not report itself healthy.
|
|
296
|
+
const ready = (async () => {
|
|
297
|
+
try {
|
|
298
|
+
await Promise.all(runs.map(async (run, i) => {
|
|
299
|
+
const name = routed.longConnections[i]?.name ?? "channel";
|
|
300
|
+
// Raced against `closed`, because the contract puts a terminal failure THERE: a channel
|
|
301
|
+
// that dies dialling may leave `ready` pending forever, and waiting on it alone hangs
|
|
302
|
+
// startup with no diagnosis.
|
|
303
|
+
await Promise.race([
|
|
304
|
+
run.ready,
|
|
305
|
+
run.closed.then(() => Promise.reject(new Error(`${name} closed before it was ready`)), (error) => Promise.reject(new Error(`${name} failed before it was ready: ${String(error)}`))),
|
|
306
|
+
]);
|
|
307
|
+
if (!abort.signal.aborted)
|
|
308
|
+
log.info(`[fastagent] long connection ready: ${name}`);
|
|
309
|
+
}));
|
|
310
|
+
}
|
|
311
|
+
catch (error) {
|
|
312
|
+
// A connection that cannot come up is a startup failure, not a degraded service: tear the rest
|
|
313
|
+
// down before rejecting, so nothing is left running behind a caller that saw an error. A
|
|
314
|
+
// cleanup that ALSO fails is logged, never rethrown — it would replace the reason the caller
|
|
315
|
+
// actually needs with the aftermath of it.
|
|
316
|
+
await close().catch((closeError) => log.error(`[fastagent] cleanup after a failed start also failed: ${String(closeError)}`));
|
|
317
|
+
throw error;
|
|
318
|
+
}
|
|
319
|
+
// A `ready` that settles because the service was CLOSED is cancellation, not readiness — the
|
|
320
|
+
// contract lets a connection resolve it on abort. Returning normally would tell a caller its
|
|
321
|
+
// channels are up while the service is shut and health says 503.
|
|
322
|
+
if (abort.signal.aborted)
|
|
323
|
+
throw new Error("service closed before it became ready");
|
|
324
|
+
// A drop DURING startup fails it. `dropped` is only reachable here from the startup window —
|
|
325
|
+
// after this line `ready` has settled — and resolving while health is permanently 503 would
|
|
326
|
+
// hand the caller two contradictory answers about the same surface.
|
|
327
|
+
if (dropped) {
|
|
328
|
+
await close();
|
|
329
|
+
throw new Error("a long connection closed before startup completed");
|
|
330
|
+
}
|
|
331
|
+
routed.setReady(true);
|
|
332
|
+
})();
|
|
333
|
+
// Observed here so a rejection is never unhandled; every caller still sees it through `ready`.
|
|
334
|
+
ready.catch(() => { });
|
|
335
|
+
return {
|
|
336
|
+
handler,
|
|
337
|
+
agent,
|
|
338
|
+
routes: withControl.routes,
|
|
339
|
+
agentDir,
|
|
340
|
+
workspace,
|
|
341
|
+
channels: {
|
|
342
|
+
routes: routed.routeChannels,
|
|
343
|
+
longConnections: routed.longConnections.map((c) => c.name),
|
|
344
|
+
builtinInvoke: routed.builtinInvoke,
|
|
345
|
+
},
|
|
346
|
+
schedules: scheduled.schedules,
|
|
347
|
+
ready,
|
|
348
|
+
...(withControl.control ? { control: withControl.control } : {}),
|
|
349
|
+
announce: (boundPort) => {
|
|
350
|
+
unannounce = withControl.announce(boundPort);
|
|
351
|
+
},
|
|
352
|
+
close,
|
|
353
|
+
};
|
|
354
|
+
}
|