@fastagent-sh/fastagent 0.19.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/dist/atomic-write.d.ts +11 -7
- package/dist/atomic-write.js +16 -11
- package/dist/channels/agentcore-limits.d.ts +9 -0
- package/dist/channels/agentcore-limits.js +9 -0
- package/dist/channels/agentcore-service.d.ts +12 -11
- package/dist/channels/agentcore-service.js +14 -18
- package/dist/channels/agentcore.d.ts +11 -19
- package/dist/channels/agentcore.js +14 -7
- package/dist/channels/control.d.ts +50 -12
- package/dist/channels/control.js +239 -96
- package/dist/channels/discover.d.ts +26 -4
- package/dist/channels/discover.js +12 -23
- package/dist/channels/feishu/feishu-api.js +5 -8
- package/dist/channels/feishu/feishu.js +35 -18
- package/dist/channels/kit/attachment-path.d.ts +12 -0
- package/dist/channels/kit/attachment-path.js +43 -0
- package/dist/channels/kit/context-buffer.d.ts +9 -0
- package/dist/channels/kit/context-buffer.js +11 -0
- package/dist/channels/kit/signature.d.ts +12 -0
- package/dist/channels/kit/signature.js +17 -0
- package/dist/channels/kit/stop-command.js +3 -3
- package/dist/channels/kit/tasks.d.ts +1 -1
- package/dist/channels/kit/tasks.js +10 -4
- package/dist/channels/kit/turn-store.d.ts +19 -3
- package/dist/channels/kit/turn-store.js +29 -2
- package/dist/channels/slack/invoke-turn.d.ts +1 -1
- package/dist/channels/slack/invoke-turn.js +5 -1
- 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/register-webhook.js +1 -1
- package/dist/channels/slack/scaffold/slack-send.ts +2 -17
- package/dist/channels/slack/slack-api.js +22 -54
- package/dist/channels/slack/slack.js +13 -13
- package/dist/channels/telegram/parse.d.ts +21 -1
- package/dist/channels/telegram/parse.js +65 -11
- 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 +2 -2
- package/dist/channels/telegram/telegram.js +57 -69
- 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 +4 -31
- package/dist/cli/commands/info.js +1 -2
- package/dist/cli/commands/init.js +1 -2
- package/dist/cli/commands/start.js +31 -41
- package/dist/cli/serve.d.ts +12 -21
- package/dist/cli/serve.js +20 -25
- package/dist/cli/shared.d.ts +40 -10
- package/dist/cli/shared.js +51 -4
- 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 +26 -22
- 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 +3 -4
- package/dist/engines/pi/agent-session-factory.js +20 -39
- package/dist/engines/pi/auth.js +43 -43
- package/dist/engines/pi/config.d.ts +1 -1
- package/dist/engines/pi/create.d.ts +14 -2
- package/dist/engines/pi/create.js +2 -2
- package/dist/engines/pi/definition.d.ts +1 -1
- package/dist/engines/pi/definition.js +3 -4
- package/dist/engines/pi/login.js +32 -16
- package/dist/engines/pi/open.d.ts +1 -2
- package/dist/engines/pi/open.js +1 -1
- package/dist/engines/pi/search-tools.js +1 -1
- package/dist/engines/pi/service.d.ts +1 -1
- package/dist/engines/pi/service.js +8 -0
- package/dist/engines/pi/session-builder.js +6 -33
- package/dist/engines/pi/session-control.d.ts +19 -6
- package/dist/engines/pi/session-control.js +565 -470
- package/dist/engines/pi/session-inheritance.d.ts +8 -22
- package/dist/engines/pi/session-inheritance.js +95 -76
- 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 +4 -4
- package/dist/engines/pi/session-settings.js +7 -4
- package/dist/engines/pi/session-store.d.ts +83 -13
- package/dist/engines/pi/session-store.js +408 -55
- package/dist/engines/pi/tool-context.d.ts +21 -8
- package/dist/engines/pi/tool-context.js +29 -4
- package/dist/engines/pi/tool.js +2 -2
- package/dist/env.js +1 -2
- package/dist/loader.d.ts +49 -7
- package/dist/loader.js +79 -18
- package/dist/paths.d.ts +19 -0
- package/dist/paths.js +27 -2
- package/dist/scaffold/add-channel.js +2 -2
- package/dist/scaffold/init.js +7 -2
- package/dist/scaffold/templates/tools/fetch-url.ts +0 -2
- package/dist/schedule/discover.js +3 -15
- package/dist/schedule/wake-alarm.d.ts +13 -7
- package/dist/schedule/wake-alarm.js +88 -46
- package/dist/schedule/wakeups.d.ts +1 -1
- package/dist/schedule/wakeups.js +10 -7
- package/dist/service.d.ts +1 -2
- package/dist/service.js +27 -5
- package/dist/session-remote.d.ts +7 -2
- package/dist/session-remote.js +162 -92
- package/dist/session.d.ts +226 -92
- 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 +1 -1
package/dist/service.js
CHANGED
|
@@ -18,14 +18,13 @@ import { mkdirSync, rmSync } from "node:fs";
|
|
|
18
18
|
import { writeFileAtomic } from "./atomic-write.js";
|
|
19
19
|
import { join } from "node:path";
|
|
20
20
|
import { classifyBind, clientHost } from "./bind.js";
|
|
21
|
-
import { createControlPlane } from "./channels/control.js";
|
|
21
|
+
import { CONTROL_TOKEN_ENV, createControlPlane } from "./channels/control.js";
|
|
22
22
|
import { createInvokeHandler } from "./channels/http.js";
|
|
23
23
|
import { text } from "./channels/respond.js";
|
|
24
|
-
import { parseRouteKey, pathUnderPrefix } from "./channels/serve.js";
|
|
24
|
+
import { parseRouteKey, pathUnderPrefix, router } from "./channels/serve.js";
|
|
25
25
|
import { loadChannels } from "./channels/discover.js";
|
|
26
26
|
import { loadSchedules } from "./schedule/discover.js";
|
|
27
27
|
import { createScheduler } from "./schedule/scheduler.js";
|
|
28
|
-
import { router } from "./channels/serve.js";
|
|
29
28
|
import { log, reportModuleLoadFailures } from "./log.js";
|
|
30
29
|
/** Default wait for a channel's `closed` before reporting it stuck. A channel that ignores its
|
|
31
30
|
* abort signal must not hang a caller's teardown — or, during a failed start, keep the original
|
|
@@ -117,7 +116,30 @@ export function assertNoControlPlaneCollision(channelRoutes, plane) {
|
|
|
117
116
|
export function mountSessionControl(routes, control, stateRoot, options = {}) {
|
|
118
117
|
if (!control)
|
|
119
118
|
return { routes, mounts: [], announce: () => () => { } };
|
|
120
|
-
|
|
119
|
+
// WHO OWNS the secret. Per-boot mint is right locally: discovery is `control.json` and its file
|
|
120
|
+
// permissions, which works because both holders share a filesystem. A deployment removes that
|
|
121
|
+
// premise — a token minted in the container is replaced every restart and reachable only by shelling
|
|
122
|
+
// in — so there the deployer mints it and injects it here, like the wake/ingress secrets.
|
|
123
|
+
// Trimmed on read, like `.env` values already are: a token pasted from a dashboard with a trailing
|
|
124
|
+
// newline would otherwise become the box's token verbatim, and every caller holding the clean value
|
|
125
|
+
// gets a bare 401 — the undiagnosable symptom, one character wide.
|
|
126
|
+
const injected = process.env[CONTROL_TOKEN_ENV]?.trim();
|
|
127
|
+
// SET BUT EMPTY is the deployed default, not an edge case: the generated Compose topology writes
|
|
128
|
+
// every secret as `NAME: "${NAME:-}"`, so an operator who skipped this one lands here. Falling back
|
|
129
|
+
// silently would leave exactly the symptom the injection exists to remove — a token the caller does
|
|
130
|
+
// not have — with nothing in the log to tell it apart from a deployment that never asked.
|
|
131
|
+
if (injected === "") {
|
|
132
|
+
log.warn(`[fastagent] ${CONTROL_TOKEN_ENV} is set but empty — minting a per-boot token instead; callers holding ` +
|
|
133
|
+
"the deploy-time value will get 401 (set it, or read the minted one from control.json on the box)");
|
|
134
|
+
}
|
|
135
|
+
else if (injected !== undefined && injected.length < 16) {
|
|
136
|
+
// Length is a crude proxy for entropy — sixteen `a`s pass. It is aimed at `changeme`, which this
|
|
137
|
+
// change makes newly dangerous: the plane went from unusable-in-a-deployment to usable by whoever
|
|
138
|
+
// holds this string, and the empty case is the only other thing that says anything.
|
|
139
|
+
log.warn(`[fastagent] ${CONTROL_TOKEN_ENV} is ${injected.length} characters — it is the ONLY thing between ` +
|
|
140
|
+
"/control/* (steer, stop, rewrite a session) and anyone who can reach the port; use a random value (uuidgen)");
|
|
141
|
+
}
|
|
142
|
+
const token = injected || crypto.randomUUID();
|
|
121
143
|
const plane = createControlPlane(control, { token, agent: options.agent });
|
|
122
144
|
assertNoControlPlaneCollision(routes, plane);
|
|
123
145
|
return {
|
|
@@ -144,7 +166,7 @@ export function mountSessionControl(routes, control, stateRoot, options = {}) {
|
|
|
144
166
|
}
|
|
145
167
|
if (options.tunnel) {
|
|
146
168
|
// Local trust = the token + its file permissions; --tunnel takes the whole port PUBLIC.
|
|
147
|
-
log.warn("[fastagent] --tunnel exposes /control/* (steer
|
|
169
|
+
log.warn("[fastagent] --tunnel exposes /control/* (steer, stop, rewrite or delete a session) at the public tunnel URL, " +
|
|
148
170
|
"protected ONLY by the bearer token — wrap it with real auth before sharing that URL (docs: design §14)");
|
|
149
171
|
}
|
|
150
172
|
// Removed on shutdown so a stale file cannot point a client at a dead port: `attach` then
|
package/dist/session-remote.d.ts
CHANGED
|
@@ -15,13 +15,18 @@
|
|
|
15
15
|
* visible as a thrown iteration error, a failed request as a rejected promise.
|
|
16
16
|
*/
|
|
17
17
|
import type { Agent } from "./agent.ts";
|
|
18
|
-
import type
|
|
18
|
+
import { type SessionControl } from "./session.ts";
|
|
19
19
|
/** A control request the server answered with a non-2xx status. Carries the STRUCTURED status so a
|
|
20
20
|
* consumer distinguishing auth failure (401 — stale token, unrecoverable) from transient transport
|
|
21
21
|
* trouble branches on `status`, never on message prose. */
|
|
22
22
|
export declare class ControlRequestError extends Error {
|
|
23
23
|
readonly status: number;
|
|
24
|
-
|
|
24
|
+
/** The plane's own error code, when the reply carried one (`sessions()` is the only read that
|
|
25
|
+
* does today — design §13). Absent for a plain-text rejection (401) or a proxy's page: a caller
|
|
26
|
+
* distinguishing "this deployment cannot list sessions" from "the endpoint is unreachable" reads
|
|
27
|
+
* THIS, not the status. */
|
|
28
|
+
readonly code?: string;
|
|
29
|
+
constructor(status: number, body: string, code?: string);
|
|
25
30
|
}
|
|
26
31
|
/** Connection parameters shared by BOTH remote planes (`connectSessionControl` and
|
|
27
32
|
* `connectAgent`) — plane-neutral on purpose: one endpoint, one token, two contracts. */
|
package/dist/session-remote.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { SSE_HEARTBEAT_MS } from "./channels/http.js";
|
|
2
2
|
import { abortFirstIterator } from "./collect.js";
|
|
3
|
+
import { isAddressableSession, } from "./session.js";
|
|
3
4
|
/** Dead-connection watchdog for SSE reads: the server heartbeats every SSE_HEARTBEAT_MS, so a
|
|
4
5
|
* PENDING READ seeing no bytes (of ANY kind — comments included) for this many missed beats
|
|
5
6
|
* means the connection is a black hole. The stream is aborted and surfaced as an error, so a
|
|
@@ -31,11 +32,34 @@ function idleWatchdog(abort) {
|
|
|
31
32
|
* trouble branches on `status`, never on message prose. */
|
|
32
33
|
export class ControlRequestError extends Error {
|
|
33
34
|
status;
|
|
34
|
-
|
|
35
|
+
/** The plane's own error code, when the reply carried one (`sessions()` is the only read that
|
|
36
|
+
* does today — design §13). Absent for a plain-text rejection (401) or a proxy's page: a caller
|
|
37
|
+
* distinguishing "this deployment cannot list sessions" from "the endpoint is unreachable" reads
|
|
38
|
+
* THIS, not the status. */
|
|
39
|
+
code;
|
|
40
|
+
constructor(status, body, code) {
|
|
35
41
|
super(`control request failed: ${status} ${body}`);
|
|
36
42
|
this.status = status;
|
|
43
|
+
if (code !== undefined)
|
|
44
|
+
this.code = code;
|
|
37
45
|
}
|
|
38
46
|
}
|
|
47
|
+
/** A non-2xx reply as an error, carrying the plane's code when the reply declared one. */
|
|
48
|
+
async function controlError(res) {
|
|
49
|
+
const body = await res.text();
|
|
50
|
+
if (!res.headers.get("content-type")?.includes("application/json"))
|
|
51
|
+
return new ControlRequestError(res.status, body);
|
|
52
|
+
let parsed;
|
|
53
|
+
try {
|
|
54
|
+
parsed = JSON.parse(body);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// The reply declared JSON and is not — a protocol fault worth seeing, but not worth losing the
|
|
58
|
+
// status over: both travel in one error rather than a bare SyntaxError from a rejection path.
|
|
59
|
+
return new ControlRequestError(res.status, `${body} (declared application/json but did not parse)`);
|
|
60
|
+
}
|
|
61
|
+
return new ControlRequestError(res.status, body, typeof parsed.code === "string" ? parsed.code : undefined);
|
|
62
|
+
}
|
|
39
63
|
/**
|
|
40
64
|
* Connect and return a remote `SessionControl`. Async because `capabilities()` is synchronous in
|
|
41
65
|
* the contract: the static declaration is fetched ONCE here and served from memory — which also
|
|
@@ -59,10 +83,104 @@ export async function connectSessionControl(options) {
|
|
|
59
83
|
const get = async (path, timeoutMs = REQUEST_TIMEOUT_MS) => {
|
|
60
84
|
const res = await fetchFn(`${base}${path}`, { headers, signal: AbortSignal.timeout(timeoutMs) });
|
|
61
85
|
if (!res.ok)
|
|
62
|
-
throw
|
|
86
|
+
throw await controlError(res);
|
|
63
87
|
return (await res.json());
|
|
64
88
|
};
|
|
65
89
|
const capabilities = await get("/control/capabilities");
|
|
90
|
+
const eventsOf = (session) => {
|
|
91
|
+
// Each ITERATION opens its own connection (gen/abort created inside asyncIterator), matching
|
|
92
|
+
// the local hub's "every iteration is a fresh subscription" — a shared single-use generator
|
|
93
|
+
// would make the second for-await silently empty, breaking local/remote isomorphism.
|
|
94
|
+
// The abort controller lives OUTSIDE the generator: a consumer's `return()`/`break` while the
|
|
95
|
+
// generator is suspended on a quiet SSE read must abort the fetch FIRST — an async generator's
|
|
96
|
+
// own finally only runs after the pending await settles, which a silent stream never does.
|
|
97
|
+
const openStream = (abort) => (async function* iterate() {
|
|
98
|
+
// Armed BEFORE the fetch: the connect phase (headers never arriving from a black-holed
|
|
99
|
+
// endpoint) is otherwise a window no timeout covers — the same watchdog terminates it,
|
|
100
|
+
// with headers-arrival counting as the first sign of life.
|
|
101
|
+
const watchdog = idleWatchdog(abort);
|
|
102
|
+
watchdog.arm(); // the connect await is a pending read
|
|
103
|
+
try {
|
|
104
|
+
const res = await fetchFn(`${base}/control/sessions/${encodeURIComponent(session)}/events`, {
|
|
105
|
+
headers,
|
|
106
|
+
signal: abort.signal,
|
|
107
|
+
});
|
|
108
|
+
watchdog.disarm(); // headers arrived
|
|
109
|
+
if (!res.ok) {
|
|
110
|
+
// The error body is a pending read too — a half-dead tunnel serving 4xx headers then
|
|
111
|
+
// black-holing the body must not hang the round outside every budget. Re-armed: the
|
|
112
|
+
// watchdog aborts the read and the round fails with the dead-connection diagnosis.
|
|
113
|
+
watchdog.arm();
|
|
114
|
+
throw new ControlRequestError(res.status, await res.text());
|
|
115
|
+
}
|
|
116
|
+
if (!res.body)
|
|
117
|
+
throw new Error("control events: response has no body");
|
|
118
|
+
let nextSeq = 0;
|
|
119
|
+
for await (const data of sseData(res.body, watchdog)) {
|
|
120
|
+
// Parse discipline, same as the other two wire planes (dispatch parses, invoke
|
|
121
|
+
// classifies drift): a non-JSON or non-envelope payload is PROTOCOL MISMATCH —
|
|
122
|
+
// thrown, so a consumer's failure budget applies — never misdiagnosed as an
|
|
123
|
+
// in-transit gap whose remedy (reconnect) can never fix it.
|
|
124
|
+
let wire;
|
|
125
|
+
try {
|
|
126
|
+
// The ONE envelope type (control.ts's WireEvent) — an inline shape would let the
|
|
127
|
+
// envelope drift server-side while this cast silently kept the old fields.
|
|
128
|
+
wire = JSON.parse(data);
|
|
129
|
+
}
|
|
130
|
+
catch (parseError) {
|
|
131
|
+
throw new Error(`control events: non-JSON data on the stream (${String(parseError)}) — protocol mismatch?`);
|
|
132
|
+
}
|
|
133
|
+
if (typeof wire.seq !== "number" || typeof wire.event !== "object" || wire.event === null) {
|
|
134
|
+
throw new Error("control events: malformed envelope — the endpoint does not speak this protocol version");
|
|
135
|
+
}
|
|
136
|
+
// Envelope checks — consumed HERE. (epoch is not compared: it cannot change within
|
|
137
|
+
// one connection — see the header note.) A gap THROWS like a protocol mismatch: the
|
|
138
|
+
// consumer's failure path (budget, its own io) owns the diagnostic — a library-level
|
|
139
|
+
// log would bypass consumer output discipline, and a silent clean end would be
|
|
140
|
+
// indistinguishable from the server closing normally.
|
|
141
|
+
if (wire.seq !== nextSeq) {
|
|
142
|
+
throw new Error(`control events: sequence gap (expected ${nextSeq}, got ${wire.seq}) — events were lost in transit; resync via entries()`);
|
|
143
|
+
}
|
|
144
|
+
nextSeq = wire.seq + 1;
|
|
145
|
+
yield wire.event;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
if (abort.signal.aborted) {
|
|
150
|
+
if (watchdog.stale()) {
|
|
151
|
+
throw new Error(`control events: no bytes for ${SSE_IDLE_LIMIT_MS / 1000}s (heartbeats absent) — dead connection; resync via entries()`);
|
|
152
|
+
}
|
|
153
|
+
return; // the consumer walked away — clean end, not an error
|
|
154
|
+
}
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
157
|
+
finally {
|
|
158
|
+
watchdog.stop();
|
|
159
|
+
}
|
|
160
|
+
})();
|
|
161
|
+
return {
|
|
162
|
+
[Symbol.asyncIterator]() {
|
|
163
|
+
const abort = new AbortController();
|
|
164
|
+
// Abort-first cancellation (see abortFirstIterator): aborting the connection unblocks a
|
|
165
|
+
// generator suspended on a quiet stream read.
|
|
166
|
+
return abortFirstIterator(openStream(abort), () => abort.abort());
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
};
|
|
170
|
+
/** A write that answers a `SessionResult`: the result rides HTTP 200 either way (`ok: false` is a
|
|
171
|
+
* protocol answer, not a transport failure), so a non-2xx here is a REAL transport/auth fault. */
|
|
172
|
+
const write = async (path, method, body) => {
|
|
173
|
+
const res = await fetchFn(`${base}${path}`, {
|
|
174
|
+
method,
|
|
175
|
+
headers: body === undefined ? headers : { ...headers, "content-type": "application/json" },
|
|
176
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
177
|
+
signal: AbortSignal.timeout(PAYLOAD_TIMEOUT_MS),
|
|
178
|
+
});
|
|
179
|
+
if (!res.ok)
|
|
180
|
+
throw await controlError(res);
|
|
181
|
+
return (await res.json());
|
|
182
|
+
};
|
|
183
|
+
const id = (session) => encodeURIComponent(session);
|
|
66
184
|
return {
|
|
67
185
|
capabilities: () => capabilities,
|
|
68
186
|
// NOT prefetched like capabilities: a live definition can grow a skill between calls, so the
|
|
@@ -82,98 +200,50 @@ export async function connectSessionControl(options) {
|
|
|
82
200
|
throw error;
|
|
83
201
|
}
|
|
84
202
|
},
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
// Each ITERATION opens its own connection (gen/abort created inside asyncIterator), matching
|
|
100
|
-
// the local hub's "every iteration is a fresh subscription" — a shared single-use generator
|
|
101
|
-
// would make the second for-await silently empty, breaking local/remote isomorphism.
|
|
102
|
-
// The abort controller lives OUTSIDE the generator: a consumer's `return()`/`break` while the
|
|
103
|
-
// generator is suspended on a quiet SSE read must abort the fetch FIRST — an async generator's
|
|
104
|
-
// own finally only runs after the pending await settles, which a silent stream never does.
|
|
105
|
-
const openStream = (abort) => (async function* iterate() {
|
|
106
|
-
// Armed BEFORE the fetch: the connect phase (headers never arriving from a black-holed
|
|
107
|
-
// endpoint) is otherwise a window no timeout covers — the same watchdog terminates it,
|
|
108
|
-
// with headers-arrival counting as the first sign of life.
|
|
109
|
-
const watchdog = idleWatchdog(abort);
|
|
110
|
-
watchdog.arm(); // the connect await is a pending read
|
|
111
|
-
try {
|
|
112
|
-
const res = await fetchFn(`${base}/control/events?session=${encodeURIComponent(session)}`, {
|
|
113
|
-
headers,
|
|
114
|
-
signal: abort.signal,
|
|
115
|
-
});
|
|
116
|
-
watchdog.disarm(); // headers arrived
|
|
117
|
-
if (!res.ok) {
|
|
118
|
-
// The error body is a pending read too — a half-dead tunnel serving 4xx headers then
|
|
119
|
-
// black-holing the body must not hang the round outside every budget. Re-armed: the
|
|
120
|
-
// watchdog aborts the read and the round fails with the dead-connection diagnosis.
|
|
121
|
-
watchdog.arm();
|
|
122
|
-
throw new ControlRequestError(res.status, await res.text());
|
|
123
|
-
}
|
|
124
|
-
if (!res.body)
|
|
125
|
-
throw new Error("control events: response has no body");
|
|
126
|
-
let nextSeq = 0;
|
|
127
|
-
for await (const data of sseData(res.body, watchdog)) {
|
|
128
|
-
// Parse discipline, same as the other two wire planes (dispatch parses, invoke
|
|
129
|
-
// classifies drift): a non-JSON or non-envelope payload is PROTOCOL MISMATCH —
|
|
130
|
-
// thrown, so a consumer's failure budget applies — never misdiagnosed as an
|
|
131
|
-
// in-transit gap whose remedy (reconnect) can never fix it.
|
|
132
|
-
let wire;
|
|
133
|
-
try {
|
|
134
|
-
// The ONE envelope type (control.ts's WireEvent) — an inline shape would let the
|
|
135
|
-
// envelope drift server-side while this cast silently kept the old fields.
|
|
136
|
-
wire = JSON.parse(data);
|
|
137
|
-
}
|
|
138
|
-
catch (parseError) {
|
|
139
|
-
throw new Error(`control events: non-JSON data on the stream (${String(parseError)}) — protocol mismatch?`);
|
|
140
|
-
}
|
|
141
|
-
if (typeof wire.seq !== "number" || typeof wire.event !== "object" || wire.event === null) {
|
|
142
|
-
throw new Error("control events: malformed envelope — the endpoint does not speak this protocol version");
|
|
143
|
-
}
|
|
144
|
-
// Envelope checks — consumed HERE. (epoch is not compared: it cannot change within
|
|
145
|
-
// one connection — see the header note.) A gap THROWS like a protocol mismatch: the
|
|
146
|
-
// consumer's failure path (budget, its own io) owns the diagnostic — a library-level
|
|
147
|
-
// log would bypass consumer output discipline, and a silent clean end would be
|
|
148
|
-
// indistinguishable from the server closing normally.
|
|
149
|
-
if (wire.seq !== nextSeq) {
|
|
150
|
-
throw new Error(`control events: sequence gap (expected ${nextSeq}, got ${wire.seq}) — events were lost in transit; resync via entries()`);
|
|
151
|
-
}
|
|
152
|
-
nextSeq = wire.seq + 1;
|
|
153
|
-
yield wire.event;
|
|
154
|
-
}
|
|
203
|
+
sessions: {
|
|
204
|
+
// Rejects when the deployment cannot enumerate its store — the coded 503 arrives as a
|
|
205
|
+
// ControlRequestError carrying `sessions_unavailable`, so a client can tell it from an
|
|
206
|
+
// unreachable endpoint instead of retrying forever.
|
|
207
|
+
list: () => get("/control/sessions", PAYLOAD_TIMEOUT_MS),
|
|
208
|
+
// PUT: the fork is idempotent, and so is the request that carries it. `into` becomes a path
|
|
209
|
+
// segment exactly like `get`'s id, so it is refused on the same rule — without this the local
|
|
210
|
+
// plane answers `invalid_command` while the wire answers 404 from a URL that normalised away.
|
|
211
|
+
// ASYNC, so the guard REJECTS rather than throwing out of a method typed `Promise`: a caller
|
|
212
|
+
// that wrote `.catch(…)` — or handed this to `Promise.all` — must not be surprised by a
|
|
213
|
+
// synchronous throw. (`get` may throw: it is synchronous by signature.)
|
|
214
|
+
fork: async ({ from, at, into }) => {
|
|
215
|
+
if (!isAddressableSession(into)) {
|
|
216
|
+
throw new Error(`session id ${JSON.stringify(into)} cannot travel as a URL path segment — this transport cannot address it`);
|
|
155
217
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
218
|
+
return write(`/control/sessions/${id(into)}`, "PUT", { from, at });
|
|
219
|
+
},
|
|
220
|
+
// The local hub's handle is a pure binding; so is this one — an id and the transport above it.
|
|
221
|
+
// Nothing is FETCHED here, which is what keeps the two isomorphic. What is checked is the one
|
|
222
|
+
// thing the wire cannot express: `.` and `..` survive `encodeURIComponent` and are then
|
|
223
|
+
// normalised away by URL parsing, so every call on such a handle would arrive at a DIFFERENT
|
|
224
|
+
// route — `.` reads as the collection (200 JSON, which the SSE reader ends as a silently empty
|
|
225
|
+
// stream) and `..` as a 404 the local plane answers normally. Refused at the binding, where a
|
|
226
|
+
// caller can see it, rather than once per call in a place it looks like a server answer.
|
|
227
|
+
get: (session) => {
|
|
228
|
+
if (!isAddressableSession(session)) {
|
|
229
|
+
throw new Error(`session id ${JSON.stringify(session)} cannot travel as a URL path segment — this transport cannot address it`);
|
|
167
230
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
231
|
+
return {
|
|
232
|
+
id: session,
|
|
233
|
+
state: () => get(`/control/sessions/${id(session)}`),
|
|
234
|
+
entries: (options) => get(`/control/sessions/${id(session)}/entries${options?.since !== undefined ? `?since=${encodeURIComponent(options.since)}` : ""}`, PAYLOAD_TIMEOUT_MS),
|
|
235
|
+
events: () => eventsOf(session),
|
|
236
|
+
update: (patch) => write(`/control/sessions/${id(session)}`, "PATCH", patch),
|
|
237
|
+
steer: (prompt) => write(`/control/sessions/${id(session)}/actions`, "POST", { type: "steer", prompt }),
|
|
238
|
+
followUp: (prompt) => write(`/control/sessions/${id(session)}/actions`, "POST", { type: "follow_up", prompt }),
|
|
239
|
+
abort: () => write(`/control/sessions/${id(session)}/actions`, "POST", { type: "abort" }),
|
|
240
|
+
compact: (options) => write(`/control/sessions/${id(session)}/actions`, "POST", {
|
|
241
|
+
type: "compact",
|
|
242
|
+
...(options?.instructions !== undefined ? { instructions: options.instructions } : {}),
|
|
243
|
+
}),
|
|
244
|
+
delete: () => write(`/control/sessions/${id(session)}`, "DELETE"),
|
|
245
|
+
};
|
|
246
|
+
},
|
|
177
247
|
},
|
|
178
248
|
};
|
|
179
249
|
}
|