@agent-team-foundation/first-tree-hub 0.12.6 → 0.12.8
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/cli/index.mjs +39 -4
- package/dist/{client-GOgUQxVe-Dqk9oZf9.mjs → client-DNEtPEBu-BtHkUya2.mjs} +2 -2
- package/dist/{client-B89AKi3Q-DAyGdQSq.mjs → client-bR8nwHaV-OxnjyKOk.mjs} +316 -277
- package/dist/{dist-xP6NpdMp.mjs → dist-CnjqakXS.mjs} +46 -12
- package/dist/drizzle/0038_chat_membership_user_state.sql +223 -0
- package/dist/drizzle/0039_drop_chat_participants_subscriptions.sql +26 -0
- package/dist/drizzle/0040_chat_user_state_engagement.sql +24 -0
- package/dist/drizzle/meta/_journal.json +21 -0
- package/dist/{feishu-CsfadBKa.mjs → feishu-DrnBbl8T.mjs} +1 -1
- package/dist/index.mjs +4 -4
- package/dist/{invitation-C299fxkP-DFBBuUcj.mjs → invitation-C299fxkP-KKslbta2.mjs} +1 -1
- package/dist/{saas-connect-RCN8zL5e.mjs → saas-connect-CLcon-De.mjs} +308 -135
- package/dist/web/assets/index-BPMrSv_A.js +11 -0
- package/dist/web/assets/{index-BdW7weV1.css → index-DxAYxUpz.css} +1 -1
- package/dist/web/assets/index-ntmzuk5X.js +421 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-BHNq2Nl1.js +0 -16
- package/dist/web/assets/index-BaLvRwAX.js +0 -416
|
@@ -2,10 +2,10 @@ import { a as __toCommonJS, o as __toESM, t as __commonJSMin } from "./chunk-BSw
|
|
|
2
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
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-BCZC1ki6.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 { A as
|
|
5
|
+
import { $ as patchOnboardingSchema, A as defaultRuntimeConfigPayload, B as inboxDeliverFrameSchema$1, C as createAdapterMappingSchema, Ct as updateOrganizationSchema, D as createMemberSchema, E as createMeChatSchema, F as githubCallbackQuerySchema, G as joinByInvitationSchema, H as isOrgSettingNamespace, I as githubDevCallbackQuerySchema, J as messageSourceSchema$1, K as listMeChatsQuerySchema, L as githubStartQuerySchema, M as dryRunAgentRuntimeConfigSchema, O as createOrgFromMeSchema, P as githubAppInstallationClaimBodySchema, Q as patchChatEngagementSchema, R as imageInlineContentSchema, S as createAdapterConfigSchema, St as updateMemberSchema, T as createChatSchema, U as isRedactedEnvValue, V as inboxPollQuerySchema, W as isReservedAgentName$1, X as onboardingEventSchema, Y as notificationQuerySchema, Z as paginationQuerySchema, _ as chatMetadataSchema$1, _t as updateAdapterConfigSchema, a as AGENT_VISIBILITY, at as safeRedirectPath, b as connectTokenExchangeSchema, bt as updateChatSchema, c as MENTION_REGEX, ct as sendMessageSchema, d as addMeChatParticipantsSchema, dt as sessionEventMessageSchema, f as addParticipantSchema, ft as sessionEventSchema$1, g as agentTypeSchema$1, gt as submitQuestionAnswerSchema, h as agentRuntimeConfigPayloadSchema$1, ht as stripCode, i as AGENT_STATUSES, it as runtimeStateMessageSchema, j as delegateFeishuUserSchema, l as ORG_SETTINGS_NAMESPACES$1, lt as sendToAgentSchema, m as agentPinnedMessageSchema$1, mt as sessionStateMessageSchema, n as AGENT_NAME_REGEX$1, nt as rebindAgentSchema, o as CHAT_ENGAGEMENT_STATUSES, p as agentBindRequestSchema, pt as sessionReconcileRequestSchema, q as loginSchema, r as AGENT_SELECTOR_HEADER$1, rt as refreshTokenSchema, st as selfServiceFeishuBotSchema, t as AGENT_BIND_REJECT_REASONS, u as WS_AUTH_FRAME_TIMEOUT_MS, ut as sessionCompletionMessageSchema, vt as updateAgentRuntimeConfigSchema, w as createAgentSchema, wt as wsAuthFrameSchema, x as contextTreeSnapshotSchema, xt as updateClientCapabilitiesSchema, y as clientRegisterSchema, yt as updateAgentSchema, z as inboxAckFrameSchema } from "./dist-CnjqakXS.mjs";
|
|
6
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
7
|
import { n as init_esm, r as trace, t as esm_exports } from "./esm-iadMkGbV.mjs";
|
|
8
|
-
import { $ as
|
|
8
|
+
import { $ as notifyRecipients, A as getPresence, B as listAgentsManagedByUser, C as ensureParticipant, D as getChatDetail, E as getCachedAudience, F as joinAsParticipant, G as listClients, H as listChatParticipantsWithNames, I as joinChat, K as listClientsForOrgAdmin, L as leaveAsParticipant, M as heartbeatInstance, N as inboxEntries, O as getClient, P as invalidateChatAudience, Q as messages, R as leaveChat, S as ensureCanJoin, T as getActivityOverview, U as listChats, V as listAgentsWithRuntime, W as listChatsForMember, X as markSupersededByChat, Y as markStaleAgents, Z as members, _ as createChat, _t as unbindAgent, a as agentVisibilityCondition, at as registerClient, b as disconnectClient, c as assertParticipant, ct as resolveChatMembership, d as chatMembership, dt as sendToAgent$1, et as pendingQuestions, f as chats, ft as serverInstances, g as clients, gt as touchAgent, h as cleanupStalePresence, ht as submitAnswer, i as agentPresence, it as registerChatMessageDispatcher, j as heartbeatClient, k as getOnlineCount, l as bindAgent, lt as retireClient, m as cleanupStaleClients, mt as setRuntimeState, n as addParticipant, nt as recomputeWatchersForAgent, o as agents, ot as removeParticipant, p as claimClient, pt as setOffline, q as listMessages, r as agentChatSessions, rt as recomputeWatchersForMember, s as assertClientOwner, st as resetActivity, t as addChatParticipants, tt as recomputeChatWatchers, u as changeChatType, ut as sendMessage, v as createNotifier, vt as updateClientCapabilities, w as findOrCreateDirectChat, x as editMessage, y as deriveAuthState, yt as upsertSessionState, z as listActiveAgentsPinnedToClient } from "./client-bR8nwHaV-OxnjyKOk.mjs";
|
|
9
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";
|
|
@@ -570,10 +570,15 @@ const runtimeStateSchema = z.enum([
|
|
|
570
570
|
z.enum([
|
|
571
571
|
"active",
|
|
572
572
|
"suspended",
|
|
573
|
-
"evicted"
|
|
573
|
+
"evicted",
|
|
574
|
+
"errored"
|
|
574
575
|
]);
|
|
575
576
|
/** Wire-level states a client may report. `evicted` from a stale client is rejected. */
|
|
576
|
-
const clientSessionStateSchema = z.enum([
|
|
577
|
+
const clientSessionStateSchema = z.enum([
|
|
578
|
+
"active",
|
|
579
|
+
"suspended",
|
|
580
|
+
"errored"
|
|
581
|
+
]);
|
|
577
582
|
z.object({
|
|
578
583
|
chatId: z.string().min(1),
|
|
579
584
|
state: clientSessionStateSchema
|
|
@@ -759,6 +764,12 @@ const chatTypeSchema = z.enum([
|
|
|
759
764
|
"group",
|
|
760
765
|
"thread"
|
|
761
766
|
]);
|
|
767
|
+
const chatEngagementStatusSchema = z.enum([
|
|
768
|
+
"active",
|
|
769
|
+
"archived",
|
|
770
|
+
"deleted"
|
|
771
|
+
]);
|
|
772
|
+
z.object({ status: chatEngagementStatusSchema });
|
|
762
773
|
z.object({
|
|
763
774
|
type: chatTypeSchema,
|
|
764
775
|
topic: z.string().max(500).optional(),
|
|
@@ -788,7 +799,8 @@ z.object({
|
|
|
788
799
|
}).extend({
|
|
789
800
|
participants: z.array(chatParticipantSchema),
|
|
790
801
|
title: z.string(),
|
|
791
|
-
firstMessagePreview: z.string().nullable()
|
|
802
|
+
firstMessagePreview: z.string().nullable(),
|
|
803
|
+
engagementStatus: chatEngagementStatusSchema
|
|
792
804
|
});
|
|
793
805
|
z.object({ topic: z.string().trim().max(500).nullable() });
|
|
794
806
|
z.object({ agentId: z.string().min(1) });
|
|
@@ -1237,10 +1249,16 @@ const meChatFilterSchema = z.enum([
|
|
|
1237
1249
|
"watching"
|
|
1238
1250
|
]);
|
|
1239
1251
|
const meChatMembershipKindSchema = z.enum(["participant", "watching"]);
|
|
1252
|
+
const chatEngagementViewSchema = z.enum([
|
|
1253
|
+
"active",
|
|
1254
|
+
"archived",
|
|
1255
|
+
"all"
|
|
1256
|
+
]);
|
|
1240
1257
|
z.object({
|
|
1241
1258
|
cursor: z.string().optional(),
|
|
1242
1259
|
limit: z.coerce.number().int().min(1).max(200).default(50),
|
|
1243
|
-
filter: meChatFilterSchema.default("all")
|
|
1260
|
+
filter: meChatFilterSchema.default("all"),
|
|
1261
|
+
engagement: chatEngagementViewSchema.default("active")
|
|
1244
1262
|
});
|
|
1245
1263
|
const meChatParticipantSchema = z.object({
|
|
1246
1264
|
agentId: z.string(),
|
|
@@ -1258,7 +1276,8 @@ const meChatRowSchema = z.object({
|
|
|
1258
1276
|
lastMessageAt: z.string().nullable(),
|
|
1259
1277
|
lastMessagePreview: z.string().nullable(),
|
|
1260
1278
|
unreadMentionCount: z.number().int(),
|
|
1261
|
-
canReply: z.boolean()
|
|
1279
|
+
canReply: z.boolean(),
|
|
1280
|
+
engagementStatus: chatEngagementStatusSchema
|
|
1262
1281
|
});
|
|
1263
1282
|
z.object({
|
|
1264
1283
|
rows: z.array(meChatRowSchema),
|
|
@@ -3353,8 +3372,26 @@ function normalizePath(rawPath) {
|
|
|
3353
3372
|
function shortHash(input) {
|
|
3354
3373
|
return createHash("sha256").update(input).digest("hex").slice(0, 8);
|
|
3355
3374
|
}
|
|
3356
|
-
|
|
3357
|
-
|
|
3375
|
+
/**
|
|
3376
|
+
* Branch name a session's worktree attaches to. The hash inputs include the
|
|
3377
|
+
* agent dimension because `(chat, url)` alone is not unique: two agents that
|
|
3378
|
+
* share a chat each open their own worktree at
|
|
3379
|
+
* `<workspaces>/<agent>/<chatId>/...`, and git refuses to point two worktrees
|
|
3380
|
+
* at the same branch (`fatal: '<branch>' is already used by worktree at …`).
|
|
3381
|
+
* Hash inputs are joined with `:` so `(chatA, agentB)` cannot collide with
|
|
3382
|
+
* `(chatAB, "")`.
|
|
3383
|
+
*
|
|
3384
|
+
* The caller picks `agentName`. Prefer the operator-stable name
|
|
3385
|
+
* (`config.yaml::agents.<name>`); fall back to `agent.agentId` (a UUID,
|
|
3386
|
+
* globally unique) when the stable name isn't available. Anything stable
|
|
3387
|
+
* across `start` and `resume` for the same `(agent, chat)` pair will do —
|
|
3388
|
+
* the contract is "no collision with a peer agent in the same chat", not
|
|
3389
|
+
* "human-readable in the branch name".
|
|
3390
|
+
*
|
|
3391
|
+
* See docs/workspace-session-branch-collision-fix-design.md §3.2.
|
|
3392
|
+
*/
|
|
3393
|
+
function deriveSessionBranchName(sessionKey, agentName, url) {
|
|
3394
|
+
return `${SESSION_BRANCH_PREFIX}-${shortHash(`${sessionKey}:${agentName}`)}-${shortHash(url)}`;
|
|
3358
3395
|
}
|
|
3359
3396
|
/**
|
|
3360
3397
|
* A value is SHA-like when it's a 7–40 character hex string. Used to decide
|
|
@@ -3766,12 +3803,12 @@ function createGitMirrorManager(opts) {
|
|
|
3766
3803
|
}
|
|
3767
3804
|
});
|
|
3768
3805
|
},
|
|
3769
|
-
createWorktree({ url, ref, targetPath, sessionKey }) {
|
|
3806
|
+
createWorktree({ url, ref, targetPath, sessionKey, agentName }) {
|
|
3770
3807
|
return withUrlLock(url, async () => {
|
|
3771
3808
|
const mirror = mirrorDir(url);
|
|
3772
3809
|
if (!existsSync(join(mirror, "HEAD"))) throw new GitMirrorError(`Cannot create worktree — no mirror exists for "${url}"`);
|
|
3773
3810
|
const absTarget = resolve(targetPath);
|
|
3774
|
-
const branchName = deriveSessionBranchName(sessionKey, url);
|
|
3811
|
+
const branchName = deriveSessionBranchName(sessionKey, agentName, url);
|
|
3775
3812
|
if (existsSync(absTarget) && !isHubManagedWorktree(absTarget)) {
|
|
3776
3813
|
log?.warn({
|
|
3777
3814
|
gitUrl: url,
|
|
@@ -4112,15 +4149,53 @@ var InputController = class {
|
|
|
4112
4149
|
};
|
|
4113
4150
|
const DEFAULT_WORKSPACE_TTL_MS = 10080 * 60 * 1e3;
|
|
4114
4151
|
/**
|
|
4152
|
+
* Sentinel that flags "stage-2 of session bootstrap (git worktree
|
|
4153
|
+
* materialisation) completed successfully". Distinct from the
|
|
4154
|
+
* `FIRST_TREE_WORKSPACE_MARKER` (`.first-tree-workspace`) which is the
|
|
4155
|
+
* "agent workspace boundary" — Codex's `project_root_markers` uses that one
|
|
4156
|
+
* to stop walking up the filesystem when looking for `AGENTS.md`. Splitting
|
|
4157
|
+
* the two so the boundary marker can be written eagerly (stage 1) while the
|
|
4158
|
+
* completion sentinel only appears after stage 2 lets `acquireWorkspace`
|
|
4159
|
+
* detect half-baked directories from a previous failed start and self-heal.
|
|
4160
|
+
*
|
|
4161
|
+
* See docs/workspace-session-branch-collision-fix-design.md §3.4.
|
|
4162
|
+
*/
|
|
4163
|
+
const INIT_COMPLETE_SENTINEL_REL = join(".agent", "init-complete");
|
|
4164
|
+
/**
|
|
4115
4165
|
* Acquire a per-chat workspace directory.
|
|
4116
|
-
*
|
|
4166
|
+
*
|
|
4167
|
+
* Healing rule: if the directory exists AND carries the boundary marker
|
|
4168
|
+
* (`.first-tree-workspace`, written in stage 1) AND is missing the
|
|
4169
|
+
* completion sentinel (`.agent/init-complete`, written after stage 2), the
|
|
4170
|
+
* previous session start crashed between the two writes — wipe it so the
|
|
4171
|
+
* fresh start gets a clean slate. The boundary marker alone (without the
|
|
4172
|
+
* sentinel) is the unambiguous shape of a half-baked workspace: only stage 1
|
|
4173
|
+
* writes it, and only stage 2 writes the sentinel.
|
|
4117
4174
|
*/
|
|
4118
4175
|
function acquireWorkspace(workspaceRoot, chatId) {
|
|
4119
4176
|
const dir = join(workspaceRoot, chatId);
|
|
4177
|
+
if (existsSync(dir) && existsSync(join(dir, ".first-tree-workspace")) && !existsSync(join(dir, INIT_COMPLETE_SENTINEL_REL))) rmSync(dir, {
|
|
4178
|
+
recursive: true,
|
|
4179
|
+
force: true
|
|
4180
|
+
});
|
|
4120
4181
|
mkdirSync(dir, { recursive: true });
|
|
4121
4182
|
return dir;
|
|
4122
4183
|
}
|
|
4123
4184
|
/**
|
|
4185
|
+
* Write the stage-2 completion sentinel. Callers must invoke this AFTER all
|
|
4186
|
+
* pre-handler-spawn setup (workspace bootstrap, git worktrees, first-tree
|
|
4187
|
+
* integration) succeeded, so a process that crashes earlier leaves a
|
|
4188
|
+
* half-baked workspace the next acquireWorkspace can heal.
|
|
4189
|
+
*/
|
|
4190
|
+
function markWorkspaceInitComplete(workspaceCwd) {
|
|
4191
|
+
const path = join(workspaceCwd, INIT_COMPLETE_SENTINEL_REL);
|
|
4192
|
+
mkdirSync(join(workspaceCwd, ".agent"), { recursive: true });
|
|
4193
|
+
writeFileSync(path, JSON.stringify({
|
|
4194
|
+
completedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4195
|
+
schemaVersion: 1
|
|
4196
|
+
}), "utf-8");
|
|
4197
|
+
}
|
|
4198
|
+
/**
|
|
4124
4199
|
* Clean stale workspace directories for an agent.
|
|
4125
4200
|
*
|
|
4126
4201
|
* A workspace is considered stale when:
|
|
@@ -4864,12 +4939,13 @@ const createClaudeCodeHandler = (config) => {
|
|
|
4864
4939
|
const mirror = await gitMirrorManager.ensureMirror(repo.url);
|
|
4865
4940
|
if (mirror.cloned) sessionCtx.log(`Git: cloned ${repo.url} in ${mirror.elapsedMs}ms`);
|
|
4866
4941
|
await gitMirrorManager.fetchMirror(repo.url);
|
|
4942
|
+
const branchAgentKey = agentName ?? sessionCtx.agent.agentId;
|
|
4867
4943
|
if (existsSync(targetPath) && isHubWorktreeMarker(targetPath)) {
|
|
4868
4944
|
sessionCtx.log(`Git: reusing existing worktree at ${localPath}`);
|
|
4869
4945
|
ownedWorktrees.push({
|
|
4870
4946
|
url: repo.url,
|
|
4871
4947
|
path: targetPath,
|
|
4872
|
-
branchName: deriveSessionBranchName(sessionCtx.chatId, repo.url)
|
|
4948
|
+
branchName: deriveSessionBranchName(sessionCtx.chatId, branchAgentKey, repo.url)
|
|
4873
4949
|
});
|
|
4874
4950
|
continue;
|
|
4875
4951
|
}
|
|
@@ -4877,7 +4953,8 @@ const createClaudeCodeHandler = (config) => {
|
|
|
4877
4953
|
url: repo.url,
|
|
4878
4954
|
ref: repo.ref,
|
|
4879
4955
|
targetPath,
|
|
4880
|
-
sessionKey: sessionCtx.chatId
|
|
4956
|
+
sessionKey: sessionCtx.chatId,
|
|
4957
|
+
agentName: branchAgentKey
|
|
4881
4958
|
});
|
|
4882
4959
|
ownedWorktrees.push({
|
|
4883
4960
|
url: repo.url,
|
|
@@ -4926,6 +5003,7 @@ const createClaudeCodeHandler = (config) => {
|
|
|
4926
5003
|
runBootstrap(cwd, sessionCtx);
|
|
4927
5004
|
const payload = agentConfigCache?.get(sessionCtx.agent.agentId)?.payload;
|
|
4928
5005
|
await prepareGitWorktrees(cwd, payload, sessionCtx);
|
|
5006
|
+
markWorkspaceInitComplete(cwd);
|
|
4929
5007
|
sessionCtx.log(`Starting session (${claudeSessionId}), cwd=${cwd}, permissionMode=${config.permissionMode ?? "bypassPermissions"}`);
|
|
4930
5008
|
spawnQuery(claudeSessionId, sessionCtx);
|
|
4931
5009
|
const sdkMsg = await toSDKUserMessage(message, sessionCtx, claudeSessionId);
|
|
@@ -4938,9 +5016,10 @@ const createClaudeCodeHandler = (config) => {
|
|
|
4938
5016
|
claudeSessionId = sessionId;
|
|
4939
5017
|
retryCount = 0;
|
|
4940
5018
|
cwd = acquireWorkspace(workspaceRoot, sessionCtx.chatId);
|
|
4941
|
-
if (!existsSync(join(cwd,
|
|
5019
|
+
if (!existsSync(join(cwd, INIT_COMPLETE_SENTINEL_REL))) runBootstrap(cwd, sessionCtx);
|
|
4942
5020
|
const payload = agentConfigCache?.get(sessionCtx.agent.agentId)?.payload;
|
|
4943
5021
|
await prepareGitWorktrees(cwd, payload, sessionCtx);
|
|
5022
|
+
markWorkspaceInitComplete(cwd);
|
|
4944
5023
|
sessionCtx.log(`Resuming session (${sessionId}), cwd=${cwd}`);
|
|
4945
5024
|
spawnQuery(sessionId, sessionCtx, sessionId);
|
|
4946
5025
|
if (message) inputController?.push(await toSDKUserMessage(message, sessionCtx, sessionId));
|
|
@@ -5157,8 +5236,9 @@ const createCodexHandler = (config) => {
|
|
|
5157
5236
|
function toCodexInput(message, sessionCtx) {
|
|
5158
5237
|
return sessionCtx.formatInboundContent(message).then((text) => text);
|
|
5159
5238
|
}
|
|
5160
|
-
async function prepareGitWorktrees(payload, workspaceCwd,
|
|
5239
|
+
async function prepareGitWorktrees(payload, workspaceCwd, sessionCtx) {
|
|
5161
5240
|
if (!gitMirrorManager) return;
|
|
5241
|
+
const branchAgentKey = agentName ?? sessionCtx.agent.agentId;
|
|
5162
5242
|
for (const repo of payload.gitRepos) {
|
|
5163
5243
|
const localPath = repo.localPath ?? deriveRepoLocalPath(repo.url);
|
|
5164
5244
|
if (!localPath) continue;
|
|
@@ -5171,7 +5251,8 @@ const createCodexHandler = (config) => {
|
|
|
5171
5251
|
url: repo.url,
|
|
5172
5252
|
ref: repo.ref,
|
|
5173
5253
|
targetPath,
|
|
5174
|
-
sessionKey: chatId
|
|
5254
|
+
sessionKey: sessionCtx.chatId,
|
|
5255
|
+
agentName: branchAgentKey
|
|
5175
5256
|
});
|
|
5176
5257
|
ownedWorktrees.push({
|
|
5177
5258
|
url: repo.url,
|
|
@@ -5413,7 +5494,8 @@ const createCodexHandler = (config) => {
|
|
|
5413
5494
|
}
|
|
5414
5495
|
});
|
|
5415
5496
|
ensureFirstTreeBinding(cwd, sessionCtx);
|
|
5416
|
-
await prepareGitWorktrees(payload, cwd, sessionCtx
|
|
5497
|
+
await prepareGitWorktrees(payload, cwd, sessionCtx);
|
|
5498
|
+
markWorkspaceInitComplete(cwd);
|
|
5417
5499
|
codex = new Codex({
|
|
5418
5500
|
env: buildEnv(sessionCtx),
|
|
5419
5501
|
config: buildCodexConfig(payload)
|
|
@@ -5437,7 +5519,7 @@ const createCodexHandler = (config) => {
|
|
|
5437
5519
|
env: [],
|
|
5438
5520
|
gitRepos: []
|
|
5439
5521
|
};
|
|
5440
|
-
if (!existsSync(join(cwd,
|
|
5522
|
+
if (!existsSync(join(cwd, INIT_COMPLETE_SENTINEL_REL))) {
|
|
5441
5523
|
bootstrapWorkspace({
|
|
5442
5524
|
workspacePath: cwd,
|
|
5443
5525
|
identity: sessionCtx.agent,
|
|
@@ -5451,7 +5533,8 @@ const createCodexHandler = (config) => {
|
|
|
5451
5533
|
});
|
|
5452
5534
|
ensureFirstTreeBinding(cwd, sessionCtx);
|
|
5453
5535
|
}
|
|
5454
|
-
await prepareGitWorktrees(payload, cwd, sessionCtx
|
|
5536
|
+
await prepareGitWorktrees(payload, cwd, sessionCtx);
|
|
5537
|
+
markWorkspaceInitComplete(cwd);
|
|
5455
5538
|
codex = new Codex({
|
|
5456
5539
|
env: buildEnv(sessionCtx),
|
|
5457
5540
|
config: buildCodexConfig(payload)
|
|
@@ -6111,11 +6194,24 @@ var SessionManager = class {
|
|
|
6111
6194
|
this.persistRegistry();
|
|
6112
6195
|
this.notifySessionState(chatId, "active");
|
|
6113
6196
|
} catch (err) {
|
|
6197
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
6198
|
+
const phase = evicted ? "resume" : "start";
|
|
6114
6199
|
this.config.log.error({
|
|
6115
6200
|
chatId,
|
|
6116
6201
|
err,
|
|
6117
|
-
phase
|
|
6202
|
+
phase
|
|
6118
6203
|
}, "session start/resume failed");
|
|
6204
|
+
this.notifySessionState(chatId, "errored");
|
|
6205
|
+
try {
|
|
6206
|
+
const preview = errMsg.slice(0, 800);
|
|
6207
|
+
const userMsg = `⚠️ Session ${phase} failed (${this.config.agentIdentity.displayName ?? this.config.agentIdentity.agentId}): ${preview}`;
|
|
6208
|
+
await ctx.forwardResult(userMsg);
|
|
6209
|
+
} catch (forwardErr) {
|
|
6210
|
+
this.config.log.warn({
|
|
6211
|
+
chatId,
|
|
6212
|
+
forwardErr
|
|
6213
|
+
}, "session error forward failed");
|
|
6214
|
+
}
|
|
6119
6215
|
this.sessions.delete(chatId);
|
|
6120
6216
|
this.sessionRuntimeStates.delete(chatId);
|
|
6121
6217
|
this.recomputeRuntimeState();
|
|
@@ -6147,11 +6243,25 @@ var SessionManager = class {
|
|
|
6147
6243
|
this.persistRegistry();
|
|
6148
6244
|
this.notifySessionState(entry.chatId, "active");
|
|
6149
6245
|
} catch (err) {
|
|
6150
|
-
|
|
6246
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
6247
|
+
this.config.log.error({
|
|
6151
6248
|
chatId: entry.chatId,
|
|
6152
6249
|
err
|
|
6153
6250
|
}, "resume failed");
|
|
6154
|
-
entry.
|
|
6251
|
+
this.notifySessionState(entry.chatId, "errored");
|
|
6252
|
+
try {
|
|
6253
|
+
const preview = errMsg.slice(0, 800);
|
|
6254
|
+
const userMsg = `⚠️ Session resume failed (${this.config.agentIdentity.displayName ?? this.config.agentIdentity.agentId}): ${preview}`;
|
|
6255
|
+
await ctx.forwardResult(userMsg);
|
|
6256
|
+
} catch (forwardErr) {
|
|
6257
|
+
this.config.log.warn({
|
|
6258
|
+
chatId: entry.chatId,
|
|
6259
|
+
forwardErr
|
|
6260
|
+
}, "session error forward failed");
|
|
6261
|
+
}
|
|
6262
|
+
this.sessions.delete(entry.chatId);
|
|
6263
|
+
this.sessionRuntimeStates.delete(entry.chatId);
|
|
6264
|
+
this.recomputeRuntimeState();
|
|
6155
6265
|
this._activeCount--;
|
|
6156
6266
|
}
|
|
6157
6267
|
}
|
|
@@ -9326,7 +9436,7 @@ async function onboardCreate(args) {
|
|
|
9326
9436
|
}
|
|
9327
9437
|
const runtimeAgent = args.type === "human" ? args.assistant : args.id;
|
|
9328
9438
|
if (args.feishuBotAppId && args.feishuBotAppSecret) {
|
|
9329
|
-
const { bindFeishuBot } = await import("./feishu-
|
|
9439
|
+
const { bindFeishuBot } = await import("./feishu-DrnBbl8T.mjs").then((n) => n.r);
|
|
9330
9440
|
const targetAgentUuid = args.type === "human" ? assistantUuid : primary.uuid;
|
|
9331
9441
|
if (!targetAgentUuid) print.line(`Warning: Cannot bind Feishu bot — no runtime agent available for "${args.id}".\n`);
|
|
9332
9442
|
else {
|
|
@@ -10539,7 +10649,7 @@ function createFeedbackHandler(config) {
|
|
|
10539
10649
|
return { handle };
|
|
10540
10650
|
}
|
|
10541
10651
|
//#endregion
|
|
10542
|
-
//#region ../server/dist/app-
|
|
10652
|
+
//#region ../server/dist/app-mkBHfGPl.mjs
|
|
10543
10653
|
var import_fastify_opentelemetry = /* @__PURE__ */ __toESM(require_fastify_opentelemetry(), 1);
|
|
10544
10654
|
init_esm();
|
|
10545
10655
|
var __defProp = Object.defineProperty;
|
|
@@ -10646,7 +10756,7 @@ async function requireChatAccess(request, db) {
|
|
|
10646
10756
|
role: caller.role,
|
|
10647
10757
|
humanAgentId: caller.humanAgentId
|
|
10648
10758
|
};
|
|
10649
|
-
const [direct] = await db.select({ chatId:
|
|
10759
|
+
const [direct] = await db.select({ chatId: chatMembership.chatId }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.agentId, caller.humanAgentId), eq(chatMembership.accessMode, "speaker"))).limit(1);
|
|
10650
10760
|
if (direct) {
|
|
10651
10761
|
stampOrgScope(request, scope);
|
|
10652
10762
|
stampChatResource(request, chat);
|
|
@@ -10655,7 +10765,7 @@ async function requireChatAccess(request, db) {
|
|
|
10655
10765
|
scope
|
|
10656
10766
|
};
|
|
10657
10767
|
}
|
|
10658
|
-
const participantRows = await db.select({ agentId:
|
|
10768
|
+
const participantRows = await db.select({ agentId: chatMembership.agentId }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")));
|
|
10659
10769
|
if (participantRows.length === 0) throw new NotFoundError(`Chat "${chatId}" not found`);
|
|
10660
10770
|
const participantIds = participantRows.map((p) => p.agentId);
|
|
10661
10771
|
const [managed] = await db.select({ uuid: agents.uuid }).from(agents).where(and(inArray(agents.uuid, participantIds), eq(agents.managerId, caller.memberId))).limit(1);
|
|
@@ -11747,19 +11857,6 @@ async function findOrCreateChatForChannel(db, data) {
|
|
|
11747
11857
|
return chatId;
|
|
11748
11858
|
});
|
|
11749
11859
|
}
|
|
11750
|
-
/**
|
|
11751
|
-
* Ensure an agent is a participant of a chat (no-op if already). Mode is
|
|
11752
|
-
* derived via the canonical entrypoint — pre-fix this also wrote `mode:`
|
|
11753
|
-
* implicitly via schema default `'full'`, which is wrong for non-human
|
|
11754
|
-
* agents in a group chat (the bug §1.1 of the Phase 1 design doc fixes).
|
|
11755
|
-
*/
|
|
11756
|
-
async function ensureParticipant(db, chatId, agentId) {
|
|
11757
|
-
const [exists] = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, agentId))).limit(1);
|
|
11758
|
-
if (!exists) await addChatParticipants(db, chatId, [{
|
|
11759
|
-
agentId,
|
|
11760
|
-
role: "member"
|
|
11761
|
-
}], { onConflictDoNothing: true });
|
|
11762
|
-
}
|
|
11763
11860
|
/** Store a cross-reference between internal and external message. */
|
|
11764
11861
|
async function createMessageReference(db, data) {
|
|
11765
11862
|
await db.insert(adapterMessageReferences).values({
|
|
@@ -11842,9 +11939,9 @@ async function buildClientMessagePayloadsForInbox(db, inboxId, items) {
|
|
|
11842
11939
|
const modeByChat = /* @__PURE__ */ new Map();
|
|
11843
11940
|
if (chatIds.length > 0) {
|
|
11844
11941
|
const rows = await db.select({
|
|
11845
|
-
chatId:
|
|
11846
|
-
mode:
|
|
11847
|
-
}).from(
|
|
11942
|
+
chatId: chatMembership.chatId,
|
|
11943
|
+
mode: chatMembership.mode
|
|
11944
|
+
}).from(chatMembership).where(and(eq(chatMembership.agentId, agentId), inArray(chatMembership.chatId, chatIds), eq(chatMembership.accessMode, "speaker")));
|
|
11848
11945
|
for (const r of rows) modeByChat.set(r.chatId, normaliseMode(r.mode));
|
|
11849
11946
|
}
|
|
11850
11947
|
const inReplyToIds = [...new Set(items.map((it) => it.message.inReplyTo).filter((id) => id !== null))];
|
|
@@ -12245,7 +12342,7 @@ async function prepareImageOutbound(db, notifier, chatId, data) {
|
|
|
12245
12342
|
* chat reply (see `services/message.ts` replyTo routing).
|
|
12246
12343
|
*/
|
|
12247
12344
|
async function collectTargetInboxes(db, chatId, inReplyTo) {
|
|
12248
|
-
const participants = await db.select({ inboxId: agents.inboxId }).from(
|
|
12345
|
+
const participants = await db.select({ inboxId: agents.inboxId }).from(chatMembership).innerJoin(agents, eq(chatMembership.agentId, agents.uuid)).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")));
|
|
12249
12346
|
const set = new Set(participants.map((p) => p.inboxId));
|
|
12250
12347
|
if (inReplyTo) {
|
|
12251
12348
|
const [original] = await db.select({ replyToInbox: messages.replyToInbox }).from(messages).where(eq(messages.id, inReplyTo)).limit(1);
|
|
@@ -15171,6 +15268,47 @@ async function bootstrapConfigRoutes(_app) {
|
|
|
15171
15268
|
return { allowedOrg: null };
|
|
15172
15269
|
});
|
|
15173
15270
|
}
|
|
15271
|
+
/**
|
|
15272
|
+
* Per-(chat, agent) user state — independent from membership structure.
|
|
15273
|
+
*
|
|
15274
|
+
* This is the third layer of the chat data model: while `chats` owns
|
|
15275
|
+
* the entity and `chat_membership` owns the structural relation
|
|
15276
|
+
* (who can speak, who watches), this table owns the user's private
|
|
15277
|
+
* state about a chat. The reason it lives apart: structural changes
|
|
15278
|
+
* (speaker ↔ watcher, manager rebind, recompute) must never overwrite
|
|
15279
|
+
* user-private state — physical separation makes that an invariant
|
|
15280
|
+
* rather than a service-layer discipline.
|
|
15281
|
+
*
|
|
15282
|
+
* Columns evolve incrementally as new per-user state is needed.
|
|
15283
|
+
* Currently:
|
|
15284
|
+
* - `last_read_at`, `unread_mention_count` — seeded by PR-A from
|
|
15285
|
+
* the legacy `chat_participants` / `chat_subscriptions` columns.
|
|
15286
|
+
* - `engagement_status` — added in 0040; per-(chat, user) view
|
|
15287
|
+
* state (active / archived / deleted). Auto-revives archived →
|
|
15288
|
+
* active on new message; deleted is sticky (only the user can
|
|
15289
|
+
* restore from the chat detail page).
|
|
15290
|
+
*
|
|
15291
|
+
* Future fields slated for this table: pinned, mute_until, draft,
|
|
15292
|
+
* custom_title, last_seen_at — each as a separate change.
|
|
15293
|
+
*
|
|
15294
|
+
* Rows are lazy-upserted on first user write (markRead / mention
|
|
15295
|
+
* counter bump / engagement transition). Reads use COALESCE for
|
|
15296
|
+
* defaults so callers see `'active'` etc. even when no row exists.
|
|
15297
|
+
* Service-layer integrity (no FK / CHECK / trigger).
|
|
15298
|
+
*
|
|
15299
|
+
* See proposals/chat-data-model-restructure.20260512.md §8.6.
|
|
15300
|
+
*/
|
|
15301
|
+
const chatUserState = pgTable("chat_user_state", {
|
|
15302
|
+
chatId: text("chat_id").notNull(),
|
|
15303
|
+
agentId: text("agent_id").notNull(),
|
|
15304
|
+
lastReadAt: timestamp("last_read_at", { withTimezone: true }),
|
|
15305
|
+
unreadMentionCount: integer("unread_mention_count").notNull().default(0),
|
|
15306
|
+
engagementStatus: text("engagement_status").notNull().default("active")
|
|
15307
|
+
}, (table) => [
|
|
15308
|
+
primaryKey({ columns: [table.chatId, table.agentId] }),
|
|
15309
|
+
index("idx_user_state_agent").on(table.agentId),
|
|
15310
|
+
index("idx_user_state_unread").on(table.agentId).where(sql`unread_mention_count > 0`)
|
|
15311
|
+
]);
|
|
15174
15312
|
/** Extract a plain-text summary from a message's JSONB content field.
|
|
15175
15313
|
* Used as the auto-title fallback in chat list rendering — see
|
|
15176
15314
|
* `me-chat.ts:resolveChatTitle` and `admin/chats.ts:getChat`.
|
|
@@ -15366,7 +15504,7 @@ async function transitionSessionState(db, agentId, chatId, target, from, organiz
|
|
|
15366
15504
|
async function filterSessionsByParticipant(db, sessions, participantAgentId) {
|
|
15367
15505
|
if (sessions.length === 0) return [];
|
|
15368
15506
|
const chatIds = sessions.map((s) => s.chatId);
|
|
15369
|
-
const participantRows = await db.select({ chatId:
|
|
15507
|
+
const participantRows = await db.select({ chatId: chatMembership.chatId }).from(chatMembership).where(and(inArray(chatMembership.chatId, chatIds), eq(chatMembership.agentId, participantAgentId), eq(chatMembership.accessMode, "speaker")));
|
|
15370
15508
|
const allowedChatIds = new Set(participantRows.map((r) => r.chatId));
|
|
15371
15509
|
return sessions.filter((s) => allowedChatIds.has(s.chatId));
|
|
15372
15510
|
}
|
|
@@ -15375,16 +15513,17 @@ async function filterSessionsByParticipant(db, sessions, participantAgentId) {
|
|
|
15375
15513
|
* workspace).
|
|
15376
15514
|
*
|
|
15377
15515
|
* Responsibilities:
|
|
15378
|
-
* - Cursor-paginated conversation list
|
|
15379
|
-
*
|
|
15516
|
+
* - Cursor-paginated conversation list (single-stream JOIN over the
|
|
15517
|
+
* unified `chat_membership` + `chat_user_state` tables).
|
|
15380
15518
|
* - Create a new chat (no dedupe, runs `recomputeChatWatchers` after).
|
|
15381
|
-
* - Add participants (idempotent,
|
|
15382
|
-
*
|
|
15383
|
-
* -
|
|
15384
|
-
* -
|
|
15519
|
+
* - Add participants (idempotent, UPSERT into `chat_membership`,
|
|
15520
|
+
* runs `recomputeChatWatchers` after).
|
|
15521
|
+
* - Mark-read (UPSERT into `chat_user_state`).
|
|
15522
|
+
* - Join → watcher to speaker (delegates to `watcher.ts`).
|
|
15523
|
+
* - Leave → speaker to watcher or detach (delegates to `watcher.ts`).
|
|
15385
15524
|
*
|
|
15386
|
-
* See
|
|
15387
|
-
*
|
|
15525
|
+
* See proposals/chat-data-model-restructure.20260512.md §8 (schema)
|
|
15526
|
+
* and §11.1 (per-route mapping).
|
|
15388
15527
|
*/
|
|
15389
15528
|
function encodeCursor(lastMessageAt, chatId) {
|
|
15390
15529
|
const payload = `${lastMessageAt ? lastMessageAt.toISOString() : ""}|${chatId}`;
|
|
@@ -15408,17 +15547,61 @@ function decodeCursor(cursor) {
|
|
|
15408
15547
|
return null;
|
|
15409
15548
|
}
|
|
15410
15549
|
}
|
|
15550
|
+
const { ACTIVE, ARCHIVED, DELETED } = CHAT_ENGAGEMENT_STATUSES;
|
|
15551
|
+
/**
|
|
15552
|
+
* SQL predicate for each engagement view tab. `deleted` is never a valid view
|
|
15553
|
+
* value — deleted rows are reachable only through `GET /chats/:chatId` + the
|
|
15554
|
+
* Restore banner on the chat detail page.
|
|
15555
|
+
*/
|
|
15556
|
+
const ENGAGEMENT_VIEW_PREDICATE = {
|
|
15557
|
+
active: sql`COALESCE(cus.engagement_status, ${ACTIVE}) = ${ACTIVE}`,
|
|
15558
|
+
archived: sql`COALESCE(cus.engagement_status, ${ACTIVE}) = ${ARCHIVED}`,
|
|
15559
|
+
all: sql`COALESCE(cus.engagement_status, ${ACTIVE}) IN (${ACTIVE}, ${ARCHIVED})`
|
|
15560
|
+
};
|
|
15561
|
+
/**
|
|
15562
|
+
* Write the caller's engagement state for this chat. UPSERT into
|
|
15563
|
+
* `chat_user_state` — the row may not yet exist (the user might not have
|
|
15564
|
+
* marked-read or been @-mentioned), so an INSERT with the engagement value
|
|
15565
|
+
* is the first write; subsequent transitions are UPDATEs.
|
|
15566
|
+
*
|
|
15567
|
+
* Idempotent. Mirrors the UPSERT shape used by `markMeChatRead`.
|
|
15568
|
+
*/
|
|
15569
|
+
async function setChatEngagement(db, chatId, agentId, status) {
|
|
15570
|
+
await db.insert(chatUserState).values({
|
|
15571
|
+
chatId,
|
|
15572
|
+
agentId,
|
|
15573
|
+
unreadMentionCount: 0,
|
|
15574
|
+
engagementStatus: status
|
|
15575
|
+
}).onConflictDoUpdate({
|
|
15576
|
+
target: [chatUserState.chatId, chatUserState.agentId],
|
|
15577
|
+
set: { engagementStatus: status }
|
|
15578
|
+
});
|
|
15579
|
+
}
|
|
15580
|
+
/**
|
|
15581
|
+
* Read the caller's engagement state. Returns `'active'` when no
|
|
15582
|
+
* `chat_user_state` row exists yet (lazy-materialised; matches the SQL
|
|
15583
|
+
* `COALESCE(..., 'active')` used elsewhere).
|
|
15584
|
+
*/
|
|
15585
|
+
async function getCallerEngagement(db, chatId, agentId) {
|
|
15586
|
+
const [row] = await db.select({ engagementStatus: chatUserState.engagementStatus }).from(chatUserState).where(and(eq(chatUserState.chatId, chatId), eq(chatUserState.agentId, agentId))).limit(1);
|
|
15587
|
+
return row?.engagementStatus ?? ACTIVE;
|
|
15588
|
+
}
|
|
15411
15589
|
/**
|
|
15412
15590
|
* GET /me/chats — cursor-paginated conversation list.
|
|
15413
15591
|
*
|
|
15414
15592
|
* SQL strategy:
|
|
15415
|
-
* -
|
|
15416
|
-
*
|
|
15417
|
-
*
|
|
15418
|
-
*
|
|
15593
|
+
* - Single-stream query: `chats JOIN chat_membership LEFT JOIN
|
|
15594
|
+
* chat_user_state`. The membership row carries access_mode
|
|
15595
|
+
* (speaker → "participant" / watcher → "watching"); the user
|
|
15596
|
+
* state row supplies the unread counter (COALESCE → 0 when
|
|
15597
|
+
* row is missing).
|
|
15598
|
+
* - Filter `parent_chat_id IS NULL` (threads excluded in v1).
|
|
15599
|
+
* - Filter `c.organization_id = ?` to defend against historical
|
|
15600
|
+
* cross-org pollution rows that may still reference the caller
|
|
15601
|
+
* (see fix/cross-org-direct-chat-pollution).
|
|
15419
15602
|
* - Sort `(last_message_at DESC NULLS LAST, chat_id DESC)`.
|
|
15420
|
-
* - Cursor narrows the result to rows STRICTLY before
|
|
15421
|
-
* - Followed by a
|
|
15603
|
+
* - Cursor narrows the result to rows STRICTLY before the cursor.
|
|
15604
|
+
* - Followed by a participants-list lookup for the page only.
|
|
15422
15605
|
*/
|
|
15423
15606
|
async function listMeChats(db, humanAgentId, organizationId, query) {
|
|
15424
15607
|
const limit = query.limit;
|
|
@@ -15426,28 +15609,12 @@ async function listMeChats(db, humanAgentId, organizationId, query) {
|
|
|
15426
15609
|
if (query.cursor && !cursor) throw new BadRequestError("Invalid cursor");
|
|
15427
15610
|
const filterUnreadOnly = query.filter === "unread";
|
|
15428
15611
|
const filterWatchingOnly = query.filter === "watching";
|
|
15612
|
+
const engagementPredicate = ENGAGEMENT_VIEW_PREDICATE[query.engagement];
|
|
15429
15613
|
const cursorTsIso = cursor?.lastMessageAt ? cursor.lastMessageAt.toISOString() : null;
|
|
15430
15614
|
const cursorPredicate = !cursor ? sql`TRUE` : cursor.lastMessageAt === null ? sql`(c.last_message_at IS NULL AND c.id < ${cursor.chatId})` : sql`(c.last_message_at IS NULL
|
|
15431
15615
|
OR c.last_message_at < ${cursorTsIso}::timestamptz
|
|
15432
15616
|
OR (c.last_message_at = ${cursorTsIso}::timestamptz AND c.id < ${cursor.chatId}))`;
|
|
15433
15617
|
const rawRows = await db.execute(sql`
|
|
15434
|
-
WITH membership AS (
|
|
15435
|
-
SELECT chat_id, 'participant'::text AS membership_kind, unread_mention_count
|
|
15436
|
-
FROM chat_participants
|
|
15437
|
-
WHERE agent_id = ${humanAgentId}
|
|
15438
|
-
UNION ALL
|
|
15439
|
-
SELECT chat_id, 'watching'::text AS membership_kind, unread_mention_count
|
|
15440
|
-
FROM chat_subscriptions
|
|
15441
|
-
WHERE agent_id = ${humanAgentId}
|
|
15442
|
-
),
|
|
15443
|
-
/* Resolve duplicates (should not happen post-invariant-1, but cheap) by
|
|
15444
|
-
preferring the participant row. */
|
|
15445
|
-
deduped AS (
|
|
15446
|
-
SELECT DISTINCT ON (chat_id)
|
|
15447
|
-
chat_id, membership_kind, unread_mention_count
|
|
15448
|
-
FROM membership
|
|
15449
|
-
ORDER BY chat_id, CASE WHEN membership_kind = 'participant' THEN 0 ELSE 1 END
|
|
15450
|
-
)
|
|
15451
15618
|
SELECT
|
|
15452
15619
|
c.id AS chat_id,
|
|
15453
15620
|
c.type AS type,
|
|
@@ -15455,20 +15622,26 @@ async function listMeChats(db, humanAgentId, organizationId, query) {
|
|
|
15455
15622
|
c.parent_chat_id AS parent_chat_id,
|
|
15456
15623
|
c.last_message_at AS last_message_at,
|
|
15457
15624
|
c.last_message_preview AS last_message_preview,
|
|
15458
|
-
(SELECT count(*) FROM
|
|
15459
|
-
|
|
15460
|
-
|
|
15625
|
+
(SELECT count(*) FROM chat_membership
|
|
15626
|
+
WHERE chat_id = c.id AND access_mode = 'speaker') AS participant_count,
|
|
15627
|
+
cm.access_mode AS access_mode,
|
|
15628
|
+
COALESCE(cus.unread_mention_count, 0) AS unread_mention_count,
|
|
15629
|
+
COALESCE(cus.engagement_status, ${ACTIVE}) AS engagement_status
|
|
15461
15630
|
FROM chats c
|
|
15462
|
-
JOIN
|
|
15631
|
+
JOIN chat_membership cm
|
|
15632
|
+
ON cm.chat_id = c.id AND cm.agent_id = ${humanAgentId}
|
|
15633
|
+
LEFT JOIN chat_user_state cus
|
|
15634
|
+
ON cus.chat_id = c.id AND cus.agent_id = ${humanAgentId}
|
|
15463
15635
|
WHERE c.parent_chat_id IS NULL
|
|
15464
|
-
/* Scope to the caller's org. Without this, cross-org dirty
|
|
15465
|
-
whose
|
|
15466
|
-
(historical pollution — see
|
|
15467
|
-
would leak into the
|
|
15636
|
+
/* Scope to the caller's org. Without this, cross-org dirty
|
|
15637
|
+
chats whose chat_membership still references the caller's
|
|
15638
|
+
human agent (historical pollution — see
|
|
15639
|
+
fix/cross-org-direct-chat-pollution) would leak into the
|
|
15640
|
+
list and 404 on click via requireChatAccess. */
|
|
15468
15641
|
AND c.organization_id = ${organizationId}
|
|
15469
|
-
|
|
15470
|
-
AND (${!
|
|
15471
|
-
AND
|
|
15642
|
+
AND (${!filterUnreadOnly}::bool OR COALESCE(cus.unread_mention_count, 0) > 0)
|
|
15643
|
+
AND (${!filterWatchingOnly}::bool OR cm.access_mode = 'watcher')
|
|
15644
|
+
AND ${engagementPredicate}
|
|
15472
15645
|
AND ${cursorPredicate}
|
|
15473
15646
|
ORDER BY c.last_message_at DESC NULLS LAST, c.id DESC
|
|
15474
15647
|
LIMIT ${limit + 1}
|
|
@@ -15487,11 +15660,11 @@ async function listMeChats(db, humanAgentId, organizationId, query) {
|
|
|
15487
15660
|
};
|
|
15488
15661
|
const chatIds = pageRaw.map((r) => r.chat_id);
|
|
15489
15662
|
const participantRows = await db.select({
|
|
15490
|
-
chatId:
|
|
15491
|
-
agentId:
|
|
15663
|
+
chatId: chatMembership.chatId,
|
|
15664
|
+
agentId: chatMembership.agentId,
|
|
15492
15665
|
displayName: agents.displayName,
|
|
15493
15666
|
type: agents.type
|
|
15494
|
-
}).from(
|
|
15667
|
+
}).from(chatMembership).innerJoin(agents, eq(chatMembership.agentId, agents.uuid)).where(and(inArray(chatMembership.chatId, chatIds), eq(chatMembership.accessMode, "speaker")));
|
|
15495
15668
|
const participantsByChat = /* @__PURE__ */ new Map();
|
|
15496
15669
|
for (const p of participantRows) {
|
|
15497
15670
|
const list = participantsByChat.get(p.chatId) ?? [];
|
|
@@ -15515,10 +15688,11 @@ async function listMeChats(db, humanAgentId, organizationId, query) {
|
|
|
15515
15688
|
rows: pageRaw.map((r) => {
|
|
15516
15689
|
const participants = participantsByChat.get(r.chat_id) ?? [];
|
|
15517
15690
|
const title = resolveChatTitle(r.topic, firstMessageSummary.get(r.chat_id) ?? null, participants, humanAgentId);
|
|
15691
|
+
const isSpeaker = r.access_mode === "speaker";
|
|
15518
15692
|
return {
|
|
15519
15693
|
chatId: r.chat_id,
|
|
15520
15694
|
type: r.type,
|
|
15521
|
-
membershipKind:
|
|
15695
|
+
membershipKind: isSpeaker ? "participant" : "watching",
|
|
15522
15696
|
title,
|
|
15523
15697
|
topic: r.topic,
|
|
15524
15698
|
participants,
|
|
@@ -15526,7 +15700,8 @@ async function listMeChats(db, humanAgentId, organizationId, query) {
|
|
|
15526
15700
|
lastMessageAt: toDate(r.last_message_at)?.toISOString() ?? null,
|
|
15527
15701
|
lastMessagePreview: r.last_message_preview,
|
|
15528
15702
|
unreadMentionCount: r.unread_mention_count,
|
|
15529
|
-
canReply:
|
|
15703
|
+
canReply: isSpeaker,
|
|
15704
|
+
engagementStatus: r.engagement_status
|
|
15530
15705
|
};
|
|
15531
15706
|
}),
|
|
15532
15707
|
nextCursor
|
|
@@ -15538,11 +15713,6 @@ async function listMeChats(db, humanAgentId, organizationId, query) {
|
|
|
15538
15713
|
* 1. `chat.topic` (manual, set via `PATCH /chats/:chatId`)
|
|
15539
15714
|
* 2. First message summary (auto, ≤ 50 chars from `extractSummary`)
|
|
15540
15715
|
* 3. Participant join (fallback when chat has no messages yet)
|
|
15541
|
-
*
|
|
15542
|
-
* The first-message fallback is the chat-first equivalent of how
|
|
15543
|
-
* ChatGPT / Claude.ai name conversations from the user's opening
|
|
15544
|
-
* prompt — gives same-agent multi-chats distinct identities and
|
|
15545
|
-
* removes the "title duplicates participants chip row" anti-pattern.
|
|
15546
15716
|
*/
|
|
15547
15717
|
function resolveChatTitle(topic, firstMessageSummary, participants, selfAgentId) {
|
|
15548
15718
|
if (topic && topic.length > 0) return topic;
|
|
@@ -15596,7 +15766,7 @@ async function addMeChatParticipants(db, chatId, callerHumanAgentId, callerOrgan
|
|
|
15596
15766
|
}).from(chats).where(eq(chats.id, chatId)).limit(1);
|
|
15597
15767
|
if (!chat) throw new NotFoundError(`Chat "${chatId}" not found`);
|
|
15598
15768
|
if (chat.organizationId !== callerOrganizationId) throw new NotFoundError(`Chat "${chatId}" not found`);
|
|
15599
|
-
const [callerRow] = await db.select({ chatId:
|
|
15769
|
+
const [callerRow] = await db.select({ chatId: chatMembership.chatId }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.agentId, callerHumanAgentId), eq(chatMembership.accessMode, "speaker"))).limit(1);
|
|
15600
15770
|
if (!callerRow) throw new NotFoundError(`Chat "${chatId}" not found`);
|
|
15601
15771
|
const found = await db.select({
|
|
15602
15772
|
uuid: agents.uuid,
|
|
@@ -15610,45 +15780,36 @@ async function addMeChatParticipants(db, chatId, callerHumanAgentId, callerOrgan
|
|
|
15610
15780
|
const crossOrg = found.filter((a) => a.organizationId !== chat.organizationId);
|
|
15611
15781
|
if (crossOrg.length > 0) throw new BadRequestError(`Cross-organization participant rejected: ${crossOrg.map((a) => a.uuid).join(", ")}`);
|
|
15612
15782
|
await db.transaction(async (tx) => {
|
|
15613
|
-
const
|
|
15614
|
-
const
|
|
15615
|
-
const
|
|
15616
|
-
if (
|
|
15783
|
+
const existingSpeakers = await tx.select({ agentId: chatMembership.agentId }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")));
|
|
15784
|
+
const existingSpeakerSet = new Set(existingSpeakers.map((e) => e.agentId));
|
|
15785
|
+
const toUpsert = distinct.filter((id) => !existingSpeakerSet.has(id));
|
|
15786
|
+
if (toUpsert.length === 0) {
|
|
15617
15787
|
await recomputeChatWatchers(tx, chatId);
|
|
15618
15788
|
return;
|
|
15619
15789
|
}
|
|
15620
|
-
if (
|
|
15621
|
-
|
|
15622
|
-
agentId
|
|
15623
|
-
|
|
15624
|
-
|
|
15625
|
-
});
|
|
15626
|
-
const carriedByAgent = new Map(carriedRows.map((r) => [r.agentId, r]));
|
|
15627
|
-
await addChatParticipants(tx, chatId, toInsert.map((agentId) => {
|
|
15628
|
-
const carried = carriedByAgent.get(agentId);
|
|
15629
|
-
return {
|
|
15630
|
-
agentId,
|
|
15631
|
-
role: "member",
|
|
15632
|
-
carriedReadState: carried ? {
|
|
15633
|
-
lastReadAt: carried.lastReadAt,
|
|
15634
|
-
unreadMentionCount: carried.unreadMentionCount
|
|
15635
|
-
} : void 0
|
|
15636
|
-
};
|
|
15637
|
-
}), { onConflictDoNothing: true });
|
|
15790
|
+
if (existingSpeakers.length + toUpsert.length >= 3 && chat.type === "direct") await changeChatType(tx, chatId, "group");
|
|
15791
|
+
await addChatParticipants(tx, chatId, toUpsert.map((agentId) => ({
|
|
15792
|
+
agentId,
|
|
15793
|
+
role: "member"
|
|
15794
|
+
})), { upgradeWatcherToSpeaker: true });
|
|
15638
15795
|
await recomputeChatWatchers(tx, chatId);
|
|
15639
15796
|
});
|
|
15640
15797
|
invalidateChatAudience(chatId);
|
|
15641
15798
|
}
|
|
15642
15799
|
async function markMeChatRead(db, chatId, humanAgentId) {
|
|
15643
15800
|
const now = /* @__PURE__ */ new Date();
|
|
15644
|
-
await db.
|
|
15645
|
-
|
|
15646
|
-
|
|
15647
|
-
}).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, humanAgentId)));
|
|
15648
|
-
await db.update(chatSubscriptions).set({
|
|
15801
|
+
await db.insert(chatUserState).values({
|
|
15802
|
+
chatId,
|
|
15803
|
+
agentId: humanAgentId,
|
|
15649
15804
|
lastReadAt: now,
|
|
15650
15805
|
unreadMentionCount: 0
|
|
15651
|
-
}).
|
|
15806
|
+
}).onConflictDoUpdate({
|
|
15807
|
+
target: [chatUserState.chatId, chatUserState.agentId],
|
|
15808
|
+
set: {
|
|
15809
|
+
lastReadAt: now,
|
|
15810
|
+
unreadMentionCount: 0
|
|
15811
|
+
}
|
|
15812
|
+
});
|
|
15652
15813
|
return {
|
|
15653
15814
|
chatId,
|
|
15654
15815
|
lastReadAt: now.toISOString(),
|
|
@@ -15674,7 +15835,7 @@ async function leaveMeChat(db, chatId, humanAgentId) {
|
|
|
15674
15835
|
async function chatRoutes(app) {
|
|
15675
15836
|
app.get("/:chatId", async (request) => {
|
|
15676
15837
|
const { chat, scope } = await requireChatAccess(request, app.db);
|
|
15677
|
-
const participants = await app.db.select().from(
|
|
15838
|
+
const participants = await app.db.select().from(chatMembership).where(and(eq(chatMembership.chatId, chat.id), eq(chatMembership.accessMode, "speaker")));
|
|
15678
15839
|
const firstMsgRows = await app.db.execute(sql`
|
|
15679
15840
|
SELECT content FROM messages
|
|
15680
15841
|
WHERE chat_id = ${chat.id}
|
|
@@ -15698,10 +15859,12 @@ async function chatRoutes(app) {
|
|
|
15698
15859
|
};
|
|
15699
15860
|
});
|
|
15700
15861
|
const title = resolveChatTitle(chat.topic, firstMessagePreview, participantsForTitle, scope.humanAgentId);
|
|
15862
|
+
const engagementStatus = await getCallerEngagement(app.db, chat.id, scope.humanAgentId);
|
|
15701
15863
|
return {
|
|
15702
15864
|
...chat,
|
|
15703
15865
|
title,
|
|
15704
15866
|
firstMessagePreview,
|
|
15867
|
+
engagementStatus,
|
|
15705
15868
|
createdAt: chat.createdAt.toISOString(),
|
|
15706
15869
|
updatedAt: chat.updatedAt.toISOString(),
|
|
15707
15870
|
participants: participants.map((p) => ({
|
|
@@ -15712,6 +15875,15 @@ async function chatRoutes(app) {
|
|
|
15712
15875
|
}))
|
|
15713
15876
|
};
|
|
15714
15877
|
});
|
|
15878
|
+
app.post("/:chatId/engagement", { config: { otelRecordBody: true } }, async (request, reply) => {
|
|
15879
|
+
const { scope } = await requireChatAccess(request, app.db);
|
|
15880
|
+
const body = patchChatEngagementSchema.parse(request.body);
|
|
15881
|
+
await setChatEngagement(app.db, request.params.chatId, scope.humanAgentId, body.status);
|
|
15882
|
+
return reply.status(200).send({
|
|
15883
|
+
chatId: request.params.chatId,
|
|
15884
|
+
engagementStatus: body.status
|
|
15885
|
+
});
|
|
15886
|
+
});
|
|
15715
15887
|
app.patch("/:chatId", { config: { otelRecordBody: true } }, async (request) => {
|
|
15716
15888
|
await requireChatAccess(request, app.db);
|
|
15717
15889
|
const body = updateChatSchema.parse(request.body);
|
|
@@ -15804,7 +15976,7 @@ async function chatRoutes(app) {
|
|
|
15804
15976
|
app.post("/:chatId/messages", async (request, reply) => {
|
|
15805
15977
|
const { scope } = await requireChatAccess(request, app.db);
|
|
15806
15978
|
const body = sendMessageSchema.parse(request.body);
|
|
15807
|
-
await ensureParticipant
|
|
15979
|
+
await ensureParticipant(app.db, request.params.chatId, scope.humanAgentId);
|
|
15808
15980
|
const prepared = await prepareImageOutbound(app.db, app.notifier, request.params.chatId, {
|
|
15809
15981
|
...body,
|
|
15810
15982
|
source: "hub_ui"
|
|
@@ -15831,7 +16003,7 @@ async function chatRoutes(app) {
|
|
|
15831
16003
|
app.post("/:chatId/questions/:correlationId/answer", { config: { otelRecordBody: false } }, async (request, reply) => {
|
|
15832
16004
|
const { scope } = await requireChatAccess(request, app.db);
|
|
15833
16005
|
const body = submitQuestionAnswerSchema.parse(request.body);
|
|
15834
|
-
await ensureParticipant
|
|
16006
|
+
await ensureParticipant(app.db, request.params.chatId, scope.humanAgentId);
|
|
15835
16007
|
const result = await submitAnswer(app.db, app.notifier, {
|
|
15836
16008
|
correlationId: request.params.correlationId,
|
|
15837
16009
|
chatId: request.params.chatId,
|
|
@@ -17228,7 +17400,7 @@ async function healthzRoutes(app) {
|
|
|
17228
17400
|
* `api/orgs/invitations.ts` (Class B, admin-gated).
|
|
17229
17401
|
*/
|
|
17230
17402
|
async function publicInvitationRoutes(app) {
|
|
17231
|
-
const { previewInvitation } = await import("./invitation-C299fxkP-
|
|
17403
|
+
const { previewInvitation } = await import("./invitation-C299fxkP-KKslbta2.mjs");
|
|
17232
17404
|
app.get("/:token/preview", async (request, reply) => {
|
|
17233
17405
|
if (!request.params.token) throw new UnauthorizedError("Token required");
|
|
17234
17406
|
const preview = await previewInvitation(app.db, request.params.token);
|
|
@@ -17494,7 +17666,7 @@ async function meRoutes(app) {
|
|
|
17494
17666
|
*/
|
|
17495
17667
|
app.get("/me/pinned-agents", async (request) => {
|
|
17496
17668
|
const { userId } = requireUser(request);
|
|
17497
|
-
const { listMyPinnedAgents } = await import("./client-
|
|
17669
|
+
const { listMyPinnedAgents } = await import("./client-DNEtPEBu-BtHkUya2.mjs");
|
|
17498
17670
|
return listMyPinnedAgents(app.db, { userId });
|
|
17499
17671
|
});
|
|
17500
17672
|
/**
|
|
@@ -17680,7 +17852,8 @@ async function orgActivityRoutes(app) {
|
|
|
17680
17852
|
activeSessions: a.activeSessions,
|
|
17681
17853
|
totalSessions: a.totalSessions,
|
|
17682
17854
|
runtimeUpdatedAt: a.runtimeUpdatedAt?.toISOString() ?? null,
|
|
17683
|
-
type: "type" in a ? a.type : null
|
|
17855
|
+
type: "type" in a ? a.type : null,
|
|
17856
|
+
managedByMe: "managerId" in a ? a.managerId === scope.memberId : false
|
|
17684
17857
|
}))
|
|
17685
17858
|
};
|
|
17686
17859
|
});
|
|
@@ -17903,7 +18076,7 @@ async function orgChatRoutes(app) {
|
|
|
17903
18076
|
createdAt: chats.createdAt,
|
|
17904
18077
|
updatedAt: chats.updatedAt,
|
|
17905
18078
|
participantCount: sql`(
|
|
17906
|
-
SELECT count(*)::int FROM
|
|
18079
|
+
SELECT count(*)::int FROM chat_membership WHERE chat_id = ${chats.id} AND access_mode = 'speaker'
|
|
17907
18080
|
)`
|
|
17908
18081
|
}).from(chats).where(and(...conditions)).orderBy(desc(chats.createdAt)).limit(query.limit + 1);
|
|
17909
18082
|
const hasMore = rows.length > query.limit;
|
|
@@ -19429,8 +19602,8 @@ var schema_exports = /* @__PURE__ */ __exportAll({
|
|
|
19429
19602
|
agentPresence: () => agentPresence,
|
|
19430
19603
|
agents: () => agents,
|
|
19431
19604
|
authIdentities: () => authIdentities,
|
|
19432
|
-
|
|
19433
|
-
|
|
19605
|
+
chatMembership: () => chatMembership,
|
|
19606
|
+
chatUserState: () => chatUserState,
|
|
19434
19607
|
chats: () => chats,
|
|
19435
19608
|
clients: () => clients,
|
|
19436
19609
|
githubAppInstallations: () => githubAppInstallations,
|