@fastagent-sh/fastagent 0.18.0 → 0.20.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 +23 -0
- package/dist/atomic-write.js +53 -0
- package/dist/{host/node.d.ts → channel.d.ts} +24 -28
- package/dist/channel.js +1 -0
- package/dist/channels/agentcore-limits.d.ts +9 -0
- package/dist/channels/agentcore-limits.js +9 -0
- package/dist/channels/agentcore-service.d.ts +55 -0
- package/dist/channels/agentcore-service.js +107 -0
- package/dist/channels/agentcore.d.ts +22 -20
- package/dist/channels/agentcore.js +20 -11
- package/dist/channels/busy.d.ts +1 -1
- package/dist/channels/busy.js +1 -1
- package/dist/channels/control.d.ts +55 -7
- package/dist/channels/control.js +312 -67
- package/dist/channels/discover.d.ts +59 -0
- package/dist/{engines/pi/channel.js → channels/discover.js} +19 -34
- 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 +6 -9
- package/dist/channels/feishu/feishu.d.ts +1 -1
- package/dist/channels/feishu/feishu.js +41 -24
- 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/kit/attachment-path.d.ts +12 -0
- package/dist/channels/kit/attachment-path.js +43 -0
- package/dist/channels/{context-buffer.d.ts → kit/context-buffer.d.ts} +9 -0
- package/dist/channels/{context-buffer.js → kit/context-buffer.js} +13 -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/kit/signature.d.ts +12 -0
- package/dist/channels/kit/signature.js +17 -0
- 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} +5 -5
- package/dist/channels/{tasks.d.ts → kit/tasks.d.ts} +1 -1
- package/dist/channels/{tasks.js → kit/tasks.js} +11 -5
- 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.d.ts → kit/turn-store.d.ts} +19 -3
- package/dist/channels/{turn-store.js → kit/turn-store.js} +30 -3
- 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 +2 -2
- package/dist/channels/slack/invoke-turn.js +13 -6
- package/dist/channels/slack/onboard.js +5 -5
- package/dist/channels/slack/onboarding-state.d.ts +7 -3
- package/dist/channels/slack/onboarding-state.js +13 -20
- 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/register-webhook.js +1 -1
- package/dist/channels/slack/scaffold/slack-send.ts +2 -17
- package/dist/channels/slack/slack-api.js +23 -55
- package/dist/channels/slack/slack.d.ts +1 -1
- package/dist/channels/slack/slack.js +20 -20
- 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.d.ts +21 -1
- package/dist/channels/telegram/parse.js +67 -13
- package/dist/channels/telegram/preview.d.ts +1 -1
- package/dist/channels/telegram/preview.js +6 -2
- package/dist/channels/telegram/scaffold/channel.ts +7 -3
- package/dist/channels/telegram/telegram-api.js +5 -6
- package/dist/channels/telegram/telegram.d.ts +3 -3
- package/dist/channels/telegram/telegram.js +60 -72
- package/dist/channels/telegram/turn-store.d.ts +2 -2
- package/dist/channels/telegram/turn-store.js +2 -2
- package/dist/cli/add-slack.js +6 -5
- package/dist/cli/commands/add.js +1 -5
- package/dist/cli/commands/attach.d.ts +2 -4
- package/dist/cli/commands/attach.js +17 -15
- package/dist/cli/commands/deploy.d.ts +50 -5
- package/dist/cli/commands/deploy.js +183 -134
- package/dist/cli/commands/dev.js +25 -40
- package/dist/cli/commands/fire.js +1 -1
- package/dist/cli/commands/info.js +7 -5
- package/dist/cli/commands/init.js +1 -2
- package/dist/cli/commands/schedule.js +1 -1
- package/dist/cli/commands/start.js +80 -121
- package/dist/cli/commands/tool.js +5 -7
- package/dist/cli/program.js +1 -1
- package/dist/cli/serve.d.ts +22 -70
- package/dist/cli/serve.js +66 -257
- package/dist/cli/shared.d.ts +40 -10
- package/dist/cli/shared.js +53 -6
- package/dist/core.d.ts +3 -5
- package/dist/core.js +7 -5
- package/dist/deploy/agentcore/logs.d.ts +2 -2
- package/dist/deploy/agentcore/logs.js +2 -2
- package/dist/deploy/agentcore/plan.d.ts +12 -10
- package/dist/deploy/agentcore/plan.js +39 -31
- package/dist/deploy/agentcore/run.d.ts +5 -4
- package/dist/deploy/agentcore/run.js +13 -36
- package/dist/deploy/channel-ingress.d.ts +73 -0
- package/dist/deploy/channel-ingress.js +101 -0
- package/dist/deploy/docker/plan.d.ts +3 -7
- package/dist/deploy/docker/plan.js +4 -15
- package/dist/deploy/docker/run.d.ts +18 -1
- package/dist/deploy/docker/run.js +10 -0
- package/dist/deploy/fly/plan.d.ts +4 -5
- package/dist/deploy/fly/plan.js +7 -22
- package/dist/deploy/fly/run.d.ts +9 -23
- package/dist/deploy/fly/run.js +32 -68
- package/dist/deploy/preflight.d.ts +6 -7
- package/dist/deploy/preflight.js +27 -23
- package/dist/deploy/railway/plan.d.ts +4 -5
- package/dist/deploy/railway/plan.js +11 -26
- package/dist/deploy/railway/run.d.ts +9 -9
- package/dist/deploy/railway/run.js +40 -40
- package/dist/deploy/runner.js +22 -2
- package/dist/deploy/secrets.d.ts +18 -9
- package/dist/deploy/secrets.js +64 -12
- package/dist/dev-supervisor.js +11 -6
- package/dist/engines/pi/agent-session-factory.d.ts +104 -0
- package/dist/engines/pi/agent-session-factory.js +295 -0
- package/dist/engines/pi/auth.js +43 -43
- package/dist/engines/pi/config.d.ts +5 -5
- package/dist/engines/pi/config.js +1 -1
- package/dist/engines/pi/create.d.ts +67 -42
- package/dist/engines/pi/create.js +142 -72
- package/dist/engines/pi/definition.d.ts +42 -8
- package/dist/engines/pi/definition.js +114 -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 +39 -20
- package/dist/engines/pi/models.d.ts +14 -0
- package/dist/engines/pi/models.js +9 -1
- package/dist/engines/pi/open.d.ts +10 -8
- package/dist/engines/pi/open.js +21 -11
- package/dist/engines/pi/report.d.ts +0 -4
- package/dist/engines/pi/report.js +0 -7
- package/dist/engines/pi/search-tools.js +1 -1
- package/dist/engines/pi/service.d.ts +23 -0
- package/dist/engines/pi/service.js +27 -0
- package/dist/engines/pi/session-builder.js +87 -118
- package/dist/engines/pi/session-control.d.ts +28 -14
- package/dist/engines/pi/session-control.js +570 -438
- package/dist/engines/pi/session-inheritance.d.ts +30 -0
- package/dist/engines/pi/session-inheritance.js +261 -0
- package/dist/engines/pi/session-markers.d.ts +48 -0
- package/dist/engines/pi/session-markers.js +59 -0
- package/dist/engines/pi/session-settings.d.ts +15 -3
- package/dist/engines/pi/session-settings.js +22 -1
- package/dist/engines/pi/session-store.d.ts +140 -0
- package/dist/engines/pi/session-store.js +666 -0
- package/dist/engines/pi/tool-context.d.ts +36 -13
- package/dist/engines/pi/tool-context.js +51 -4
- package/dist/engines/pi/tool.d.ts +5 -5
- package/dist/engines/pi/tool.js +3 -3
- 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/env.js +1 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/loader.d.ts +49 -7
- package/dist/loader.js +79 -18
- 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 +26 -0
- package/dist/paths.js +37 -2
- package/dist/pi.d.ts +14 -8
- package/dist/pi.js +6 -5
- package/dist/scaffold/add-channel.js +2 -2
- package/dist/scaffold/init.js +7 -2
- package/dist/scaffold/templates/persona.md +2 -2
- package/dist/scaffold/templates/tools/fetch-url.ts +0 -2
- package/dist/schedule/discover.js +3 -15
- package/dist/schedule/state.js +4 -9
- package/dist/schedule/wake-alarm.d.ts +13 -7
- package/dist/schedule/wake-alarm.js +88 -46
- package/dist/schedule/wakeups.d.ts +2 -2
- package/dist/schedule/wakeups.js +11 -8
- package/dist/service.d.ts +157 -0
- package/dist/service.js +376 -0
- package/dist/session-remote.d.ts +8 -3
- package/dist/session-remote.js +162 -92
- package/dist/session.d.ts +229 -93
- package/dist/session.js +61 -23
- package/dist/telegram.d.ts +1 -1
- package/dist/telegram.js +1 -1
- package/dist/tunnel.d.ts +18 -3
- package/dist/tunnel.js +32 -40
- package/package.json +14 -5
- package/dist/engines/pi/channel.d.ts +0 -37
- 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/{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/{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
|
@@ -1,17 +1,47 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The session control plane over HTTP + SSE — the Phase 3 transport (design §13). Engine-neutral:
|
|
3
|
+
* consumes only the `SessionControl` contract. One transport serves every remote consumer (Web
|
|
4
|
+
* panel, desktop app, `fastagent attach`); the embedded API stays semantic-only and the ENVELOPE
|
|
5
|
+
* lives here: `id` (request correlation — implicit in HTTP), `epoch` (serving-process incarnation
|
|
6
|
+
* — INFORMATIONAL, for consumers correlating across connections; a restart surfaces as its
|
|
7
|
+
* connections dropping, so no one fences on it), `seq` (per-connection monotonic, detects loss in
|
|
8
|
+
* transit).
|
|
9
|
+
*
|
|
10
|
+
* SECURITY: these routes steer and stop runs, rewrite session properties, and delete records — a
|
|
11
|
+
* remote-control surface. The bearer token is REQUIRED (there is no unauthenticated mode) and is the only auth the framework owns; anything
|
|
12
|
+
* beyond a shared secret (principals, per-permission split, audit) is the wrapping host's job
|
|
13
|
+
* (design §14). Locally the serving process generates a per-boot token and writes it to
|
|
14
|
+
* `<stateRoot>/control.json` for local discovery — filesystem permissions guard the token, and the
|
|
15
|
+
* token guards the routes. That premise is shared-filesystem: a deployment breaks it, so there the
|
|
16
|
+
* DEPLOYER supplies the token ({@link CONTROL_TOKEN_ENV}) and both sides know it. How far those routes REACH is the bind address: all interfaces by
|
|
17
|
+
* default (containers require it), so the port is LAN-reachable and the mount warns accordingly —
|
|
18
|
+
* `--bind 127.0.0.1` (or `http.host`) closes exactly that reach, and the warning goes quiet because
|
|
19
|
+
* there is none left to state.
|
|
20
|
+
*/
|
|
2
21
|
import type { Agent } from "../agent.ts";
|
|
3
|
-
import type
|
|
22
|
+
import { type SessionControl, type SessionEvent } from "../session.ts";
|
|
23
|
+
import { type PrefixMount } from "./serve.ts";
|
|
24
|
+
/** A plane handler: the request, plus the session id the path named (`""` where the path has none). */
|
|
25
|
+
type PlaneHandler = (req: Request, session: string) => Response | Promise<Response>;
|
|
26
|
+
/** The plane's route table: `"<METHOD> <path>"` → handler, where at most one path segment is
|
|
27
|
+
* {@link SESSION_SEGMENT}. */
|
|
28
|
+
export type PlaneRoutes = Record<string, PlaneHandler>;
|
|
29
|
+
/** The token, when the DEPLOYER owns it rather than the box (`mountSessionControl` reads it, `deploy`
|
|
30
|
+
* carries it). Declared here with the prefix because both are the plane's public names: the serving
|
|
31
|
+
* side and the deploy side must spell it identically, and a rename that hits only one of them fails
|
|
32
|
+
* silently — the box mints its own and every caller the runbook told gets a 401. */
|
|
33
|
+
export declare const CONTROL_TOKEN_ENV = "FASTAGENT_CONTROL_TOKEN";
|
|
4
34
|
/** The SSE payload: one control-plane event in its transport envelope. */
|
|
5
35
|
export interface WireEvent {
|
|
6
36
|
sessionId: string;
|
|
7
|
-
/** Serving-process incarnation (per `
|
|
37
|
+
/** Serving-process incarnation (per `createControlPlane` call). A change means the server restarted:
|
|
8
38
|
* live continuity is gone — run the reconnect steps (entries cursor + state). */
|
|
9
39
|
epoch: string;
|
|
10
40
|
/** Per-connection monotonic counter. A gap means events were lost in transit on THIS connection. */
|
|
11
41
|
seq: number;
|
|
12
42
|
event: SessionEvent;
|
|
13
43
|
}
|
|
14
|
-
export interface
|
|
44
|
+
export interface ControlPlaneOptions {
|
|
15
45
|
/** Shared bearer secret, required on every route. Never optional: an unauthenticated
|
|
16
46
|
* remote-control endpoint must not be constructible by omission. */
|
|
17
47
|
token: string;
|
|
@@ -22,7 +52,25 @@ export interface ControlRoutesOptions {
|
|
|
22
52
|
agent?: Agent;
|
|
23
53
|
}
|
|
24
54
|
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
55
|
+
* Create the control plane as a mountable prefix owner — a RESTful surface over
|
|
56
|
+
* {@link CONTROL_PREFIX}: sessions are a collection, a session is a resource, its history and event
|
|
57
|
+
* stream are sub-resources, and the things that HAPPEN to a run are posted to `…/actions`.
|
|
58
|
+
* The plane OWNS the prefix: it answers its own 404/405/preflight and puts CORS headers on every
|
|
59
|
+
* reply — see {@link planeApp}.
|
|
60
|
+
*/
|
|
61
|
+
export declare function createControlPlane(control: SessionControl, options: ControlPlaneOptions): PrefixMount;
|
|
62
|
+
/** Mount a plane route table as a {@link PrefixMount} — the plane owns a PREFIX, while a route
|
|
63
|
+
* table is a set of paths, at most one segment of which is a session id. */
|
|
64
|
+
export declare function mountControlPlane(routes: PlaneRoutes): PrefixMount;
|
|
65
|
+
/**
|
|
66
|
+
* The plane's route table. Exported so the conformance sweeps derive their route list from what is
|
|
67
|
+
* actually mounted, rather than from a hand-kept copy that cannot notice a new route.
|
|
68
|
+
*
|
|
69
|
+
* The shape mirrors the contract: a collection, a resource, its sub-resources, and one action
|
|
70
|
+
* endpoint. What a session IS gets `GET`; what a session HAS gets `PATCH` (properties, last-wins);
|
|
71
|
+
* what happens TO a run gets `POST …/actions` (not a property — an event in time). `PUT` on a
|
|
72
|
+
* session id is the fork, and it is a PUT because a fork is idempotent by construction: the id is
|
|
73
|
+
* the caller's, the body says where the history came from, and repeating it changes nothing.
|
|
27
74
|
*/
|
|
28
|
-
export declare function
|
|
75
|
+
export declare function controlPlaneRoutes(control: SessionControl, options: ControlPlaneOptions): PlaneRoutes;
|
|
76
|
+
export {};
|
package/dist/channels/control.js
CHANGED
|
@@ -1,33 +1,158 @@
|
|
|
1
|
-
import { INVALID_COMMAND_CODE } from "../session.js";
|
|
1
|
+
import { INVALID_COMMAND_CODE, SESSIONS_UNAVAILABLE_CODE, UNSUPPORTED_CAPABILITY_CODE, UPDATE_FIELDS, } 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";
|
|
10
|
+
/** The one variable segment in this plane's paths: a percent-encoded session id. Written into route
|
|
11
|
+
* keys so the table reads like the URLs it serves. */
|
|
12
|
+
const SESSION_SEGMENT = "{session}";
|
|
13
|
+
/** The token, when the DEPLOYER owns it rather than the box (`mountSessionControl` reads it, `deploy`
|
|
14
|
+
* carries it). Declared here with the prefix because both are the plane's public names: the serving
|
|
15
|
+
* side and the deploy side must spell it identically, and a rename that hits only one of them fails
|
|
16
|
+
* silently — the box mints its own and every caller the runbook told gets a 401. */
|
|
17
|
+
export const CONTROL_TOKEN_ENV = "FASTAGENT_CONTROL_TOKEN";
|
|
6
18
|
const json = (value, status = 200) => new Response(`${JSON.stringify(value)}\n`, { status, headers: { "content-type": "application/json" } });
|
|
19
|
+
/**
|
|
20
|
+
* The plane as one mounted sub-application rather than routes sharing a prefix.
|
|
21
|
+
*
|
|
22
|
+
* CORS belongs to every reply that leaves the plane — including the ones no route produces (an
|
|
23
|
+
* unknown path, an unserved method, a throwing handler). As separate routes those came from the
|
|
24
|
+
* host, outside anything the plane could decorate. Owning the prefix makes them its own answers,
|
|
25
|
+
* headers applied at the single exit they share.
|
|
26
|
+
*
|
|
27
|
+
* `*` is the right origin: authorisation is the bearer token — never the origin, never a cookie —
|
|
28
|
+
* so an origin that cannot present it gets 401 either way, and a deployment cannot know the origins
|
|
29
|
+
* of the GUIs that will manage it (§14's asymmetry).
|
|
30
|
+
*
|
|
31
|
+
* `authorization` is not CORS-safelisted, so EVERY call preflights, including a plain GET.
|
|
32
|
+
* `content-type` is not either (only three values are, and `application/json` is not among them),
|
|
33
|
+
* so a browser POSTing to dispatch/invoke names it — allowing just `authorization` leaves precisely
|
|
34
|
+
* the WRITE routes unreachable while reads work.
|
|
35
|
+
*/
|
|
36
|
+
function planeApp(routes) {
|
|
37
|
+
/** A route key's path split into segments, with `{session}` marked. Paths are matched SEGMENT BY
|
|
38
|
+
* SEGMENT rather than by regex because a session id is an opaque Caller string: percent-encoded
|
|
39
|
+
* it can contain anything, and `URL.pathname` leaves `%2F` encoded — so splitting on `/` cannot
|
|
40
|
+
* be fooled by an id that contains one. */
|
|
41
|
+
const compiled = Object.entries(routes).map(([key, handler]) => {
|
|
42
|
+
const { method, path } = parseRouteKey(key);
|
|
43
|
+
return { method, path, segments: path.split("/"), handler };
|
|
44
|
+
});
|
|
45
|
+
const match = (path) => {
|
|
46
|
+
const segments = path.split("/");
|
|
47
|
+
const hits = [];
|
|
48
|
+
for (const route of compiled) {
|
|
49
|
+
if (route.segments.length !== segments.length)
|
|
50
|
+
continue;
|
|
51
|
+
let session;
|
|
52
|
+
let ok = true;
|
|
53
|
+
for (const [i, expected] of route.segments.entries()) {
|
|
54
|
+
const actual = segments[i];
|
|
55
|
+
if (expected === SESSION_SEGMENT) {
|
|
56
|
+
// The one place a path segment becomes a Caller id again. An empty segment is not an id —
|
|
57
|
+
// it would address a session no other call can name.
|
|
58
|
+
if (actual === "") {
|
|
59
|
+
ok = false;
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
session = decodeURIComponent(actual);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// `%zz` and friends: not an id any client could have produced, so this path matches
|
|
67
|
+
// nothing and falls through to the plane's own 404. Decoding runs BEFORE the try that
|
|
68
|
+
// guards the handlers, so letting it throw would leave the boundary entirely — no CORS
|
|
69
|
+
// headers, no log line, and a rejected promise for an embedder mounting this handler
|
|
70
|
+
// directly. The query-parameter form this replaced decoded leniently and could not.
|
|
71
|
+
ok = false;
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
else if (expected !== actual) {
|
|
76
|
+
ok = false;
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (ok)
|
|
81
|
+
hits.push({ route, session });
|
|
82
|
+
}
|
|
83
|
+
return hits;
|
|
84
|
+
};
|
|
85
|
+
// Per PATH, stating what it actually serves — omitting a method it does serve has the browser
|
|
86
|
+
// refuse a call that would have worked. `HEAD` is that case: every GET route answers it.
|
|
87
|
+
const allowMethods = (hits, requested) => {
|
|
88
|
+
const methods = new Set(hits.flatMap((h) => (h.route.method ? [h.route.method] : [])));
|
|
89
|
+
if (methods.has("GET"))
|
|
90
|
+
methods.add("HEAD");
|
|
91
|
+
// The requested method is always allowed, even where this path does not serve it: preflight is a
|
|
92
|
+
// gate applied BEFORE the request exists, so refusing there means the real request is never sent
|
|
93
|
+
// and the client sees an opaque network error. Allowing it lets the plane's own 404/405 arrive,
|
|
94
|
+
// with these headers and an explanation.
|
|
95
|
+
if (requested)
|
|
96
|
+
methods.add(requested.toUpperCase());
|
|
97
|
+
return [...methods, "OPTIONS"].join(", ");
|
|
98
|
+
};
|
|
99
|
+
return async (req) => {
|
|
100
|
+
const path = new URL(req.url).pathname;
|
|
101
|
+
const hits = match(path);
|
|
102
|
+
const answer = async () => {
|
|
103
|
+
// A preflight carries no token — that is its purpose — so it is answered before auth, and for
|
|
104
|
+
// ANY path under the prefix: gating it would stop the request the 404 below is waiting for.
|
|
105
|
+
if (req.method === "OPTIONS")
|
|
106
|
+
return new Response(null, { status: 204 });
|
|
107
|
+
const hit = hits.find((h) => h.route.method === req.method) ??
|
|
108
|
+
(req.method === "HEAD" ? hits.find((h) => h.route.method === "GET") : undefined);
|
|
109
|
+
if (hit)
|
|
110
|
+
return await hit.route.handler(req, hit.session ?? "");
|
|
111
|
+
// 404 vs 405 as in the host router: a client reads 404 as "this serve predates the route".
|
|
112
|
+
if (hits.length > 0)
|
|
113
|
+
return text("method not allowed\n", 405);
|
|
114
|
+
return text("not found\n", 404);
|
|
115
|
+
};
|
|
116
|
+
let res;
|
|
117
|
+
try {
|
|
118
|
+
// HEAD carries no content, whichever branch answered — including this plane's own 404/405.
|
|
119
|
+
const answered = await answer();
|
|
120
|
+
res = req.method === "HEAD" ? withoutBody(answered) : answered;
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
// The plane's own totality boundary: a rejecting handler (`commands()` on an unreadable
|
|
124
|
+
// definition) must still answer with the headers; the message stays internal.
|
|
125
|
+
log.error(`[control] ${req.method} ${path} failed: ${String(error)}`);
|
|
126
|
+
res = text("internal error\n", 500);
|
|
127
|
+
}
|
|
128
|
+
// THE single exit. Every reply above — route, preflight, 404, 405, 500 — leaves through here.
|
|
129
|
+
res.headers.set("access-control-allow-origin", "*");
|
|
130
|
+
res.headers.set("access-control-allow-headers", "authorization, content-type");
|
|
131
|
+
res.headers.set("access-control-allow-methods", allowMethods(hits, req.headers.get("access-control-request-method")));
|
|
132
|
+
return res;
|
|
133
|
+
};
|
|
134
|
+
}
|
|
7
135
|
// ONE constant for every Prompt-bearing wire surface (imported from the invoke channel — the two
|
|
8
|
-
// caps cannot drift apart):
|
|
9
|
-
const
|
|
136
|
+
// caps cannot drift apart): actions carry Prompts, which may ride base64 images.
|
|
137
|
+
const ACTION_BODY_LIMIT = MAX_BODY_BYTES;
|
|
10
138
|
/**
|
|
11
139
|
* Parse-don't-validate at the wire: a remote client can send any JSON, and the hub's inner layers
|
|
12
|
-
* trust
|
|
13
|
-
* `run_command_failed`). Returns the typed
|
|
140
|
+
* trust action shapes (a malformed `steer` would surface as an ENGINE failure misclassified as
|
|
141
|
+
* `run_command_failed`). Returns the typed action, or undefined for anything malformed — which
|
|
14
142
|
* answers protocol-level `invalid_command`, same responsibility as the hub's unknown-type default.
|
|
15
143
|
*/
|
|
16
|
-
function
|
|
17
|
-
// COMPILE-TIME drift guard, variant level: this switch hand-mirrors the
|
|
18
|
-
//
|
|
144
|
+
function parseWireAction(raw) {
|
|
145
|
+
// COMPILE-TIME drift guard, variant level: this switch hand-mirrors the SessionAction union, and a
|
|
146
|
+
// new variant added in session.ts would otherwise compile clean while the wire answers it
|
|
19
147
|
// `invalid_command` — silently breaking local/remote isomorphism. A new variant must break THIS
|
|
20
148
|
// line first, forcing the decision of how the wire carries it.
|
|
21
|
-
const
|
|
149
|
+
const _actionDriftGuard = {
|
|
22
150
|
steer: true,
|
|
23
151
|
follow_up: true,
|
|
24
152
|
abort: true,
|
|
25
153
|
compact: true,
|
|
26
|
-
set_model: true,
|
|
27
|
-
set_thinking: true,
|
|
28
|
-
navigate: true,
|
|
29
154
|
};
|
|
30
|
-
void
|
|
155
|
+
void _actionDriftGuard;
|
|
31
156
|
if (typeof raw !== "object" || raw === null)
|
|
32
157
|
return undefined;
|
|
33
158
|
const c = raw;
|
|
@@ -45,8 +170,8 @@ function parseWireCommand(raw) {
|
|
|
45
170
|
// failure this parser exists to prevent (ImageRef shape from src/session.ts's Prompt).
|
|
46
171
|
return images === undefined || (Array.isArray(images) && images.every(imageOk));
|
|
47
172
|
};
|
|
48
|
-
// REBUILD, never pass raw through: "typed
|
|
49
|
-
//
|
|
173
|
+
// REBUILD, never pass raw through: "typed out" must be construction, not assertion — a passed-
|
|
174
|
+
// through object would carry arbitrary extra keys into the engine.
|
|
50
175
|
const rebuildPrompt = (p) => {
|
|
51
176
|
const images = p.images;
|
|
52
177
|
return {
|
|
@@ -73,24 +198,73 @@ function parseWireCommand(raw) {
|
|
|
73
198
|
return c.instructions === undefined || typeof c.instructions === "string"
|
|
74
199
|
? { type: "compact", instructions: c.instructions }
|
|
75
200
|
: undefined;
|
|
76
|
-
case "set_model":
|
|
77
|
-
return typeof c.model === "string" ? { type: "set_model", model: c.model } : undefined;
|
|
78
|
-
case "set_thinking":
|
|
79
|
-
return typeof c.level === "string" ? { type: "set_thinking", level: c.level } : undefined;
|
|
80
|
-
case "navigate":
|
|
81
|
-
return typeof c.targetId === "string" ? { type: "navigate", targetId: c.targetId } : undefined;
|
|
82
201
|
default:
|
|
83
202
|
return undefined;
|
|
84
203
|
}
|
|
85
204
|
}
|
|
86
205
|
/**
|
|
87
|
-
*
|
|
88
|
-
*
|
|
206
|
+
* Parse a PATCH body into a session update — or into the REASON it is not one, because the two
|
|
207
|
+
* reasons are different answers to the client. An unknown KEY means this serve does not have that
|
|
208
|
+
* field (a newer client talking to an older serve, or a typo), and the client's move is to drop it:
|
|
209
|
+
* `unsupported_capability`, the same code the in-process path answers, so the two planes stay
|
|
210
|
+
* isomorphic. A wrong VALUE TYPE is a malformed payload: `invalid_command`. Neither is dropped
|
|
211
|
+
* silently, which would answer `ok: true` for a patch that set nothing.
|
|
89
212
|
*/
|
|
90
|
-
|
|
213
|
+
function parseWireUpdate(raw) {
|
|
214
|
+
if (typeof raw !== "object" || raw === null) {
|
|
215
|
+
return { code: INVALID_COMMAND_CODE, message: "expected an object of session properties" };
|
|
216
|
+
}
|
|
217
|
+
const c = raw;
|
|
218
|
+
const unknown = Object.keys(c).filter((key) => !UPDATE_FIELDS.includes(key));
|
|
219
|
+
if (unknown.length > 0) {
|
|
220
|
+
return {
|
|
221
|
+
code: UNSUPPORTED_CAPABILITY_CODE,
|
|
222
|
+
message: `update field(s) ${unknown.join(", ")} — capabilities().updatable lists what this serve sets`,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
const patch = {};
|
|
226
|
+
// The field list is the CONTRACT's (`UPDATE_FIELDS`), not a copy: a field added to SessionUpdate
|
|
227
|
+
// travels here without anyone remembering to, and one removed cannot linger.
|
|
228
|
+
for (const field of UPDATE_FIELDS) {
|
|
229
|
+
const value = c[field];
|
|
230
|
+
if (value === undefined)
|
|
231
|
+
continue;
|
|
232
|
+
if (typeof value !== "string") {
|
|
233
|
+
return { code: INVALID_COMMAND_CODE, message: `${field} must be a string` };
|
|
234
|
+
}
|
|
235
|
+
patch[field] = value;
|
|
236
|
+
}
|
|
237
|
+
return { patch };
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Create the control plane as a mountable prefix owner — a RESTful surface over
|
|
241
|
+
* {@link CONTROL_PREFIX}: sessions are a collection, a session is a resource, its history and event
|
|
242
|
+
* stream are sub-resources, and the things that HAPPEN to a run are posted to `…/actions`.
|
|
243
|
+
* The plane OWNS the prefix: it answers its own 404/405/preflight and puts CORS headers on every
|
|
244
|
+
* reply — see {@link planeApp}.
|
|
245
|
+
*/
|
|
246
|
+
export function createControlPlane(control, options) {
|
|
247
|
+
return mountControlPlane(controlPlaneRoutes(control, options));
|
|
248
|
+
}
|
|
249
|
+
/** Mount a plane route table as a {@link PrefixMount} — the plane owns a PREFIX, while a route
|
|
250
|
+
* table is a set of paths, at most one segment of which is a session id. */
|
|
251
|
+
export function mountControlPlane(routes) {
|
|
252
|
+
return { prefix: CONTROL_PREFIX, handler: planeApp(routes) };
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* The plane's route table. Exported so the conformance sweeps derive their route list from what is
|
|
256
|
+
* actually mounted, rather than from a hand-kept copy that cannot notice a new route.
|
|
257
|
+
*
|
|
258
|
+
* The shape mirrors the contract: a collection, a resource, its sub-resources, and one action
|
|
259
|
+
* endpoint. What a session IS gets `GET`; what a session HAS gets `PATCH` (properties, last-wins);
|
|
260
|
+
* what happens TO a run gets `POST …/actions` (not a property — an event in time). `PUT` on a
|
|
261
|
+
* session id is the fork, and it is a PUT because a fork is idempotent by construction: the id is
|
|
262
|
+
* the caller's, the body says where the history came from, and repeating it changes nothing.
|
|
263
|
+
*/
|
|
264
|
+
export function controlPlaneRoutes(control, options) {
|
|
91
265
|
const { token } = options;
|
|
92
266
|
if (!token)
|
|
93
|
-
throw new Error("
|
|
267
|
+
throw new Error("createControlPlane: a bearer token is required (empty tokens are not a mode)");
|
|
94
268
|
const epoch = crypto.randomUUID();
|
|
95
269
|
// Timing-safe: the bearer token is this surface's ONLY auth (and the --tunnel warning names it
|
|
96
270
|
// as the sole protection on a public URL) — a plain === would leak byte-by-byte via timing.
|
|
@@ -100,66 +274,137 @@ export function controlRoutes(control, options) {
|
|
|
100
274
|
return header.length === expected.length && timingSafeEqual(header, expected);
|
|
101
275
|
};
|
|
102
276
|
const invokeHandler = options.agent ? createInvokeHandler(options.agent) : undefined;
|
|
103
|
-
/**
|
|
104
|
-
|
|
277
|
+
/** Authenticate, then hand the handler the pieces every route wants: the request, the URL (for
|
|
278
|
+
* query parameters), and the session the PATH named — `""` on the routes that have no id in
|
|
279
|
+
* them, which those handlers never read. */
|
|
280
|
+
const guard = (handler) => (req, session) => {
|
|
105
281
|
if (!authed(req))
|
|
106
282
|
return text("unauthorized\n", 401);
|
|
107
|
-
return handler(req, new URL(req.url));
|
|
283
|
+
return handler(req, new URL(req.url), session);
|
|
284
|
+
};
|
|
285
|
+
/** Read a JSON body under the shared cap. Answers the Response to send on failure, so a route can
|
|
286
|
+
* `if ("error" in read) return read.error`. */
|
|
287
|
+
const readJson = async (req) => {
|
|
288
|
+
const body = await readBodyCapped(req, ACTION_BODY_LIMIT);
|
|
289
|
+
// The 413 names the ceiling: the docs promise images on this plane, and an unexplained
|
|
290
|
+
// rejection would send a client author hunting everywhere but the cap. Derived from the
|
|
291
|
+
// constant — a hardcoded "1 MiB" would lie the day the cap changes.
|
|
292
|
+
if ("tooLarge" in body) {
|
|
293
|
+
return {
|
|
294
|
+
error: text(`body too large (limit ${MAX_BODY_BYTES >> 20} MiB — images count base64-inflated)\n`, 413),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
// An empty body is an empty object: `POST …/actions` always carries one, but `PATCH` with
|
|
298
|
+
// nothing to set is a legal no-op and a client should not have to send `{}` to say so.
|
|
299
|
+
if (body.text.trim() === "")
|
|
300
|
+
return { value: {} };
|
|
301
|
+
try {
|
|
302
|
+
return { value: JSON.parse(body.text) };
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
return { error: text("invalid JSON\n", 400) };
|
|
306
|
+
}
|
|
108
307
|
};
|
|
109
|
-
// Extraction only — each route still answers its own 400 (the name must not imply enforcement).
|
|
110
|
-
const sessionParam = (url) => url.searchParams.get("session") ?? undefined;
|
|
111
308
|
return {
|
|
309
|
+
// The DATA plane, at the prefix rather than under a session: its body already carries the scope
|
|
310
|
+
// (SPEC `invoke(scope, prompt)`), so a session in the path would be a second place to say it —
|
|
311
|
+
// and two places to say one thing is a place for them to disagree.
|
|
112
312
|
...(invokeHandler ? { "POST /control/invoke": guard((req) => invokeHandler(req)) } : {}),
|
|
113
313
|
"GET /control/capabilities": guard(() => json(control.capabilities())),
|
|
114
314
|
"GET /control/commands": guard(async () => json(await control.commands())),
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
"GET /control/entries": guard(async (_req, url) => {
|
|
122
|
-
const session = sessionParam(url);
|
|
123
|
-
if (!session)
|
|
124
|
-
return text("missing ?session\n", 400);
|
|
125
|
-
const since = url.searchParams.get("since") ?? undefined;
|
|
126
|
-
return json(await control.entries(session, since !== undefined ? { since } : undefined));
|
|
127
|
-
}),
|
|
128
|
-
"POST /control/dispatch": guard(async (req) => {
|
|
129
|
-
const body = await readBodyCapped(req, DISPATCH_BODY_LIMIT);
|
|
130
|
-
// The 413 names the ceiling: the docs promise images on this plane, and an unexplained
|
|
131
|
-
// rejection would send a client author hunting everywhere but the cap.
|
|
132
|
-
if ("tooLarge" in body) {
|
|
133
|
-
// Derived from the constant — a hardcoded "1 MiB" would lie the day the cap changes.
|
|
134
|
-
return text(`body too large (limit ${MAX_BODY_BYTES >> 20} MiB — images count base64-inflated)\n`, 413);
|
|
135
|
-
}
|
|
136
|
-
let parsed;
|
|
315
|
+
// The DEPLOYMENT's conversation list — and the one read that may fail: `[]` is what an empty
|
|
316
|
+
// deployment answers, so a store that cannot be enumerated gets a coded non-2xx instead. 503 +
|
|
317
|
+
// the code, because the alternative (#309's lesson) is a client that can only classify a bare
|
|
318
|
+
// 500 as "the endpoint is unreachable" and burns its reconnect budget on a condition
|
|
319
|
+
// reconnecting cannot fix.
|
|
320
|
+
"GET /control/sessions": guard(async () => {
|
|
137
321
|
try {
|
|
138
|
-
|
|
322
|
+
return json(await control.sessions.list());
|
|
139
323
|
}
|
|
140
|
-
catch {
|
|
141
|
-
|
|
324
|
+
catch (error) {
|
|
325
|
+
// ONLY a store fault becomes the retryable code. A TypeError from our own row building is a
|
|
326
|
+
// bug, and answering `retryable: true` for it has a client poll forever on something no
|
|
327
|
+
// retry can fix — so it goes back to the plane's totality boundary, which logs it and
|
|
328
|
+
// answers 500. An IO error carries an ERRNO `code` (EACCES, ENOTDIR); that is the shape of a
|
|
329
|
+
// condition the operator can act on.
|
|
330
|
+
//
|
|
331
|
+
// The errno SHAPE, not merely "has a string code": Node's own argument-validation errors are
|
|
332
|
+
// TypeErrors carrying `ERR_INVALID_ARG_TYPE`/`ERR_OUT_OF_RANGE`, so a bug in row building
|
|
333
|
+
// would otherwise be handed to a client as retryable — the exact case this excludes. Read
|
|
334
|
+
// through `?.` because a thrown null must reach the boundary as itself, not as a TypeError
|
|
335
|
+
// from this line that replaces it in the log.
|
|
336
|
+
const code = error?.code;
|
|
337
|
+
if (typeof code !== "string" || !/^E[A-Z]+$/.test(code))
|
|
338
|
+
throw error;
|
|
339
|
+
// Logged as well as answered: the catch would otherwise be the one place a store fault is
|
|
340
|
+
// invisible on the server, since it preempts the boundary that does the logging.
|
|
341
|
+
log.error(`[control] GET /control/sessions failed: ${String(error)}`);
|
|
342
|
+
return json({ code: SESSIONS_UNAVAILABLE_CODE, message: String(error), retryable: true }, 503);
|
|
142
343
|
}
|
|
143
|
-
|
|
144
|
-
|
|
344
|
+
}),
|
|
345
|
+
// PUT, because a fork is idempotent: this id, holding the history that was at `from`@`at`.
|
|
346
|
+
// Repeating it answers ok and writes nothing; naming an id that holds a different history is a
|
|
347
|
+
// conflict, not an overwrite.
|
|
348
|
+
[`PUT /control/sessions/${SESSION_SEGMENT}`]: guard(async (req, _url, session) => {
|
|
349
|
+
const read = await readJson(req);
|
|
350
|
+
if ("error" in read)
|
|
351
|
+
return read.error;
|
|
352
|
+
// `JSON.parse("null")` is null, and a body is whatever the client sent: reaching into it
|
|
353
|
+
// unguarded turns a malformed request into a 500 the client cannot act on.
|
|
354
|
+
const body = read.value;
|
|
355
|
+
if (typeof body?.from !== "string" || typeof body.at !== "string") {
|
|
356
|
+
return text("expected { from: string, at: string }\n", 400);
|
|
357
|
+
}
|
|
358
|
+
return json(await control.sessions.fork({ from: body.from, at: body.at, into: session }));
|
|
359
|
+
}),
|
|
360
|
+
[`GET /control/sessions/${SESSION_SEGMENT}`]: guard(async (_req, _url, session) => json(await control.sessions.get(session).state())),
|
|
361
|
+
// PATCH, because these are session PROPERTIES: last-wins, durable, applied by the next turn.
|
|
362
|
+
[`PATCH /control/sessions/${SESSION_SEGMENT}`]: guard(async (req, _url, session) => {
|
|
363
|
+
const read = await readJson(req);
|
|
364
|
+
if ("error" in read)
|
|
365
|
+
return read.error;
|
|
366
|
+
const parsed = parseWireUpdate(read.value);
|
|
367
|
+
// A protocol-level answer carrying the SAME code the hub would have used — the wire must not
|
|
368
|
+
// be where a client loses the difference between "drop that field" and "fix that value".
|
|
369
|
+
if (!("patch" in parsed)) {
|
|
370
|
+
return json({ ok: false, error: { ...parsed, retryable: false } });
|
|
145
371
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
372
|
+
return json(await control.sessions.get(session).update(parsed.patch));
|
|
373
|
+
}),
|
|
374
|
+
[`DELETE /control/sessions/${SESSION_SEGMENT}`]: guard(async (_req, _url, session) => json(await control.sessions.get(session).delete())),
|
|
375
|
+
[`GET /control/sessions/${SESSION_SEGMENT}/entries`]: guard(async (_req, url, session) => {
|
|
376
|
+
const since = url.searchParams.get("since") ?? undefined;
|
|
377
|
+
return json(await control.sessions.get(session).entries(since !== undefined ? { since } : undefined));
|
|
378
|
+
}),
|
|
379
|
+
// The run actions. Not PATCH: none of them SETS anything — they join, queue, stop, or summarize,
|
|
380
|
+
// and the outcome arrives on the event stream rather than in the resource's next read.
|
|
381
|
+
[`POST /control/sessions/${SESSION_SEGMENT}/actions`]: guard(async (req, _url, session) => {
|
|
382
|
+
const read = await readJson(req);
|
|
383
|
+
if ("error" in read)
|
|
384
|
+
return read.error;
|
|
385
|
+
const action = parseWireAction(read.value);
|
|
386
|
+
if (!action) {
|
|
149
387
|
return json({
|
|
150
388
|
ok: false,
|
|
151
|
-
error: { code: INVALID_COMMAND_CODE, message: "malformed
|
|
389
|
+
error: { code: INVALID_COMMAND_CODE, message: "malformed action", retryable: false },
|
|
152
390
|
});
|
|
153
391
|
}
|
|
392
|
+
const s = control.sessions.get(session);
|
|
154
393
|
// The result rides HTTP 200 either way: `ok: false` is a protocol-level answer (rejected
|
|
155
394
|
// before acceptance), not a transport failure.
|
|
156
|
-
|
|
395
|
+
switch (action.type) {
|
|
396
|
+
case "steer":
|
|
397
|
+
return json(await s.steer(action.prompt));
|
|
398
|
+
case "follow_up":
|
|
399
|
+
return json(await s.followUp(action.prompt));
|
|
400
|
+
case "abort":
|
|
401
|
+
return json(await s.abort());
|
|
402
|
+
case "compact":
|
|
403
|
+
return json(await s.compact(action.instructions !== undefined ? { instructions: action.instructions } : {}));
|
|
404
|
+
}
|
|
157
405
|
}),
|
|
158
|
-
|
|
159
|
-
const
|
|
160
|
-
if (!session)
|
|
161
|
-
return text("missing ?session\n", 400);
|
|
162
|
-
const iterator = control.events(session)[Symbol.asyncIterator]();
|
|
406
|
+
[`GET /control/sessions/${SESSION_SEGMENT}/events`]: guard((_req, _url, session) => {
|
|
407
|
+
const iterator = control.sessions.get(session).events()[Symbol.asyncIterator]();
|
|
163
408
|
// EAGER registration: issue the first pull NOW, before the Response (and thus the client's
|
|
164
409
|
// fetch resolution) exists — hub subscription is registered synchronously inside next(), so
|
|
165
410
|
// "the client saw response headers" implies "events from that moment on will be delivered".
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { ChannelContext, LongConnection, Routes } from "../channel.ts";
|
|
2
|
+
import { type ModuleLoadFailure } from "../loader.ts";
|
|
3
|
+
/** A dropped route: two channels claim the same key. Surfaced, never silent. */
|
|
4
|
+
export interface ChannelCollision {
|
|
5
|
+
route: string;
|
|
6
|
+
source: string;
|
|
7
|
+
}
|
|
8
|
+
/** A long-connection module bound to the same context route factories receive. Internal serving shape. */
|
|
9
|
+
export interface LoadedLongConnectionChannel {
|
|
10
|
+
name: string;
|
|
11
|
+
connect(signal: AbortSignal): LongConnection;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* HOW A CHANNEL IS REACHED — the authored structural fact, and the ONE shape it travels in.
|
|
15
|
+
*
|
|
16
|
+
* A webhook channel is reached at a URL someone must set; a long-connection channel dials out, so
|
|
17
|
+
* there is no URL and setting one breaks it (Telegram answers `getUpdates` with 409 once a webhook
|
|
18
|
+
* exists). Everything downstream — which secrets to carry, what the runbook says, what `--run` and
|
|
19
|
+
* `--tunnel` register — is a question about THIS.
|
|
20
|
+
*
|
|
21
|
+
* It is one list because it used to be three (`channels` + `routeChannels` + `longConnectionChannels`,
|
|
22
|
+
* two of them including custom channels and one not), and every consumer re-derived the answer from
|
|
23
|
+
* whichever pair it happened to hold. Two deploys shipped a webhook for a long-connection channel
|
|
24
|
+
* that way. A list of pairs cannot be recombined wrongly, and a consumer that needs a subset asks for
|
|
25
|
+
* it here rather than trusting its caller to have filtered.
|
|
26
|
+
*/
|
|
27
|
+
export type ChannelIngress = "webhook" | "long-connection";
|
|
28
|
+
/** One channel a directory declares, with the ingress its module shape says it has. `name` is the
|
|
29
|
+
* basename, which is a {@link ChannelKind} for the first-party ones and anything for a custom one. */
|
|
30
|
+
export interface DeclaredChannel {
|
|
31
|
+
name: string;
|
|
32
|
+
ingress: ChannelIngress;
|
|
33
|
+
}
|
|
34
|
+
/** Declared channels from basenames that share one ingress: the serving surface's mounted route list
|
|
35
|
+
* (a long-connection channel mounts no HTTP route, so every route IS a webhook channel), and fixtures. */
|
|
36
|
+
export declare function declaredChannels(names: readonly string[], ingress?: ChannelIngress): DeclaredChannel[];
|
|
37
|
+
/**
|
|
38
|
+
* Import channel files without mounting route factories or opening connections. Deployment needs only
|
|
39
|
+
* the authored structural fact: function exports are webhook channels; `{ connect() }` exports are
|
|
40
|
+
* long-connection channels. There is no second ingress/lifecycle declaration to keep in sync.
|
|
41
|
+
*/
|
|
42
|
+
export declare function inspectChannels(dir: string): Promise<{
|
|
43
|
+
channels: DeclaredChannel[];
|
|
44
|
+
failures: ModuleLoadFailure[];
|
|
45
|
+
}>;
|
|
46
|
+
/**
|
|
47
|
+
* Channel file basenames under `<dir>/channels/` — the authoring view (`fastagent info`), which lists
|
|
48
|
+
* WITHOUT importing. A symlinked channels directory must remain inside the agent dir.
|
|
49
|
+
*/
|
|
50
|
+
export declare function discoverChannelFiles(dir: string): Promise<string[]>;
|
|
51
|
+
/** Discover, validate, and bind all channel modules. No long connection is opened here; the CLI owns it. */
|
|
52
|
+
export declare function loadChannels(dir: string, ctx: ChannelContext): Promise<{
|
|
53
|
+
routes: Routes;
|
|
54
|
+
longConnections: LoadedLongConnectionChannel[];
|
|
55
|
+
routeChannels: string[];
|
|
56
|
+
longConnectionChannels: string[];
|
|
57
|
+
collisions: ChannelCollision[];
|
|
58
|
+
failures: ModuleLoadFailure[];
|
|
59
|
+
}>;
|