@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/README.md
CHANGED
|
@@ -157,21 +157,39 @@ const agent = createPiAgent({
|
|
|
157
157
|
|
|
158
158
|
The root export intentionally contains the supported surface only.
|
|
159
159
|
|
|
160
|
+
Engine-neutral and runtime-neutral are different properties, and the entries are layered by them —
|
|
161
|
+
each layer drops one, and every layer's dependency list is asserted in CI:
|
|
162
|
+
|
|
163
|
+
| | engine-neutral | runtime-neutral | costs |
|
|
164
|
+
|---|---|---|---|
|
|
165
|
+
| `/core`, `/session` | yes | yes | nothing |
|
|
166
|
+
| `/node` | yes | no (filesystem, clock, environment) | `@hono/node-server`, `croner` |
|
|
167
|
+
| `/pi` | no | no | the pi runtime |
|
|
168
|
+
|
|
169
|
+
`Provider` and `ProviderAuth` are exported as types because our options name them; the factory that
|
|
170
|
+
builds one, `createProvider`, comes from `@earendil-works/pi-ai` — add it as a direct dependency when
|
|
171
|
+
you register a custom provider.
|
|
172
|
+
|
|
160
173
|
| Area | Examples | Stability |
|
|
161
174
|
|---|---|---|
|
|
162
175
|
| Contract | `Agent`, `AgentEvent`, `collect` | Stable within SPEC v0.1 |
|
|
163
|
-
|
|
|
176
|
+
| Directory → service | `createAgentService` (pi opener), `mountAgentService` + `MountableAgent` (neutral assembly), `AgentService` | The supported way to mount an agent directory in an app |
|
|
177
|
+
| Mounting | `createInvokeHandler`, `Routes`, `ChannelHandler` | Reference implementation, pre-1.0 |
|
|
178
|
+
| Node binding | `nodeListener`, `serveNode` (from `/node`) | The one runtime-specific piece; see below |
|
|
164
179
|
| pi assembly | `createPiAgentFromDir`, `createPiAgentFromDefinition`, `createPiAgent` | Usable now, may tighten before 1.0 |
|
|
165
|
-
| Tool/channel authoring | `defineTool`, `z`, `
|
|
166
|
-
| Injection ports | `
|
|
167
|
-
| Not exported |
|
|
180
|
+
| Tool/channel authoring | `defineTool`, `z`, `defineSchedule`, `ChannelModule` | Usable now, may tighten before 1.0 |
|
|
181
|
+
| Injection ports | `PiSessionRecordStore`, `piSessionRecordStore`, `piInMemorySessionRecordStore`, `Lease`, `Provider` | Public because options reference them |
|
|
182
|
+
| Not exported | The assembly's parts — `router`, `createControlPlane`, `loadTools`/`loadChannels`/`loadSchedules`, `createScheduler` — and prompt/config internals | `createAgentService` does this; no compatibility promise |
|
|
168
183
|
|
|
169
|
-
Subpath
|
|
184
|
+
Subpath entry points (`./package.json` is also exported, for tools that read the version):
|
|
170
185
|
|
|
171
|
-
- `@fastagent-sh/fastagent/core` — engine-neutral contract, consumption helpers, channel
|
|
186
|
+
- `@fastagent-sh/fastagent/core` — engine-neutral contract, consumption helpers, channel kit, schedules, and the session-control clients (`connectSessionControl`, `connectAgent`). **Zero third-party dependencies**, enforced by test;
|
|
187
|
+
- `@fastagent-sh/fastagent/node` — the engine-neutral pieces that need a Node runtime: `mountAgentService` (the assembly), `serveNode` / `nodeListener` (the `node:http` ↔ Fetch binding);
|
|
188
|
+
- `@fastagent-sh/fastagent/session` — the engine-neutral session-control contract (types and error codes);
|
|
172
189
|
- `@fastagent-sh/fastagent/pi` — the pi reference implementation;
|
|
173
190
|
- `@fastagent-sh/fastagent/github` — GitHub webhook channel;
|
|
174
191
|
- `@fastagent-sh/fastagent/telegram` — Telegram bot channel;
|
|
192
|
+
- `@fastagent-sh/fastagent/slack` — Slack Events API bot channel;
|
|
175
193
|
- `@fastagent-sh/fastagent/feishu` — canonical Feishu bot channel (飞书, open.feishu.cn);
|
|
176
194
|
- `@fastagent-sh/fastagent/lark` — Lark-international compatibility profile over the Feishu engine.
|
|
177
195
|
|
|
@@ -196,7 +214,7 @@ FastAgent is pre-1.0. The stable design center is the Agent Handler contract in
|
|
|
196
214
|
The neutral contract leaves room for capabilities that are not complete product features yet:
|
|
197
215
|
|
|
198
216
|
- **Durable execution**: Telegram, Slack, and Feishu/Lark accepted turns replay at least once today; general durability and exactly-once execution remain future backend work.
|
|
199
|
-
- **Sandboxed execution** —
|
|
217
|
+
- **Sandboxed execution** — all seven coding tools, ② project context, channels, and author-written `tools/` reach the local process directly; a sandbox around the complete agent process is future work.
|
|
200
218
|
- **Observability export** — leveled logs and per-turn traces exist today; an OpenTelemetry exporter does not.
|
|
201
219
|
- **More harness bindings and channels** — pi is the built-in harness; another harness can implement the Agent contract, and community channels can use the channel kit.
|
|
202
220
|
- **More deploy targets** — local Docker, Fly, Railway, and AWS Bedrock AgentCore ship today; the generated container is the portable path for other hosts.
|
package/dist/agent.d.ts
CHANGED
|
@@ -86,7 +86,7 @@ export type AgentEvent = {
|
|
|
86
86
|
export declare const SESSION_BUSY_CODE = "session_busy";
|
|
87
87
|
/**
|
|
88
88
|
* The `failed.code` set when a run was DELIBERATELY stopped — a control-plane abort, or any
|
|
89
|
-
*
|
|
89
|
+
* engine-level abort it attributes (`stopReason: "aborted"`) — rather than failing on its
|
|
90
90
|
* own. Channels can render cancellation distinctly from an error, and MUST treat it as a settled
|
|
91
91
|
* outcome — durable turn-intent cleanup included — so a deliberate stop is never replayed as a
|
|
92
92
|
* fresh turn on restart. Exported as a constant for the same reason as {@link SESSION_BUSY_CODE}:
|
package/dist/agent.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
export const SESSION_BUSY_CODE = "session_busy";
|
|
15
15
|
/**
|
|
16
16
|
* The `failed.code` set when a run was DELIBERATELY stopped — a control-plane abort, or any
|
|
17
|
-
*
|
|
17
|
+
* engine-level abort it attributes (`stopReason: "aborted"`) — rather than failing on its
|
|
18
18
|
* own. Channels can render cancellation distinctly from an error, and MUST treat it as a settled
|
|
19
19
|
* outcome — durable turn-intent cleanup included — so a deliberate stop is never replayed as a
|
|
20
20
|
* fresh turn on restart. Exported as a constant for the same reason as {@link SESSION_BUSY_CODE}:
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Write a file so a reader sees the whole thing or nothing: same-directory temp, then rename.
|
|
3
|
+
*
|
|
4
|
+
* The temp name is fixed (`<path>.tmp`). That is safe here because no two processes write one state
|
|
5
|
+
* root: a deployment runs one container, and `dev`'s supervisor respawns its worker only after the
|
|
6
|
+
* old one has EXITED (dev-supervisor.ts) — and these writes are synchronous, so an exited process
|
|
7
|
+
* has none in flight. The fixed name is also the seam several channel tests use to inject a write
|
|
8
|
+
* failure, by occupying that path with a directory. A unique name would trade that seam for a race
|
|
9
|
+
* this codebase does not have; revisit it if a second writer ever becomes real.
|
|
10
|
+
*
|
|
11
|
+
* `mode` is applied to the temp first, so the content is never briefly world-readable.
|
|
12
|
+
*
|
|
13
|
+
* The `chmod` is NOT redundant with the `mode` option: `writeFileSync` applies `mode` only when it
|
|
14
|
+
* CREATES the file, so a temp left behind by a crashed writer keeps its old, possibly loose
|
|
15
|
+
* permissions and the rename publishes them (verified: 0644 survives a `{ mode: 0o600 }` write).
|
|
16
|
+
* It runs on the temp, before the rename — the final path is then never observable with the wrong
|
|
17
|
+
* permissions, which a chmod after the rename cannot promise.
|
|
18
|
+
*/
|
|
19
|
+
export declare function writeFileAtomic(path: string, data: string, mode?: number): void;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One spelling of "a reader sees the whole file or none of it" for SYNCHRONOUS writes, after four
|
|
3
|
+
* copies of it drifted apart: two identical, two with different temp names and different permission
|
|
4
|
+
* handling.
|
|
5
|
+
*
|
|
6
|
+
* Slack's onboarding state stays on its own async path — this is deliberately not an async API, and
|
|
7
|
+
* converting that caller is a separate question from de-duplicating these four.
|
|
8
|
+
*/
|
|
9
|
+
import { chmodSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { dirname } from "node:path";
|
|
11
|
+
/**
|
|
12
|
+
* Write a file so a reader sees the whole thing or nothing: same-directory temp, then rename.
|
|
13
|
+
*
|
|
14
|
+
* The temp name is fixed (`<path>.tmp`). That is safe here because no two processes write one state
|
|
15
|
+
* root: a deployment runs one container, and `dev`'s supervisor respawns its worker only after the
|
|
16
|
+
* old one has EXITED (dev-supervisor.ts) — and these writes are synchronous, so an exited process
|
|
17
|
+
* has none in flight. The fixed name is also the seam several channel tests use to inject a write
|
|
18
|
+
* failure, by occupying that path with a directory. A unique name would trade that seam for a race
|
|
19
|
+
* this codebase does not have; revisit it if a second writer ever becomes real.
|
|
20
|
+
*
|
|
21
|
+
* `mode` is applied to the temp first, so the content is never briefly world-readable.
|
|
22
|
+
*
|
|
23
|
+
* The `chmod` is NOT redundant with the `mode` option: `writeFileSync` applies `mode` only when it
|
|
24
|
+
* CREATES the file, so a temp left behind by a crashed writer keeps its old, possibly loose
|
|
25
|
+
* permissions and the rename publishes them (verified: 0644 survives a `{ mode: 0o600 }` write).
|
|
26
|
+
* It runs on the temp, before the rename — the final path is then never observable with the wrong
|
|
27
|
+
* permissions, which a chmod after the rename cannot promise.
|
|
28
|
+
*/
|
|
29
|
+
export function writeFileAtomic(path, data, mode) {
|
|
30
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
31
|
+
const tmp = `${path}.tmp`;
|
|
32
|
+
try {
|
|
33
|
+
writeFileSync(tmp, data, mode === undefined ? undefined : { mode });
|
|
34
|
+
if (mode !== undefined)
|
|
35
|
+
chmodSync(tmp, mode);
|
|
36
|
+
renameSync(tmp, path);
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
try {
|
|
40
|
+
rmSync(tmp, { force: true });
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// `force` only forgives ENOENT: if the temp is a directory, rmSync throws its own error and
|
|
44
|
+
// would replace the write failure that actually explains what went wrong.
|
|
45
|
+
}
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -1,8 +1,28 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The Channel contract — the trigger side of the product boundary (core.md §1), beside `agent.ts`
|
|
3
|
+
* (what an engine implements) and `session.ts` (the serving control plane). Pure types, no runtime
|
|
4
|
+
* dependency; importing a host, a framework, or an engine here is forbidden, as in those two.
|
|
5
|
+
*
|
|
6
|
+
* §7 fixes two module forms: a function is a route channel, an object with `connect` is a long
|
|
7
|
+
* connection. An agent directory ships hand-written `channels/*.ts` against them, which is why they
|
|
8
|
+
* live away from the code that serves them — a WebSocket ingress needs `LongConnection` and has no
|
|
9
|
+
* HTTP in it, and a `ChannelModule` import must not drag `node:http` in behind it.
|
|
10
|
+
*
|
|
11
|
+
* How a route table becomes a running server: `channels/serve.ts`.
|
|
12
|
+
*/
|
|
13
|
+
import type { Agent } from "./agent.ts";
|
|
14
|
+
import type { SessionControl } from "./session.ts";
|
|
15
|
+
/** A mounted request handler (a channel's fetch, or a plain route like health). Fetch-shaped by
|
|
16
|
+
* contract (SPEC §11): the one signature every runtime and embedding app already speaks. */
|
|
4
17
|
export type ChannelHandler = (req: Request) => Response | Promise<Response>;
|
|
5
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* This deployment's HTTP surface: route key → handler.
|
|
20
|
+
*
|
|
21
|
+
* A key is `"/path"` (any method) or `"METHOD /path"`, with a LITERAL path — small on purpose, so
|
|
22
|
+
* that "would these two fight over a request?" is string equality rather than a prediction about a
|
|
23
|
+
* matcher, and no channel silently shadows another. `assertRouteKey` in `channels/serve.ts`
|
|
24
|
+
* enforces it; a handler owning a prefix is a `PrefixMount`, never a key.
|
|
25
|
+
*/
|
|
6
26
|
export type Routes = Record<string, ChannelHandler>;
|
|
7
27
|
/**
|
|
8
28
|
* What the framework hands a channel at mount time: the assembled agent plus the resolved state ROOT
|
|
@@ -34,27 +54,3 @@ export interface LongConnectionChannelModule {
|
|
|
34
54
|
name: string;
|
|
35
55
|
connect(ctx: ChannelContext, signal: AbortSignal): LongConnection;
|
|
36
56
|
}
|
|
37
|
-
/** Parse a route key: `"METHOD /path"` → `{ method, path }`, or `"/path"` → `{ path }` (any method). */
|
|
38
|
-
export declare function parseRouteKey(key: string): {
|
|
39
|
-
method?: string;
|
|
40
|
-
path: string;
|
|
41
|
-
};
|
|
42
|
-
/**
|
|
43
|
-
* Compose a {@link Routes} table into one handler: exact pathname match (optionally method-qualified),
|
|
44
|
-
* 405 when the path exists under another method, 404 otherwise. No params/wildcards.
|
|
45
|
-
*/
|
|
46
|
-
export declare function router(routes: Routes): ChannelHandler;
|
|
47
|
-
/**
|
|
48
|
-
* Serve `handler` on a Node HTTP server. Thin mechanism: bind, report the port, let the caller stop
|
|
49
|
-
* accepting or force-close active connections — no logging/signals/exit (the CLI owns those).
|
|
50
|
-
* `listening` resolves with the bound port (useful for port 0) or rejects on a bind error.
|
|
51
|
-
* `host` is the bind address; unset means all interfaces (what containers need).
|
|
52
|
-
*/
|
|
53
|
-
export declare function serveNode(handler: ChannelHandler, options: {
|
|
54
|
-
port: number;
|
|
55
|
-
host?: string;
|
|
56
|
-
}): {
|
|
57
|
-
listening: Promise<number>;
|
|
58
|
-
close: () => Promise<void>;
|
|
59
|
-
closeAllConnections: () => void;
|
|
60
|
-
};
|
package/dist/channel.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The AgentCore serving assembly — the same product as `mountAgentService`, built differently
|
|
3
|
+
* because the host is.
|
|
4
|
+
*
|
|
5
|
+
* Two facts drive every difference. There is no public URL (the adapter's `POST /invocations` is the
|
|
6
|
+
* only ingress, and cron slots arrive through it from an external clock, so no resident timers), and
|
|
7
|
+
* **the state mount at boot is PRE-RESTORE** — empty after every version update. Discovering channels
|
|
8
|
+
* eagerly would therefore cache that emptiness (thread participation, delivery dedup, pending turns)
|
|
9
|
+
* and then clobber the restored files with it, so channels are constructed lazily on the first
|
|
10
|
+
* envelope. Everywhere else the state root is durable at boot and a broken channel fails startup.
|
|
11
|
+
*
|
|
12
|
+
* That is a different assembly, not a flag on the shared one: handler shape, discovery timing, clock
|
|
13
|
+
* source, long-connection support and shutdown all differ. What it is NOT is a different product —
|
|
14
|
+
* it returns the same {@link AgentService}, so `start` picks an assembly once and everything after
|
|
15
|
+
* that point is common.
|
|
16
|
+
*/
|
|
17
|
+
import type { Agent } from "../agent.ts";
|
|
18
|
+
import type { Routes } from "../channel.ts";
|
|
19
|
+
import type { LoadedSchedule } from "../schedule/schedule.ts";
|
|
20
|
+
import { type AgentService, type MountableAgent } from "../service.ts";
|
|
21
|
+
import { type RouteSurface } from "./agentcore.ts";
|
|
22
|
+
export interface MountAgentcoreServiceOptions {
|
|
23
|
+
/** Wrap the opened agent before anything binds to it (the CLI's turn trace). */
|
|
24
|
+
wrapAgent?: (agent: Agent) => Agent;
|
|
25
|
+
/** Runs once the state snapshot is restored. The wake-alarm reconcile passes through here because
|
|
26
|
+
* its sink is a PROCESS-global: the process entry owns that, not a service that can be closed. */
|
|
27
|
+
onStateReady?: () => void;
|
|
28
|
+
control?: {
|
|
29
|
+
tunnel?: boolean;
|
|
30
|
+
host?: string;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/** Is this process running inside the AgentCore Runtime? Set by the generated deploy artifacts. */
|
|
34
|
+
export declare function isAgentcoreRuntime(): boolean;
|
|
35
|
+
export declare function mountAgentcoreService(opened: MountableAgent, options?: MountAgentcoreServiceOptions): Promise<AgentService>;
|
|
36
|
+
/**
|
|
37
|
+
* Mount the AgentCore Runtime adapter (`POST /invocations` + `GET /ping`) over the serving routes —
|
|
38
|
+
* the deployed container's ONLY reachable surface (channels/agentcore.ts). Wired by `start` when
|
|
39
|
+
* `FASTAGENT_AGENTCORE=1` (set by the generated deploy artifacts, never by hand). A channel colliding
|
|
40
|
+
* on either path fails startup, same disposition as the control-plane mount: the adapter's paths are
|
|
41
|
+
* the platform's contract, so a channel shadowing them would silently unserve the whole deployment.
|
|
42
|
+
*/
|
|
43
|
+
export declare function mountAgentcore(routes: Routes, options: {
|
|
44
|
+
agent: Agent;
|
|
45
|
+
stateRoot: string;
|
|
46
|
+
schedules: readonly LoadedSchedule[];
|
|
47
|
+
onStateReady?: () => void;
|
|
48
|
+
/** Cancels the adapter's process-global registrations on close. */
|
|
49
|
+
signal?: AbortSignal;
|
|
50
|
+
/** The serving path's LAZY channel surface: constructed by the adapter on the first envelope
|
|
51
|
+
* AFTER the state-snapshot restore, never at boot (channels/agentcore.ts). When absent,
|
|
52
|
+
* `routes` is the dispatch target — for wirings whose state root is already authoritative. */
|
|
53
|
+
lazyChannels?: () => Promise<RouteSurface>;
|
|
54
|
+
}): Routes;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { log } from "../log.js";
|
|
2
|
+
import { fireScheduleOnce } from "../schedule/scheduler.js";
|
|
3
|
+
import { assertNoControlPlaneCollision, mountSessionControl, routesFor, startSchedules, } from "../service.js";
|
|
4
|
+
import { UnknownScheduleError, agentcoreRoutes } from "./agentcore.js";
|
|
5
|
+
import { createStateSync } from "./agentcore-state.js";
|
|
6
|
+
import { activeWork } from "./busy.js";
|
|
7
|
+
import { routeKeysConflict, router } from "./serve.js";
|
|
8
|
+
/** Is this process running inside the AgentCore Runtime? Set by the generated deploy artifacts. */
|
|
9
|
+
export function isAgentcoreRuntime() {
|
|
10
|
+
return process.env.FASTAGENT_AGENTCORE === "1";
|
|
11
|
+
}
|
|
12
|
+
export async function mountAgentcoreService(opened, options = {}) {
|
|
13
|
+
const { agentDir, workspace, stateRoot, sessionControl } = opened;
|
|
14
|
+
const agent = options.wrapAgent?.(opened.agent) ?? opened.agent;
|
|
15
|
+
// The control plane mounts over an EMPTY route surface: the lazy channels join it later, and the
|
|
16
|
+
// collision rule runs again then (below) against what they actually brought.
|
|
17
|
+
const withControl = mountSessionControl({}, sessionControl, stateRoot, { ...options.control, agent });
|
|
18
|
+
const scheduled = await startSchedules(agentDir, agent, stateRoot, opened.selfSchedule, {
|
|
19
|
+
externalClock: true,
|
|
20
|
+
});
|
|
21
|
+
const lazyChannels = async () => {
|
|
22
|
+
const lazy = await routesFor(agentDir, agent, stateRoot, sessionControl, { builtinInvoke: false });
|
|
23
|
+
if (lazy.longConnections.length > 0) {
|
|
24
|
+
throw new Error(`long-connection channel(s) ${lazy.longConnections.map((c) => c.name).join(", ")} cannot serve on ` +
|
|
25
|
+
`AgentCore (scale-to-zero severs resident connections) — use the channel's webhook form`);
|
|
26
|
+
}
|
|
27
|
+
// The SAME rule mountSessionControl applies, through the same function: its check ran against an
|
|
28
|
+
// empty base at boot, so it has to run again once the channels are real.
|
|
29
|
+
for (const plane of withControl.mounts)
|
|
30
|
+
assertNoControlPlaneCollision(lazy.routes, plane);
|
|
31
|
+
return { routes: lazy.routes, mounts: withControl.mounts };
|
|
32
|
+
};
|
|
33
|
+
// The adapter registers process-global listeners; this is what takes them down on close.
|
|
34
|
+
const closed = new AbortController();
|
|
35
|
+
const adapterRoutes = mountAgentcore({}, {
|
|
36
|
+
signal: closed.signal,
|
|
37
|
+
agent,
|
|
38
|
+
stateRoot,
|
|
39
|
+
schedules: scheduled.schedules,
|
|
40
|
+
onStateReady: options.onStateReady,
|
|
41
|
+
lazyChannels,
|
|
42
|
+
});
|
|
43
|
+
const handler = router(adapterRoutes, withControl.mounts);
|
|
44
|
+
log.info(`[fastagent] agentcore: serving POST /invocations + GET /ping (FASTAGENT_AGENTCORE=1)`);
|
|
45
|
+
let unannounce;
|
|
46
|
+
return {
|
|
47
|
+
handler,
|
|
48
|
+
agent,
|
|
49
|
+
// The adapter IS the surface here; the channel routes arrive lazily BEHIND it. Reporting `{}`
|
|
50
|
+
// would make the startup line claim nothing is served.
|
|
51
|
+
routes: adapterRoutes,
|
|
52
|
+
agentDir,
|
|
53
|
+
workspace,
|
|
54
|
+
// Unknown at boot by design — a channel list here would be the pre-restore emptiness.
|
|
55
|
+
channels: { routes: [], longConnections: [], builtinInvoke: false },
|
|
56
|
+
schedules: scheduled.schedules,
|
|
57
|
+
ready: Promise.resolve(), // nothing to open: no port of our own, no resident connections
|
|
58
|
+
...(withControl.control ? { control: withControl.control } : {}),
|
|
59
|
+
announce(boundPort) {
|
|
60
|
+
unannounce = withControl.announce(boundPort);
|
|
61
|
+
},
|
|
62
|
+
async close() {
|
|
63
|
+
// UNTESTED, deliberately noted: no test observes these timers being cleared. Installing fake
|
|
64
|
+
// timers early enough to count them deadlocks the assembly's own IO. What IS tested is that
|
|
65
|
+
// close() runs and is idempotent; the stop itself rides on scheduler.stop()'s own tests.
|
|
66
|
+
scheduled.stop();
|
|
67
|
+
closed.abort();
|
|
68
|
+
unannounce?.(); // a stale discovery file would point `attach` at a stopped service
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Mount the AgentCore Runtime adapter (`POST /invocations` + `GET /ping`) over the serving routes —
|
|
74
|
+
* the deployed container's ONLY reachable surface (channels/agentcore.ts). Wired by `start` when
|
|
75
|
+
* `FASTAGENT_AGENTCORE=1` (set by the generated deploy artifacts, never by hand). A channel colliding
|
|
76
|
+
* on either path fails startup, same disposition as the control-plane mount: the adapter's paths are
|
|
77
|
+
* the platform's contract, so a channel shadowing them would silently unserve the whole deployment.
|
|
78
|
+
*/
|
|
79
|
+
export function mountAgentcore(routes, options) {
|
|
80
|
+
const { agent, stateRoot, schedules, onStateReady, lazyChannels, signal } = options;
|
|
81
|
+
const mounted = agentcoreRoutes({
|
|
82
|
+
routes: lazyChannels ?? { routes },
|
|
83
|
+
agent,
|
|
84
|
+
stateRoot,
|
|
85
|
+
isBusy: () => activeWork() > 0,
|
|
86
|
+
// Cross-deploy durability: AgentCore wipes the state mount on every runtime version update, so
|
|
87
|
+
// the state root is restored from (and pushed to) an S3 snapshot through presigned URLs the
|
|
88
|
+
// forwarder mints per envelope. Always wired on this path — the platform gives no other way to
|
|
89
|
+
// keep an agent's memory across a deploy.
|
|
90
|
+
stateSync: createStateSync({ stateRoot }),
|
|
91
|
+
// What separates a forwarder envelope from any IAM principal's InvokeAgentRuntime call. Absent =
|
|
92
|
+
// no forwarder in this topology, so only the public `invoke` kind is servable.
|
|
93
|
+
ingressSecret: process.env.FASTAGENT_INGRESS_SECRET,
|
|
94
|
+
onStateReady,
|
|
95
|
+
...(signal ? { signal } : {}),
|
|
96
|
+
fire: schedules.length === 0
|
|
97
|
+
? undefined
|
|
98
|
+
: (name, slot) => {
|
|
99
|
+
const schedule = schedules.find((s) => s.name === name);
|
|
100
|
+
if (!schedule)
|
|
101
|
+
throw new UnknownScheduleError(name);
|
|
102
|
+
return fireScheduleOnce({ agent, stateRoot, schedule, slot });
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
const collisions = Object.keys(routes).filter((key) => Object.keys(mounted).some((adapterKey) => routeKeysConflict(key, adapterKey)));
|
|
106
|
+
if (collisions.length > 0) {
|
|
107
|
+
throw new Error(`channel route(s) ${collisions.map((key) => `"${key}"`).join(", ")} collide with the AgentCore adapter ` +
|
|
108
|
+
`(/invocations, /ping) — rename the channel route`);
|
|
109
|
+
}
|
|
110
|
+
return { ...routes, ...mounted };
|
|
111
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Agent } from "../agent.ts";
|
|
2
2
|
import type { StateSync, StateUrls } from "./agentcore-state.ts";
|
|
3
|
-
import type { Routes } from "../
|
|
3
|
+
import type { Routes } from "../channel.ts";
|
|
4
|
+
import { type PrefixMount } from "../channels/serve.ts";
|
|
4
5
|
import type { ScheduleFireOutcome } from "../schedule/scheduler.ts";
|
|
5
6
|
/**
|
|
6
7
|
* The HOST's webhook body limit, and the one place it is computed. Lambda Function URLs cap a request
|
|
@@ -71,6 +72,12 @@ export interface WebhookReply {
|
|
|
71
72
|
headers: Record<string, string>;
|
|
72
73
|
bodyB64: string;
|
|
73
74
|
}
|
|
75
|
+
/** What the lazy factory hands back: literal routes plus any prefix-owning mounts (the control
|
|
76
|
+
* plane), so the adapter's INNER dispatch is assembled exactly like a direct host's. */
|
|
77
|
+
export interface RouteSurface {
|
|
78
|
+
routes: Routes;
|
|
79
|
+
mounts?: readonly PrefixMount[];
|
|
80
|
+
}
|
|
74
81
|
export interface AgentcoreAdapterOptions {
|
|
75
82
|
/** The serving routes a direct deployment would mount (channels or the builtin invoke + health).
|
|
76
83
|
* The serving path passes a LAZY factory: channel construction loads channel state and replays
|
|
@@ -78,7 +85,7 @@ export interface AgentcoreAdapterOptions {
|
|
|
78
85
|
* which happens at the first envelope's `stateSync.ready()` (the restore URLs only an envelope
|
|
79
86
|
* carries), never at boot, where the mount is pre-restore (empty after every version update).
|
|
80
87
|
* An eager `Routes` value remains supported for wirings whose state root is already durable. */
|
|
81
|
-
routes:
|
|
88
|
+
routes: RouteSurface | (() => Promise<RouteSurface> | RouteSurface);
|
|
82
89
|
agent: Agent;
|
|
83
90
|
/** Where the forwarder URL from envelopes is persisted for the wake-alarm sink (the state root). */
|
|
84
91
|
stateRoot: string;
|
|
@@ -94,6 +101,9 @@ export interface AgentcoreAdapterOptions {
|
|
|
94
101
|
/** FASTAGENT_INGRESS_SECRET: what makes an envelope the FORWARDER's rather than any IAM principal's.
|
|
95
102
|
* Undefined = nothing can be trusted, so only the public `invoke` kind is served. */
|
|
96
103
|
ingressSecret?: string;
|
|
104
|
+
/** Cancels the adapter's process-global registrations. Without it a closed adapter keeps saving
|
|
105
|
+
* state on every later idle edge — including work belonging to whatever mounted after it. */
|
|
106
|
+
signal?: AbortSignal;
|
|
97
107
|
/** Runs ONCE, after the state root is authoritative (post-restore) — the wake-alarm reconcile, which
|
|
98
108
|
* at boot would see the mount the platform just wiped and conclude there is nothing pending. */
|
|
99
109
|
onStateReady?: () => void;
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
import { Buffer } from "node:buffer";
|
|
29
29
|
import { timingSafeEqual } from "node:crypto";
|
|
30
30
|
import { beginWork, onIdle } from "./busy.js";
|
|
31
|
-
import { router } from "../
|
|
31
|
+
import { router } from "../channels/serve.js";
|
|
32
32
|
import { log } from "../log.js";
|
|
33
33
|
import { rememberWakeAlarmUrl } from "../schedule/wake-alarm.js";
|
|
34
34
|
import { readBodyCapped } from "./body.js";
|
|
@@ -72,7 +72,7 @@ export function agentcoreRoutes(options) {
|
|
|
72
72
|
// not escape before `dispatchP` is assigned (which would silently re-run the activation).
|
|
73
73
|
dispatchP = Promise.resolve()
|
|
74
74
|
.then(() => (typeof routes === "function" ? routes() : routes))
|
|
75
|
-
.then(router);
|
|
75
|
+
.then((surface) => router(surface.routes, surface.mounts));
|
|
76
76
|
dispatchP.catch(() => { }); // observed here so the CACHED rejection is never "unhandled"
|
|
77
77
|
}
|
|
78
78
|
return dispatchP;
|
|
@@ -80,8 +80,10 @@ export function agentcoreRoutes(options) {
|
|
|
80
80
|
const invokeHandler = createInvokeHandler(agent);
|
|
81
81
|
// Snapshot on the 0-in-flight edge: webhook channels ACK fast and finish the turn in the
|
|
82
82
|
// background, so "the request returned" is NOT when the state root settles.
|
|
83
|
-
if (stateSync)
|
|
84
|
-
onIdle(() => stateSync.save());
|
|
83
|
+
if (stateSync) {
|
|
84
|
+
const off = onIdle(() => stateSync.save());
|
|
85
|
+
options.signal?.addEventListener("abort", off, { once: true });
|
|
86
|
+
}
|
|
85
87
|
let warnedUnsnapshotted = false;
|
|
86
88
|
let stateReadyFired = false;
|
|
87
89
|
const handleInvocation = async (req) => {
|
package/dist/channels/busy.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* SHARED process-wide in-flight work signal. Channels ACK a webhook fast and run the turn
|
|
3
|
-
* fire-and-forget on this process's event loop (
|
|
3
|
+
* fire-and-forget on this process's event loop (serve.ts) — so "is this process busy?" is not
|
|
4
4
|
* derivable from open HTTP requests. The two shared execution primitives (turn-queue chains,
|
|
5
5
|
* task-tracker side tasks) report here; a serving surface that must stay alive while background
|
|
6
6
|
* work runs (the AgentCore adapter's /ping → HealthyBusy) reads it.
|
package/dist/channels/busy.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* SHARED process-wide in-flight work signal. Channels ACK a webhook fast and run the turn
|
|
3
|
-
* fire-and-forget on this process's event loop (
|
|
3
|
+
* fire-and-forget on this process's event loop (serve.ts) — so "is this process busy?" is not
|
|
4
4
|
* derivable from open HTTP requests. The two shared execution primitives (turn-queue chains,
|
|
5
5
|
* task-tracker side tasks) report here; a serving surface that must stay alive while background
|
|
6
6
|
* work runs (the AgentCore adapter's /ping → HealthyBusy) reads it.
|
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
import { type SessionControl, type SessionEvent } from "../session.ts";
|
|
2
2
|
import type { Agent } from "../agent.ts";
|
|
3
|
-
import type {
|
|
3
|
+
import type { ChannelHandler } from "../channel.ts";
|
|
4
|
+
import { type PrefixMount } from "./serve.ts";
|
|
4
5
|
/** The SSE payload: one control-plane event in its transport envelope. */
|
|
5
6
|
export interface WireEvent {
|
|
6
7
|
sessionId: string;
|
|
7
|
-
/** Serving-process incarnation (per `
|
|
8
|
+
/** Serving-process incarnation (per `createControlPlane` call). A change means the server restarted:
|
|
8
9
|
* live continuity is gone — run the reconnect steps (entries cursor + state). */
|
|
9
10
|
epoch: string;
|
|
10
11
|
/** Per-connection monotonic counter. A gap means events were lost in transit on THIS connection. */
|
|
11
12
|
seq: number;
|
|
12
13
|
event: SessionEvent;
|
|
13
14
|
}
|
|
14
|
-
export interface
|
|
15
|
+
export interface ControlPlaneOptions {
|
|
15
16
|
/** Shared bearer secret, required on every route. Never optional: an unauthenticated
|
|
16
17
|
* remote-control endpoint must not be constructible by omission. */
|
|
17
18
|
token: string;
|
|
@@ -22,7 +23,16 @@ export interface ControlRoutesOptions {
|
|
|
22
23
|
agent?: Agent;
|
|
23
24
|
}
|
|
24
25
|
/**
|
|
25
|
-
*
|
|
26
|
-
* /control/
|
|
26
|
+
* Create the control plane as a mountable prefix owner: `GET
|
|
27
|
+
* /control/capabilities|commands|state|entries|events` + `POST /control/dispatch`, all
|
|
28
|
+
* bearer-authenticated. `events` streams SSE (`data: <WireEvent>` lines).
|
|
29
|
+
* The plane OWNS {@link CONTROL_PREFIX}: it is mounted as one sub-application, answers its own
|
|
30
|
+
* 404/405/preflight, and puts CORS headers on every reply — see {@link planeApp}.
|
|
27
31
|
*/
|
|
28
|
-
export declare function
|
|
32
|
+
export declare function createControlPlane(control: SessionControl, options: ControlPlaneOptions): PrefixMount;
|
|
33
|
+
/** Mount a plane route table as a {@link PrefixMount} — the plane owns a PREFIX, while a route
|
|
34
|
+
* table is a set of literal paths. */
|
|
35
|
+
export declare function mountControlPlane(routes: Record<string, ChannelHandler>): PrefixMount;
|
|
36
|
+
/** The plane's route table. Exported so the conformance sweeps derive their route list from what is
|
|
37
|
+
* actually mounted, rather than from a hand-kept copy that cannot notice a new route. */
|
|
38
|
+
export declare function controlPlaneRoutes(control: SessionControl, options: ControlPlaneOptions): Record<string, ChannelHandler>;
|
package/dist/channels/control.js
CHANGED
|
@@ -1,9 +1,98 @@
|
|
|
1
1
|
import { INVALID_COMMAND_CODE } from "../session.js";
|
|
2
2
|
import { timingSafeEqual } from "node:crypto";
|
|
3
|
+
import { parseRouteKey, withoutBody } from "./serve.js";
|
|
4
|
+
import { log } from "../log.js";
|
|
3
5
|
import { readBodyCapped } from "./body.js";
|
|
4
6
|
import { MAX_BODY_BYTES, createInvokeHandler, sseHeartbeat } from "./http.js";
|
|
5
7
|
import { text } from "./respond.js";
|
|
8
|
+
/** The prefix this plane OWNS: everything under it is the plane's to answer. */
|
|
9
|
+
const CONTROL_PREFIX = "/control";
|
|
6
10
|
const json = (value, status = 200) => new Response(`${JSON.stringify(value)}\n`, { status, headers: { "content-type": "application/json" } });
|
|
11
|
+
/**
|
|
12
|
+
* The plane as one mounted sub-application rather than routes sharing a prefix.
|
|
13
|
+
*
|
|
14
|
+
* CORS belongs to every reply that leaves the plane — including the ones no route produces (an
|
|
15
|
+
* unknown path, an unserved method, a throwing handler). As separate routes those came from the
|
|
16
|
+
* host, outside anything the plane could decorate. Owning the prefix makes them its own answers,
|
|
17
|
+
* headers applied at the single exit they share.
|
|
18
|
+
*
|
|
19
|
+
* `*` is the right origin: authorisation is the bearer token — never the origin, never a cookie —
|
|
20
|
+
* so an origin that cannot present it gets 401 either way, and a deployment cannot know the origins
|
|
21
|
+
* of the GUIs that will manage it (§14's asymmetry).
|
|
22
|
+
*
|
|
23
|
+
* `authorization` is not CORS-safelisted, so EVERY call preflights, including a plain GET.
|
|
24
|
+
* `content-type` is not either (only three values are, and `application/json` is not among them),
|
|
25
|
+
* so a browser POSTing to dispatch/invoke names it — allowing just `authorization` leaves precisely
|
|
26
|
+
* the WRITE routes unreachable while reads work.
|
|
27
|
+
*/
|
|
28
|
+
function planeApp(routes) {
|
|
29
|
+
// Normalised keys (see serve.ts): the method is upper-cased when parsed.
|
|
30
|
+
const byKey = new Map(Object.entries(routes).map(([key, handler]) => {
|
|
31
|
+
const { method, path } = parseRouteKey(key);
|
|
32
|
+
return [method ? `${method} ${path}` : path, handler];
|
|
33
|
+
}));
|
|
34
|
+
const methodsByPath = new Map();
|
|
35
|
+
for (const key of Object.keys(routes)) {
|
|
36
|
+
const { method, path } = parseRouteKey(key);
|
|
37
|
+
const methods = methodsByPath.get(path) ?? new Set();
|
|
38
|
+
if (method)
|
|
39
|
+
methods.add(method);
|
|
40
|
+
methodsByPath.set(path, methods);
|
|
41
|
+
}
|
|
42
|
+
// Per PATH, stating what it actually serves — omitting a method it does serve has the browser
|
|
43
|
+
// refuse a call that would have worked. `HEAD` is that case: every GET route answers it.
|
|
44
|
+
const allowMethods = (path, requested) => {
|
|
45
|
+
const methods = new Set(methodsByPath.get(path) ?? []);
|
|
46
|
+
if (methods.has("GET"))
|
|
47
|
+
methods.add("HEAD");
|
|
48
|
+
// The requested method is always allowed, even where this path does not serve it: preflight is a
|
|
49
|
+
// gate applied BEFORE the request exists, so refusing there means the real request is never sent
|
|
50
|
+
// and the client sees an opaque network error. Allowing it lets the plane's own 404/405 arrive,
|
|
51
|
+
// with these headers and an explanation.
|
|
52
|
+
if (requested)
|
|
53
|
+
methods.add(requested.toUpperCase());
|
|
54
|
+
return [...methods, "OPTIONS"].join(", ");
|
|
55
|
+
};
|
|
56
|
+
return async (req) => {
|
|
57
|
+
const path = new URL(req.url).pathname;
|
|
58
|
+
const known = methodsByPath.has(path);
|
|
59
|
+
const answer = async () => {
|
|
60
|
+
// A preflight carries no token — that is its purpose — so it is answered before auth, and for
|
|
61
|
+
// ANY path under the prefix: gating it would stop the request the 404 below is waiting for.
|
|
62
|
+
if (req.method === "OPTIONS")
|
|
63
|
+
return new Response(null, { status: 204 });
|
|
64
|
+
const handler = byKey.get(`${req.method} ${path}`) ?? byKey.get(path);
|
|
65
|
+
if (handler)
|
|
66
|
+
return await handler(req);
|
|
67
|
+
if (known && req.method === "HEAD") {
|
|
68
|
+
const get = byKey.get(`GET ${path}`);
|
|
69
|
+
if (get)
|
|
70
|
+
return await get(req);
|
|
71
|
+
}
|
|
72
|
+
// 404 vs 405 as in the host router: a client reads 404 as "this serve predates the route".
|
|
73
|
+
if (known)
|
|
74
|
+
return text("method not allowed\n", 405);
|
|
75
|
+
return text("not found\n", 404);
|
|
76
|
+
};
|
|
77
|
+
let res;
|
|
78
|
+
try {
|
|
79
|
+
// HEAD carries no content, whichever branch answered — including this plane's own 404/405.
|
|
80
|
+
const answered = await answer();
|
|
81
|
+
res = req.method === "HEAD" ? withoutBody(answered) : answered;
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
// The plane's own totality boundary: a rejecting handler (`commands()` on an unreadable
|
|
85
|
+
// definition) must still answer with the headers; the message stays internal.
|
|
86
|
+
log.error(`[control] ${req.method} ${path} failed: ${String(error)}`);
|
|
87
|
+
res = text("internal error\n", 500);
|
|
88
|
+
}
|
|
89
|
+
// THE single exit. Every reply above — route, preflight, 404, 405, 500 — leaves through here.
|
|
90
|
+
res.headers.set("access-control-allow-origin", "*");
|
|
91
|
+
res.headers.set("access-control-allow-headers", "authorization, content-type");
|
|
92
|
+
res.headers.set("access-control-allow-methods", allowMethods(path, req.headers.get("access-control-request-method")));
|
|
93
|
+
return res;
|
|
94
|
+
};
|
|
95
|
+
}
|
|
7
96
|
// ONE constant for every Prompt-bearing wire surface (imported from the invoke channel — the two
|
|
8
97
|
// caps cannot drift apart): commands carry Prompts, which may ride base64 images.
|
|
9
98
|
const DISPATCH_BODY_LIMIT = MAX_BODY_BYTES;
|
|
@@ -84,13 +173,26 @@ function parseWireCommand(raw) {
|
|
|
84
173
|
}
|
|
85
174
|
}
|
|
86
175
|
/**
|
|
87
|
-
*
|
|
88
|
-
* /control/
|
|
176
|
+
* Create the control plane as a mountable prefix owner: `GET
|
|
177
|
+
* /control/capabilities|commands|state|entries|events` + `POST /control/dispatch`, all
|
|
178
|
+
* bearer-authenticated. `events` streams SSE (`data: <WireEvent>` lines).
|
|
179
|
+
* The plane OWNS {@link CONTROL_PREFIX}: it is mounted as one sub-application, answers its own
|
|
180
|
+
* 404/405/preflight, and puts CORS headers on every reply — see {@link planeApp}.
|
|
89
181
|
*/
|
|
90
|
-
export function
|
|
182
|
+
export function createControlPlane(control, options) {
|
|
183
|
+
return mountControlPlane(controlPlaneRoutes(control, options));
|
|
184
|
+
}
|
|
185
|
+
/** Mount a plane route table as a {@link PrefixMount} — the plane owns a PREFIX, while a route
|
|
186
|
+
* table is a set of literal paths. */
|
|
187
|
+
export function mountControlPlane(routes) {
|
|
188
|
+
return { prefix: CONTROL_PREFIX, handler: planeApp(routes) };
|
|
189
|
+
}
|
|
190
|
+
/** The plane's route table. Exported so the conformance sweeps derive their route list from what is
|
|
191
|
+
* actually mounted, rather than from a hand-kept copy that cannot notice a new route. */
|
|
192
|
+
export function controlPlaneRoutes(control, options) {
|
|
91
193
|
const { token } = options;
|
|
92
194
|
if (!token)
|
|
93
|
-
throw new Error("
|
|
195
|
+
throw new Error("createControlPlane: a bearer token is required (empty tokens are not a mode)");
|
|
94
196
|
const epoch = crypto.randomUUID();
|
|
95
197
|
// Timing-safe: the bearer token is this surface's ONLY auth (and the --tunnel warning names it
|
|
96
198
|
// as the sole protection on a public URL) — a plain === would leak byte-by-byte via timing.
|