@standardagents/builder 0.29.1 → 0.30.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/built-in-routes.js +192 -3
- package/dist/built-in-routes.js.map +1 -1
- package/dist/{index-BNLDsLsL.d.ts → index-CcnGEkPh.d.ts} +4 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +766 -21
- package/dist/index.js.map +1 -1
- package/dist/plugin.d.ts +1 -0
- package/dist/plugin.js +66 -5
- package/dist/plugin.js.map +1 -1
- package/dist/runtime.d.ts +60 -5
- package/dist/runtime.js +700 -16
- package/dist/runtime.js.map +1 -1
- package/dist/test.d.ts +1 -1
- package/package.json +5 -5
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { getProviderModelPricing } from '@standardagents/provider-pricing';
|
|
2
2
|
import { jsonPathReader } from '@formkit/jsonreader';
|
|
3
|
-
import { ProviderError, mapReasoningLevel, THREAD_STREAM_REASONING_PARAM } from '@standardagents/spec';
|
|
3
|
+
import { ProviderError, mapReasoningLevel, THREAD_STREAM_REASONING_PARAM, isUserEndpoint } from '@standardagents/spec';
|
|
4
4
|
export { ProviderError, belongsToPackage, defineAgent, defineHook, defineModel, definePrompt, defineProvider, defineTool, isPacked, isVisibleInNamespace, mapReasoningLevel, providerConst, providerEnv, providerValue } from '@standardagents/spec';
|
|
5
5
|
import 'virtual:@standardagents-providers';
|
|
6
6
|
import { z } from 'zod';
|
|
@@ -13545,7 +13545,10 @@ var init_ThreadStateImpl = __esm({
|
|
|
13545
13545
|
data: { deferred: true, toolCallId }
|
|
13546
13546
|
};
|
|
13547
13547
|
}
|
|
13548
|
-
const local = await this._threadInstance.callClientTool(
|
|
13548
|
+
const local = await this._threadInstance.callClientTool(
|
|
13549
|
+
request,
|
|
13550
|
+
canPark && toolCallId ? { toolCallId, side } : void 0
|
|
13551
|
+
);
|
|
13549
13552
|
if (local.ok || !this.isNoClient(local)) return local;
|
|
13550
13553
|
const env = this._threadInstance.env;
|
|
13551
13554
|
if (env?.AGENT_BUILDER_THREAD) {
|
|
@@ -14095,10 +14098,35 @@ var init_ThreadStateImpl = __esm({
|
|
|
14095
14098
|
return { parentThreadId, parentStub };
|
|
14096
14099
|
}
|
|
14097
14100
|
_getAgentBuilderStub() {
|
|
14098
|
-
const env = this.
|
|
14101
|
+
const env = this._getRuntimeEnv();
|
|
14099
14102
|
const agentBuilderId = env.AGENT_BUILDER.idFromName("singleton");
|
|
14100
14103
|
return env.AGENT_BUILDER.get(agentBuilderId);
|
|
14101
14104
|
}
|
|
14105
|
+
/**
|
|
14106
|
+
* Environment bindings for this state. In-DO construction uses the thread
|
|
14107
|
+
* instance's own env; stub-backed states (thread endpoints, the user
|
|
14108
|
+
* scope's `thread()`) have no `instance.env` over RPC and use the env the
|
|
14109
|
+
* dispatcher recorded in the runtime context instead.
|
|
14110
|
+
*/
|
|
14111
|
+
_getRuntimeEnv() {
|
|
14112
|
+
const instanceEnv = this._threadInstance.env;
|
|
14113
|
+
const env = instanceEnv?.AGENT_BUILDER ? instanceEnv : this._runtimeContext.env;
|
|
14114
|
+
if (!env?.AGENT_BUILDER) {
|
|
14115
|
+
throw new Error(
|
|
14116
|
+
"No runtime environment available on this ThreadState. Stub-backed states must be constructed with runtimeContext.env."
|
|
14117
|
+
);
|
|
14118
|
+
}
|
|
14119
|
+
return env;
|
|
14120
|
+
}
|
|
14121
|
+
/** The per-user hub stub for this thread's user (user-scope surfaces). */
|
|
14122
|
+
_getUserHubStub(userId) {
|
|
14123
|
+
const env = this._getRuntimeEnv();
|
|
14124
|
+
const namespace = env.AGENT_BUILDER_USER_HUB;
|
|
14125
|
+
if (!namespace) {
|
|
14126
|
+
throw new Error("User scope unavailable: AGENT_BUILDER_USER_HUB binding is missing.");
|
|
14127
|
+
}
|
|
14128
|
+
return namespace.get(namespace.idFromName(userId));
|
|
14129
|
+
}
|
|
14102
14130
|
// ─────────────────────────────────────────────────────────────────────────
|
|
14103
14131
|
// Skills — instance-global Agent Skills library, backed by the
|
|
14104
14132
|
// DurableAgentBuilder singleton (skills are never stored per-thread or on
|
|
@@ -14115,7 +14143,67 @@ var init_ThreadStateImpl = __esm({
|
|
|
14115
14143
|
const stub = this._getAgentBuilderStub();
|
|
14116
14144
|
const user = await stub.getUserById(userId).catch(() => null);
|
|
14117
14145
|
if (!user) return null;
|
|
14146
|
+
const inUserScope = this._runtimeContext.inUserScope === true;
|
|
14147
|
+
const agentId = this._metadata.agent_id;
|
|
14148
|
+
const hub = () => this._getUserHubStub(userId);
|
|
14149
|
+
const capabilities = {
|
|
14150
|
+
call: async (endpoint, payload) => {
|
|
14151
|
+
if (inUserScope) {
|
|
14152
|
+
throw new Error(
|
|
14153
|
+
"Reentrant user-scope call: this ThreadState was raised from user-scope code; state.user().call() is unavailable here."
|
|
14154
|
+
);
|
|
14155
|
+
}
|
|
14156
|
+
const path20 = endpoint.replace(/^\/+/, "");
|
|
14157
|
+
return hub().fetch(
|
|
14158
|
+
new Request(`https://user-hub/users/${encodeURIComponent(userId)}/endpoints/${path20}`, {
|
|
14159
|
+
method: "POST",
|
|
14160
|
+
headers: {
|
|
14161
|
+
"Content-Type": "application/json",
|
|
14162
|
+
// Runtime-internal caller channel — the public routes overwrite
|
|
14163
|
+
// this header, so only thread-scope code can present "thread".
|
|
14164
|
+
"X-Standard-Agents-Caller": "thread"
|
|
14165
|
+
},
|
|
14166
|
+
body: payload === void 0 ? void 0 : JSON.stringify(payload)
|
|
14167
|
+
})
|
|
14168
|
+
);
|
|
14169
|
+
},
|
|
14170
|
+
getValue: async (key) => {
|
|
14171
|
+
return await stub.getUserValue?.(userId, key);
|
|
14172
|
+
},
|
|
14173
|
+
setValue: async (key, value) => {
|
|
14174
|
+
await stub.setUserValue?.(userId, key, value);
|
|
14175
|
+
},
|
|
14176
|
+
emit: (event, data) => {
|
|
14177
|
+
const delivery = hub().broadcastUserEvent(userId, event, data).catch(() => {
|
|
14178
|
+
});
|
|
14179
|
+
this._pendingEventDeliveries.add(delivery);
|
|
14180
|
+
void delivery.finally(
|
|
14181
|
+
() => this._pendingEventDeliveries.delete(delivery)
|
|
14182
|
+
);
|
|
14183
|
+
},
|
|
14184
|
+
fetch: async (grant, input, init) => {
|
|
14185
|
+
const body = init?.body;
|
|
14186
|
+
if (body !== void 0 && body !== null && typeof body !== "string") {
|
|
14187
|
+
throw new Error("Grant fetch bodies must be strings in this runtime.");
|
|
14188
|
+
}
|
|
14189
|
+
return hub().fetch(
|
|
14190
|
+
new Request(`https://user-hub/users/${encodeURIComponent(userId)}/grant-fetch`, {
|
|
14191
|
+
method: "POST",
|
|
14192
|
+
headers: { "Content-Type": "application/json" },
|
|
14193
|
+
body: JSON.stringify({
|
|
14194
|
+
grant,
|
|
14195
|
+
url: String(input),
|
|
14196
|
+
method: init?.method ?? "GET",
|
|
14197
|
+
headers: init?.headers ? Object.fromEntries(new Headers(init.headers)) : {},
|
|
14198
|
+
body: body ?? null,
|
|
14199
|
+
caller: { kind: "agent", name: agentId }
|
|
14200
|
+
})
|
|
14201
|
+
})
|
|
14202
|
+
);
|
|
14203
|
+
}
|
|
14204
|
+
};
|
|
14118
14205
|
return {
|
|
14206
|
+
...capabilities,
|
|
14119
14207
|
id: user.id,
|
|
14120
14208
|
username: user.username,
|
|
14121
14209
|
role: user.role,
|
|
@@ -15473,8 +15561,9 @@ function createHmrReloader() {
|
|
|
15473
15561
|
const isPromptFile = file.startsWith(dirs.promptsDir) && file.endsWith(".ts");
|
|
15474
15562
|
const isAgentFile = file.startsWith(dirs.agentsDir) && file.endsWith(".ts");
|
|
15475
15563
|
const isEffectFile = file.startsWith(dirs.effectsDir) && file.endsWith(".ts");
|
|
15564
|
+
const isUserEndpointFile = file.startsWith(dirs.usersDir) && file.endsWith(".ts");
|
|
15476
15565
|
const isConfigFile = isModelFile || isProviderFile || isPromptFile || isAgentFile || isEffectFile;
|
|
15477
|
-
if (!isToolFile && !isThreadApiFile && !isHookFile && !isConfigFile) return;
|
|
15566
|
+
if (!isToolFile && !isThreadApiFile && !isHookFile && !isConfigFile && !isUserEndpointFile) return;
|
|
15478
15567
|
reloader.invalidateSourceFileModule(server, file);
|
|
15479
15568
|
if (isConfigFile || isToolFile) regenerateTypes();
|
|
15480
15569
|
if (isToolFile) await reloader.reloadToolsModule(server);
|
|
@@ -17500,6 +17589,35 @@ function parseRequestBody(req) {
|
|
|
17500
17589
|
req.on("error", reject);
|
|
17501
17590
|
});
|
|
17502
17591
|
}
|
|
17592
|
+
function scanUsersDirectory(dir) {
|
|
17593
|
+
const results = [];
|
|
17594
|
+
if (!fs8__default.existsSync(dir)) return results;
|
|
17595
|
+
const walk = (current, prefix) => {
|
|
17596
|
+
let entries;
|
|
17597
|
+
try {
|
|
17598
|
+
entries = fs8__default.readdirSync(current, { withFileTypes: true });
|
|
17599
|
+
} catch (error) {
|
|
17600
|
+
console.error(`Error scanning users directory ${current}:`, error);
|
|
17601
|
+
return;
|
|
17602
|
+
}
|
|
17603
|
+
for (const entry of entries) {
|
|
17604
|
+
const fullPath = path8__default.join(current, entry.name);
|
|
17605
|
+
if (entry.isDirectory()) {
|
|
17606
|
+
walk(fullPath, prefix ? `${prefix}/${entry.name}` : entry.name);
|
|
17607
|
+
continue;
|
|
17608
|
+
}
|
|
17609
|
+
if (!entry.name.endsWith(".ts") || entry.name.endsWith(".d.ts")) continue;
|
|
17610
|
+
const base = entry.name.slice(0, -3);
|
|
17611
|
+
const key = prefix ? `${prefix}/${base}` : base;
|
|
17612
|
+
results.push({
|
|
17613
|
+
key,
|
|
17614
|
+
importPath: "./" + path8__default.relative(process.cwd(), fullPath).replace(/\\/g, "/")
|
|
17615
|
+
});
|
|
17616
|
+
}
|
|
17617
|
+
};
|
|
17618
|
+
walk(dir, "");
|
|
17619
|
+
return results;
|
|
17620
|
+
}
|
|
17503
17621
|
|
|
17504
17622
|
// src/plugin/agent-api-middleware.ts
|
|
17505
17623
|
async function handleAgentApiRequest(context) {
|
|
@@ -21097,9 +21215,10 @@ function createDevMiddleware(server, context) {
|
|
|
21097
21215
|
});
|
|
21098
21216
|
}
|
|
21099
21217
|
async function loadBuilderVirtualModule(context) {
|
|
21100
|
-
const { toolsDir, hooksDir, modelsDir, promptsDir, agentsDir, effectsDir, hmr } = context;
|
|
21218
|
+
const { toolsDir, hooksDir, modelsDir, promptsDir, agentsDir, effectsDir, usersDir, hmr } = context;
|
|
21101
21219
|
const tools = await scanToolsDirectory(toolsDir);
|
|
21102
21220
|
const hooks = await scanHooksDirectory(hooksDir);
|
|
21221
|
+
const userEndpoints = scanUsersDirectory(usersDir);
|
|
21103
21222
|
const models = await scanModelsDirectory(modelsDir);
|
|
21104
21223
|
const prompts = await scanPromptsDirectory(promptsDir);
|
|
21105
21224
|
const agents = await scanAgentsDirectory(agentsDir);
|
|
@@ -21201,6 +21320,17 @@ async function loadBuilderVirtualModule(context) {
|
|
|
21201
21320
|
},`;
|
|
21202
21321
|
}
|
|
21203
21322
|
}).join("\n");
|
|
21323
|
+
const userEndpointsCode = userEndpoints.map(({ key, importPath }) => {
|
|
21324
|
+
const absPath = toAbsolutePath(importPath);
|
|
21325
|
+
return ` "${key}": async () => {
|
|
21326
|
+
try {
|
|
21327
|
+
return (await import("${absPath}")).default;
|
|
21328
|
+
} catch (error) {
|
|
21329
|
+
console.error('Failed to import user endpoint ${key}:', error);
|
|
21330
|
+
throw error;
|
|
21331
|
+
}
|
|
21332
|
+
},`;
|
|
21333
|
+
}).join("\n");
|
|
21204
21334
|
return `// Consolidated virtual module: virtual:@standardagents/builder /* v${hmr.versions.builder} */
|
|
21205
21335
|
// Provides DurableThread, DurableAgentBuilder, ThreadWatchdog, and router
|
|
21206
21336
|
import { DurableThread as _BaseDurableThread } from '@standardagents/builder/runtime';
|
|
@@ -21246,6 +21376,10 @@ const _effects = {
|
|
|
21246
21376
|
${effectsCode}
|
|
21247
21377
|
};
|
|
21248
21378
|
|
|
21379
|
+
const _userEndpoints = {
|
|
21380
|
+
${userEndpointsCode}
|
|
21381
|
+
};
|
|
21382
|
+
|
|
21249
21383
|
/**
|
|
21250
21384
|
* DurableThread with all virtual module methods already implemented.
|
|
21251
21385
|
* Exported by the generated AgentBuilder Worker entry; user projects do not
|
|
@@ -21335,10 +21469,21 @@ export class CodeExecutionBridge extends _BaseCodeExecutionBridge {}
|
|
|
21335
21469
|
export class ThreadWatchdog extends _BaseThreadWatchdog {}
|
|
21336
21470
|
|
|
21337
21471
|
/**
|
|
21338
|
-
* Per-user hub companion
|
|
21339
|
-
*
|
|
21472
|
+
* Per-user hub companion: persistent daemon socket, wake signal, sharded
|
|
21473
|
+
* account KV \u2014 and the USER SCOPE (spec: Users): packaged user endpoints from
|
|
21474
|
+
* the users/ directory executed under per-user serialization, the user event
|
|
21475
|
+
* stream, and user lifecycle hooks (filtered from the shared hooks registry
|
|
21476
|
+
* by hook type at dispatch).
|
|
21340
21477
|
*/
|
|
21341
|
-
export class DurableUserHub extends _BaseDurableUserHub {
|
|
21478
|
+
export class DurableUserHub extends _BaseDurableUserHub {
|
|
21479
|
+
userEndpoints() {
|
|
21480
|
+
return _userEndpoints;
|
|
21481
|
+
}
|
|
21482
|
+
|
|
21483
|
+
userHooks() {
|
|
21484
|
+
return _hooks;
|
|
21485
|
+
}
|
|
21486
|
+
}
|
|
21342
21487
|
`;
|
|
21343
21488
|
}
|
|
21344
21489
|
async function loadRegistryVirtualModule(context) {
|
|
@@ -22394,6 +22539,7 @@ function agentbuilder(options = {}) {
|
|
|
22394
22539
|
const promptsDir = options.promptsDir ? path8__default.resolve(process.cwd(), options.promptsDir) : path8__default.resolve(process.cwd(), "agents/prompts");
|
|
22395
22540
|
const agentsDir = options.agentsDir ? path8__default.resolve(process.cwd(), options.agentsDir) : path8__default.resolve(process.cwd(), "agents/agents");
|
|
22396
22541
|
const effectsDir = options.effectsDir ? path8__default.resolve(process.cwd(), options.effectsDir) : path8__default.resolve(process.cwd(), "agents/effects");
|
|
22542
|
+
const usersDir = options.usersDir ? path8__default.resolve(process.cwd(), options.usersDir) : path8__default.resolve(process.cwd(), "agents/users");
|
|
22397
22543
|
const outputDir = path8__default.resolve(process.cwd(), ".agents");
|
|
22398
22544
|
const generatedWorkerEntry = path8__default.join(outputDir, "worker.ts");
|
|
22399
22545
|
const isProviderPackageInstalled = (packageName) => {
|
|
@@ -22456,6 +22602,7 @@ export { CodeExecutionBridge, DurableAgentBuilder, DurableThread, ThreadWatchdog
|
|
|
22456
22602
|
toolsDir,
|
|
22457
22603
|
hooksDir,
|
|
22458
22604
|
threadApiDir,
|
|
22605
|
+
usersDir,
|
|
22459
22606
|
modelsDir,
|
|
22460
22607
|
providersDir,
|
|
22461
22608
|
promptsDir,
|
|
@@ -22466,6 +22613,7 @@ export { CodeExecutionBridge, DurableAgentBuilder, DurableThread, ThreadWatchdog
|
|
|
22466
22613
|
toolsDir,
|
|
22467
22614
|
hooksDir,
|
|
22468
22615
|
threadApiDir,
|
|
22616
|
+
usersDir,
|
|
22469
22617
|
modelsDir,
|
|
22470
22618
|
providersDir,
|
|
22471
22619
|
promptsDir,
|
|
@@ -22553,6 +22701,7 @@ export { CodeExecutionBridge, DurableAgentBuilder, DurableThread, ThreadWatchdog
|
|
|
22553
22701
|
this.addWatchFile(promptsDir);
|
|
22554
22702
|
this.addWatchFile(agentsDir);
|
|
22555
22703
|
this.addWatchFile(effectsDir);
|
|
22704
|
+
this.addWatchFile(usersDir);
|
|
22556
22705
|
},
|
|
22557
22706
|
configureServer(server) {
|
|
22558
22707
|
createDevMiddleware(server, devMiddlewareContext);
|
|
@@ -22657,7 +22806,9 @@ function createThreadEndpointHandler(handler) {
|
|
|
22657
22806
|
const durableId = env.AGENT_BUILDER_THREAD.idFromName(threadId);
|
|
22658
22807
|
const stub = env.AGENT_BUILDER_THREAD.get(durableId);
|
|
22659
22808
|
const instance = stub;
|
|
22660
|
-
const threadState = ThreadStateImpl.fromThreadInstance(instance, metadata
|
|
22809
|
+
const threadState = ThreadStateImpl.fromThreadInstance(instance, metadata, {
|
|
22810
|
+
env
|
|
22811
|
+
});
|
|
22661
22812
|
const endpointParams = {};
|
|
22662
22813
|
for (const [key, value] of Object.entries(params)) {
|
|
22663
22814
|
if (key === "threadId" || key === "id" && !params.threadId) {
|
|
@@ -28279,7 +28430,7 @@ ${resultContent}${attachmentSummary}`;
|
|
|
28279
28430
|
}
|
|
28280
28431
|
/** Whether any forwarded call is mid-flight (blocking await or parked). */
|
|
28281
28432
|
async hasInFlightClientCalls() {
|
|
28282
|
-
if (this.pendingClientCalls
|
|
28433
|
+
if ((this.pendingClientCalls?.size ?? 0) > 0) return true;
|
|
28283
28434
|
await this.ensureMigrated();
|
|
28284
28435
|
await this.ensurePendingClientTable();
|
|
28285
28436
|
const cursor = await this.ctx.storage.sql.exec(
|
|
@@ -28338,7 +28489,7 @@ ${resultContent}${attachmentSummary}`;
|
|
|
28338
28489
|
* always produce a normal tool error (no client connected, timeout, or user
|
|
28339
28490
|
* denial) for the model to reason about.
|
|
28340
28491
|
*/
|
|
28341
|
-
async callClientTool(request) {
|
|
28492
|
+
async callClientTool(request, durability) {
|
|
28342
28493
|
const owner = await this.getExecutionOwner();
|
|
28343
28494
|
const socket = this.getOpenBridgeSocket(owner?.client_id ?? null);
|
|
28344
28495
|
if (!socket) {
|
|
@@ -28348,7 +28499,33 @@ ${resultContent}${attachmentSummary}`;
|
|
|
28348
28499
|
data: { noClient: true }
|
|
28349
28500
|
};
|
|
28350
28501
|
}
|
|
28351
|
-
|
|
28502
|
+
if (durability) {
|
|
28503
|
+
try {
|
|
28504
|
+
await this.ensureMigrated();
|
|
28505
|
+
await this.ensurePendingClientTable();
|
|
28506
|
+
const ownerId = owner?.client_id ?? null;
|
|
28507
|
+
const socketClientId = this.bridgeClientInfo.get(socket)?.clientId ?? null;
|
|
28508
|
+
const fenced = owner && socketClientId === owner.client_id;
|
|
28509
|
+
await this.ctx.storage.sql.exec(
|
|
28510
|
+
`INSERT OR REPLACE INTO pending_client_calls
|
|
28511
|
+
(tool_call_id, tool, args, request_permission, risk, side, status, created_at,
|
|
28512
|
+
dispatched_client_id, dispatched_generation)
|
|
28513
|
+
VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?)`,
|
|
28514
|
+
durability.toolCallId,
|
|
28515
|
+
request.tool,
|
|
28516
|
+
JSON.stringify(request.args ?? {}),
|
|
28517
|
+
request.requestPermission ?? null,
|
|
28518
|
+
typeof request.risk === "number" ? request.risk : null,
|
|
28519
|
+
durability.side,
|
|
28520
|
+
Date.now(),
|
|
28521
|
+
fenced ? ownerId : null,
|
|
28522
|
+
fenced ? owner.generation : 0
|
|
28523
|
+
);
|
|
28524
|
+
} catch (error) {
|
|
28525
|
+
console.error("[DurableThread] Failed to persist fast-path call (continuing):", error);
|
|
28526
|
+
}
|
|
28527
|
+
}
|
|
28528
|
+
const id = durability?.toolCallId ?? crypto.randomUUID();
|
|
28352
28529
|
const timeoutMs = typeof request.timeoutMs === "number" && request.timeoutMs > 0 ? request.timeoutMs : DEFAULT_CLIENT_TOOL_TIMEOUT_MS;
|
|
28353
28530
|
this.bridgeLastSeen.set(socket, Date.now());
|
|
28354
28531
|
return new Promise((resolve2) => {
|
|
@@ -28399,6 +28576,13 @@ ${resultContent}${attachmentSummary}`;
|
|
|
28399
28576
|
this.pendingClientCalls.delete(id);
|
|
28400
28577
|
if (pending.timer) clearTimeout(pending.timer);
|
|
28401
28578
|
if (pending.liveness) clearInterval(pending.liveness);
|
|
28579
|
+
try {
|
|
28580
|
+
void this.ctx.storage.sql.exec(
|
|
28581
|
+
`UPDATE pending_client_calls SET status = 'resolved' WHERE tool_call_id = ? AND status = 'pending'`,
|
|
28582
|
+
id
|
|
28583
|
+
);
|
|
28584
|
+
} catch {
|
|
28585
|
+
}
|
|
28402
28586
|
pending.resolve(res);
|
|
28403
28587
|
}
|
|
28404
28588
|
/** Close and forget a bridge socket we've deemed dead, failing remaining calls. */
|
|
@@ -28650,6 +28834,26 @@ ${resultContent}${attachmentSummary}`;
|
|
|
28650
28834
|
toolStatus,
|
|
28651
28835
|
toolCallId
|
|
28652
28836
|
);
|
|
28837
|
+
const updated = await this.ctx.storage.sql.exec(
|
|
28838
|
+
`SELECT COUNT(*) AS total FROM messages WHERE tool_call_id = ? AND role = 'tool'`,
|
|
28839
|
+
toolCallId
|
|
28840
|
+
);
|
|
28841
|
+
if (Number(updated.toArray()[0]?.total ?? 0) === 0) {
|
|
28842
|
+
const toolRow = await this.ctx.storage.sql.exec(
|
|
28843
|
+
`SELECT tool FROM pending_client_calls WHERE tool_call_id = ? LIMIT 1`,
|
|
28844
|
+
toolCallId
|
|
28845
|
+
);
|
|
28846
|
+
await this.ctx.storage.sql.exec(
|
|
28847
|
+
`INSERT INTO messages (id, role, content, name, tool_call_id, tool_status, created_at, status, prompt)
|
|
28848
|
+
VALUES (?, 'tool', ?, ?, ?, ?, ?, 'completed', NULL)`,
|
|
28849
|
+
crypto.randomUUID(),
|
|
28850
|
+
content,
|
|
28851
|
+
toolRow.toArray()[0]?.tool ?? null,
|
|
28852
|
+
toolCallId,
|
|
28853
|
+
toolStatus,
|
|
28854
|
+
Date.now() * TIMESTAMP_MULTIPLIER
|
|
28855
|
+
);
|
|
28856
|
+
}
|
|
28653
28857
|
await this.ctx.storage.sql.exec(
|
|
28654
28858
|
`UPDATE pending_client_calls SET status = 'resolved' WHERE tool_call_id = ?`,
|
|
28655
28859
|
toolCallId
|
|
@@ -29587,6 +29791,31 @@ ${resultContent}${attachmentSummary}`;
|
|
|
29587
29791
|
`SELECT COUNT(*) AS c FROM message_queue`
|
|
29588
29792
|
)).toArray()[0]?.c ?? 0;
|
|
29589
29793
|
if (stopped || finalizedCount === 0 && queued === 0) {
|
|
29794
|
+
let orphanedClientCalls = false;
|
|
29795
|
+
if (!stopped) {
|
|
29796
|
+
try {
|
|
29797
|
+
orphanedClientCalls = await this.hasInFlightClientCalls();
|
|
29798
|
+
} catch {
|
|
29799
|
+
orphanedClientCalls = false;
|
|
29800
|
+
}
|
|
29801
|
+
}
|
|
29802
|
+
if (orphanedClientCalls) {
|
|
29803
|
+
const owner = await this.getExecutionOwner();
|
|
29804
|
+
const socket = this.getOpenBridgeSocket(owner?.client_id ?? null);
|
|
29805
|
+
if (socket) {
|
|
29806
|
+
void this.resendPendingClientCalls(socket);
|
|
29807
|
+
} else if (threadId) {
|
|
29808
|
+
try {
|
|
29809
|
+
const meta = await this.getThreadMetadata(threadId);
|
|
29810
|
+
if (meta.user_id) await this.requestHubWake(meta.user_id, threadId);
|
|
29811
|
+
} catch {
|
|
29812
|
+
}
|
|
29813
|
+
}
|
|
29814
|
+
console.warn(
|
|
29815
|
+
`[DurableThread] Watchdog: pending forwarded call with no executor on ${threadId} \u2014 ${socket ? "re-dispatched to the connected bridge" : "requested owner wake"}`
|
|
29816
|
+
);
|
|
29817
|
+
return report("observing", "none", { queuedMessages: queued });
|
|
29818
|
+
}
|
|
29590
29819
|
if (finalizedCount > 0) {
|
|
29591
29820
|
return report("parked", "finalized_parked", {
|
|
29592
29821
|
queuedMessages: queued,
|
|
@@ -34459,6 +34688,232 @@ function formatMs(ms) {
|
|
|
34459
34688
|
const minutes = Math.floor(seconds / 60);
|
|
34460
34689
|
return `${minutes}m${String(seconds % 60).padStart(2, "0")}s`;
|
|
34461
34690
|
}
|
|
34691
|
+
|
|
34692
|
+
// src/users/route-match.ts
|
|
34693
|
+
var METHOD_SUFFIXES = /* @__PURE__ */ new Set(["get", "post", "put", "patch", "delete"]);
|
|
34694
|
+
function parseUserRouteKey(key) {
|
|
34695
|
+
const parts = key.split("/");
|
|
34696
|
+
let file = parts.pop() ?? "";
|
|
34697
|
+
let method = "GET";
|
|
34698
|
+
const dot = file.lastIndexOf(".");
|
|
34699
|
+
if (dot >= 0) {
|
|
34700
|
+
const suffix = file.slice(dot + 1).toLowerCase();
|
|
34701
|
+
if (METHOD_SUFFIXES.has(suffix)) {
|
|
34702
|
+
method = suffix.toUpperCase();
|
|
34703
|
+
file = file.slice(0, dot);
|
|
34704
|
+
}
|
|
34705
|
+
}
|
|
34706
|
+
const segments = [...parts];
|
|
34707
|
+
if (file !== "index") segments.push(file);
|
|
34708
|
+
let dynamism = 0;
|
|
34709
|
+
for (const segment of segments) {
|
|
34710
|
+
if (segment === "[*]") dynamism = 2;
|
|
34711
|
+
else if (dynamism < 2 && segment.startsWith("[") && segment.endsWith("]")) dynamism = 1;
|
|
34712
|
+
}
|
|
34713
|
+
return { key, method, segments, dynamism };
|
|
34714
|
+
}
|
|
34715
|
+
function matchSegments(route, path20) {
|
|
34716
|
+
const params = {};
|
|
34717
|
+
for (let i = 0; i < route.length; i++) {
|
|
34718
|
+
const seg = route[i];
|
|
34719
|
+
if (seg === "[*]") {
|
|
34720
|
+
if (i !== route.length - 1) return null;
|
|
34721
|
+
params["*"] = path20.slice(i).join("/");
|
|
34722
|
+
return params;
|
|
34723
|
+
}
|
|
34724
|
+
const value = path20[i];
|
|
34725
|
+
if (value === void 0) return null;
|
|
34726
|
+
if (seg.startsWith("[") && seg.endsWith("]")) {
|
|
34727
|
+
params[seg.slice(1, -1)] = value;
|
|
34728
|
+
} else if (seg !== value) {
|
|
34729
|
+
return null;
|
|
34730
|
+
}
|
|
34731
|
+
}
|
|
34732
|
+
return path20.length === route.length ? params : null;
|
|
34733
|
+
}
|
|
34734
|
+
function matchUserRoute(keys, method, endpointPath) {
|
|
34735
|
+
const path20 = endpointPath.split("/").filter((s) => s.length > 0);
|
|
34736
|
+
const wanted = method.toUpperCase();
|
|
34737
|
+
let best = null;
|
|
34738
|
+
for (const key of keys) {
|
|
34739
|
+
const route = parseUserRouteKey(key);
|
|
34740
|
+
if (route.method !== wanted) continue;
|
|
34741
|
+
const params = matchSegments(route.segments, path20);
|
|
34742
|
+
if (!params) continue;
|
|
34743
|
+
if (!best || route.dynamism < best.route.dynamism) {
|
|
34744
|
+
best = { route, params };
|
|
34745
|
+
}
|
|
34746
|
+
}
|
|
34747
|
+
return best ? { key: best.route.key, params: best.params } : null;
|
|
34748
|
+
}
|
|
34749
|
+
|
|
34750
|
+
// src/users/UserStateImpl.ts
|
|
34751
|
+
init_ThreadStateImpl();
|
|
34752
|
+
var UserStateImpl = class {
|
|
34753
|
+
userId;
|
|
34754
|
+
host;
|
|
34755
|
+
/** Caller descriptor for grant `allow` policy checks. */
|
|
34756
|
+
caller;
|
|
34757
|
+
constructor(userId, host, caller) {
|
|
34758
|
+
this.userId = userId;
|
|
34759
|
+
this.host = host;
|
|
34760
|
+
this.caller = caller;
|
|
34761
|
+
}
|
|
34762
|
+
singleton() {
|
|
34763
|
+
const ns = this.host.env.AGENT_BUILDER;
|
|
34764
|
+
if (!ns) throw new Error("AGENT_BUILDER binding unavailable in the user scope.");
|
|
34765
|
+
return ns.get(ns.idFromName("singleton"));
|
|
34766
|
+
}
|
|
34767
|
+
async identity() {
|
|
34768
|
+
const user = await this.singleton().getUserById(this.userId);
|
|
34769
|
+
if (!user) throw new Error(`Unknown user: ${this.userId}`);
|
|
34770
|
+
return {
|
|
34771
|
+
id: user.id,
|
|
34772
|
+
username: user.username,
|
|
34773
|
+
role: user.role,
|
|
34774
|
+
permissions: user.permissions ?? null,
|
|
34775
|
+
email: user.email ?? null,
|
|
34776
|
+
display_name: user.display_name ?? null,
|
|
34777
|
+
avatar_url: user.avatar_url ?? null
|
|
34778
|
+
};
|
|
34779
|
+
}
|
|
34780
|
+
async getValue(key) {
|
|
34781
|
+
return await this.host.getUserValue(this.userId, key);
|
|
34782
|
+
}
|
|
34783
|
+
async setValue(key, value) {
|
|
34784
|
+
await this.host.setUserValue(this.userId, key, value);
|
|
34785
|
+
}
|
|
34786
|
+
async listValues(options) {
|
|
34787
|
+
const { entries, total } = await this.host.listUserValues(this.userId, options);
|
|
34788
|
+
return { entries, total };
|
|
34789
|
+
}
|
|
34790
|
+
async env(name) {
|
|
34791
|
+
const values = await this.singleton().getUserEnv(this.userId);
|
|
34792
|
+
const value = values[name];
|
|
34793
|
+
if (value === void 0) throw new Error(`Environment variable not found: ${name}`);
|
|
34794
|
+
return value;
|
|
34795
|
+
}
|
|
34796
|
+
emit(event, data) {
|
|
34797
|
+
this.host.broadcastUserEvent(this.userId, event, data);
|
|
34798
|
+
}
|
|
34799
|
+
async listThreads(options) {
|
|
34800
|
+
const { threads, total } = await this.singleton().listThreads({
|
|
34801
|
+
user_id: this.userId,
|
|
34802
|
+
agent_name: options?.agentId,
|
|
34803
|
+
limit: options?.limit,
|
|
34804
|
+
offset: options?.offset
|
|
34805
|
+
});
|
|
34806
|
+
return {
|
|
34807
|
+
threads: threads.map((t) => ({
|
|
34808
|
+
id: t.id,
|
|
34809
|
+
agent_id: t.agent_id ?? t.agent_name ?? "",
|
|
34810
|
+
tags: t.tags ?? [],
|
|
34811
|
+
created_at: t.created_at
|
|
34812
|
+
})),
|
|
34813
|
+
total
|
|
34814
|
+
};
|
|
34815
|
+
}
|
|
34816
|
+
async fetch(grant, input, init) {
|
|
34817
|
+
const body = init?.body;
|
|
34818
|
+
if (body !== void 0 && body !== null && typeof body !== "string") {
|
|
34819
|
+
throw new Error("Grant fetch bodies must be strings in this runtime.");
|
|
34820
|
+
}
|
|
34821
|
+
return this.host.grantFetch(this.userId, grant, {
|
|
34822
|
+
url: String(input),
|
|
34823
|
+
method: init?.method ?? "GET",
|
|
34824
|
+
headers: init?.headers ? Object.fromEntries(new Headers(init.headers)) : {},
|
|
34825
|
+
body: body ?? null,
|
|
34826
|
+
caller: this.caller
|
|
34827
|
+
});
|
|
34828
|
+
}
|
|
34829
|
+
/**
|
|
34830
|
+
* Raise one of this user's threads at rest — the same stub-backed
|
|
34831
|
+
* materialization thread endpoints receive. `inUserScope` marks the state
|
|
34832
|
+
* so a reentrant `state.user().call()` fails instead of deadlocking the
|
|
34833
|
+
* serialized user execution (spec: Users §2.2.3).
|
|
34834
|
+
*/
|
|
34835
|
+
async thread(threadId) {
|
|
34836
|
+
const metadata = await this.singleton().getThread(threadId);
|
|
34837
|
+
if (!metadata) throw new Error(`Thread not found: ${threadId}`);
|
|
34838
|
+
if (metadata.user_id !== this.userId) {
|
|
34839
|
+
throw new Error(`Thread ${threadId} does not belong to user ${this.userId}.`);
|
|
34840
|
+
}
|
|
34841
|
+
const meta = metadata;
|
|
34842
|
+
if (!meta.agent_id && meta.agent_name) meta.agent_id = meta.agent_name;
|
|
34843
|
+
const ns = this.host.env.AGENT_BUILDER_THREAD;
|
|
34844
|
+
if (!ns) throw new Error("AGENT_BUILDER_THREAD binding unavailable in the user scope.");
|
|
34845
|
+
const instance = ns.get(ns.idFromName(threadId));
|
|
34846
|
+
return ThreadStateImpl.fromThreadInstance(instance, meta, {
|
|
34847
|
+
env: this.host.env,
|
|
34848
|
+
inUserScope: true
|
|
34849
|
+
});
|
|
34850
|
+
}
|
|
34851
|
+
};
|
|
34852
|
+
|
|
34853
|
+
// src/users/serialize.ts
|
|
34854
|
+
function createExecutionChain() {
|
|
34855
|
+
let tail2 = Promise.resolve();
|
|
34856
|
+
return {
|
|
34857
|
+
run(fn) {
|
|
34858
|
+
const next = tail2.then(fn, fn);
|
|
34859
|
+
tail2 = next.catch(() => {
|
|
34860
|
+
});
|
|
34861
|
+
return next;
|
|
34862
|
+
}
|
|
34863
|
+
};
|
|
34864
|
+
}
|
|
34865
|
+
|
|
34866
|
+
// src/users/grants.ts
|
|
34867
|
+
function originAllowed(origins, target) {
|
|
34868
|
+
if (!Array.isArray(origins)) return false;
|
|
34869
|
+
for (const allowed of origins) {
|
|
34870
|
+
if (allowed.startsWith("*.")) {
|
|
34871
|
+
const suffix = allowed.slice(1);
|
|
34872
|
+
if (target.protocol === "https:" && target.hostname.endsWith(suffix)) return true;
|
|
34873
|
+
} else {
|
|
34874
|
+
try {
|
|
34875
|
+
if (new URL(allowed).origin === target.origin) return true;
|
|
34876
|
+
} catch {
|
|
34877
|
+
}
|
|
34878
|
+
}
|
|
34879
|
+
}
|
|
34880
|
+
return false;
|
|
34881
|
+
}
|
|
34882
|
+
function callerAllowed(allow, caller) {
|
|
34883
|
+
if (!allow) return false;
|
|
34884
|
+
const lists = {
|
|
34885
|
+
tool: allow.tools,
|
|
34886
|
+
endpoint: allow.endpoints,
|
|
34887
|
+
userEndpoint: allow.userEndpoints,
|
|
34888
|
+
userHook: allow.userEndpoints,
|
|
34889
|
+
agent: allow.agents
|
|
34890
|
+
};
|
|
34891
|
+
const list = lists[caller.kind];
|
|
34892
|
+
if (!list) return false;
|
|
34893
|
+
const bare = caller.name.replace(/\.(get|post|put|patch|delete)$/i, "");
|
|
34894
|
+
return list.includes(caller.name) || list.includes(bare);
|
|
34895
|
+
}
|
|
34896
|
+
function injectGrantCredential(config, secret, target, headers) {
|
|
34897
|
+
const format = config.inject?.format ?? "SECRET";
|
|
34898
|
+
switch (config.kind) {
|
|
34899
|
+
case "bearer":
|
|
34900
|
+
headers.set("Authorization", `Bearer ${secret}`);
|
|
34901
|
+
return true;
|
|
34902
|
+
case "header":
|
|
34903
|
+
headers.set(config.inject?.header ?? "Authorization", format.replace(/SECRET/g, secret));
|
|
34904
|
+
return true;
|
|
34905
|
+
case "query":
|
|
34906
|
+
target.searchParams.set(config.inject?.query ?? "key", secret);
|
|
34907
|
+
return true;
|
|
34908
|
+
case "basic":
|
|
34909
|
+
headers.set("Authorization", `Basic ${btoa(secret)}`);
|
|
34910
|
+
return true;
|
|
34911
|
+
default:
|
|
34912
|
+
return false;
|
|
34913
|
+
}
|
|
34914
|
+
}
|
|
34915
|
+
|
|
34916
|
+
// src/durable-objects/DurableUserHub.ts
|
|
34462
34917
|
var DurableUserHub = class extends DurableObject {
|
|
34463
34918
|
/** Persistent client (daemon) sockets, restored across hibernation. */
|
|
34464
34919
|
clientSockets = /* @__PURE__ */ new Set();
|
|
@@ -34466,11 +34921,9 @@ var DurableUserHub = class extends DurableObject {
|
|
|
34466
34921
|
migrated = false;
|
|
34467
34922
|
constructor(ctx, env) {
|
|
34468
34923
|
super(ctx, env);
|
|
34469
|
-
|
|
34470
|
-
|
|
34471
|
-
|
|
34472
|
-
);
|
|
34473
|
-
}
|
|
34924
|
+
ctx.blockConcurrencyWhile(async () => {
|
|
34925
|
+
this.ensureMigrated();
|
|
34926
|
+
});
|
|
34474
34927
|
for (const ws of ctx.getWebSockets()) {
|
|
34475
34928
|
const attachment = ws.deserializeAttachment();
|
|
34476
34929
|
if (attachment?.type === "hub_client") {
|
|
@@ -34498,8 +34951,38 @@ var DurableUserHub = class extends DurableObject {
|
|
|
34498
34951
|
this.ctx.storage.sql.exec(
|
|
34499
34952
|
`CREATE INDEX IF NOT EXISTS idx_user_values_user ON user_values(user_id)`
|
|
34500
34953
|
);
|
|
34954
|
+
this.ctx.storage.sql.exec(`
|
|
34955
|
+
CREATE TABLE IF NOT EXISTS user_grants (
|
|
34956
|
+
user_id TEXT NOT NULL,
|
|
34957
|
+
name TEXT NOT NULL,
|
|
34958
|
+
secret TEXT NOT NULL,
|
|
34959
|
+
config TEXT NOT NULL,
|
|
34960
|
+
created_at INTEGER NOT NULL,
|
|
34961
|
+
updated_at INTEGER NOT NULL,
|
|
34962
|
+
PRIMARY KEY (user_id, name)
|
|
34963
|
+
)
|
|
34964
|
+
`);
|
|
34501
34965
|
this.migrated = true;
|
|
34502
34966
|
}
|
|
34967
|
+
// ─── User scope: registries (overridden by the generated virtual module) ──
|
|
34968
|
+
/**
|
|
34969
|
+
* Returns the user endpoints registry for lazy-loading `users/` files.
|
|
34970
|
+
* Keys are endpoint file paths relative to `users/` with the extension
|
|
34971
|
+
* stripped except the method suffix (e.g. `standardcode/lease_acquire.post`).
|
|
34972
|
+
* Implemented when DurableUserHub is imported from
|
|
34973
|
+
* 'virtual:@standardagents/builder'.
|
|
34974
|
+
*/
|
|
34975
|
+
userEndpoints() {
|
|
34976
|
+
return {};
|
|
34977
|
+
}
|
|
34978
|
+
/**
|
|
34979
|
+
* Returns the hooks registry (same registry the thread runtime uses). The
|
|
34980
|
+
* hub filters it to user-scope hook types at dispatch time. Implemented
|
|
34981
|
+
* when DurableUserHub is imported from 'virtual:@standardagents/builder'.
|
|
34982
|
+
*/
|
|
34983
|
+
userHooks() {
|
|
34984
|
+
return {};
|
|
34985
|
+
}
|
|
34503
34986
|
// ─── Persistent client socket ──────────────────────────────────────────────
|
|
34504
34987
|
/**
|
|
34505
34988
|
* Accept the user's persistent client (daemon) WebSocket. The daemon holds
|
|
@@ -34508,6 +34991,16 @@ var DurableUserHub = class extends DurableObject {
|
|
|
34508
34991
|
*/
|
|
34509
34992
|
async fetch(request) {
|
|
34510
34993
|
const url = new URL(request.url);
|
|
34994
|
+
const scopeMatch = /^\/users\/([^/]+)\/(stream|endpoints|grant-fetch)(?:\/(.*))?$/.exec(
|
|
34995
|
+
url.pathname
|
|
34996
|
+
);
|
|
34997
|
+
if (scopeMatch) {
|
|
34998
|
+
const userId = decodeURIComponent(scopeMatch[1]);
|
|
34999
|
+
const kind = scopeMatch[2];
|
|
35000
|
+
if (kind === "stream") return this.acceptUserStream(request, url, userId);
|
|
35001
|
+
if (kind === "grant-fetch") return this.handleGrantFetch(request, userId);
|
|
35002
|
+
return this.runUserEndpoint(request, userId, scopeMatch[3] ?? "");
|
|
35003
|
+
}
|
|
34511
35004
|
if (request.headers.get("Upgrade") === "websocket") {
|
|
34512
35005
|
const pair = new WebSocketPair();
|
|
34513
35006
|
const [client, server] = [pair[0], pair[1]];
|
|
@@ -34516,6 +35009,7 @@ var DurableUserHub = class extends DurableObject {
|
|
|
34516
35009
|
clientName: url.searchParams.get("client_name"),
|
|
34517
35010
|
clientKind: url.searchParams.get("client_kind")
|
|
34518
35011
|
};
|
|
35012
|
+
this.ensureHeartbeatAutoResponse();
|
|
34519
35013
|
this.ctx.acceptWebSocket(server);
|
|
34520
35014
|
server.serializeAttachment({ type: "hub_client", ...info });
|
|
34521
35015
|
this.clientSockets.add(server);
|
|
@@ -34528,16 +35022,265 @@ var DurableUserHub = class extends DurableObject {
|
|
|
34528
35022
|
}
|
|
34529
35023
|
return new Response("Not found", { status: 404 });
|
|
34530
35024
|
}
|
|
34531
|
-
|
|
34532
|
-
|
|
35025
|
+
// ─── User scope: event stream (spec: Users §4) ─────────────────────────────
|
|
35026
|
+
/** Idle sockets answer heartbeats at the edge without waking the object.
|
|
35027
|
+
* Registered at accept time rather than in the constructor so a hub that
|
|
35028
|
+
* never accepts a socket stays fully evictable (this also keeps
|
|
35029
|
+
* vitest-pool-workers' isolated storage able to pop the DO's frame). */
|
|
35030
|
+
ensureHeartbeatAutoResponse() {
|
|
35031
|
+
if (typeof WebSocketRequestResponsePair !== "undefined") {
|
|
35032
|
+
this.ctx.setWebSocketAutoResponse(
|
|
35033
|
+
new WebSocketRequestResponsePair("stream_ping", "stream_pong")
|
|
35034
|
+
);
|
|
35035
|
+
}
|
|
35036
|
+
}
|
|
35037
|
+
/** Sockets attached to the user event stream, restored after hibernation. */
|
|
35038
|
+
streamSockets() {
|
|
35039
|
+
return this.ctx.getWebSockets().filter((ws) => ws.deserializeAttachment()?.type === "user_stream");
|
|
35040
|
+
}
|
|
35041
|
+
acceptUserStream(request, url, userId) {
|
|
35042
|
+
if (request.headers.get("Upgrade") !== "websocket") {
|
|
35043
|
+
return Response.json({ error: "WebSocket upgrade required" }, { status: 426 });
|
|
35044
|
+
}
|
|
35045
|
+
const pair = new WebSocketPair();
|
|
35046
|
+
const [client, server] = [pair[0], pair[1]];
|
|
35047
|
+
const clientId = url.searchParams.get("client_id") ?? crypto.randomUUID();
|
|
35048
|
+
const clientKind = url.searchParams.get("client_kind");
|
|
35049
|
+
this.ensureHeartbeatAutoResponse();
|
|
35050
|
+
this.ctx.acceptWebSocket(server);
|
|
35051
|
+
server.serializeAttachment({ type: "user_stream", userId, clientId, clientKind });
|
|
35052
|
+
try {
|
|
35053
|
+
server.send(JSON.stringify({ event: "stream_ready", data: { client_id: clientId } }));
|
|
35054
|
+
} catch {
|
|
35055
|
+
}
|
|
35056
|
+
this.dispatchUserHooks(userId, "user_client_attached", [
|
|
35057
|
+
{ id: clientId, kind: clientKind ?? void 0 }
|
|
35058
|
+
]);
|
|
35059
|
+
return new Response(null, { status: 101, webSocket: client });
|
|
35060
|
+
}
|
|
35061
|
+
/**
|
|
35062
|
+
* Deliver an event frame to every socket attached to this user's stream.
|
|
35063
|
+
* At-most-once, live-only (spec: Users §4) — no history, no acks.
|
|
35064
|
+
*/
|
|
35065
|
+
broadcastUserEvent(userId, event, data) {
|
|
35066
|
+
const frame = JSON.stringify({ event, data });
|
|
35067
|
+
for (const ws of this.streamSockets()) {
|
|
35068
|
+
const attachment = ws.deserializeAttachment();
|
|
35069
|
+
if (attachment?.userId !== userId) continue;
|
|
35070
|
+
try {
|
|
35071
|
+
ws.send(frame);
|
|
35072
|
+
} catch {
|
|
35073
|
+
}
|
|
35074
|
+
}
|
|
35075
|
+
}
|
|
35076
|
+
// ─── User scope: serialized endpoint execution (spec: Users §2.1, §3) ─────
|
|
35077
|
+
/** Per-user execution chain (spec: Users §2.1) — see users/serialize.ts. */
|
|
35078
|
+
executionChain = createExecutionChain();
|
|
35079
|
+
scopeHost() {
|
|
35080
|
+
return {
|
|
35081
|
+
getUserValue: (userId, key) => this.getUserValue(userId, key),
|
|
35082
|
+
setUserValue: (userId, key, value) => this.setUserValue(userId, key, value),
|
|
35083
|
+
listUserValues: (userId, options) => this.listUserValues(userId, options ?? {}),
|
|
35084
|
+
broadcastUserEvent: (userId, event, data) => this.broadcastUserEvent(userId, event, data),
|
|
35085
|
+
grantFetch: (userId, grant, req) => this.grantFetch(userId, grant, req),
|
|
35086
|
+
env: this.env
|
|
35087
|
+
};
|
|
35088
|
+
}
|
|
35089
|
+
async runUserEndpoint(request, userId, endpointPath) {
|
|
35090
|
+
const registry = this.userEndpoints();
|
|
35091
|
+
const match = matchUserRoute(Object.keys(registry), request.method, endpointPath);
|
|
35092
|
+
if (!match) return Response.json({ error: "Not found" }, { status: 404 });
|
|
35093
|
+
let loaded;
|
|
35094
|
+
try {
|
|
35095
|
+
loaded = await registry[match.key]();
|
|
35096
|
+
} catch (error) {
|
|
35097
|
+
return Response.json(
|
|
35098
|
+
{ error: `Failed to load user endpoint: ${match.key}` },
|
|
35099
|
+
{ status: 500 }
|
|
35100
|
+
);
|
|
35101
|
+
}
|
|
35102
|
+
const module = loaded;
|
|
35103
|
+
const handler = module.default ?? loaded;
|
|
35104
|
+
if (!isUserEndpoint(handler)) {
|
|
35105
|
+
return Response.json(
|
|
35106
|
+
{ error: `${match.key} is not a defineUserEndpoint export` },
|
|
35107
|
+
{ status: 500 }
|
|
35108
|
+
);
|
|
35109
|
+
}
|
|
35110
|
+
return this.executionChain.run(async () => {
|
|
35111
|
+
const user = new UserStateImpl(userId, this.scopeHost(), {
|
|
35112
|
+
kind: "userEndpoint",
|
|
35113
|
+
name: match.key
|
|
35114
|
+
});
|
|
35115
|
+
try {
|
|
35116
|
+
return await handler.__handler(request, user, match.params);
|
|
35117
|
+
} catch (error) {
|
|
35118
|
+
return Response.json(
|
|
35119
|
+
{ error: error instanceof Error ? error.message : "User endpoint failed" },
|
|
35120
|
+
{ status: 500 }
|
|
35121
|
+
);
|
|
35122
|
+
}
|
|
35123
|
+
});
|
|
35124
|
+
}
|
|
35125
|
+
// ─── User scope: lifecycle hooks (spec: Users §7) ──────────────────────────
|
|
35126
|
+
/**
|
|
35127
|
+
* Fire every definition of a user hook type. Observational and best-effort:
|
|
35128
|
+
* failures are swallowed and dispatch never blocks the triggering
|
|
35129
|
+
* operation. Nested dispatch is suppressed so a `user_value_changed` hook
|
|
35130
|
+
* writing values cannot recurse.
|
|
35131
|
+
*/
|
|
35132
|
+
inUserHookDispatch = false;
|
|
35133
|
+
dispatchUserHooks(userId, hookType, args) {
|
|
35134
|
+
if (this.inUserHookDispatch) return;
|
|
35135
|
+
const registry = this.userHooks();
|
|
35136
|
+
const names = Object.keys(registry);
|
|
35137
|
+
if (names.length === 0) return;
|
|
35138
|
+
const run = async () => {
|
|
35139
|
+
const user = new UserStateImpl(userId, this.scopeHost(), {
|
|
35140
|
+
kind: "userHook",
|
|
35141
|
+
name: hookType
|
|
35142
|
+
});
|
|
35143
|
+
for (const name of names) {
|
|
35144
|
+
try {
|
|
35145
|
+
const loaded = await registry[name]();
|
|
35146
|
+
const definition = loaded.default ?? loaded;
|
|
35147
|
+
if (definition?.hook !== hookType || typeof definition.execute !== "function") {
|
|
35148
|
+
continue;
|
|
35149
|
+
}
|
|
35150
|
+
this.inUserHookDispatch = true;
|
|
35151
|
+
try {
|
|
35152
|
+
await definition.execute(user, ...args);
|
|
35153
|
+
} finally {
|
|
35154
|
+
this.inUserHookDispatch = false;
|
|
35155
|
+
}
|
|
35156
|
+
} catch {
|
|
35157
|
+
}
|
|
35158
|
+
}
|
|
35159
|
+
};
|
|
35160
|
+
this.ctx.waitUntil(run());
|
|
34533
35161
|
}
|
|
34534
35162
|
async webSocketClose(ws) {
|
|
35163
|
+
const attachment = ws.deserializeAttachment();
|
|
35164
|
+
if (attachment?.type === "user_stream") {
|
|
35165
|
+
this.dispatchUserHooks(String(attachment.userId), "user_client_detached", [
|
|
35166
|
+
{
|
|
35167
|
+
id: typeof attachment.clientId === "string" ? attachment.clientId : "unknown",
|
|
35168
|
+
kind: typeof attachment.clientKind === "string" ? attachment.clientKind : void 0
|
|
35169
|
+
}
|
|
35170
|
+
]);
|
|
35171
|
+
return;
|
|
35172
|
+
}
|
|
34535
35173
|
this.clientSockets.delete(ws);
|
|
34536
35174
|
this.clientInfo.delete(ws);
|
|
34537
35175
|
}
|
|
35176
|
+
// ─── User scope: credential grants (spec: Users §6) ───────────────────────
|
|
35177
|
+
/**
|
|
35178
|
+
* Store or replace a grant. `secret` is write-only from this point on.
|
|
35179
|
+
* Policy (`CredentialGrant` minus the secret) is kept as JSON config.
|
|
35180
|
+
*/
|
|
35181
|
+
async setGrant(userId, grant, secret) {
|
|
35182
|
+
this.ensureMigrated();
|
|
35183
|
+
const { name, ...config } = grant;
|
|
35184
|
+
const now = Date.now();
|
|
35185
|
+
await this.ctx.storage.sql.exec(
|
|
35186
|
+
`INSERT INTO user_grants (user_id, name, secret, config, created_at, updated_at)
|
|
35187
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?5)
|
|
35188
|
+
ON CONFLICT(user_id, name) DO UPDATE SET
|
|
35189
|
+
secret = excluded.secret, config = excluded.config, updated_at = excluded.updated_at`,
|
|
35190
|
+
userId,
|
|
35191
|
+
name,
|
|
35192
|
+
secret,
|
|
35193
|
+
JSON.stringify(config),
|
|
35194
|
+
now
|
|
35195
|
+
);
|
|
35196
|
+
}
|
|
35197
|
+
async deleteGrant(userId, name) {
|
|
35198
|
+
this.ensureMigrated();
|
|
35199
|
+
await this.ctx.storage.sql.exec(
|
|
35200
|
+
`DELETE FROM user_grants WHERE user_id = ? AND name = ?`,
|
|
35201
|
+
userId,
|
|
35202
|
+
name
|
|
35203
|
+
);
|
|
35204
|
+
}
|
|
35205
|
+
/** Metadata-only listing — never returns secret material. */
|
|
35206
|
+
async listGrants(userId) {
|
|
35207
|
+
this.ensureMigrated();
|
|
35208
|
+
const cursor = await this.ctx.storage.sql.exec(
|
|
35209
|
+
`SELECT name, config FROM user_grants WHERE user_id = ? ORDER BY name ASC`,
|
|
35210
|
+
userId
|
|
35211
|
+
);
|
|
35212
|
+
return cursor.toArray().map((row) => {
|
|
35213
|
+
let config = {};
|
|
35214
|
+
try {
|
|
35215
|
+
config = JSON.parse(row.config);
|
|
35216
|
+
} catch {
|
|
35217
|
+
}
|
|
35218
|
+
return { name: row.name, ...config };
|
|
35219
|
+
});
|
|
35220
|
+
}
|
|
35221
|
+
/** Perform a privileged fetch with the grant's credential injected. */
|
|
35222
|
+
async grantFetch(userId, grantName, req) {
|
|
35223
|
+
this.ensureMigrated();
|
|
35224
|
+
const cursor = await this.ctx.storage.sql.exec(
|
|
35225
|
+
`SELECT secret, config FROM user_grants WHERE user_id = ? AND name = ? LIMIT 1`,
|
|
35226
|
+
userId,
|
|
35227
|
+
grantName
|
|
35228
|
+
);
|
|
35229
|
+
const row = cursor.toArray()[0];
|
|
35230
|
+
if (!row) {
|
|
35231
|
+
return Response.json({ error: "grant_not_found", grant: grantName }, { status: 404 });
|
|
35232
|
+
}
|
|
35233
|
+
let config;
|
|
35234
|
+
try {
|
|
35235
|
+
config = JSON.parse(row.config);
|
|
35236
|
+
} catch {
|
|
35237
|
+
return Response.json({ error: "grant_config_invalid" }, { status: 500 });
|
|
35238
|
+
}
|
|
35239
|
+
let target;
|
|
35240
|
+
try {
|
|
35241
|
+
target = new URL(req.url);
|
|
35242
|
+
} catch {
|
|
35243
|
+
return Response.json({ error: "grant_target_invalid" }, { status: 400 });
|
|
35244
|
+
}
|
|
35245
|
+
if (!originAllowed(config.origins, target)) {
|
|
35246
|
+
return Response.json({ error: "grant_origin_denied" }, { status: 403 });
|
|
35247
|
+
}
|
|
35248
|
+
if (!callerAllowed(config.allow, req.caller)) {
|
|
35249
|
+
return Response.json({ error: "grant_caller_denied" }, { status: 403 });
|
|
35250
|
+
}
|
|
35251
|
+
const headers = new Headers(req.headers);
|
|
35252
|
+
if (!injectGrantCredential(config, row.secret, target, headers)) {
|
|
35253
|
+
return Response.json({ error: "grant_kind_unsupported" }, { status: 501 });
|
|
35254
|
+
}
|
|
35255
|
+
try {
|
|
35256
|
+
const response = await fetch(target.toString(), {
|
|
35257
|
+
method: req.method,
|
|
35258
|
+
headers,
|
|
35259
|
+
body: req.body ?? void 0
|
|
35260
|
+
});
|
|
35261
|
+
return new Response(response.body, { status: response.status, headers: response.headers });
|
|
35262
|
+
} catch {
|
|
35263
|
+
return Response.json({ error: "grant_fetch_failed" }, { status: 502 });
|
|
35264
|
+
}
|
|
35265
|
+
}
|
|
35266
|
+
async handleGrantFetch(request, userId) {
|
|
35267
|
+
const body = await request.json().catch(() => null);
|
|
35268
|
+
if (!body?.grant || !body.url) {
|
|
35269
|
+
return Response.json({ error: "grant and url required" }, { status: 400 });
|
|
35270
|
+
}
|
|
35271
|
+
return this.grantFetch(userId, body.grant, {
|
|
35272
|
+
url: body.url,
|
|
35273
|
+
method: body.method ?? "GET",
|
|
35274
|
+
headers: body.headers ?? {},
|
|
35275
|
+
body: body.body ?? null,
|
|
35276
|
+
caller: body.caller ?? { kind: "unknown", name: "unknown" }
|
|
35277
|
+
});
|
|
35278
|
+
}
|
|
35279
|
+
async webSocketMessage(ws, _message) {
|
|
35280
|
+
if (!this.clientSockets.has(ws)) this.clientSockets.add(ws);
|
|
35281
|
+
}
|
|
34538
35282
|
async webSocketError(ws) {
|
|
34539
|
-
this.
|
|
34540
|
-
this.clientInfo.delete(ws);
|
|
35283
|
+
await this.webSocketClose(ws);
|
|
34541
35284
|
}
|
|
34542
35285
|
/** Whether a persistent client (daemon) is connected right now. */
|
|
34543
35286
|
async hasClient() {
|
|
@@ -34597,6 +35340,7 @@ var DurableUserHub = class extends DurableObject {
|
|
|
34597
35340
|
userId,
|
|
34598
35341
|
key
|
|
34599
35342
|
);
|
|
35343
|
+
this.dispatchUserHooks(userId, "user_value_changed", [key]);
|
|
34600
35344
|
return;
|
|
34601
35345
|
}
|
|
34602
35346
|
const serialized = JSON.stringify(value);
|
|
@@ -34612,6 +35356,7 @@ var DurableUserHub = class extends DurableObject {
|
|
|
34612
35356
|
serialized,
|
|
34613
35357
|
now
|
|
34614
35358
|
);
|
|
35359
|
+
this.dispatchUserHooks(userId, "user_value_changed", [key]);
|
|
34615
35360
|
}
|
|
34616
35361
|
async listUserValues(userId, options = {}) {
|
|
34617
35362
|
this.ensureMigrated();
|