@agent-team-foundation/first-tree-hub 0.11.5 → 0.12.2
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 +6 -6
- package/dist/client-DHCSQ8kg-DjlSmE9q.mjs +2033 -0
- package/dist/client-WubcgX-W-B2bOvgJ1.mjs +7 -0
- package/dist/{dist-CfvCT4E0.mjs → dist-DHHd2dar.mjs} +144 -16
- package/dist/{dist-BQtAQNRD.mjs → dist-LgF7LHpE.mjs} +1 -1
- package/dist/drizzle/0034_pending_questions.sql +34 -0
- package/dist/drizzle/meta/_journal.json +7 -0
- package/dist/{feishu-DbSvp9UH.mjs → feishu-fLnwqCOs.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 +6 -6
- package/dist/{invitation-C299fxkP-BR-niZyp.mjs → invitation-C299fxkP-B89eqDos.mjs} +1 -1
- 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-CO554S-V.mjs → saas-connect-_2M4kfPR.mjs} +838 -1401
- package/dist/web/assets/index-C1DBMrHD.js +391 -0
- package/dist/web/assets/index-CwC0zzF5.css +1 -0
- package/dist/web/assets/{index-B7noAoV-.js → index-D5RJDuFw.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/client-D_TRJFZY-LbgJF47t.mjs +0 -4
- package/dist/client-DqdGiggm-NQoGZ2vM.mjs +0 -524
- package/dist/web/assets/index-DPLa60vJ.css +0 -1
- package/dist/web/assets/index-DvGkka4N.js +0 -390
- /package/dist/{esm-Ci8E1Gtj.mjs → esm-iadMkGbV.mjs} +0 -0
- /package/dist/{src-aJMV60mR.mjs → src-DNBS5Yjj.mjs} +0 -0
|
@@ -1,11 +1,11 @@
|
|
|
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,
|
|
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-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
|
|
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-DHHd2dar.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
|
-
import {
|
|
8
|
-
import { n as
|
|
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-DHCSQ8kg-DjlSmE9q.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";
|
|
@@ -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";
|
|
@@ -1015,7 +1015,9 @@ const messageFormatSchema = z.enum([
|
|
|
1015
1015
|
"card",
|
|
1016
1016
|
"reference",
|
|
1017
1017
|
"file",
|
|
1018
|
-
"task"
|
|
1018
|
+
"task",
|
|
1019
|
+
"question",
|
|
1020
|
+
"question_answer"
|
|
1019
1021
|
]);
|
|
1020
1022
|
z.object({
|
|
1021
1023
|
format: messageFormatSchema.default("text"),
|
|
@@ -1363,26 +1365,51 @@ z.object({
|
|
|
1363
1365
|
* 2. Add a key to `ORG_SETTINGS_NAMESPACES`.
|
|
1364
1366
|
* 3. Done. No DB migration, no new API route.
|
|
1365
1367
|
*/
|
|
1366
|
-
const
|
|
1368
|
+
const SCP_LIKE_SSH_RE = /^(?:[A-Za-z0-9_.-]+@)?[A-Za-z0-9.-]+:(?!\d+(?:\/|$))[^/:@\s][^:@\s]*$/;
|
|
1369
|
+
function isScpLikeSshUrl(value) {
|
|
1370
|
+
if (value.includes("://")) return false;
|
|
1371
|
+
return SCP_LIKE_SSH_RE.test(value);
|
|
1372
|
+
}
|
|
1373
|
+
const repoUrlSchema = z.string().min(1).superRefine((value, ctx) => {
|
|
1374
|
+
if (isScpLikeSshUrl(value)) return;
|
|
1375
|
+
let url;
|
|
1367
1376
|
try {
|
|
1368
|
-
|
|
1377
|
+
url = new URL(value);
|
|
1369
1378
|
} catch {
|
|
1370
|
-
|
|
1379
|
+
ctx.addIssue({
|
|
1380
|
+
code: z.ZodIssueCode.custom,
|
|
1381
|
+
message: "Repo URL must be HTTPS, SSH (ssh://...), or scp-like (git@host:path)."
|
|
1382
|
+
});
|
|
1383
|
+
return;
|
|
1371
1384
|
}
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
return
|
|
1385
|
+
if (url.protocol !== "https:" && url.protocol !== "ssh:") {
|
|
1386
|
+
ctx.addIssue({
|
|
1387
|
+
code: z.ZodIssueCode.custom,
|
|
1388
|
+
message: "Repo URL must use HTTPS or SSH."
|
|
1389
|
+
});
|
|
1390
|
+
return;
|
|
1391
|
+
}
|
|
1392
|
+
if (url.password.length > 0) {
|
|
1393
|
+
ctx.addIssue({
|
|
1394
|
+
code: z.ZodIssueCode.custom,
|
|
1395
|
+
message: "Repo URL must not include credentials."
|
|
1396
|
+
});
|
|
1397
|
+
return;
|
|
1398
|
+
}
|
|
1399
|
+
if (url.protocol === "https:" && url.username.length > 0) {
|
|
1400
|
+
ctx.addIssue({
|
|
1401
|
+
code: z.ZodIssueCode.custom,
|
|
1402
|
+
message: "Repo URL must not include credentials."
|
|
1403
|
+
});
|
|
1404
|
+
return;
|
|
1378
1405
|
}
|
|
1379
|
-
}
|
|
1406
|
+
});
|
|
1380
1407
|
const orgContextTreeStorageSchema = z.object({
|
|
1381
|
-
repo:
|
|
1408
|
+
repo: repoUrlSchema.optional(),
|
|
1382
1409
|
branch: z.string().default("main")
|
|
1383
1410
|
});
|
|
1384
1411
|
const orgContextTreeInputSchema = z.object({
|
|
1385
|
-
repo:
|
|
1412
|
+
repo: repoUrlSchema.nullish(),
|
|
1386
1413
|
branch: z.string().min(1).nullish()
|
|
1387
1414
|
});
|
|
1388
1415
|
const orgContextTreeOutputSchema = z.object({
|
|
@@ -1395,16 +1422,36 @@ const orgGithubIntegrationOutputSchema = z.object({
|
|
|
1395
1422
|
webhookSecretConfigured: z.boolean(),
|
|
1396
1423
|
webhookUrl: z.string()
|
|
1397
1424
|
});
|
|
1425
|
+
const orgSourceReposStorageSchema = z.object({ repos: z.array(z.object({
|
|
1426
|
+
url: repoUrlSchema,
|
|
1427
|
+
defaultBranch: z.string().optional()
|
|
1428
|
+
})).default([]) });
|
|
1429
|
+
const orgSourceReposInputSchema = z.object({ repos: z.array(z.object({
|
|
1430
|
+
url: repoUrlSchema,
|
|
1431
|
+
defaultBranch: z.string().min(1).optional()
|
|
1432
|
+
})).optional() });
|
|
1433
|
+
const orgSourceReposOutputSchema = z.object({ repos: z.array(z.object({
|
|
1434
|
+
url: z.string(),
|
|
1435
|
+
defaultBranch: z.string().optional()
|
|
1436
|
+
})) });
|
|
1398
1437
|
const ORG_SETTINGS_NAMESPACES = {
|
|
1399
1438
|
context_tree: {
|
|
1400
1439
|
storage: orgContextTreeStorageSchema,
|
|
1401
1440
|
input: orgContextTreeInputSchema,
|
|
1402
|
-
output: orgContextTreeOutputSchema
|
|
1441
|
+
output: orgContextTreeOutputSchema,
|
|
1442
|
+
readPolicy: "member"
|
|
1403
1443
|
},
|
|
1404
1444
|
github_integration: {
|
|
1405
1445
|
storage: orgGithubIntegrationStorageSchema,
|
|
1406
1446
|
input: orgGithubIntegrationInputSchema,
|
|
1407
|
-
output: orgGithubIntegrationOutputSchema
|
|
1447
|
+
output: orgGithubIntegrationOutputSchema,
|
|
1448
|
+
readPolicy: "admin"
|
|
1449
|
+
},
|
|
1450
|
+
source_repos: {
|
|
1451
|
+
storage: orgSourceReposStorageSchema,
|
|
1452
|
+
input: orgSourceReposInputSchema,
|
|
1453
|
+
output: orgSourceReposOutputSchema,
|
|
1454
|
+
readPolicy: "member"
|
|
1408
1455
|
}
|
|
1409
1456
|
};
|
|
1410
1457
|
const ORG_SETTINGS_NAMESPACE_KEYS = Object.keys(ORG_SETTINGS_NAMESPACES);
|
|
@@ -1443,6 +1490,78 @@ z.object({
|
|
|
1443
1490
|
organizationId: z.string(),
|
|
1444
1491
|
agents: z.record(z.string(), z.array(pulseBucketSchema).length(32))
|
|
1445
1492
|
});
|
|
1493
|
+
/**
|
|
1494
|
+
* Structured ask-user payloads bridged from the Claude Agent SDK
|
|
1495
|
+
* `AskUserQuestion` tool to Hub messages.
|
|
1496
|
+
*
|
|
1497
|
+
* Shape mirrors the SDK 0.2.84 input/output verbatim so the client
|
|
1498
|
+
* runtime adapter can pass `updatedInput` straight through. See
|
|
1499
|
+
* verify scripts under `packages/client/tmp-verify/` for the live
|
|
1500
|
+
* matrix this was validated against.
|
|
1501
|
+
*
|
|
1502
|
+
* Lifecycle:
|
|
1503
|
+
* 1. Agent emits a `format: "question"` message — its `content` is a
|
|
1504
|
+
* `QuestionMessageContent` carrying `correlationId` + `questions[]`.
|
|
1505
|
+
* 2. User picks options in the Web UI and POSTs answers; server writes
|
|
1506
|
+
* a `format: "question_answer"` message — its `content` is a
|
|
1507
|
+
* `QuestionAnswerMessageContent` referencing the same `correlationId`.
|
|
1508
|
+
* 3. Client runtime resolves the in-flight `canUseTool` promise with the
|
|
1509
|
+
* answers, and the SDK feeds them back to the model.
|
|
1510
|
+
*
|
|
1511
|
+
* `pending → answered → superseded` runtime status lives in a separate
|
|
1512
|
+
* server table (`pending_questions`) and is not part of the message —
|
|
1513
|
+
* messages are immutable once written.
|
|
1514
|
+
*/
|
|
1515
|
+
/**
|
|
1516
|
+
* Single option inside a question. `preview` is rich content rendered above
|
|
1517
|
+
* the label — the SDK's tool input emits it as `string | undefined` (the
|
|
1518
|
+
* field is omitted when the model didn't generate any preview content), so
|
|
1519
|
+
* we accept undefined / null / string and normalise downstream renderers
|
|
1520
|
+
* to treat all three the same way.
|
|
1521
|
+
*/
|
|
1522
|
+
const questionOptionSchema = z.object({
|
|
1523
|
+
label: z.string().min(1),
|
|
1524
|
+
description: z.string(),
|
|
1525
|
+
preview: z.string().nullable().optional()
|
|
1526
|
+
});
|
|
1527
|
+
/**
|
|
1528
|
+
* One question. `header` is a chip-style short tag. The SDK schema docs
|
|
1529
|
+
* describe ≤12 chars but in practice the model occasionally emits
|
|
1530
|
+
* slightly longer headers; we keep the rule loose (≤24) so a stylistic
|
|
1531
|
+
* regression doesn't fail-closed at canUseTool and abandon the entire
|
|
1532
|
+
* tool call. The UI truncates visually if needed.
|
|
1533
|
+
*/
|
|
1534
|
+
const questionItemSchema = z.object({
|
|
1535
|
+
question: z.string().min(1),
|
|
1536
|
+
header: z.string().min(1).max(24),
|
|
1537
|
+
options: z.array(questionOptionSchema).min(2).max(4),
|
|
1538
|
+
multiSelect: z.boolean()
|
|
1539
|
+
});
|
|
1540
|
+
/** Session-level preview format hint. Mirrors `toolConfig.askUserQuestion.previewFormat`. */
|
|
1541
|
+
const questionPreviewFormatSchema = z.enum(["html", "markdown"]).nullable();
|
|
1542
|
+
z.object({
|
|
1543
|
+
correlationId: z.string().min(1),
|
|
1544
|
+
questions: z.array(questionItemSchema).min(1).max(4),
|
|
1545
|
+
previewFormat: questionPreviewFormatSchema,
|
|
1546
|
+
allowFreeText: z.boolean()
|
|
1547
|
+
});
|
|
1548
|
+
/**
|
|
1549
|
+
* Content payload for a message whose `format === "question_answer"`.
|
|
1550
|
+
*
|
|
1551
|
+
* `answers` is keyed by `QuestionItem.question` text. For `multiSelect` questions
|
|
1552
|
+
* the value is a `, `-joined string of selected labels (matches SDK convention).
|
|
1553
|
+
* For free-text answers the value is the user's raw input.
|
|
1554
|
+
*/
|
|
1555
|
+
const questionAnswerMessageContentSchema = z.object({
|
|
1556
|
+
correlationId: z.string().min(1),
|
|
1557
|
+
answers: z.record(z.string().min(1), z.string())
|
|
1558
|
+
});
|
|
1559
|
+
z.object({ answers: z.record(z.string().min(1), z.string()) });
|
|
1560
|
+
z.enum([
|
|
1561
|
+
"pending",
|
|
1562
|
+
"answered",
|
|
1563
|
+
"superseded"
|
|
1564
|
+
]);
|
|
1446
1565
|
const sessionEventKind = z.enum([
|
|
1447
1566
|
"tool_call",
|
|
1448
1567
|
"error",
|
|
@@ -2935,8 +3054,65 @@ For content with quotes, \`$\`, backticks, or newlines, prefer stdin to avoid sh
|
|
|
2935
3054
|
const DEFAULT_CLONE_TIMEOUT_MS = 300 * 1e3;
|
|
2936
3055
|
const FETCH_REFSPEC = "+refs/heads/*:refs/remotes/origin/*";
|
|
2937
3056
|
const SESSION_BRANCH_PREFIX = "hub-session";
|
|
3057
|
+
/**
|
|
3058
|
+
* Hash a repo URL into the mirror directory name.
|
|
3059
|
+
*
|
|
3060
|
+
* The hash is computed over a *canonical* form (host + path, lowercased
|
|
3061
|
+
* host, no `.git` suffix, no leading/trailing slash, no protocol, no
|
|
3062
|
+
* `user@` prefix), so the same upstream repo addressed via any of the
|
|
3063
|
+
* accepted forms (HTTPS, `ssh://`, or scp-like) all land in the same
|
|
3064
|
+
* mirror dir. That matters because:
|
|
3065
|
+
* - admins may write any of those three forms into `source_repos[].url`
|
|
3066
|
+
* (the schema accepts all of them)
|
|
3067
|
+
* - the `fetchOrigin` fallback below transparently swaps protocols when
|
|
3068
|
+
* credentials are missing — without canonical hashing, the fallback
|
|
3069
|
+
* would silently maintain a second mirror dir
|
|
3070
|
+
*
|
|
3071
|
+
* Migration cost: pre-existing mirrors created before this change use the
|
|
3072
|
+
* raw-URL hash and will be orphaned after the upgrade. `gcMirrors` removes
|
|
3073
|
+
* them on the next run; the next fetch repopulates the canonical-keyed
|
|
3074
|
+
* mirror. No data loss — mirror is a cache, not state.
|
|
3075
|
+
*/
|
|
2938
3076
|
function hashUrl(url) {
|
|
2939
|
-
return createHash("sha256").update(url).digest("hex").slice(0, 32);
|
|
3077
|
+
return createHash("sha256").update(canonicalizeRepoUrl(url)).digest("hex").slice(0, 32);
|
|
3078
|
+
}
|
|
3079
|
+
/**
|
|
3080
|
+
* Reduce a repo URL to `<host[:port]>/<path-without-.git>`. Used as the
|
|
3081
|
+
* input to the mirror dir hash and exposed for unit testing.
|
|
3082
|
+
*
|
|
3083
|
+
* `https://github.com/foo/bar.git` → `github.com/foo/bar`
|
|
3084
|
+
* `git@github.com:foo/bar.git` → `github.com/foo/bar`
|
|
3085
|
+
* `ssh://git@github.com/foo/bar.git` → `github.com/foo/bar`
|
|
3086
|
+
* `ssh://git@gitlab.example.com:2222/x/y` → `gitlab.example.com:2222/x/y`
|
|
3087
|
+
*
|
|
3088
|
+
* Falls back to the raw input for un-parseable strings — better to keep
|
|
3089
|
+
* a stable mirror than to throw mid-bootstrap.
|
|
3090
|
+
*/
|
|
3091
|
+
function canonicalizeRepoUrl(url) {
|
|
3092
|
+
if (!url.includes("://")) {
|
|
3093
|
+
const m = url.match(/^(?:[A-Za-z0-9_.-]+@)?([A-Za-z0-9.-]+):([^/@:\s][^@:\s]*)$/);
|
|
3094
|
+
const host = m?.[1];
|
|
3095
|
+
const rawPath = m?.[2];
|
|
3096
|
+
if (host && rawPath && !/^\d+(?:\/|$)/.test(rawPath)) {
|
|
3097
|
+
const path = normalizePath(rawPath);
|
|
3098
|
+
return `${host.toLowerCase()}/${path}`;
|
|
3099
|
+
}
|
|
3100
|
+
}
|
|
3101
|
+
try {
|
|
3102
|
+
const parsed = new URL(url);
|
|
3103
|
+
const host = parsed.hostname.toLowerCase();
|
|
3104
|
+
return `${parsed.port === "" || parsed.protocol === "https:" && parsed.port === "443" || parsed.protocol === "ssh:" && parsed.port === "22" ? host : `${host}:${parsed.port}`}/${normalizePath(parsed.pathname)}`;
|
|
3105
|
+
} catch {
|
|
3106
|
+
return url;
|
|
3107
|
+
}
|
|
3108
|
+
}
|
|
3109
|
+
/**
|
|
3110
|
+
* Strip leading slashes, trailing slashes, and a trailing `.git`. Used by
|
|
3111
|
+
* `canonicalizeRepoUrl` so that `…/foo/bar`, `…/foo/bar/`, and `…/foo/bar.git`
|
|
3112
|
+
* all collapse to the same canonical path (and therefore the same mirror dir).
|
|
3113
|
+
*/
|
|
3114
|
+
function normalizePath(rawPath) {
|
|
3115
|
+
return rawPath.replace(/^\/+/, "").replace(/\/+$/, "").replace(/\.git$/i, "");
|
|
2940
3116
|
}
|
|
2941
3117
|
function shortHash(input) {
|
|
2942
3118
|
return createHash("sha256").update(input).digest("hex").slice(0, 8);
|
|
@@ -2973,10 +3149,14 @@ function createGitMirrorManager(opts) {
|
|
|
2973
3149
|
}
|
|
2974
3150
|
async function git(args, cwd, timeoutMs, env) {
|
|
2975
3151
|
const start = Date.now();
|
|
3152
|
+
const finalEnv = {
|
|
3153
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
3154
|
+
...env ?? process.env
|
|
3155
|
+
};
|
|
2976
3156
|
return await new Promise((resolveExec, rejectExec) => {
|
|
2977
3157
|
const proc = spawn("git", args, {
|
|
2978
3158
|
cwd: cwd ?? void 0,
|
|
2979
|
-
env:
|
|
3159
|
+
env: finalEnv,
|
|
2980
3160
|
stdio: [
|
|
2981
3161
|
"ignore",
|
|
2982
3162
|
"pipe",
|
|
@@ -3020,6 +3200,116 @@ function createGitMirrorManager(opts) {
|
|
|
3020
3200
|
}
|
|
3021
3201
|
}
|
|
3022
3202
|
/**
|
|
3203
|
+
* `git fetch --prune origin` with one-shot bidirectional protocol fallback.
|
|
3204
|
+
*
|
|
3205
|
+
* Decides direction from `originUrl`'s protocol:
|
|
3206
|
+
* - HTTPS origin → on credential-shaped failure, retry as SSH
|
|
3207
|
+
* - SSH origin → on credential-shaped failure, retry as HTTPS
|
|
3208
|
+
* The fallback fires only when **all** hold:
|
|
3209
|
+
* 1. The first attempt failed with a credential-shaped error in the
|
|
3210
|
+
* direction-appropriate sense (`isLikelyHttpsAuthFailure` /
|
|
3211
|
+
* `isLikelySshAuthFailure`). Network failures, missing-ref errors,
|
|
3212
|
+
* and TLS/host-key surprises propagate as-is — those won't be cured
|
|
3213
|
+
* by switching transports and silently retrying would mask real bugs.
|
|
3214
|
+
* 2. The origin URL maps to a usable peer-protocol form
|
|
3215
|
+
* (see `httpsToSshBaseRewrite` / `sshToHttpsBaseRewrite`). Custom
|
|
3216
|
+
* ports beyond the protocol default disable the rewrite — there's no
|
|
3217
|
+
* universal mapping between `https://host:8443` and an SSH peer.
|
|
3218
|
+
*
|
|
3219
|
+
* Implementation: the retry uses `git -c url.<peer-base>.insteadOf=<origin-base>`
|
|
3220
|
+
* — git resolves origin's URL through that rewrite for the duration of
|
|
3221
|
+
* one subprocess and never persists anything to disk. The mirror's
|
|
3222
|
+
* `remote.origin.url` stays as configured, so the next fetch starts back
|
|
3223
|
+
* at the original protocol unless this fallback fires again.
|
|
3224
|
+
*/
|
|
3225
|
+
/**
|
|
3226
|
+
* `remote set-head --auto` is a remote-talking op too — under the same
|
|
3227
|
+
* credential rules as `fetch`. If we don't apply the same fallback,
|
|
3228
|
+
* mirrors whose HTTPS creds are missing end up with `refs/remotes/origin/HEAD`
|
|
3229
|
+
* unset, which then breaks `resolveBase()` for callers without an explicit
|
|
3230
|
+
* `ref` (the default-branch lookup throws).
|
|
3231
|
+
*
|
|
3232
|
+
* Strategy mirrors `fetchOrigin`: try the configured protocol first, fall
|
|
3233
|
+
* back to the peer protocol once via `-c url.<peer>.insteadOf=<origin>`.
|
|
3234
|
+
* Returns true on either success, false if both attempts failed (which
|
|
3235
|
+
* mirrors the historical `gitOk` semantics — non-fatal). No `GitMirrorAuthError`
|
|
3236
|
+
* here: callers that need origin/HEAD already get a clear
|
|
3237
|
+
* `GitMirrorError("Cannot resolve default branch …")` if both attempts fail.
|
|
3238
|
+
*/
|
|
3239
|
+
async function setHeadAuto(mirrorPath, originUrl) {
|
|
3240
|
+
if (await gitOk([
|
|
3241
|
+
"remote",
|
|
3242
|
+
"set-head",
|
|
3243
|
+
"origin",
|
|
3244
|
+
"--auto"
|
|
3245
|
+
], mirrorPath, 3e4)) return true;
|
|
3246
|
+
const direction = pickFallbackDirection(originUrl);
|
|
3247
|
+
if (!direction) return false;
|
|
3248
|
+
return await gitOk([
|
|
3249
|
+
"-c",
|
|
3250
|
+
`url.${direction.peerBase}.insteadOf=${direction.originBase}`,
|
|
3251
|
+
"remote",
|
|
3252
|
+
"set-head",
|
|
3253
|
+
"origin",
|
|
3254
|
+
"--auto"
|
|
3255
|
+
], mirrorPath, 3e4);
|
|
3256
|
+
}
|
|
3257
|
+
async function readOriginUrl(mirrorPath) {
|
|
3258
|
+
try {
|
|
3259
|
+
const { stdout } = await git([
|
|
3260
|
+
"config",
|
|
3261
|
+
"--get",
|
|
3262
|
+
"remote.origin.url"
|
|
3263
|
+
], mirrorPath, 1e4);
|
|
3264
|
+
return stdout.trim() || null;
|
|
3265
|
+
} catch {
|
|
3266
|
+
return null;
|
|
3267
|
+
}
|
|
3268
|
+
}
|
|
3269
|
+
async function fetchOrigin(mirrorPath, originUrl) {
|
|
3270
|
+
const direction = pickFallbackDirection(originUrl);
|
|
3271
|
+
try {
|
|
3272
|
+
const { elapsedMs } = await git([
|
|
3273
|
+
"fetch",
|
|
3274
|
+
"--prune",
|
|
3275
|
+
"origin"
|
|
3276
|
+
], mirrorPath, cloneTimeoutMs);
|
|
3277
|
+
return {
|
|
3278
|
+
elapsedMs,
|
|
3279
|
+
usedFallback: false
|
|
3280
|
+
};
|
|
3281
|
+
} catch (primaryErr) {
|
|
3282
|
+
const primaryMessage = primaryErr instanceof Error ? primaryErr.message : String(primaryErr);
|
|
3283
|
+
if (!direction || !direction.shouldRetry(primaryMessage)) throw primaryErr;
|
|
3284
|
+
log?.info({
|
|
3285
|
+
gitUrl: originUrl,
|
|
3286
|
+
fromProtocol: direction.fromProtocol,
|
|
3287
|
+
toProtocol: direction.toProtocol,
|
|
3288
|
+
peerBase: direction.peerBase
|
|
3289
|
+
}, "fetch failed with credential-shaped error; retrying with peer-protocol insteadOf rewrite");
|
|
3290
|
+
try {
|
|
3291
|
+
const { elapsedMs } = await git([
|
|
3292
|
+
"-c",
|
|
3293
|
+
`url.${direction.peerBase}.insteadOf=${direction.originBase}`,
|
|
3294
|
+
"fetch",
|
|
3295
|
+
"--prune",
|
|
3296
|
+
"origin"
|
|
3297
|
+
], mirrorPath, cloneTimeoutMs);
|
|
3298
|
+
log?.info({
|
|
3299
|
+
gitUrl: originUrl,
|
|
3300
|
+
toProtocol: direction.toProtocol
|
|
3301
|
+
}, "protocol-fallback fetch succeeded");
|
|
3302
|
+
return {
|
|
3303
|
+
elapsedMs,
|
|
3304
|
+
usedFallback: true
|
|
3305
|
+
};
|
|
3306
|
+
} catch (peerErr) {
|
|
3307
|
+
const peerMessage = peerErr instanceof Error ? peerErr.message : String(peerErr);
|
|
3308
|
+
throw new GitMirrorAuthError(`Could not fetch ${originUrl} over ${direction.fromProtocol.toUpperCase()} or ${direction.toProtocol.toUpperCase()}. ${direction.fromProtocol.toUpperCase()} attempt failed: ${truncate(primaryMessage)} ${direction.toProtocol.toUpperCase()} retry (${direction.peerBase}) failed: ${truncate(peerMessage)}`);
|
|
3309
|
+
}
|
|
3310
|
+
}
|
|
3311
|
+
}
|
|
3312
|
+
/**
|
|
3023
3313
|
* Bring the mirror's config to the invariant expected by this module:
|
|
3024
3314
|
* fetch refspec = `+refs/heads/*:refs/remotes/origin/*`, `remote.origin.mirror`
|
|
3025
3315
|
* absent, `refs/remotes/origin/HEAD` resolvable.
|
|
@@ -3087,17 +3377,8 @@ function createGitMirrorManager(opts) {
|
|
|
3087
3377
|
migrated = true;
|
|
3088
3378
|
}
|
|
3089
3379
|
if (migrated) {
|
|
3090
|
-
await
|
|
3091
|
-
|
|
3092
|
-
"--prune",
|
|
3093
|
-
"origin"
|
|
3094
|
-
], mirrorPath, cloneTimeoutMs);
|
|
3095
|
-
await gitOk([
|
|
3096
|
-
"remote",
|
|
3097
|
-
"set-head",
|
|
3098
|
-
"origin",
|
|
3099
|
-
"--auto"
|
|
3100
|
-
], mirrorPath, 3e4);
|
|
3380
|
+
await fetchOrigin(mirrorPath, url);
|
|
3381
|
+
await setHeadAuto(mirrorPath, url);
|
|
3101
3382
|
log?.info({ gitUrl: url }, "mirror config migrated");
|
|
3102
3383
|
}
|
|
3103
3384
|
return { migrated };
|
|
@@ -3127,17 +3408,8 @@ function createGitMirrorManager(opts) {
|
|
|
3127
3408
|
"remote.origin.fetch",
|
|
3128
3409
|
FETCH_REFSPEC
|
|
3129
3410
|
], mirrorPath, 1e4);
|
|
3130
|
-
await
|
|
3131
|
-
|
|
3132
|
-
"--prune",
|
|
3133
|
-
"origin"
|
|
3134
|
-
], mirrorPath, cloneTimeoutMs);
|
|
3135
|
-
await gitOk([
|
|
3136
|
-
"remote",
|
|
3137
|
-
"set-head",
|
|
3138
|
-
"origin",
|
|
3139
|
-
"--auto"
|
|
3140
|
-
], mirrorPath, 3e4);
|
|
3411
|
+
await fetchOrigin(mirrorPath, url);
|
|
3412
|
+
await setHeadAuto(mirrorPath, url);
|
|
3141
3413
|
}
|
|
3142
3414
|
async function branchExists(mirrorPath, branchName) {
|
|
3143
3415
|
return await gitOk([
|
|
@@ -3163,6 +3435,21 @@ function createGitMirrorManager(opts) {
|
|
|
3163
3435
|
"--quiet",
|
|
3164
3436
|
"refs/remotes/origin/HEAD"
|
|
3165
3437
|
], mirrorPath, 1e4)) return "refs/remotes/origin/HEAD";
|
|
3438
|
+
const url = await readOriginUrl(mirrorPath);
|
|
3439
|
+
if (url && await setHeadAuto(mirrorPath, url)) {
|
|
3440
|
+
if (await gitOk([
|
|
3441
|
+
"rev-parse",
|
|
3442
|
+
"--verify",
|
|
3443
|
+
"--quiet",
|
|
3444
|
+
"refs/remotes/origin/HEAD"
|
|
3445
|
+
], mirrorPath, 1e4)) {
|
|
3446
|
+
log?.info({
|
|
3447
|
+
mirrorPath,
|
|
3448
|
+
gitUrl: url
|
|
3449
|
+
}, "origin/HEAD self-healed via setHeadAuto");
|
|
3450
|
+
return "refs/remotes/origin/HEAD";
|
|
3451
|
+
}
|
|
3452
|
+
}
|
|
3166
3453
|
throw new GitMirrorError("Cannot resolve default branch: refs/remotes/origin/HEAD is missing. Re-run with an explicit `ref`.");
|
|
3167
3454
|
}
|
|
3168
3455
|
if (looksLikeCommitSha(ref)) {
|
|
@@ -3231,16 +3518,12 @@ function createGitMirrorManager(opts) {
|
|
|
3231
3518
|
const path = mirrorDir(url);
|
|
3232
3519
|
if (!existsSync(join(path, "HEAD"))) throw new GitMirrorError(`Cannot fetch — no mirror exists for "${url}"`);
|
|
3233
3520
|
try {
|
|
3234
|
-
const { elapsedMs } = await
|
|
3235
|
-
"fetch",
|
|
3236
|
-
"--prune",
|
|
3237
|
-
"origin"
|
|
3238
|
-
], path, cloneTimeoutMs);
|
|
3521
|
+
const { elapsedMs } = await fetchOrigin(path, url);
|
|
3239
3522
|
return { elapsedMs };
|
|
3240
3523
|
} catch (err) {
|
|
3241
3524
|
log?.warn({
|
|
3242
3525
|
gitUrl: url,
|
|
3243
|
-
errorCode: err instanceof GitMirrorError ? "git-failed" : "unknown",
|
|
3526
|
+
errorCode: err instanceof GitMirrorAuthError ? "auth-failed" : err instanceof GitMirrorTimeoutError ? "timeout" : err instanceof GitMirrorError ? "git-failed" : "unknown",
|
|
3244
3527
|
stderr: err instanceof Error ? err.message.slice(0, 1024) : String(err).slice(0, 1024)
|
|
3245
3528
|
}, "mirror fetch failed");
|
|
3246
3529
|
throw err;
|
|
@@ -3378,6 +3661,166 @@ var GitMirrorWorktreeConflictError = class extends GitMirrorError {
|
|
|
3378
3661
|
}
|
|
3379
3662
|
};
|
|
3380
3663
|
/**
|
|
3664
|
+
* Thrown when both the HTTPS fetch and the SSH fallback fail. The message
|
|
3665
|
+
* carries trimmed stderr from both attempts so the operator can see whether
|
|
3666
|
+
* the host's HTTPS credentials are missing, the SSH key is missing, or both.
|
|
3667
|
+
*/
|
|
3668
|
+
var GitMirrorAuthError = class extends GitMirrorError {
|
|
3669
|
+
constructor(message) {
|
|
3670
|
+
super(message);
|
|
3671
|
+
this.name = "GitMirrorAuthError";
|
|
3672
|
+
}
|
|
3673
|
+
};
|
|
3674
|
+
/**
|
|
3675
|
+
* Heuristic for HTTPS-side credential failures. Matches the indicators git
|
|
3676
|
+
* itself emits over libcurl / git-credential helpers, regardless of platform.
|
|
3677
|
+
*
|
|
3678
|
+
* Negative space (intentionally NOT matched): network errors
|
|
3679
|
+
* (`Could not resolve host`, `connection refused`), repo errors
|
|
3680
|
+
* (`Repository not found`, `couldn't find remote ref`), TLS errors
|
|
3681
|
+
* (`SSL certificate problem`). Those won't be cured by switching transports
|
|
3682
|
+
* and silently retrying would mask the real bug.
|
|
3683
|
+
*
|
|
3684
|
+
* Exported for unit testing.
|
|
3685
|
+
*/
|
|
3686
|
+
function isLikelyHttpsAuthFailure(message) {
|
|
3687
|
+
if (!message) return false;
|
|
3688
|
+
return /could not read Username/i.test(message) || /could not read Password/i.test(message) || /Authentication failed/i.test(message) || /terminal prompts disabled/i.test(message) || /HTTP\s*(?:Basic:\s*)?Access denied/i.test(message) || /\bHTTP[/ ]?(1\.[01]|2|2\.0)?\s*40[13]\b/i.test(message) || /\bfatal:\s*unable to access\b.*\b(401|403)\b/i.test(message) || /\bremote: Invalid username or password\b/i.test(message);
|
|
3689
|
+
}
|
|
3690
|
+
/**
|
|
3691
|
+
* Heuristic for SSH-side credential failures (no key on disk, key not
|
|
3692
|
+
* accepted by remote, agent has nothing usable, host key mismatch).
|
|
3693
|
+
*
|
|
3694
|
+
* Negative space (intentionally NOT matched): SSH-level network errors
|
|
3695
|
+
* (`Could not resolve hostname`, `Connection refused`, `Connection timed out`).
|
|
3696
|
+
* Those are network reachability issues — switching to HTTPS won't help
|
|
3697
|
+
* unless the network policy specifically blocks port 22, which is rare
|
|
3698
|
+
* enough that we'd rather surface the original error than guess.
|
|
3699
|
+
*
|
|
3700
|
+
* Exported for unit testing.
|
|
3701
|
+
*/
|
|
3702
|
+
function isLikelySshAuthFailure(message) {
|
|
3703
|
+
if (!message) return false;
|
|
3704
|
+
return /Permission denied\s*(?:\(|,)/i.test(message) || /Could not read from remote repository/i.test(message) || /Host key verification failed/i.test(message) || /no matching host key type/i.test(message) || /no mutual signature algorithm/i.test(message);
|
|
3705
|
+
}
|
|
3706
|
+
/**
|
|
3707
|
+
* Map an HTTPS git URL to the `insteadOf` rewrite needed to make git resolve
|
|
3708
|
+
* it through SSH. Returns *base* strings (suitable for
|
|
3709
|
+
* `git -c url.<sshBase>.insteadOf=<httpsBase>`) — git's `insteadOf` is a
|
|
3710
|
+
* prefix match, so we only need the host segment.
|
|
3711
|
+
*
|
|
3712
|
+
* `https://github.com/owner/repo.git` → `git@github.com:` / `https://github.com/`
|
|
3713
|
+
*
|
|
3714
|
+
* Returns `null` for inputs that should NOT trigger fallback:
|
|
3715
|
+
* - non-HTTPS URLs (already SSH, `git://`, `file://`, etc.)
|
|
3716
|
+
* - URLs with embedded credentials (schema rejects these on input;
|
|
3717
|
+
* belt-and-braces — never silently downgrade auth strength)
|
|
3718
|
+
* - HTTPS URLs with a non-default port — there is no portable mapping to
|
|
3719
|
+
* an SSH port (HTTPS:8443 ↔ SSH:???), so we refuse to guess
|
|
3720
|
+
* - URLs that fail to parse
|
|
3721
|
+
*
|
|
3722
|
+
* Exported for unit testing.
|
|
3723
|
+
*/
|
|
3724
|
+
function httpsToSshBaseRewrite(url) {
|
|
3725
|
+
if (!url || !/^https:\/\//i.test(url)) return null;
|
|
3726
|
+
let parsed;
|
|
3727
|
+
try {
|
|
3728
|
+
parsed = new URL(url);
|
|
3729
|
+
} catch {
|
|
3730
|
+
return null;
|
|
3731
|
+
}
|
|
3732
|
+
if (parsed.protocol !== "https:") return null;
|
|
3733
|
+
if (parsed.username.length > 0 || parsed.password.length > 0) return null;
|
|
3734
|
+
if (!parsed.hostname) return null;
|
|
3735
|
+
if (parsed.port && parsed.port !== "443") return null;
|
|
3736
|
+
return {
|
|
3737
|
+
httpsBase: `https://${parsed.hostname}/`,
|
|
3738
|
+
sshBase: `git@${parsed.hostname}:`
|
|
3739
|
+
};
|
|
3740
|
+
}
|
|
3741
|
+
/**
|
|
3742
|
+
* Map an SSH git URL (either `ssh://` or scp-like `[user@]host:path`) to the
|
|
3743
|
+
* `insteadOf` rewrite for resolving via HTTPS. Mirror of
|
|
3744
|
+
* `httpsToSshBaseRewrite`.
|
|
3745
|
+
*
|
|
3746
|
+
* `git@github.com:owner/repo.git` → `git@github.com:` / `https://github.com/`
|
|
3747
|
+
* `ssh://git@github.com/owner/repo.git` → `ssh://git@github.com/` / `https://github.com/`
|
|
3748
|
+
* `ssh://git@gitlab.example.com:22/x/y.git` → `ssh://git@gitlab.example.com:22/` / `https://gitlab.example.com/`
|
|
3749
|
+
*
|
|
3750
|
+
* Returns `null` when:
|
|
3751
|
+
* - URL is not SSH-shaped
|
|
3752
|
+
* - URL has an embedded password (`user:pass@`)
|
|
3753
|
+
* - SSH URL has a non-default port (≠ 22) — no portable mapping to HTTPS
|
|
3754
|
+
*
|
|
3755
|
+
* Exported for unit testing.
|
|
3756
|
+
*/
|
|
3757
|
+
function sshToHttpsBaseRewrite(url) {
|
|
3758
|
+
if (!url) return null;
|
|
3759
|
+
if (!url.includes("://")) {
|
|
3760
|
+
const m = url.match(/^((?:[A-Za-z0-9_.-]+@)?)([A-Za-z0-9.-]+):([^/@:\s][^@:\s]*)$/);
|
|
3761
|
+
const userAt = m?.[1];
|
|
3762
|
+
const host = m?.[2];
|
|
3763
|
+
const path = m?.[3];
|
|
3764
|
+
if (userAt === void 0 || !host || !path) return null;
|
|
3765
|
+
if (/^\d+(?:\/|$)/.test(path)) return null;
|
|
3766
|
+
return {
|
|
3767
|
+
sshBase: `${userAt}${host}:`,
|
|
3768
|
+
httpsBase: `https://${host}/`
|
|
3769
|
+
};
|
|
3770
|
+
}
|
|
3771
|
+
let parsed;
|
|
3772
|
+
try {
|
|
3773
|
+
parsed = new URL(url);
|
|
3774
|
+
} catch {
|
|
3775
|
+
return null;
|
|
3776
|
+
}
|
|
3777
|
+
if (parsed.protocol !== "ssh:") return null;
|
|
3778
|
+
if (parsed.password.length > 0) return null;
|
|
3779
|
+
if (!parsed.hostname) return null;
|
|
3780
|
+
if (parsed.port && parsed.port !== "22") return null;
|
|
3781
|
+
const userAt = parsed.username.length > 0 ? `${parsed.username}@` : "";
|
|
3782
|
+
return {
|
|
3783
|
+
sshBase: parsed.port ? `ssh://${userAt}${parsed.hostname}:${parsed.port}/` : `ssh://${userAt}${parsed.hostname}/`,
|
|
3784
|
+
httpsBase: `https://${parsed.hostname}/`
|
|
3785
|
+
};
|
|
3786
|
+
}
|
|
3787
|
+
/**
|
|
3788
|
+
* Same shape as `SCP_LIKE_SSH_RE` in the shared schema — kept in sync so
|
|
3789
|
+
* what the schema accepts is exactly what we route through the SSH-side
|
|
3790
|
+
* fallback. Single source of truth would be ideal, but cross-package import
|
|
3791
|
+
* for a regex isn't worth the build coupling.
|
|
3792
|
+
*/
|
|
3793
|
+
const SCP_LIKE_RE = /^(?:[A-Za-z0-9_.-]+@)?[A-Za-z0-9.-]+:(?!\d+(?:\/|$))[^/:@\s][^:@\s]*$/;
|
|
3794
|
+
function pickFallbackDirection(originUrl) {
|
|
3795
|
+
if (/^https:\/\//i.test(originUrl)) {
|
|
3796
|
+
const r = httpsToSshBaseRewrite(originUrl);
|
|
3797
|
+
if (!r) return null;
|
|
3798
|
+
return {
|
|
3799
|
+
fromProtocol: "https",
|
|
3800
|
+
toProtocol: "ssh",
|
|
3801
|
+
originBase: r.httpsBase,
|
|
3802
|
+
peerBase: r.sshBase,
|
|
3803
|
+
shouldRetry: isLikelyHttpsAuthFailure
|
|
3804
|
+
};
|
|
3805
|
+
}
|
|
3806
|
+
if (/^ssh:\/\//i.test(originUrl) || SCP_LIKE_RE.test(originUrl)) {
|
|
3807
|
+
const r = sshToHttpsBaseRewrite(originUrl);
|
|
3808
|
+
if (!r) return null;
|
|
3809
|
+
return {
|
|
3810
|
+
fromProtocol: "ssh",
|
|
3811
|
+
toProtocol: "https",
|
|
3812
|
+
originBase: r.sshBase,
|
|
3813
|
+
peerBase: r.httpsBase,
|
|
3814
|
+
shouldRetry: isLikelySshAuthFailure
|
|
3815
|
+
};
|
|
3816
|
+
}
|
|
3817
|
+
return null;
|
|
3818
|
+
}
|
|
3819
|
+
function truncate(text, max = 512) {
|
|
3820
|
+
if (text.length <= max) return text;
|
|
3821
|
+
return `${text.slice(0, max)}…[truncated]`;
|
|
3822
|
+
}
|
|
3823
|
+
/**
|
|
3381
3824
|
* InputController — push-based async iterable bridge.
|
|
3382
3825
|
*
|
|
3383
3826
|
* Bridges imperative `push()` calls to the `AsyncIterable` that
|
|
@@ -3471,6 +3914,85 @@ function cleanWorkspaces(workspaceRoot, activeChatIds, ttlMs = DEFAULT_WORKSPACE
|
|
|
3471
3914
|
}
|
|
3472
3915
|
return removed;
|
|
3473
3916
|
}
|
|
3917
|
+
const pending = /* @__PURE__ */ new Map();
|
|
3918
|
+
/**
|
|
3919
|
+
* Register a Promise that will resolve when the matching `question_answer`
|
|
3920
|
+
* message arrives. Same `correlationId` re-registration is treated as the
|
|
3921
|
+
* SDK retrying — the previous entry is rejected as superseded so its
|
|
3922
|
+
* `canUseTool` callback unblocks.
|
|
3923
|
+
*/
|
|
3924
|
+
function registerPendingQuestion(args) {
|
|
3925
|
+
const { correlationId, agentId, chatId } = args;
|
|
3926
|
+
const existing = pending.get(correlationId);
|
|
3927
|
+
if (existing) existing.resolve({
|
|
3928
|
+
status: "denied",
|
|
3929
|
+
reason: "Question re-registered with the same correlation id."
|
|
3930
|
+
});
|
|
3931
|
+
return new Promise((resolve) => {
|
|
3932
|
+
pending.set(correlationId, {
|
|
3933
|
+
agentId,
|
|
3934
|
+
chatId,
|
|
3935
|
+
registeredAt: Date.now(),
|
|
3936
|
+
resolve
|
|
3937
|
+
});
|
|
3938
|
+
});
|
|
3939
|
+
}
|
|
3940
|
+
/**
|
|
3941
|
+
* Best-effort resolve. Called by the inbox dispatcher when a
|
|
3942
|
+
* `format: "question_answer"` message arrives. Returns `true` when an entry
|
|
3943
|
+
* matched (so the caller knows it can ack + skip normal session routing),
|
|
3944
|
+
* `false` when there was no waiter (stale answer, e.g. session resumed after
|
|
3945
|
+
* the bridge was already cleaned up).
|
|
3946
|
+
*
|
|
3947
|
+
* Schema validation lives here too — a malformed answer payload is logged
|
|
3948
|
+
* (well, `false` returned and the dispatcher emits a warn) but never throws.
|
|
3949
|
+
*/
|
|
3950
|
+
function tryResolveQuestionAnswer(content) {
|
|
3951
|
+
const parsed = questionAnswerMessageContentSchema.safeParse(content);
|
|
3952
|
+
if (!parsed.success) return false;
|
|
3953
|
+
const entry = pending.get(parsed.data.correlationId);
|
|
3954
|
+
if (!entry) return false;
|
|
3955
|
+
pending.delete(parsed.data.correlationId);
|
|
3956
|
+
entry.resolve({
|
|
3957
|
+
status: "answered",
|
|
3958
|
+
answers: parsed.data.answers
|
|
3959
|
+
});
|
|
3960
|
+
return true;
|
|
3961
|
+
}
|
|
3962
|
+
/** Cleanup hook used by handler.shutdown() to fail-fast every in-flight question. */
|
|
3963
|
+
function rejectPendingForAgent(agentId, reason) {
|
|
3964
|
+
let count = 0;
|
|
3965
|
+
for (const [correlationId, entry] of pending) {
|
|
3966
|
+
if (entry.agentId !== agentId) continue;
|
|
3967
|
+
pending.delete(correlationId);
|
|
3968
|
+
entry.resolve({
|
|
3969
|
+
status: "denied",
|
|
3970
|
+
reason
|
|
3971
|
+
});
|
|
3972
|
+
count++;
|
|
3973
|
+
}
|
|
3974
|
+
return count;
|
|
3975
|
+
}
|
|
3976
|
+
/**
|
|
3977
|
+
* Silent cleanup for `handler.suspend()`. Removes pending entries WITHOUT
|
|
3978
|
+
* resolving the Promise — the SDK process is being torn down anyway, and
|
|
3979
|
+
* resolving would unblock the canUseTool callback whose return value the
|
|
3980
|
+
* SDK then writes to a now-closed transport (the symptom: 'ProcessTransport
|
|
3981
|
+
* is not ready for writing' uncaught). The orphaned Promise stack frame is
|
|
3982
|
+
* GC'd alongside the SDK process exit. When the user eventually answers,
|
|
3983
|
+
* SessionManager.dispatch sees no matching waiter (`tryResolveQuestionAnswer`
|
|
3984
|
+
* returns false) and routes the answer message through the normal dispatch
|
|
3985
|
+
* path so the suspended session resumes with the answer as fresh input.
|
|
3986
|
+
*/
|
|
3987
|
+
function clearPendingForAgent(agentId) {
|
|
3988
|
+
let count = 0;
|
|
3989
|
+
for (const [correlationId, entry] of pending) {
|
|
3990
|
+
if (entry.agentId !== agentId) continue;
|
|
3991
|
+
pending.delete(correlationId);
|
|
3992
|
+
count++;
|
|
3993
|
+
}
|
|
3994
|
+
return count;
|
|
3995
|
+
}
|
|
3474
3996
|
/**
|
|
3475
3997
|
* Resolve which Claude Code binary the SDK should spawn.
|
|
3476
3998
|
*
|
|
@@ -3826,6 +4348,90 @@ const createClaudeCodeHandler = (config) => {
|
|
|
3826
4348
|
recordAppliedPayload(sessionCtx);
|
|
3827
4349
|
consumerDone = consumeOutput(sessionCtx);
|
|
3828
4350
|
}
|
|
4351
|
+
/**
|
|
4352
|
+
* Build the SDK `canUseTool` callback for this session. Auto-allows every
|
|
4353
|
+
* tool except `AskUserQuestion`, which we route through the Hub's inbox:
|
|
4354
|
+
*
|
|
4355
|
+
* 1. Validate the SDK's question shape against the shared Zod schema (so
|
|
4356
|
+
* a malformed model output can't smuggle bad data into Hub messages).
|
|
4357
|
+
* 2. Send a `format: "question"` message via the agent SDK — this hits
|
|
4358
|
+
* the server's `sendMessage` path which writes the `pending_questions`
|
|
4359
|
+
* lifecycle row in the same transaction (see commit 2).
|
|
4360
|
+
* 3. Register a Promise keyed on the SDK `toolUseID`. The matching
|
|
4361
|
+
* `question_answer` message arrives over the inbox WS / poll path
|
|
4362
|
+
* and SessionManager.dispatch resolves the Promise (commit 2 wired
|
|
4363
|
+
* the answer route + supersede hooks; SessionManager wiring lives
|
|
4364
|
+
* in this commit).
|
|
4365
|
+
* 4. Map the bridge result to `PermissionResult`: `answered` →
|
|
4366
|
+
* `{ behavior: "allow", updatedInput: { questions, answers } }`,
|
|
4367
|
+
* `denied` → `{ behavior: "deny", message }` so the model abandons
|
|
4368
|
+
* the call instead of looping.
|
|
4369
|
+
*
|
|
4370
|
+
* `bypassPermissions` mode still calls `canUseTool` for `AskUserQuestion`
|
|
4371
|
+
* specifically — verified by `tmp-verify/verify.mjs` cases A through G.
|
|
4372
|
+
*/
|
|
4373
|
+
function buildAskUserCanUseTool(sessionCtx) {
|
|
4374
|
+
return async (toolName, input, options) => {
|
|
4375
|
+
if (toolName !== "AskUserQuestion") return {
|
|
4376
|
+
behavior: "allow",
|
|
4377
|
+
updatedInput: input
|
|
4378
|
+
};
|
|
4379
|
+
const parsed = z.object({ questions: z.array(questionItemSchema).min(1).max(4) }).safeParse(input);
|
|
4380
|
+
if (!parsed.success) {
|
|
4381
|
+
sessionCtx.log(`AskUserQuestion: malformed input — ${parsed.error.message.slice(0, 200)}`);
|
|
4382
|
+
return {
|
|
4383
|
+
behavior: "deny",
|
|
4384
|
+
message: "AskUserQuestion input did not validate; abandon the question and pick a different tool or answer."
|
|
4385
|
+
};
|
|
4386
|
+
}
|
|
4387
|
+
const correlationId = options.toolUseID;
|
|
4388
|
+
const questions = parsed.data.questions;
|
|
4389
|
+
const questionContent = {
|
|
4390
|
+
correlationId,
|
|
4391
|
+
questions,
|
|
4392
|
+
previewFormat: "html",
|
|
4393
|
+
allowFreeText: true
|
|
4394
|
+
};
|
|
4395
|
+
try {
|
|
4396
|
+
await sessionCtx.sdk.sendMessage(sessionCtx.chatId, {
|
|
4397
|
+
format: "question",
|
|
4398
|
+
content: questionContent
|
|
4399
|
+
});
|
|
4400
|
+
} catch (err) {
|
|
4401
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
4402
|
+
sessionCtx.log(`AskUserQuestion: failed to publish question — ${reason}`);
|
|
4403
|
+
return {
|
|
4404
|
+
behavior: "deny",
|
|
4405
|
+
message: `Hub could not publish the question (${reason}); abandon the call.`
|
|
4406
|
+
};
|
|
4407
|
+
}
|
|
4408
|
+
sessionCtx.log(`AskUserQuestion: published correlationId=${correlationId}; awaiting user answer`);
|
|
4409
|
+
const result = await registerPendingQuestion({
|
|
4410
|
+
correlationId,
|
|
4411
|
+
agentId: sessionCtx.agent.agentId,
|
|
4412
|
+
chatId: sessionCtx.chatId
|
|
4413
|
+
});
|
|
4414
|
+
if (options.signal.aborted) return {
|
|
4415
|
+
behavior: "deny",
|
|
4416
|
+
message: "AskUserQuestion aborted before an answer arrived."
|
|
4417
|
+
};
|
|
4418
|
+
if (result.status === "denied") {
|
|
4419
|
+
sessionCtx.log(`AskUserQuestion: denied correlationId=${correlationId} reason=${result.reason}`);
|
|
4420
|
+
return {
|
|
4421
|
+
behavior: "deny",
|
|
4422
|
+
message: result.reason
|
|
4423
|
+
};
|
|
4424
|
+
}
|
|
4425
|
+
sessionCtx.log(`AskUserQuestion: answered correlationId=${correlationId}`);
|
|
4426
|
+
return {
|
|
4427
|
+
behavior: "allow",
|
|
4428
|
+
updatedInput: {
|
|
4429
|
+
questions,
|
|
4430
|
+
answers: result.answers
|
|
4431
|
+
}
|
|
4432
|
+
};
|
|
4433
|
+
};
|
|
4434
|
+
}
|
|
3829
4435
|
/** Rebuild query and input controller without starting a new consumer loop (used for retry within the existing loop). */
|
|
3830
4436
|
function respawnQuery(sessionId, sessionCtx) {
|
|
3831
4437
|
buildQuery(sessionId, sessionCtx, sessionId);
|
|
@@ -3858,6 +4464,8 @@ const createClaudeCodeHandler = (config) => {
|
|
|
3858
4464
|
allowDangerouslySkipPermissions: true,
|
|
3859
4465
|
settingSources: ["user", "project"],
|
|
3860
4466
|
env: buildEnv(sessionCtx),
|
|
4467
|
+
canUseTool: buildAskUserCanUseTool(sessionCtx),
|
|
4468
|
+
toolConfig: { askUserQuestion: { previewFormat: "html" } },
|
|
3861
4469
|
...claudeCodeExecutable ? { pathToClaudeCodeExecutable: claudeCodeExecutable } : {},
|
|
3862
4470
|
...payload?.model ? { model: payload.model } : {},
|
|
3863
4471
|
...payload?.prompt.append ? { systemPrompt: {
|
|
@@ -4120,6 +4728,11 @@ const createClaudeCodeHandler = (config) => {
|
|
|
4120
4728
|
},
|
|
4121
4729
|
async suspend() {
|
|
4122
4730
|
ctx?.log("Suspending session");
|
|
4731
|
+
const sessionCtx = ctx;
|
|
4732
|
+
if (sessionCtx) {
|
|
4733
|
+
const dropped = clearPendingForAgent(sessionCtx.agent.agentId);
|
|
4734
|
+
if (dropped > 0) sessionCtx.log(`Cleared ${dropped} pending AskUserQuestion entries on suspend`);
|
|
4735
|
+
}
|
|
4123
4736
|
if (inputController) {
|
|
4124
4737
|
inputController.end();
|
|
4125
4738
|
inputController = null;
|
|
@@ -4137,6 +4750,10 @@ const createClaudeCodeHandler = (config) => {
|
|
|
4137
4750
|
async shutdown() {
|
|
4138
4751
|
const sessionCtx = ctx;
|
|
4139
4752
|
await handler.suspend();
|
|
4753
|
+
if (sessionCtx) {
|
|
4754
|
+
const dropped = rejectPendingForAgent(sessionCtx.agent.agentId, "Session shutting down.");
|
|
4755
|
+
if (dropped > 0) sessionCtx.log(`Rejected ${dropped} pending AskUserQuestion entries during shutdown`);
|
|
4756
|
+
}
|
|
4140
4757
|
if (sessionCtx) await cleanupGitWorktrees(sessionCtx);
|
|
4141
4758
|
if (cwd) {
|
|
4142
4759
|
try {
|
|
@@ -4815,8 +5432,34 @@ function resolveSenderLabel(senderId, participants) {
|
|
|
4815
5432
|
* Async because the participant list may need a server round-trip on first
|
|
4816
5433
|
* use; subsequent messages in the same session hit the cache.
|
|
4817
5434
|
*/
|
|
5435
|
+
/**
|
|
5436
|
+
* Convert a SessionMessage's payload to a plain-text snippet the LLM can
|
|
5437
|
+
* read as user input. Most formats are already strings; the special case
|
|
5438
|
+
* is `question_answer` — when an answer arrives AFTER the SDK process was
|
|
5439
|
+
* suspended, SessionManager.dispatch routes it through the normal path,
|
|
5440
|
+
* and we need to render the structured `{correlationId, answers}` payload
|
|
5441
|
+
* as readable English so the resumed Claude turn can act on it.
|
|
5442
|
+
*/
|
|
5443
|
+
function renderForLLM(message) {
|
|
5444
|
+
if (message.format === "question_answer" && message.content && typeof message.content === "object") {
|
|
5445
|
+
const c = message.content;
|
|
5446
|
+
if (c.answers && typeof c.answers === "object") {
|
|
5447
|
+
const lines = [];
|
|
5448
|
+
for (const [question, answer] of Object.entries(c.answers)) {
|
|
5449
|
+
lines.push(`Q: ${question}`);
|
|
5450
|
+
lines.push(`A: ${answer}`);
|
|
5451
|
+
}
|
|
5452
|
+
return [
|
|
5453
|
+
"[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.]",
|
|
5454
|
+
"",
|
|
5455
|
+
...lines
|
|
5456
|
+
].join("\n");
|
|
5457
|
+
}
|
|
5458
|
+
}
|
|
5459
|
+
return typeof message.content === "string" ? message.content : JSON.stringify(message.content);
|
|
5460
|
+
}
|
|
4818
5461
|
async function formatInboundContent(message, participants) {
|
|
4819
|
-
const rawContent =
|
|
5462
|
+
const rawContent = renderForLLM(message);
|
|
4820
5463
|
const preceding = message.precedingMessages ?? [];
|
|
4821
5464
|
let header = "";
|
|
4822
5465
|
if (preceding.length > 0) {
|
|
@@ -5019,6 +5662,16 @@ var SessionManager = class {
|
|
|
5019
5662
|
async dispatch(entry) {
|
|
5020
5663
|
const chatId = entry.chatId ?? entry.message.chatId;
|
|
5021
5664
|
const messageId = entry.message.id;
|
|
5665
|
+
if (entry.message.format === "question_answer") {
|
|
5666
|
+
if (tryResolveQuestionAnswer(entry.message.content)) {
|
|
5667
|
+
await this.ackEntry(entry.id, chatId);
|
|
5668
|
+
return;
|
|
5669
|
+
}
|
|
5670
|
+
this.config.log.info({
|
|
5671
|
+
chatId,
|
|
5672
|
+
messageId
|
|
5673
|
+
}, "question_answer with no live bridge waiter — resuming session with answer as input");
|
|
5674
|
+
}
|
|
5022
5675
|
const dedupKey = `${chatId}:${messageId}`;
|
|
5023
5676
|
if (this.deduplicator.isDuplicate(dedupKey)) {
|
|
5024
5677
|
this.config.log.debug({
|
|
@@ -8376,7 +9029,7 @@ async function onboardCreate(args) {
|
|
|
8376
9029
|
}
|
|
8377
9030
|
const runtimeAgent = args.type === "human" ? args.assistant : args.id;
|
|
8378
9031
|
if (args.feishuBotAppId && args.feishuBotAppSecret) {
|
|
8379
|
-
const { bindFeishuBot } = await import("./feishu-
|
|
9032
|
+
const { bindFeishuBot } = await import("./feishu-fLnwqCOs.mjs").then((n) => n.r);
|
|
8380
9033
|
const targetAgentUuid = args.type === "human" ? assistantUuid : primary.uuid;
|
|
8381
9034
|
if (!targetAgentUuid) print.line(`Warning: Cannot bind Feishu bot — no runtime agent available for "${args.id}".\n`);
|
|
8382
9035
|
else {
|
|
@@ -9589,7 +10242,7 @@ function createFeedbackHandler(config) {
|
|
|
9589
10242
|
return { handle };
|
|
9590
10243
|
}
|
|
9591
10244
|
//#endregion
|
|
9592
|
-
//#region ../server/dist/app
|
|
10245
|
+
//#region ../server/dist/app-CkYiQS_D.mjs
|
|
9593
10246
|
var import_fastify_opentelemetry = /* @__PURE__ */ __toESM(require_fastify_opentelemetry(), 1);
|
|
9594
10247
|
init_esm();
|
|
9595
10248
|
var __defProp = Object.defineProperty;
|
|
@@ -9613,53 +10266,6 @@ const adapterAgentMappings = pgTable("adapter_agent_mappings", {
|
|
|
9613
10266
|
metadata: jsonb("metadata").$type().notNull().default({}),
|
|
9614
10267
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
|
|
9615
10268
|
}, (table) => [unique("uq_adapter_agent_mapping").on(table.platform, table.externalUserId)]);
|
|
9616
|
-
/** Communication container. All messages between agents flow within a Chat. */
|
|
9617
|
-
const chats = pgTable("chats", {
|
|
9618
|
-
id: text("id").primaryKey(),
|
|
9619
|
-
organizationId: text("organization_id").notNull().references(() => organizations.id),
|
|
9620
|
-
type: text("type").notNull().default("direct"),
|
|
9621
|
-
topic: text("topic"),
|
|
9622
|
-
lifecyclePolicy: text("lifecycle_policy").default("persistent"),
|
|
9623
|
-
parentChatId: text("parent_chat_id"),
|
|
9624
|
-
metadata: jsonb("metadata").$type().notNull().default({}),
|
|
9625
|
-
lastMessageAt: timestamp("last_message_at", { withTimezone: true }),
|
|
9626
|
-
lastMessagePreview: text("last_message_preview"),
|
|
9627
|
-
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
9628
|
-
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
|
|
9629
|
-
}, (table) => [index("idx_chats_org_last_message").on(table.organizationId, desc(table.lastMessageAt))]);
|
|
9630
|
-
/** Speaking participants of a chat (M:N). Watchers live in chat_subscriptions. */
|
|
9631
|
-
const chatParticipants = pgTable("chat_participants", {
|
|
9632
|
-
chatId: text("chat_id").notNull().references(() => chats.id, { onDelete: "cascade" }),
|
|
9633
|
-
agentId: text("agent_id").notNull().references(() => agents.uuid),
|
|
9634
|
-
role: text("role").notNull().default("member"),
|
|
9635
|
-
mode: text("mode").notNull().default("full"),
|
|
9636
|
-
lastReadAt: timestamp("last_read_at", { withTimezone: true }),
|
|
9637
|
-
unreadMentionCount: integer("unread_mention_count").notNull().default(0),
|
|
9638
|
-
joinedAt: timestamp("joined_at", { withTimezone: true }).notNull().defaultNow()
|
|
9639
|
-
}, (table) => [primaryKey({ columns: [table.chatId, table.agentId] }), index("idx_participants_agent").on(table.agentId)]);
|
|
9640
|
-
/**
|
|
9641
|
-
* Non-speaking observers ("watchers"). Used by the chat-first workspace so a
|
|
9642
|
-
* user can supervise chats their managed agents participate in without
|
|
9643
|
-
* accidentally being part of fan-out.
|
|
9644
|
-
*
|
|
9645
|
-
* Invariants:
|
|
9646
|
-
* 1. (chat_id, agent_id) is mutually exclusive with chat_participants.
|
|
9647
|
-
* 2. Rows here NEVER produce inbox_entries (fan-out exclusivity).
|
|
9648
|
-
* 3. Mention candidate resolution NEVER includes these rows.
|
|
9649
|
-
* 4. State transitions (join/leave) carry last_read_at + counter; lifecycle
|
|
9650
|
-
* recomputes default to NULL/0 and MUST NOT run on the join/leave path.
|
|
9651
|
-
*
|
|
9652
|
-
* See docs/chat-first-workspace-product-design.md "Data Model" + "State
|
|
9653
|
-
* Transitions" for the full contract.
|
|
9654
|
-
*/
|
|
9655
|
-
const chatSubscriptions = pgTable("chat_subscriptions", {
|
|
9656
|
-
chatId: text("chat_id").notNull().references(() => chats.id, { onDelete: "cascade" }),
|
|
9657
|
-
agentId: text("agent_id").notNull().references(() => agents.uuid),
|
|
9658
|
-
kind: text("kind").notNull().default("watching"),
|
|
9659
|
-
lastReadAt: timestamp("last_read_at", { withTimezone: true }),
|
|
9660
|
-
unreadMentionCount: integer("unread_mention_count").notNull().default(0),
|
|
9661
|
-
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
|
|
9662
|
-
}, (table) => [primaryKey({ columns: [table.chatId, table.agentId] }), index("idx_chat_subscriptions_agent").on(table.agentId)]);
|
|
9663
10269
|
/**
|
|
9664
10270
|
* Tasks — lightweight work units. Process descriptors, not tickets.
|
|
9665
10271
|
* Immutable status state machine: pending → assigned → working → (completed | failed | cancelled).
|
|
@@ -10090,7 +10696,7 @@ async function deleteAdapterConfig(db, id) {
|
|
|
10090
10696
|
const [row] = await db.delete(adapterConfigs).where(eq(adapterConfigs.id, id)).returning();
|
|
10091
10697
|
if (!row) throw new NotFoundError(`Adapter config "${id}" not found`);
|
|
10092
10698
|
}
|
|
10093
|
-
const log$
|
|
10699
|
+
const log$5 = createLogger$1("Adapters");
|
|
10094
10700
|
function parseId(raw) {
|
|
10095
10701
|
const id = Number(raw);
|
|
10096
10702
|
if (!Number.isInteger(id) || id <= 0) throw new BadRequestError(`Invalid adapter ID: "${raw}"`);
|
|
@@ -10116,7 +10722,7 @@ async function adapterRoutes(app) {
|
|
|
10116
10722
|
const existing = await getAdapterConfig(app.db, id);
|
|
10117
10723
|
await assertAgentManageableByUser(app.db, userId, existing.agentId);
|
|
10118
10724
|
const config = await updateAdapterConfig(app.db, id, body, app.config.secrets.encryptionKey);
|
|
10119
|
-
app.adapterManager.reload().catch((err) => log$
|
|
10725
|
+
app.adapterManager.reload().catch((err) => log$5.error({ err }, "adapter reload failed after update"));
|
|
10120
10726
|
app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
|
|
10121
10727
|
return {
|
|
10122
10728
|
...config,
|
|
@@ -10130,7 +10736,7 @@ async function adapterRoutes(app) {
|
|
|
10130
10736
|
const existing = await getAdapterConfig(app.db, id);
|
|
10131
10737
|
await assertAgentManageableByUser(app.db, userId, existing.agentId);
|
|
10132
10738
|
await deleteAdapterConfig(app.db, id);
|
|
10133
|
-
app.adapterManager.reload().catch((err) => log$
|
|
10739
|
+
app.adapterManager.reload().catch((err) => log$5.error({ err }, "adapter reload failed after delete"));
|
|
10134
10740
|
app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
|
|
10135
10741
|
return reply.status(204).send();
|
|
10136
10742
|
});
|
|
@@ -10146,619 +10752,6 @@ function requireAgent(request) {
|
|
|
10146
10752
|
if (!agent) throw new UnauthorizedError("Agent authentication required");
|
|
10147
10753
|
return agent;
|
|
10148
10754
|
}
|
|
10149
|
-
/**
|
|
10150
|
-
* Process-local cache for the per-chat realtime push audience
|
|
10151
|
-
* (`chat_participants ∪ chat_subscriptions`, keyed by human agent
|
|
10152
|
-
* uuid). Sits in front of the admin WS dispatch so a chat with N
|
|
10153
|
-
* messages/sec doesn't issue N audience-resolution queries; one query
|
|
10154
|
-
* + cache hit per chat per TTL window.
|
|
10155
|
-
*
|
|
10156
|
-
* The cache exposes both a populator (`getCachedAudience`) and an
|
|
10157
|
-
* invalidator (`invalidateChatAudience`). Participant-mutation paths
|
|
10158
|
-
* (`addMeChatParticipants`, `joinMeChat`, `leaveMeChat`,
|
|
10159
|
-
* `recomputeChatWatchers`, `joinAsParticipant`, `leaveAsParticipant`)
|
|
10160
|
-
* MUST call `invalidateChatAudience` after their tx commits so the
|
|
10161
|
-
* very next dispatch reflects the new audience without waiting for
|
|
10162
|
-
* the TTL to age out — without invalidation, a freshly-added speaker
|
|
10163
|
-
* would miss `chat:message` pushes for up to TTL_MS.
|
|
10164
|
-
*
|
|
10165
|
-
* Cross-instance correctness: not handled here. The PG NOTIFY layer
|
|
10166
|
-
* already broadcasts message events to every replica; each replica's
|
|
10167
|
-
* audience cache is independently invalidated by its own
|
|
10168
|
-
* service-layer mutations on chats it routes traffic for. For
|
|
10169
|
-
* cross-replica participant changes to invalidate this cache, route
|
|
10170
|
-
* the mutation through the same replica that hosts the WS connection
|
|
10171
|
-
* (sticky routing) or add a dedicated `chat:audience` PG NOTIFY in
|
|
10172
|
-
* a follow-up.
|
|
10173
|
-
*/
|
|
10174
|
-
const log$6 = createLogger$1("ChatAudienceCache");
|
|
10175
|
-
const TTL_MS = 5e3;
|
|
10176
|
-
const MAX_ENTRIES = 1024;
|
|
10177
|
-
const cache = /* @__PURE__ */ new Map();
|
|
10178
|
-
/** Resolve a chat's push audience, hitting the cache when fresh.
|
|
10179
|
-
* Returns null on DB error (caller should skip dispatch). */
|
|
10180
|
-
async function getCachedAudience(db, chatId) {
|
|
10181
|
-
const now = Date.now();
|
|
10182
|
-
const cached = cache.get(chatId);
|
|
10183
|
-
if (cached && cached.expiresAt > now) return cached.audience;
|
|
10184
|
-
try {
|
|
10185
|
-
const rows = await db.execute(sql`
|
|
10186
|
-
SELECT agent_id FROM chat_participants WHERE chat_id = ${chatId}
|
|
10187
|
-
UNION
|
|
10188
|
-
SELECT agent_id FROM chat_subscriptions WHERE chat_id = ${chatId}
|
|
10189
|
-
`);
|
|
10190
|
-
const audience = new Set(rows.map((r) => r.agent_id));
|
|
10191
|
-
cache.set(chatId, {
|
|
10192
|
-
audience,
|
|
10193
|
-
expiresAt: now + TTL_MS
|
|
10194
|
-
});
|
|
10195
|
-
if (cache.size > MAX_ENTRIES) {
|
|
10196
|
-
for (const [k, v] of cache) if (v.expiresAt <= now) cache.delete(k);
|
|
10197
|
-
}
|
|
10198
|
-
return audience;
|
|
10199
|
-
} catch (err) {
|
|
10200
|
-
log$6.warn({
|
|
10201
|
-
err,
|
|
10202
|
-
chatId
|
|
10203
|
-
}, "failed to resolve chat audience");
|
|
10204
|
-
return null;
|
|
10205
|
-
}
|
|
10206
|
-
}
|
|
10207
|
-
/** Drop the cached audience for a chat. Called from participant-
|
|
10208
|
-
* mutation paths after their transaction commits, so the next
|
|
10209
|
-
* `chat:message` dispatch hits the DB and reflects the new
|
|
10210
|
-
* membership instead of serving a stale TTL window. */
|
|
10211
|
-
function invalidateChatAudience(chatId) {
|
|
10212
|
-
cache.delete(chatId);
|
|
10213
|
-
}
|
|
10214
|
-
/**
|
|
10215
|
-
* Chat-first workspace — watcher subscription helpers.
|
|
10216
|
-
*
|
|
10217
|
-
* Watchers (rows in `chat_subscriptions`) are non-speaking observers. A
|
|
10218
|
-
* member who manages an agent that participates in a chat — but whose own
|
|
10219
|
-
* human agent is not a speaker there — sees the chat in their workspace
|
|
10220
|
-
* via a watcher row.
|
|
10221
|
-
*
|
|
10222
|
-
* Two distinct kinds of operation live here:
|
|
10223
|
-
*
|
|
10224
|
-
* 1. Set rebuilds (`recompute*`). Idempotent set-based recomputations
|
|
10225
|
-
* driven by lifecycle events (chat created, participant added/removed,
|
|
10226
|
-
* member status flipped, etc.). These DEFAULT new rows to NULL/0 read
|
|
10227
|
-
* state.
|
|
10228
|
-
*
|
|
10229
|
-
* 2. State-carry transitions (`joinAsParticipant`, `leaveAsParticipant`).
|
|
10230
|
-
* Move a single (chat, agent) pair between `chat_participants` and
|
|
10231
|
-
* `chat_subscriptions` while preserving `last_read_at` and
|
|
10232
|
-
* `unread_mention_count`. NEVER call recompute on this path or you'll
|
|
10233
|
-
* lose read state.
|
|
10234
|
-
*
|
|
10235
|
-
* See docs/chat-first-workspace-product-design.md "State Transitions" and
|
|
10236
|
-
* "Risk Constraints".
|
|
10237
|
-
*/
|
|
10238
|
-
/**
|
|
10239
|
-
* Recompute watcher rows for ONE chat. For every active member who:
|
|
10240
|
-
* - manages a non-human agent that speaks in the chat, AND
|
|
10241
|
-
* - whose own human agent is NOT a speaker in the chat
|
|
10242
|
-
* an `(chat_id, member.agent_id)` watcher row is upserted (NULL read state).
|
|
10243
|
-
*
|
|
10244
|
-
* Watchers whose anchoring condition no longer holds (manager left, the
|
|
10245
|
-
* managed agent was removed from the chat, the manager joined as a speaker
|
|
10246
|
-
* themselves) are deleted.
|
|
10247
|
-
*
|
|
10248
|
-
* Idempotent: safe to call multiple times for the same chat.
|
|
10249
|
-
*/
|
|
10250
|
-
async function recomputeChatWatchers(db, chatId) {
|
|
10251
|
-
await db.execute(sql`
|
|
10252
|
-
INSERT INTO chat_subscriptions
|
|
10253
|
-
(chat_id, agent_id, kind, last_read_at, unread_mention_count, created_at)
|
|
10254
|
-
SELECT DISTINCT cp.chat_id, m.agent_id, 'watching', NULL::timestamp with time zone, 0, now()
|
|
10255
|
-
FROM chat_participants cp
|
|
10256
|
-
JOIN agents a ON a.uuid = cp.agent_id
|
|
10257
|
-
JOIN members m ON m.id = a.manager_id
|
|
10258
|
-
WHERE cp.chat_id = ${chatId}
|
|
10259
|
-
AND m.status = 'active'
|
|
10260
|
-
AND a.type <> 'human'
|
|
10261
|
-
AND NOT EXISTS (
|
|
10262
|
-
SELECT 1 FROM chat_participants cp2
|
|
10263
|
-
WHERE cp2.chat_id = cp.chat_id
|
|
10264
|
-
AND cp2.agent_id = m.agent_id
|
|
10265
|
-
)
|
|
10266
|
-
ON CONFLICT (chat_id, agent_id) DO NOTHING
|
|
10267
|
-
`);
|
|
10268
|
-
await db.execute(sql`
|
|
10269
|
-
DELETE FROM chat_subscriptions cs
|
|
10270
|
-
WHERE cs.chat_id = ${chatId}
|
|
10271
|
-
AND NOT EXISTS (
|
|
10272
|
-
SELECT 1
|
|
10273
|
-
FROM chat_participants cp
|
|
10274
|
-
JOIN agents a ON a.uuid = cp.agent_id
|
|
10275
|
-
JOIN members m ON m.id = a.manager_id
|
|
10276
|
-
WHERE cp.chat_id = cs.chat_id
|
|
10277
|
-
AND m.agent_id = cs.agent_id
|
|
10278
|
-
AND m.status = 'active'
|
|
10279
|
-
AND a.type <> 'human'
|
|
10280
|
-
AND NOT EXISTS (
|
|
10281
|
-
SELECT 1 FROM chat_participants cp2
|
|
10282
|
-
WHERE cp2.chat_id = cp.chat_id
|
|
10283
|
-
AND cp2.agent_id = m.agent_id
|
|
10284
|
-
)
|
|
10285
|
-
)
|
|
10286
|
-
`);
|
|
10287
|
-
}
|
|
10288
|
-
/**
|
|
10289
|
-
* Recompute watcher rows touching ONE agent across all chats it speaks in.
|
|
10290
|
-
* Used after `rebindAgent` (manager change) so the new manager picks up
|
|
10291
|
-
* watcher rows and the old manager's are dropped.
|
|
10292
|
-
*/
|
|
10293
|
-
async function recomputeWatchersForAgent(db, agentId) {
|
|
10294
|
-
const chatRows = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(eq(chatParticipants.agentId, agentId));
|
|
10295
|
-
for (const { chatId } of chatRows) await recomputeChatWatchers(db, chatId);
|
|
10296
|
-
}
|
|
10297
|
-
/**
|
|
10298
|
-
* Recompute watcher rows touching ONE member across all chats. Triggered
|
|
10299
|
-
* when the member's status flips active ↔ left.
|
|
10300
|
-
*/
|
|
10301
|
-
async function recomputeWatchersForMember(db, memberId) {
|
|
10302
|
-
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")));
|
|
10303
|
-
for (const { chatId } of rows) await recomputeChatWatchers(db, chatId);
|
|
10304
|
-
}
|
|
10305
|
-
/**
|
|
10306
|
-
* Mirror of `services/chat.ts` `maybeUpgradeDirectToGroup`. Inlined here so
|
|
10307
|
-
* `joinAsParticipant` keeps the upgrade rule + the state carry in one
|
|
10308
|
-
* transaction without depending on chat.ts (avoids a circular import).
|
|
10309
|
-
*/
|
|
10310
|
-
async function maybeUpgradeDirectToGroup$1(tx, chatId, existingParticipantIds) {
|
|
10311
|
-
if (existingParticipantIds.length + 1 < 3) return;
|
|
10312
|
-
const [chat] = await tx.select({ type: chats.type }).from(chats).where(eq(chats.id, chatId)).limit(1);
|
|
10313
|
-
if (!chat || chat.type !== "direct") return;
|
|
10314
|
-
await tx.update(chats).set({
|
|
10315
|
-
type: "group",
|
|
10316
|
-
updatedAt: /* @__PURE__ */ new Date()
|
|
10317
|
-
}).where(eq(chats.id, chatId));
|
|
10318
|
-
if (existingParticipantIds.length === 0) return;
|
|
10319
|
-
const ids = (await tx.select({ uuid: agents.uuid }).from(agents).where(and(inArray(agents.uuid, existingParticipantIds), ne(agents.type, "human")))).map((r) => r.uuid);
|
|
10320
|
-
if (ids.length === 0) return;
|
|
10321
|
-
await tx.update(chatParticipants).set({ mode: "mention_only" }).where(and(eq(chatParticipants.chatId, chatId), inArray(chatParticipants.agentId, ids)));
|
|
10322
|
-
}
|
|
10323
|
-
/**
|
|
10324
|
-
* Watcher → speaking participant. State-carry transaction.
|
|
10325
|
-
*
|
|
10326
|
-
* 1. DELETE the watcher row (returning read state).
|
|
10327
|
-
* 2. If a participant row already exists, no-op (idempotent).
|
|
10328
|
-
* 3. Otherwise, run the direct → group upgrade rule against the *current*
|
|
10329
|
-
* participant set, then INSERT the participant row carrying read state.
|
|
10330
|
-
*
|
|
10331
|
-
* If `requireWatcherOrVisible` is true, refuse when the user has neither a
|
|
10332
|
-
* watcher row nor admin-derived visibility — used to keep the public
|
|
10333
|
-
* `/me/chats/:chatId/join` endpoint honest. Pre-check happens in the
|
|
10334
|
-
* route layer where we have the full member scope.
|
|
10335
|
-
*/
|
|
10336
|
-
async function joinAsParticipant(db, chatId, humanAgentId) {
|
|
10337
|
-
return db.transaction(async (tx) => {
|
|
10338
|
-
const [carriedRow] = await tx.delete(chatSubscriptions).where(and(eq(chatSubscriptions.chatId, chatId), eq(chatSubscriptions.agentId, humanAgentId))).returning({
|
|
10339
|
-
lastReadAt: chatSubscriptions.lastReadAt,
|
|
10340
|
-
unreadMentionCount: chatSubscriptions.unreadMentionCount
|
|
10341
|
-
});
|
|
10342
|
-
const [existing] = await tx.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, humanAgentId))).limit(1);
|
|
10343
|
-
if (existing) return {
|
|
10344
|
-
chatId,
|
|
10345
|
-
inserted: false,
|
|
10346
|
-
carried: carriedRow ?? null
|
|
10347
|
-
};
|
|
10348
|
-
await maybeUpgradeDirectToGroup$1(tx, chatId, (await tx.select({ agentId: chatParticipants.agentId }).from(chatParticipants).where(eq(chatParticipants.chatId, chatId))).map((p) => p.agentId));
|
|
10349
|
-
await tx.insert(chatParticipants).values({
|
|
10350
|
-
chatId,
|
|
10351
|
-
agentId: humanAgentId,
|
|
10352
|
-
role: "member",
|
|
10353
|
-
mode: "full",
|
|
10354
|
-
lastReadAt: carriedRow?.lastReadAt ?? null,
|
|
10355
|
-
unreadMentionCount: carriedRow?.unreadMentionCount ?? 0
|
|
10356
|
-
});
|
|
10357
|
-
return {
|
|
10358
|
-
chatId,
|
|
10359
|
-
inserted: true,
|
|
10360
|
-
carried: carriedRow ?? null
|
|
10361
|
-
};
|
|
10362
|
-
});
|
|
10363
|
-
}
|
|
10364
|
-
/**
|
|
10365
|
-
* Speaking participant → watcher (or fully detach).
|
|
10366
|
-
*
|
|
10367
|
-
* 1. DELETE the participant row (returning read state).
|
|
10368
|
-
* 2. Test "still visible": is the user still the manager of an agent that
|
|
10369
|
-
* remains a participant in this chat? If yes, INSERT a watcher row
|
|
10370
|
-
* carrying read state. If no, drop entirely.
|
|
10371
|
-
*
|
|
10372
|
-
* Caller must validate that the user actually has a participant row to
|
|
10373
|
-
* leave (returns `NotFoundError` if not).
|
|
10374
|
-
*/
|
|
10375
|
-
async function leaveAsParticipant(db, chatId, humanAgentId) {
|
|
10376
|
-
return db.transaction(async (tx) => {
|
|
10377
|
-
const [carried] = await tx.delete(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, humanAgentId))).returning({
|
|
10378
|
-
lastReadAt: chatParticipants.lastReadAt,
|
|
10379
|
-
unreadMentionCount: chatParticipants.unreadMentionCount
|
|
10380
|
-
});
|
|
10381
|
-
if (!carried) throw new NotFoundError("Not a participant of this chat");
|
|
10382
|
-
const [stillVisibleRow] = await tx.execute(sql`
|
|
10383
|
-
SELECT EXISTS (
|
|
10384
|
-
SELECT 1
|
|
10385
|
-
FROM chat_participants cp
|
|
10386
|
-
JOIN agents a ON a.uuid = cp.agent_id
|
|
10387
|
-
JOIN members m ON m.id = a.manager_id
|
|
10388
|
-
WHERE cp.chat_id = ${chatId}
|
|
10389
|
-
AND m.agent_id = ${humanAgentId}
|
|
10390
|
-
AND m.status = 'active'
|
|
10391
|
-
AND a.type <> 'human'
|
|
10392
|
-
) AS visible
|
|
10393
|
-
`);
|
|
10394
|
-
if (!Boolean(stillVisibleRow?.visible)) return {
|
|
10395
|
-
chatId,
|
|
10396
|
-
membershipKind: null
|
|
10397
|
-
};
|
|
10398
|
-
await tx.insert(chatSubscriptions).values({
|
|
10399
|
-
chatId,
|
|
10400
|
-
agentId: humanAgentId,
|
|
10401
|
-
kind: "watching",
|
|
10402
|
-
lastReadAt: carried.lastReadAt,
|
|
10403
|
-
unreadMentionCount: carried.unreadMentionCount
|
|
10404
|
-
}).onConflictDoNothing();
|
|
10405
|
-
return {
|
|
10406
|
-
chatId,
|
|
10407
|
-
membershipKind: "watching"
|
|
10408
|
-
};
|
|
10409
|
-
});
|
|
10410
|
-
}
|
|
10411
|
-
/**
|
|
10412
|
-
* Resolve the membership row of the human agent for the given chat. Returns
|
|
10413
|
-
* one of: 'participant', 'watching', or null.
|
|
10414
|
-
*
|
|
10415
|
-
* Used by `/me/chats/:chatId/join` to refuse a join when the user has
|
|
10416
|
-
* neither a watcher row nor a participant row, and isn't otherwise
|
|
10417
|
-
* authorised (admin in the chat's org).
|
|
10418
|
-
*/
|
|
10419
|
-
async function resolveChatMembership(db, chatId, humanAgentId) {
|
|
10420
|
-
const [participant] = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, humanAgentId))).limit(1);
|
|
10421
|
-
if (participant) return "participant";
|
|
10422
|
-
const [sub] = await db.select({ chatId: chatSubscriptions.chatId }).from(chatSubscriptions).where(and(eq(chatSubscriptions.chatId, chatId), eq(chatSubscriptions.agentId, humanAgentId))).limit(1);
|
|
10423
|
-
if (sub) return "watching";
|
|
10424
|
-
return null;
|
|
10425
|
-
}
|
|
10426
|
-
/**
|
|
10427
|
-
* Used by `/me/chats/:chatId/join`. Throw 409 if already a speaker (no work
|
|
10428
|
-
* to do) and 403 if no watcher row and no admin override. Admin override is
|
|
10429
|
-
* resolved at the route layer; this helper only reports the watcher state.
|
|
10430
|
-
*/
|
|
10431
|
-
function ensureCanJoin(membership) {
|
|
10432
|
-
if (membership === "participant") throw new ConflictError("Already a participant in this chat");
|
|
10433
|
-
if (membership === null) throw new ForbiddenError("Not a watcher of this chat — open the chat from your workspace before joining");
|
|
10434
|
-
}
|
|
10435
|
-
/**
|
|
10436
|
-
* When a direct chat grows past 2 participants, upgrade it to `group` and
|
|
10437
|
-
* flip every existing non-human agent participant to `mention_only` — see
|
|
10438
|
-
* proposals/hub-agent-messaging-reply-and-mentions §3.3. The caller is
|
|
10439
|
-
* expected to insert the new participant AFTER this runs, so the "existing"
|
|
10440
|
-
* set excludes them.
|
|
10441
|
-
*
|
|
10442
|
-
* Idempotent: if the chat is already a group, no-op.
|
|
10443
|
-
*/
|
|
10444
|
-
async function maybeUpgradeDirectToGroup(db, chatId, existingParticipantIds, newParticipantCount) {
|
|
10445
|
-
if (existingParticipantIds.length + newParticipantCount < 3) return;
|
|
10446
|
-
const [chat] = await db.select({ type: chats.type }).from(chats).where(eq(chats.id, chatId)).limit(1);
|
|
10447
|
-
if (!chat || chat.type !== "direct") return;
|
|
10448
|
-
await db.update(chats).set({
|
|
10449
|
-
type: "group",
|
|
10450
|
-
updatedAt: /* @__PURE__ */ new Date()
|
|
10451
|
-
}).where(eq(chats.id, chatId));
|
|
10452
|
-
if (existingParticipantIds.length === 0) return;
|
|
10453
|
-
const ids = (await db.select({ uuid: agents.uuid }).from(agents).where(and(inArray(agents.uuid, existingParticipantIds), ne(agents.type, "human")))).map((a) => a.uuid);
|
|
10454
|
-
if (ids.length === 0) return;
|
|
10455
|
-
await db.update(chatParticipants).set({ mode: "mention_only" }).where(and(eq(chatParticipants.chatId, chatId), inArray(chatParticipants.agentId, ids)));
|
|
10456
|
-
}
|
|
10457
|
-
async function createChat(db, creatorId, data) {
|
|
10458
|
-
const chatId = randomUUID();
|
|
10459
|
-
const allParticipantIds = new Set([creatorId, ...data.participantIds]);
|
|
10460
|
-
const existingAgents = await db.select({
|
|
10461
|
-
id: agents.uuid,
|
|
10462
|
-
organizationId: agents.organizationId,
|
|
10463
|
-
type: agents.type
|
|
10464
|
-
}).from(agents).where(inArray(agents.uuid, [...allParticipantIds]));
|
|
10465
|
-
if (existingAgents.length !== allParticipantIds.size) {
|
|
10466
|
-
const found = new Set(existingAgents.map((a) => a.id));
|
|
10467
|
-
throw new BadRequestError(`Agents not found: ${[...allParticipantIds].filter((id) => !found.has(id)).join(", ")}`);
|
|
10468
|
-
}
|
|
10469
|
-
const creator = existingAgents.find((a) => a.id === creatorId);
|
|
10470
|
-
if (!creator) throw new Error("Unexpected: creator not in existingAgents");
|
|
10471
|
-
const orgId = creator.organizationId;
|
|
10472
|
-
const crossOrg = existingAgents.filter((a) => a.organizationId !== orgId);
|
|
10473
|
-
if (crossOrg.length > 0) throw new BadRequestError(`Cross-organization chat not allowed: ${crossOrg.map((a) => a.id).join(", ")}`);
|
|
10474
|
-
const isDirectAgentOnly = data.type === "direct" && existingAgents.every((a) => a.type !== "human");
|
|
10475
|
-
return db.transaction(async (tx) => {
|
|
10476
|
-
const [chat] = await tx.insert(chats).values({
|
|
10477
|
-
id: chatId,
|
|
10478
|
-
organizationId: orgId,
|
|
10479
|
-
type: data.type,
|
|
10480
|
-
topic: data.topic ?? null,
|
|
10481
|
-
metadata: data.metadata ?? {}
|
|
10482
|
-
}).returning();
|
|
10483
|
-
const participantRows = [...allParticipantIds].map((agentId) => ({
|
|
10484
|
-
chatId,
|
|
10485
|
-
agentId,
|
|
10486
|
-
role: agentId === creatorId ? "owner" : "member",
|
|
10487
|
-
...isDirectAgentOnly ? { mode: "mention_only" } : {}
|
|
10488
|
-
}));
|
|
10489
|
-
await tx.insert(chatParticipants).values(participantRows);
|
|
10490
|
-
await recomputeChatWatchers(tx, chatId);
|
|
10491
|
-
const participants = await tx.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
|
|
10492
|
-
if (!chat) throw new Error("Unexpected: INSERT RETURNING produced no row");
|
|
10493
|
-
return {
|
|
10494
|
-
...chat,
|
|
10495
|
-
participants
|
|
10496
|
-
};
|
|
10497
|
-
});
|
|
10498
|
-
}
|
|
10499
|
-
async function getChat(db, chatId) {
|
|
10500
|
-
const [chat] = await db.select().from(chats).where(eq(chats.id, chatId)).limit(1);
|
|
10501
|
-
if (!chat) throw new NotFoundError(`Chat "${chatId}" not found`);
|
|
10502
|
-
return chat;
|
|
10503
|
-
}
|
|
10504
|
-
async function getChatDetail(db, chatId) {
|
|
10505
|
-
const chat = await getChat(db, chatId);
|
|
10506
|
-
const participants = await db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
|
|
10507
|
-
return {
|
|
10508
|
-
...chat,
|
|
10509
|
-
participants
|
|
10510
|
-
};
|
|
10511
|
-
}
|
|
10512
|
-
async function listChats(db, agentId, limit, cursor) {
|
|
10513
|
-
const chatIds = (await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(eq(chatParticipants.agentId, agentId))).map((r) => r.chatId);
|
|
10514
|
-
if (chatIds.length === 0) return {
|
|
10515
|
-
items: [],
|
|
10516
|
-
nextCursor: null
|
|
10517
|
-
};
|
|
10518
|
-
const where = cursor ? and(inArray(chats.id, chatIds), lt(chats.updatedAt, new Date(cursor))) : inArray(chats.id, chatIds);
|
|
10519
|
-
const rows = await db.select().from(chats).where(where).orderBy(desc(chats.updatedAt)).limit(limit + 1);
|
|
10520
|
-
const hasMore = rows.length > limit;
|
|
10521
|
-
const items = hasMore ? rows.slice(0, limit) : rows;
|
|
10522
|
-
const last = items[items.length - 1];
|
|
10523
|
-
return {
|
|
10524
|
-
items,
|
|
10525
|
-
nextCursor: hasMore && last ? last.updatedAt.toISOString() : null
|
|
10526
|
-
};
|
|
10527
|
-
}
|
|
10528
|
-
/**
|
|
10529
|
-
* List participants of a chat with their agent names — used by the client
|
|
10530
|
-
* runtime to resolve `@<name>` mentions against the authoritative participant
|
|
10531
|
-
* set (see proposals/hub-agent-messaging-reply-and-mentions §4).
|
|
10532
|
-
*/
|
|
10533
|
-
async function listChatParticipantsWithNames(db, chatId) {
|
|
10534
|
-
return await db.select({
|
|
10535
|
-
agentId: chatParticipants.agentId,
|
|
10536
|
-
role: chatParticipants.role,
|
|
10537
|
-
mode: chatParticipants.mode,
|
|
10538
|
-
joinedAt: chatParticipants.joinedAt,
|
|
10539
|
-
name: agents.name,
|
|
10540
|
-
displayName: agents.displayName,
|
|
10541
|
-
type: agents.type
|
|
10542
|
-
}).from(chatParticipants).innerJoin(agents, eq(chatParticipants.agentId, agents.uuid)).where(eq(chatParticipants.chatId, chatId));
|
|
10543
|
-
}
|
|
10544
|
-
async function assertParticipant(db, chatId, agentId) {
|
|
10545
|
-
const [row] = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, agentId))).limit(1);
|
|
10546
|
-
if (!row) throw new ForbiddenError("Not a participant of this chat");
|
|
10547
|
-
}
|
|
10548
|
-
/** Ensure an agent is a participant of a chat. Silently adds them if not already. */
|
|
10549
|
-
async function ensureParticipant$1(db, chatId, agentId) {
|
|
10550
|
-
const [existing] = await db.select({ agentId: chatParticipants.agentId }).from(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, agentId))).limit(1);
|
|
10551
|
-
if (existing) return;
|
|
10552
|
-
await db.transaction(async (tx) => {
|
|
10553
|
-
await maybeUpgradeDirectToGroup(tx, chatId, (await tx.select({ agentId: chatParticipants.agentId }).from(chatParticipants).where(eq(chatParticipants.chatId, chatId))).map((p) => p.agentId), 1);
|
|
10554
|
-
await tx.delete(chatSubscriptions).where(and(eq(chatSubscriptions.chatId, chatId), eq(chatSubscriptions.agentId, agentId)));
|
|
10555
|
-
await tx.insert(chatParticipants).values({
|
|
10556
|
-
chatId,
|
|
10557
|
-
agentId,
|
|
10558
|
-
mode: "full"
|
|
10559
|
-
}).onConflictDoNothing({ target: [chatParticipants.chatId, chatParticipants.agentId] });
|
|
10560
|
-
await recomputeChatWatchers(tx, chatId);
|
|
10561
|
-
});
|
|
10562
|
-
invalidateChatAudience(chatId);
|
|
10563
|
-
}
|
|
10564
|
-
async function addParticipant(db, chatId, requesterId, data) {
|
|
10565
|
-
const chat = await getChat(db, chatId);
|
|
10566
|
-
await assertParticipant(db, chatId, requesterId);
|
|
10567
|
-
const [targetAgent] = await db.select({
|
|
10568
|
-
id: agents.uuid,
|
|
10569
|
-
organizationId: agents.organizationId
|
|
10570
|
-
}).from(agents).where(eq(agents.uuid, data.agentId)).limit(1);
|
|
10571
|
-
if (!targetAgent) throw new NotFoundError(`Agent "${data.agentId}" not found`);
|
|
10572
|
-
if (targetAgent.organizationId !== chat.organizationId) throw new BadRequestError("Cannot add agent from different organization");
|
|
10573
|
-
const [existing] = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, data.agentId))).limit(1);
|
|
10574
|
-
if (existing) throw new ConflictError(`Agent "${data.agentId}" is already a participant`);
|
|
10575
|
-
await db.transaction(async (tx) => {
|
|
10576
|
-
await maybeUpgradeDirectToGroup(tx, chatId, (await tx.select({ agentId: chatParticipants.agentId }).from(chatParticipants).where(eq(chatParticipants.chatId, chatId))).map((p) => p.agentId), 1);
|
|
10577
|
-
await tx.delete(chatSubscriptions).where(and(eq(chatSubscriptions.chatId, chatId), eq(chatSubscriptions.agentId, data.agentId)));
|
|
10578
|
-
await tx.insert(chatParticipants).values({
|
|
10579
|
-
chatId,
|
|
10580
|
-
agentId: data.agentId,
|
|
10581
|
-
mode: data.mode ?? "full"
|
|
10582
|
-
});
|
|
10583
|
-
await recomputeChatWatchers(tx, chatId);
|
|
10584
|
-
});
|
|
10585
|
-
invalidateChatAudience(chatId);
|
|
10586
|
-
return db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
|
|
10587
|
-
}
|
|
10588
|
-
async function removeParticipant(db, chatId, requesterId, targetAgentId) {
|
|
10589
|
-
await assertParticipant(db, chatId, requesterId);
|
|
10590
|
-
if (requesterId === targetAgentId) throw new BadRequestError("Cannot remove yourself from a chat");
|
|
10591
|
-
const [removed] = await db.delete(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, targetAgentId))).returning();
|
|
10592
|
-
if (!removed) throw new NotFoundError(`Agent "${targetAgentId}" is not a participant of this chat`);
|
|
10593
|
-
await recomputeChatWatchers(db, chatId);
|
|
10594
|
-
invalidateChatAudience(chatId);
|
|
10595
|
-
return db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
|
|
10596
|
-
}
|
|
10597
|
-
/**
|
|
10598
|
-
* List chats visible to a member, grouped by agent.
|
|
10599
|
-
* A member sees chats where:
|
|
10600
|
-
* 1. Their human agent is a participant, OR
|
|
10601
|
-
* 2. Any agent they manage (managerId = memberId) is a participant (supervision)
|
|
10602
|
-
*/
|
|
10603
|
-
async function listChatsForMember(db, memberId, humanAgentId) {
|
|
10604
|
-
const managedAgents = await db.select({
|
|
10605
|
-
uuid: agents.uuid,
|
|
10606
|
-
name: agents.name,
|
|
10607
|
-
type: agents.type,
|
|
10608
|
-
displayName: agents.displayName
|
|
10609
|
-
}).from(agents).where(eq(agents.managerId, memberId));
|
|
10610
|
-
const agentMap = /* @__PURE__ */ new Map();
|
|
10611
|
-
for (const a of managedAgents) agentMap.set(a.uuid, a);
|
|
10612
|
-
if (!agentMap.has(humanAgentId)) {
|
|
10613
|
-
const [ha] = await db.select({
|
|
10614
|
-
uuid: agents.uuid,
|
|
10615
|
-
name: agents.name,
|
|
10616
|
-
type: agents.type,
|
|
10617
|
-
displayName: agents.displayName
|
|
10618
|
-
}).from(agents).where(eq(agents.uuid, humanAgentId)).limit(1);
|
|
10619
|
-
if (ha) agentMap.set(ha.uuid, ha);
|
|
10620
|
-
}
|
|
10621
|
-
const agentIds = [...agentMap.keys()];
|
|
10622
|
-
if (agentIds.length === 0) return [];
|
|
10623
|
-
const participations = await db.select({
|
|
10624
|
-
chatId: chatParticipants.chatId,
|
|
10625
|
-
agentId: chatParticipants.agentId,
|
|
10626
|
-
role: chatParticipants.role,
|
|
10627
|
-
mode: chatParticipants.mode
|
|
10628
|
-
}).from(chatParticipants).where(inArray(chatParticipants.agentId, agentIds));
|
|
10629
|
-
if (participations.length === 0) return [];
|
|
10630
|
-
const chatIds = [...new Set(participations.map((p) => p.chatId))];
|
|
10631
|
-
const agentChatMap = /* @__PURE__ */ new Map();
|
|
10632
|
-
for (const p of participations) {
|
|
10633
|
-
const list = agentChatMap.get(p.agentId) ?? [];
|
|
10634
|
-
list.push(p.chatId);
|
|
10635
|
-
agentChatMap.set(p.agentId, list);
|
|
10636
|
-
}
|
|
10637
|
-
const chatRows = await db.select({
|
|
10638
|
-
id: chats.id,
|
|
10639
|
-
type: chats.type,
|
|
10640
|
-
topic: chats.topic,
|
|
10641
|
-
metadata: chats.metadata,
|
|
10642
|
-
createdAt: chats.createdAt,
|
|
10643
|
-
updatedAt: chats.updatedAt,
|
|
10644
|
-
participantCount: sql`(SELECT count(*)::int FROM chat_participants WHERE chat_id = ${chats.id})`
|
|
10645
|
-
}).from(chats).where(inArray(chats.id, chatIds)).orderBy(desc(chats.updatedAt));
|
|
10646
|
-
const chatMap = new Map(chatRows.map((c) => [c.id, c]));
|
|
10647
|
-
const humanParticipantChatIds = new Set(participations.filter((p) => p.agentId === humanAgentId).map((p) => p.chatId));
|
|
10648
|
-
const result = [];
|
|
10649
|
-
for (const [agentId, agentChatIds] of agentChatMap) {
|
|
10650
|
-
const agentInfo = agentMap.get(agentId);
|
|
10651
|
-
if (!agentInfo) continue;
|
|
10652
|
-
const agentChats = agentChatIds.map((chatId) => {
|
|
10653
|
-
const chat = chatMap.get(chatId);
|
|
10654
|
-
if (!chat) return null;
|
|
10655
|
-
const isSupervisionOnly = agentId !== humanAgentId && !humanParticipantChatIds.has(chatId);
|
|
10656
|
-
return {
|
|
10657
|
-
id: chat.id,
|
|
10658
|
-
type: chat.type,
|
|
10659
|
-
topic: chat.topic,
|
|
10660
|
-
participantCount: chat.participantCount,
|
|
10661
|
-
isSupervisionOnly,
|
|
10662
|
-
createdAt: chat.createdAt.toISOString(),
|
|
10663
|
-
updatedAt: chat.updatedAt.toISOString()
|
|
10664
|
-
};
|
|
10665
|
-
}).filter((c) => c !== null);
|
|
10666
|
-
if (agentChats.length > 0) result.push({
|
|
10667
|
-
agent: agentInfo,
|
|
10668
|
-
chats: agentChats
|
|
10669
|
-
});
|
|
10670
|
-
}
|
|
10671
|
-
return result;
|
|
10672
|
-
}
|
|
10673
|
-
/**
|
|
10674
|
-
* Manager joins a chat. Adds their human agent as a participant.
|
|
10675
|
-
* Requires the member to have supervision rights (manages at least one existing participant).
|
|
10676
|
-
*/
|
|
10677
|
-
async function joinChat(db, chatId, memberId, humanAgentId) {
|
|
10678
|
-
const chat = await getChat(db, chatId);
|
|
10679
|
-
const participantAgentIds = (await db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId))).map((p) => p.agentId);
|
|
10680
|
-
if (participantAgentIds.length === 0) throw new NotFoundError("Chat has no participants");
|
|
10681
|
-
if (participantAgentIds.includes(humanAgentId)) throw new ConflictError("Already a participant in this chat");
|
|
10682
|
-
if ((await db.select({ uuid: agents.uuid }).from(agents).where(and(inArray(agents.uuid, participantAgentIds), eq(agents.managerId, memberId)))).length === 0) throw new ForbiddenError("You can only join chats where you manage at least one participant");
|
|
10683
|
-
const [humanAgent] = await db.select({ organizationId: agents.organizationId }).from(agents).where(eq(agents.uuid, humanAgentId)).limit(1);
|
|
10684
|
-
if (!humanAgent || humanAgent.organizationId !== chat.organizationId) throw new BadRequestError("Agent does not belong to the same organization as the chat");
|
|
10685
|
-
await db.transaction(async (tx) => {
|
|
10686
|
-
const [carriedRow] = await tx.delete(chatSubscriptions).where(and(eq(chatSubscriptions.chatId, chatId), eq(chatSubscriptions.agentId, humanAgentId))).returning({
|
|
10687
|
-
lastReadAt: chatSubscriptions.lastReadAt,
|
|
10688
|
-
unreadMentionCount: chatSubscriptions.unreadMentionCount
|
|
10689
|
-
});
|
|
10690
|
-
await maybeUpgradeDirectToGroup(tx, chatId, participantAgentIds, 1);
|
|
10691
|
-
await tx.insert(chatParticipants).values({
|
|
10692
|
-
chatId,
|
|
10693
|
-
agentId: humanAgentId,
|
|
10694
|
-
role: "member",
|
|
10695
|
-
mode: "full",
|
|
10696
|
-
lastReadAt: carriedRow?.lastReadAt ?? null,
|
|
10697
|
-
unreadMentionCount: carriedRow?.unreadMentionCount ?? 0
|
|
10698
|
-
});
|
|
10699
|
-
await recomputeChatWatchers(tx, chatId);
|
|
10700
|
-
});
|
|
10701
|
-
invalidateChatAudience(chatId);
|
|
10702
|
-
return db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
|
|
10703
|
-
}
|
|
10704
|
-
/**
|
|
10705
|
-
* Manager leaves a chat. Removes their human agent from participants.
|
|
10706
|
-
* Only allowed if the human agent is a participant.
|
|
10707
|
-
*
|
|
10708
|
-
* Delegates the participant→watcher transition to `leaveAsParticipant`
|
|
10709
|
-
* so admin-side and `/me/chats/:id/leave` share one canonical path. The
|
|
10710
|
-
* earlier "recompute then UPDATE-back state" variant violated the design
|
|
10711
|
-
* rule that recompute is only for set rebuild — never on a transition
|
|
10712
|
-
* path (review #228 issue #2). The returned participant list is fetched
|
|
10713
|
-
* after the tx commits, matching the admin route's existing contract.
|
|
10714
|
-
*/
|
|
10715
|
-
async function leaveChat(db, chatId, humanAgentId) {
|
|
10716
|
-
await leaveAsParticipant(db, chatId, humanAgentId);
|
|
10717
|
-
invalidateChatAudience(chatId);
|
|
10718
|
-
return db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
|
|
10719
|
-
}
|
|
10720
|
-
async function findOrCreateDirectChat(db, agentAId, agentBId) {
|
|
10721
|
-
const aChats = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(eq(chatParticipants.agentId, agentAId));
|
|
10722
|
-
const bChats = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(eq(chatParticipants.agentId, agentBId));
|
|
10723
|
-
const bChatIds = new Set(bChats.map((r) => r.chatId));
|
|
10724
|
-
const commonChatIds = aChats.map((r) => r.chatId).filter((id) => bChatIds.has(id));
|
|
10725
|
-
if (commonChatIds.length > 0) {
|
|
10726
|
-
const directChats = await db.select().from(chats).where(and(inArray(chats.id, commonChatIds), eq(chats.type, "direct")));
|
|
10727
|
-
if (directChats.length > 0 && directChats[0]) return directChats[0];
|
|
10728
|
-
}
|
|
10729
|
-
const ends = await db.select({
|
|
10730
|
-
uuid: agents.uuid,
|
|
10731
|
-
organizationId: agents.organizationId,
|
|
10732
|
-
type: agents.type
|
|
10733
|
-
}).from(agents).where(inArray(agents.uuid, [agentAId, agentBId]));
|
|
10734
|
-
const agentA = ends.find((a) => a.uuid === agentAId);
|
|
10735
|
-
if (!agentA) throw new NotFoundError(`Agent "${agentAId}" not found`);
|
|
10736
|
-
const agentB = ends.find((a) => a.uuid === agentBId);
|
|
10737
|
-
if (!agentB) throw new NotFoundError(`Agent "${agentBId}" not found`);
|
|
10738
|
-
const mode = agentA.type !== "human" && agentB.type !== "human" ? "mention_only" : "full";
|
|
10739
|
-
const chatId = randomUUID();
|
|
10740
|
-
return db.transaction(async (tx) => {
|
|
10741
|
-
const [chat] = await tx.insert(chats).values({
|
|
10742
|
-
id: chatId,
|
|
10743
|
-
organizationId: agentA.organizationId,
|
|
10744
|
-
type: "direct"
|
|
10745
|
-
}).returning();
|
|
10746
|
-
await tx.insert(chatParticipants).values([{
|
|
10747
|
-
chatId,
|
|
10748
|
-
agentId: agentAId,
|
|
10749
|
-
role: "member",
|
|
10750
|
-
mode
|
|
10751
|
-
}, {
|
|
10752
|
-
chatId,
|
|
10753
|
-
agentId: agentBId,
|
|
10754
|
-
role: "member",
|
|
10755
|
-
mode
|
|
10756
|
-
}]);
|
|
10757
|
-
await recomputeChatWatchers(tx, chatId);
|
|
10758
|
-
if (!chat) throw new Error("Unexpected: INSERT RETURNING produced no row");
|
|
10759
|
-
return chat;
|
|
10760
|
-
});
|
|
10761
|
-
}
|
|
10762
10755
|
function serializeChat(chat) {
|
|
10763
10756
|
return {
|
|
10764
10757
|
...chat,
|
|
@@ -10866,46 +10859,6 @@ const agentConfigs = pgTable("agent_configs", {
|
|
|
10866
10859
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
|
|
10867
10860
|
});
|
|
10868
10861
|
/**
|
|
10869
|
-
* Shared access-control primitives. Most route-level gating now lives in
|
|
10870
|
-
* `scope/require-*.ts` — this module is reduced to two helpers that need
|
|
10871
|
-
* SQL building blocks reused across routes and tests:
|
|
10872
|
-
*
|
|
10873
|
-
* - `agentVisibilityCondition` — WHERE clause for "agents visible to a
|
|
10874
|
-
* member" (org-visible OR managerId = the caller's member). Composed
|
|
10875
|
-
* into list queries that already select from `agents`.
|
|
10876
|
-
* - `listAgentsManagedByUser` — cross-org list of agents personally
|
|
10877
|
-
* managed by a user; powers the CLI `agent list --remote` view.
|
|
10878
|
-
*
|
|
10879
|
-
* Visibility is the same for all roles — admin sees the same set as a
|
|
10880
|
-
* regular member. Admin privilege is expressed through manageability
|
|
10881
|
-
* (`requireAgentAccess(..., "manage")`), not visibility.
|
|
10882
|
-
*/
|
|
10883
|
-
/**
|
|
10884
|
-
* SQL WHERE conditions for agents visible to a member.
|
|
10885
|
-
* target org + not deleted + (organization-visible OR managerId = caller's member)
|
|
10886
|
-
*/
|
|
10887
|
-
function agentVisibilityCondition(orgId, memberId) {
|
|
10888
|
-
return and(eq(agents.organizationId, orgId), ne(agents.status, AGENT_STATUSES.DELETED), or(eq(agents.visibility, AGENT_VISIBILITY.ORGANIZATION), eq(agents.managerId, memberId)));
|
|
10889
|
-
}
|
|
10890
|
-
/**
|
|
10891
|
-
* Cross-org listing helper for "agents I personally manage". Used by the
|
|
10892
|
-
* CLI `agent list --remote` view — JOINs `agents → members.id` and filters
|
|
10893
|
-
* by `members.user_id`.
|
|
10894
|
-
*/
|
|
10895
|
-
async function listAgentsManagedByUser(db, userId) {
|
|
10896
|
-
return db.select({
|
|
10897
|
-
uuid: agents.uuid,
|
|
10898
|
-
name: agents.name,
|
|
10899
|
-
displayName: agents.displayName,
|
|
10900
|
-
type: agents.type,
|
|
10901
|
-
organizationId: agents.organizationId,
|
|
10902
|
-
inboxId: agents.inboxId,
|
|
10903
|
-
visibility: agents.visibility,
|
|
10904
|
-
runtimeProvider: agents.runtimeProvider,
|
|
10905
|
-
clientId: agents.clientId
|
|
10906
|
-
}).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)));
|
|
10907
|
-
}
|
|
10908
|
-
/**
|
|
10909
10862
|
* Resolve the UUID of the "default" organization. Internal use only —
|
|
10910
10863
|
* webhooks, fallbacks, etc. The HTTP API layer no longer falls back to
|
|
10911
10864
|
* the JWT default org.
|
|
@@ -11348,7 +11301,7 @@ async function deleteAgent(db, uuid) {
|
|
|
11348
11301
|
if (!agent) throw new Error("Unexpected: UPDATE RETURNING produced no row");
|
|
11349
11302
|
return agent;
|
|
11350
11303
|
}
|
|
11351
|
-
const log$
|
|
11304
|
+
const log$4 = createLogger$1("AgentFeishuBot");
|
|
11352
11305
|
async function agentFeishuBotRoutes(app) {
|
|
11353
11306
|
/**
|
|
11354
11307
|
* PUT /agent/me/feishu-bot
|
|
@@ -11376,7 +11329,7 @@ async function agentFeishuBotRoutes(app) {
|
|
|
11376
11329
|
},
|
|
11377
11330
|
status: "active"
|
|
11378
11331
|
}, app.config.secrets.encryptionKey);
|
|
11379
|
-
app.adapterManager.reload().catch((err) => log$
|
|
11332
|
+
app.adapterManager.reload().catch((err) => log$4.error({ err }, "adapter reload failed after self-service bind"));
|
|
11380
11333
|
app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
|
|
11381
11334
|
return reply.status(current ? 200 : 201).send({
|
|
11382
11335
|
...config,
|
|
@@ -11393,7 +11346,7 @@ async function agentFeishuBotRoutes(app) {
|
|
|
11393
11346
|
const current = (await listAdapterConfigs(app.db)).find((c) => c.agentId === identity.uuid && c.platform === "feishu");
|
|
11394
11347
|
if (!current) return reply.status(204).send();
|
|
11395
11348
|
await deleteAdapterConfig(app.db, current.id);
|
|
11396
|
-
app.adapterManager.reload().catch((err) => log$
|
|
11349
|
+
app.adapterManager.reload().catch((err) => log$4.error({ err }, "adapter reload failed after self-service unbind"));
|
|
11397
11350
|
app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
|
|
11398
11351
|
return reply.status(204).send();
|
|
11399
11352
|
});
|
|
@@ -11414,20 +11367,6 @@ const adapterChatMappings = pgTable("adapter_chat_mappings", {
|
|
|
11414
11367
|
metadata: jsonb("metadata").$type().notNull().default({}),
|
|
11415
11368
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
|
|
11416
11369
|
});
|
|
11417
|
-
/** Messages. Immutable after creation. Each message belongs to exactly one Chat. */
|
|
11418
|
-
const messages = pgTable("messages", {
|
|
11419
|
-
id: text("id").primaryKey(),
|
|
11420
|
-
chatId: text("chat_id").notNull().references(() => chats.id),
|
|
11421
|
-
senderId: text("sender_id").notNull(),
|
|
11422
|
-
format: text("format").notNull(),
|
|
11423
|
-
content: jsonb("content").$type().notNull(),
|
|
11424
|
-
metadata: jsonb("metadata").$type().notNull().default({}),
|
|
11425
|
-
replyToInbox: text("reply_to_inbox"),
|
|
11426
|
-
replyToChat: text("reply_to_chat"),
|
|
11427
|
-
inReplyTo: text("in_reply_to"),
|
|
11428
|
-
source: text("source"),
|
|
11429
|
-
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
|
|
11430
|
-
}, (table) => [index("idx_messages_chat_time").on(table.chatId, table.createdAt), index("idx_messages_in_reply_to").on(table.inReplyTo)]);
|
|
11431
11370
|
/** Cross-reference between internal messages and external platform message IDs. */
|
|
11432
11371
|
const adapterMessageReferences = pgTable("adapter_message_references", {
|
|
11433
11372
|
id: serial("id").primaryKey(),
|
|
@@ -11618,24 +11557,6 @@ async function agentFeishuUserRoutes(app) {
|
|
|
11618
11557
|
return reply.status(204).send();
|
|
11619
11558
|
});
|
|
11620
11559
|
}
|
|
11621
|
-
/** Delivery queue (envelope). One entry per recipient created during message fan-out. Uses SKIP LOCKED for concurrent-safe consumption. */
|
|
11622
|
-
const inboxEntries = pgTable("inbox_entries", {
|
|
11623
|
-
id: bigserial("id", { mode: "number" }).primaryKey(),
|
|
11624
|
-
inboxId: text("inbox_id").notNull(),
|
|
11625
|
-
messageId: text("message_id").notNull().references(() => messages.id),
|
|
11626
|
-
chatId: text("chat_id"),
|
|
11627
|
-
status: text("status").notNull().default("pending"),
|
|
11628
|
-
notify: boolean("notify").notNull().default(true),
|
|
11629
|
-
retryCount: integer("retry_count").notNull().default(0),
|
|
11630
|
-
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
11631
|
-
deliveredAt: timestamp("delivered_at", { withTimezone: true }),
|
|
11632
|
-
ackedAt: timestamp("acked_at", { withTimezone: true })
|
|
11633
|
-
}, (table) => [
|
|
11634
|
-
unique("uq_inbox_delivery").on(table.inboxId, table.messageId, table.chatId),
|
|
11635
|
-
index("idx_inbox_pending").on(table.inboxId, table.createdAt),
|
|
11636
|
-
index("idx_inbox_pending_notify").on(table.inboxId, table.createdAt).where(sql`status = 'pending' AND notify = true`),
|
|
11637
|
-
index("idx_inbox_chat_silent").on(table.inboxId, table.chatId, table.notify, table.status)
|
|
11638
|
-
]);
|
|
11639
11560
|
function normaliseSource(source) {
|
|
11640
11561
|
if (source === null) return null;
|
|
11641
11562
|
const parsed = messageSourceSchema$1.safeParse(source);
|
|
@@ -12071,585 +11992,6 @@ async function collectTargetInboxes(db, chatId, inReplyTo) {
|
|
|
12071
11992
|
}
|
|
12072
11993
|
return [...set];
|
|
12073
11994
|
}
|
|
12074
|
-
/** Per-session state snapshot. One row per (agent, chat) pair, upserted on each session:state message. */
|
|
12075
|
-
const agentChatSessions = pgTable("agent_chat_sessions", {
|
|
12076
|
-
agentId: text("agent_id").notNull().references(() => agents.uuid, { onDelete: "cascade" }),
|
|
12077
|
-
chatId: text("chat_id").notNull().references(() => chats.id, { onDelete: "cascade" }),
|
|
12078
|
-
state: text("state").notNull(),
|
|
12079
|
-
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
|
|
12080
|
-
}, (table) => [primaryKey({ columns: [table.agentId, table.chatId] })]);
|
|
12081
|
-
/**
|
|
12082
|
-
* Upsert session state + refresh presence aggregates + NOTIFY.
|
|
12083
|
-
*
|
|
12084
|
-
* `agent_chat_sessions.(agent_id, chat_id)` is a single-row "current session
|
|
12085
|
-
* state" cache, not a session history log. A new runtime session starting on
|
|
12086
|
-
* the same (agent, chat) pair MUST overwrite whatever ended before — including
|
|
12087
|
-
* an `evicted` row left by a previous terminate. The previous "revival
|
|
12088
|
-
* defense" conflated two concerns: "this runtime session ended" (which is
|
|
12089
|
-
* what `evicted` actually means) and "this chat is permanently archived for
|
|
12090
|
-
* this agent" (a chat-level decision that should live on `chats`, not here).
|
|
12091
|
-
* See proposals/hub-agent-messaging-reply-and-mentions §M2-session-lifecycle.
|
|
12092
|
-
*
|
|
12093
|
-
* Presence row contract: this function tolerates a missing `agent_presence`
|
|
12094
|
-
* row by using `INSERT ... ON CONFLICT DO UPDATE`. The predictive-write path
|
|
12095
|
-
* (sendMessage on first message) may target an agent whose client has never
|
|
12096
|
-
* bound, so a prior `update agent_presence ... where agentId` would silently
|
|
12097
|
-
* drop the activeSessions/totalSessions refresh. See PR #198 review §2.
|
|
12098
|
-
*/
|
|
12099
|
-
async function upsertSessionState(db, agentId, chatId, state, organizationId, notifier, options) {
|
|
12100
|
-
const now = /* @__PURE__ */ new Date();
|
|
12101
|
-
let wrote = false;
|
|
12102
|
-
await db.transaction(async (tx) => {
|
|
12103
|
-
await tx.insert(agentChatSessions).values({
|
|
12104
|
-
agentId,
|
|
12105
|
-
chatId,
|
|
12106
|
-
state,
|
|
12107
|
-
updatedAt: now
|
|
12108
|
-
}).onConflictDoUpdate({
|
|
12109
|
-
target: [agentChatSessions.agentId, agentChatSessions.chatId],
|
|
12110
|
-
set: {
|
|
12111
|
-
state,
|
|
12112
|
-
updatedAt: now
|
|
12113
|
-
},
|
|
12114
|
-
setWhere: ne(agentChatSessions.state, state)
|
|
12115
|
-
});
|
|
12116
|
-
const [counts] = await tx.select({
|
|
12117
|
-
active: sql`count(*) FILTER (WHERE ${agentChatSessions.state} = 'active')::int`,
|
|
12118
|
-
total: sql`count(*) FILTER (WHERE ${agentChatSessions.state} != 'evicted')::int`
|
|
12119
|
-
}).from(agentChatSessions).where(eq(agentChatSessions.agentId, agentId));
|
|
12120
|
-
const activeSessions = counts?.active ?? 0;
|
|
12121
|
-
const totalSessions = counts?.total ?? 0;
|
|
12122
|
-
const presenceSet = options?.touchPresenceLastSeen ?? true ? {
|
|
12123
|
-
activeSessions,
|
|
12124
|
-
totalSessions,
|
|
12125
|
-
lastSeenAt: now
|
|
12126
|
-
} : {
|
|
12127
|
-
activeSessions,
|
|
12128
|
-
totalSessions
|
|
12129
|
-
};
|
|
12130
|
-
await tx.insert(agentPresence).values({
|
|
12131
|
-
agentId,
|
|
12132
|
-
activeSessions,
|
|
12133
|
-
totalSessions
|
|
12134
|
-
}).onConflictDoUpdate({
|
|
12135
|
-
target: [agentPresence.agentId],
|
|
12136
|
-
set: presenceSet
|
|
12137
|
-
});
|
|
12138
|
-
wrote = true;
|
|
12139
|
-
});
|
|
12140
|
-
if (wrote && notifier) notifier.notifySessionStateChange(agentId, chatId, state, organizationId).catch(() => {});
|
|
12141
|
-
}
|
|
12142
|
-
async function resetActivity(db, agentId) {
|
|
12143
|
-
const now = /* @__PURE__ */ new Date();
|
|
12144
|
-
await db.update(agentPresence).set({
|
|
12145
|
-
runtimeState: "idle",
|
|
12146
|
-
runtimeUpdatedAt: now
|
|
12147
|
-
}).where(eq(agentPresence.agentId, agentId));
|
|
12148
|
-
}
|
|
12149
|
-
async function getActivityOverview(db) {
|
|
12150
|
-
const [agentCounts] = await db.select({
|
|
12151
|
-
total: sql`count(*)::int`,
|
|
12152
|
-
running: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} IS NOT NULL)::int`,
|
|
12153
|
-
idle: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} = 'idle')::int`,
|
|
12154
|
-
working: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} = 'working')::int`,
|
|
12155
|
-
blocked: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} = 'blocked')::int`,
|
|
12156
|
-
error: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} = 'error')::int`
|
|
12157
|
-
}).from(agentPresence);
|
|
12158
|
-
const [clientCounts] = await db.select({ count: sql`count(*)::int` }).from(clients).where(eq(clients.status, "connected"));
|
|
12159
|
-
return {
|
|
12160
|
-
total: agentCounts?.total ?? 0,
|
|
12161
|
-
running: agentCounts?.running ?? 0,
|
|
12162
|
-
byState: {
|
|
12163
|
-
idle: agentCounts?.idle ?? 0,
|
|
12164
|
-
working: agentCounts?.working ?? 0,
|
|
12165
|
-
blocked: agentCounts?.blocked ?? 0,
|
|
12166
|
-
error: agentCounts?.error ?? 0
|
|
12167
|
-
},
|
|
12168
|
-
clients: clientCounts?.count ?? 0
|
|
12169
|
-
};
|
|
12170
|
-
}
|
|
12171
|
-
/**
|
|
12172
|
-
* List agents with active runtime state.
|
|
12173
|
-
* When scope is provided, filters to agents visible to the member.
|
|
12174
|
-
*/
|
|
12175
|
-
async function listAgentsWithRuntime(db, scope) {
|
|
12176
|
-
if (!scope) return db.select().from(agentPresence).where(isNotNull(agentPresence.runtimeState));
|
|
12177
|
-
return db.select({
|
|
12178
|
-
agentId: agentPresence.agentId,
|
|
12179
|
-
status: agentPresence.status,
|
|
12180
|
-
instanceId: agentPresence.instanceId,
|
|
12181
|
-
connectedAt: agentPresence.connectedAt,
|
|
12182
|
-
lastSeenAt: agentPresence.lastSeenAt,
|
|
12183
|
-
clientId: agentPresence.clientId,
|
|
12184
|
-
runtimeType: agentPresence.runtimeType,
|
|
12185
|
-
runtimeVersion: agentPresence.runtimeVersion,
|
|
12186
|
-
runtimeState: agentPresence.runtimeState,
|
|
12187
|
-
activeSessions: agentPresence.activeSessions,
|
|
12188
|
-
totalSessions: agentPresence.totalSessions,
|
|
12189
|
-
runtimeUpdatedAt: agentPresence.runtimeUpdatedAt,
|
|
12190
|
-
type: agents.type
|
|
12191
|
-
}).from(agentPresence).innerJoin(agents, eq(agentPresence.agentId, agents.uuid)).where(and(isNotNull(agentPresence.runtimeState), agentVisibilityCondition(scope.organizationId, scope.memberId)));
|
|
12192
|
-
}
|
|
12193
|
-
/**
|
|
12194
|
-
* Chat-first workspace — append-only post-fan-out projection.
|
|
12195
|
-
*
|
|
12196
|
-
* The single sanctioned extension point on the message hot path. Called
|
|
12197
|
-
* from `services/message.ts` AFTER existing fan-out completes, inside the
|
|
12198
|
-
* same transaction. Three responsibilities:
|
|
12199
|
-
*
|
|
12200
|
-
* 1. Mention propagation: increment `unread_mention_count` for mentioned
|
|
12201
|
-
* speaking participants AND for watcher rows whose managed agent was
|
|
12202
|
-
* mentioned. Sender row is excluded.
|
|
12203
|
-
*
|
|
12204
|
-
* 2. Chats projection: roll forward `chats.last_message_at`,
|
|
12205
|
-
* `chats.last_message_preview`. Powers the conversation list cursor +
|
|
12206
|
-
* sort + preview.
|
|
12207
|
-
*
|
|
12208
|
-
* 3. Realtime kick: fire-and-forget `pg_notify('chat_message_events', …)`
|
|
12209
|
-
* so admin WS sockets can translate it into a `chat:message` frame.
|
|
12210
|
-
* Failure is swallowed — durable persistence is the correctness path.
|
|
12211
|
-
*
|
|
12212
|
-
* Strict invariants (see docs/chat-first-workspace-product-design.md
|
|
12213
|
-
* "Risk Constraints"):
|
|
12214
|
-
* - This module appends ONLY. Never edits existing fan-out / inbox /
|
|
12215
|
-
* mention-extraction code.
|
|
12216
|
-
* - Watchers (chat_subscriptions) are NEVER added to inbox_entries here.
|
|
12217
|
-
* Their counters are bumped purely as a per-user red-dot signal.
|
|
12218
|
-
* - Mention candidate set is `chat_participants` only; watchers are not
|
|
12219
|
-
* direct `@`-mention targets.
|
|
12220
|
-
*/
|
|
12221
|
-
let dispatcher = null;
|
|
12222
|
-
function registerChatMessageDispatcher(fn) {
|
|
12223
|
-
dispatcher = fn;
|
|
12224
|
-
}
|
|
12225
|
-
/**
|
|
12226
|
-
* Best-effort cross-process kick for the chat-first workspace. Call AFTER
|
|
12227
|
-
* the message transaction commits — never inside the tx. Failure logs +
|
|
12228
|
-
* drops; web reconnect refetches.
|
|
12229
|
-
*
|
|
12230
|
-
* Speakers also get an inbox NOTIFY through the existing path. They will
|
|
12231
|
-
* receive both, and the web client de-dupes naturally because both end up
|
|
12232
|
-
* invalidating the same query keys.
|
|
12233
|
-
*/
|
|
12234
|
-
function fireChatMessageKick(chatId, messageId) {
|
|
12235
|
-
if (!dispatcher) return;
|
|
12236
|
-
try {
|
|
12237
|
-
dispatcher(chatId, messageId);
|
|
12238
|
-
} catch {}
|
|
12239
|
-
}
|
|
12240
|
-
/**
|
|
12241
|
-
* Apply the post-fan-out projection. MUST be called inside the same
|
|
12242
|
-
* transaction as the message INSERT. Safe to call when `mentionedAgentIds`
|
|
12243
|
-
* is empty (degenerate case skips the mention UPDATEs).
|
|
12244
|
-
*/
|
|
12245
|
-
async function applyAfterFanOut(tx, input) {
|
|
12246
|
-
const { chatId, senderId, mentionedAgentIds, contentPreview, messageCreatedAt } = input;
|
|
12247
|
-
const previewClipped = contentPreview.length > 0 ? contentPreview.slice(0, 200) : null;
|
|
12248
|
-
const ts = messageCreatedAt ?? /* @__PURE__ */ new Date();
|
|
12249
|
-
await tx.update(chats).set({
|
|
12250
|
-
lastMessageAt: ts,
|
|
12251
|
-
lastMessagePreview: previewClipped
|
|
12252
|
-
}).where(eq(chats.id, chatId));
|
|
12253
|
-
if (mentionedAgentIds.length === 0) return;
|
|
12254
|
-
await tx.update(chatParticipants).set({ unreadMentionCount: sql`${chatParticipants.unreadMentionCount} + 1` }).where(and(eq(chatParticipants.chatId, chatId), inArray(chatParticipants.agentId, mentionedAgentIds), ne(chatParticipants.agentId, senderId)));
|
|
12255
|
-
const managerHumanAgentIds = (await tx.execute(sql`
|
|
12256
|
-
SELECT DISTINCT m.agent_id AS human_agent_id
|
|
12257
|
-
FROM agents a
|
|
12258
|
-
JOIN members m ON m.id = a.manager_id
|
|
12259
|
-
WHERE a.uuid IN ${makeUuidList(mentionedAgentIds)}
|
|
12260
|
-
AND a.type <> 'human'
|
|
12261
|
-
AND m.status = 'active'
|
|
12262
|
-
`)).map((r) => r.human_agent_id);
|
|
12263
|
-
if (managerHumanAgentIds.length === 0) return;
|
|
12264
|
-
await tx.update(chatSubscriptions).set({ unreadMentionCount: sql`${chatSubscriptions.unreadMentionCount} + 1` }).where(and(eq(chatSubscriptions.chatId, chatId), inArray(chatSubscriptions.agentId, managerHumanAgentIds)));
|
|
12265
|
-
}
|
|
12266
|
-
/**
|
|
12267
|
-
* Build a parenthesised, comma-separated list of bound parameters: `(?, ?, ?)`.
|
|
12268
|
-
* Used in raw SQL where drizzle's `inArray` can't be directly applied (e.g.
|
|
12269
|
-
* inside a hand-rolled SELECT). Always called with a non-empty list — the
|
|
12270
|
-
* caller short-circuits the empty case.
|
|
12271
|
-
*/
|
|
12272
|
-
function makeUuidList(ids) {
|
|
12273
|
-
return sql`(${sql.join(ids.map((id) => sql`${id}`), sql`, `)})`;
|
|
12274
|
-
}
|
|
12275
|
-
const log$4 = createLogger$1("message");
|
|
12276
|
-
async function sendMessage(db, chatId, senderId, data, options = {}) {
|
|
12277
|
-
return withSpan("inbox.enqueue", messageAttrs({
|
|
12278
|
-
chatId,
|
|
12279
|
-
senderAgentId: senderId,
|
|
12280
|
-
source: data.source ?? void 0
|
|
12281
|
-
}), () => sendMessageInner(db, chatId, senderId, data, options));
|
|
12282
|
-
}
|
|
12283
|
-
async function sendMessageInner(db, chatId, senderId, data, options) {
|
|
12284
|
-
const txResult = await db.transaction(async (tx) => {
|
|
12285
|
-
const [participants, [chatRow], [senderRow]] = await Promise.all([
|
|
12286
|
-
tx.select({
|
|
12287
|
-
agentId: chatParticipants.agentId,
|
|
12288
|
-
inboxId: agents.inboxId,
|
|
12289
|
-
mode: chatParticipants.mode,
|
|
12290
|
-
name: agents.name
|
|
12291
|
-
}).from(chatParticipants).innerJoin(agents, eq(chatParticipants.agentId, agents.uuid)).where(eq(chatParticipants.chatId, chatId)),
|
|
12292
|
-
tx.select({ type: chats.type }).from(chats).where(eq(chats.id, chatId)).limit(1),
|
|
12293
|
-
tx.select({
|
|
12294
|
-
inboxId: agents.inboxId,
|
|
12295
|
-
organizationId: agents.organizationId
|
|
12296
|
-
}).from(agents).where(eq(agents.uuid, senderId)).limit(1)
|
|
12297
|
-
]);
|
|
12298
|
-
const chatType = chatRow?.type ?? null;
|
|
12299
|
-
if (!senderRow) throw new NotFoundError(`Sender agent "${senderId}" not found`);
|
|
12300
|
-
if (data.replyToInbox !== void 0 && data.replyToInbox !== null) {
|
|
12301
|
-
if (senderRow.inboxId !== data.replyToInbox) throw new BadRequestError("replyToInbox must reference the sender's own inbox");
|
|
12302
|
-
}
|
|
12303
|
-
const incomingMeta = data.metadata ?? {};
|
|
12304
|
-
const explicitMentionsRaw = incomingMeta.mentions;
|
|
12305
|
-
const explicitMentions = Array.isArray(explicitMentionsRaw) ? explicitMentionsRaw.filter((m) => typeof m === "string") : [];
|
|
12306
|
-
const contentText = typeof data.content === "string" ? data.content : "";
|
|
12307
|
-
const resolved = contentText ? extractMentions(contentText, participants) : [];
|
|
12308
|
-
const mergedMentions = [...new Set([...explicitMentions, ...resolved])];
|
|
12309
|
-
const metadataToStore = mergedMentions.length > 0 ? {
|
|
12310
|
-
...incomingMeta,
|
|
12311
|
-
mentions: mergedMentions
|
|
12312
|
-
} : incomingMeta;
|
|
12313
|
-
if (options.enforceGroupMention && chatType === "group") {
|
|
12314
|
-
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.");
|
|
12315
|
-
}
|
|
12316
|
-
let outboundContent = data.content;
|
|
12317
|
-
if (options.normalizeMentionsInContent && typeof outboundContent === "string") {
|
|
12318
|
-
const present = new Set(scanMentionTokens(outboundContent));
|
|
12319
|
-
const missingNames = [];
|
|
12320
|
-
for (const id of mergedMentions) {
|
|
12321
|
-
if (id === senderId) continue;
|
|
12322
|
-
const p = participants.find((q) => q.agentId === id);
|
|
12323
|
-
if (!p?.name) continue;
|
|
12324
|
-
if (present.has(p.name.toLowerCase())) continue;
|
|
12325
|
-
missingNames.push(p.name);
|
|
12326
|
-
}
|
|
12327
|
-
if (missingNames.length > 0) {
|
|
12328
|
-
const prefix = missingNames.map((n) => `@${n}`).join(" ");
|
|
12329
|
-
outboundContent = outboundContent.length > 0 ? `${prefix} ${outboundContent}` : prefix;
|
|
12330
|
-
}
|
|
12331
|
-
}
|
|
12332
|
-
const messageId = randomUUID();
|
|
12333
|
-
const [msg] = await tx.insert(messages).values({
|
|
12334
|
-
id: messageId,
|
|
12335
|
-
chatId,
|
|
12336
|
-
senderId,
|
|
12337
|
-
format: data.format,
|
|
12338
|
-
content: outboundContent,
|
|
12339
|
-
metadata: metadataToStore,
|
|
12340
|
-
replyToInbox: data.replyToInbox ?? null,
|
|
12341
|
-
replyToChat: data.replyToChat ?? null,
|
|
12342
|
-
inReplyTo: data.inReplyTo ?? null,
|
|
12343
|
-
source: data.source ?? null
|
|
12344
|
-
}).returning();
|
|
12345
|
-
const mentionSet = new Set(mergedMentions);
|
|
12346
|
-
const fanout = participants.filter((p) => p.agentId !== senderId).map((p) => ({
|
|
12347
|
-
agentId: p.agentId,
|
|
12348
|
-
inboxId: p.inboxId,
|
|
12349
|
-
notify: p.mode !== "mention_only" || mentionSet.has(p.agentId)
|
|
12350
|
-
}));
|
|
12351
|
-
if (fanout.length > 0) await tx.insert(inboxEntries).values(fanout.map((f) => ({
|
|
12352
|
-
inboxId: f.inboxId,
|
|
12353
|
-
messageId,
|
|
12354
|
-
chatId,
|
|
12355
|
-
notify: f.notify
|
|
12356
|
-
})));
|
|
12357
|
-
const notified = fanout.filter((f) => f.notify);
|
|
12358
|
-
const recipients = notified.map((f) => f.inboxId);
|
|
12359
|
-
const recipientAgentIds = notified.map((f) => f.agentId);
|
|
12360
|
-
if (data.inReplyTo) {
|
|
12361
|
-
const [original] = await tx.select({
|
|
12362
|
-
replyToInbox: messages.replyToInbox,
|
|
12363
|
-
replyToChat: messages.replyToChat
|
|
12364
|
-
}).from(messages).where(eq(messages.id, data.inReplyTo)).limit(1);
|
|
12365
|
-
if (original?.replyToInbox && original?.replyToChat) {
|
|
12366
|
-
await tx.insert(inboxEntries).values({
|
|
12367
|
-
inboxId: original.replyToInbox,
|
|
12368
|
-
messageId,
|
|
12369
|
-
chatId: original.replyToChat
|
|
12370
|
-
}).onConflictDoNothing();
|
|
12371
|
-
if (!recipients.includes(original.replyToInbox)) recipients.push(original.replyToInbox);
|
|
12372
|
-
}
|
|
12373
|
-
}
|
|
12374
|
-
await tx.update(chats).set({ updatedAt: /* @__PURE__ */ new Date() }).where(eq(chats.id, chatId));
|
|
12375
|
-
if (!msg) throw new Error("Unexpected: INSERT RETURNING produced no row");
|
|
12376
|
-
const previewText = typeof outboundContent === "string" ? outboundContent.trim() : "";
|
|
12377
|
-
await applyAfterFanOut(tx, {
|
|
12378
|
-
chatId,
|
|
12379
|
-
messageId: msg.id,
|
|
12380
|
-
senderId,
|
|
12381
|
-
mentionedAgentIds: mergedMentions,
|
|
12382
|
-
contentPreview: previewText,
|
|
12383
|
-
messageCreatedAt: msg.createdAt
|
|
12384
|
-
});
|
|
12385
|
-
return {
|
|
12386
|
-
message: msg,
|
|
12387
|
-
recipients,
|
|
12388
|
-
recipientAgentIds,
|
|
12389
|
-
organizationId: senderRow.organizationId
|
|
12390
|
-
};
|
|
12391
|
-
});
|
|
12392
|
-
const settled = await Promise.allSettled(txResult.recipientAgentIds.map((agentId) => upsertSessionState(db, agentId, chatId, "active", txResult.organizationId, void 0, { touchPresenceLastSeen: false })));
|
|
12393
|
-
for (let i = 0; i < settled.length; i++) {
|
|
12394
|
-
const r = settled[i];
|
|
12395
|
-
if (r?.status === "rejected") log$4.error({
|
|
12396
|
-
err: r.reason,
|
|
12397
|
-
chatId,
|
|
12398
|
-
agentId: txResult.recipientAgentIds[i]
|
|
12399
|
-
}, "predictive session activation failed");
|
|
12400
|
-
}
|
|
12401
|
-
fireChatMessageKick(chatId, txResult.message.id);
|
|
12402
|
-
return {
|
|
12403
|
-
message: txResult.message,
|
|
12404
|
-
recipients: txResult.recipients
|
|
12405
|
-
};
|
|
12406
|
-
}
|
|
12407
|
-
async function sendToAgent$1(db, senderUuid, targetName, data) {
|
|
12408
|
-
const [sender] = await db.select({
|
|
12409
|
-
uuid: agents.uuid,
|
|
12410
|
-
organizationId: agents.organizationId
|
|
12411
|
-
}).from(agents).where(eq(agents.uuid, senderUuid)).limit(1);
|
|
12412
|
-
if (!sender) throw new NotFoundError(`Agent "${senderUuid}" not found`);
|
|
12413
|
-
const [target] = await db.select({ uuid: agents.uuid }).from(agents).where(and(eq(agents.organizationId, sender.organizationId), eq(agents.name, targetName), ne(agents.status, "deleted"))).limit(1);
|
|
12414
|
-
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." : ""}`);
|
|
12415
|
-
const chat = await findOrCreateDirectChat(db, senderUuid, target.uuid);
|
|
12416
|
-
const incomingMeta = data.metadata ?? {};
|
|
12417
|
-
const existingMentionsRaw = incomingMeta.mentions;
|
|
12418
|
-
const existingMentions = Array.isArray(existingMentionsRaw) ? existingMentionsRaw.filter((m) => typeof m === "string") : [];
|
|
12419
|
-
const mergedMentions = existingMentions.includes(target.uuid) ? existingMentions : [...existingMentions, target.uuid];
|
|
12420
|
-
const metadata = {
|
|
12421
|
-
...incomingMeta,
|
|
12422
|
-
mentions: mergedMentions
|
|
12423
|
-
};
|
|
12424
|
-
return sendMessage(db, chat.id, senderUuid, {
|
|
12425
|
-
format: data.format,
|
|
12426
|
-
content: data.content,
|
|
12427
|
-
metadata,
|
|
12428
|
-
replyToInbox: data.replyToInbox,
|
|
12429
|
-
replyToChat: data.replyToChat,
|
|
12430
|
-
source: data.source
|
|
12431
|
-
}, { normalizeMentionsInContent: true });
|
|
12432
|
-
}
|
|
12433
|
-
async function editMessage(db, chatId, messageId, senderId, data) {
|
|
12434
|
-
const [msg] = await db.select().from(messages).where(eq(messages.id, messageId)).limit(1);
|
|
12435
|
-
if (!msg) throw new NotFoundError(`Message "${messageId}" not found`);
|
|
12436
|
-
if (msg.chatId !== chatId) throw new NotFoundError(`Message "${messageId}" not found in this chat`);
|
|
12437
|
-
if (msg.senderId !== senderId) throw new ForbiddenError("Only the sender can edit a message");
|
|
12438
|
-
const setClause = {};
|
|
12439
|
-
if (data.format !== void 0) setClause.format = data.format;
|
|
12440
|
-
if (data.content !== void 0) setClause.content = data.content;
|
|
12441
|
-
const meta = msg.metadata ?? {};
|
|
12442
|
-
meta.editedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
12443
|
-
setClause.metadata = meta;
|
|
12444
|
-
const [updated] = await db.update(messages).set(setClause).where(eq(messages.id, messageId)).returning();
|
|
12445
|
-
if (!updated) throw new Error("Unexpected: UPDATE RETURNING produced no row");
|
|
12446
|
-
return updated;
|
|
12447
|
-
}
|
|
12448
|
-
async function listMessages(db, chatId, limit, cursor) {
|
|
12449
|
-
const where = cursor ? and(eq(messages.chatId, chatId), lt(messages.createdAt, new Date(cursor))) : eq(messages.chatId, chatId);
|
|
12450
|
-
const rows = await db.select().from(messages).where(where).orderBy(desc(messages.createdAt)).limit(limit + 1);
|
|
12451
|
-
const hasMore = rows.length > limit;
|
|
12452
|
-
const items = hasMore ? rows.slice(0, limit) : rows;
|
|
12453
|
-
const last = items[items.length - 1];
|
|
12454
|
-
return {
|
|
12455
|
-
items,
|
|
12456
|
-
nextCursor: hasMore && last ? last.createdAt.toISOString() : null
|
|
12457
|
-
};
|
|
12458
|
-
}
|
|
12459
|
-
const INBOX_CHANNEL = "inbox_notifications";
|
|
12460
|
-
const CONFIG_CHANNEL = "config_changes";
|
|
12461
|
-
const SESSION_STATE_CHANNEL = "session_state_changes";
|
|
12462
|
-
const RUNTIME_STATE_CHANNEL = "runtime_state_changes";
|
|
12463
|
-
/**
|
|
12464
|
-
* Chat-first workspace cross-process kick. Carries `<chatId>:<messageId>`.
|
|
12465
|
-
* Lets admin WS sockets translate every chat message (speaker AND watcher
|
|
12466
|
-
* audience) into a `chat:message` frame, without being coupled to the
|
|
12467
|
-
* inbox NOTIFY path that only reaches speakers.
|
|
12468
|
-
*/
|
|
12469
|
-
const CHAT_MESSAGE_CHANNEL = "chat_message_events";
|
|
12470
|
-
function createNotifier(listenClient) {
|
|
12471
|
-
const subscriptions = /* @__PURE__ */ new Map();
|
|
12472
|
-
const configChangeHandlers = [];
|
|
12473
|
-
const sessionStateChangeHandlers = [];
|
|
12474
|
-
const runtimeStateChangeHandlers = [];
|
|
12475
|
-
const chatMessageHandlers = [];
|
|
12476
|
-
let unlistenInboxFn = null;
|
|
12477
|
-
let unlistenConfigFn = null;
|
|
12478
|
-
let unlistenSessionStateFn = null;
|
|
12479
|
-
let unlistenRuntimeStateFn = null;
|
|
12480
|
-
let unlistenChatMessageFn = null;
|
|
12481
|
-
function handleNotification(payload) {
|
|
12482
|
-
const sepIdx = payload.indexOf(":");
|
|
12483
|
-
if (sepIdx === -1) return;
|
|
12484
|
-
const inboxId = payload.slice(0, sepIdx);
|
|
12485
|
-
const messageId = payload.slice(sepIdx + 1);
|
|
12486
|
-
const sockets = subscriptions.get(inboxId);
|
|
12487
|
-
if (!sockets) return;
|
|
12488
|
-
const doorbellFrame = JSON.stringify({
|
|
12489
|
-
type: "new_message",
|
|
12490
|
-
inboxId,
|
|
12491
|
-
messageId
|
|
12492
|
-
});
|
|
12493
|
-
for (const [ws, pushHandler] of sockets) {
|
|
12494
|
-
if (ws.readyState !== ws.OPEN) continue;
|
|
12495
|
-
if (pushHandler) Promise.resolve(pushHandler(messageId)).catch(() => {});
|
|
12496
|
-
else ws.send(doorbellFrame);
|
|
12497
|
-
}
|
|
12498
|
-
}
|
|
12499
|
-
return {
|
|
12500
|
-
subscribe(inboxId, ws, pushHandler) {
|
|
12501
|
-
let map = subscriptions.get(inboxId);
|
|
12502
|
-
if (!map) {
|
|
12503
|
-
map = /* @__PURE__ */ new Map();
|
|
12504
|
-
subscriptions.set(inboxId, map);
|
|
12505
|
-
}
|
|
12506
|
-
map.set(ws, pushHandler ?? null);
|
|
12507
|
-
},
|
|
12508
|
-
unsubscribe(inboxId, ws) {
|
|
12509
|
-
const map = subscriptions.get(inboxId);
|
|
12510
|
-
if (map) {
|
|
12511
|
-
map.delete(ws);
|
|
12512
|
-
if (map.size === 0) subscriptions.delete(inboxId);
|
|
12513
|
-
}
|
|
12514
|
-
},
|
|
12515
|
-
async notify(inboxId, messageId) {
|
|
12516
|
-
try {
|
|
12517
|
-
await listenClient`SELECT pg_notify(${INBOX_CHANNEL}, ${`${inboxId}:${messageId}`})`;
|
|
12518
|
-
} catch {}
|
|
12519
|
-
},
|
|
12520
|
-
async notifyConfigChange(configType) {
|
|
12521
|
-
try {
|
|
12522
|
-
await listenClient`SELECT pg_notify(${CONFIG_CHANNEL}, ${configType})`;
|
|
12523
|
-
} catch {}
|
|
12524
|
-
},
|
|
12525
|
-
async notifySessionStateChange(agentId, chatId, state, organizationId) {
|
|
12526
|
-
try {
|
|
12527
|
-
await listenClient`SELECT pg_notify(${SESSION_STATE_CHANNEL}, ${`${agentId}:${chatId}:${state}:${organizationId}`})`;
|
|
12528
|
-
} catch {}
|
|
12529
|
-
},
|
|
12530
|
-
async notifyRuntimeStateChange(agentId, state, organizationId) {
|
|
12531
|
-
try {
|
|
12532
|
-
await listenClient`SELECT pg_notify(${RUNTIME_STATE_CHANNEL}, ${`${agentId}:${state}:${organizationId}`})`;
|
|
12533
|
-
} catch {}
|
|
12534
|
-
},
|
|
12535
|
-
async notifyChatMessage(chatId, messageId) {
|
|
12536
|
-
try {
|
|
12537
|
-
await listenClient`SELECT pg_notify(${CHAT_MESSAGE_CHANNEL}, ${`${chatId}:${messageId}`})`;
|
|
12538
|
-
} catch {}
|
|
12539
|
-
},
|
|
12540
|
-
async pushFrameToInbox(inboxId, frame) {
|
|
12541
|
-
const map = subscriptions.get(inboxId);
|
|
12542
|
-
if (!map) return 0;
|
|
12543
|
-
let queued = 0;
|
|
12544
|
-
const pending = [];
|
|
12545
|
-
for (const ws of map.keys()) {
|
|
12546
|
-
if (ws.readyState !== ws.OPEN) continue;
|
|
12547
|
-
pending.push(new Promise((resolve) => {
|
|
12548
|
-
ws.send(frame, (err) => {
|
|
12549
|
-
if (!err) queued += 1;
|
|
12550
|
-
resolve();
|
|
12551
|
-
});
|
|
12552
|
-
}));
|
|
12553
|
-
}
|
|
12554
|
-
await Promise.all(pending);
|
|
12555
|
-
return queued;
|
|
12556
|
-
},
|
|
12557
|
-
onConfigChange(handler) {
|
|
12558
|
-
configChangeHandlers.push(handler);
|
|
12559
|
-
},
|
|
12560
|
-
onSessionStateChange(handler) {
|
|
12561
|
-
sessionStateChangeHandlers.push(handler);
|
|
12562
|
-
},
|
|
12563
|
-
onRuntimeStateChange(handler) {
|
|
12564
|
-
runtimeStateChangeHandlers.push(handler);
|
|
12565
|
-
},
|
|
12566
|
-
onChatMessage(handler) {
|
|
12567
|
-
chatMessageHandlers.push(handler);
|
|
12568
|
-
},
|
|
12569
|
-
async start() {
|
|
12570
|
-
unlistenInboxFn = (await listenClient.listen(INBOX_CHANNEL, (payload) => {
|
|
12571
|
-
if (payload) handleNotification(payload);
|
|
12572
|
-
})).unlisten;
|
|
12573
|
-
unlistenConfigFn = (await listenClient.listen(CONFIG_CHANNEL, (payload) => {
|
|
12574
|
-
if (payload) for (const handler of configChangeHandlers) handler(payload);
|
|
12575
|
-
})).unlisten;
|
|
12576
|
-
unlistenSessionStateFn = (await listenClient.listen(SESSION_STATE_CHANNEL, (payload) => {
|
|
12577
|
-
if (payload) {
|
|
12578
|
-
const firstSep = payload.indexOf(":");
|
|
12579
|
-
const secondSep = payload.indexOf(":", firstSep + 1);
|
|
12580
|
-
const thirdSep = payload.indexOf(":", secondSep + 1);
|
|
12581
|
-
if (firstSep > 0 && secondSep > firstSep && thirdSep > secondSep) {
|
|
12582
|
-
const agentId = payload.slice(0, firstSep);
|
|
12583
|
-
const chatId = payload.slice(firstSep + 1, secondSep);
|
|
12584
|
-
const state = payload.slice(secondSep + 1, thirdSep);
|
|
12585
|
-
const organizationId = payload.slice(thirdSep + 1);
|
|
12586
|
-
for (const handler of sessionStateChangeHandlers) handler({
|
|
12587
|
-
agentId,
|
|
12588
|
-
chatId,
|
|
12589
|
-
state,
|
|
12590
|
-
organizationId
|
|
12591
|
-
});
|
|
12592
|
-
}
|
|
12593
|
-
}
|
|
12594
|
-
})).unlisten;
|
|
12595
|
-
unlistenRuntimeStateFn = (await listenClient.listen(RUNTIME_STATE_CHANNEL, (payload) => {
|
|
12596
|
-
if (payload) {
|
|
12597
|
-
const firstSep = payload.indexOf(":");
|
|
12598
|
-
const secondSep = payload.indexOf(":", firstSep + 1);
|
|
12599
|
-
if (firstSep > 0 && secondSep > firstSep) {
|
|
12600
|
-
const agentId = payload.slice(0, firstSep);
|
|
12601
|
-
const state = payload.slice(firstSep + 1, secondSep);
|
|
12602
|
-
const organizationId = payload.slice(secondSep + 1);
|
|
12603
|
-
for (const handler of runtimeStateChangeHandlers) handler({
|
|
12604
|
-
agentId,
|
|
12605
|
-
state,
|
|
12606
|
-
organizationId
|
|
12607
|
-
});
|
|
12608
|
-
}
|
|
12609
|
-
}
|
|
12610
|
-
})).unlisten;
|
|
12611
|
-
unlistenChatMessageFn = (await listenClient.listen(CHAT_MESSAGE_CHANNEL, (payload) => {
|
|
12612
|
-
if (!payload) return;
|
|
12613
|
-
const sep = payload.indexOf(":");
|
|
12614
|
-
if (sep <= 0) return;
|
|
12615
|
-
const chatId = payload.slice(0, sep);
|
|
12616
|
-
const messageId = payload.slice(sep + 1);
|
|
12617
|
-
for (const handler of chatMessageHandlers) try {
|
|
12618
|
-
handler({
|
|
12619
|
-
chatId,
|
|
12620
|
-
messageId
|
|
12621
|
-
});
|
|
12622
|
-
} catch {}
|
|
12623
|
-
})).unlisten;
|
|
12624
|
-
},
|
|
12625
|
-
async stop() {
|
|
12626
|
-
if (unlistenInboxFn) {
|
|
12627
|
-
await unlistenInboxFn();
|
|
12628
|
-
unlistenInboxFn = null;
|
|
12629
|
-
}
|
|
12630
|
-
if (unlistenConfigFn) {
|
|
12631
|
-
await unlistenConfigFn();
|
|
12632
|
-
unlistenConfigFn = null;
|
|
12633
|
-
}
|
|
12634
|
-
if (unlistenSessionStateFn) {
|
|
12635
|
-
await unlistenSessionStateFn();
|
|
12636
|
-
unlistenSessionStateFn = null;
|
|
12637
|
-
}
|
|
12638
|
-
if (unlistenRuntimeStateFn) {
|
|
12639
|
-
await unlistenRuntimeStateFn();
|
|
12640
|
-
unlistenRuntimeStateFn = null;
|
|
12641
|
-
}
|
|
12642
|
-
if (unlistenChatMessageFn) {
|
|
12643
|
-
await unlistenChatMessageFn();
|
|
12644
|
-
unlistenChatMessageFn = null;
|
|
12645
|
-
}
|
|
12646
|
-
}
|
|
12647
|
-
};
|
|
12648
|
-
}
|
|
12649
|
-
/** Fire-and-forget: notify all recipients that a new message is available. */
|
|
12650
|
-
function notifyRecipients(notifier, recipients, messageId) {
|
|
12651
|
-
for (const inboxId of recipients) notifier.notify(inboxId, messageId).catch(() => {});
|
|
12652
|
-
}
|
|
12653
11995
|
const log$3 = createLogger$1("AgentMessages");
|
|
12654
11996
|
const editMessageSchema = z.object({
|
|
12655
11997
|
format: z.string().optional(),
|
|
@@ -15602,6 +14944,7 @@ async function transitionSessionState(db, agentId, chatId, target, from, organiz
|
|
|
15602
14944
|
totalSessions: counts?.total ?? 0,
|
|
15603
14945
|
lastSeenAt: now
|
|
15604
14946
|
}).where(eq(agentPresence.agentId, agentId));
|
|
14947
|
+
if (target === "evicted") await markSupersededByChat(tx, chatId, "chat_archived");
|
|
15605
14948
|
finalState = target;
|
|
15606
14949
|
transitioned = true;
|
|
15607
14950
|
});
|
|
@@ -16088,6 +15431,28 @@ async function chatRoutes(app) {
|
|
|
16088
15431
|
createdAt: result.message.createdAt.toISOString()
|
|
16089
15432
|
});
|
|
16090
15433
|
});
|
|
15434
|
+
/**
|
|
15435
|
+
* POST /chats/:chatId/questions/:correlationId/answer — submit an answer
|
|
15436
|
+
* to a pending agent-emitted question. Caller speaks as their human agent;
|
|
15437
|
+
* the answer is fanned out as a `format=question_answer` message back to
|
|
15438
|
+
* the original agent's inbox so the in-flight `canUseTool` callback can
|
|
15439
|
+
* resolve. Returns 409 if already answered or superseded.
|
|
15440
|
+
*/
|
|
15441
|
+
app.post("/:chatId/questions/:correlationId/answer", { config: { otelRecordBody: false } }, async (request, reply) => {
|
|
15442
|
+
const { scope } = await requireChatAccess(request, app.db);
|
|
15443
|
+
const body = submitQuestionAnswerSchema.parse(request.body);
|
|
15444
|
+
await ensureParticipant$1(app.db, request.params.chatId, scope.humanAgentId);
|
|
15445
|
+
const result = await submitAnswer(app.db, app.notifier, {
|
|
15446
|
+
correlationId: request.params.correlationId,
|
|
15447
|
+
chatId: request.params.chatId,
|
|
15448
|
+
submitterAgentId: scope.humanAgentId,
|
|
15449
|
+
answers: body.answers
|
|
15450
|
+
});
|
|
15451
|
+
return reply.status(201).send({
|
|
15452
|
+
correlationId: request.params.correlationId,
|
|
15453
|
+
messageId: result.messageId
|
|
15454
|
+
});
|
|
15455
|
+
});
|
|
16091
15456
|
/** POST /chats/:chatId/read — chat-first-workspace read cursor. Idempotent. */
|
|
16092
15457
|
app.post("/:chatId/read", async (request) => {
|
|
16093
15458
|
const { scope } = await requireChatAccess(request, app.db);
|
|
@@ -16269,6 +15634,11 @@ function applyInputDelta(namespace, current, input, encryptionKey) {
|
|
|
16269
15634
|
const inp = input;
|
|
16270
15635
|
return { webhookSecretCipher: inp.webhookSecret === void 0 ? cur.webhookSecretCipher : inp.webhookSecret === null ? void 0 : ensureEncrypted(inp.webhookSecret, encryptionKey) };
|
|
16271
15636
|
}
|
|
15637
|
+
if (namespace === "source_repos") {
|
|
15638
|
+
const cur = current;
|
|
15639
|
+
const inp = input;
|
|
15640
|
+
return { repos: inp.repos === void 0 ? cur.repos : inp.repos };
|
|
15641
|
+
}
|
|
16272
15642
|
return namespace;
|
|
16273
15643
|
}
|
|
16274
15644
|
/**
|
|
@@ -16292,6 +15662,7 @@ function toOutput(namespace, storage) {
|
|
|
16292
15662
|
webhookUrl: ""
|
|
16293
15663
|
};
|
|
16294
15664
|
}
|
|
15665
|
+
if (namespace === "source_repos") return { repos: storage.repos };
|
|
16295
15666
|
return namespace;
|
|
16296
15667
|
}
|
|
16297
15668
|
/**
|
|
@@ -16366,19 +15737,26 @@ async function deleteOrgSetting(db, orgId, namespace) {
|
|
|
16366
15737
|
await db.delete(organizationSettings).where(and(eq(organizationSettings.organizationId, orgId), eq(organizationSettings.namespace, namespace)));
|
|
16367
15738
|
}
|
|
16368
15739
|
/**
|
|
16369
|
-
* Resolve the caller's "primary org"
|
|
16370
|
-
*
|
|
16371
|
-
*
|
|
16372
|
-
* the SDK call shape doesn't have to change while the per-tenant lookup
|
|
16373
|
-
* still happens correctly.
|
|
15740
|
+
* Resolve the caller's "primary org" for user-scoped routes that
|
|
15741
|
+
* historically didn't take an `:orgId` (e.g. `/context-tree/info`,
|
|
15742
|
+
* `/context-tree/snapshot`).
|
|
16374
15743
|
*
|
|
16375
|
-
*
|
|
16376
|
-
*
|
|
16377
|
-
*
|
|
15744
|
+
* Uses the same `pickDefaultMembership` helper that `/me` uses to compute
|
|
15745
|
+
* `defaultOrganizationId` (most-recently-active membership, id desc tie-break).
|
|
15746
|
+
* That guarantees the org `/me` reports as the default is the same org these
|
|
15747
|
+
* server-internal lookups read from — earlier the two sides used opposite
|
|
15748
|
+
* orderings (`/me` desc, this fn asc), so multi-org users saw `/info`
|
|
15749
|
+
* resolve to a different (often unconfigured) org than the one Team Settings
|
|
15750
|
+
* was edited for.
|
|
15751
|
+
*
|
|
15752
|
+
* Returns `null` for users with no active membership.
|
|
16378
15753
|
*/
|
|
16379
15754
|
async function resolveUserPrimaryOrgId(db, userId) {
|
|
16380
|
-
|
|
16381
|
-
|
|
15755
|
+
return pickDefaultMembership(await db.select({
|
|
15756
|
+
id: members.id,
|
|
15757
|
+
organizationId: members.organizationId,
|
|
15758
|
+
createdAt: members.createdAt
|
|
15759
|
+
}).from(members).where(and(eq(members.userId, userId), eq(members.status, "active"))))?.organizationId ?? null;
|
|
16382
15760
|
}
|
|
16383
15761
|
async function contextTreeInfoRoutes(app) {
|
|
16384
15762
|
/**
|
|
@@ -17311,7 +16689,7 @@ function ghostNodeId(path) {
|
|
|
17311
16689
|
function toPosix(path) {
|
|
17312
16690
|
return sep === "/" ? path : path.split(sep).join("/");
|
|
17313
16691
|
}
|
|
17314
|
-
const querySchema = z.object({ window: z.enum([
|
|
16692
|
+
const querySchema$1 = z.object({ window: z.enum([
|
|
17315
16693
|
"1d",
|
|
17316
16694
|
"7d",
|
|
17317
16695
|
"30d"
|
|
@@ -17322,7 +16700,7 @@ async function contextTreeSnapshotRoutes(app) {
|
|
|
17322
16700
|
timeWindow: "1 minute",
|
|
17323
16701
|
keyGenerator: (request) => request.user?.userId ?? request.ip
|
|
17324
16702
|
} } }, async (request) => {
|
|
17325
|
-
const query = querySchema.parse(request.query);
|
|
16703
|
+
const query = querySchema$1.parse(request.query);
|
|
17326
16704
|
const { userId } = requireUser(request);
|
|
17327
16705
|
const orgId = await resolveUserPrimaryOrgId(app.db, userId);
|
|
17328
16706
|
const binding = orgId ? await getOrgContextTree(app.db, orgId) : {};
|
|
@@ -17460,7 +16838,7 @@ async function healthzRoutes(app) {
|
|
|
17460
16838
|
* `api/orgs/invitations.ts` (Class B, admin-gated).
|
|
17461
16839
|
*/
|
|
17462
16840
|
async function publicInvitationRoutes(app) {
|
|
17463
|
-
const { previewInvitation } = await import("./invitation-C299fxkP-
|
|
16841
|
+
const { previewInvitation } = await import("./invitation-C299fxkP-B89eqDos.mjs");
|
|
17464
16842
|
app.get("/:token/preview", async (request, reply) => {
|
|
17465
16843
|
if (!request.params.token) throw new UnauthorizedError("Token required");
|
|
17466
16844
|
const preview = await previewInvitation(app.db, request.params.token);
|
|
@@ -17640,7 +17018,7 @@ async function meRoutes(app) {
|
|
|
17640
17018
|
*/
|
|
17641
17019
|
app.get("/me/pinned-agents", async (request) => {
|
|
17642
17020
|
const { userId } = requireUser(request);
|
|
17643
|
-
const { listMyPinnedAgents } = await import("./client-
|
|
17021
|
+
const { listMyPinnedAgents } = await import("./client-WubcgX-W-B2bOvgJ1.mjs");
|
|
17644
17022
|
return listMyPinnedAgents(app.db, { userId });
|
|
17645
17023
|
});
|
|
17646
17024
|
/**
|
|
@@ -18114,6 +17492,34 @@ async function orgClientRoutes(app) {
|
|
|
18114
17492
|
}));
|
|
18115
17493
|
});
|
|
18116
17494
|
}
|
|
17495
|
+
const querySchema = z.object({ window: z.enum([
|
|
17496
|
+
"1d",
|
|
17497
|
+
"7d",
|
|
17498
|
+
"30d"
|
|
17499
|
+
]).optional() }).strict();
|
|
17500
|
+
async function orgContextTreeSnapshotRoutes(app) {
|
|
17501
|
+
app.get("/snapshot", { config: { rateLimit: {
|
|
17502
|
+
max: app.config.rateLimit?.contextTreeSnapshotMax ?? 6,
|
|
17503
|
+
timeWindow: "1 minute",
|
|
17504
|
+
keyGenerator: (request) => `${request.user?.userId ?? request.ip}:${orgIdParam(request.params) ?? "unknown-org"}`
|
|
17505
|
+
} } }, async (request) => {
|
|
17506
|
+
const query = querySchema.parse(request.query);
|
|
17507
|
+
const scope = await requireOrgMembership(request, app.db);
|
|
17508
|
+
const binding = await getOrgContextTree(app.db, scope.organizationId);
|
|
17509
|
+
const githubToken = contextTreeGithubTokenForRepo(binding.repo, app.config.contextTreeSync);
|
|
17510
|
+
const snapshot = await getContextTreeSnapshot({
|
|
17511
|
+
...binding,
|
|
17512
|
+
githubToken
|
|
17513
|
+
}, query.window ?? "7d");
|
|
17514
|
+
return contextTreeSnapshotSchema.parse(snapshot);
|
|
17515
|
+
});
|
|
17516
|
+
}
|
|
17517
|
+
function orgIdParam(params) {
|
|
17518
|
+
if (!params || typeof params !== "object") return null;
|
|
17519
|
+
if (!("orgId" in params)) return null;
|
|
17520
|
+
const orgId = params.orgId;
|
|
17521
|
+
return typeof orgId === "string" ? orgId : null;
|
|
17522
|
+
}
|
|
18117
17523
|
/**
|
|
18118
17524
|
* Class B — `/api/v1/orgs/:orgId` itself: read & rename the org row.
|
|
18119
17525
|
* Replaces the deleted `/admin/organizations/:id` GET/PATCH pair.
|
|
@@ -18371,14 +17777,18 @@ async function orgSessionRoutes(app) {
|
|
|
18371
17777
|
* adding a new config group only requires registering it there — no new
|
|
18372
17778
|
* route file.
|
|
18373
17779
|
*
|
|
18374
|
-
*
|
|
18375
|
-
*
|
|
18376
|
-
*
|
|
17780
|
+
* GET gating is per-namespace via `readPolicy` in the registry: namespaces
|
|
17781
|
+
* with no secret fields (`context_tree`, `source_repos`) are readable by
|
|
17782
|
+
* any active org member, so an invitee can see what tree and repos the
|
|
17783
|
+
* team is bound to before joining the chat. Namespaces whose masked output
|
|
17784
|
+
* still leaks a `…Configured` boolean (`github_integration`) stay
|
|
17785
|
+
* admin-only. PUT and DELETE are always admin-only regardless of
|
|
17786
|
+
* namespace — non-admins must never mutate org-wide config.
|
|
18377
17787
|
*/
|
|
18378
17788
|
async function orgSettingsRoutes(app) {
|
|
18379
17789
|
app.get("/:namespace", async (request) => {
|
|
18380
|
-
const scope = await requireOrgAdmin(request, app.db);
|
|
18381
17790
|
const namespace = parseNamespace(request.params.namespace);
|
|
17791
|
+
const scope = ORG_SETTINGS_NAMESPACES$1[namespace].readPolicy === "member" ? await requireOrgMembership(request, app.db) : await requireOrgAdmin(request, app.db);
|
|
18382
17792
|
return enrichOutput(namespace, await getOrgSetting(app.db, scope.organizationId, namespace), scope.organizationId, app.config.server.publicUrl);
|
|
18383
17793
|
});
|
|
18384
17794
|
app.put("/:namespace", { config: { otelRecordBody: true } }, async (request) => {
|
|
@@ -18902,18 +18312,43 @@ async function githubWebhookRoutes(app) {
|
|
|
18902
18312
|
ok: true,
|
|
18903
18313
|
event: "ping"
|
|
18904
18314
|
});
|
|
18905
|
-
|
|
18906
|
-
|
|
18907
|
-
|
|
18908
|
-
|
|
18909
|
-
|
|
18910
|
-
|
|
18911
|
-
|
|
18912
|
-
|
|
18913
|
-
|
|
18914
|
-
|
|
18915
|
-
|
|
18916
|
-
|
|
18315
|
+
const deliveryHeader = request.headers["x-github-delivery"];
|
|
18316
|
+
const deliveryId = typeof deliveryHeader === "string" && deliveryHeader.length > 0 ? deliveryHeader : null;
|
|
18317
|
+
if (deliveryId) {
|
|
18318
|
+
if (!await claimEvent(app.db, deliveryId, "github")) {
|
|
18319
|
+
log$1.info({
|
|
18320
|
+
deliveryId,
|
|
18321
|
+
eventType
|
|
18322
|
+
}, "duplicate GitHub delivery, skipping");
|
|
18323
|
+
return reply.status(200).send({
|
|
18324
|
+
ok: true,
|
|
18325
|
+
event: eventType,
|
|
18326
|
+
deduped: true
|
|
18327
|
+
});
|
|
18328
|
+
}
|
|
18329
|
+
}
|
|
18330
|
+
try {
|
|
18331
|
+
if (eventType === "issues") return await handleIssuesEvent(app, orgId, eventType, payload, reply);
|
|
18332
|
+
if (eventType === "issue_comment") return await handleIssueCommentEvent(app, orgId, eventType, payload, reply);
|
|
18333
|
+
let mentionsRouted = 0;
|
|
18334
|
+
const allowedActions = MENTION_ACTIONS[eventType];
|
|
18335
|
+
const action = isRecord(payload) && typeof payload.action === "string" ? payload.action : void 0;
|
|
18336
|
+
if (allowedActions && action && allowedActions.includes(action)) mentionsRouted = await handleMentionDelegation(app, orgId, eventType, payload);
|
|
18337
|
+
return reply.status(200).send({
|
|
18338
|
+
ok: true,
|
|
18339
|
+
event: eventType,
|
|
18340
|
+
handled: mentionsRouted > 0,
|
|
18341
|
+
mentionsRouted
|
|
18342
|
+
});
|
|
18343
|
+
} catch (err) {
|
|
18344
|
+
if (deliveryId) await unclaimEvent(app.db, deliveryId, "github").catch((unclaimErr) => {
|
|
18345
|
+
log$1.error({
|
|
18346
|
+
err: unclaimErr,
|
|
18347
|
+
deliveryId
|
|
18348
|
+
}, "failed to unclaim GitHub delivery after handler error");
|
|
18349
|
+
});
|
|
18350
|
+
throw err;
|
|
18351
|
+
}
|
|
18917
18352
|
});
|
|
18918
18353
|
}
|
|
18919
18354
|
/** Extract text body from any GitHub webhook event for @mention scanning. */
|
|
@@ -19218,6 +18653,7 @@ var schema_exports = /* @__PURE__ */ __exportAll({
|
|
|
19218
18653
|
notifications: () => notifications,
|
|
19219
18654
|
organizationSettings: () => organizationSettings,
|
|
19220
18655
|
organizations: () => organizations,
|
|
18656
|
+
pendingQuestions: () => pendingQuestions,
|
|
19221
18657
|
serverInstances: () => serverInstances,
|
|
19222
18658
|
sessionEvents: () => sessionEvents,
|
|
19223
18659
|
taskChats: () => taskChats,
|
|
@@ -20721,6 +20157,7 @@ async function buildApp(config) {
|
|
|
20721
20157
|
await scope.register(orgInvitationRoutes, { prefix: "/invitations" });
|
|
20722
20158
|
await scope.register(orgMemberRoutes, { prefix: "/members" });
|
|
20723
20159
|
await scope.register(orgSettingsRoutes, { prefix: "/settings" });
|
|
20160
|
+
await scope.register(orgContextTreeSnapshotRoutes, { prefix: "/context-tree" });
|
|
20724
20161
|
}), { prefix: "/orgs/:orgId" });
|
|
20725
20162
|
await api.register(orgWsRoutes(notifier, config.secrets.jwtSecret), { prefix: "/orgs/:orgId/ws" });
|
|
20726
20163
|
await api.register(userScope("resourcesScope", async (scope) => {
|
|
@@ -20879,7 +20316,7 @@ async function startServer(options) {
|
|
|
20879
20316
|
instanceId: `srv_${randomUUID().slice(0, 8)}`,
|
|
20880
20317
|
commandVersion: COMMAND_VERSION
|
|
20881
20318
|
};
|
|
20882
|
-
const { initTelemetry, shutdownTelemetry } = await import("./observability-
|
|
20319
|
+
const { initTelemetry, shutdownTelemetry } = await import("./observability-eLA9iNK_.mjs");
|
|
20883
20320
|
await initTelemetry(serverConfig.observability.tracing, config.instanceId);
|
|
20884
20321
|
const app = await buildApp(config);
|
|
20885
20322
|
const SHUTDOWN_FORCE_EXIT_MS = 8e3;
|