@sleep2agi/commhub-server 0.9.0-preview.3 → 0.9.0-preview.30
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 +125 -7
- package/bin/commhub.ts +10 -2
- package/package.json +5 -5
- package/src/ack-create-request-transport.test.ts +159 -0
- package/src/ack-create-request.test.ts +331 -0
- package/src/admin-networks-http.test.ts +84 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +32 -0
- package/src/api-host-supervisors-fallback.test.ts +267 -0
- package/src/api-nodes-shape.test.ts +188 -0
- package/src/auth.ts +93 -14
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/config-apply-sec1.test.ts +107 -0
- package/src/config-apply-validate.test.ts +92 -0
- package/src/config-apply-validate.ts +81 -3
- package/src/create-node.ts +48 -1
- package/src/dashboard-slash-routing-http.test.ts +103 -0
- package/src/db-adapter-guard.test.ts +85 -0
- package/src/db-adapter.ts +64 -23
- package/src/db.ts +522 -32
- package/src/ext-token-shared.test.ts +60 -0
- package/src/ext-validation-regression.test.ts +131 -0
- package/src/external-schedule-edits-http.test.ts +316 -0
- package/src/external-schedule-edits.ts +314 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +403 -0
- package/src/file-download-date-bucket.test.ts +436 -0
- package/src/file-network-scope-dev-open.test.ts +97 -0
- package/src/file-network-scope.test.ts +731 -0
- package/src/health-redaction.test.ts +637 -0
- package/src/index.ts +15 -2172
- package/src/lifecycle-guard.ts +50 -0
- package/src/list-host-supervisors.test.ts +314 -0
- package/src/mcp-write-network-resolution.test.ts +429 -0
- package/src/network-name-http.test.ts +71 -0
- package/src/network-scope.ts +101 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +213 -0
- package/src/observer-avatar-http.test.ts +349 -0
- package/src/observer-push.test.ts +176 -0
- package/src/peer-reply-atomic.test.ts +455 -0
- package/src/peer-reply-capability.test.ts +70 -0
- package/src/probe-validate.test.ts +221 -0
- package/src/probe-validate.ts +104 -0
- package/src/probe.test.ts +80 -0
- package/src/probe.ts +200 -0
- package/src/push.ts +96 -7
- package/src/require-explicit-test-db.ts +9 -0
- package/src/resolve-port.test.ts +57 -0
- package/src/resolve-port.ts +44 -0
- package/src/response-charset.test.ts +130 -0
- package/src/rest-explicit-columns-http.test.ts +211 -0
- package/src/rest-identity.test.ts +129 -0
- package/src/rest-identity.ts +100 -0
- package/src/rest-projections.ts +67 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/runtime-label.test.ts +22 -0
- package/src/runtime-label.ts +21 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-tasks-http.test.ts +479 -0
- package/src/scheduled-tasks.ts +549 -0
- package/src/send-reply-agent-warning.test.ts +202 -0
- package/src/send-reply-attachments.test.ts +437 -0
- package/src/server.ts +3492 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
- package/src/shared/probe-host-allowlist.ts +128 -0
- package/src/side-thread-command-transport.test.ts +140 -0
- package/src/side-thread-command-transport.ts +309 -0
- package/src/side-thread-http-integration.test.ts +229 -0
- package/src/side-thread-http.ts +504 -0
- package/src/side-thread.test.ts +1038 -0
- package/src/side-thread.ts +2241 -0
- package/src/skillhub-http.test.ts +88 -0
- package/src/stop-delete-node.test.ts +720 -0
- package/src/task-auth-origin-http.test.ts +87 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +398 -0
- package/src/task-diagnostic-http.test.ts +145 -0
- package/src/task-diagnostic.test.ts +115 -0
- package/src/task-diagnostic.ts +118 -0
- package/src/task-idempotency-mcp.test.ts +142 -0
- package/src/task-idempotency.test.ts +60 -0
- package/src/task-idempotency.ts +75 -0
- package/src/task-lifecycle-watcher.test.ts +200 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +137 -0
- package/src/tasks-pagination-http.test.ts +137 -0
- package/src/tools.ts +2302 -169
- package/src/update-provider.test.ts +234 -0
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +92 -14
- package/src/uploads.test.ts +66 -0
- package/src/uploads.ts +130 -5
- package/src/vault.test.ts +200 -0
- package/src/vault.ts +156 -0
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SIDE_THREAD_API_ACTIONS,
|
|
3
|
+
SIDE_THREAD_API_ROOT,
|
|
4
|
+
SideThreadCoordinator,
|
|
5
|
+
SideThreadError,
|
|
6
|
+
type SideThreadActor,
|
|
7
|
+
type SideThreadRecord,
|
|
8
|
+
} from "./side-thread.js";
|
|
9
|
+
|
|
10
|
+
export interface SideThreadHttpContext {
|
|
11
|
+
req: Request;
|
|
12
|
+
url: URL;
|
|
13
|
+
actor: SideThreadActor | null;
|
|
14
|
+
coordinator: SideThreadCoordinator;
|
|
15
|
+
resolveCapabilityTarget?: (input: {
|
|
16
|
+
actor: SideThreadActor;
|
|
17
|
+
alias?: string;
|
|
18
|
+
nodeId?: string;
|
|
19
|
+
networkId?: string;
|
|
20
|
+
}) => { nodeId: string; networkId: string } | undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function handleSideThreadHttpRequest(
|
|
24
|
+
ctx: SideThreadHttpContext,
|
|
25
|
+
): Promise<Response | null> {
|
|
26
|
+
const root = ctx.url.pathname === SIDE_THREAD_API_ROOT;
|
|
27
|
+
const capabilityPath =
|
|
28
|
+
ctx.url.pathname === `${SIDE_THREAD_API_ROOT}/capability`;
|
|
29
|
+
const match = ctx.url.pathname.match(
|
|
30
|
+
new RegExp(
|
|
31
|
+
`^${SIDE_THREAD_API_ROOT}/([^/]+)(?:/(${SIDE_THREAD_API_ACTIONS.join("|")}))?$`,
|
|
32
|
+
),
|
|
33
|
+
);
|
|
34
|
+
if (!root && !capabilityPath && !match) return null;
|
|
35
|
+
if (!ctx.coordinator.isEnabled())
|
|
36
|
+
return json({ ok: false, error: "SIDE_THREAD_DISABLED" }, 404);
|
|
37
|
+
if (!ctx.actor)
|
|
38
|
+
return json({ ok: false, error: "SIDE_THREAD_AUTH_REQUIRED" }, 401);
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
if (capabilityPath) {
|
|
42
|
+
if (ctx.req.method !== "GET") return methodNotAllowed("GET");
|
|
43
|
+
const target = ctx.resolveCapabilityTarget?.({
|
|
44
|
+
actor: ctx.actor,
|
|
45
|
+
alias: query(ctx.url, "alias"),
|
|
46
|
+
nodeId: query(ctx.url, "nodeId", "node_id"),
|
|
47
|
+
networkId: query(ctx.url, "networkId", "network_id"),
|
|
48
|
+
});
|
|
49
|
+
if (!target)
|
|
50
|
+
throw new SideThreadError(
|
|
51
|
+
"SIDE_THREAD_NOT_FOUND",
|
|
52
|
+
"node not found",
|
|
53
|
+
404,
|
|
54
|
+
);
|
|
55
|
+
const sourceThreadId = requiredQueryIdentity(ctx.url, "sourceThreadId");
|
|
56
|
+
const boundaryKind = query(ctx.url, "boundaryKind");
|
|
57
|
+
const boundaryTurnId = requiredQueryIdentity(ctx.url, "boundaryTurnId");
|
|
58
|
+
if (boundaryKind !== "through" && boundaryKind !== "before")
|
|
59
|
+
throw new SideThreadError(
|
|
60
|
+
"SIDE_THREAD_INVALID_CONTEXT",
|
|
61
|
+
"boundaryKind must be through or before",
|
|
62
|
+
400,
|
|
63
|
+
);
|
|
64
|
+
const boundary = { kind: boundaryKind, turnId: boundaryTurnId } as const;
|
|
65
|
+
const capability = await ctx.coordinator.capability(
|
|
66
|
+
ctx.actor,
|
|
67
|
+
target.networkId,
|
|
68
|
+
target.nodeId,
|
|
69
|
+
boundary,
|
|
70
|
+
);
|
|
71
|
+
return json({
|
|
72
|
+
ok: true,
|
|
73
|
+
capability: serializeCapability(capability, {
|
|
74
|
+
networkId: target.networkId,
|
|
75
|
+
nodeId: target.nodeId,
|
|
76
|
+
sourceThreadId,
|
|
77
|
+
boundary,
|
|
78
|
+
}),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
if (root && ctx.req.method === "GET") {
|
|
82
|
+
const rawLimit = ctx.url.searchParams.get("limit");
|
|
83
|
+
const limit = rawLimit === null ? undefined : parseLimit(rawLimit);
|
|
84
|
+
if (rawLimit !== null && limit === null)
|
|
85
|
+
throw new SideThreadError(
|
|
86
|
+
"SIDE_THREAD_INVALID_LIMIT",
|
|
87
|
+
"invalid list limit",
|
|
88
|
+
400,
|
|
89
|
+
);
|
|
90
|
+
const records = ctx.coordinator.list(ctx.actor, {
|
|
91
|
+
networkId: query(ctx.url, "networkId", "network_id"),
|
|
92
|
+
nodeId: query(ctx.url, "nodeId", "node_id"),
|
|
93
|
+
limit: limit ?? undefined,
|
|
94
|
+
});
|
|
95
|
+
return json({
|
|
96
|
+
ok: true,
|
|
97
|
+
sideThreads: records.map(serialize),
|
|
98
|
+
count: records.length,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
if (root && ctx.req.method === "POST") {
|
|
102
|
+
const body = await requestJson(ctx.req);
|
|
103
|
+
const record = await ctx.coordinator.create(ctx.actor, {
|
|
104
|
+
requestKey: field(body, "requestKey", "request_key"),
|
|
105
|
+
networkId: field(body, "networkId", "network_id"),
|
|
106
|
+
nodeId: field(body, "nodeId", "node_id"),
|
|
107
|
+
sourceThreadId: field(body, "sourceThreadId", "source_thread_id"),
|
|
108
|
+
boundary: {
|
|
109
|
+
kind: body.boundary?.kind,
|
|
110
|
+
turnId: field(body.boundary, "turnId", "turn_id"),
|
|
111
|
+
},
|
|
112
|
+
prompt: field(body, "question", "prompt"),
|
|
113
|
+
attachments: mapAttachments(body.attachments),
|
|
114
|
+
});
|
|
115
|
+
return json({ ok: true, sideThread: serialize(record) }, 201);
|
|
116
|
+
}
|
|
117
|
+
if (root) return methodNotAllowed("GET, POST");
|
|
118
|
+
|
|
119
|
+
const sideChatId = decodeSegment(match![1]);
|
|
120
|
+
const action = match![2];
|
|
121
|
+
if (!action && ctx.req.method === "GET") {
|
|
122
|
+
return json({
|
|
123
|
+
ok: true,
|
|
124
|
+
sideThread: serialize(ctx.coordinator.get(ctx.actor, sideChatId)),
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
if (!action) return methodNotAllowed("GET");
|
|
128
|
+
|
|
129
|
+
if (action === "events" && ctx.req.method === "GET") {
|
|
130
|
+
// Authorize before opening a long-lived stream. A non-owner gets the
|
|
131
|
+
// same 404 as an unknown id and cannot infer another user's side chat.
|
|
132
|
+
ctx.coordinator.get(ctx.actor, sideChatId);
|
|
133
|
+
const headerCursor = ctx.req.headers.get("Last-Event-ID");
|
|
134
|
+
const queryCursor = ctx.url.searchParams.get("after");
|
|
135
|
+
const after = parseCursor(headerCursor ?? queryCursor);
|
|
136
|
+
if (after === null)
|
|
137
|
+
throw new SideThreadError(
|
|
138
|
+
"SIDE_THREAD_INVALID_CURSOR",
|
|
139
|
+
"invalid event cursor",
|
|
140
|
+
400,
|
|
141
|
+
);
|
|
142
|
+
return sideThreadEventStream(
|
|
143
|
+
ctx.coordinator,
|
|
144
|
+
ctx.actor,
|
|
145
|
+
sideChatId,
|
|
146
|
+
after,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (ctx.req.method !== "POST") return methodNotAllowed("POST");
|
|
151
|
+
const body = await requestJson(ctx.req);
|
|
152
|
+
const requestKey = field(body, "requestKey", "request_key");
|
|
153
|
+
if (action === "cancel") {
|
|
154
|
+
return json({
|
|
155
|
+
ok: true,
|
|
156
|
+
sideThread: serialize(
|
|
157
|
+
await ctx.coordinator.cancel(ctx.actor, sideChatId, { requestKey }),
|
|
158
|
+
),
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
if (action === "archive") {
|
|
162
|
+
return json({
|
|
163
|
+
ok: true,
|
|
164
|
+
sideThread: serialize(
|
|
165
|
+
await ctx.coordinator.archive(ctx.actor, sideChatId, { requestKey }),
|
|
166
|
+
),
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
if (action === "purge") {
|
|
170
|
+
return json({
|
|
171
|
+
ok: true,
|
|
172
|
+
sideThread: serialize(
|
|
173
|
+
await ctx.coordinator.purge(ctx.actor, sideChatId, { requestKey }),
|
|
174
|
+
),
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
if (action === "retry") {
|
|
178
|
+
return json({
|
|
179
|
+
ok: true,
|
|
180
|
+
sideThread: serialize(
|
|
181
|
+
await ctx.coordinator.retry(ctx.actor, sideChatId, {
|
|
182
|
+
requestKey,
|
|
183
|
+
prompt: field(body, "question", "prompt"),
|
|
184
|
+
attachments:
|
|
185
|
+
body.attachments === undefined
|
|
186
|
+
? undefined
|
|
187
|
+
: mapAttachments(body.attachments),
|
|
188
|
+
}),
|
|
189
|
+
),
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
const broughtBack = await ctx.coordinator.bringBack(ctx.actor, sideChatId, {
|
|
193
|
+
requestKey,
|
|
194
|
+
destinationThreadId: field(
|
|
195
|
+
body,
|
|
196
|
+
"destinationThreadId",
|
|
197
|
+
"destination_thread_id",
|
|
198
|
+
),
|
|
199
|
+
attemptId: field(body, "attemptId", "attempt_id"),
|
|
200
|
+
});
|
|
201
|
+
return json({ ok: true, bringBack: broughtBack });
|
|
202
|
+
} catch (error) {
|
|
203
|
+
if (error instanceof SideThreadError) {
|
|
204
|
+
return json(
|
|
205
|
+
{
|
|
206
|
+
ok: false,
|
|
207
|
+
error: error.code,
|
|
208
|
+
message: error.message,
|
|
209
|
+
...(error.operationId ? { operationId: error.operationId } : {}),
|
|
210
|
+
...(error.sideChatId ? { sideThreadId: error.sideChatId } : {}),
|
|
211
|
+
...(error.attemptId ? { attemptId: error.attemptId } : {}),
|
|
212
|
+
},
|
|
213
|
+
error.status,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
return json({ ok: false, error: "SIDE_THREAD_INTERNAL_ERROR" }, 500);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function serialize(record: SideThreadRecord) {
|
|
221
|
+
const completedBringBackAttempts = new Set(
|
|
222
|
+
record.bringBacks
|
|
223
|
+
.filter((receipt) => receipt.state === "completed")
|
|
224
|
+
.map((receipt) => receipt.attemptId),
|
|
225
|
+
);
|
|
226
|
+
return {
|
|
227
|
+
sideThreadId: record.sideChatId,
|
|
228
|
+
requestKey: record.requestKey,
|
|
229
|
+
networkId: record.networkId,
|
|
230
|
+
nodeId: record.nodeId,
|
|
231
|
+
sourceThreadId: record.sourceThreadId,
|
|
232
|
+
question: record.question,
|
|
233
|
+
title: record.question.split(/\r?\n/, 1)[0].slice(0, 120),
|
|
234
|
+
boundary: { kind: record.boundary.kind, turnId: record.boundary.turnId },
|
|
235
|
+
threadId: record.threadId ?? null,
|
|
236
|
+
state: record.state,
|
|
237
|
+
activeAttemptId: record.activeAttemptId ?? null,
|
|
238
|
+
capability: {
|
|
239
|
+
runtime: record.runtime ?? null,
|
|
240
|
+
runtimeVersion: record.runtimeVersion ?? null,
|
|
241
|
+
topology: record.topology ?? null,
|
|
242
|
+
evidenceRevision: record.evidenceRevision ?? null,
|
|
243
|
+
},
|
|
244
|
+
attachments: record.attachments.map((a) => ({ fileId: a.fileId })),
|
|
245
|
+
attempts: record.attempts.map((a) => ({
|
|
246
|
+
attemptId: a.attemptId,
|
|
247
|
+
requestKey: a.requestKey,
|
|
248
|
+
parentAttemptId: a.parentAttemptId ?? null,
|
|
249
|
+
threadId: a.threadId ?? null,
|
|
250
|
+
turnId: a.turnId ?? null,
|
|
251
|
+
state: a.state,
|
|
252
|
+
result: a.result ?? null,
|
|
253
|
+
error: a.error ?? null,
|
|
254
|
+
attachments: a.attachments.map((attachment) => ({
|
|
255
|
+
fileId: attachment.fileId,
|
|
256
|
+
})),
|
|
257
|
+
broughtBack: completedBringBackAttempts.has(a.attemptId),
|
|
258
|
+
createdAt: a.createdAt,
|
|
259
|
+
updatedAt: a.updatedAt,
|
|
260
|
+
})),
|
|
261
|
+
bringBacks: record.bringBacks.map((receipt) => ({
|
|
262
|
+
bringBackId: receipt.bringBackId,
|
|
263
|
+
attemptId: receipt.attemptId,
|
|
264
|
+
requestKey: receipt.requestKey,
|
|
265
|
+
destinationThreadId: receipt.destinationThreadId,
|
|
266
|
+
destinationTurnId: receipt.destinationTurnId ?? null,
|
|
267
|
+
state: receipt.state,
|
|
268
|
+
broughtBack: receipt.state === "completed",
|
|
269
|
+
createdAt: receipt.createdAt,
|
|
270
|
+
updatedAt: receipt.updatedAt,
|
|
271
|
+
completedAt: receipt.completedAt ?? null,
|
|
272
|
+
})),
|
|
273
|
+
operations: record.operations.map((operation) => ({
|
|
274
|
+
operationId: operation.operationId,
|
|
275
|
+
attemptId: operation.attemptId ?? null,
|
|
276
|
+
kind: operation.kind,
|
|
277
|
+
requestKey: operation.requestKey,
|
|
278
|
+
state: operation.state,
|
|
279
|
+
threadId: operation.threadId ?? null,
|
|
280
|
+
turnId: operation.turnId ?? null,
|
|
281
|
+
errorCode: operation.errorCode ?? null,
|
|
282
|
+
createdAt: operation.createdAt,
|
|
283
|
+
updatedAt: operation.updatedAt,
|
|
284
|
+
})),
|
|
285
|
+
createdAt: record.createdAt,
|
|
286
|
+
updatedAt: record.updatedAt,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function serializeCapability(
|
|
291
|
+
capability: Awaited<ReturnType<SideThreadCoordinator["capability"]>>,
|
|
292
|
+
context: {
|
|
293
|
+
networkId: string;
|
|
294
|
+
nodeId: string;
|
|
295
|
+
sourceThreadId: string;
|
|
296
|
+
boundary: { kind: "through" | "before"; turnId: string };
|
|
297
|
+
},
|
|
298
|
+
) {
|
|
299
|
+
return {
|
|
300
|
+
enabled: true,
|
|
301
|
+
supported: capability.supported,
|
|
302
|
+
mode: capability.mode ?? null,
|
|
303
|
+
runtime: capability.runtime ?? null,
|
|
304
|
+
runtimeVersion: capability.runtimeVersion ?? null,
|
|
305
|
+
topology: capability.topology ?? null,
|
|
306
|
+
evidenceRevision: capability.evidenceRevision ?? null,
|
|
307
|
+
exactBoundary: capability.exactBoundary ?? null,
|
|
308
|
+
reason: capability.reason ?? null,
|
|
309
|
+
context: capability.supported ? context : null,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function sideThreadEventStream(
|
|
314
|
+
coordinator: SideThreadCoordinator,
|
|
315
|
+
actor: SideThreadActor,
|
|
316
|
+
sideChatId: string,
|
|
317
|
+
after: number,
|
|
318
|
+
): Response {
|
|
319
|
+
const encoder = new TextEncoder();
|
|
320
|
+
let unsubscribe = () => {};
|
|
321
|
+
let heartbeat: ReturnType<typeof setInterval> | undefined;
|
|
322
|
+
let closed = false;
|
|
323
|
+
const cleanup = () => {
|
|
324
|
+
if (closed) return;
|
|
325
|
+
closed = true;
|
|
326
|
+
unsubscribe();
|
|
327
|
+
if (heartbeat) clearInterval(heartbeat);
|
|
328
|
+
};
|
|
329
|
+
const stream = new ReadableStream<Uint8Array>(
|
|
330
|
+
{
|
|
331
|
+
start(controller) {
|
|
332
|
+
const send = (
|
|
333
|
+
event: ReturnType<SideThreadCoordinator["listEvents"]>[number],
|
|
334
|
+
) => {
|
|
335
|
+
if (closed) return;
|
|
336
|
+
if ((controller.desiredSize ?? -262_145) < -262_144) {
|
|
337
|
+
cleanup();
|
|
338
|
+
try {
|
|
339
|
+
controller.close();
|
|
340
|
+
} catch {}
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
try {
|
|
344
|
+
controller.enqueue(
|
|
345
|
+
encoder.encode(
|
|
346
|
+
`id: ${event.eventId}\nevent: side_thread\ndata: ${JSON.stringify(serializeEvent(event))}\n\n`,
|
|
347
|
+
),
|
|
348
|
+
);
|
|
349
|
+
} catch {
|
|
350
|
+
cleanup();
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
for (const event of coordinator.listEvents(actor, sideChatId, after))
|
|
354
|
+
send(event);
|
|
355
|
+
if (closed) return;
|
|
356
|
+
unsubscribe = coordinator.subscribe(sideChatId, send);
|
|
357
|
+
heartbeat = setInterval(() => {
|
|
358
|
+
if ((controller.desiredSize ?? -262_145) < -262_144) {
|
|
359
|
+
cleanup();
|
|
360
|
+
try {
|
|
361
|
+
controller.close();
|
|
362
|
+
} catch {}
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
try {
|
|
366
|
+
controller.enqueue(encoder.encode(": keepalive\n\n"));
|
|
367
|
+
} catch {
|
|
368
|
+
cleanup();
|
|
369
|
+
}
|
|
370
|
+
}, 30_000);
|
|
371
|
+
},
|
|
372
|
+
cancel() {
|
|
373
|
+
cleanup();
|
|
374
|
+
},
|
|
375
|
+
},
|
|
376
|
+
{ highWaterMark: 64 * 1024, size: (chunk) => chunk.byteLength },
|
|
377
|
+
);
|
|
378
|
+
return new Response(stream, {
|
|
379
|
+
headers: {
|
|
380
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
381
|
+
"Cache-Control": "no-cache, no-transform",
|
|
382
|
+
"X-Accel-Buffering": "no",
|
|
383
|
+
},
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function serializeEvent(
|
|
388
|
+
event: ReturnType<SideThreadCoordinator["listEvents"]>[number],
|
|
389
|
+
) {
|
|
390
|
+
return {
|
|
391
|
+
eventId: event.eventId,
|
|
392
|
+
sideThreadId: event.sideChatId,
|
|
393
|
+
attemptId: event.attemptId ?? null,
|
|
394
|
+
threadId: event.threadId ?? null,
|
|
395
|
+
turnId: event.turnId ?? null,
|
|
396
|
+
type: event.type,
|
|
397
|
+
state: event.state ?? null,
|
|
398
|
+
reason: event.reason ?? null,
|
|
399
|
+
createdAt: event.createdAt,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function mapAttachments(value: unknown): Array<{ fileId: string }> | undefined {
|
|
404
|
+
if (value === undefined) return undefined;
|
|
405
|
+
if (!Array.isArray(value))
|
|
406
|
+
throw new SideThreadError(
|
|
407
|
+
"SIDE_THREAD_CONFLICT",
|
|
408
|
+
"invalid attachments",
|
|
409
|
+
409,
|
|
410
|
+
);
|
|
411
|
+
return value.map((entry: any) => {
|
|
412
|
+
if (!entry || typeof entry !== "object")
|
|
413
|
+
return { fileId: undefined as any };
|
|
414
|
+
// Do not forward path/url/name fields even temporarily. The coordinator
|
|
415
|
+
// rejects the original shape when extra keys are present.
|
|
416
|
+
const allowed = Object.keys(entry).every(
|
|
417
|
+
(key) => key === "fileId" || key === "file_id",
|
|
418
|
+
);
|
|
419
|
+
return allowed
|
|
420
|
+
? { fileId: field(entry, "fileId", "file_id") }
|
|
421
|
+
: { ...(entry as any), fileId: field(entry, "fileId", "file_id") };
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function field(value: any, camel: string, legacy: string): any {
|
|
426
|
+
return value?.[camel] ?? value?.[legacy];
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function query(url: URL, camel: string, legacy?: string): string | undefined {
|
|
430
|
+
return (
|
|
431
|
+
url.searchParams.get(camel) ??
|
|
432
|
+
(legacy ? url.searchParams.get(legacy) : null) ??
|
|
433
|
+
undefined
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function requiredQueryIdentity(url: URL, name: string): string {
|
|
438
|
+
const value = query(url, name);
|
|
439
|
+
if (!value || value.length > 512 || /[\r\n\0]/.test(value))
|
|
440
|
+
throw new SideThreadError(
|
|
441
|
+
"SIDE_THREAD_INVALID_CONTEXT",
|
|
442
|
+
`invalid ${name}`,
|
|
443
|
+
400,
|
|
444
|
+
);
|
|
445
|
+
return value;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
async function requestJson(req: Request): Promise<any> {
|
|
449
|
+
const contentType = req.headers.get("Content-Type") ?? "";
|
|
450
|
+
if (!/^application\/json(?:;|$)/i.test(contentType)) {
|
|
451
|
+
throw new SideThreadError(
|
|
452
|
+
"SIDE_THREAD_INVALID_BODY",
|
|
453
|
+
"application/json required",
|
|
454
|
+
415,
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
try {
|
|
458
|
+
const value = await req.json();
|
|
459
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
460
|
+
throw new SideThreadError(
|
|
461
|
+
"SIDE_THREAD_INVALID_BODY",
|
|
462
|
+
"JSON object required",
|
|
463
|
+
400,
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
return value;
|
|
467
|
+
} catch {
|
|
468
|
+
throw new SideThreadError("SIDE_THREAD_INVALID_BODY", "invalid JSON", 400);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
function decodeSegment(value: string): string {
|
|
472
|
+
try {
|
|
473
|
+
return decodeURIComponent(value);
|
|
474
|
+
} catch {
|
|
475
|
+
throw new SideThreadError(
|
|
476
|
+
"SIDE_THREAD_NOT_FOUND",
|
|
477
|
+
"side chat not found",
|
|
478
|
+
404,
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
function parseCursor(value: string | null): number | null {
|
|
483
|
+
if (value === null || value === "") return 0;
|
|
484
|
+
if (!/^\d+$/.test(value)) return null;
|
|
485
|
+
const n = Number(value);
|
|
486
|
+
return Number.isSafeInteger(n) ? n : null;
|
|
487
|
+
}
|
|
488
|
+
function parseLimit(value: string): number | null {
|
|
489
|
+
if (!/^\d+$/.test(value)) return null;
|
|
490
|
+
const n = Number(value);
|
|
491
|
+
return Number.isSafeInteger(n) && n >= 1 && n <= 100 ? n : null;
|
|
492
|
+
}
|
|
493
|
+
function json(body: unknown, status = 200): Response {
|
|
494
|
+
return Response.json(body, {
|
|
495
|
+
status,
|
|
496
|
+
headers: { "Cache-Control": "no-store" },
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
function methodNotAllowed(allow: string): Response {
|
|
500
|
+
return Response.json(
|
|
501
|
+
{ ok: false, error: "SIDE_THREAD_METHOD_NOT_ALLOWED" },
|
|
502
|
+
{ status: 405, headers: { Allow: allow } },
|
|
503
|
+
);
|
|
504
|
+
}
|