@agent-team-foundation/first-tree-hub 0.11.4 → 0.12.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/{bootstrap-D-Yf8yOc.mjs → bootstrap-C_K2CKXC.mjs} +7 -0
- package/dist/cli/index.mjs +73 -11
- package/dist/client-BPUdUaZT-CyCrpCTP.mjs +2033 -0
- package/dist/client-BhCtO2df-BGOu-rRN.mjs +7 -0
- package/dist/{dist-BQtAQNRD.mjs → dist-LgF7LHpE.mjs} +1 -1
- package/dist/{dist-ClFs4WMj.mjs → dist-UOZ6vMUW.mjs} +372 -197
- package/dist/drizzle/0033_onboarding_dismissed_at.sql +13 -0
- package/dist/drizzle/0034_pending_questions.sql +34 -0
- package/dist/drizzle/meta/_journal.json +14 -0
- package/dist/{errors-BmyRwN0Y-Dad3eV8F.mjs → errors-CF5evtJt-B0NTIVPt.mjs} +2 -1
- package/dist/{feishu-AI3pwmqN.mjs → feishu-C6qlhju2.mjs} +1 -1
- package/dist/{getMachineId-bsd-c2VImogj.mjs → getMachineId-bsd-BmasEOJr.mjs} +1 -1
- package/dist/{getMachineId-bsd-DyySs8xz.mjs → getMachineId-bsd-Dh3h0DDE.mjs} +1 -1
- package/dist/{getMachineId-darwin-Cl7TSzgO.mjs → getMachineId-darwin-CuhM3hfZ.mjs} +1 -1
- package/dist/{getMachineId-darwin-DKgI8b1d.mjs → getMachineId-darwin-D9wR0SLj.mjs} +1 -1
- package/dist/{getMachineId-linux-1OIMWfdh.mjs → getMachineId-linux-CYfb0oxZ.mjs} +1 -1
- package/dist/{getMachineId-linux-cT7EbP10.mjs → getMachineId-linux-D8ZaSjAC.mjs} +1 -1
- package/dist/{getMachineId-unsupported-CkX-YOG1.mjs → getMachineId-unsupported-Cu3iisaD.mjs} +1 -1
- package/dist/{getMachineId-unsupported-CmVlhzIo.mjs → getMachineId-unsupported-DZqI4ZT5.mjs} +1 -1
- package/dist/{getMachineId-win-C2cM60YT.mjs → getMachineId-win-8ZJbtrdf.mjs} +1 -1
- package/dist/{getMachineId-win-Chl03TYe.mjs → getMachineId-win-DT-hqwVp.mjs} +1 -1
- package/dist/index.mjs +9 -9
- package/dist/{invitation-Dnn5gGGX-DXryyvRG.mjs → invitation-Bg0TRiyx-BsZH4GCS.mjs} +2 -2
- package/dist/invitation-C299fxkP-KyCNax4T.mjs +4 -0
- package/dist/{observability-BAScT_5S-gw1ODB_o.mjs → observability-BAScT_5S-BcW9HgkG.mjs} +13 -13
- package/dist/{observability-CYsdAcoF.mjs → observability-eLA9iNK_.mjs} +3 -3
- package/dist/{saas-connect-CVoRK0Ex.mjs → saas-connect-Drn9g6cR.mjs} +1195 -1685
- package/dist/web/assets/index-B_Tf2I6v.css +1 -0
- package/dist/web/assets/{index-Bm6hgcvt.js → index-Bnyz7inW.js} +1 -1
- package/dist/web/assets/index-Dy3jIUX5.js +391 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/client-By1K4VVT-DuI6EnSh.mjs +0 -4
- package/dist/client-CLdRbuml-svTO0Eat.mjs +0 -524
- package/dist/invitation-DWlyNb8x-BvXubk24.mjs +0 -4
- package/dist/web/assets/index-fNb_M0nL.css +0 -1
- package/dist/web/assets/index-k2bWRKc-.js +0 -388
- /package/dist/{esm-Ci8E1Gtj.mjs → esm-iadMkGbV.mjs} +0 -0
- /package/dist/{src-aJMV60mR.mjs → src-DNBS5Yjj.mjs} +0 -0
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { a as __toCommonJS, o as __toESM, t as __commonJSMin } from "./chunk-BSw8zbkd.mjs";
|
|
2
|
-
import { A as FIRST_TREE_HUB_ATTR, C as stampOrgScope, D as untrustedAttrs, E as startWsConnectionSpan, M as require_pino, O as withSpan, S as stampChatResource, _ as rootLogger$1, a as buildRateLimitError, c as currentTraceId,
|
|
3
|
-
import { C as resetConfigMeta, E as setConfigValue, S as resetConfig, T as serverConfigSchema, b as migrateLegacyHome, c as resolveServerUrl, d as DEFAULT_CONFIG_DIR, f as DEFAULT_DATA_DIR$1, g as collectMissingPrompts, h as clientConfigSchema, i as ensureFreshAccessToken, l as saveAgentConfig, m as agentConfigSchema, o as loadCredentials, p as DEFAULT_HOME_DIR$1, u as saveCredentials, v as initConfig, w as resolveConfigReadonly, y as loadAgents } from "./bootstrap-
|
|
2
|
+
import { A as FIRST_TREE_HUB_ATTR, C as stampOrgScope, D as untrustedAttrs, E as startWsConnectionSpan, M as require_pino, O as withSpan, S as stampChatResource, _ as rootLogger$1, a as buildRateLimitError, c as currentTraceId, g as reportErrorToRoot, i as bodyCaptureOnSendHook, j as redactUrl, k as withWsMessageSpan, l as decodeJwtForTrace, m as observabilityPlugin, n as applyLoggerConfig, o as classifyJoseError, r as attachRequestContext, s as createLogger$1, t as adapterAttrs, u as endWsConnectionSpan, x as stampAgentResource, y as setWsConnectionAttrs } from "./observability-BAScT_5S-BcW9HgkG.mjs";
|
|
3
|
+
import { C as resetConfigMeta, E as setConfigValue, S as resetConfig, T as serverConfigSchema, b as migrateLegacyHome, c as resolveServerUrl, d as DEFAULT_CONFIG_DIR, f as DEFAULT_DATA_DIR$1, g as collectMissingPrompts, h as clientConfigSchema, i as ensureFreshAccessToken, l as saveAgentConfig, m as agentConfigSchema, o as loadCredentials, p as DEFAULT_HOME_DIR$1, u as saveCredentials, v as initConfig, w as resolveConfigReadonly, y as loadAgents } from "./bootstrap-C_K2CKXC.mjs";
|
|
4
4
|
import { a as print, i as blank, n as CLI_USER_AGENT, r as COMMAND_VERSION, s as status, t as cliFetch } from "./cli-fetch--tiwKm5S.mjs";
|
|
5
|
-
import { $ as loginSchema, A as createAgentSchema, B as githubCallbackQuerySchema, C as agentTypeSchema$1, Ct as
|
|
6
|
-
import { a as ConflictError, c as UnauthorizedError, i as ClientUserMismatchError$1, l as organizations, n as BadRequestError, o as ForbiddenError, r as ClientOrgMismatchError$1, s as NotFoundError, t as AppError, u as users } from "./errors-
|
|
7
|
-
import {
|
|
8
|
-
import { n as
|
|
9
|
-
import { a as invitationRedemptions, c as recordRedemption, i as getActiveInvitation, l as rotateInvitation, n as ensureActiveInvitation, o as invitations, r as findActiveByToken, t as buildInviteUrl, u as uuidv7 } from "./invitation-
|
|
5
|
+
import { $ as loginSchema, A as createAgentSchema, At as updateTaskStatusSchema, B as githubCallbackQuerySchema, C as agentTypeSchema$1, Ct as updateAdapterConfigSchema, D as contextTreeSnapshotSchema, Dt as updateClientCapabilitiesSchema, E as connectTokenExchangeSchema, Et as updateChatSchema, F as createTaskSchema, G as inboxDeliverFrameSchema$1, H as githubStartQuerySchema, I as defaultRuntimeConfigPayload, J as isRedactedEnvValue, K as inboxPollQuerySchema, L as delegateFeishuUserSchema, M as createMeChatSchema, N as createMemberSchema, O as createAdapterConfigSchema, Ot as updateMemberSchema, P as createOrgFromMeSchema, Q as listMeChatsQuerySchema, R as dryRunAgentRuntimeConfigSchema, S as agentRuntimeConfigPayloadSchema$1, St as taskListQuerySchema, T as clientRegisterSchema, Tt as updateAgentSchema, U as imageInlineContentSchema, V as githubDevCallbackQuerySchema, W as inboxAckFrameSchema, X as joinByInvitationSchema, Y as isReservedAgentName$1, Z as linkTaskChatSchema, _ as addParticipantSchema, _t as sessionEventSchema$1, a as AGENT_STATUSES, b as agentBindRequestSchema, bt as stripCode, ct as refreshTokenSchema, d as TASK_CREATOR_TYPES, et as messageSourceSchema$1, f as TASK_HEALTH_SIGNALS, ft as selfServiceFeishuBotSchema, g as addMeChatParticipantsSchema, gt as sessionEventMessageSchema, h as WS_AUTH_FRAME_TIMEOUT_MS, ht as sessionCompletionMessageSchema, i as AGENT_SOURCES, it as patchOnboardingSchema, j as createChatSchema, jt as wsAuthFrameSchema, k as createAdapterMappingSchema, kt as updateOrganizationSchema, l as MENTION_REGEX, lt as runtimeStateMessageSchema, m as TASK_TERMINAL_STATUSES, mt as sendToAgentSchema, n as AGENT_NAME_REGEX$1, nt as onboardingEventSchema, o as AGENT_TYPES, p as TASK_STATUSES, pt as sendMessageSchema, q as isOrgSettingNamespace, r as AGENT_SELECTOR_HEADER$1, rt as paginationQuerySchema, s as AGENT_VISIBILITY, st as rebindAgentSchema, t as AGENT_BIND_REJECT_REASONS, tt as notificationQuerySchema, u as ORG_SETTINGS_NAMESPACES$1, ut as safeRedirectPath, v as adminCreateTaskSchema, vt as sessionReconcileRequestSchema, wt as updateAgentRuntimeConfigSchema, x as agentPinnedMessageSchema$1, xt as submitQuestionAnswerSchema, y as adminUpdateTaskSchema, yt as sessionStateMessageSchema } from "./dist-UOZ6vMUW.mjs";
|
|
6
|
+
import { a as ConflictError, c as UnauthorizedError, i as ClientUserMismatchError$1, l as organizations, n as BadRequestError, o as ForbiddenError, r as ClientOrgMismatchError$1, s as NotFoundError, t as AppError, u as users } from "./errors-CF5evtJt-B0NTIVPt.mjs";
|
|
7
|
+
import { n as init_esm, r as trace, t as esm_exports } from "./esm-iadMkGbV.mjs";
|
|
8
|
+
import { $ as pendingQuestions, A as heartbeatClient, B as listAgentsWithRuntime, C as findOrCreateDirectChat, D as getClient, E as getChatDetail, F as joinChat, G as listClientsForOrgAdmin, H as listChats, I as leaveAsParticipant, J as markStaleAgents, K as listMessages, L as leaveChat, M as inboxEntries, N as invalidateChatAudience, O as getOnlineCount, P as joinAsParticipant, Q as notifyRecipients, R as listActiveAgentsPinnedToClient, S as ensureParticipant$1, T as getCachedAudience, U as listChatsForMember, V as listChatParticipantsWithNames, W as listClients, X as members, Y as markSupersededByChat, Z as messages, _ as createNotifier, _t as updateClientCapabilities, a as agents, at as removeParticipant, b as editMessage, c as bindAgent, ct as retireClient, d as chats, dt as serverInstances, et as recomputeChatWatchers, f as claimClient, ft as setOffline, g as createChat, gt as unbindAgent, h as clients, ht as touchAgent, i as agentVisibilityCondition, it as registerClient, j as heartbeatInstance, k as getPresence, l as chatParticipants, lt as sendMessage, m as cleanupStalePresence, mt as submitAnswer, n as agentChatSessions, nt as recomputeWatchersForMember, o as assertClientOwner, ot as resetActivity, p as cleanupStaleClients, pt as setRuntimeState, r as agentPresence, rt as registerChatMessageDispatcher, s as assertParticipant, st as resolveChatMembership, t as addParticipant, tt as recomputeWatchersForAgent, u as chatSubscriptions, ut as sendToAgent$1, v as deriveAuthState, vt as upsertSessionState, w as getActivityOverview, x as ensureCanJoin, y as disconnectClient, z as listAgentsManagedByUser } from "./client-BPUdUaZT-CyCrpCTP.mjs";
|
|
9
|
+
import { a as invitationRedemptions, c as recordRedemption, i as getActiveInvitation, l as rotateInvitation, n as ensureActiveInvitation, o as invitations, r as findActiveByToken, t as buildInviteUrl, u as uuidv7 } from "./invitation-Bg0TRiyx-BsZH4GCS.mjs";
|
|
10
10
|
import { createRequire } from "node:module";
|
|
11
11
|
import { ZodError, z } from "zod";
|
|
12
12
|
import { basename, delimiter, dirname, extname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
|
|
@@ -16,7 +16,7 @@ import { EventEmitter } from "node:events";
|
|
|
16
16
|
import { closeSync, copyFileSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, watch, writeFileSync, writeSync } from "node:fs";
|
|
17
17
|
import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
18
18
|
import WebSocket from "ws";
|
|
19
|
-
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
19
|
+
import { chmod, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
20
20
|
import { parse, stringify } from "yaml";
|
|
21
21
|
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
22
22
|
import { execFile, execFileSync, execSync, spawn, spawnSync } from "node:child_process";
|
|
@@ -29,7 +29,7 @@ import { and, asc, count, desc, eq, gt, gte, inArray, isNotNull, isNull, lt, ne,
|
|
|
29
29
|
import { drizzle } from "drizzle-orm/postgres-js";
|
|
30
30
|
import postgres from "postgres";
|
|
31
31
|
import { migrate } from "drizzle-orm/postgres-js/migrator";
|
|
32
|
-
import {
|
|
32
|
+
import { boolean, index, integer, jsonb, pgTable, primaryKey, serial, text, timestamp, unique, uniqueIndex } from "drizzle-orm/pg-core";
|
|
33
33
|
import { SignJWT, jwtVerify } from "jose";
|
|
34
34
|
import cors from "@fastify/cors";
|
|
35
35
|
import rateLimit from "@fastify/rate-limit";
|
|
@@ -371,160 +371,6 @@ z.object({
|
|
|
371
371
|
connected: z.boolean(),
|
|
372
372
|
lastActiveAt: z.string().nullable()
|
|
373
373
|
});
|
|
374
|
-
const presenceStatusSchema = z.enum(["online", "offline"]);
|
|
375
|
-
const runtimeStateSchema = z.enum([
|
|
376
|
-
"idle",
|
|
377
|
-
"working",
|
|
378
|
-
"blocked",
|
|
379
|
-
"error"
|
|
380
|
-
]);
|
|
381
|
-
z.enum([
|
|
382
|
-
"active",
|
|
383
|
-
"suspended",
|
|
384
|
-
"evicted"
|
|
385
|
-
]);
|
|
386
|
-
/** Wire-level states a client may report. `evicted` from a stale client is rejected. */
|
|
387
|
-
const clientSessionStateSchema = z.enum(["active", "suspended"]);
|
|
388
|
-
z.object({
|
|
389
|
-
chatId: z.string().min(1),
|
|
390
|
-
state: clientSessionStateSchema
|
|
391
|
-
});
|
|
392
|
-
z.object({ runtimeState: runtimeStateSchema });
|
|
393
|
-
z.object({
|
|
394
|
-
agentId: z.string().min(1),
|
|
395
|
-
runtimeType: z.string().max(50),
|
|
396
|
-
runtimeVersion: z.string().max(50).optional()
|
|
397
|
-
});
|
|
398
|
-
z.enum([
|
|
399
|
-
"wrong_client",
|
|
400
|
-
"not_owned",
|
|
401
|
-
"agent_suspended",
|
|
402
|
-
"wrong_org",
|
|
403
|
-
"unknown_agent",
|
|
404
|
-
"runtime_provider_mismatch"
|
|
405
|
-
]);
|
|
406
|
-
/** Header used on agent-scoped HTTP calls to select which managed agent the JWT acts as. */
|
|
407
|
-
const AGENT_SELECTOR_HEADER = "x-agent-id";
|
|
408
|
-
z.object({
|
|
409
|
-
agentId: z.string(),
|
|
410
|
-
status: presenceStatusSchema,
|
|
411
|
-
connectedAt: z.string().nullable(),
|
|
412
|
-
lastSeenAt: z.string(),
|
|
413
|
-
clientId: z.string().nullable().optional(),
|
|
414
|
-
runtimeType: z.string().nullable().optional(),
|
|
415
|
-
runtimeVersion: z.string().nullable().optional(),
|
|
416
|
-
runtimeState: runtimeStateSchema.nullable().optional(),
|
|
417
|
-
activeSessions: z.number().int().nullable().optional(),
|
|
418
|
-
totalSessions: z.number().int().nullable().optional(),
|
|
419
|
-
runtimeUpdatedAt: z.string().nullable().optional()
|
|
420
|
-
});
|
|
421
|
-
z.object({
|
|
422
|
-
total: z.number().int(),
|
|
423
|
-
running: z.number().int(),
|
|
424
|
-
byState: z.object({
|
|
425
|
-
idle: z.number().int(),
|
|
426
|
-
working: z.number().int(),
|
|
427
|
-
blocked: z.number().int(),
|
|
428
|
-
error: z.number().int()
|
|
429
|
-
}),
|
|
430
|
-
clients: z.number().int()
|
|
431
|
-
});
|
|
432
|
-
const runtimeProviderSchema = z.enum(["claude-code", "codex"]);
|
|
433
|
-
const agentTypeSchema = z.enum([
|
|
434
|
-
"human",
|
|
435
|
-
"personal_assistant",
|
|
436
|
-
"autonomous_agent"
|
|
437
|
-
]);
|
|
438
|
-
const agentVisibilitySchema = z.enum(["private", "organization"]);
|
|
439
|
-
const agentSourceSchema = z.enum(["admin-api", "portal"]);
|
|
440
|
-
z.enum(["active", "suspended"]);
|
|
441
|
-
/**
|
|
442
|
-
* Agent-name rules (see docs/agent-naming-design.md §3.1):
|
|
443
|
-
* - Lowercase ASCII slug, hyphens + underscores allowed.
|
|
444
|
-
* - Must start with alphanumeric: `-` / `_` as first char collide with
|
|
445
|
-
* CLI flag parsing and markdown list syntax.
|
|
446
|
-
* - 1–64 chars — aligned with `MENTION_REGEX` so any valid name can be
|
|
447
|
-
* @-mentioned in chat. Older rows created under the previous 1–100
|
|
448
|
-
* regex are grandfathered; the tight rule only gates new creates.
|
|
449
|
-
*/
|
|
450
|
-
const AGENT_NAME_REGEX = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
451
|
-
const RESERVED_AGENT_NAMES_SET = new Set([
|
|
452
|
-
"admin",
|
|
453
|
-
"agent",
|
|
454
|
-
"first-tree",
|
|
455
|
-
"hub",
|
|
456
|
-
"me",
|
|
457
|
-
"null",
|
|
458
|
-
"system",
|
|
459
|
-
"undefined"
|
|
460
|
-
]);
|
|
461
|
-
function isReservedAgentName(name) {
|
|
462
|
-
return RESERVED_AGENT_NAMES_SET.has(name);
|
|
463
|
-
}
|
|
464
|
-
z.object({
|
|
465
|
-
name: z.string().min(1).max(64).regex(AGENT_NAME_REGEX, "Must start with a letter or digit and contain only lowercase letters, digits, hyphens (-), and underscores (_). Max 64 chars.").refine((n) => !isReservedAgentName(n), { message: "That agent name is reserved — pick a different one." }).optional(),
|
|
466
|
-
type: agentTypeSchema,
|
|
467
|
-
displayName: z.string().min(1).max(200).optional(),
|
|
468
|
-
delegateMention: z.string().max(100).optional(),
|
|
469
|
-
organizationId: z.string().min(1).max(100).optional(),
|
|
470
|
-
source: agentSourceSchema.optional(),
|
|
471
|
-
visibility: agentVisibilitySchema.optional(),
|
|
472
|
-
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
473
|
-
managerId: z.string().optional(),
|
|
474
|
-
clientId: z.string().min(1).max(100).optional(),
|
|
475
|
-
runtimeProvider: runtimeProviderSchema.optional()
|
|
476
|
-
});
|
|
477
|
-
z.object({
|
|
478
|
-
type: agentTypeSchema.optional(),
|
|
479
|
-
displayName: z.string().min(1).max(200).optional(),
|
|
480
|
-
delegateMention: z.string().max(100).nullable().optional(),
|
|
481
|
-
visibility: agentVisibilitySchema.optional(),
|
|
482
|
-
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
483
|
-
managerId: z.string().nullable().optional(),
|
|
484
|
-
clientId: z.string().min(1).max(100).nullable().optional()
|
|
485
|
-
});
|
|
486
|
-
z.object({
|
|
487
|
-
clientId: z.string().min(1).max(100),
|
|
488
|
-
runtimeProvider: runtimeProviderSchema,
|
|
489
|
-
force: z.boolean().optional()
|
|
490
|
-
});
|
|
491
|
-
z.object({
|
|
492
|
-
uuid: z.string(),
|
|
493
|
-
name: z.string().nullable(),
|
|
494
|
-
organizationId: z.string(),
|
|
495
|
-
type: agentTypeSchema,
|
|
496
|
-
displayName: z.string(),
|
|
497
|
-
delegateMention: z.string().nullable(),
|
|
498
|
-
inboxId: z.string(),
|
|
499
|
-
status: z.string(),
|
|
500
|
-
source: z.string().nullable().optional(),
|
|
501
|
-
visibility: agentVisibilitySchema,
|
|
502
|
-
metadata: z.record(z.string(), z.unknown()),
|
|
503
|
-
managerId: z.string().nullable(),
|
|
504
|
-
clientId: z.string().nullable(),
|
|
505
|
-
runtimeProvider: runtimeProviderSchema,
|
|
506
|
-
presenceStatus: presenceStatusSchema.optional(),
|
|
507
|
-
createdAt: z.string(),
|
|
508
|
-
updatedAt: z.string()
|
|
509
|
-
});
|
|
510
|
-
z.object({
|
|
511
|
-
repo: z.string().nullable(),
|
|
512
|
-
branch: z.string().nullable()
|
|
513
|
-
});
|
|
514
|
-
/**
|
|
515
|
-
* Server → client WebSocket frame announcing that an agent has just been
|
|
516
|
-
* pinned to the connected client (either created with `clientId` or bound via
|
|
517
|
-
* PATCH NULL → ID). The client can auto-register a local config from this so
|
|
518
|
-
* the operator doesn't have to run `first-tree-hub agent add` manually.
|
|
519
|
-
*/
|
|
520
|
-
const agentPinnedMessageSchema = z.object({
|
|
521
|
-
type: z.literal("agent:pinned"),
|
|
522
|
-
agentId: z.string(),
|
|
523
|
-
name: z.string().nullable(),
|
|
524
|
-
displayName: z.string(),
|
|
525
|
-
agentType: agentTypeSchema,
|
|
526
|
-
runtimeProvider: runtimeProviderSchema
|
|
527
|
-
});
|
|
528
374
|
/**
|
|
529
375
|
* Agent runtime configuration.
|
|
530
376
|
*
|
|
@@ -665,11 +511,31 @@ const agentRuntimeConfigSchema = z.object({
|
|
|
665
511
|
updatedAt: z.string(),
|
|
666
512
|
updatedBy: z.string()
|
|
667
513
|
});
|
|
514
|
+
/**
|
|
515
|
+
* Write-side shape with no `.default()` per field.
|
|
516
|
+
*
|
|
517
|
+
* `agentRuntimeConfigPayloadShape` carries `.default()` on every field for the
|
|
518
|
+
* read path (so legacy DB rows parse cleanly). On the PATCH side those defaults
|
|
519
|
+
* are actively harmful: Zod 4's `.partial()` makes a field optional but keeps
|
|
520
|
+
* the inner `ZodDefault`, so a body like `{ mcpServers: [...] }` parses to a
|
|
521
|
+
* fully-populated patch where the omitted fields are filled with their
|
|
522
|
+
* defaults — the service layer's `patch.x ?? current.x` then sees a truthy
|
|
523
|
+
* default and *replaces* the user's saved value with empty. Mirroring the 5
|
|
524
|
+
* fields here without defaults keeps "field absent" → `undefined` in the
|
|
525
|
+
* parsed patch, which is what the merge logic expects.
|
|
526
|
+
*/
|
|
527
|
+
const agentRuntimeConfigPatchShape = z.object({
|
|
528
|
+
prompt: promptConfigSchema,
|
|
529
|
+
model: z.string(),
|
|
530
|
+
mcpServers: z.array(mcpServerSchema),
|
|
531
|
+
env: z.array(envEntrySchema),
|
|
532
|
+
gitRepos: z.array(gitRepoSchema)
|
|
533
|
+
}).partial();
|
|
668
534
|
z.object({
|
|
669
535
|
expectedVersion: z.number().int().positive(),
|
|
670
|
-
payload:
|
|
536
|
+
payload: agentRuntimeConfigPatchShape
|
|
671
537
|
});
|
|
672
|
-
z.object({ payload:
|
|
538
|
+
z.object({ payload: agentRuntimeConfigPatchShape });
|
|
673
539
|
z.object({
|
|
674
540
|
current: agentRuntimeConfigSchema,
|
|
675
541
|
next: agentRuntimeConfigPayloadSchema,
|
|
@@ -694,6 +560,161 @@ function deriveRepoLocalPath(url) {
|
|
|
694
560
|
if (!trimmed) return "";
|
|
695
561
|
return ((trimmed.split(/[?#]/)[0] ?? "").split(/[/:]/).filter(Boolean).pop() ?? "").replace(/\.git$/i, "");
|
|
696
562
|
}
|
|
563
|
+
const presenceStatusSchema = z.enum(["online", "offline"]);
|
|
564
|
+
const runtimeStateSchema = z.enum([
|
|
565
|
+
"idle",
|
|
566
|
+
"working",
|
|
567
|
+
"blocked",
|
|
568
|
+
"error"
|
|
569
|
+
]);
|
|
570
|
+
z.enum([
|
|
571
|
+
"active",
|
|
572
|
+
"suspended",
|
|
573
|
+
"evicted"
|
|
574
|
+
]);
|
|
575
|
+
/** Wire-level states a client may report. `evicted` from a stale client is rejected. */
|
|
576
|
+
const clientSessionStateSchema = z.enum(["active", "suspended"]);
|
|
577
|
+
z.object({
|
|
578
|
+
chatId: z.string().min(1),
|
|
579
|
+
state: clientSessionStateSchema
|
|
580
|
+
});
|
|
581
|
+
z.object({ runtimeState: runtimeStateSchema });
|
|
582
|
+
z.object({
|
|
583
|
+
agentId: z.string().min(1),
|
|
584
|
+
runtimeType: z.string().max(50),
|
|
585
|
+
runtimeVersion: z.string().max(50).optional()
|
|
586
|
+
});
|
|
587
|
+
z.enum([
|
|
588
|
+
"wrong_client",
|
|
589
|
+
"not_owned",
|
|
590
|
+
"agent_suspended",
|
|
591
|
+
"wrong_org",
|
|
592
|
+
"unknown_agent",
|
|
593
|
+
"runtime_provider_mismatch"
|
|
594
|
+
]);
|
|
595
|
+
/** Header used on agent-scoped HTTP calls to select which managed agent the JWT acts as. */
|
|
596
|
+
const AGENT_SELECTOR_HEADER = "x-agent-id";
|
|
597
|
+
z.object({
|
|
598
|
+
agentId: z.string(),
|
|
599
|
+
status: presenceStatusSchema,
|
|
600
|
+
connectedAt: z.string().nullable(),
|
|
601
|
+
lastSeenAt: z.string(),
|
|
602
|
+
clientId: z.string().nullable().optional(),
|
|
603
|
+
runtimeType: z.string().nullable().optional(),
|
|
604
|
+
runtimeVersion: z.string().nullable().optional(),
|
|
605
|
+
runtimeState: runtimeStateSchema.nullable().optional(),
|
|
606
|
+
activeSessions: z.number().int().nullable().optional(),
|
|
607
|
+
totalSessions: z.number().int().nullable().optional(),
|
|
608
|
+
runtimeUpdatedAt: z.string().nullable().optional()
|
|
609
|
+
});
|
|
610
|
+
z.object({
|
|
611
|
+
total: z.number().int(),
|
|
612
|
+
running: z.number().int(),
|
|
613
|
+
byState: z.object({
|
|
614
|
+
idle: z.number().int(),
|
|
615
|
+
working: z.number().int(),
|
|
616
|
+
blocked: z.number().int(),
|
|
617
|
+
error: z.number().int()
|
|
618
|
+
}),
|
|
619
|
+
clients: z.number().int()
|
|
620
|
+
});
|
|
621
|
+
const runtimeProviderSchema = z.enum(["claude-code", "codex"]);
|
|
622
|
+
const agentTypeSchema = z.enum([
|
|
623
|
+
"human",
|
|
624
|
+
"personal_assistant",
|
|
625
|
+
"autonomous_agent"
|
|
626
|
+
]);
|
|
627
|
+
const agentVisibilitySchema = z.enum(["private", "organization"]);
|
|
628
|
+
const agentSourceSchema = z.enum(["admin-api", "portal"]);
|
|
629
|
+
z.enum(["active", "suspended"]);
|
|
630
|
+
/**
|
|
631
|
+
* Agent-name rules (see docs/agent-naming-design.md §3.1):
|
|
632
|
+
* - Lowercase ASCII slug, hyphens + underscores allowed.
|
|
633
|
+
* - Must start with alphanumeric: `-` / `_` as first char collide with
|
|
634
|
+
* CLI flag parsing and markdown list syntax.
|
|
635
|
+
* - 1–64 chars — aligned with `MENTION_REGEX` so any valid name can be
|
|
636
|
+
* @-mentioned in chat. Older rows created under the previous 1–100
|
|
637
|
+
* regex are grandfathered; the tight rule only gates new creates.
|
|
638
|
+
*/
|
|
639
|
+
const AGENT_NAME_REGEX = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
640
|
+
const RESERVED_AGENT_NAMES_SET = new Set([
|
|
641
|
+
"admin",
|
|
642
|
+
"agent",
|
|
643
|
+
"first-tree",
|
|
644
|
+
"hub",
|
|
645
|
+
"me",
|
|
646
|
+
"null",
|
|
647
|
+
"system",
|
|
648
|
+
"undefined"
|
|
649
|
+
]);
|
|
650
|
+
function isReservedAgentName(name) {
|
|
651
|
+
return RESERVED_AGENT_NAMES_SET.has(name);
|
|
652
|
+
}
|
|
653
|
+
z.object({
|
|
654
|
+
name: z.string().min(1).max(64).regex(AGENT_NAME_REGEX, "Must start with a letter or digit and contain only lowercase letters, digits, hyphens (-), and underscores (_). Max 64 chars.").refine((n) => !isReservedAgentName(n), { message: "That agent name is reserved — pick a different one." }).optional(),
|
|
655
|
+
type: agentTypeSchema,
|
|
656
|
+
displayName: z.string().min(1).max(200).optional(),
|
|
657
|
+
delegateMention: z.string().max(100).optional(),
|
|
658
|
+
organizationId: z.string().min(1).max(100).optional(),
|
|
659
|
+
source: agentSourceSchema.optional(),
|
|
660
|
+
visibility: agentVisibilitySchema.optional(),
|
|
661
|
+
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
662
|
+
managerId: z.string().optional(),
|
|
663
|
+
clientId: z.string().min(1).max(100).optional(),
|
|
664
|
+
runtimeProvider: runtimeProviderSchema.optional(),
|
|
665
|
+
gitRepos: z.array(gitRepoSchema).optional()
|
|
666
|
+
});
|
|
667
|
+
z.object({
|
|
668
|
+
type: agentTypeSchema.optional(),
|
|
669
|
+
displayName: z.string().min(1).max(200).optional(),
|
|
670
|
+
delegateMention: z.string().max(100).nullable().optional(),
|
|
671
|
+
visibility: agentVisibilitySchema.optional(),
|
|
672
|
+
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
673
|
+
managerId: z.string().nullable().optional(),
|
|
674
|
+
clientId: z.string().min(1).max(100).nullable().optional()
|
|
675
|
+
});
|
|
676
|
+
z.object({
|
|
677
|
+
clientId: z.string().min(1).max(100),
|
|
678
|
+
runtimeProvider: runtimeProviderSchema,
|
|
679
|
+
force: z.boolean().optional()
|
|
680
|
+
});
|
|
681
|
+
z.object({
|
|
682
|
+
uuid: z.string(),
|
|
683
|
+
name: z.string().nullable(),
|
|
684
|
+
organizationId: z.string(),
|
|
685
|
+
type: agentTypeSchema,
|
|
686
|
+
displayName: z.string(),
|
|
687
|
+
delegateMention: z.string().nullable(),
|
|
688
|
+
inboxId: z.string(),
|
|
689
|
+
status: z.string(),
|
|
690
|
+
source: z.string().nullable().optional(),
|
|
691
|
+
visibility: agentVisibilitySchema,
|
|
692
|
+
metadata: z.record(z.string(), z.unknown()),
|
|
693
|
+
managerId: z.string().nullable(),
|
|
694
|
+
clientId: z.string().nullable(),
|
|
695
|
+
runtimeProvider: runtimeProviderSchema,
|
|
696
|
+
presenceStatus: presenceStatusSchema.optional(),
|
|
697
|
+
createdAt: z.string(),
|
|
698
|
+
updatedAt: z.string()
|
|
699
|
+
});
|
|
700
|
+
z.object({
|
|
701
|
+
repo: z.string().nullable(),
|
|
702
|
+
branch: z.string().nullable()
|
|
703
|
+
});
|
|
704
|
+
/**
|
|
705
|
+
* Server → client WebSocket frame announcing that an agent has just been
|
|
706
|
+
* pinned to the connected client (either created with `clientId` or bound via
|
|
707
|
+
* PATCH NULL → ID). The client can auto-register a local config from this so
|
|
708
|
+
* the operator doesn't have to run `first-tree-hub agent add` manually.
|
|
709
|
+
*/
|
|
710
|
+
const agentPinnedMessageSchema = z.object({
|
|
711
|
+
type: z.literal("agent:pinned"),
|
|
712
|
+
agentId: z.string(),
|
|
713
|
+
name: z.string().nullable(),
|
|
714
|
+
displayName: z.string(),
|
|
715
|
+
agentType: agentTypeSchema,
|
|
716
|
+
runtimeProvider: runtimeProviderSchema
|
|
717
|
+
});
|
|
697
718
|
z.object({
|
|
698
719
|
username: z.string().min(1),
|
|
699
720
|
password: z.string().min(1)
|
|
@@ -994,7 +1015,9 @@ const messageFormatSchema = z.enum([
|
|
|
994
1015
|
"card",
|
|
995
1016
|
"reference",
|
|
996
1017
|
"file",
|
|
997
|
-
"task"
|
|
1018
|
+
"task",
|
|
1019
|
+
"question",
|
|
1020
|
+
"question_answer"
|
|
998
1021
|
]);
|
|
999
1022
|
z.object({
|
|
1000
1023
|
format: messageFormatSchema.default("text"),
|
|
@@ -1217,6 +1240,37 @@ z.object({
|
|
|
1217
1240
|
name: z.string().min(2).max(50).regex(/^[a-z0-9][a-z0-9-]*$/),
|
|
1218
1241
|
displayName: z.string().min(1).max(200)
|
|
1219
1242
|
});
|
|
1243
|
+
z.object({ dismissed: z.boolean().optional() });
|
|
1244
|
+
/**
|
|
1245
|
+
* Body for `POST /me/onboarding/events`. The web SPA reports key
|
|
1246
|
+
* milestones so the server can log them as a single funnel-trackable
|
|
1247
|
+
* stream alongside server-emitted events (`team_created`, `dismissed`).
|
|
1248
|
+
*
|
|
1249
|
+
* Server emits:
|
|
1250
|
+
* - `team_created` — at OAuth callback when joinPath === "solo"
|
|
1251
|
+
* - `dismissed` — when PATCH /me/onboarding flips dismissed
|
|
1252
|
+
*
|
|
1253
|
+
* Web reports:
|
|
1254
|
+
* - `team_renamed` — Step 1 user changed the auto-named team
|
|
1255
|
+
* - `agent_created` — Step 2 successfully created the agent
|
|
1256
|
+
* - `tree_chat_started` — Step 3 [Yes, set it up] succeeded
|
|
1257
|
+
* - `tree_intro_dismissed` — Step 3 [I'll do it later] clicked
|
|
1258
|
+
*/
|
|
1259
|
+
const onboardingEventNameSchema = z.enum([
|
|
1260
|
+
"team_renamed",
|
|
1261
|
+
"agent_created",
|
|
1262
|
+
"tree_chat_started",
|
|
1263
|
+
"tree_intro_dismissed"
|
|
1264
|
+
]);
|
|
1265
|
+
z.object({
|
|
1266
|
+
event: onboardingEventNameSchema,
|
|
1267
|
+
attrs: z.record(z.string(), z.union([
|
|
1268
|
+
z.string(),
|
|
1269
|
+
z.number(),
|
|
1270
|
+
z.boolean(),
|
|
1271
|
+
z.null()
|
|
1272
|
+
])).optional()
|
|
1273
|
+
});
|
|
1220
1274
|
z.object({
|
|
1221
1275
|
id: z.string(),
|
|
1222
1276
|
organizationId: z.string(),
|
|
@@ -1311,12 +1365,26 @@ z.object({
|
|
|
1311
1365
|
* 2. Add a key to `ORG_SETTINGS_NAMESPACES`.
|
|
1312
1366
|
* 3. Done. No DB migration, no new API route.
|
|
1313
1367
|
*/
|
|
1368
|
+
const httpsRepoUrlSchema = z.string().url().refine((value) => {
|
|
1369
|
+
try {
|
|
1370
|
+
return new URL(value).protocol === "https:";
|
|
1371
|
+
} catch {
|
|
1372
|
+
return false;
|
|
1373
|
+
}
|
|
1374
|
+
}, { message: "Repo URL must use HTTPS." }).refine((value) => {
|
|
1375
|
+
try {
|
|
1376
|
+
const url = new URL(value);
|
|
1377
|
+
return url.username.length === 0 && url.password.length === 0;
|
|
1378
|
+
} catch {
|
|
1379
|
+
return false;
|
|
1380
|
+
}
|
|
1381
|
+
}, { message: "Repo URL must not include credentials." });
|
|
1314
1382
|
const orgContextTreeStorageSchema = z.object({
|
|
1315
|
-
repo:
|
|
1383
|
+
repo: httpsRepoUrlSchema.optional(),
|
|
1316
1384
|
branch: z.string().default("main")
|
|
1317
1385
|
});
|
|
1318
1386
|
const orgContextTreeInputSchema = z.object({
|
|
1319
|
-
repo:
|
|
1387
|
+
repo: httpsRepoUrlSchema.nullish(),
|
|
1320
1388
|
branch: z.string().min(1).nullish()
|
|
1321
1389
|
});
|
|
1322
1390
|
const orgContextTreeOutputSchema = z.object({
|
|
@@ -1329,16 +1397,36 @@ const orgGithubIntegrationOutputSchema = z.object({
|
|
|
1329
1397
|
webhookSecretConfigured: z.boolean(),
|
|
1330
1398
|
webhookUrl: z.string()
|
|
1331
1399
|
});
|
|
1400
|
+
const orgSourceReposStorageSchema = z.object({ repos: z.array(z.object({
|
|
1401
|
+
url: httpsRepoUrlSchema,
|
|
1402
|
+
defaultBranch: z.string().optional()
|
|
1403
|
+
})).default([]) });
|
|
1404
|
+
const orgSourceReposInputSchema = z.object({ repos: z.array(z.object({
|
|
1405
|
+
url: httpsRepoUrlSchema,
|
|
1406
|
+
defaultBranch: z.string().min(1).optional()
|
|
1407
|
+
})).optional() });
|
|
1408
|
+
const orgSourceReposOutputSchema = z.object({ repos: z.array(z.object({
|
|
1409
|
+
url: z.string(),
|
|
1410
|
+
defaultBranch: z.string().optional()
|
|
1411
|
+
})) });
|
|
1332
1412
|
const ORG_SETTINGS_NAMESPACES = {
|
|
1333
1413
|
context_tree: {
|
|
1334
1414
|
storage: orgContextTreeStorageSchema,
|
|
1335
1415
|
input: orgContextTreeInputSchema,
|
|
1336
|
-
output: orgContextTreeOutputSchema
|
|
1416
|
+
output: orgContextTreeOutputSchema,
|
|
1417
|
+
readPolicy: "member"
|
|
1337
1418
|
},
|
|
1338
1419
|
github_integration: {
|
|
1339
1420
|
storage: orgGithubIntegrationStorageSchema,
|
|
1340
1421
|
input: orgGithubIntegrationInputSchema,
|
|
1341
|
-
output: orgGithubIntegrationOutputSchema
|
|
1422
|
+
output: orgGithubIntegrationOutputSchema,
|
|
1423
|
+
readPolicy: "admin"
|
|
1424
|
+
},
|
|
1425
|
+
source_repos: {
|
|
1426
|
+
storage: orgSourceReposStorageSchema,
|
|
1427
|
+
input: orgSourceReposInputSchema,
|
|
1428
|
+
output: orgSourceReposOutputSchema,
|
|
1429
|
+
readPolicy: "member"
|
|
1342
1430
|
}
|
|
1343
1431
|
};
|
|
1344
1432
|
const ORG_SETTINGS_NAMESPACE_KEYS = Object.keys(ORG_SETTINGS_NAMESPACES);
|
|
@@ -1377,6 +1465,78 @@ z.object({
|
|
|
1377
1465
|
organizationId: z.string(),
|
|
1378
1466
|
agents: z.record(z.string(), z.array(pulseBucketSchema).length(32))
|
|
1379
1467
|
});
|
|
1468
|
+
/**
|
|
1469
|
+
* Structured ask-user payloads bridged from the Claude Agent SDK
|
|
1470
|
+
* `AskUserQuestion` tool to Hub messages.
|
|
1471
|
+
*
|
|
1472
|
+
* Shape mirrors the SDK 0.2.84 input/output verbatim so the client
|
|
1473
|
+
* runtime adapter can pass `updatedInput` straight through. See
|
|
1474
|
+
* verify scripts under `packages/client/tmp-verify/` for the live
|
|
1475
|
+
* matrix this was validated against.
|
|
1476
|
+
*
|
|
1477
|
+
* Lifecycle:
|
|
1478
|
+
* 1. Agent emits a `format: "question"` message — its `content` is a
|
|
1479
|
+
* `QuestionMessageContent` carrying `correlationId` + `questions[]`.
|
|
1480
|
+
* 2. User picks options in the Web UI and POSTs answers; server writes
|
|
1481
|
+
* a `format: "question_answer"` message — its `content` is a
|
|
1482
|
+
* `QuestionAnswerMessageContent` referencing the same `correlationId`.
|
|
1483
|
+
* 3. Client runtime resolves the in-flight `canUseTool` promise with the
|
|
1484
|
+
* answers, and the SDK feeds them back to the model.
|
|
1485
|
+
*
|
|
1486
|
+
* `pending → answered → superseded` runtime status lives in a separate
|
|
1487
|
+
* server table (`pending_questions`) and is not part of the message —
|
|
1488
|
+
* messages are immutable once written.
|
|
1489
|
+
*/
|
|
1490
|
+
/**
|
|
1491
|
+
* Single option inside a question. `preview` is rich content rendered above
|
|
1492
|
+
* the label — the SDK's tool input emits it as `string | undefined` (the
|
|
1493
|
+
* field is omitted when the model didn't generate any preview content), so
|
|
1494
|
+
* we accept undefined / null / string and normalise downstream renderers
|
|
1495
|
+
* to treat all three the same way.
|
|
1496
|
+
*/
|
|
1497
|
+
const questionOptionSchema = z.object({
|
|
1498
|
+
label: z.string().min(1),
|
|
1499
|
+
description: z.string(),
|
|
1500
|
+
preview: z.string().nullable().optional()
|
|
1501
|
+
});
|
|
1502
|
+
/**
|
|
1503
|
+
* One question. `header` is a chip-style short tag. The SDK schema docs
|
|
1504
|
+
* describe ≤12 chars but in practice the model occasionally emits
|
|
1505
|
+
* slightly longer headers; we keep the rule loose (≤24) so a stylistic
|
|
1506
|
+
* regression doesn't fail-closed at canUseTool and abandon the entire
|
|
1507
|
+
* tool call. The UI truncates visually if needed.
|
|
1508
|
+
*/
|
|
1509
|
+
const questionItemSchema = z.object({
|
|
1510
|
+
question: z.string().min(1),
|
|
1511
|
+
header: z.string().min(1).max(24),
|
|
1512
|
+
options: z.array(questionOptionSchema).min(2).max(4),
|
|
1513
|
+
multiSelect: z.boolean()
|
|
1514
|
+
});
|
|
1515
|
+
/** Session-level preview format hint. Mirrors `toolConfig.askUserQuestion.previewFormat`. */
|
|
1516
|
+
const questionPreviewFormatSchema = z.enum(["html", "markdown"]).nullable();
|
|
1517
|
+
z.object({
|
|
1518
|
+
correlationId: z.string().min(1),
|
|
1519
|
+
questions: z.array(questionItemSchema).min(1).max(4),
|
|
1520
|
+
previewFormat: questionPreviewFormatSchema,
|
|
1521
|
+
allowFreeText: z.boolean()
|
|
1522
|
+
});
|
|
1523
|
+
/**
|
|
1524
|
+
* Content payload for a message whose `format === "question_answer"`.
|
|
1525
|
+
*
|
|
1526
|
+
* `answers` is keyed by `QuestionItem.question` text. For `multiSelect` questions
|
|
1527
|
+
* the value is a `, `-joined string of selected labels (matches SDK convention).
|
|
1528
|
+
* For free-text answers the value is the user's raw input.
|
|
1529
|
+
*/
|
|
1530
|
+
const questionAnswerMessageContentSchema = z.object({
|
|
1531
|
+
correlationId: z.string().min(1),
|
|
1532
|
+
answers: z.record(z.string().min(1), z.string())
|
|
1533
|
+
});
|
|
1534
|
+
z.object({ answers: z.record(z.string().min(1), z.string()) });
|
|
1535
|
+
z.enum([
|
|
1536
|
+
"pending",
|
|
1537
|
+
"answered",
|
|
1538
|
+
"superseded"
|
|
1539
|
+
]);
|
|
1380
1540
|
const sessionEventKind = z.enum([
|
|
1381
1541
|
"tool_call",
|
|
1382
1542
|
"error",
|
|
@@ -1735,6 +1895,13 @@ defineConfig({
|
|
|
1735
1895
|
refreshTokenExpiry: field(z.string().default("30d"), { env: "FIRST_TREE_HUB_AUTH_REFRESH_TOKEN_EXPIRY" }),
|
|
1736
1896
|
connectTokenExpiry: field(z.string().default("10m"), { env: "FIRST_TREE_HUB_AUTH_CONNECT_TOKEN_EXPIRY" })
|
|
1737
1897
|
},
|
|
1898
|
+
contextTreeSync: optional({
|
|
1899
|
+
githubToken: field(z.string(), {
|
|
1900
|
+
env: "FIRST_TREE_HUB_CONTEXT_TREE_GITHUB_TOKEN",
|
|
1901
|
+
secret: true
|
|
1902
|
+
}),
|
|
1903
|
+
githubTokenRepos: field(z.string().optional(), { env: "FIRST_TREE_HUB_CONTEXT_TREE_GITHUB_TOKEN_REPOS" })
|
|
1904
|
+
}),
|
|
1738
1905
|
oauth: optional({ github: optional({
|
|
1739
1906
|
clientId: field(z.string(), { env: "FIRST_TREE_HUB_GITHUB_OAUTH_CLIENT_ID" }),
|
|
1740
1907
|
clientSecret: field(z.string(), {
|
|
@@ -3398,6 +3565,85 @@ function cleanWorkspaces(workspaceRoot, activeChatIds, ttlMs = DEFAULT_WORKSPACE
|
|
|
3398
3565
|
}
|
|
3399
3566
|
return removed;
|
|
3400
3567
|
}
|
|
3568
|
+
const pending = /* @__PURE__ */ new Map();
|
|
3569
|
+
/**
|
|
3570
|
+
* Register a Promise that will resolve when the matching `question_answer`
|
|
3571
|
+
* message arrives. Same `correlationId` re-registration is treated as the
|
|
3572
|
+
* SDK retrying — the previous entry is rejected as superseded so its
|
|
3573
|
+
* `canUseTool` callback unblocks.
|
|
3574
|
+
*/
|
|
3575
|
+
function registerPendingQuestion(args) {
|
|
3576
|
+
const { correlationId, agentId, chatId } = args;
|
|
3577
|
+
const existing = pending.get(correlationId);
|
|
3578
|
+
if (existing) existing.resolve({
|
|
3579
|
+
status: "denied",
|
|
3580
|
+
reason: "Question re-registered with the same correlation id."
|
|
3581
|
+
});
|
|
3582
|
+
return new Promise((resolve) => {
|
|
3583
|
+
pending.set(correlationId, {
|
|
3584
|
+
agentId,
|
|
3585
|
+
chatId,
|
|
3586
|
+
registeredAt: Date.now(),
|
|
3587
|
+
resolve
|
|
3588
|
+
});
|
|
3589
|
+
});
|
|
3590
|
+
}
|
|
3591
|
+
/**
|
|
3592
|
+
* Best-effort resolve. Called by the inbox dispatcher when a
|
|
3593
|
+
* `format: "question_answer"` message arrives. Returns `true` when an entry
|
|
3594
|
+
* matched (so the caller knows it can ack + skip normal session routing),
|
|
3595
|
+
* `false` when there was no waiter (stale answer, e.g. session resumed after
|
|
3596
|
+
* the bridge was already cleaned up).
|
|
3597
|
+
*
|
|
3598
|
+
* Schema validation lives here too — a malformed answer payload is logged
|
|
3599
|
+
* (well, `false` returned and the dispatcher emits a warn) but never throws.
|
|
3600
|
+
*/
|
|
3601
|
+
function tryResolveQuestionAnswer(content) {
|
|
3602
|
+
const parsed = questionAnswerMessageContentSchema.safeParse(content);
|
|
3603
|
+
if (!parsed.success) return false;
|
|
3604
|
+
const entry = pending.get(parsed.data.correlationId);
|
|
3605
|
+
if (!entry) return false;
|
|
3606
|
+
pending.delete(parsed.data.correlationId);
|
|
3607
|
+
entry.resolve({
|
|
3608
|
+
status: "answered",
|
|
3609
|
+
answers: parsed.data.answers
|
|
3610
|
+
});
|
|
3611
|
+
return true;
|
|
3612
|
+
}
|
|
3613
|
+
/** Cleanup hook used by handler.shutdown() to fail-fast every in-flight question. */
|
|
3614
|
+
function rejectPendingForAgent(agentId, reason) {
|
|
3615
|
+
let count = 0;
|
|
3616
|
+
for (const [correlationId, entry] of pending) {
|
|
3617
|
+
if (entry.agentId !== agentId) continue;
|
|
3618
|
+
pending.delete(correlationId);
|
|
3619
|
+
entry.resolve({
|
|
3620
|
+
status: "denied",
|
|
3621
|
+
reason
|
|
3622
|
+
});
|
|
3623
|
+
count++;
|
|
3624
|
+
}
|
|
3625
|
+
return count;
|
|
3626
|
+
}
|
|
3627
|
+
/**
|
|
3628
|
+
* Silent cleanup for `handler.suspend()`. Removes pending entries WITHOUT
|
|
3629
|
+
* resolving the Promise — the SDK process is being torn down anyway, and
|
|
3630
|
+
* resolving would unblock the canUseTool callback whose return value the
|
|
3631
|
+
* SDK then writes to a now-closed transport (the symptom: 'ProcessTransport
|
|
3632
|
+
* is not ready for writing' uncaught). The orphaned Promise stack frame is
|
|
3633
|
+
* GC'd alongside the SDK process exit. When the user eventually answers,
|
|
3634
|
+
* SessionManager.dispatch sees no matching waiter (`tryResolveQuestionAnswer`
|
|
3635
|
+
* returns false) and routes the answer message through the normal dispatch
|
|
3636
|
+
* path so the suspended session resumes with the answer as fresh input.
|
|
3637
|
+
*/
|
|
3638
|
+
function clearPendingForAgent(agentId) {
|
|
3639
|
+
let count = 0;
|
|
3640
|
+
for (const [correlationId, entry] of pending) {
|
|
3641
|
+
if (entry.agentId !== agentId) continue;
|
|
3642
|
+
pending.delete(correlationId);
|
|
3643
|
+
count++;
|
|
3644
|
+
}
|
|
3645
|
+
return count;
|
|
3646
|
+
}
|
|
3401
3647
|
/**
|
|
3402
3648
|
* Resolve which Claude Code binary the SDK should spawn.
|
|
3403
3649
|
*
|
|
@@ -3753,6 +3999,90 @@ const createClaudeCodeHandler = (config) => {
|
|
|
3753
3999
|
recordAppliedPayload(sessionCtx);
|
|
3754
4000
|
consumerDone = consumeOutput(sessionCtx);
|
|
3755
4001
|
}
|
|
4002
|
+
/**
|
|
4003
|
+
* Build the SDK `canUseTool` callback for this session. Auto-allows every
|
|
4004
|
+
* tool except `AskUserQuestion`, which we route through the Hub's inbox:
|
|
4005
|
+
*
|
|
4006
|
+
* 1. Validate the SDK's question shape against the shared Zod schema (so
|
|
4007
|
+
* a malformed model output can't smuggle bad data into Hub messages).
|
|
4008
|
+
* 2. Send a `format: "question"` message via the agent SDK — this hits
|
|
4009
|
+
* the server's `sendMessage` path which writes the `pending_questions`
|
|
4010
|
+
* lifecycle row in the same transaction (see commit 2).
|
|
4011
|
+
* 3. Register a Promise keyed on the SDK `toolUseID`. The matching
|
|
4012
|
+
* `question_answer` message arrives over the inbox WS / poll path
|
|
4013
|
+
* and SessionManager.dispatch resolves the Promise (commit 2 wired
|
|
4014
|
+
* the answer route + supersede hooks; SessionManager wiring lives
|
|
4015
|
+
* in this commit).
|
|
4016
|
+
* 4. Map the bridge result to `PermissionResult`: `answered` →
|
|
4017
|
+
* `{ behavior: "allow", updatedInput: { questions, answers } }`,
|
|
4018
|
+
* `denied` → `{ behavior: "deny", message }` so the model abandons
|
|
4019
|
+
* the call instead of looping.
|
|
4020
|
+
*
|
|
4021
|
+
* `bypassPermissions` mode still calls `canUseTool` for `AskUserQuestion`
|
|
4022
|
+
* specifically — verified by `tmp-verify/verify.mjs` cases A through G.
|
|
4023
|
+
*/
|
|
4024
|
+
function buildAskUserCanUseTool(sessionCtx) {
|
|
4025
|
+
return async (toolName, input, options) => {
|
|
4026
|
+
if (toolName !== "AskUserQuestion") return {
|
|
4027
|
+
behavior: "allow",
|
|
4028
|
+
updatedInput: input
|
|
4029
|
+
};
|
|
4030
|
+
const parsed = z.object({ questions: z.array(questionItemSchema).min(1).max(4) }).safeParse(input);
|
|
4031
|
+
if (!parsed.success) {
|
|
4032
|
+
sessionCtx.log(`AskUserQuestion: malformed input — ${parsed.error.message.slice(0, 200)}`);
|
|
4033
|
+
return {
|
|
4034
|
+
behavior: "deny",
|
|
4035
|
+
message: "AskUserQuestion input did not validate; abandon the question and pick a different tool or answer."
|
|
4036
|
+
};
|
|
4037
|
+
}
|
|
4038
|
+
const correlationId = options.toolUseID;
|
|
4039
|
+
const questions = parsed.data.questions;
|
|
4040
|
+
const questionContent = {
|
|
4041
|
+
correlationId,
|
|
4042
|
+
questions,
|
|
4043
|
+
previewFormat: "html",
|
|
4044
|
+
allowFreeText: true
|
|
4045
|
+
};
|
|
4046
|
+
try {
|
|
4047
|
+
await sessionCtx.sdk.sendMessage(sessionCtx.chatId, {
|
|
4048
|
+
format: "question",
|
|
4049
|
+
content: questionContent
|
|
4050
|
+
});
|
|
4051
|
+
} catch (err) {
|
|
4052
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
4053
|
+
sessionCtx.log(`AskUserQuestion: failed to publish question — ${reason}`);
|
|
4054
|
+
return {
|
|
4055
|
+
behavior: "deny",
|
|
4056
|
+
message: `Hub could not publish the question (${reason}); abandon the call.`
|
|
4057
|
+
};
|
|
4058
|
+
}
|
|
4059
|
+
sessionCtx.log(`AskUserQuestion: published correlationId=${correlationId}; awaiting user answer`);
|
|
4060
|
+
const result = await registerPendingQuestion({
|
|
4061
|
+
correlationId,
|
|
4062
|
+
agentId: sessionCtx.agent.agentId,
|
|
4063
|
+
chatId: sessionCtx.chatId
|
|
4064
|
+
});
|
|
4065
|
+
if (options.signal.aborted) return {
|
|
4066
|
+
behavior: "deny",
|
|
4067
|
+
message: "AskUserQuestion aborted before an answer arrived."
|
|
4068
|
+
};
|
|
4069
|
+
if (result.status === "denied") {
|
|
4070
|
+
sessionCtx.log(`AskUserQuestion: denied correlationId=${correlationId} reason=${result.reason}`);
|
|
4071
|
+
return {
|
|
4072
|
+
behavior: "deny",
|
|
4073
|
+
message: result.reason
|
|
4074
|
+
};
|
|
4075
|
+
}
|
|
4076
|
+
sessionCtx.log(`AskUserQuestion: answered correlationId=${correlationId}`);
|
|
4077
|
+
return {
|
|
4078
|
+
behavior: "allow",
|
|
4079
|
+
updatedInput: {
|
|
4080
|
+
questions,
|
|
4081
|
+
answers: result.answers
|
|
4082
|
+
}
|
|
4083
|
+
};
|
|
4084
|
+
};
|
|
4085
|
+
}
|
|
3756
4086
|
/** Rebuild query and input controller without starting a new consumer loop (used for retry within the existing loop). */
|
|
3757
4087
|
function respawnQuery(sessionId, sessionCtx) {
|
|
3758
4088
|
buildQuery(sessionId, sessionCtx, sessionId);
|
|
@@ -3785,6 +4115,8 @@ const createClaudeCodeHandler = (config) => {
|
|
|
3785
4115
|
allowDangerouslySkipPermissions: true,
|
|
3786
4116
|
settingSources: ["user", "project"],
|
|
3787
4117
|
env: buildEnv(sessionCtx),
|
|
4118
|
+
canUseTool: buildAskUserCanUseTool(sessionCtx),
|
|
4119
|
+
toolConfig: { askUserQuestion: { previewFormat: "html" } },
|
|
3788
4120
|
...claudeCodeExecutable ? { pathToClaudeCodeExecutable: claudeCodeExecutable } : {},
|
|
3789
4121
|
...payload?.model ? { model: payload.model } : {},
|
|
3790
4122
|
...payload?.prompt.append ? { systemPrompt: {
|
|
@@ -4047,6 +4379,11 @@ const createClaudeCodeHandler = (config) => {
|
|
|
4047
4379
|
},
|
|
4048
4380
|
async suspend() {
|
|
4049
4381
|
ctx?.log("Suspending session");
|
|
4382
|
+
const sessionCtx = ctx;
|
|
4383
|
+
if (sessionCtx) {
|
|
4384
|
+
const dropped = clearPendingForAgent(sessionCtx.agent.agentId);
|
|
4385
|
+
if (dropped > 0) sessionCtx.log(`Cleared ${dropped} pending AskUserQuestion entries on suspend`);
|
|
4386
|
+
}
|
|
4050
4387
|
if (inputController) {
|
|
4051
4388
|
inputController.end();
|
|
4052
4389
|
inputController = null;
|
|
@@ -4064,6 +4401,10 @@ const createClaudeCodeHandler = (config) => {
|
|
|
4064
4401
|
async shutdown() {
|
|
4065
4402
|
const sessionCtx = ctx;
|
|
4066
4403
|
await handler.suspend();
|
|
4404
|
+
if (sessionCtx) {
|
|
4405
|
+
const dropped = rejectPendingForAgent(sessionCtx.agent.agentId, "Session shutting down.");
|
|
4406
|
+
if (dropped > 0) sessionCtx.log(`Rejected ${dropped} pending AskUserQuestion entries during shutdown`);
|
|
4407
|
+
}
|
|
4067
4408
|
if (sessionCtx) await cleanupGitWorktrees(sessionCtx);
|
|
4068
4409
|
if (cwd) {
|
|
4069
4410
|
try {
|
|
@@ -4742,8 +5083,34 @@ function resolveSenderLabel(senderId, participants) {
|
|
|
4742
5083
|
* Async because the participant list may need a server round-trip on first
|
|
4743
5084
|
* use; subsequent messages in the same session hit the cache.
|
|
4744
5085
|
*/
|
|
5086
|
+
/**
|
|
5087
|
+
* Convert a SessionMessage's payload to a plain-text snippet the LLM can
|
|
5088
|
+
* read as user input. Most formats are already strings; the special case
|
|
5089
|
+
* is `question_answer` — when an answer arrives AFTER the SDK process was
|
|
5090
|
+
* suspended, SessionManager.dispatch routes it through the normal path,
|
|
5091
|
+
* and we need to render the structured `{correlationId, answers}` payload
|
|
5092
|
+
* as readable English so the resumed Claude turn can act on it.
|
|
5093
|
+
*/
|
|
5094
|
+
function renderForLLM(message) {
|
|
5095
|
+
if (message.format === "question_answer" && message.content && typeof message.content === "object") {
|
|
5096
|
+
const c = message.content;
|
|
5097
|
+
if (c.answers && typeof c.answers === "object") {
|
|
5098
|
+
const lines = [];
|
|
5099
|
+
for (const [question, answer] of Object.entries(c.answers)) {
|
|
5100
|
+
lines.push(`Q: ${question}`);
|
|
5101
|
+
lines.push(`A: ${answer}`);
|
|
5102
|
+
}
|
|
5103
|
+
return [
|
|
5104
|
+
"[The user has answered an earlier AskUserQuestion you raised. Continue the task using their answers below; do NOT call AskUserQuestion again for the same questions.]",
|
|
5105
|
+
"",
|
|
5106
|
+
...lines
|
|
5107
|
+
].join("\n");
|
|
5108
|
+
}
|
|
5109
|
+
}
|
|
5110
|
+
return typeof message.content === "string" ? message.content : JSON.stringify(message.content);
|
|
5111
|
+
}
|
|
4745
5112
|
async function formatInboundContent(message, participants) {
|
|
4746
|
-
const rawContent =
|
|
5113
|
+
const rawContent = renderForLLM(message);
|
|
4747
5114
|
const preceding = message.precedingMessages ?? [];
|
|
4748
5115
|
let header = "";
|
|
4749
5116
|
if (preceding.length > 0) {
|
|
@@ -4946,6 +5313,16 @@ var SessionManager = class {
|
|
|
4946
5313
|
async dispatch(entry) {
|
|
4947
5314
|
const chatId = entry.chatId ?? entry.message.chatId;
|
|
4948
5315
|
const messageId = entry.message.id;
|
|
5316
|
+
if (entry.message.format === "question_answer") {
|
|
5317
|
+
if (tryResolveQuestionAnswer(entry.message.content)) {
|
|
5318
|
+
await this.ackEntry(entry.id, chatId);
|
|
5319
|
+
return;
|
|
5320
|
+
}
|
|
5321
|
+
this.config.log.info({
|
|
5322
|
+
chatId,
|
|
5323
|
+
messageId
|
|
5324
|
+
}, "question_answer with no live bridge waiter — resuming session with answer as input");
|
|
5325
|
+
}
|
|
4949
5326
|
const dedupKey = `${chatId}:${messageId}`;
|
|
4950
5327
|
if (this.deduplicator.isDuplicate(dedupKey)) {
|
|
4951
5328
|
this.config.log.debug({
|
|
@@ -8303,7 +8680,7 @@ async function onboardCreate(args) {
|
|
|
8303
8680
|
}
|
|
8304
8681
|
const runtimeAgent = args.type === "human" ? args.assistant : args.id;
|
|
8305
8682
|
if (args.feishuBotAppId && args.feishuBotAppSecret) {
|
|
8306
|
-
const { bindFeishuBot } = await import("./feishu-
|
|
8683
|
+
const { bindFeishuBot } = await import("./feishu-C6qlhju2.mjs").then((n) => n.r);
|
|
8307
8684
|
const targetAgentUuid = args.type === "human" ? assistantUuid : primary.uuid;
|
|
8308
8685
|
if (!targetAgentUuid) print.line(`Warning: Cannot bind Feishu bot — no runtime agent available for "${args.id}".\n`);
|
|
8309
8686
|
else {
|
|
@@ -9516,7 +9893,7 @@ function createFeedbackHandler(config) {
|
|
|
9516
9893
|
return { handle };
|
|
9517
9894
|
}
|
|
9518
9895
|
//#endregion
|
|
9519
|
-
//#region ../server/dist/app-
|
|
9896
|
+
//#region ../server/dist/app-CVe_gn9M.mjs
|
|
9520
9897
|
var import_fastify_opentelemetry = /* @__PURE__ */ __toESM(require_fastify_opentelemetry(), 1);
|
|
9521
9898
|
init_esm();
|
|
9522
9899
|
var __defProp = Object.defineProperty;
|
|
@@ -9540,53 +9917,6 @@ const adapterAgentMappings = pgTable("adapter_agent_mappings", {
|
|
|
9540
9917
|
metadata: jsonb("metadata").$type().notNull().default({}),
|
|
9541
9918
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
|
|
9542
9919
|
}, (table) => [unique("uq_adapter_agent_mapping").on(table.platform, table.externalUserId)]);
|
|
9543
|
-
/** Communication container. All messages between agents flow within a Chat. */
|
|
9544
|
-
const chats = pgTable("chats", {
|
|
9545
|
-
id: text("id").primaryKey(),
|
|
9546
|
-
organizationId: text("organization_id").notNull().references(() => organizations.id),
|
|
9547
|
-
type: text("type").notNull().default("direct"),
|
|
9548
|
-
topic: text("topic"),
|
|
9549
|
-
lifecyclePolicy: text("lifecycle_policy").default("persistent"),
|
|
9550
|
-
parentChatId: text("parent_chat_id"),
|
|
9551
|
-
metadata: jsonb("metadata").$type().notNull().default({}),
|
|
9552
|
-
lastMessageAt: timestamp("last_message_at", { withTimezone: true }),
|
|
9553
|
-
lastMessagePreview: text("last_message_preview"),
|
|
9554
|
-
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
9555
|
-
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
|
|
9556
|
-
}, (table) => [index("idx_chats_org_last_message").on(table.organizationId, desc(table.lastMessageAt))]);
|
|
9557
|
-
/** Speaking participants of a chat (M:N). Watchers live in chat_subscriptions. */
|
|
9558
|
-
const chatParticipants = pgTable("chat_participants", {
|
|
9559
|
-
chatId: text("chat_id").notNull().references(() => chats.id, { onDelete: "cascade" }),
|
|
9560
|
-
agentId: text("agent_id").notNull().references(() => agents.uuid),
|
|
9561
|
-
role: text("role").notNull().default("member"),
|
|
9562
|
-
mode: text("mode").notNull().default("full"),
|
|
9563
|
-
lastReadAt: timestamp("last_read_at", { withTimezone: true }),
|
|
9564
|
-
unreadMentionCount: integer("unread_mention_count").notNull().default(0),
|
|
9565
|
-
joinedAt: timestamp("joined_at", { withTimezone: true }).notNull().defaultNow()
|
|
9566
|
-
}, (table) => [primaryKey({ columns: [table.chatId, table.agentId] }), index("idx_participants_agent").on(table.agentId)]);
|
|
9567
|
-
/**
|
|
9568
|
-
* Non-speaking observers ("watchers"). Used by the chat-first workspace so a
|
|
9569
|
-
* user can supervise chats their managed agents participate in without
|
|
9570
|
-
* accidentally being part of fan-out.
|
|
9571
|
-
*
|
|
9572
|
-
* Invariants:
|
|
9573
|
-
* 1. (chat_id, agent_id) is mutually exclusive with chat_participants.
|
|
9574
|
-
* 2. Rows here NEVER produce inbox_entries (fan-out exclusivity).
|
|
9575
|
-
* 3. Mention candidate resolution NEVER includes these rows.
|
|
9576
|
-
* 4. State transitions (join/leave) carry last_read_at + counter; lifecycle
|
|
9577
|
-
* recomputes default to NULL/0 and MUST NOT run on the join/leave path.
|
|
9578
|
-
*
|
|
9579
|
-
* See docs/chat-first-workspace-product-design.md "Data Model" + "State
|
|
9580
|
-
* Transitions" for the full contract.
|
|
9581
|
-
*/
|
|
9582
|
-
const chatSubscriptions = pgTable("chat_subscriptions", {
|
|
9583
|
-
chatId: text("chat_id").notNull().references(() => chats.id, { onDelete: "cascade" }),
|
|
9584
|
-
agentId: text("agent_id").notNull().references(() => agents.uuid),
|
|
9585
|
-
kind: text("kind").notNull().default("watching"),
|
|
9586
|
-
lastReadAt: timestamp("last_read_at", { withTimezone: true }),
|
|
9587
|
-
unreadMentionCount: integer("unread_mention_count").notNull().default(0),
|
|
9588
|
-
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
|
|
9589
|
-
}, (table) => [primaryKey({ columns: [table.chatId, table.agentId] }), index("idx_chat_subscriptions_agent").on(table.agentId)]);
|
|
9590
9920
|
/**
|
|
9591
9921
|
* Tasks — lightweight work units. Process descriptors, not tickets.
|
|
9592
9922
|
* Immutable status state machine: pending → assigned → working → (completed | failed | cancelled).
|
|
@@ -9986,705 +10316,92 @@ async function createAdapterConfig(db, data, encryptionKey) {
|
|
|
9986
10316
|
const encrypted = encryptCredentials(data.credentials, key);
|
|
9987
10317
|
try {
|
|
9988
10318
|
const [row] = await db.insert(adapterConfigs).values({
|
|
9989
|
-
platform: data.platform,
|
|
9990
|
-
agentId: data.agentId,
|
|
9991
|
-
credentials: encrypted,
|
|
9992
|
-
status: data.status ?? "active"
|
|
9993
|
-
}).returning();
|
|
9994
|
-
if (!row) throw new Error("Unexpected: INSERT RETURNING produced no row");
|
|
9995
|
-
return toResponse(row);
|
|
9996
|
-
} catch (err) {
|
|
9997
|
-
if ((err?.code ?? err?.cause?.code ?? "") === "23505") throw new ConflictError(`Agent "${data.agentId}" already has a ${data.platform} adapter config`);
|
|
9998
|
-
throw err;
|
|
9999
|
-
}
|
|
10000
|
-
}
|
|
10001
|
-
async function updateAdapterConfig(db, id, data, encryptionKey) {
|
|
10002
|
-
const setClause = { updatedAt: /* @__PURE__ */ new Date() };
|
|
10003
|
-
if (data.agentId !== void 0) {
|
|
10004
|
-
await validateAgentId(db, data.agentId);
|
|
10005
|
-
setClause.agentId = data.agentId;
|
|
10006
|
-
}
|
|
10007
|
-
if (data.status !== void 0) setClause.status = data.status;
|
|
10008
|
-
if (data.credentials !== void 0) {
|
|
10009
|
-
const key = requireEncryptionKey(encryptionKey);
|
|
10010
|
-
setClause.credentials = encryptCredentials(data.credentials, key);
|
|
10011
|
-
}
|
|
10012
|
-
const [row] = await db.update(adapterConfigs).set(setClause).where(eq(adapterConfigs.id, id)).returning();
|
|
10013
|
-
if (!row) throw new NotFoundError(`Adapter config "${id}" not found`);
|
|
10014
|
-
return toResponse(row);
|
|
10015
|
-
}
|
|
10016
|
-
async function deleteAdapterConfig(db, id) {
|
|
10017
|
-
const [row] = await db.delete(adapterConfigs).where(eq(adapterConfigs.id, id)).returning();
|
|
10018
|
-
if (!row) throw new NotFoundError(`Adapter config "${id}" not found`);
|
|
10019
|
-
}
|
|
10020
|
-
const log$7 = createLogger$1("Adapters");
|
|
10021
|
-
function parseId(raw) {
|
|
10022
|
-
const id = Number(raw);
|
|
10023
|
-
if (!Number.isInteger(id) || id <= 0) throw new BadRequestError(`Invalid adapter ID: "${raw}"`);
|
|
10024
|
-
return id;
|
|
10025
|
-
}
|
|
10026
|
-
/** Class C — `/api/v1/adapters/:id`. */
|
|
10027
|
-
async function adapterRoutes(app) {
|
|
10028
|
-
app.get("/:id", async (request) => {
|
|
10029
|
-
const { userId } = requireUser(request);
|
|
10030
|
-
const id = parseId(request.params.id);
|
|
10031
|
-
const config = await getAdapterConfig(app.db, id);
|
|
10032
|
-
await assertAgentManageableByUser(app.db, userId, config.agentId);
|
|
10033
|
-
return {
|
|
10034
|
-
...config,
|
|
10035
|
-
createdAt: config.createdAt.toISOString(),
|
|
10036
|
-
updatedAt: config.updatedAt.toISOString()
|
|
10037
|
-
};
|
|
10038
|
-
});
|
|
10039
|
-
app.patch("/:id", { config: { otelRecordBody: true } }, async (request) => {
|
|
10040
|
-
const { userId } = requireUser(request);
|
|
10041
|
-
const id = parseId(request.params.id);
|
|
10042
|
-
const body = updateAdapterConfigSchema.parse(request.body);
|
|
10043
|
-
const existing = await getAdapterConfig(app.db, id);
|
|
10044
|
-
await assertAgentManageableByUser(app.db, userId, existing.agentId);
|
|
10045
|
-
const config = await updateAdapterConfig(app.db, id, body, app.config.secrets.encryptionKey);
|
|
10046
|
-
app.adapterManager.reload().catch((err) => log$7.error({ err }, "adapter reload failed after update"));
|
|
10047
|
-
app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
|
|
10048
|
-
return {
|
|
10049
|
-
...config,
|
|
10050
|
-
createdAt: config.createdAt.toISOString(),
|
|
10051
|
-
updatedAt: config.updatedAt.toISOString()
|
|
10052
|
-
};
|
|
10053
|
-
});
|
|
10054
|
-
app.delete("/:id", async (request, reply) => {
|
|
10055
|
-
const { userId } = requireUser(request);
|
|
10056
|
-
const id = parseId(request.params.id);
|
|
10057
|
-
const existing = await getAdapterConfig(app.db, id);
|
|
10058
|
-
await assertAgentManageableByUser(app.db, userId, existing.agentId);
|
|
10059
|
-
await deleteAdapterConfig(app.db, id);
|
|
10060
|
-
app.adapterManager.reload().catch((err) => log$7.error({ err }, "adapter reload failed after delete"));
|
|
10061
|
-
app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
|
|
10062
|
-
return reply.status(204).send();
|
|
10063
|
-
});
|
|
10064
|
-
}
|
|
10065
|
-
/**
|
|
10066
|
-
* Pull the agent identity populated by `agentSelectorHook` off the
|
|
10067
|
-
* request. The hook runs before any handler and assigns `request.agent`,
|
|
10068
|
-
* but the optional shape is what fastify exposes to consumers — narrowing
|
|
10069
|
-
* it here keeps every Class D handler clean of `if (!request.agent)`.
|
|
10070
|
-
*/
|
|
10071
|
-
function requireAgent(request) {
|
|
10072
|
-
const agent = request.agent;
|
|
10073
|
-
if (!agent) throw new UnauthorizedError("Agent authentication required");
|
|
10074
|
-
return agent;
|
|
10075
|
-
}
|
|
10076
|
-
/**
|
|
10077
|
-
* Process-local cache for the per-chat realtime push audience
|
|
10078
|
-
* (`chat_participants ∪ chat_subscriptions`, keyed by human agent
|
|
10079
|
-
* uuid). Sits in front of the admin WS dispatch so a chat with N
|
|
10080
|
-
* messages/sec doesn't issue N audience-resolution queries; one query
|
|
10081
|
-
* + cache hit per chat per TTL window.
|
|
10082
|
-
*
|
|
10083
|
-
* The cache exposes both a populator (`getCachedAudience`) and an
|
|
10084
|
-
* invalidator (`invalidateChatAudience`). Participant-mutation paths
|
|
10085
|
-
* (`addMeChatParticipants`, `joinMeChat`, `leaveMeChat`,
|
|
10086
|
-
* `recomputeChatWatchers`, `joinAsParticipant`, `leaveAsParticipant`)
|
|
10087
|
-
* MUST call `invalidateChatAudience` after their tx commits so the
|
|
10088
|
-
* very next dispatch reflects the new audience without waiting for
|
|
10089
|
-
* the TTL to age out — without invalidation, a freshly-added speaker
|
|
10090
|
-
* would miss `chat:message` pushes for up to TTL_MS.
|
|
10091
|
-
*
|
|
10092
|
-
* Cross-instance correctness: not handled here. The PG NOTIFY layer
|
|
10093
|
-
* already broadcasts message events to every replica; each replica's
|
|
10094
|
-
* audience cache is independently invalidated by its own
|
|
10095
|
-
* service-layer mutations on chats it routes traffic for. For
|
|
10096
|
-
* cross-replica participant changes to invalidate this cache, route
|
|
10097
|
-
* the mutation through the same replica that hosts the WS connection
|
|
10098
|
-
* (sticky routing) or add a dedicated `chat:audience` PG NOTIFY in
|
|
10099
|
-
* a follow-up.
|
|
10100
|
-
*/
|
|
10101
|
-
const log$6 = createLogger$1("ChatAudienceCache");
|
|
10102
|
-
const TTL_MS = 5e3;
|
|
10103
|
-
const MAX_ENTRIES = 1024;
|
|
10104
|
-
const cache = /* @__PURE__ */ new Map();
|
|
10105
|
-
/** Resolve a chat's push audience, hitting the cache when fresh.
|
|
10106
|
-
* Returns null on DB error (caller should skip dispatch). */
|
|
10107
|
-
async function getCachedAudience(db, chatId) {
|
|
10108
|
-
const now = Date.now();
|
|
10109
|
-
const cached = cache.get(chatId);
|
|
10110
|
-
if (cached && cached.expiresAt > now) return cached.audience;
|
|
10111
|
-
try {
|
|
10112
|
-
const rows = await db.execute(sql`
|
|
10113
|
-
SELECT agent_id FROM chat_participants WHERE chat_id = ${chatId}
|
|
10114
|
-
UNION
|
|
10115
|
-
SELECT agent_id FROM chat_subscriptions WHERE chat_id = ${chatId}
|
|
10116
|
-
`);
|
|
10117
|
-
const audience = new Set(rows.map((r) => r.agent_id));
|
|
10118
|
-
cache.set(chatId, {
|
|
10119
|
-
audience,
|
|
10120
|
-
expiresAt: now + TTL_MS
|
|
10121
|
-
});
|
|
10122
|
-
if (cache.size > MAX_ENTRIES) {
|
|
10123
|
-
for (const [k, v] of cache) if (v.expiresAt <= now) cache.delete(k);
|
|
10124
|
-
}
|
|
10125
|
-
return audience;
|
|
10126
|
-
} catch (err) {
|
|
10127
|
-
log$6.warn({
|
|
10128
|
-
err,
|
|
10129
|
-
chatId
|
|
10130
|
-
}, "failed to resolve chat audience");
|
|
10131
|
-
return null;
|
|
10132
|
-
}
|
|
10133
|
-
}
|
|
10134
|
-
/** Drop the cached audience for a chat. Called from participant-
|
|
10135
|
-
* mutation paths after their transaction commits, so the next
|
|
10136
|
-
* `chat:message` dispatch hits the DB and reflects the new
|
|
10137
|
-
* membership instead of serving a stale TTL window. */
|
|
10138
|
-
function invalidateChatAudience(chatId) {
|
|
10139
|
-
cache.delete(chatId);
|
|
10140
|
-
}
|
|
10141
|
-
/**
|
|
10142
|
-
* Chat-first workspace — watcher subscription helpers.
|
|
10143
|
-
*
|
|
10144
|
-
* Watchers (rows in `chat_subscriptions`) are non-speaking observers. A
|
|
10145
|
-
* member who manages an agent that participates in a chat — but whose own
|
|
10146
|
-
* human agent is not a speaker there — sees the chat in their workspace
|
|
10147
|
-
* via a watcher row.
|
|
10148
|
-
*
|
|
10149
|
-
* Two distinct kinds of operation live here:
|
|
10150
|
-
*
|
|
10151
|
-
* 1. Set rebuilds (`recompute*`). Idempotent set-based recomputations
|
|
10152
|
-
* driven by lifecycle events (chat created, participant added/removed,
|
|
10153
|
-
* member status flipped, etc.). These DEFAULT new rows to NULL/0 read
|
|
10154
|
-
* state.
|
|
10155
|
-
*
|
|
10156
|
-
* 2. State-carry transitions (`joinAsParticipant`, `leaveAsParticipant`).
|
|
10157
|
-
* Move a single (chat, agent) pair between `chat_participants` and
|
|
10158
|
-
* `chat_subscriptions` while preserving `last_read_at` and
|
|
10159
|
-
* `unread_mention_count`. NEVER call recompute on this path or you'll
|
|
10160
|
-
* lose read state.
|
|
10161
|
-
*
|
|
10162
|
-
* See docs/chat-first-workspace-product-design.md "State Transitions" and
|
|
10163
|
-
* "Risk Constraints".
|
|
10164
|
-
*/
|
|
10165
|
-
/**
|
|
10166
|
-
* Recompute watcher rows for ONE chat. For every active member who:
|
|
10167
|
-
* - manages a non-human agent that speaks in the chat, AND
|
|
10168
|
-
* - whose own human agent is NOT a speaker in the chat
|
|
10169
|
-
* an `(chat_id, member.agent_id)` watcher row is upserted (NULL read state).
|
|
10170
|
-
*
|
|
10171
|
-
* Watchers whose anchoring condition no longer holds (manager left, the
|
|
10172
|
-
* managed agent was removed from the chat, the manager joined as a speaker
|
|
10173
|
-
* themselves) are deleted.
|
|
10174
|
-
*
|
|
10175
|
-
* Idempotent: safe to call multiple times for the same chat.
|
|
10176
|
-
*/
|
|
10177
|
-
async function recomputeChatWatchers(db, chatId) {
|
|
10178
|
-
await db.execute(sql`
|
|
10179
|
-
INSERT INTO chat_subscriptions
|
|
10180
|
-
(chat_id, agent_id, kind, last_read_at, unread_mention_count, created_at)
|
|
10181
|
-
SELECT DISTINCT cp.chat_id, m.agent_id, 'watching', NULL::timestamp with time zone, 0, now()
|
|
10182
|
-
FROM chat_participants cp
|
|
10183
|
-
JOIN agents a ON a.uuid = cp.agent_id
|
|
10184
|
-
JOIN members m ON m.id = a.manager_id
|
|
10185
|
-
WHERE cp.chat_id = ${chatId}
|
|
10186
|
-
AND m.status = 'active'
|
|
10187
|
-
AND a.type <> 'human'
|
|
10188
|
-
AND NOT EXISTS (
|
|
10189
|
-
SELECT 1 FROM chat_participants cp2
|
|
10190
|
-
WHERE cp2.chat_id = cp.chat_id
|
|
10191
|
-
AND cp2.agent_id = m.agent_id
|
|
10192
|
-
)
|
|
10193
|
-
ON CONFLICT (chat_id, agent_id) DO NOTHING
|
|
10194
|
-
`);
|
|
10195
|
-
await db.execute(sql`
|
|
10196
|
-
DELETE FROM chat_subscriptions cs
|
|
10197
|
-
WHERE cs.chat_id = ${chatId}
|
|
10198
|
-
AND NOT EXISTS (
|
|
10199
|
-
SELECT 1
|
|
10200
|
-
FROM chat_participants cp
|
|
10201
|
-
JOIN agents a ON a.uuid = cp.agent_id
|
|
10202
|
-
JOIN members m ON m.id = a.manager_id
|
|
10203
|
-
WHERE cp.chat_id = cs.chat_id
|
|
10204
|
-
AND m.agent_id = cs.agent_id
|
|
10205
|
-
AND m.status = 'active'
|
|
10206
|
-
AND a.type <> 'human'
|
|
10207
|
-
AND NOT EXISTS (
|
|
10208
|
-
SELECT 1 FROM chat_participants cp2
|
|
10209
|
-
WHERE cp2.chat_id = cp.chat_id
|
|
10210
|
-
AND cp2.agent_id = m.agent_id
|
|
10211
|
-
)
|
|
10212
|
-
)
|
|
10213
|
-
`);
|
|
10214
|
-
}
|
|
10215
|
-
/**
|
|
10216
|
-
* Recompute watcher rows touching ONE agent across all chats it speaks in.
|
|
10217
|
-
* Used after `rebindAgent` (manager change) so the new manager picks up
|
|
10218
|
-
* watcher rows and the old manager's are dropped.
|
|
10219
|
-
*/
|
|
10220
|
-
async function recomputeWatchersForAgent(db, agentId) {
|
|
10221
|
-
const chatRows = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(eq(chatParticipants.agentId, agentId));
|
|
10222
|
-
for (const { chatId } of chatRows) await recomputeChatWatchers(db, chatId);
|
|
10223
|
-
}
|
|
10224
|
-
/**
|
|
10225
|
-
* Recompute watcher rows touching ONE member across all chats. Triggered
|
|
10226
|
-
* when the member's status flips active ↔ left.
|
|
10227
|
-
*/
|
|
10228
|
-
async function recomputeWatchersForMember(db, memberId) {
|
|
10229
|
-
const rows = await db.selectDistinct({ chatId: chatParticipants.chatId }).from(chatParticipants).innerJoin(agents, eq(chatParticipants.agentId, agents.uuid)).where(and(eq(agents.managerId, memberId), ne(agents.type, "human")));
|
|
10230
|
-
for (const { chatId } of rows) await recomputeChatWatchers(db, chatId);
|
|
10231
|
-
}
|
|
10232
|
-
/**
|
|
10233
|
-
* Mirror of `services/chat.ts` `maybeUpgradeDirectToGroup`. Inlined here so
|
|
10234
|
-
* `joinAsParticipant` keeps the upgrade rule + the state carry in one
|
|
10235
|
-
* transaction without depending on chat.ts (avoids a circular import).
|
|
10236
|
-
*/
|
|
10237
|
-
async function maybeUpgradeDirectToGroup$1(tx, chatId, existingParticipantIds) {
|
|
10238
|
-
if (existingParticipantIds.length + 1 < 3) return;
|
|
10239
|
-
const [chat] = await tx.select({ type: chats.type }).from(chats).where(eq(chats.id, chatId)).limit(1);
|
|
10240
|
-
if (!chat || chat.type !== "direct") return;
|
|
10241
|
-
await tx.update(chats).set({
|
|
10242
|
-
type: "group",
|
|
10243
|
-
updatedAt: /* @__PURE__ */ new Date()
|
|
10244
|
-
}).where(eq(chats.id, chatId));
|
|
10245
|
-
if (existingParticipantIds.length === 0) return;
|
|
10246
|
-
const ids = (await tx.select({ uuid: agents.uuid }).from(agents).where(and(inArray(agents.uuid, existingParticipantIds), ne(agents.type, "human")))).map((r) => r.uuid);
|
|
10247
|
-
if (ids.length === 0) return;
|
|
10248
|
-
await tx.update(chatParticipants).set({ mode: "mention_only" }).where(and(eq(chatParticipants.chatId, chatId), inArray(chatParticipants.agentId, ids)));
|
|
10249
|
-
}
|
|
10250
|
-
/**
|
|
10251
|
-
* Watcher → speaking participant. State-carry transaction.
|
|
10252
|
-
*
|
|
10253
|
-
* 1. DELETE the watcher row (returning read state).
|
|
10254
|
-
* 2. If a participant row already exists, no-op (idempotent).
|
|
10255
|
-
* 3. Otherwise, run the direct → group upgrade rule against the *current*
|
|
10256
|
-
* participant set, then INSERT the participant row carrying read state.
|
|
10257
|
-
*
|
|
10258
|
-
* If `requireWatcherOrVisible` is true, refuse when the user has neither a
|
|
10259
|
-
* watcher row nor admin-derived visibility — used to keep the public
|
|
10260
|
-
* `/me/chats/:chatId/join` endpoint honest. Pre-check happens in the
|
|
10261
|
-
* route layer where we have the full member scope.
|
|
10262
|
-
*/
|
|
10263
|
-
async function joinAsParticipant(db, chatId, humanAgentId) {
|
|
10264
|
-
return db.transaction(async (tx) => {
|
|
10265
|
-
const [carriedRow] = await tx.delete(chatSubscriptions).where(and(eq(chatSubscriptions.chatId, chatId), eq(chatSubscriptions.agentId, humanAgentId))).returning({
|
|
10266
|
-
lastReadAt: chatSubscriptions.lastReadAt,
|
|
10267
|
-
unreadMentionCount: chatSubscriptions.unreadMentionCount
|
|
10268
|
-
});
|
|
10269
|
-
const [existing] = await tx.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, humanAgentId))).limit(1);
|
|
10270
|
-
if (existing) return {
|
|
10271
|
-
chatId,
|
|
10272
|
-
inserted: false,
|
|
10273
|
-
carried: carriedRow ?? null
|
|
10274
|
-
};
|
|
10275
|
-
await maybeUpgradeDirectToGroup$1(tx, chatId, (await tx.select({ agentId: chatParticipants.agentId }).from(chatParticipants).where(eq(chatParticipants.chatId, chatId))).map((p) => p.agentId));
|
|
10276
|
-
await tx.insert(chatParticipants).values({
|
|
10277
|
-
chatId,
|
|
10278
|
-
agentId: humanAgentId,
|
|
10279
|
-
role: "member",
|
|
10280
|
-
mode: "full",
|
|
10281
|
-
lastReadAt: carriedRow?.lastReadAt ?? null,
|
|
10282
|
-
unreadMentionCount: carriedRow?.unreadMentionCount ?? 0
|
|
10283
|
-
});
|
|
10284
|
-
return {
|
|
10285
|
-
chatId,
|
|
10286
|
-
inserted: true,
|
|
10287
|
-
carried: carriedRow ?? null
|
|
10288
|
-
};
|
|
10289
|
-
});
|
|
10290
|
-
}
|
|
10291
|
-
/**
|
|
10292
|
-
* Speaking participant → watcher (or fully detach).
|
|
10293
|
-
*
|
|
10294
|
-
* 1. DELETE the participant row (returning read state).
|
|
10295
|
-
* 2. Test "still visible": is the user still the manager of an agent that
|
|
10296
|
-
* remains a participant in this chat? If yes, INSERT a watcher row
|
|
10297
|
-
* carrying read state. If no, drop entirely.
|
|
10298
|
-
*
|
|
10299
|
-
* Caller must validate that the user actually has a participant row to
|
|
10300
|
-
* leave (returns `NotFoundError` if not).
|
|
10301
|
-
*/
|
|
10302
|
-
async function leaveAsParticipant(db, chatId, humanAgentId) {
|
|
10303
|
-
return db.transaction(async (tx) => {
|
|
10304
|
-
const [carried] = await tx.delete(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, humanAgentId))).returning({
|
|
10305
|
-
lastReadAt: chatParticipants.lastReadAt,
|
|
10306
|
-
unreadMentionCount: chatParticipants.unreadMentionCount
|
|
10307
|
-
});
|
|
10308
|
-
if (!carried) throw new NotFoundError("Not a participant of this chat");
|
|
10309
|
-
const [stillVisibleRow] = await tx.execute(sql`
|
|
10310
|
-
SELECT EXISTS (
|
|
10311
|
-
SELECT 1
|
|
10312
|
-
FROM chat_participants cp
|
|
10313
|
-
JOIN agents a ON a.uuid = cp.agent_id
|
|
10314
|
-
JOIN members m ON m.id = a.manager_id
|
|
10315
|
-
WHERE cp.chat_id = ${chatId}
|
|
10316
|
-
AND m.agent_id = ${humanAgentId}
|
|
10317
|
-
AND m.status = 'active'
|
|
10318
|
-
AND a.type <> 'human'
|
|
10319
|
-
) AS visible
|
|
10320
|
-
`);
|
|
10321
|
-
if (!Boolean(stillVisibleRow?.visible)) return {
|
|
10322
|
-
chatId,
|
|
10323
|
-
membershipKind: null
|
|
10324
|
-
};
|
|
10325
|
-
await tx.insert(chatSubscriptions).values({
|
|
10326
|
-
chatId,
|
|
10327
|
-
agentId: humanAgentId,
|
|
10328
|
-
kind: "watching",
|
|
10329
|
-
lastReadAt: carried.lastReadAt,
|
|
10330
|
-
unreadMentionCount: carried.unreadMentionCount
|
|
10331
|
-
}).onConflictDoNothing();
|
|
10332
|
-
return {
|
|
10333
|
-
chatId,
|
|
10334
|
-
membershipKind: "watching"
|
|
10335
|
-
};
|
|
10336
|
-
});
|
|
10337
|
-
}
|
|
10338
|
-
/**
|
|
10339
|
-
* Resolve the membership row of the human agent for the given chat. Returns
|
|
10340
|
-
* one of: 'participant', 'watching', or null.
|
|
10341
|
-
*
|
|
10342
|
-
* Used by `/me/chats/:chatId/join` to refuse a join when the user has
|
|
10343
|
-
* neither a watcher row nor a participant row, and isn't otherwise
|
|
10344
|
-
* authorised (admin in the chat's org).
|
|
10345
|
-
*/
|
|
10346
|
-
async function resolveChatMembership(db, chatId, humanAgentId) {
|
|
10347
|
-
const [participant] = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, humanAgentId))).limit(1);
|
|
10348
|
-
if (participant) return "participant";
|
|
10349
|
-
const [sub] = await db.select({ chatId: chatSubscriptions.chatId }).from(chatSubscriptions).where(and(eq(chatSubscriptions.chatId, chatId), eq(chatSubscriptions.agentId, humanAgentId))).limit(1);
|
|
10350
|
-
if (sub) return "watching";
|
|
10351
|
-
return null;
|
|
10352
|
-
}
|
|
10353
|
-
/**
|
|
10354
|
-
* Used by `/me/chats/:chatId/join`. Throw 409 if already a speaker (no work
|
|
10355
|
-
* to do) and 403 if no watcher row and no admin override. Admin override is
|
|
10356
|
-
* resolved at the route layer; this helper only reports the watcher state.
|
|
10357
|
-
*/
|
|
10358
|
-
function ensureCanJoin(membership) {
|
|
10359
|
-
if (membership === "participant") throw new ConflictError("Already a participant in this chat");
|
|
10360
|
-
if (membership === null) throw new ForbiddenError("Not a watcher of this chat — open the chat from your workspace before joining");
|
|
10361
|
-
}
|
|
10362
|
-
/**
|
|
10363
|
-
* When a direct chat grows past 2 participants, upgrade it to `group` and
|
|
10364
|
-
* flip every existing non-human agent participant to `mention_only` — see
|
|
10365
|
-
* proposals/hub-agent-messaging-reply-and-mentions §3.3. The caller is
|
|
10366
|
-
* expected to insert the new participant AFTER this runs, so the "existing"
|
|
10367
|
-
* set excludes them.
|
|
10368
|
-
*
|
|
10369
|
-
* Idempotent: if the chat is already a group, no-op.
|
|
10370
|
-
*/
|
|
10371
|
-
async function maybeUpgradeDirectToGroup(db, chatId, existingParticipantIds, newParticipantCount) {
|
|
10372
|
-
if (existingParticipantIds.length + newParticipantCount < 3) return;
|
|
10373
|
-
const [chat] = await db.select({ type: chats.type }).from(chats).where(eq(chats.id, chatId)).limit(1);
|
|
10374
|
-
if (!chat || chat.type !== "direct") return;
|
|
10375
|
-
await db.update(chats).set({
|
|
10376
|
-
type: "group",
|
|
10377
|
-
updatedAt: /* @__PURE__ */ new Date()
|
|
10378
|
-
}).where(eq(chats.id, chatId));
|
|
10379
|
-
if (existingParticipantIds.length === 0) return;
|
|
10380
|
-
const ids = (await db.select({ uuid: agents.uuid }).from(agents).where(and(inArray(agents.uuid, existingParticipantIds), ne(agents.type, "human")))).map((a) => a.uuid);
|
|
10381
|
-
if (ids.length === 0) return;
|
|
10382
|
-
await db.update(chatParticipants).set({ mode: "mention_only" }).where(and(eq(chatParticipants.chatId, chatId), inArray(chatParticipants.agentId, ids)));
|
|
10383
|
-
}
|
|
10384
|
-
async function createChat(db, creatorId, data) {
|
|
10385
|
-
const chatId = randomUUID();
|
|
10386
|
-
const allParticipantIds = new Set([creatorId, ...data.participantIds]);
|
|
10387
|
-
const existingAgents = await db.select({
|
|
10388
|
-
id: agents.uuid,
|
|
10389
|
-
organizationId: agents.organizationId,
|
|
10390
|
-
type: agents.type
|
|
10391
|
-
}).from(agents).where(inArray(agents.uuid, [...allParticipantIds]));
|
|
10392
|
-
if (existingAgents.length !== allParticipantIds.size) {
|
|
10393
|
-
const found = new Set(existingAgents.map((a) => a.id));
|
|
10394
|
-
throw new BadRequestError(`Agents not found: ${[...allParticipantIds].filter((id) => !found.has(id)).join(", ")}`);
|
|
10395
|
-
}
|
|
10396
|
-
const creator = existingAgents.find((a) => a.id === creatorId);
|
|
10397
|
-
if (!creator) throw new Error("Unexpected: creator not in existingAgents");
|
|
10398
|
-
const orgId = creator.organizationId;
|
|
10399
|
-
const crossOrg = existingAgents.filter((a) => a.organizationId !== orgId);
|
|
10400
|
-
if (crossOrg.length > 0) throw new BadRequestError(`Cross-organization chat not allowed: ${crossOrg.map((a) => a.id).join(", ")}`);
|
|
10401
|
-
const isDirectAgentOnly = data.type === "direct" && existingAgents.every((a) => a.type !== "human");
|
|
10402
|
-
return db.transaction(async (tx) => {
|
|
10403
|
-
const [chat] = await tx.insert(chats).values({
|
|
10404
|
-
id: chatId,
|
|
10405
|
-
organizationId: orgId,
|
|
10406
|
-
type: data.type,
|
|
10407
|
-
topic: data.topic ?? null,
|
|
10408
|
-
metadata: data.metadata ?? {}
|
|
10409
|
-
}).returning();
|
|
10410
|
-
const participantRows = [...allParticipantIds].map((agentId) => ({
|
|
10411
|
-
chatId,
|
|
10412
|
-
agentId,
|
|
10413
|
-
role: agentId === creatorId ? "owner" : "member",
|
|
10414
|
-
...isDirectAgentOnly ? { mode: "mention_only" } : {}
|
|
10415
|
-
}));
|
|
10416
|
-
await tx.insert(chatParticipants).values(participantRows);
|
|
10417
|
-
await recomputeChatWatchers(tx, chatId);
|
|
10418
|
-
const participants = await tx.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
|
|
10419
|
-
if (!chat) throw new Error("Unexpected: INSERT RETURNING produced no row");
|
|
10420
|
-
return {
|
|
10421
|
-
...chat,
|
|
10422
|
-
participants
|
|
10423
|
-
};
|
|
10424
|
-
});
|
|
10425
|
-
}
|
|
10426
|
-
async function getChat(db, chatId) {
|
|
10427
|
-
const [chat] = await db.select().from(chats).where(eq(chats.id, chatId)).limit(1);
|
|
10428
|
-
if (!chat) throw new NotFoundError(`Chat "${chatId}" not found`);
|
|
10429
|
-
return chat;
|
|
10430
|
-
}
|
|
10431
|
-
async function getChatDetail(db, chatId) {
|
|
10432
|
-
const chat = await getChat(db, chatId);
|
|
10433
|
-
const participants = await db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
|
|
10434
|
-
return {
|
|
10435
|
-
...chat,
|
|
10436
|
-
participants
|
|
10437
|
-
};
|
|
10438
|
-
}
|
|
10439
|
-
async function listChats(db, agentId, limit, cursor) {
|
|
10440
|
-
const chatIds = (await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(eq(chatParticipants.agentId, agentId))).map((r) => r.chatId);
|
|
10441
|
-
if (chatIds.length === 0) return {
|
|
10442
|
-
items: [],
|
|
10443
|
-
nextCursor: null
|
|
10444
|
-
};
|
|
10445
|
-
const where = cursor ? and(inArray(chats.id, chatIds), lt(chats.updatedAt, new Date(cursor))) : inArray(chats.id, chatIds);
|
|
10446
|
-
const rows = await db.select().from(chats).where(where).orderBy(desc(chats.updatedAt)).limit(limit + 1);
|
|
10447
|
-
const hasMore = rows.length > limit;
|
|
10448
|
-
const items = hasMore ? rows.slice(0, limit) : rows;
|
|
10449
|
-
const last = items[items.length - 1];
|
|
10450
|
-
return {
|
|
10451
|
-
items,
|
|
10452
|
-
nextCursor: hasMore && last ? last.updatedAt.toISOString() : null
|
|
10453
|
-
};
|
|
10454
|
-
}
|
|
10455
|
-
/**
|
|
10456
|
-
* List participants of a chat with their agent names — used by the client
|
|
10457
|
-
* runtime to resolve `@<name>` mentions against the authoritative participant
|
|
10458
|
-
* set (see proposals/hub-agent-messaging-reply-and-mentions §4).
|
|
10459
|
-
*/
|
|
10460
|
-
async function listChatParticipantsWithNames(db, chatId) {
|
|
10461
|
-
return await db.select({
|
|
10462
|
-
agentId: chatParticipants.agentId,
|
|
10463
|
-
role: chatParticipants.role,
|
|
10464
|
-
mode: chatParticipants.mode,
|
|
10465
|
-
joinedAt: chatParticipants.joinedAt,
|
|
10466
|
-
name: agents.name,
|
|
10467
|
-
displayName: agents.displayName,
|
|
10468
|
-
type: agents.type
|
|
10469
|
-
}).from(chatParticipants).innerJoin(agents, eq(chatParticipants.agentId, agents.uuid)).where(eq(chatParticipants.chatId, chatId));
|
|
10470
|
-
}
|
|
10471
|
-
async function assertParticipant(db, chatId, agentId) {
|
|
10472
|
-
const [row] = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, agentId))).limit(1);
|
|
10473
|
-
if (!row) throw new ForbiddenError("Not a participant of this chat");
|
|
10474
|
-
}
|
|
10475
|
-
/** Ensure an agent is a participant of a chat. Silently adds them if not already. */
|
|
10476
|
-
async function ensureParticipant$1(db, chatId, agentId) {
|
|
10477
|
-
const [existing] = await db.select({ agentId: chatParticipants.agentId }).from(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, agentId))).limit(1);
|
|
10478
|
-
if (existing) return;
|
|
10479
|
-
await db.transaction(async (tx) => {
|
|
10480
|
-
await maybeUpgradeDirectToGroup(tx, chatId, (await tx.select({ agentId: chatParticipants.agentId }).from(chatParticipants).where(eq(chatParticipants.chatId, chatId))).map((p) => p.agentId), 1);
|
|
10481
|
-
await tx.delete(chatSubscriptions).where(and(eq(chatSubscriptions.chatId, chatId), eq(chatSubscriptions.agentId, agentId)));
|
|
10482
|
-
await tx.insert(chatParticipants).values({
|
|
10483
|
-
chatId,
|
|
10484
|
-
agentId,
|
|
10485
|
-
mode: "full"
|
|
10486
|
-
}).onConflictDoNothing({ target: [chatParticipants.chatId, chatParticipants.agentId] });
|
|
10487
|
-
await recomputeChatWatchers(tx, chatId);
|
|
10488
|
-
});
|
|
10489
|
-
invalidateChatAudience(chatId);
|
|
10490
|
-
}
|
|
10491
|
-
async function addParticipant(db, chatId, requesterId, data) {
|
|
10492
|
-
const chat = await getChat(db, chatId);
|
|
10493
|
-
await assertParticipant(db, chatId, requesterId);
|
|
10494
|
-
const [targetAgent] = await db.select({
|
|
10495
|
-
id: agents.uuid,
|
|
10496
|
-
organizationId: agents.organizationId
|
|
10497
|
-
}).from(agents).where(eq(agents.uuid, data.agentId)).limit(1);
|
|
10498
|
-
if (!targetAgent) throw new NotFoundError(`Agent "${data.agentId}" not found`);
|
|
10499
|
-
if (targetAgent.organizationId !== chat.organizationId) throw new BadRequestError("Cannot add agent from different organization");
|
|
10500
|
-
const [existing] = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, data.agentId))).limit(1);
|
|
10501
|
-
if (existing) throw new ConflictError(`Agent "${data.agentId}" is already a participant`);
|
|
10502
|
-
await db.transaction(async (tx) => {
|
|
10503
|
-
await maybeUpgradeDirectToGroup(tx, chatId, (await tx.select({ agentId: chatParticipants.agentId }).from(chatParticipants).where(eq(chatParticipants.chatId, chatId))).map((p) => p.agentId), 1);
|
|
10504
|
-
await tx.delete(chatSubscriptions).where(and(eq(chatSubscriptions.chatId, chatId), eq(chatSubscriptions.agentId, data.agentId)));
|
|
10505
|
-
await tx.insert(chatParticipants).values({
|
|
10506
|
-
chatId,
|
|
10319
|
+
platform: data.platform,
|
|
10507
10320
|
agentId: data.agentId,
|
|
10508
|
-
|
|
10509
|
-
|
|
10510
|
-
|
|
10511
|
-
|
|
10512
|
-
|
|
10513
|
-
|
|
10514
|
-
}
|
|
10515
|
-
|
|
10516
|
-
|
|
10517
|
-
if (requesterId === targetAgentId) throw new BadRequestError("Cannot remove yourself from a chat");
|
|
10518
|
-
const [removed] = await db.delete(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, targetAgentId))).returning();
|
|
10519
|
-
if (!removed) throw new NotFoundError(`Agent "${targetAgentId}" is not a participant of this chat`);
|
|
10520
|
-
await recomputeChatWatchers(db, chatId);
|
|
10521
|
-
invalidateChatAudience(chatId);
|
|
10522
|
-
return db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
|
|
10321
|
+
credentials: encrypted,
|
|
10322
|
+
status: data.status ?? "active"
|
|
10323
|
+
}).returning();
|
|
10324
|
+
if (!row) throw new Error("Unexpected: INSERT RETURNING produced no row");
|
|
10325
|
+
return toResponse(row);
|
|
10326
|
+
} catch (err) {
|
|
10327
|
+
if ((err?.code ?? err?.cause?.code ?? "") === "23505") throw new ConflictError(`Agent "${data.agentId}" already has a ${data.platform} adapter config`);
|
|
10328
|
+
throw err;
|
|
10329
|
+
}
|
|
10523
10330
|
}
|
|
10524
|
-
|
|
10525
|
-
|
|
10526
|
-
|
|
10527
|
-
|
|
10528
|
-
|
|
10529
|
-
*/
|
|
10530
|
-
async function listChatsForMember(db, memberId, humanAgentId) {
|
|
10531
|
-
const managedAgents = await db.select({
|
|
10532
|
-
uuid: agents.uuid,
|
|
10533
|
-
name: agents.name,
|
|
10534
|
-
type: agents.type,
|
|
10535
|
-
displayName: agents.displayName
|
|
10536
|
-
}).from(agents).where(eq(agents.managerId, memberId));
|
|
10537
|
-
const agentMap = /* @__PURE__ */ new Map();
|
|
10538
|
-
for (const a of managedAgents) agentMap.set(a.uuid, a);
|
|
10539
|
-
if (!agentMap.has(humanAgentId)) {
|
|
10540
|
-
const [ha] = await db.select({
|
|
10541
|
-
uuid: agents.uuid,
|
|
10542
|
-
name: agents.name,
|
|
10543
|
-
type: agents.type,
|
|
10544
|
-
displayName: agents.displayName
|
|
10545
|
-
}).from(agents).where(eq(agents.uuid, humanAgentId)).limit(1);
|
|
10546
|
-
if (ha) agentMap.set(ha.uuid, ha);
|
|
10331
|
+
async function updateAdapterConfig(db, id, data, encryptionKey) {
|
|
10332
|
+
const setClause = { updatedAt: /* @__PURE__ */ new Date() };
|
|
10333
|
+
if (data.agentId !== void 0) {
|
|
10334
|
+
await validateAgentId(db, data.agentId);
|
|
10335
|
+
setClause.agentId = data.agentId;
|
|
10547
10336
|
}
|
|
10548
|
-
|
|
10549
|
-
if (
|
|
10550
|
-
|
|
10551
|
-
|
|
10552
|
-
agentId: chatParticipants.agentId,
|
|
10553
|
-
role: chatParticipants.role,
|
|
10554
|
-
mode: chatParticipants.mode
|
|
10555
|
-
}).from(chatParticipants).where(inArray(chatParticipants.agentId, agentIds));
|
|
10556
|
-
if (participations.length === 0) return [];
|
|
10557
|
-
const chatIds = [...new Set(participations.map((p) => p.chatId))];
|
|
10558
|
-
const agentChatMap = /* @__PURE__ */ new Map();
|
|
10559
|
-
for (const p of participations) {
|
|
10560
|
-
const list = agentChatMap.get(p.agentId) ?? [];
|
|
10561
|
-
list.push(p.chatId);
|
|
10562
|
-
agentChatMap.set(p.agentId, list);
|
|
10563
|
-
}
|
|
10564
|
-
const chatRows = await db.select({
|
|
10565
|
-
id: chats.id,
|
|
10566
|
-
type: chats.type,
|
|
10567
|
-
topic: chats.topic,
|
|
10568
|
-
metadata: chats.metadata,
|
|
10569
|
-
createdAt: chats.createdAt,
|
|
10570
|
-
updatedAt: chats.updatedAt,
|
|
10571
|
-
participantCount: sql`(SELECT count(*)::int FROM chat_participants WHERE chat_id = ${chats.id})`
|
|
10572
|
-
}).from(chats).where(inArray(chats.id, chatIds)).orderBy(desc(chats.updatedAt));
|
|
10573
|
-
const chatMap = new Map(chatRows.map((c) => [c.id, c]));
|
|
10574
|
-
const humanParticipantChatIds = new Set(participations.filter((p) => p.agentId === humanAgentId).map((p) => p.chatId));
|
|
10575
|
-
const result = [];
|
|
10576
|
-
for (const [agentId, agentChatIds] of agentChatMap) {
|
|
10577
|
-
const agentInfo = agentMap.get(agentId);
|
|
10578
|
-
if (!agentInfo) continue;
|
|
10579
|
-
const agentChats = agentChatIds.map((chatId) => {
|
|
10580
|
-
const chat = chatMap.get(chatId);
|
|
10581
|
-
if (!chat) return null;
|
|
10582
|
-
const isSupervisionOnly = agentId !== humanAgentId && !humanParticipantChatIds.has(chatId);
|
|
10583
|
-
return {
|
|
10584
|
-
id: chat.id,
|
|
10585
|
-
type: chat.type,
|
|
10586
|
-
topic: chat.topic,
|
|
10587
|
-
participantCount: chat.participantCount,
|
|
10588
|
-
isSupervisionOnly,
|
|
10589
|
-
createdAt: chat.createdAt.toISOString(),
|
|
10590
|
-
updatedAt: chat.updatedAt.toISOString()
|
|
10591
|
-
};
|
|
10592
|
-
}).filter((c) => c !== null);
|
|
10593
|
-
if (agentChats.length > 0) result.push({
|
|
10594
|
-
agent: agentInfo,
|
|
10595
|
-
chats: agentChats
|
|
10596
|
-
});
|
|
10337
|
+
if (data.status !== void 0) setClause.status = data.status;
|
|
10338
|
+
if (data.credentials !== void 0) {
|
|
10339
|
+
const key = requireEncryptionKey(encryptionKey);
|
|
10340
|
+
setClause.credentials = encryptCredentials(data.credentials, key);
|
|
10597
10341
|
}
|
|
10598
|
-
|
|
10342
|
+
const [row] = await db.update(adapterConfigs).set(setClause).where(eq(adapterConfigs.id, id)).returning();
|
|
10343
|
+
if (!row) throw new NotFoundError(`Adapter config "${id}" not found`);
|
|
10344
|
+
return toResponse(row);
|
|
10599
10345
|
}
|
|
10600
|
-
|
|
10601
|
-
|
|
10602
|
-
|
|
10603
|
-
|
|
10604
|
-
|
|
10605
|
-
|
|
10606
|
-
const
|
|
10607
|
-
if (
|
|
10608
|
-
|
|
10609
|
-
|
|
10610
|
-
|
|
10611
|
-
|
|
10612
|
-
|
|
10613
|
-
const
|
|
10614
|
-
|
|
10615
|
-
|
|
10616
|
-
|
|
10617
|
-
|
|
10618
|
-
|
|
10619
|
-
|
|
10620
|
-
|
|
10621
|
-
|
|
10622
|
-
|
|
10623
|
-
|
|
10624
|
-
|
|
10625
|
-
|
|
10626
|
-
|
|
10346
|
+
async function deleteAdapterConfig(db, id) {
|
|
10347
|
+
const [row] = await db.delete(adapterConfigs).where(eq(adapterConfigs.id, id)).returning();
|
|
10348
|
+
if (!row) throw new NotFoundError(`Adapter config "${id}" not found`);
|
|
10349
|
+
}
|
|
10350
|
+
const log$5 = createLogger$1("Adapters");
|
|
10351
|
+
function parseId(raw) {
|
|
10352
|
+
const id = Number(raw);
|
|
10353
|
+
if (!Number.isInteger(id) || id <= 0) throw new BadRequestError(`Invalid adapter ID: "${raw}"`);
|
|
10354
|
+
return id;
|
|
10355
|
+
}
|
|
10356
|
+
/** Class C — `/api/v1/adapters/:id`. */
|
|
10357
|
+
async function adapterRoutes(app) {
|
|
10358
|
+
app.get("/:id", async (request) => {
|
|
10359
|
+
const { userId } = requireUser(request);
|
|
10360
|
+
const id = parseId(request.params.id);
|
|
10361
|
+
const config = await getAdapterConfig(app.db, id);
|
|
10362
|
+
await assertAgentManageableByUser(app.db, userId, config.agentId);
|
|
10363
|
+
return {
|
|
10364
|
+
...config,
|
|
10365
|
+
createdAt: config.createdAt.toISOString(),
|
|
10366
|
+
updatedAt: config.updatedAt.toISOString()
|
|
10367
|
+
};
|
|
10368
|
+
});
|
|
10369
|
+
app.patch("/:id", { config: { otelRecordBody: true } }, async (request) => {
|
|
10370
|
+
const { userId } = requireUser(request);
|
|
10371
|
+
const id = parseId(request.params.id);
|
|
10372
|
+
const body = updateAdapterConfigSchema.parse(request.body);
|
|
10373
|
+
const existing = await getAdapterConfig(app.db, id);
|
|
10374
|
+
await assertAgentManageableByUser(app.db, userId, existing.agentId);
|
|
10375
|
+
const config = await updateAdapterConfig(app.db, id, body, app.config.secrets.encryptionKey);
|
|
10376
|
+
app.adapterManager.reload().catch((err) => log$5.error({ err }, "adapter reload failed after update"));
|
|
10377
|
+
app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
|
|
10378
|
+
return {
|
|
10379
|
+
...config,
|
|
10380
|
+
createdAt: config.createdAt.toISOString(),
|
|
10381
|
+
updatedAt: config.updatedAt.toISOString()
|
|
10382
|
+
};
|
|
10383
|
+
});
|
|
10384
|
+
app.delete("/:id", async (request, reply) => {
|
|
10385
|
+
const { userId } = requireUser(request);
|
|
10386
|
+
const id = parseId(request.params.id);
|
|
10387
|
+
const existing = await getAdapterConfig(app.db, id);
|
|
10388
|
+
await assertAgentManageableByUser(app.db, userId, existing.agentId);
|
|
10389
|
+
await deleteAdapterConfig(app.db, id);
|
|
10390
|
+
app.adapterManager.reload().catch((err) => log$5.error({ err }, "adapter reload failed after delete"));
|
|
10391
|
+
app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
|
|
10392
|
+
return reply.status(204).send();
|
|
10627
10393
|
});
|
|
10628
|
-
invalidateChatAudience(chatId);
|
|
10629
|
-
return db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
|
|
10630
10394
|
}
|
|
10631
10395
|
/**
|
|
10632
|
-
*
|
|
10633
|
-
*
|
|
10634
|
-
*
|
|
10635
|
-
*
|
|
10636
|
-
* so admin-side and `/me/chats/:id/leave` share one canonical path. The
|
|
10637
|
-
* earlier "recompute then UPDATE-back state" variant violated the design
|
|
10638
|
-
* rule that recompute is only for set rebuild — never on a transition
|
|
10639
|
-
* path (review #228 issue #2). The returned participant list is fetched
|
|
10640
|
-
* after the tx commits, matching the admin route's existing contract.
|
|
10396
|
+
* Pull the agent identity populated by `agentSelectorHook` off the
|
|
10397
|
+
* request. The hook runs before any handler and assigns `request.agent`,
|
|
10398
|
+
* but the optional shape is what fastify exposes to consumers — narrowing
|
|
10399
|
+
* it here keeps every Class D handler clean of `if (!request.agent)`.
|
|
10641
10400
|
*/
|
|
10642
|
-
|
|
10643
|
-
|
|
10644
|
-
|
|
10645
|
-
return
|
|
10646
|
-
}
|
|
10647
|
-
async function findOrCreateDirectChat(db, agentAId, agentBId) {
|
|
10648
|
-
const aChats = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(eq(chatParticipants.agentId, agentAId));
|
|
10649
|
-
const bChats = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(eq(chatParticipants.agentId, agentBId));
|
|
10650
|
-
const bChatIds = new Set(bChats.map((r) => r.chatId));
|
|
10651
|
-
const commonChatIds = aChats.map((r) => r.chatId).filter((id) => bChatIds.has(id));
|
|
10652
|
-
if (commonChatIds.length > 0) {
|
|
10653
|
-
const directChats = await db.select().from(chats).where(and(inArray(chats.id, commonChatIds), eq(chats.type, "direct")));
|
|
10654
|
-
if (directChats.length > 0 && directChats[0]) return directChats[0];
|
|
10655
|
-
}
|
|
10656
|
-
const ends = await db.select({
|
|
10657
|
-
uuid: agents.uuid,
|
|
10658
|
-
organizationId: agents.organizationId,
|
|
10659
|
-
type: agents.type
|
|
10660
|
-
}).from(agents).where(inArray(agents.uuid, [agentAId, agentBId]));
|
|
10661
|
-
const agentA = ends.find((a) => a.uuid === agentAId);
|
|
10662
|
-
if (!agentA) throw new NotFoundError(`Agent "${agentAId}" not found`);
|
|
10663
|
-
const agentB = ends.find((a) => a.uuid === agentBId);
|
|
10664
|
-
if (!agentB) throw new NotFoundError(`Agent "${agentBId}" not found`);
|
|
10665
|
-
const mode = agentA.type !== "human" && agentB.type !== "human" ? "mention_only" : "full";
|
|
10666
|
-
const chatId = randomUUID();
|
|
10667
|
-
return db.transaction(async (tx) => {
|
|
10668
|
-
const [chat] = await tx.insert(chats).values({
|
|
10669
|
-
id: chatId,
|
|
10670
|
-
organizationId: agentA.organizationId,
|
|
10671
|
-
type: "direct"
|
|
10672
|
-
}).returning();
|
|
10673
|
-
await tx.insert(chatParticipants).values([{
|
|
10674
|
-
chatId,
|
|
10675
|
-
agentId: agentAId,
|
|
10676
|
-
role: "member",
|
|
10677
|
-
mode
|
|
10678
|
-
}, {
|
|
10679
|
-
chatId,
|
|
10680
|
-
agentId: agentBId,
|
|
10681
|
-
role: "member",
|
|
10682
|
-
mode
|
|
10683
|
-
}]);
|
|
10684
|
-
await recomputeChatWatchers(tx, chatId);
|
|
10685
|
-
if (!chat) throw new Error("Unexpected: INSERT RETURNING produced no row");
|
|
10686
|
-
return chat;
|
|
10687
|
-
});
|
|
10401
|
+
function requireAgent(request) {
|
|
10402
|
+
const agent = request.agent;
|
|
10403
|
+
if (!agent) throw new UnauthorizedError("Agent authentication required");
|
|
10404
|
+
return agent;
|
|
10688
10405
|
}
|
|
10689
10406
|
function serializeChat(chat) {
|
|
10690
10407
|
return {
|
|
@@ -10793,46 +10510,6 @@ const agentConfigs = pgTable("agent_configs", {
|
|
|
10793
10510
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
|
|
10794
10511
|
});
|
|
10795
10512
|
/**
|
|
10796
|
-
* Shared access-control primitives. Most route-level gating now lives in
|
|
10797
|
-
* `scope/require-*.ts` — this module is reduced to two helpers that need
|
|
10798
|
-
* SQL building blocks reused across routes and tests:
|
|
10799
|
-
*
|
|
10800
|
-
* - `agentVisibilityCondition` — WHERE clause for "agents visible to a
|
|
10801
|
-
* member" (org-visible OR managerId = the caller's member). Composed
|
|
10802
|
-
* into list queries that already select from `agents`.
|
|
10803
|
-
* - `listAgentsManagedByUser` — cross-org list of agents personally
|
|
10804
|
-
* managed by a user; powers the CLI `agent list --remote` view.
|
|
10805
|
-
*
|
|
10806
|
-
* Visibility is the same for all roles — admin sees the same set as a
|
|
10807
|
-
* regular member. Admin privilege is expressed through manageability
|
|
10808
|
-
* (`requireAgentAccess(..., "manage")`), not visibility.
|
|
10809
|
-
*/
|
|
10810
|
-
/**
|
|
10811
|
-
* SQL WHERE conditions for agents visible to a member.
|
|
10812
|
-
* target org + not deleted + (organization-visible OR managerId = caller's member)
|
|
10813
|
-
*/
|
|
10814
|
-
function agentVisibilityCondition(orgId, memberId) {
|
|
10815
|
-
return and(eq(agents.organizationId, orgId), ne(agents.status, AGENT_STATUSES.DELETED), or(eq(agents.visibility, AGENT_VISIBILITY.ORGANIZATION), eq(agents.managerId, memberId)));
|
|
10816
|
-
}
|
|
10817
|
-
/**
|
|
10818
|
-
* Cross-org listing helper for "agents I personally manage". Used by the
|
|
10819
|
-
* CLI `agent list --remote` view — JOINs `agents → members.id` and filters
|
|
10820
|
-
* by `members.user_id`.
|
|
10821
|
-
*/
|
|
10822
|
-
async function listAgentsManagedByUser(db, userId) {
|
|
10823
|
-
return db.select({
|
|
10824
|
-
uuid: agents.uuid,
|
|
10825
|
-
name: agents.name,
|
|
10826
|
-
displayName: agents.displayName,
|
|
10827
|
-
type: agents.type,
|
|
10828
|
-
organizationId: agents.organizationId,
|
|
10829
|
-
inboxId: agents.inboxId,
|
|
10830
|
-
visibility: agents.visibility,
|
|
10831
|
-
runtimeProvider: agents.runtimeProvider,
|
|
10832
|
-
clientId: agents.clientId
|
|
10833
|
-
}).from(agents).innerJoin(members, eq(agents.managerId, members.id)).where(and(eq(members.userId, userId), eq(members.status, "active"), ne(agents.status, AGENT_STATUSES.DELETED)));
|
|
10834
|
-
}
|
|
10835
|
-
/**
|
|
10836
10513
|
* Resolve the UUID of the "default" organization. Internal use only —
|
|
10837
10514
|
* webhooks, fallbacks, etc. The HTTP API layer no longer falls back to
|
|
10838
10515
|
* the JWT default org.
|
|
@@ -11026,29 +10703,33 @@ async function createAgent(db, data, options = {}) {
|
|
|
11026
10703
|
}
|
|
11027
10704
|
const resolvedDisplayName = data.displayName?.trim() || name || "Unnamed Agent";
|
|
11028
10705
|
try {
|
|
11029
|
-
|
|
11030
|
-
|
|
11031
|
-
|
|
11032
|
-
|
|
11033
|
-
|
|
11034
|
-
|
|
11035
|
-
|
|
11036
|
-
|
|
11037
|
-
|
|
11038
|
-
|
|
11039
|
-
|
|
11040
|
-
|
|
11041
|
-
|
|
11042
|
-
|
|
11043
|
-
|
|
11044
|
-
|
|
11045
|
-
|
|
11046
|
-
|
|
11047
|
-
|
|
11048
|
-
|
|
11049
|
-
|
|
11050
|
-
|
|
11051
|
-
|
|
10706
|
+
return await db.transaction(async (tx) => {
|
|
10707
|
+
const [row] = await tx.insert(agents).values({
|
|
10708
|
+
uuid,
|
|
10709
|
+
name,
|
|
10710
|
+
organizationId: orgId,
|
|
10711
|
+
type: data.type,
|
|
10712
|
+
displayName: resolvedDisplayName,
|
|
10713
|
+
delegateMention: data.delegateMention ?? null,
|
|
10714
|
+
inboxId,
|
|
10715
|
+
source: data.source ?? null,
|
|
10716
|
+
visibility: data.visibility ?? defaultVisibility(data.type),
|
|
10717
|
+
metadata: data.metadata ?? {},
|
|
10718
|
+
managerId,
|
|
10719
|
+
clientId,
|
|
10720
|
+
runtimeProvider
|
|
10721
|
+
}).returning();
|
|
10722
|
+
if (!row) throw new Error("Unexpected: INSERT RETURNING produced no row");
|
|
10723
|
+
const initialPayload = defaultRuntimeConfigPayload(runtimeProvider);
|
|
10724
|
+
if (data.gitRepos && data.gitRepos.length > 0) initialPayload.gitRepos = data.gitRepos;
|
|
10725
|
+
await tx.insert(agentConfigs).values({
|
|
10726
|
+
agentId: row.uuid,
|
|
10727
|
+
version: 1,
|
|
10728
|
+
payload: initialPayload,
|
|
10729
|
+
updatedBy: "system"
|
|
10730
|
+
}).onConflictDoNothing();
|
|
10731
|
+
return row;
|
|
10732
|
+
});
|
|
11052
10733
|
} catch (err) {
|
|
11053
10734
|
if ((err?.code ?? err?.cause?.code ?? "") === "23505" && name) throw new ConflictError(`Agent name "${name}" already exists in organization "${orgId}"`);
|
|
11054
10735
|
throw err;
|
|
@@ -11271,7 +10952,7 @@ async function deleteAgent(db, uuid) {
|
|
|
11271
10952
|
if (!agent) throw new Error("Unexpected: UPDATE RETURNING produced no row");
|
|
11272
10953
|
return agent;
|
|
11273
10954
|
}
|
|
11274
|
-
const log$
|
|
10955
|
+
const log$4 = createLogger$1("AgentFeishuBot");
|
|
11275
10956
|
async function agentFeishuBotRoutes(app) {
|
|
11276
10957
|
/**
|
|
11277
10958
|
* PUT /agent/me/feishu-bot
|
|
@@ -11299,7 +10980,7 @@ async function agentFeishuBotRoutes(app) {
|
|
|
11299
10980
|
},
|
|
11300
10981
|
status: "active"
|
|
11301
10982
|
}, app.config.secrets.encryptionKey);
|
|
11302
|
-
app.adapterManager.reload().catch((err) => log$
|
|
10983
|
+
app.adapterManager.reload().catch((err) => log$4.error({ err }, "adapter reload failed after self-service bind"));
|
|
11303
10984
|
app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
|
|
11304
10985
|
return reply.status(current ? 200 : 201).send({
|
|
11305
10986
|
...config,
|
|
@@ -11316,7 +10997,7 @@ async function agentFeishuBotRoutes(app) {
|
|
|
11316
10997
|
const current = (await listAdapterConfigs(app.db)).find((c) => c.agentId === identity.uuid && c.platform === "feishu");
|
|
11317
10998
|
if (!current) return reply.status(204).send();
|
|
11318
10999
|
await deleteAdapterConfig(app.db, current.id);
|
|
11319
|
-
app.adapterManager.reload().catch((err) => log$
|
|
11000
|
+
app.adapterManager.reload().catch((err) => log$4.error({ err }, "adapter reload failed after self-service unbind"));
|
|
11320
11001
|
app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
|
|
11321
11002
|
return reply.status(204).send();
|
|
11322
11003
|
});
|
|
@@ -11337,20 +11018,6 @@ const adapterChatMappings = pgTable("adapter_chat_mappings", {
|
|
|
11337
11018
|
metadata: jsonb("metadata").$type().notNull().default({}),
|
|
11338
11019
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
|
|
11339
11020
|
});
|
|
11340
|
-
/** Messages. Immutable after creation. Each message belongs to exactly one Chat. */
|
|
11341
|
-
const messages = pgTable("messages", {
|
|
11342
|
-
id: text("id").primaryKey(),
|
|
11343
|
-
chatId: text("chat_id").notNull().references(() => chats.id),
|
|
11344
|
-
senderId: text("sender_id").notNull(),
|
|
11345
|
-
format: text("format").notNull(),
|
|
11346
|
-
content: jsonb("content").$type().notNull(),
|
|
11347
|
-
metadata: jsonb("metadata").$type().notNull().default({}),
|
|
11348
|
-
replyToInbox: text("reply_to_inbox"),
|
|
11349
|
-
replyToChat: text("reply_to_chat"),
|
|
11350
|
-
inReplyTo: text("in_reply_to"),
|
|
11351
|
-
source: text("source"),
|
|
11352
|
-
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
|
|
11353
|
-
}, (table) => [index("idx_messages_chat_time").on(table.chatId, table.createdAt), index("idx_messages_in_reply_to").on(table.inReplyTo)]);
|
|
11354
11021
|
/** Cross-reference between internal messages and external platform message IDs. */
|
|
11355
11022
|
const adapterMessageReferences = pgTable("adapter_message_references", {
|
|
11356
11023
|
id: serial("id").primaryKey(),
|
|
@@ -11541,24 +11208,6 @@ async function agentFeishuUserRoutes(app) {
|
|
|
11541
11208
|
return reply.status(204).send();
|
|
11542
11209
|
});
|
|
11543
11210
|
}
|
|
11544
|
-
/** Delivery queue (envelope). One entry per recipient created during message fan-out. Uses SKIP LOCKED for concurrent-safe consumption. */
|
|
11545
|
-
const inboxEntries = pgTable("inbox_entries", {
|
|
11546
|
-
id: bigserial("id", { mode: "number" }).primaryKey(),
|
|
11547
|
-
inboxId: text("inbox_id").notNull(),
|
|
11548
|
-
messageId: text("message_id").notNull().references(() => messages.id),
|
|
11549
|
-
chatId: text("chat_id"),
|
|
11550
|
-
status: text("status").notNull().default("pending"),
|
|
11551
|
-
notify: boolean("notify").notNull().default(true),
|
|
11552
|
-
retryCount: integer("retry_count").notNull().default(0),
|
|
11553
|
-
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
11554
|
-
deliveredAt: timestamp("delivered_at", { withTimezone: true }),
|
|
11555
|
-
ackedAt: timestamp("acked_at", { withTimezone: true })
|
|
11556
|
-
}, (table) => [
|
|
11557
|
-
unique("uq_inbox_delivery").on(table.inboxId, table.messageId, table.chatId),
|
|
11558
|
-
index("idx_inbox_pending").on(table.inboxId, table.createdAt),
|
|
11559
|
-
index("idx_inbox_pending_notify").on(table.inboxId, table.createdAt).where(sql`status = 'pending' AND notify = true`),
|
|
11560
|
-
index("idx_inbox_chat_silent").on(table.inboxId, table.chatId, table.notify, table.status)
|
|
11561
|
-
]);
|
|
11562
11211
|
function normaliseSource(source) {
|
|
11563
11212
|
if (source === null) return null;
|
|
11564
11213
|
const parsed = messageSourceSchema$1.safeParse(source);
|
|
@@ -11956,622 +11605,43 @@ async function prepareImageOutbound(db, notifier, chatId, data) {
|
|
|
11956
11605
|
const parsed = imageInlineContentSchema.safeParse(data.content);
|
|
11957
11606
|
if (!parsed.success) return data;
|
|
11958
11607
|
const inline = parsed.data;
|
|
11959
|
-
const imageId = inline.imageId ?? randomUUID();
|
|
11960
|
-
const frame = {
|
|
11961
|
-
type: "image_payload",
|
|
11962
|
-
imageId,
|
|
11963
|
-
chatId,
|
|
11964
|
-
base64: inline.data,
|
|
11965
|
-
mimeType: inline.mimeType,
|
|
11966
|
-
filename: inline.filename,
|
|
11967
|
-
...inline.size !== void 0 ? { size: inline.size } : {}
|
|
11968
|
-
};
|
|
11969
|
-
const serialised = JSON.stringify(frame);
|
|
11970
|
-
const inboxIds = await collectTargetInboxes(db, chatId, data.inReplyTo);
|
|
11971
|
-
for (const inboxId of inboxIds) notifier.pushFrameToInbox(inboxId, serialised).catch(() => {});
|
|
11972
|
-
const ref = {
|
|
11973
|
-
imageId,
|
|
11974
|
-
mimeType: inline.mimeType,
|
|
11975
|
-
filename: inline.filename,
|
|
11976
|
-
...inline.size !== void 0 ? { size: inline.size } : {}
|
|
11977
|
-
};
|
|
11978
|
-
return {
|
|
11979
|
-
...data,
|
|
11980
|
-
content: ref
|
|
11981
|
-
};
|
|
11982
|
-
}
|
|
11983
|
-
/**
|
|
11984
|
-
* Mirror `sendMessage`'s fan-out set: every participant of the current
|
|
11985
|
-
* chat, plus the original requester's inbox when this message is a cross-
|
|
11986
|
-
* chat reply (see `services/message.ts` replyTo routing).
|
|
11987
|
-
*/
|
|
11988
|
-
async function collectTargetInboxes(db, chatId, inReplyTo) {
|
|
11989
|
-
const participants = await db.select({ inboxId: agents.inboxId }).from(chatParticipants).innerJoin(agents, eq(chatParticipants.agentId, agents.uuid)).where(eq(chatParticipants.chatId, chatId));
|
|
11990
|
-
const set = new Set(participants.map((p) => p.inboxId));
|
|
11991
|
-
if (inReplyTo) {
|
|
11992
|
-
const [original] = await db.select({ replyToInbox: messages.replyToInbox }).from(messages).where(eq(messages.id, inReplyTo)).limit(1);
|
|
11993
|
-
if (original?.replyToInbox) set.add(original.replyToInbox);
|
|
11994
|
-
}
|
|
11995
|
-
return [...set];
|
|
11996
|
-
}
|
|
11997
|
-
/** Per-session state snapshot. One row per (agent, chat) pair, upserted on each session:state message. */
|
|
11998
|
-
const agentChatSessions = pgTable("agent_chat_sessions", {
|
|
11999
|
-
agentId: text("agent_id").notNull().references(() => agents.uuid, { onDelete: "cascade" }),
|
|
12000
|
-
chatId: text("chat_id").notNull().references(() => chats.id, { onDelete: "cascade" }),
|
|
12001
|
-
state: text("state").notNull(),
|
|
12002
|
-
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
|
|
12003
|
-
}, (table) => [primaryKey({ columns: [table.agentId, table.chatId] })]);
|
|
12004
|
-
/**
|
|
12005
|
-
* Upsert session state + refresh presence aggregates + NOTIFY.
|
|
12006
|
-
*
|
|
12007
|
-
* `agent_chat_sessions.(agent_id, chat_id)` is a single-row "current session
|
|
12008
|
-
* state" cache, not a session history log. A new runtime session starting on
|
|
12009
|
-
* the same (agent, chat) pair MUST overwrite whatever ended before — including
|
|
12010
|
-
* an `evicted` row left by a previous terminate. The previous "revival
|
|
12011
|
-
* defense" conflated two concerns: "this runtime session ended" (which is
|
|
12012
|
-
* what `evicted` actually means) and "this chat is permanently archived for
|
|
12013
|
-
* this agent" (a chat-level decision that should live on `chats`, not here).
|
|
12014
|
-
* See proposals/hub-agent-messaging-reply-and-mentions §M2-session-lifecycle.
|
|
12015
|
-
*
|
|
12016
|
-
* Presence row contract: this function tolerates a missing `agent_presence`
|
|
12017
|
-
* row by using `INSERT ... ON CONFLICT DO UPDATE`. The predictive-write path
|
|
12018
|
-
* (sendMessage on first message) may target an agent whose client has never
|
|
12019
|
-
* bound, so a prior `update agent_presence ... where agentId` would silently
|
|
12020
|
-
* drop the activeSessions/totalSessions refresh. See PR #198 review §2.
|
|
12021
|
-
*/
|
|
12022
|
-
async function upsertSessionState(db, agentId, chatId, state, organizationId, notifier, options) {
|
|
12023
|
-
const now = /* @__PURE__ */ new Date();
|
|
12024
|
-
let wrote = false;
|
|
12025
|
-
await db.transaction(async (tx) => {
|
|
12026
|
-
await tx.insert(agentChatSessions).values({
|
|
12027
|
-
agentId,
|
|
12028
|
-
chatId,
|
|
12029
|
-
state,
|
|
12030
|
-
updatedAt: now
|
|
12031
|
-
}).onConflictDoUpdate({
|
|
12032
|
-
target: [agentChatSessions.agentId, agentChatSessions.chatId],
|
|
12033
|
-
set: {
|
|
12034
|
-
state,
|
|
12035
|
-
updatedAt: now
|
|
12036
|
-
},
|
|
12037
|
-
setWhere: ne(agentChatSessions.state, state)
|
|
12038
|
-
});
|
|
12039
|
-
const [counts] = await tx.select({
|
|
12040
|
-
active: sql`count(*) FILTER (WHERE ${agentChatSessions.state} = 'active')::int`,
|
|
12041
|
-
total: sql`count(*) FILTER (WHERE ${agentChatSessions.state} != 'evicted')::int`
|
|
12042
|
-
}).from(agentChatSessions).where(eq(agentChatSessions.agentId, agentId));
|
|
12043
|
-
const activeSessions = counts?.active ?? 0;
|
|
12044
|
-
const totalSessions = counts?.total ?? 0;
|
|
12045
|
-
const presenceSet = options?.touchPresenceLastSeen ?? true ? {
|
|
12046
|
-
activeSessions,
|
|
12047
|
-
totalSessions,
|
|
12048
|
-
lastSeenAt: now
|
|
12049
|
-
} : {
|
|
12050
|
-
activeSessions,
|
|
12051
|
-
totalSessions
|
|
12052
|
-
};
|
|
12053
|
-
await tx.insert(agentPresence).values({
|
|
12054
|
-
agentId,
|
|
12055
|
-
activeSessions,
|
|
12056
|
-
totalSessions
|
|
12057
|
-
}).onConflictDoUpdate({
|
|
12058
|
-
target: [agentPresence.agentId],
|
|
12059
|
-
set: presenceSet
|
|
12060
|
-
});
|
|
12061
|
-
wrote = true;
|
|
12062
|
-
});
|
|
12063
|
-
if (wrote && notifier) notifier.notifySessionStateChange(agentId, chatId, state, organizationId).catch(() => {});
|
|
12064
|
-
}
|
|
12065
|
-
async function resetActivity(db, agentId) {
|
|
12066
|
-
const now = /* @__PURE__ */ new Date();
|
|
12067
|
-
await db.update(agentPresence).set({
|
|
12068
|
-
runtimeState: "idle",
|
|
12069
|
-
runtimeUpdatedAt: now
|
|
12070
|
-
}).where(eq(agentPresence.agentId, agentId));
|
|
12071
|
-
}
|
|
12072
|
-
async function getActivityOverview(db) {
|
|
12073
|
-
const [agentCounts] = await db.select({
|
|
12074
|
-
total: sql`count(*)::int`,
|
|
12075
|
-
running: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} IS NOT NULL)::int`,
|
|
12076
|
-
idle: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} = 'idle')::int`,
|
|
12077
|
-
working: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} = 'working')::int`,
|
|
12078
|
-
blocked: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} = 'blocked')::int`,
|
|
12079
|
-
error: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} = 'error')::int`
|
|
12080
|
-
}).from(agentPresence);
|
|
12081
|
-
const [clientCounts] = await db.select({ count: sql`count(*)::int` }).from(clients).where(eq(clients.status, "connected"));
|
|
12082
|
-
return {
|
|
12083
|
-
total: agentCounts?.total ?? 0,
|
|
12084
|
-
running: agentCounts?.running ?? 0,
|
|
12085
|
-
byState: {
|
|
12086
|
-
idle: agentCounts?.idle ?? 0,
|
|
12087
|
-
working: agentCounts?.working ?? 0,
|
|
12088
|
-
blocked: agentCounts?.blocked ?? 0,
|
|
12089
|
-
error: agentCounts?.error ?? 0
|
|
12090
|
-
},
|
|
12091
|
-
clients: clientCounts?.count ?? 0
|
|
12092
|
-
};
|
|
12093
|
-
}
|
|
12094
|
-
/**
|
|
12095
|
-
* List agents with active runtime state.
|
|
12096
|
-
* When scope is provided, filters to agents visible to the member.
|
|
12097
|
-
*/
|
|
12098
|
-
async function listAgentsWithRuntime(db, scope) {
|
|
12099
|
-
if (!scope) return db.select().from(agentPresence).where(isNotNull(agentPresence.runtimeState));
|
|
12100
|
-
return db.select({
|
|
12101
|
-
agentId: agentPresence.agentId,
|
|
12102
|
-
status: agentPresence.status,
|
|
12103
|
-
instanceId: agentPresence.instanceId,
|
|
12104
|
-
connectedAt: agentPresence.connectedAt,
|
|
12105
|
-
lastSeenAt: agentPresence.lastSeenAt,
|
|
12106
|
-
clientId: agentPresence.clientId,
|
|
12107
|
-
runtimeType: agentPresence.runtimeType,
|
|
12108
|
-
runtimeVersion: agentPresence.runtimeVersion,
|
|
12109
|
-
runtimeState: agentPresence.runtimeState,
|
|
12110
|
-
activeSessions: agentPresence.activeSessions,
|
|
12111
|
-
totalSessions: agentPresence.totalSessions,
|
|
12112
|
-
runtimeUpdatedAt: agentPresence.runtimeUpdatedAt,
|
|
12113
|
-
type: agents.type
|
|
12114
|
-
}).from(agentPresence).innerJoin(agents, eq(agentPresence.agentId, agents.uuid)).where(and(isNotNull(agentPresence.runtimeState), agentVisibilityCondition(scope.organizationId, scope.memberId)));
|
|
12115
|
-
}
|
|
12116
|
-
/**
|
|
12117
|
-
* Chat-first workspace — append-only post-fan-out projection.
|
|
12118
|
-
*
|
|
12119
|
-
* The single sanctioned extension point on the message hot path. Called
|
|
12120
|
-
* from `services/message.ts` AFTER existing fan-out completes, inside the
|
|
12121
|
-
* same transaction. Three responsibilities:
|
|
12122
|
-
*
|
|
12123
|
-
* 1. Mention propagation: increment `unread_mention_count` for mentioned
|
|
12124
|
-
* speaking participants AND for watcher rows whose managed agent was
|
|
12125
|
-
* mentioned. Sender row is excluded.
|
|
12126
|
-
*
|
|
12127
|
-
* 2. Chats projection: roll forward `chats.last_message_at`,
|
|
12128
|
-
* `chats.last_message_preview`. Powers the conversation list cursor +
|
|
12129
|
-
* sort + preview.
|
|
12130
|
-
*
|
|
12131
|
-
* 3. Realtime kick: fire-and-forget `pg_notify('chat_message_events', …)`
|
|
12132
|
-
* so admin WS sockets can translate it into a `chat:message` frame.
|
|
12133
|
-
* Failure is swallowed — durable persistence is the correctness path.
|
|
12134
|
-
*
|
|
12135
|
-
* Strict invariants (see docs/chat-first-workspace-product-design.md
|
|
12136
|
-
* "Risk Constraints"):
|
|
12137
|
-
* - This module appends ONLY. Never edits existing fan-out / inbox /
|
|
12138
|
-
* mention-extraction code.
|
|
12139
|
-
* - Watchers (chat_subscriptions) are NEVER added to inbox_entries here.
|
|
12140
|
-
* Their counters are bumped purely as a per-user red-dot signal.
|
|
12141
|
-
* - Mention candidate set is `chat_participants` only; watchers are not
|
|
12142
|
-
* direct `@`-mention targets.
|
|
12143
|
-
*/
|
|
12144
|
-
let dispatcher = null;
|
|
12145
|
-
function registerChatMessageDispatcher(fn) {
|
|
12146
|
-
dispatcher = fn;
|
|
12147
|
-
}
|
|
12148
|
-
/**
|
|
12149
|
-
* Best-effort cross-process kick for the chat-first workspace. Call AFTER
|
|
12150
|
-
* the message transaction commits — never inside the tx. Failure logs +
|
|
12151
|
-
* drops; web reconnect refetches.
|
|
12152
|
-
*
|
|
12153
|
-
* Speakers also get an inbox NOTIFY through the existing path. They will
|
|
12154
|
-
* receive both, and the web client de-dupes naturally because both end up
|
|
12155
|
-
* invalidating the same query keys.
|
|
12156
|
-
*/
|
|
12157
|
-
function fireChatMessageKick(chatId, messageId) {
|
|
12158
|
-
if (!dispatcher) return;
|
|
12159
|
-
try {
|
|
12160
|
-
dispatcher(chatId, messageId);
|
|
12161
|
-
} catch {}
|
|
12162
|
-
}
|
|
12163
|
-
/**
|
|
12164
|
-
* Apply the post-fan-out projection. MUST be called inside the same
|
|
12165
|
-
* transaction as the message INSERT. Safe to call when `mentionedAgentIds`
|
|
12166
|
-
* is empty (degenerate case skips the mention UPDATEs).
|
|
12167
|
-
*/
|
|
12168
|
-
async function applyAfterFanOut(tx, input) {
|
|
12169
|
-
const { chatId, senderId, mentionedAgentIds, contentPreview, messageCreatedAt } = input;
|
|
12170
|
-
const previewClipped = contentPreview.length > 0 ? contentPreview.slice(0, 200) : null;
|
|
12171
|
-
const ts = messageCreatedAt ?? /* @__PURE__ */ new Date();
|
|
12172
|
-
await tx.update(chats).set({
|
|
12173
|
-
lastMessageAt: ts,
|
|
12174
|
-
lastMessagePreview: previewClipped
|
|
12175
|
-
}).where(eq(chats.id, chatId));
|
|
12176
|
-
if (mentionedAgentIds.length === 0) return;
|
|
12177
|
-
await tx.update(chatParticipants).set({ unreadMentionCount: sql`${chatParticipants.unreadMentionCount} + 1` }).where(and(eq(chatParticipants.chatId, chatId), inArray(chatParticipants.agentId, mentionedAgentIds), ne(chatParticipants.agentId, senderId)));
|
|
12178
|
-
const managerHumanAgentIds = (await tx.execute(sql`
|
|
12179
|
-
SELECT DISTINCT m.agent_id AS human_agent_id
|
|
12180
|
-
FROM agents a
|
|
12181
|
-
JOIN members m ON m.id = a.manager_id
|
|
12182
|
-
WHERE a.uuid IN ${makeUuidList(mentionedAgentIds)}
|
|
12183
|
-
AND a.type <> 'human'
|
|
12184
|
-
AND m.status = 'active'
|
|
12185
|
-
`)).map((r) => r.human_agent_id);
|
|
12186
|
-
if (managerHumanAgentIds.length === 0) return;
|
|
12187
|
-
await tx.update(chatSubscriptions).set({ unreadMentionCount: sql`${chatSubscriptions.unreadMentionCount} + 1` }).where(and(eq(chatSubscriptions.chatId, chatId), inArray(chatSubscriptions.agentId, managerHumanAgentIds)));
|
|
12188
|
-
}
|
|
12189
|
-
/**
|
|
12190
|
-
* Build a parenthesised, comma-separated list of bound parameters: `(?, ?, ?)`.
|
|
12191
|
-
* Used in raw SQL where drizzle's `inArray` can't be directly applied (e.g.
|
|
12192
|
-
* inside a hand-rolled SELECT). Always called with a non-empty list — the
|
|
12193
|
-
* caller short-circuits the empty case.
|
|
12194
|
-
*/
|
|
12195
|
-
function makeUuidList(ids) {
|
|
12196
|
-
return sql`(${sql.join(ids.map((id) => sql`${id}`), sql`, `)})`;
|
|
12197
|
-
}
|
|
12198
|
-
const log$4 = createLogger$1("message");
|
|
12199
|
-
async function sendMessage(db, chatId, senderId, data, options = {}) {
|
|
12200
|
-
return withSpan("inbox.enqueue", messageAttrs({
|
|
11608
|
+
const imageId = inline.imageId ?? randomUUID();
|
|
11609
|
+
const frame = {
|
|
11610
|
+
type: "image_payload",
|
|
11611
|
+
imageId,
|
|
12201
11612
|
chatId,
|
|
12202
|
-
|
|
12203
|
-
|
|
12204
|
-
|
|
12205
|
-
}
|
|
12206
|
-
async function sendMessageInner(db, chatId, senderId, data, options) {
|
|
12207
|
-
const txResult = await db.transaction(async (tx) => {
|
|
12208
|
-
const [participants, [chatRow], [senderRow]] = await Promise.all([
|
|
12209
|
-
tx.select({
|
|
12210
|
-
agentId: chatParticipants.agentId,
|
|
12211
|
-
inboxId: agents.inboxId,
|
|
12212
|
-
mode: chatParticipants.mode,
|
|
12213
|
-
name: agents.name
|
|
12214
|
-
}).from(chatParticipants).innerJoin(agents, eq(chatParticipants.agentId, agents.uuid)).where(eq(chatParticipants.chatId, chatId)),
|
|
12215
|
-
tx.select({ type: chats.type }).from(chats).where(eq(chats.id, chatId)).limit(1),
|
|
12216
|
-
tx.select({
|
|
12217
|
-
inboxId: agents.inboxId,
|
|
12218
|
-
organizationId: agents.organizationId
|
|
12219
|
-
}).from(agents).where(eq(agents.uuid, senderId)).limit(1)
|
|
12220
|
-
]);
|
|
12221
|
-
const chatType = chatRow?.type ?? null;
|
|
12222
|
-
if (!senderRow) throw new NotFoundError(`Sender agent "${senderId}" not found`);
|
|
12223
|
-
if (data.replyToInbox !== void 0 && data.replyToInbox !== null) {
|
|
12224
|
-
if (senderRow.inboxId !== data.replyToInbox) throw new BadRequestError("replyToInbox must reference the sender's own inbox");
|
|
12225
|
-
}
|
|
12226
|
-
const incomingMeta = data.metadata ?? {};
|
|
12227
|
-
const explicitMentionsRaw = incomingMeta.mentions;
|
|
12228
|
-
const explicitMentions = Array.isArray(explicitMentionsRaw) ? explicitMentionsRaw.filter((m) => typeof m === "string") : [];
|
|
12229
|
-
const contentText = typeof data.content === "string" ? data.content : "";
|
|
12230
|
-
const resolved = contentText ? extractMentions(contentText, participants) : [];
|
|
12231
|
-
const mergedMentions = [...new Set([...explicitMentions, ...resolved])];
|
|
12232
|
-
const metadataToStore = mergedMentions.length > 0 ? {
|
|
12233
|
-
...incomingMeta,
|
|
12234
|
-
mentions: mergedMentions
|
|
12235
|
-
} : incomingMeta;
|
|
12236
|
-
if (options.enforceGroupMention && chatType === "group") {
|
|
12237
|
-
if (mergedMentions.filter((id) => id !== senderId).length === 0) throw new BadRequestError("Sending to a group chat requires an explicit @mention. Use `agent send <name>` to message a single agent, or @<name> in the content to address one or more group members.");
|
|
12238
|
-
}
|
|
12239
|
-
let outboundContent = data.content;
|
|
12240
|
-
if (options.normalizeMentionsInContent && typeof outboundContent === "string") {
|
|
12241
|
-
const present = new Set(scanMentionTokens(outboundContent));
|
|
12242
|
-
const missingNames = [];
|
|
12243
|
-
for (const id of mergedMentions) {
|
|
12244
|
-
if (id === senderId) continue;
|
|
12245
|
-
const p = participants.find((q) => q.agentId === id);
|
|
12246
|
-
if (!p?.name) continue;
|
|
12247
|
-
if (present.has(p.name.toLowerCase())) continue;
|
|
12248
|
-
missingNames.push(p.name);
|
|
12249
|
-
}
|
|
12250
|
-
if (missingNames.length > 0) {
|
|
12251
|
-
const prefix = missingNames.map((n) => `@${n}`).join(" ");
|
|
12252
|
-
outboundContent = outboundContent.length > 0 ? `${prefix} ${outboundContent}` : prefix;
|
|
12253
|
-
}
|
|
12254
|
-
}
|
|
12255
|
-
const messageId = randomUUID();
|
|
12256
|
-
const [msg] = await tx.insert(messages).values({
|
|
12257
|
-
id: messageId,
|
|
12258
|
-
chatId,
|
|
12259
|
-
senderId,
|
|
12260
|
-
format: data.format,
|
|
12261
|
-
content: outboundContent,
|
|
12262
|
-
metadata: metadataToStore,
|
|
12263
|
-
replyToInbox: data.replyToInbox ?? null,
|
|
12264
|
-
replyToChat: data.replyToChat ?? null,
|
|
12265
|
-
inReplyTo: data.inReplyTo ?? null,
|
|
12266
|
-
source: data.source ?? null
|
|
12267
|
-
}).returning();
|
|
12268
|
-
const mentionSet = new Set(mergedMentions);
|
|
12269
|
-
const fanout = participants.filter((p) => p.agentId !== senderId).map((p) => ({
|
|
12270
|
-
agentId: p.agentId,
|
|
12271
|
-
inboxId: p.inboxId,
|
|
12272
|
-
notify: p.mode !== "mention_only" || mentionSet.has(p.agentId)
|
|
12273
|
-
}));
|
|
12274
|
-
if (fanout.length > 0) await tx.insert(inboxEntries).values(fanout.map((f) => ({
|
|
12275
|
-
inboxId: f.inboxId,
|
|
12276
|
-
messageId,
|
|
12277
|
-
chatId,
|
|
12278
|
-
notify: f.notify
|
|
12279
|
-
})));
|
|
12280
|
-
const notified = fanout.filter((f) => f.notify);
|
|
12281
|
-
const recipients = notified.map((f) => f.inboxId);
|
|
12282
|
-
const recipientAgentIds = notified.map((f) => f.agentId);
|
|
12283
|
-
if (data.inReplyTo) {
|
|
12284
|
-
const [original] = await tx.select({
|
|
12285
|
-
replyToInbox: messages.replyToInbox,
|
|
12286
|
-
replyToChat: messages.replyToChat
|
|
12287
|
-
}).from(messages).where(eq(messages.id, data.inReplyTo)).limit(1);
|
|
12288
|
-
if (original?.replyToInbox && original?.replyToChat) {
|
|
12289
|
-
await tx.insert(inboxEntries).values({
|
|
12290
|
-
inboxId: original.replyToInbox,
|
|
12291
|
-
messageId,
|
|
12292
|
-
chatId: original.replyToChat
|
|
12293
|
-
}).onConflictDoNothing();
|
|
12294
|
-
if (!recipients.includes(original.replyToInbox)) recipients.push(original.replyToInbox);
|
|
12295
|
-
}
|
|
12296
|
-
}
|
|
12297
|
-
await tx.update(chats).set({ updatedAt: /* @__PURE__ */ new Date() }).where(eq(chats.id, chatId));
|
|
12298
|
-
if (!msg) throw new Error("Unexpected: INSERT RETURNING produced no row");
|
|
12299
|
-
const previewText = typeof outboundContent === "string" ? outboundContent.trim() : "";
|
|
12300
|
-
await applyAfterFanOut(tx, {
|
|
12301
|
-
chatId,
|
|
12302
|
-
messageId: msg.id,
|
|
12303
|
-
senderId,
|
|
12304
|
-
mentionedAgentIds: mergedMentions,
|
|
12305
|
-
contentPreview: previewText,
|
|
12306
|
-
messageCreatedAt: msg.createdAt
|
|
12307
|
-
});
|
|
12308
|
-
return {
|
|
12309
|
-
message: msg,
|
|
12310
|
-
recipients,
|
|
12311
|
-
recipientAgentIds,
|
|
12312
|
-
organizationId: senderRow.organizationId
|
|
12313
|
-
};
|
|
12314
|
-
});
|
|
12315
|
-
const settled = await Promise.allSettled(txResult.recipientAgentIds.map((agentId) => upsertSessionState(db, agentId, chatId, "active", txResult.organizationId, void 0, { touchPresenceLastSeen: false })));
|
|
12316
|
-
for (let i = 0; i < settled.length; i++) {
|
|
12317
|
-
const r = settled[i];
|
|
12318
|
-
if (r?.status === "rejected") log$4.error({
|
|
12319
|
-
err: r.reason,
|
|
12320
|
-
chatId,
|
|
12321
|
-
agentId: txResult.recipientAgentIds[i]
|
|
12322
|
-
}, "predictive session activation failed");
|
|
12323
|
-
}
|
|
12324
|
-
fireChatMessageKick(chatId, txResult.message.id);
|
|
12325
|
-
return {
|
|
12326
|
-
message: txResult.message,
|
|
12327
|
-
recipients: txResult.recipients
|
|
11613
|
+
base64: inline.data,
|
|
11614
|
+
mimeType: inline.mimeType,
|
|
11615
|
+
filename: inline.filename,
|
|
11616
|
+
...inline.size !== void 0 ? { size: inline.size } : {}
|
|
12328
11617
|
};
|
|
12329
|
-
|
|
12330
|
-
|
|
12331
|
-
const
|
|
12332
|
-
|
|
12333
|
-
|
|
12334
|
-
|
|
12335
|
-
|
|
12336
|
-
|
|
12337
|
-
if (!target) throw new NotFoundError(`Agent "${targetName}" not found${/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(targetName) ? " — `agent send` expects an agent NAME, not a uuid. Run `first-tree-hub agent list` to see available names." : ""}`);
|
|
12338
|
-
const chat = await findOrCreateDirectChat(db, senderUuid, target.uuid);
|
|
12339
|
-
const incomingMeta = data.metadata ?? {};
|
|
12340
|
-
const existingMentionsRaw = incomingMeta.mentions;
|
|
12341
|
-
const existingMentions = Array.isArray(existingMentionsRaw) ? existingMentionsRaw.filter((m) => typeof m === "string") : [];
|
|
12342
|
-
const mergedMentions = existingMentions.includes(target.uuid) ? existingMentions : [...existingMentions, target.uuid];
|
|
12343
|
-
const metadata = {
|
|
12344
|
-
...incomingMeta,
|
|
12345
|
-
mentions: mergedMentions
|
|
11618
|
+
const serialised = JSON.stringify(frame);
|
|
11619
|
+
const inboxIds = await collectTargetInboxes(db, chatId, data.inReplyTo);
|
|
11620
|
+
for (const inboxId of inboxIds) notifier.pushFrameToInbox(inboxId, serialised).catch(() => {});
|
|
11621
|
+
const ref = {
|
|
11622
|
+
imageId,
|
|
11623
|
+
mimeType: inline.mimeType,
|
|
11624
|
+
filename: inline.filename,
|
|
11625
|
+
...inline.size !== void 0 ? { size: inline.size } : {}
|
|
12346
11626
|
};
|
|
12347
|
-
return sendMessage(db, chat.id, senderUuid, {
|
|
12348
|
-
format: data.format,
|
|
12349
|
-
content: data.content,
|
|
12350
|
-
metadata,
|
|
12351
|
-
replyToInbox: data.replyToInbox,
|
|
12352
|
-
replyToChat: data.replyToChat,
|
|
12353
|
-
source: data.source
|
|
12354
|
-
}, { normalizeMentionsInContent: true });
|
|
12355
|
-
}
|
|
12356
|
-
async function editMessage(db, chatId, messageId, senderId, data) {
|
|
12357
|
-
const [msg] = await db.select().from(messages).where(eq(messages.id, messageId)).limit(1);
|
|
12358
|
-
if (!msg) throw new NotFoundError(`Message "${messageId}" not found`);
|
|
12359
|
-
if (msg.chatId !== chatId) throw new NotFoundError(`Message "${messageId}" not found in this chat`);
|
|
12360
|
-
if (msg.senderId !== senderId) throw new ForbiddenError("Only the sender can edit a message");
|
|
12361
|
-
const setClause = {};
|
|
12362
|
-
if (data.format !== void 0) setClause.format = data.format;
|
|
12363
|
-
if (data.content !== void 0) setClause.content = data.content;
|
|
12364
|
-
const meta = msg.metadata ?? {};
|
|
12365
|
-
meta.editedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
12366
|
-
setClause.metadata = meta;
|
|
12367
|
-
const [updated] = await db.update(messages).set(setClause).where(eq(messages.id, messageId)).returning();
|
|
12368
|
-
if (!updated) throw new Error("Unexpected: UPDATE RETURNING produced no row");
|
|
12369
|
-
return updated;
|
|
12370
|
-
}
|
|
12371
|
-
async function listMessages(db, chatId, limit, cursor) {
|
|
12372
|
-
const where = cursor ? and(eq(messages.chatId, chatId), lt(messages.createdAt, new Date(cursor))) : eq(messages.chatId, chatId);
|
|
12373
|
-
const rows = await db.select().from(messages).where(where).orderBy(desc(messages.createdAt)).limit(limit + 1);
|
|
12374
|
-
const hasMore = rows.length > limit;
|
|
12375
|
-
const items = hasMore ? rows.slice(0, limit) : rows;
|
|
12376
|
-
const last = items[items.length - 1];
|
|
12377
11627
|
return {
|
|
12378
|
-
|
|
12379
|
-
|
|
11628
|
+
...data,
|
|
11629
|
+
content: ref
|
|
12380
11630
|
};
|
|
12381
11631
|
}
|
|
12382
|
-
const INBOX_CHANNEL = "inbox_notifications";
|
|
12383
|
-
const CONFIG_CHANNEL = "config_changes";
|
|
12384
|
-
const SESSION_STATE_CHANNEL = "session_state_changes";
|
|
12385
|
-
const RUNTIME_STATE_CHANNEL = "runtime_state_changes";
|
|
12386
11632
|
/**
|
|
12387
|
-
*
|
|
12388
|
-
*
|
|
12389
|
-
*
|
|
12390
|
-
* inbox NOTIFY path that only reaches speakers.
|
|
11633
|
+
* Mirror `sendMessage`'s fan-out set: every participant of the current
|
|
11634
|
+
* chat, plus the original requester's inbox when this message is a cross-
|
|
11635
|
+
* chat reply (see `services/message.ts` replyTo routing).
|
|
12391
11636
|
*/
|
|
12392
|
-
|
|
12393
|
-
|
|
12394
|
-
const
|
|
12395
|
-
|
|
12396
|
-
|
|
12397
|
-
|
|
12398
|
-
const chatMessageHandlers = [];
|
|
12399
|
-
let unlistenInboxFn = null;
|
|
12400
|
-
let unlistenConfigFn = null;
|
|
12401
|
-
let unlistenSessionStateFn = null;
|
|
12402
|
-
let unlistenRuntimeStateFn = null;
|
|
12403
|
-
let unlistenChatMessageFn = null;
|
|
12404
|
-
function handleNotification(payload) {
|
|
12405
|
-
const sepIdx = payload.indexOf(":");
|
|
12406
|
-
if (sepIdx === -1) return;
|
|
12407
|
-
const inboxId = payload.slice(0, sepIdx);
|
|
12408
|
-
const messageId = payload.slice(sepIdx + 1);
|
|
12409
|
-
const sockets = subscriptions.get(inboxId);
|
|
12410
|
-
if (!sockets) return;
|
|
12411
|
-
const doorbellFrame = JSON.stringify({
|
|
12412
|
-
type: "new_message",
|
|
12413
|
-
inboxId,
|
|
12414
|
-
messageId
|
|
12415
|
-
});
|
|
12416
|
-
for (const [ws, pushHandler] of sockets) {
|
|
12417
|
-
if (ws.readyState !== ws.OPEN) continue;
|
|
12418
|
-
if (pushHandler) Promise.resolve(pushHandler(messageId)).catch(() => {});
|
|
12419
|
-
else ws.send(doorbellFrame);
|
|
12420
|
-
}
|
|
11637
|
+
async function collectTargetInboxes(db, chatId, inReplyTo) {
|
|
11638
|
+
const participants = await db.select({ inboxId: agents.inboxId }).from(chatParticipants).innerJoin(agents, eq(chatParticipants.agentId, agents.uuid)).where(eq(chatParticipants.chatId, chatId));
|
|
11639
|
+
const set = new Set(participants.map((p) => p.inboxId));
|
|
11640
|
+
if (inReplyTo) {
|
|
11641
|
+
const [original] = await db.select({ replyToInbox: messages.replyToInbox }).from(messages).where(eq(messages.id, inReplyTo)).limit(1);
|
|
11642
|
+
if (original?.replyToInbox) set.add(original.replyToInbox);
|
|
12421
11643
|
}
|
|
12422
|
-
return
|
|
12423
|
-
subscribe(inboxId, ws, pushHandler) {
|
|
12424
|
-
let map = subscriptions.get(inboxId);
|
|
12425
|
-
if (!map) {
|
|
12426
|
-
map = /* @__PURE__ */ new Map();
|
|
12427
|
-
subscriptions.set(inboxId, map);
|
|
12428
|
-
}
|
|
12429
|
-
map.set(ws, pushHandler ?? null);
|
|
12430
|
-
},
|
|
12431
|
-
unsubscribe(inboxId, ws) {
|
|
12432
|
-
const map = subscriptions.get(inboxId);
|
|
12433
|
-
if (map) {
|
|
12434
|
-
map.delete(ws);
|
|
12435
|
-
if (map.size === 0) subscriptions.delete(inboxId);
|
|
12436
|
-
}
|
|
12437
|
-
},
|
|
12438
|
-
async notify(inboxId, messageId) {
|
|
12439
|
-
try {
|
|
12440
|
-
await listenClient`SELECT pg_notify(${INBOX_CHANNEL}, ${`${inboxId}:${messageId}`})`;
|
|
12441
|
-
} catch {}
|
|
12442
|
-
},
|
|
12443
|
-
async notifyConfigChange(configType) {
|
|
12444
|
-
try {
|
|
12445
|
-
await listenClient`SELECT pg_notify(${CONFIG_CHANNEL}, ${configType})`;
|
|
12446
|
-
} catch {}
|
|
12447
|
-
},
|
|
12448
|
-
async notifySessionStateChange(agentId, chatId, state, organizationId) {
|
|
12449
|
-
try {
|
|
12450
|
-
await listenClient`SELECT pg_notify(${SESSION_STATE_CHANNEL}, ${`${agentId}:${chatId}:${state}:${organizationId}`})`;
|
|
12451
|
-
} catch {}
|
|
12452
|
-
},
|
|
12453
|
-
async notifyRuntimeStateChange(agentId, state, organizationId) {
|
|
12454
|
-
try {
|
|
12455
|
-
await listenClient`SELECT pg_notify(${RUNTIME_STATE_CHANNEL}, ${`${agentId}:${state}:${organizationId}`})`;
|
|
12456
|
-
} catch {}
|
|
12457
|
-
},
|
|
12458
|
-
async notifyChatMessage(chatId, messageId) {
|
|
12459
|
-
try {
|
|
12460
|
-
await listenClient`SELECT pg_notify(${CHAT_MESSAGE_CHANNEL}, ${`${chatId}:${messageId}`})`;
|
|
12461
|
-
} catch {}
|
|
12462
|
-
},
|
|
12463
|
-
async pushFrameToInbox(inboxId, frame) {
|
|
12464
|
-
const map = subscriptions.get(inboxId);
|
|
12465
|
-
if (!map) return 0;
|
|
12466
|
-
let queued = 0;
|
|
12467
|
-
const pending = [];
|
|
12468
|
-
for (const ws of map.keys()) {
|
|
12469
|
-
if (ws.readyState !== ws.OPEN) continue;
|
|
12470
|
-
pending.push(new Promise((resolve) => {
|
|
12471
|
-
ws.send(frame, (err) => {
|
|
12472
|
-
if (!err) queued += 1;
|
|
12473
|
-
resolve();
|
|
12474
|
-
});
|
|
12475
|
-
}));
|
|
12476
|
-
}
|
|
12477
|
-
await Promise.all(pending);
|
|
12478
|
-
return queued;
|
|
12479
|
-
},
|
|
12480
|
-
onConfigChange(handler) {
|
|
12481
|
-
configChangeHandlers.push(handler);
|
|
12482
|
-
},
|
|
12483
|
-
onSessionStateChange(handler) {
|
|
12484
|
-
sessionStateChangeHandlers.push(handler);
|
|
12485
|
-
},
|
|
12486
|
-
onRuntimeStateChange(handler) {
|
|
12487
|
-
runtimeStateChangeHandlers.push(handler);
|
|
12488
|
-
},
|
|
12489
|
-
onChatMessage(handler) {
|
|
12490
|
-
chatMessageHandlers.push(handler);
|
|
12491
|
-
},
|
|
12492
|
-
async start() {
|
|
12493
|
-
unlistenInboxFn = (await listenClient.listen(INBOX_CHANNEL, (payload) => {
|
|
12494
|
-
if (payload) handleNotification(payload);
|
|
12495
|
-
})).unlisten;
|
|
12496
|
-
unlistenConfigFn = (await listenClient.listen(CONFIG_CHANNEL, (payload) => {
|
|
12497
|
-
if (payload) for (const handler of configChangeHandlers) handler(payload);
|
|
12498
|
-
})).unlisten;
|
|
12499
|
-
unlistenSessionStateFn = (await listenClient.listen(SESSION_STATE_CHANNEL, (payload) => {
|
|
12500
|
-
if (payload) {
|
|
12501
|
-
const firstSep = payload.indexOf(":");
|
|
12502
|
-
const secondSep = payload.indexOf(":", firstSep + 1);
|
|
12503
|
-
const thirdSep = payload.indexOf(":", secondSep + 1);
|
|
12504
|
-
if (firstSep > 0 && secondSep > firstSep && thirdSep > secondSep) {
|
|
12505
|
-
const agentId = payload.slice(0, firstSep);
|
|
12506
|
-
const chatId = payload.slice(firstSep + 1, secondSep);
|
|
12507
|
-
const state = payload.slice(secondSep + 1, thirdSep);
|
|
12508
|
-
const organizationId = payload.slice(thirdSep + 1);
|
|
12509
|
-
for (const handler of sessionStateChangeHandlers) handler({
|
|
12510
|
-
agentId,
|
|
12511
|
-
chatId,
|
|
12512
|
-
state,
|
|
12513
|
-
organizationId
|
|
12514
|
-
});
|
|
12515
|
-
}
|
|
12516
|
-
}
|
|
12517
|
-
})).unlisten;
|
|
12518
|
-
unlistenRuntimeStateFn = (await listenClient.listen(RUNTIME_STATE_CHANNEL, (payload) => {
|
|
12519
|
-
if (payload) {
|
|
12520
|
-
const firstSep = payload.indexOf(":");
|
|
12521
|
-
const secondSep = payload.indexOf(":", firstSep + 1);
|
|
12522
|
-
if (firstSep > 0 && secondSep > firstSep) {
|
|
12523
|
-
const agentId = payload.slice(0, firstSep);
|
|
12524
|
-
const state = payload.slice(firstSep + 1, secondSep);
|
|
12525
|
-
const organizationId = payload.slice(secondSep + 1);
|
|
12526
|
-
for (const handler of runtimeStateChangeHandlers) handler({
|
|
12527
|
-
agentId,
|
|
12528
|
-
state,
|
|
12529
|
-
organizationId
|
|
12530
|
-
});
|
|
12531
|
-
}
|
|
12532
|
-
}
|
|
12533
|
-
})).unlisten;
|
|
12534
|
-
unlistenChatMessageFn = (await listenClient.listen(CHAT_MESSAGE_CHANNEL, (payload) => {
|
|
12535
|
-
if (!payload) return;
|
|
12536
|
-
const sep = payload.indexOf(":");
|
|
12537
|
-
if (sep <= 0) return;
|
|
12538
|
-
const chatId = payload.slice(0, sep);
|
|
12539
|
-
const messageId = payload.slice(sep + 1);
|
|
12540
|
-
for (const handler of chatMessageHandlers) try {
|
|
12541
|
-
handler({
|
|
12542
|
-
chatId,
|
|
12543
|
-
messageId
|
|
12544
|
-
});
|
|
12545
|
-
} catch {}
|
|
12546
|
-
})).unlisten;
|
|
12547
|
-
},
|
|
12548
|
-
async stop() {
|
|
12549
|
-
if (unlistenInboxFn) {
|
|
12550
|
-
await unlistenInboxFn();
|
|
12551
|
-
unlistenInboxFn = null;
|
|
12552
|
-
}
|
|
12553
|
-
if (unlistenConfigFn) {
|
|
12554
|
-
await unlistenConfigFn();
|
|
12555
|
-
unlistenConfigFn = null;
|
|
12556
|
-
}
|
|
12557
|
-
if (unlistenSessionStateFn) {
|
|
12558
|
-
await unlistenSessionStateFn();
|
|
12559
|
-
unlistenSessionStateFn = null;
|
|
12560
|
-
}
|
|
12561
|
-
if (unlistenRuntimeStateFn) {
|
|
12562
|
-
await unlistenRuntimeStateFn();
|
|
12563
|
-
unlistenRuntimeStateFn = null;
|
|
12564
|
-
}
|
|
12565
|
-
if (unlistenChatMessageFn) {
|
|
12566
|
-
await unlistenChatMessageFn();
|
|
12567
|
-
unlistenChatMessageFn = null;
|
|
12568
|
-
}
|
|
12569
|
-
}
|
|
12570
|
-
};
|
|
12571
|
-
}
|
|
12572
|
-
/** Fire-and-forget: notify all recipients that a new message is available. */
|
|
12573
|
-
function notifyRecipients(notifier, recipients, messageId) {
|
|
12574
|
-
for (const inboxId of recipients) notifier.notify(inboxId, messageId).catch(() => {});
|
|
11644
|
+
return [...set];
|
|
12575
11645
|
}
|
|
12576
11646
|
const log$3 = createLogger$1("AgentMessages");
|
|
12577
11647
|
const editMessageSchema = z.object({
|
|
@@ -14704,11 +13774,21 @@ const authIdentities = pgTable("auth_identities", {
|
|
|
14704
13774
|
* treats it as a plain string and rejects every password — that's the
|
|
14705
13775
|
* intended behaviour: SaaS users cannot fall back to password login.
|
|
14706
13776
|
*/
|
|
14707
|
-
async function findOrCreateUserFromGithub(db, profile) {
|
|
14708
|
-
const [existing] = await db.select({
|
|
13777
|
+
async function findOrCreateUserFromGithub(db, profile, opts = {}) {
|
|
13778
|
+
const [existing] = await db.select({
|
|
13779
|
+
userId: authIdentities.userId,
|
|
13780
|
+
metadata: authIdentities.metadata
|
|
13781
|
+
}).from(authIdentities).where(and(eq(authIdentities.provider, "github"), eq(authIdentities.identifier, profile.githubId))).limit(1);
|
|
14709
13782
|
if (existing) {
|
|
14710
|
-
|
|
14711
|
-
|
|
13783
|
+
const patch = {};
|
|
13784
|
+
if (profile.email) patch.email = profile.email;
|
|
13785
|
+
if (opts.encryptedAccessToken) patch.metadata = {
|
|
13786
|
+
...existing.metadata ?? {},
|
|
13787
|
+
accessToken: opts.encryptedAccessToken,
|
|
13788
|
+
login: profile.login
|
|
13789
|
+
};
|
|
13790
|
+
if (Object.keys(patch).length > 0) await db.update(authIdentities).set({
|
|
13791
|
+
...patch,
|
|
14712
13792
|
updatedAt: /* @__PURE__ */ new Date()
|
|
14713
13793
|
}).where(and(eq(authIdentities.provider, "github"), eq(authIdentities.identifier, profile.githubId)));
|
|
14714
13794
|
return { userId: existing.userId };
|
|
@@ -14724,6 +13804,8 @@ async function findOrCreateUserFromGithub(db, profile) {
|
|
|
14724
13804
|
displayName: profile.displayName?.trim() || profile.login,
|
|
14725
13805
|
avatarUrl: profile.avatarUrl ?? null
|
|
14726
13806
|
});
|
|
13807
|
+
const metadata = { login: profile.login };
|
|
13808
|
+
if (opts.encryptedAccessToken) metadata.accessToken = opts.encryptedAccessToken;
|
|
14727
13809
|
await tx.insert(authIdentities).values({
|
|
14728
13810
|
id: uuidv7(),
|
|
14729
13811
|
userId,
|
|
@@ -14731,7 +13813,7 @@ async function findOrCreateUserFromGithub(db, profile) {
|
|
|
14731
13813
|
identifier: profile.githubId,
|
|
14732
13814
|
email: profile.email,
|
|
14733
13815
|
verifiedAt: /* @__PURE__ */ new Date(),
|
|
14734
|
-
metadata
|
|
13816
|
+
metadata
|
|
14735
13817
|
});
|
|
14736
13818
|
});
|
|
14737
13819
|
return { userId };
|
|
@@ -14813,13 +13895,57 @@ async function exchangeCodeForProfile(config, code, redirectUri, opts = {}) {
|
|
|
14813
13895
|
}
|
|
14814
13896
|
}
|
|
14815
13897
|
return {
|
|
14816
|
-
|
|
14817
|
-
|
|
14818
|
-
|
|
14819
|
-
|
|
14820
|
-
|
|
13898
|
+
profile: {
|
|
13899
|
+
githubId: String(user.id),
|
|
13900
|
+
login: user.login,
|
|
13901
|
+
email,
|
|
13902
|
+
displayName: user.name ?? null,
|
|
13903
|
+
avatarUrl: user.avatar_url ?? null
|
|
13904
|
+
},
|
|
13905
|
+
accessToken: tokenJson.access_token
|
|
14821
13906
|
};
|
|
14822
13907
|
}
|
|
13908
|
+
/**
|
|
13909
|
+
* Thrown when GitHub's API returns a non-2xx for a token-scoped call.
|
|
13910
|
+
* Carries the HTTP status so callers can distinguish auth failures (401 /
|
|
13911
|
+
* 403 — typically a stale token or a missing scope after we expanded to
|
|
13912
|
+
* `repo`) from transient upstream errors.
|
|
13913
|
+
*/
|
|
13914
|
+
var GithubApiError = class extends Error {
|
|
13915
|
+
constructor(status, message) {
|
|
13916
|
+
super(message);
|
|
13917
|
+
this.status = status;
|
|
13918
|
+
this.name = "GithubApiError";
|
|
13919
|
+
}
|
|
13920
|
+
};
|
|
13921
|
+
/**
|
|
13922
|
+
* Fetch the authenticated user's accessible repositories. Used by the
|
|
13923
|
+
* Step 2 repo picker. Walks paginated GitHub API responses up to the cap.
|
|
13924
|
+
*/
|
|
13925
|
+
async function listUserRepos(accessToken, opts = {}) {
|
|
13926
|
+
const fetcher = opts.fetcher ?? fetch;
|
|
13927
|
+
const perPage = opts.perPage ?? 100;
|
|
13928
|
+
const maxPages = opts.maxPages ?? 3;
|
|
13929
|
+
const out = [];
|
|
13930
|
+
for (let page = 1; page <= maxPages; page++) {
|
|
13931
|
+
const res = await fetcher(`https://api.github.com/user/repos?affiliation=owner,collaborator,organization_member&sort=pushed&per_page=${perPage}&page=${page}`, { headers: {
|
|
13932
|
+
Authorization: `Bearer ${accessToken}`,
|
|
13933
|
+
Accept: "application/vnd.github+json"
|
|
13934
|
+
} });
|
|
13935
|
+
if (!res.ok) throw new GithubApiError(res.status, `GitHub repo list failed (${res.status})`);
|
|
13936
|
+
const rows = await res.json();
|
|
13937
|
+
for (const r of rows) out.push({
|
|
13938
|
+
fullName: r.full_name,
|
|
13939
|
+
cloneUrl: r.clone_url,
|
|
13940
|
+
htmlUrl: r.html_url,
|
|
13941
|
+
private: r.private,
|
|
13942
|
+
defaultBranch: r.default_branch ?? null,
|
|
13943
|
+
pushedAt: r.pushed_at ?? null
|
|
13944
|
+
});
|
|
13945
|
+
if (rows.length < perPage) break;
|
|
13946
|
+
}
|
|
13947
|
+
return out;
|
|
13948
|
+
}
|
|
14823
13949
|
/** Insert (or reactivate) a `members` row for `userId` in `organizationId`. */
|
|
14824
13950
|
async function ensureMembership(db, data) {
|
|
14825
13951
|
const [existing] = await db.select().from(members).where(and(eq(members.userId, data.userId), eq(members.organizationId, data.organizationId))).limit(1);
|
|
@@ -14872,14 +13998,15 @@ function sanitizeAgentName(login) {
|
|
|
14872
13998
|
* - First try: `${login}` (lowercased, sanitized)
|
|
14873
13999
|
* - On collision: append a 4-char hex disambiguator
|
|
14874
14000
|
*
|
|
14875
|
-
*
|
|
14876
|
-
*
|
|
14877
|
-
*
|
|
14878
|
-
*
|
|
14001
|
+
* Default team display name is `${login}'s team` (set by the caller — see
|
|
14002
|
+
* docs/new-user-onboarding-design.md §5.5). Reads as "this is a collective
|
|
14003
|
+
* space" from day one so a later teammate-invite doesn't surface a label
|
|
14004
|
+
* that looks like a private sandbox. Users can rename via Step 1 of the
|
|
14005
|
+
* onboarding flow or Settings.
|
|
14879
14006
|
*/
|
|
14880
14007
|
async function createPersonalTeam(db, input) {
|
|
14881
14008
|
const baseSlug = sanitizeOrgSlug(input.loginSeed);
|
|
14882
|
-
const displayName = input.
|
|
14009
|
+
const displayName = input.teamDisplayName;
|
|
14883
14010
|
const orgId = uuidv7();
|
|
14884
14011
|
return {
|
|
14885
14012
|
organizationId: orgId,
|
|
@@ -15136,7 +14263,7 @@ async function githubOauthRoutes(app) {
|
|
|
15136
14263
|
client_id: oauthCfg.clientId,
|
|
15137
14264
|
redirect_uri: redirectUri,
|
|
15138
14265
|
state: token,
|
|
15139
|
-
scope: "read:user user:email",
|
|
14266
|
+
scope: "read:user user:email repo",
|
|
15140
14267
|
allow_signup: "true"
|
|
15141
14268
|
});
|
|
15142
14269
|
return reply.redirect(`https://github.com/login/oauth/authorize?${params}`, 302);
|
|
@@ -15160,17 +14287,20 @@ async function githubOauthRoutes(app) {
|
|
|
15160
14287
|
}));
|
|
15161
14288
|
const redirectUri = `${resolvePublicUrl(app, request)}/api/v1/auth/github/callback`;
|
|
15162
14289
|
let profile;
|
|
14290
|
+
let accessToken;
|
|
15163
14291
|
try {
|
|
15164
|
-
|
|
14292
|
+
const result = await exchangeCodeForProfile({
|
|
15165
14293
|
clientId: oauthCfg.clientId,
|
|
15166
14294
|
clientSecret: oauthCfg.clientSecret
|
|
15167
14295
|
}, code, redirectUri);
|
|
14296
|
+
profile = result.profile;
|
|
14297
|
+
accessToken = result.accessToken;
|
|
15168
14298
|
} catch (err) {
|
|
15169
14299
|
const msg = err instanceof Error ? err.message : "GitHub exchange failed";
|
|
15170
14300
|
app.log.warn({ err }, "github oauth code exchange failed");
|
|
15171
14301
|
return reply.status(401).send({ error: msg });
|
|
15172
14302
|
}
|
|
15173
|
-
return completeOauthFlow(app, request, reply, profile, next);
|
|
14303
|
+
return completeOauthFlow(app, request, reply, profile, next, accessToken);
|
|
15174
14304
|
});
|
|
15175
14305
|
app.get("/dev-callback", async (request, reply) => {
|
|
15176
14306
|
if (process.env.NODE_ENV === "production") return reply.status(404).send({ error: "Not found" });
|
|
@@ -15182,11 +14312,12 @@ async function githubOauthRoutes(app) {
|
|
|
15182
14312
|
email: params.email ?? null,
|
|
15183
14313
|
displayName: params.displayName ?? params.login,
|
|
15184
14314
|
avatarUrl: null
|
|
15185
|
-
}, next);
|
|
14315
|
+
}, next, process.env.DEV_GITHUB_PAT?.trim() || null);
|
|
15186
14316
|
});
|
|
15187
14317
|
}
|
|
15188
|
-
async function completeOauthFlow(app, request, reply, profile, next) {
|
|
15189
|
-
const
|
|
14318
|
+
async function completeOauthFlow(app, request, reply, profile, next, rawAccessToken) {
|
|
14319
|
+
const encryptedAccessToken = rawAccessToken ? encryptValue(rawAccessToken, app.config.secrets.encryptionKey) : void 0;
|
|
14320
|
+
const { userId } = await findOrCreateUserFromGithub(app.db, profile, { encryptedAccessToken });
|
|
15190
14321
|
let joinPath = "returning";
|
|
15191
14322
|
const inviteMatch = /^\/invite\/([^/?#]+)/.exec(next);
|
|
15192
14323
|
let resolved = false;
|
|
@@ -15212,14 +14343,21 @@ async function completeOauthFlow(app, request, reply, profile, next) {
|
|
|
15212
14343
|
next = "/";
|
|
15213
14344
|
} else if (await pickPrimaryMembership(app.db, userId)) resolved = true;
|
|
15214
14345
|
else {
|
|
15215
|
-
await createPersonalTeam(app.db, {
|
|
14346
|
+
const personal = await createPersonalTeam(app.db, {
|
|
15216
14347
|
userId,
|
|
15217
14348
|
loginSeed: profile.login,
|
|
14349
|
+
teamDisplayName: `${profile.login}'s team`,
|
|
15218
14350
|
userDisplayName: profile.displayName?.trim() || profile.login
|
|
15219
14351
|
});
|
|
15220
14352
|
joinPath = "solo";
|
|
15221
14353
|
resolved = true;
|
|
15222
14354
|
next = "/";
|
|
14355
|
+
app.log.info({
|
|
14356
|
+
event: "onboarding.team_created",
|
|
14357
|
+
userId,
|
|
14358
|
+
organizationId: personal.organizationId,
|
|
14359
|
+
source: "oauth-bootstrap"
|
|
14360
|
+
}, "onboarding funnel: team auto-created at OAuth bootstrap");
|
|
15223
14361
|
}
|
|
15224
14362
|
if (!resolved) return reply.status(500).send({ error: "Failed to resolve membership" });
|
|
15225
14363
|
const tokens = await signTokensForUser(app.config.secrets.jwtSecret, userId, app.config.auth);
|
|
@@ -15457,6 +14595,7 @@ async function transitionSessionState(db, agentId, chatId, target, from, organiz
|
|
|
15457
14595
|
totalSessions: counts?.total ?? 0,
|
|
15458
14596
|
lastSeenAt: now
|
|
15459
14597
|
}).where(eq(agentPresence.agentId, agentId));
|
|
14598
|
+
if (target === "evicted") await markSupersededByChat(tx, chatId, "chat_archived");
|
|
15460
14599
|
finalState = target;
|
|
15461
14600
|
transitioned = true;
|
|
15462
14601
|
});
|
|
@@ -15943,6 +15082,28 @@ async function chatRoutes(app) {
|
|
|
15943
15082
|
createdAt: result.message.createdAt.toISOString()
|
|
15944
15083
|
});
|
|
15945
15084
|
});
|
|
15085
|
+
/**
|
|
15086
|
+
* POST /chats/:chatId/questions/:correlationId/answer — submit an answer
|
|
15087
|
+
* to a pending agent-emitted question. Caller speaks as their human agent;
|
|
15088
|
+
* the answer is fanned out as a `format=question_answer` message back to
|
|
15089
|
+
* the original agent's inbox so the in-flight `canUseTool` callback can
|
|
15090
|
+
* resolve. Returns 409 if already answered or superseded.
|
|
15091
|
+
*/
|
|
15092
|
+
app.post("/:chatId/questions/:correlationId/answer", { config: { otelRecordBody: false } }, async (request, reply) => {
|
|
15093
|
+
const { scope } = await requireChatAccess(request, app.db);
|
|
15094
|
+
const body = submitQuestionAnswerSchema.parse(request.body);
|
|
15095
|
+
await ensureParticipant$1(app.db, request.params.chatId, scope.humanAgentId);
|
|
15096
|
+
const result = await submitAnswer(app.db, app.notifier, {
|
|
15097
|
+
correlationId: request.params.correlationId,
|
|
15098
|
+
chatId: request.params.chatId,
|
|
15099
|
+
submitterAgentId: scope.humanAgentId,
|
|
15100
|
+
answers: body.answers
|
|
15101
|
+
});
|
|
15102
|
+
return reply.status(201).send({
|
|
15103
|
+
correlationId: request.params.correlationId,
|
|
15104
|
+
messageId: result.messageId
|
|
15105
|
+
});
|
|
15106
|
+
});
|
|
15946
15107
|
/** POST /chats/:chatId/read — chat-first-workspace read cursor. Idempotent. */
|
|
15947
15108
|
app.post("/:chatId/read", async (request) => {
|
|
15948
15109
|
const { scope } = await requireChatAccess(request, app.db);
|
|
@@ -16124,6 +15285,11 @@ function applyInputDelta(namespace, current, input, encryptionKey) {
|
|
|
16124
15285
|
const inp = input;
|
|
16125
15286
|
return { webhookSecretCipher: inp.webhookSecret === void 0 ? cur.webhookSecretCipher : inp.webhookSecret === null ? void 0 : ensureEncrypted(inp.webhookSecret, encryptionKey) };
|
|
16126
15287
|
}
|
|
15288
|
+
if (namespace === "source_repos") {
|
|
15289
|
+
const cur = current;
|
|
15290
|
+
const inp = input;
|
|
15291
|
+
return { repos: inp.repos === void 0 ? cur.repos : inp.repos };
|
|
15292
|
+
}
|
|
16127
15293
|
return namespace;
|
|
16128
15294
|
}
|
|
16129
15295
|
/**
|
|
@@ -16147,6 +15313,7 @@ function toOutput(namespace, storage) {
|
|
|
16147
15313
|
webhookUrl: ""
|
|
16148
15314
|
};
|
|
16149
15315
|
}
|
|
15316
|
+
if (namespace === "source_repos") return { repos: storage.repos };
|
|
16150
15317
|
return namespace;
|
|
16151
15318
|
}
|
|
16152
15319
|
/**
|
|
@@ -16265,8 +15432,11 @@ const MAX_MARKDOWN_FILES = 1e3;
|
|
|
16265
15432
|
const MAX_MARKDOWN_FILE_BYTES = 512 * 1024;
|
|
16266
15433
|
const SNAPSHOT_CACHE_TTL_MS = 3e4;
|
|
16267
15434
|
const GIT_TIMEOUT_MS = 5e3;
|
|
15435
|
+
const GIT_SYNC_TIMEOUT_MS = 12e4;
|
|
16268
15436
|
const GIT_MAX_BUFFER = 10 * 1024 * 1024;
|
|
16269
15437
|
const GIT_LOG_RECORD_SEPARATOR = "";
|
|
15438
|
+
const REMOTE_SYNC_TTL_MS = 6e4;
|
|
15439
|
+
const REMOTE_FAILURE_TTL_MS = 3e4;
|
|
16270
15440
|
const CONTEXT_TREE_SNAPSHOT_WINDOWS = {
|
|
16271
15441
|
ONE_DAY: "1d",
|
|
16272
15442
|
SEVEN_DAYS: "7d",
|
|
@@ -16278,10 +15448,14 @@ const WINDOW_DAYS = {
|
|
|
16278
15448
|
"30d": 30
|
|
16279
15449
|
};
|
|
16280
15450
|
const snapshotCache = /* @__PURE__ */ new Map();
|
|
15451
|
+
const remoteSyncPromises = /* @__PURE__ */ new Map();
|
|
15452
|
+
const remoteLastSyncedAt = /* @__PURE__ */ new Map();
|
|
15453
|
+
const remoteLastSyncWarnings = /* @__PURE__ */ new Map();
|
|
15454
|
+
const remoteLastFailures = /* @__PURE__ */ new Map();
|
|
16281
15455
|
async function getContextTreeSnapshot(binding, window = CONTEXT_TREE_SNAPSHOT_WINDOWS.SEVEN_DAYS) {
|
|
16282
15456
|
const repo = binding.repo ?? null;
|
|
16283
15457
|
const branch = binding.branch ?? null;
|
|
16284
|
-
const resolved = resolveContextTreeRoot(repo, binding.localPath);
|
|
15458
|
+
const resolved = await resolveContextTreeRoot(repo, binding.localPath, branch, binding.githubToken);
|
|
16285
15459
|
if (!resolved.root) return unavailableSnapshot(repo, branch, resolved.reason);
|
|
16286
15460
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
16287
15461
|
try {
|
|
@@ -16291,14 +15465,13 @@ async function getContextTreeSnapshot(binding, window = CONTEXT_TREE_SNAPSHOT_WI
|
|
|
16291
15465
|
"--abbrev-ref",
|
|
16292
15466
|
"HEAD"
|
|
16293
15467
|
]);
|
|
16294
|
-
if (branch && actualBranch && actualBranch !== branch) return unavailableSnapshot(repo, actualBranch, `Context Tree checkout is on branch "${actualBranch}", but
|
|
15468
|
+
if (branch && actualBranch && actualBranch !== branch) return unavailableSnapshot(repo, actualBranch, `Context Tree checkout is on branch "${actualBranch}", but the configured Context Tree branch is "${branch}".`);
|
|
16295
15469
|
const comparisonBaseCommit = await comparisonBaseForWindow(resolved.root, window);
|
|
16296
15470
|
const cacheKey = snapshotCacheKey(resolved.root, actualBranch ?? branch, headCommit, comparisonBaseCommit, window);
|
|
16297
15471
|
const cached = snapshotCache.get(cacheKey);
|
|
16298
|
-
if (cached && cached.expiresAt > Date.now())
|
|
16299
|
-
|
|
16300
|
-
|
|
16301
|
-
};
|
|
15472
|
+
if (cached && cached.expiresAt > Date.now()) {
|
|
15473
|
+
if (!(cached.snapshot.snapshotStatus === "stale" && !resolved.staleReason)) return withSnapshotStatus(cached.snapshot, now, statusWarningFromResolved(resolved.staleReason, null));
|
|
15474
|
+
}
|
|
16302
15475
|
const tree = buildTree(await readMarkdownFiles(resolved.root));
|
|
16303
15476
|
const diffResult = comparisonBaseCommit ? await readDiffEntries(resolved.root, comparisonBaseCommit, headCommit) : {
|
|
16304
15477
|
entries: [],
|
|
@@ -16308,18 +15481,14 @@ async function getContextTreeSnapshot(binding, window = CONTEXT_TREE_SNAPSHOT_WI
|
|
|
16308
15481
|
const nodesWithGhosts = addRemovedGhostNodes(applyChangesToNodes(tree.nodes, changes), changes);
|
|
16309
15482
|
const summary = summarizeChanges(changes);
|
|
16310
15483
|
const updates = buildUpdates(changes, nodesWithGhosts);
|
|
16311
|
-
const statusWarning =
|
|
15484
|
+
const statusWarning = statusWarningFromResolved(resolved.staleReason, diffResult.truncated);
|
|
16312
15485
|
const snapshot = {
|
|
16313
15486
|
repo,
|
|
16314
15487
|
branch: actualBranch ?? branch,
|
|
16315
15488
|
headCommit,
|
|
16316
15489
|
syncedAt: now,
|
|
16317
|
-
snapshotStatus: "active",
|
|
16318
|
-
contextStatus:
|
|
16319
|
-
label: statusWarning ? "Team context needs attention" : "Team context is current",
|
|
16320
|
-
detail: statusWarning ?? "Agents have a synced team context snapshot available.",
|
|
16321
|
-
severity: statusWarning ? "warning" : "ok"
|
|
16322
|
-
},
|
|
15490
|
+
snapshotStatus: statusWarning?.stale ? "stale" : "active",
|
|
15491
|
+
contextStatus: contextStatus(statusWarning),
|
|
16323
15492
|
summary,
|
|
16324
15493
|
updates,
|
|
16325
15494
|
nodes: nodesWithGhosts,
|
|
@@ -16344,31 +15513,249 @@ function snapshotCacheKey(root, branch, headCommit, comparisonBase, window) {
|
|
|
16344
15513
|
window
|
|
16345
15514
|
].join(":");
|
|
16346
15515
|
}
|
|
16347
|
-
function
|
|
16348
|
-
if (
|
|
15516
|
+
function statusWarningFromResolved(staleReason, truncated) {
|
|
15517
|
+
if (staleReason) return {
|
|
15518
|
+
detail: `${staleReason}${truncated ? ` Showing the first ${MAX_DIFF_ENTRIES} changed files.` : ""}`,
|
|
15519
|
+
stale: true
|
|
15520
|
+
};
|
|
15521
|
+
if (truncated) return {
|
|
15522
|
+
detail: `Showing the first ${MAX_DIFF_ENTRIES} changed files.`,
|
|
15523
|
+
stale: false
|
|
15524
|
+
};
|
|
16349
15525
|
return null;
|
|
16350
15526
|
}
|
|
16351
|
-
function resolveContextTreeRoot(repo, localPath) {
|
|
16352
|
-
|
|
16353
|
-
|
|
15527
|
+
async function resolveContextTreeRoot(repo, localPath, branch, githubToken) {
|
|
15528
|
+
if (localPath && localPath.trim().length > 0) {
|
|
15529
|
+
const root = resolveLocalPath(localPath);
|
|
15530
|
+
if (existsSync(root)) return {
|
|
15531
|
+
root,
|
|
15532
|
+
reason: "ok",
|
|
15533
|
+
staleReason: null
|
|
15534
|
+
};
|
|
15535
|
+
return {
|
|
15536
|
+
root: null,
|
|
15537
|
+
reason: `Context Tree checkout not found at ${root}.`,
|
|
15538
|
+
staleReason: null
|
|
15539
|
+
};
|
|
15540
|
+
}
|
|
15541
|
+
if (!repo) return {
|
|
16354
15542
|
root: null,
|
|
16355
|
-
reason: "Context Tree is not configured."
|
|
15543
|
+
reason: "Context Tree is not configured.",
|
|
15544
|
+
staleReason: null
|
|
16356
15545
|
};
|
|
16357
|
-
|
|
16358
|
-
|
|
15546
|
+
if (isRemoteRepo(repo)) {
|
|
15547
|
+
const resolvedBranch = branch ?? "main";
|
|
15548
|
+
if (!isSafeBranchName(resolvedBranch)) return {
|
|
15549
|
+
root: null,
|
|
15550
|
+
reason: `Configured Context Tree branch "${resolvedBranch}" is invalid.`,
|
|
15551
|
+
staleReason: null
|
|
15552
|
+
};
|
|
15553
|
+
try {
|
|
15554
|
+
const materialized = await materializeRemoteContextTree(repo, resolvedBranch, void 0, githubToken);
|
|
15555
|
+
return {
|
|
15556
|
+
root: materialized.root,
|
|
15557
|
+
reason: "ok",
|
|
15558
|
+
staleReason: materialized.staleReason
|
|
15559
|
+
};
|
|
15560
|
+
} catch (error) {
|
|
15561
|
+
return {
|
|
15562
|
+
root: null,
|
|
15563
|
+
reason: `Hub could not sync the configured Context Tree repo. Check repo access and branch "${resolvedBranch}". ${errorMessage(error)}`,
|
|
15564
|
+
staleReason: null
|
|
15565
|
+
};
|
|
15566
|
+
}
|
|
15567
|
+
}
|
|
15568
|
+
const root = resolveLocalPath(repo);
|
|
16359
15569
|
if (existsSync(root)) return {
|
|
16360
15570
|
root,
|
|
16361
|
-
reason: "ok"
|
|
15571
|
+
reason: "ok",
|
|
15572
|
+
staleReason: null
|
|
16362
15573
|
};
|
|
16363
|
-
|
|
15574
|
+
return {
|
|
16364
15575
|
root: null,
|
|
16365
|
-
reason:
|
|
15576
|
+
reason: `Context Tree checkout not found at ${root}.`,
|
|
15577
|
+
staleReason: null
|
|
15578
|
+
};
|
|
15579
|
+
}
|
|
15580
|
+
function resolveLocalPath(value) {
|
|
15581
|
+
const normalized = value.startsWith("file://") ? value.slice(7) : value;
|
|
15582
|
+
return isAbsolute(normalized) ? normalize(normalized) : resolve(process.cwd(), normalized);
|
|
15583
|
+
}
|
|
15584
|
+
function isRemoteRepo(value) {
|
|
15585
|
+
return /^https?:\/\//.test(value) || /^file:\/\//.test(value) || /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(?:\.git)?$/.test(value);
|
|
15586
|
+
}
|
|
15587
|
+
function normalizeRemoteRepoUrl(value) {
|
|
15588
|
+
if (/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(?:\.git)?$/.test(value)) return `https://github.com/${value}`;
|
|
15589
|
+
return value;
|
|
15590
|
+
}
|
|
15591
|
+
function managedContextTreeCacheRoot() {
|
|
15592
|
+
return join(DEFAULT_DATA_DIR$1, "context-tree-repos");
|
|
15593
|
+
}
|
|
15594
|
+
function managedContextTreePath(repoUrl, branch, cacheRoot = managedContextTreeCacheRoot()) {
|
|
15595
|
+
return join(cacheRoot, createHash("sha256").update(`${repoUrl}\0${branch}`).digest("hex"));
|
|
15596
|
+
}
|
|
15597
|
+
async function materializeRemoteContextTree(repo, branch, cacheRoot = managedContextTreeCacheRoot(), githubToken) {
|
|
15598
|
+
const repoUrl = normalizeRemoteRepoUrl(repo);
|
|
15599
|
+
const root = managedContextTreePath(repoUrl, branch, cacheRoot);
|
|
15600
|
+
const lastSyncedAt = remoteLastSyncedAt.get(root);
|
|
15601
|
+
if (lastSyncedAt && Date.now() - lastSyncedAt < REMOTE_SYNC_TTL_MS && existsSync(join(root, ".git"))) return {
|
|
15602
|
+
root,
|
|
15603
|
+
staleReason: remoteLastSyncWarnings.get(root) ?? null
|
|
15604
|
+
};
|
|
15605
|
+
const lastFailure = remoteLastFailures.get(root);
|
|
15606
|
+
if (lastFailure && Date.now() - lastFailure.failedAt < REMOTE_FAILURE_TTL_MS && !existsSync(join(root, ".git"))) throw new Error(lastFailure.reason);
|
|
15607
|
+
const existing = remoteSyncPromises.get(root);
|
|
15608
|
+
if (existing) return {
|
|
15609
|
+
root,
|
|
15610
|
+
staleReason: (await existing).staleReason
|
|
15611
|
+
};
|
|
15612
|
+
const syncPromise = syncRemoteContextTree(repoUrl, branch, root, cacheRoot, githubToken);
|
|
15613
|
+
remoteSyncPromises.set(root, syncPromise);
|
|
15614
|
+
try {
|
|
15615
|
+
const syncResult = await syncPromise;
|
|
15616
|
+
remoteLastSyncedAt.set(root, Date.now());
|
|
15617
|
+
remoteLastFailures.delete(root);
|
|
15618
|
+
if (syncResult.staleReason) remoteLastSyncWarnings.set(root, syncResult.staleReason);
|
|
15619
|
+
else remoteLastSyncWarnings.delete(root);
|
|
15620
|
+
return {
|
|
15621
|
+
root,
|
|
15622
|
+
staleReason: syncResult.staleReason
|
|
15623
|
+
};
|
|
15624
|
+
} catch (error) {
|
|
15625
|
+
if (!existsSync(join(root, ".git"))) remoteLastFailures.set(root, {
|
|
15626
|
+
failedAt: Date.now(),
|
|
15627
|
+
reason: `Previous Context Tree sync failed recently. ${errorMessage(error)}`
|
|
15628
|
+
});
|
|
15629
|
+
throw error;
|
|
15630
|
+
} finally {
|
|
15631
|
+
remoteSyncPromises.delete(root);
|
|
15632
|
+
}
|
|
15633
|
+
}
|
|
15634
|
+
async function syncRemoteContextTree(repoUrl, branch, root, cacheRoot, githubToken) {
|
|
15635
|
+
await mkdir(cacheRoot, { recursive: true });
|
|
15636
|
+
const env = await gitAuthEnv(repoUrl, cacheRoot, githubToken);
|
|
15637
|
+
if (!existsSync(join(root, ".git"))) {
|
|
15638
|
+
await rm(root, {
|
|
15639
|
+
recursive: true,
|
|
15640
|
+
force: true
|
|
15641
|
+
});
|
|
15642
|
+
await gitOutput(cacheRoot, [
|
|
15643
|
+
"clone",
|
|
15644
|
+
"--branch",
|
|
15645
|
+
branch,
|
|
15646
|
+
"--single-branch",
|
|
15647
|
+
repoUrl,
|
|
15648
|
+
root
|
|
15649
|
+
], {
|
|
15650
|
+
timeout: GIT_SYNC_TIMEOUT_MS,
|
|
15651
|
+
env,
|
|
15652
|
+
disableHooks: true
|
|
15653
|
+
});
|
|
15654
|
+
return { staleReason: null };
|
|
15655
|
+
}
|
|
15656
|
+
try {
|
|
15657
|
+
await gitOutput(root, [
|
|
15658
|
+
"remote",
|
|
15659
|
+
"set-url",
|
|
15660
|
+
"origin",
|
|
15661
|
+
repoUrl
|
|
15662
|
+
], {
|
|
15663
|
+
timeout: GIT_TIMEOUT_MS,
|
|
15664
|
+
disableHooks: true
|
|
15665
|
+
});
|
|
15666
|
+
await gitOutput(root, [
|
|
15667
|
+
"fetch",
|
|
15668
|
+
"origin",
|
|
15669
|
+
branch,
|
|
15670
|
+
"--prune"
|
|
15671
|
+
], {
|
|
15672
|
+
timeout: GIT_SYNC_TIMEOUT_MS,
|
|
15673
|
+
env,
|
|
15674
|
+
disableHooks: true
|
|
15675
|
+
});
|
|
15676
|
+
await gitOutput(root, [
|
|
15677
|
+
"checkout",
|
|
15678
|
+
"-B",
|
|
15679
|
+
branch,
|
|
15680
|
+
`origin/${branch}`
|
|
15681
|
+
], {
|
|
15682
|
+
timeout: GIT_TIMEOUT_MS,
|
|
15683
|
+
disableHooks: true
|
|
15684
|
+
});
|
|
15685
|
+
return { staleReason: null };
|
|
15686
|
+
} catch (error) {
|
|
15687
|
+
if (existsSync(join(root, ".git"))) return { staleReason: `Showing the last synced Context Tree snapshot because Hub could not refresh the configured repo. ${errorMessage(error)}` };
|
|
15688
|
+
throw error;
|
|
15689
|
+
}
|
|
15690
|
+
}
|
|
15691
|
+
async function gitAuthEnv(repoUrl, cacheRoot, githubToken) {
|
|
15692
|
+
if (!githubToken || !isGithubHttpsRepo(repoUrl)) return void 0;
|
|
15693
|
+
const askpassPath = join(cacheRoot, ".tools", "git-askpass.sh");
|
|
15694
|
+
if (!existsSync(askpassPath)) {
|
|
15695
|
+
await mkdir(dirname(askpassPath), { recursive: true });
|
|
15696
|
+
await writeFile(askpassPath, [
|
|
15697
|
+
"#!/bin/sh",
|
|
15698
|
+
"case \"$1\" in",
|
|
15699
|
+
"*Username*) printf \"%s\\n\" \"$GIT_USERNAME\" ;;",
|
|
15700
|
+
"*) printf \"%s\\n\" \"$GIT_PASSWORD\" ;;",
|
|
15701
|
+
"esac",
|
|
15702
|
+
""
|
|
15703
|
+
].join("\n"), "utf8");
|
|
15704
|
+
await chmod(askpassPath, 448);
|
|
15705
|
+
}
|
|
15706
|
+
return {
|
|
15707
|
+
...process.env,
|
|
15708
|
+
GIT_ASKPASS: askpassPath,
|
|
15709
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
15710
|
+
GIT_USERNAME: "x-access-token",
|
|
15711
|
+
GIT_PASSWORD: githubToken
|
|
15712
|
+
};
|
|
15713
|
+
}
|
|
15714
|
+
function isGithubHttpsRepo(repoUrl) {
|
|
15715
|
+
try {
|
|
15716
|
+
const url = new URL(repoUrl);
|
|
15717
|
+
return url.protocol === "https:" && url.hostname.toLowerCase() === "github.com";
|
|
15718
|
+
} catch {
|
|
15719
|
+
return false;
|
|
15720
|
+
}
|
|
15721
|
+
}
|
|
15722
|
+
function contextStatus(warning) {
|
|
15723
|
+
if (warning?.stale) return {
|
|
15724
|
+
label: "Team context is stale",
|
|
15725
|
+
detail: warning.detail,
|
|
15726
|
+
severity: "warning"
|
|
15727
|
+
};
|
|
15728
|
+
if (warning) return {
|
|
15729
|
+
label: "Team context needs attention",
|
|
15730
|
+
detail: warning.detail,
|
|
15731
|
+
severity: "warning"
|
|
16366
15732
|
};
|
|
16367
15733
|
return {
|
|
16368
|
-
|
|
16369
|
-
|
|
15734
|
+
label: "Team context is current",
|
|
15735
|
+
detail: "Agents have a synced team context snapshot available.",
|
|
15736
|
+
severity: "ok"
|
|
15737
|
+
};
|
|
15738
|
+
}
|
|
15739
|
+
function withSnapshotStatus(snapshot, syncedAt, warning) {
|
|
15740
|
+
return {
|
|
15741
|
+
...snapshot,
|
|
15742
|
+
syncedAt,
|
|
15743
|
+
snapshotStatus: warning?.stale ? "stale" : snapshot.snapshotStatus,
|
|
15744
|
+
contextStatus: warning ? contextStatus(warning) : snapshot.contextStatus
|
|
16370
15745
|
};
|
|
16371
15746
|
}
|
|
15747
|
+
function isSafeBranchName(branch) {
|
|
15748
|
+
if (branch.startsWith("-")) return false;
|
|
15749
|
+
if (branch.includes("..") || branch.includes("@{") || branch.includes("\\")) return false;
|
|
15750
|
+
return /^[A-Za-z0-9._/-]+$/.test(branch);
|
|
15751
|
+
}
|
|
15752
|
+
function errorMessage(error) {
|
|
15753
|
+
if (!(error instanceof Error) || error.message.trim().length === 0) return "";
|
|
15754
|
+
return redactSecret(error.message.trim().split("\n")[0] ?? "");
|
|
15755
|
+
}
|
|
15756
|
+
function redactSecret(message) {
|
|
15757
|
+
return message.replace(/(https?:\/\/)[^/@\s]+@/g, "$1[redacted]@").replace(/\bghp_[A-Za-z0-9_]+/g, "[redacted]").replace(/\bgithub_pat_[A-Za-z0-9_]+/g, "[redacted]");
|
|
15758
|
+
}
|
|
16372
15759
|
function unavailableSnapshot(repo, branch, detail) {
|
|
16373
15760
|
return {
|
|
16374
15761
|
repo,
|
|
@@ -16393,11 +15780,16 @@ function unavailableSnapshot(repo, branch, detail) {
|
|
|
16393
15780
|
changes: []
|
|
16394
15781
|
};
|
|
16395
15782
|
}
|
|
16396
|
-
async function gitOutput(cwd, args) {
|
|
16397
|
-
const { stdout } = await execFileAsync("git",
|
|
15783
|
+
async function gitOutput(cwd, args, options) {
|
|
15784
|
+
const { stdout } = await execFileAsync("git", options?.disableHooks ? [
|
|
15785
|
+
"-c",
|
|
15786
|
+
"core.hooksPath=/dev/null",
|
|
15787
|
+
...args
|
|
15788
|
+
] : args, {
|
|
16398
15789
|
cwd,
|
|
16399
|
-
timeout: GIT_TIMEOUT_MS,
|
|
16400
|
-
maxBuffer: GIT_MAX_BUFFER
|
|
15790
|
+
timeout: options?.timeout ?? GIT_TIMEOUT_MS,
|
|
15791
|
+
maxBuffer: GIT_MAX_BUFFER,
|
|
15792
|
+
env: options?.env
|
|
16401
15793
|
});
|
|
16402
15794
|
return stdout.trim();
|
|
16403
15795
|
}
|
|
@@ -16955,10 +16347,40 @@ async function contextTreeSnapshotRoutes(app) {
|
|
|
16955
16347
|
const query = querySchema.parse(request.query);
|
|
16956
16348
|
const { userId } = requireUser(request);
|
|
16957
16349
|
const orgId = await resolveUserPrimaryOrgId(app.db, userId);
|
|
16958
|
-
const
|
|
16350
|
+
const binding = orgId ? await getOrgContextTree(app.db, orgId) : {};
|
|
16351
|
+
const githubToken = contextTreeGithubTokenForRepo(binding.repo, app.config.contextTreeSync);
|
|
16352
|
+
const snapshot = await getContextTreeSnapshot({
|
|
16353
|
+
...binding,
|
|
16354
|
+
githubToken
|
|
16355
|
+
}, query.window ?? "7d");
|
|
16959
16356
|
return contextTreeSnapshotSchema.parse(snapshot);
|
|
16960
16357
|
});
|
|
16961
16358
|
}
|
|
16359
|
+
function contextTreeGithubTokenForRepo(repo, syncConfig) {
|
|
16360
|
+
if (!repo || !syncConfig?.githubToken) return void 0;
|
|
16361
|
+
const repoKey = githubRepoKey(repo);
|
|
16362
|
+
if (!repoKey) return void 0;
|
|
16363
|
+
return new Set((syncConfig.githubTokenRepos ?? "").split(",").map((entry) => normalizeGithubRepoKey(entry)).filter((entry) => entry !== null)).has(repoKey) ? syncConfig.githubToken : void 0;
|
|
16364
|
+
}
|
|
16365
|
+
function githubRepoKey(value) {
|
|
16366
|
+
const shorthand = normalizeGithubRepoKey(value);
|
|
16367
|
+
if (shorthand) return shorthand;
|
|
16368
|
+
let url;
|
|
16369
|
+
try {
|
|
16370
|
+
url = new URL(value);
|
|
16371
|
+
} catch {
|
|
16372
|
+
return null;
|
|
16373
|
+
}
|
|
16374
|
+
if (url.protocol !== "https:" || url.hostname.toLowerCase() !== "github.com") return null;
|
|
16375
|
+
if (url.username || url.password) return null;
|
|
16376
|
+
return normalizeGithubRepoKey(url.pathname.replace(/^\/+/, ""));
|
|
16377
|
+
}
|
|
16378
|
+
function normalizeGithubRepoKey(value) {
|
|
16379
|
+
const trimmed = value.trim().replace(/^\/+/, "").replace(/\.git$/i, "");
|
|
16380
|
+
const match = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/.exec(trimmed);
|
|
16381
|
+
if (!match) return null;
|
|
16382
|
+
return `${match[1]?.toLowerCase()}/${match[2]?.toLowerCase()}`;
|
|
16383
|
+
}
|
|
16962
16384
|
/**
|
|
16963
16385
|
* Resolve the client IP for rate-limit attribution.
|
|
16964
16386
|
*
|
|
@@ -17060,7 +16482,7 @@ async function healthzRoutes(app) {
|
|
|
17060
16482
|
* `api/orgs/invitations.ts` (Class B, admin-gated).
|
|
17061
16483
|
*/
|
|
17062
16484
|
async function publicInvitationRoutes(app) {
|
|
17063
|
-
const { previewInvitation } = await import("./invitation-
|
|
16485
|
+
const { previewInvitation } = await import("./invitation-C299fxkP-KyCNax4T.mjs");
|
|
17064
16486
|
app.get("/:token/preview", async (request, reply) => {
|
|
17065
16487
|
if (!request.params.token) throw new UnauthorizedError("Token required");
|
|
17066
16488
|
const preview = await previewInvitation(app.db, request.params.token);
|
|
@@ -17083,7 +16505,8 @@ async function meRoutes(app) {
|
|
|
17083
16505
|
id: users.id,
|
|
17084
16506
|
username: users.username,
|
|
17085
16507
|
displayName: users.displayName,
|
|
17086
|
-
avatarUrl: users.avatarUrl
|
|
16508
|
+
avatarUrl: users.avatarUrl,
|
|
16509
|
+
onboardingDismissedAt: users.onboardingDismissedAt
|
|
17087
16510
|
}).from(users).where(eq(users.id, userId)).limit(1);
|
|
17088
16511
|
const memberships = await listActiveMemberships(app.db, userId);
|
|
17089
16512
|
const defaultMembership = pickDefaultMembership(memberships.map((m) => ({
|
|
@@ -17098,7 +16521,7 @@ async function meRoutes(app) {
|
|
|
17098
16521
|
if (inv) inviteUrl = buildInviteUrl(resolvePublicUrl(app, request), inv.token);
|
|
17099
16522
|
}
|
|
17100
16523
|
}
|
|
17101
|
-
const
|
|
16524
|
+
const onboardingStep = await inferOnboardingStep(app, userId);
|
|
17102
16525
|
return {
|
|
17103
16526
|
user: user ?? null,
|
|
17104
16527
|
defaultOrganizationId: defaultOrgId,
|
|
@@ -17109,11 +16532,93 @@ async function meRoutes(app) {
|
|
|
17109
16532
|
role: mb.role,
|
|
17110
16533
|
agentId: mb.agentId
|
|
17111
16534
|
})),
|
|
17112
|
-
|
|
16535
|
+
onboarding: {
|
|
16536
|
+
step: onboardingStep,
|
|
16537
|
+
dismissedAt: user?.onboardingDismissedAt ? user.onboardingDismissedAt.toISOString() : null
|
|
16538
|
+
},
|
|
17113
16539
|
inviteUrl
|
|
17114
16540
|
};
|
|
17115
16541
|
});
|
|
17116
16542
|
/**
|
|
16543
|
+
* PATCH /me/onboarding — currently the only mutable field is
|
|
16544
|
+
* `dismissed`, set when the user clicks `✕` on the onboarding stepper.
|
|
16545
|
+
* Stamping NOW() server-side avoids client-clock skew. Idempotent: a
|
|
16546
|
+
* second PATCH leaves the original timestamp in place.
|
|
16547
|
+
*
|
|
16548
|
+
* See docs/new-user-onboarding-design.md §8.4.
|
|
16549
|
+
*/
|
|
16550
|
+
app.patch("/me/onboarding", async (request, reply) => {
|
|
16551
|
+
const { userId } = requireUser(request);
|
|
16552
|
+
const body = patchOnboardingSchema.parse(request.body);
|
|
16553
|
+
if (body.dismissed === true) {
|
|
16554
|
+
if ((await app.db.update(users).set({ onboardingDismissedAt: /* @__PURE__ */ new Date() }).where(and(eq(users.id, userId), isNull(users.onboardingDismissedAt))).returning({ id: users.id })).length > 0) app.log.info({
|
|
16555
|
+
event: "onboarding.dismissed",
|
|
16556
|
+
userId
|
|
16557
|
+
}, "onboarding funnel: stepper dismissed");
|
|
16558
|
+
} else if (body.dismissed === false) await app.db.update(users).set({ onboardingDismissedAt: null }).where(eq(users.id, userId));
|
|
16559
|
+
const [u] = await app.db.select({ onboardingDismissedAt: users.onboardingDismissedAt }).from(users).where(eq(users.id, userId)).limit(1);
|
|
16560
|
+
return reply.status(200).send({ dismissedAt: u?.onboardingDismissedAt ? u.onboardingDismissedAt.toISOString() : null });
|
|
16561
|
+
});
|
|
16562
|
+
/**
|
|
16563
|
+
* POST /me/onboarding/events — web-side onboarding funnel reporter.
|
|
16564
|
+
* Server-side milestones (`team_created` at OAuth, `dismissed` on PATCH)
|
|
16565
|
+
* are emitted directly; this endpoint surfaces the web-driven ones into
|
|
16566
|
+
* the same log stream so a single funnel query covers the full flow.
|
|
16567
|
+
* Body shape is enum-validated so the server won't log arbitrary names.
|
|
16568
|
+
*
|
|
16569
|
+
* Rate-limited to keep a buggy or hostile authenticated tab from
|
|
16570
|
+
* flooding the log stream. The cap is generous relative to legitimate
|
|
16571
|
+
* funnel traffic (≤ 4 events per onboarding pass).
|
|
16572
|
+
*/
|
|
16573
|
+
app.post("/me/onboarding/events", { config: { rateLimit: {
|
|
16574
|
+
max: 60,
|
|
16575
|
+
timeWindow: "1 minute"
|
|
16576
|
+
} } }, async (request, reply) => {
|
|
16577
|
+
const { userId } = requireUser(request);
|
|
16578
|
+
const body = onboardingEventSchema.parse(request.body);
|
|
16579
|
+
app.log.info({
|
|
16580
|
+
...body.attrs ?? {},
|
|
16581
|
+
event: `onboarding.${body.event}`,
|
|
16582
|
+
userId
|
|
16583
|
+
}, `onboarding funnel: ${body.event}`);
|
|
16584
|
+
return reply.status(204).send();
|
|
16585
|
+
});
|
|
16586
|
+
/**
|
|
16587
|
+
* GET /me/github/repos — list the caller's accessible GitHub repos. Used
|
|
16588
|
+
* by the Step 2 onboarding repo picker. The OAuth access token was
|
|
16589
|
+
* captured at sign-in (encrypted at rest in `auth_identities.metadata`)
|
|
16590
|
+
* so this endpoint avoids a second redirect.
|
|
16591
|
+
*
|
|
16592
|
+
* 503 if the user has no GitHub identity bound or the token wasn't
|
|
16593
|
+
* captured (e.g. dev-callback sign-in or pre-redesign user). The web
|
|
16594
|
+
* client falls back to a "Reconnect GitHub" hint in that case.
|
|
16595
|
+
*/
|
|
16596
|
+
app.get("/me/github/repos", async (request, reply) => {
|
|
16597
|
+
const { userId } = requireUser(request);
|
|
16598
|
+
const [identity] = await app.db.select({ metadata: authIdentities.metadata }).from(authIdentities).where(and(eq(authIdentities.userId, userId), eq(authIdentities.provider, "github"))).limit(1);
|
|
16599
|
+
const encrypted = identity?.metadata && typeof identity.metadata === "object" && "accessToken" in identity.metadata ? identity.metadata.accessToken : void 0;
|
|
16600
|
+
if (typeof encrypted !== "string" || !encrypted) return reply.status(503).send({ error: "GitHub access token unavailable — please reconnect your account" });
|
|
16601
|
+
let token;
|
|
16602
|
+
try {
|
|
16603
|
+
token = decryptValue(encrypted, app.config.secrets.encryptionKey);
|
|
16604
|
+
} catch {
|
|
16605
|
+
return reply.status(503).send({ error: "GitHub access token could not be decoded — please reconnect" });
|
|
16606
|
+
}
|
|
16607
|
+
try {
|
|
16608
|
+
return { repos: await listUserRepos(token) };
|
|
16609
|
+
} catch (err) {
|
|
16610
|
+
app.log.warn({
|
|
16611
|
+
err,
|
|
16612
|
+
userId
|
|
16613
|
+
}, "list github repos failed");
|
|
16614
|
+
if (err instanceof GithubApiError && (err.status === 401 || err.status === 403)) return reply.status(403).send({
|
|
16615
|
+
error: "GitHub access token is missing the `repo` scope. Please reconnect your GitHub account.",
|
|
16616
|
+
code: "scope_missing"
|
|
16617
|
+
});
|
|
16618
|
+
return reply.status(502).send({ error: "Couldn't reach GitHub. Try again, or reconnect your GitHub account." });
|
|
16619
|
+
}
|
|
16620
|
+
});
|
|
16621
|
+
/**
|
|
17117
16622
|
* POST /me/connect-tokens — short-lived connect token for the CLI.
|
|
17118
16623
|
* The token now carries only `sub = userId`; the CLI rejoins via
|
|
17119
16624
|
* `exchangeConnectToken` which probes `members` realtime.
|
|
@@ -17157,7 +16662,7 @@ async function meRoutes(app) {
|
|
|
17157
16662
|
*/
|
|
17158
16663
|
app.get("/me/pinned-agents", async (request) => {
|
|
17159
16664
|
const { userId } = requireUser(request);
|
|
17160
|
-
const { listMyPinnedAgents } = await import("./client-
|
|
16665
|
+
const { listMyPinnedAgents } = await import("./client-BhCtO2df-BGOu-rRN.mjs");
|
|
17161
16666
|
return listMyPinnedAgents(app.db, { userId });
|
|
17162
16667
|
});
|
|
17163
16668
|
/**
|
|
@@ -17263,24 +16768,24 @@ async function meRoutes(app) {
|
|
|
17263
16768
|
return reply.status(204).send();
|
|
17264
16769
|
});
|
|
17265
16770
|
/**
|
|
17266
|
-
* GET /me/
|
|
17267
|
-
* full /me payload. Same logic as
|
|
16771
|
+
* GET /me/onboarding-step — bare endpoint for clients that don't want the
|
|
16772
|
+
* full /me payload. Same logic as inferOnboardingStep below.
|
|
17268
16773
|
*/
|
|
17269
|
-
app.get("/me/
|
|
16774
|
+
app.get("/me/onboarding-step", async (request) => {
|
|
17270
16775
|
const { userId } = requireUser(request);
|
|
17271
|
-
return { step: await
|
|
16776
|
+
return { step: await inferOnboardingStep(app, userId) };
|
|
17272
16777
|
});
|
|
17273
16778
|
}
|
|
17274
16779
|
/**
|
|
17275
|
-
* Infer the onboarding
|
|
16780
|
+
* Infer the onboarding step from the *user-level* facts:
|
|
17276
16781
|
* - has at least one client → past "connect"
|
|
17277
16782
|
* - manages at least one non-human active agent (any org) → past "create_agent"
|
|
17278
16783
|
*
|
|
17279
16784
|
* Critically: the join from agents → members → userId means a user with
|
|
17280
|
-
* memberships across multiple orgs has
|
|
16785
|
+
* memberships across multiple orgs has onboarding satisfied as soon as ANY
|
|
17281
16786
|
* org has a non-human agent — matching the user-level mental model.
|
|
17282
16787
|
*/
|
|
17283
|
-
async function
|
|
16788
|
+
async function inferOnboardingStep(app, userId) {
|
|
17284
16789
|
const [hasClient] = await app.db.select({ id: clients.id }).from(clients).where(eq(clients.userId, userId)).limit(1);
|
|
17285
16790
|
if (!hasClient) return "connect";
|
|
17286
16791
|
const [hasAgent] = await app.db.select({ uuid: agents.uuid }).from(agents).innerJoin(members, eq(members.id, agents.managerId)).where(and(eq(members.userId, userId), eq(members.status, "active"), ne(agents.type, "human"), eq(agents.status, "active"))).limit(1);
|
|
@@ -17888,14 +17393,18 @@ async function orgSessionRoutes(app) {
|
|
|
17888
17393
|
* adding a new config group only requires registering it there — no new
|
|
17889
17394
|
* route file.
|
|
17890
17395
|
*
|
|
17891
|
-
*
|
|
17892
|
-
*
|
|
17893
|
-
*
|
|
17396
|
+
* GET gating is per-namespace via `readPolicy` in the registry: namespaces
|
|
17397
|
+
* with no secret fields (`context_tree`, `source_repos`) are readable by
|
|
17398
|
+
* any active org member, so an invitee can see what tree and repos the
|
|
17399
|
+
* team is bound to before joining the chat. Namespaces whose masked output
|
|
17400
|
+
* still leaks a `…Configured` boolean (`github_integration`) stay
|
|
17401
|
+
* admin-only. PUT and DELETE are always admin-only regardless of
|
|
17402
|
+
* namespace — non-admins must never mutate org-wide config.
|
|
17894
17403
|
*/
|
|
17895
17404
|
async function orgSettingsRoutes(app) {
|
|
17896
17405
|
app.get("/:namespace", async (request) => {
|
|
17897
|
-
const scope = await requireOrgAdmin(request, app.db);
|
|
17898
17406
|
const namespace = parseNamespace(request.params.namespace);
|
|
17407
|
+
const scope = ORG_SETTINGS_NAMESPACES$1[namespace].readPolicy === "member" ? await requireOrgMembership(request, app.db) : await requireOrgAdmin(request, app.db);
|
|
17899
17408
|
return enrichOutput(namespace, await getOrgSetting(app.db, scope.organizationId, namespace), scope.organizationId, app.config.server.publicUrl);
|
|
17900
17409
|
});
|
|
17901
17410
|
app.put("/:namespace", { config: { otelRecordBody: true } }, async (request) => {
|
|
@@ -18735,6 +18244,7 @@ var schema_exports = /* @__PURE__ */ __exportAll({
|
|
|
18735
18244
|
notifications: () => notifications,
|
|
18736
18245
|
organizationSettings: () => organizationSettings,
|
|
18737
18246
|
organizations: () => organizations,
|
|
18247
|
+
pendingQuestions: () => pendingQuestions,
|
|
18738
18248
|
serverInstances: () => serverInstances,
|
|
18739
18249
|
sessionEvents: () => sessionEvents,
|
|
18740
18250
|
taskChats: () => taskChats,
|
|
@@ -20396,7 +19906,7 @@ async function startServer(options) {
|
|
|
20396
19906
|
instanceId: `srv_${randomUUID().slice(0, 8)}`,
|
|
20397
19907
|
commandVersion: COMMAND_VERSION
|
|
20398
19908
|
};
|
|
20399
|
-
const { initTelemetry, shutdownTelemetry } = await import("./observability-
|
|
19909
|
+
const { initTelemetry, shutdownTelemetry } = await import("./observability-eLA9iNK_.mjs");
|
|
20400
19910
|
await initTelemetry(serverConfig.observability.tracing, config.instanceId);
|
|
20401
19911
|
const app = await buildApp(config);
|
|
20402
19912
|
const SHUTDOWN_FORCE_EXIT_MS = 8e3;
|