From ce2f0c1e96b44bc45ef14b5cbcd403053fc39814 Mon Sep 17 00:00:00 2001 From: Yihang Huang <180665176+PekingSpades@users.noreply.github.com> Date: Fri, 15 May 2026 05:07:00 +0000 Subject: [PATCH 1/2] chore: import feat/relay-vfs-dom-semantic-fuse from PekingSpades fork Squashed import of 4 commits ahead of PekingSpades/synapse:main: ddaae82 Harden remote agent delivery and participant handling be3a433 refactor: centralize business enum usage 628d1a7 Add relay VFS browser/CUA mount support fdf34c9 Standardize relay VFS validation flow Method: pre-formatted both peking/main and peking/feat with zai-org's prettier+gofmt style, diffed the formatted trees, then applied with git apply --3way to suppress format-only conflicts. Resolved one semantic conflict in packages/web-next/app/dashboard/settings/model-group-detail.tsx (zai-org renamed strategyLabel to a local helper; kept the new shared model-group-shared imports from the fork). Source: https://github.com/PekingSpades/synapse/tree/feat/relay-vfs-dom-semantic-fuse --- .github/workflows/relay-build.yml | 31 + .github/workflows/relay-ci.yml | 31 + AGENTS.md | 9 + package.json | 1 + .../infrastructure/database/generated/db.ts | 1 - .../src/infrastructure/database/schema.sql | 2 +- .../api/src/modules/ai/context-compiler.ts | 9 +- packages/api/src/modules/ai/index.ts | 12 +- .../api/src/modules/ai/inline-ref-resolver.ts | 53 +- packages/api/src/modules/chat/service.ts | 156 ++-- packages/api/src/modules/chat/summary-view.ts | 88 ++- .../src/modules/relationship/controller.ts | 10 +- .../api/src/modules/relationship/service.ts | 329 +++++---- .../src/modules/remote-agents/controller.ts | 43 +- .../api/src/modules/remote-agents/service.ts | 261 +++++-- packages/mobile-app/app/actors/select.tsx | 9 +- .../mobile-app/app/chat/[conversationId].tsx | 13 +- .../contacts/[contactType]/[contactId].tsx | 45 +- packages/mobile-app/app/contacts/discover.tsx | 53 +- .../mobile-app/app/contacts/group/new.tsx | 9 +- packages/mobile-app/app/contacts/requests.tsx | 11 +- .../[conversationId]/details.tsx | 36 +- packages/mobile-app/app/scan.tsx | 27 +- packages/mobile-app/app/search.tsx | 30 +- .../alphabet-indexed-entity-list.tsx | 15 +- .../components/chat-mention-picker-screen.tsx | 23 +- .../workspace-entity-picker-screen.tsx | 59 +- packages/mobile-app/src/lib/api.ts | 13 +- packages/mobile-app/src/lib/chat-data.ts | 51 +- .../src/screens/tabs/contacts-tab-screen.tsx | 32 +- packages/mobile-app/src/types/api.ts | 178 +---- .../remote-agent-daemon/src/chat-bridge.ts | 148 +++- packages/remote-agent-daemon/src/index.ts | 196 ++++- packages/shared/src/constants/enums.ts | 331 ++++++++- packages/shared/src/types/index.ts | 461 +++++++++++- packages/shared/src/utils/index.ts | 16 +- .../app/dashboard/chat/chat-member-strip.tsx | 34 +- .../dashboard/chat/chat-mentions-input.tsx | 14 +- .../chat/chat-participant-detail-dialog.tsx | 35 +- .../chat/chat-participant-hover-card.tsx | 16 +- .../app/dashboard/chat/conversation-chat.tsx | 70 +- .../app/dashboard/chat/member-utils.ts | 64 +- .../app/dashboard/chat/message-bubble.tsx | 111 ++- .../mobile-conversation-details-dialog.tsx | 14 +- .../chat/mobile-participant-picker-screen.tsx | 27 +- .../dashboard/contacts/contact-hub-client.tsx | 174 +++-- .../app/dashboard/dashboard-home-page.tsx | 2 +- .../agents/[remoteAgentId]/page.tsx | 95 ++- .../settings/model-group-browser.tsx | 121 +--- .../dashboard/settings/model-group-detail.tsx | 100 +-- .../dashboard/settings/model-group-dialog.tsx | 72 +- .../dashboard/settings/model-group-list.tsx | 135 ++-- .../dashboard/settings/model-group-shared.ts | 116 +++ .../settings/model-settings-workbench.tsx | 203 +++--- .../web-next/components/chat-composer.tsx | 72 +- packages/web-next/lib/api.ts | 317 ++------- packages/web-next/stores/chat-store.ts | 23 +- relay/Makefile | 18 +- relay/TESTING.md | 98 +++ relay/cmd/synapse-relay-mount/main.go | 108 +++ relay/cmd/synapse-relay/main.go | 5 + relay/go.mod | 1 + relay/go.sum | 2 + relay/internal/builtinmcp/chrome/server.go | 2 +- .../internal/builtinmcp/chrome/server_test.go | 27 + relay/internal/config/config.go | 2 +- relay/internal/config/config_test.go | 27 + relay/internal/relayagent/agent.go | 72 ++ relay/internal/relayagent/client.go | 34 + relay/internal/relaycontroller/controller.go | 73 ++ relay/internal/vfs/backend.go | 194 +++++ relay/internal/vfs/backend_test.go | 81 +++ relay/internal/vfs/browser.go | 649 +++++++++++++++++ relay/internal/vfs/browser_dom.go | 588 +++++++++++++++ relay/internal/vfs/browser_tree.go | 328 +++++++++ relay/internal/vfs/cua.go | 333 +++++++++ relay/internal/vfs/cua_semantic.go | 290 ++++++++ relay/internal/vfs/cua_semantic_actions.go | 107 +++ relay/internal/vfs/cua_semantic_darwin.go | 256 +++++++ relay/internal/vfs/cua_semantic_linux.go | 235 ++++++ relay/internal/vfs/cua_semantic_other.go | 13 + relay/internal/vfs/cua_semantic_windows.go | 193 +++++ relay/internal/vfs/cua_support_desktop.go | 11 + relay/internal/vfs/cua_support_stub.go | 11 + relay/internal/vfs/cua_tree.go | 295 ++++++++ relay/internal/vfs/runtime_test.go | 588 +++++++++++++++ relay/internal/vfs/service.go | 669 ++++++++++++++++++ relay/internal/vfs/service_test.go | 39 + relay/internal/vfs/types.go | 47 ++ relay/internal/vfscli/run.go | 306 ++++++++ relay/internal/vfscli/run_test.go | 34 + relay/internal/vfsmount/fs.go | 392 ++++++++++ relay/internal/vfsmount/fs_test.go | 183 +++++ relay/scripts/lib/validation-common.sh | 368 ++++++++++ .../scripts/validate-vfs-browser-headless.go | 239 +++++++ .../scripts/validate-vfs-browser-headless.sh | 54 ++ .../validate-vfs-fuse-linux-headless.sh | 128 ++++ relay/scripts/validate-vfs-linux-headless.sh | 9 + relay/scripts/validate-vfs-unit.sh | 22 + scripts/audit-business-enum-literals.mjs | 150 ++++ 100 files changed, 10212 insertions(+), 1676 deletions(-) create mode 100644 packages/web-next/app/dashboard/settings/model-group-shared.ts create mode 100644 relay/TESTING.md create mode 100644 relay/cmd/synapse-relay-mount/main.go create mode 100644 relay/internal/vfs/backend.go create mode 100644 relay/internal/vfs/backend_test.go create mode 100644 relay/internal/vfs/browser.go create mode 100644 relay/internal/vfs/browser_dom.go create mode 100644 relay/internal/vfs/browser_tree.go create mode 100644 relay/internal/vfs/cua.go create mode 100644 relay/internal/vfs/cua_semantic.go create mode 100644 relay/internal/vfs/cua_semantic_actions.go create mode 100644 relay/internal/vfs/cua_semantic_darwin.go create mode 100644 relay/internal/vfs/cua_semantic_linux.go create mode 100644 relay/internal/vfs/cua_semantic_other.go create mode 100644 relay/internal/vfs/cua_semantic_windows.go create mode 100644 relay/internal/vfs/cua_support_desktop.go create mode 100644 relay/internal/vfs/cua_support_stub.go create mode 100644 relay/internal/vfs/cua_tree.go create mode 100644 relay/internal/vfs/runtime_test.go create mode 100644 relay/internal/vfs/service.go create mode 100644 relay/internal/vfs/service_test.go create mode 100644 relay/internal/vfs/types.go create mode 100644 relay/internal/vfscli/run.go create mode 100644 relay/internal/vfscli/run_test.go create mode 100644 relay/internal/vfsmount/fs.go create mode 100644 relay/internal/vfsmount/fs_test.go create mode 100755 relay/scripts/lib/validation-common.sh create mode 100644 relay/scripts/validate-vfs-browser-headless.go create mode 100755 relay/scripts/validate-vfs-browser-headless.sh create mode 100755 relay/scripts/validate-vfs-fuse-linux-headless.sh create mode 100755 relay/scripts/validate-vfs-linux-headless.sh create mode 100755 relay/scripts/validate-vfs-unit.sh create mode 100644 scripts/audit-business-enum-literals.mjs diff --git a/.github/workflows/relay-build.yml b/.github/workflows/relay-build.yml index d3c45cd3..2a17e9ee 100644 --- a/.github/workflows/relay-build.yml +++ b/.github/workflows/relay-build.yml @@ -127,6 +127,12 @@ jobs: run: | echo "Selected CLI platforms: ${{ needs.prepare-matrix.outputs.selected_platforms }}" + - name: Install Linux FUSE build dependencies + if: matrix.goos == 'linux' && matrix.goarch == 'amd64' + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq libfuse-dev libfuse2t64 + - name: Prepare shared Node runtime run: node relay/scripts/prepare-node-bundle.mjs --target-platform=${{ matrix.goos }}-${{ matrix.goarch }} --node-version=${{ env.BUNDLED_NODE_VERSION }} @@ -146,6 +152,23 @@ jobs: go build -ldflags="-s -w -X main.Version=${{ steps.version.outputs.version }}" \ -o ../synapse-relay-${{ matrix.suffix }}${{ matrix.ext }} ./cmd/synapse-relay + - name: Run Relay VFS unit/build validation + if: matrix.goos == 'linux' && matrix.goarch == 'amd64' + working-directory: relay + run: | + bash scripts/validate-vfs-unit.sh + + - name: Build Linux mount helper + if: matrix.goos == 'linux' && matrix.goarch == 'amd64' + working-directory: relay + env: + CGO_ENABLED: "1" + GOOS: linux + GOARCH: amd64 + run: | + go build -tags 'relay_fuse,desktop_cua' -ldflags="-s -w -X main.Version=${{ steps.version.outputs.version }}" \ + -o ../synapse-relay-mount-linux-amd64 ./cmd/synapse-relay-mount + - name: Upload artifacts to Tencent COS if: startsWith(github.ref, 'refs/tags/') || env.ENABLE_COS_UPLOAD == 'true' continue-on-error: true @@ -245,6 +268,14 @@ jobs: name: synapse-relay-${{ matrix.suffix }} path: synapse-relay-${{ matrix.suffix }}${{ matrix.ext }} + - name: Upload Linux mount helper artifact + if: matrix.goos == 'linux' && matrix.goarch == 'amd64' + continue-on-error: true + uses: actions/upload-artifact@v4 + with: + name: synapse-relay-mount-linux-amd64 + path: synapse-relay-mount-linux-amd64 + release: needs: build runs-on: ubuntu-latest diff --git a/.github/workflows/relay-ci.yml b/.github/workflows/relay-ci.yml index 011190ee..18d647d9 100644 --- a/.github/workflows/relay-ci.yml +++ b/.github/workflows/relay-ci.yml @@ -220,6 +220,12 @@ jobs: run: | tar -xzf relay-runtime-bundle-${{ matrix.suffix }}.tar.gz + - name: Install Linux FUSE build dependencies + if: matrix.goos == 'linux' && matrix.goarch == 'amd64' + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq libfuse-dev libfuse2t64 + - name: Build working-directory: relay env: @@ -230,6 +236,23 @@ jobs: go build -ldflags="-s -w -X main.Version=${{ steps.version.outputs.version }}" \ -o ../synapse-relay-${{ matrix.suffix }}${{ matrix.ext }} ./cmd/synapse-relay + - name: Run Relay VFS unit/build validation + if: matrix.goos == 'linux' && matrix.goarch == 'amd64' + working-directory: relay + run: | + bash scripts/validate-vfs-unit.sh + + - name: Build Linux mount helper + if: matrix.goos == 'linux' && matrix.goarch == 'amd64' + working-directory: relay + env: + CGO_ENABLED: "1" + GOOS: linux + GOARCH: amd64 + run: | + go build -tags 'relay_fuse,desktop_cua' -ldflags="-s -w -X main.Version=${{ steps.version.outputs.version }}" \ + -o ../synapse-relay-mount-linux-amd64 ./cmd/synapse-relay-mount + - name: Upload artifacts to Tencent COS if: startsWith(github.ref, 'refs/tags/') || env.ENABLE_COS_UPLOAD == 'true' continue-on-error: true @@ -329,6 +352,14 @@ jobs: name: synapse-relay-${{ matrix.suffix }} path: synapse-relay-${{ matrix.suffix }}${{ matrix.ext }} + - name: Upload Linux mount helper artifact + if: matrix.goos == 'linux' && matrix.goarch == 'amd64' + continue-on-error: true + uses: actions/upload-artifact@v4 + with: + name: synapse-relay-mount-linux-amd64 + path: synapse-relay-mount-linux-amd64 + build-gui: needs: - prepare-gui-matrix diff --git a/AGENTS.md b/AGENTS.md index 7bc2d25b..50885c2a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,14 @@ 当前系统处于初期设计实现阶段,不要考虑兼容旧数据。 +## 业务枚举与分支判断规范 + +- 跨 `packages/api`、`packages/web-next`、`packages/mobile-app` 共享的业务枚举与协议值,统一定义在 `packages/shared`,禁止在消费端重复声明同义字符串联合或手抄枚举数组。 +- 业务分支判断禁止直接写原始业务字符串字面量,例如 `participant.type === "actor"`、`status === "pending_approval"`、`z.enum(["workspace_open", "approval_required"])` 这类写法不再允许。 +- 统一写法为 shared 常量成员比较,例如 `participant.type === CONVERSATION_PARTICIPANT_TYPE.ACTOR`,以及 `z.enum(RELATIONSHIP_ACCESS_POLICIES)`。 +- 共享业务枚举统一使用 `const object + values tuple + 派生 type` 模式;不要只写裸联合类型。 +- 数据库已有 enum 的业务值,以 `packages/shared` 为应用层真源,`packages/api/src/infrastructure/database/enum-compat.ts` 负责与 DB 生成类型做编译期对齐。 +- 纯 UI 文案、临时交互状态、展示 label、样式 key 不纳入这一条;但文案选择逻辑里涉及业务枚举时,仍必须使用 shared 常量。 + ## Backend 更新与重启 - 修改 `packages/api` 或任何会影响后端运行时行为、配置加载、数据库访问、权限逻辑的代码后,需要通过 `systemctl` 重启后端服务,不能只假设热更新或旧进程会自动生效。 diff --git a/package.json b/package.json index ef98412b..00e3bcfa 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "build:api": "npm run build -w packages/api", "build:daemon": "npm run build -w packages/remote-agent-daemon", "build:web": "npm run build -w packages/shared && npm run build -w packages/web-next", + "audit:business-enums": "node ./scripts/audit-business-enum-literals.mjs", "db:bootstrap": "npm run db:bootstrap -w packages/api", "db:codegen": "npm run db:codegen -w packages/api", "db:seed": "npm run db:seed -w packages/api", diff --git a/packages/api/src/infrastructure/database/generated/db.ts b/packages/api/src/infrastructure/database/generated/db.ts index bc691438..a2553273 100644 --- a/packages/api/src/infrastructure/database/generated/db.ts +++ b/packages/api/src/infrastructure/database/generated/db.ts @@ -574,7 +574,6 @@ export type RemoteAgentMachinesTrustStatus = | "revoked" export type RemoteAgentMessageDeliveriesStatus = - | "acked" | "completed" | "failed" | "pending" diff --git a/packages/api/src/infrastructure/database/schema.sql b/packages/api/src/infrastructure/database/schema.sql index 350bacfb..f2c2d6cd 100644 --- a/packages/api/src/infrastructure/database/schema.sql +++ b/packages/api/src/infrastructure/database/schema.sql @@ -43,7 +43,7 @@ CREATE TYPE remote_agent_machine_sessions_transport AS ENUM ('websocket'); CREATE TYPE remote_agent_bindings_status AS ENUM ('active', 'disabled', 'error'); CREATE TYPE remote_agent_bindings_runtime_state AS ENUM ('offline', 'idle', 'running', 'waiting_user_input', 'plan_drafting', 'waiting_plan_approval', 'error'); CREATE TYPE remote_agent_runs_status AS ENUM ('queued', 'running', 'completed', 'failed', 'cancelled'); -CREATE TYPE remote_agent_message_deliveries_status AS ENUM ('pending', 'acked', 'completed', 'failed'); +CREATE TYPE remote_agent_message_deliveries_status AS ENUM ('pending', 'completed', 'failed'); CREATE TYPE remote_agent_runtime_catalog_status AS ENUM ('available', 'missing_binary', 'broken_path', 'unsupported_platform', 'runtime_error'); CREATE TYPE relationship_approval_mode AS ENUM ('auto', 'manual'); CREATE TYPE relationship_request_status AS ENUM ('pending', 'approved', 'rejected'); diff --git a/packages/api/src/modules/ai/context-compiler.ts b/packages/api/src/modules/ai/context-compiler.ts index a6056b0b..e80277e9 100644 --- a/packages/api/src/modules/ai/context-compiler.ts +++ b/packages/api/src/modules/ai/context-compiler.ts @@ -5,7 +5,11 @@ import type { ProviderContextManifest, ProviderContextWindow, } from "@synapse/shared" -import { buildConversationMessageRef, textBlock } from "@synapse/shared" +import { + CONVERSATION_PARTICIPANT_TYPE, + buildConversationMessageRef, + textBlock, +} from "@synapse/shared" import type { CanonicalContextItem, ConversationEntityRef, @@ -371,7 +375,8 @@ function compileManifestMessage( const isSelf = (manifest.selfParticipantId && participant.participantId === manifest.selfParticipantId) || - (participant.type === "actor" && participant.id === manifest.selfActorId) + (participant.type === CONVERSATION_PARTICIPANT_TYPE.ACTOR && + participant.id === manifest.selfActorId) content.push( toXmlTextBlock( selfClosingXmlTag("participant", { diff --git a/packages/api/src/modules/ai/index.ts b/packages/api/src/modules/ai/index.ts index 4ed36efd..6a160736 100644 --- a/packages/api/src/modules/ai/index.ts +++ b/packages/api/src/modules/ai/index.ts @@ -23,6 +23,7 @@ import type { NormalizedMcpToolResult, } from "@synapse/shared/types" import { + CONVERSATION_PARTICIPANT_TYPE, extractText, formatMentionText, getDefaultModelEngineKind, @@ -344,9 +345,9 @@ async function loadToolResolveConversationParticipants(params: { type: "actor", id: member.actor_id, participantId: member.id, - name: member.actor_name || "Unknown actor", - title: member.actor_title || member.actor_role || "Actor", - role: member.actor_role || undefined, + name: member.participant_name || "Unknown actor", + title: member.participant_title || member.participant_role || "Actor", + role: member.participant_role || undefined, }) continue } @@ -678,7 +679,10 @@ export async function actorThink( otherParticipantCount: currentToolConversationParticipants?.filter( (participant) => - !(participant.type === "actor" && participant.id === actor.id) + !( + participant.type === CONVERSATION_PARTICIPANT_TYPE.ACTOR && + participant.id === actor.id + ) ).length || 0, }) const buildResolveCtx = (): ToolResolveContext => ({ diff --git a/packages/api/src/modules/ai/inline-ref-resolver.ts b/packages/api/src/modules/ai/inline-ref-resolver.ts index 213ac43b..78913fe3 100644 --- a/packages/api/src/modules/ai/inline-ref-resolver.ts +++ b/packages/api/src/modules/ai/inline-ref-resolver.ts @@ -1,4 +1,5 @@ import { + CONVERSATION_PARTICIPANT_TYPE, mentionBlock, textBlock, type CanonicalContentBlock, @@ -125,27 +126,41 @@ function uniqueMatch( function normalizeMentionType( value: string -): "actor" | "workspace_member" | "external" | null { +): + | typeof CONVERSATION_PARTICIPANT_TYPE.ACTOR + | typeof CONVERSATION_PARTICIPANT_TYPE.WORKSPACE_MEMBER + | typeof CONVERSATION_PARTICIPANT_TYPE.EXTERNAL + | null { const normalized = value.trim().toLowerCase() - if (normalized === "actor") return "actor" - if (normalized === "user" || normalized === "workspace_member") { - return "workspace_member" + if (normalized === CONVERSATION_PARTICIPANT_TYPE.ACTOR) { + return CONVERSATION_PARTICIPANT_TYPE.ACTOR + } + if ( + normalized === "user" || + normalized === CONVERSATION_PARTICIPANT_TYPE.WORKSPACE_MEMBER + ) { + return CONVERSATION_PARTICIPANT_TYPE.WORKSPACE_MEMBER + } + if (normalized === CONVERSATION_PARTICIPANT_TYPE.EXTERNAL) { + return CONVERSATION_PARTICIPANT_TYPE.EXTERNAL } - if (normalized === "external") return "external" return null } function matchesMentionTypeAndId( candidate: ConversationEntityRef, - participantType: "actor" | "workspace_member" | "external", + participantType: + | typeof CONVERSATION_PARTICIPANT_TYPE.ACTOR + | typeof CONVERSATION_PARTICIPANT_TYPE.WORKSPACE_MEMBER + | typeof CONVERSATION_PARTICIPANT_TYPE.EXTERNAL, id: string ): boolean { if (candidate.participantType !== participantType) return false - if (participantType === "actor") { + if (participantType === CONVERSATION_PARTICIPANT_TYPE.ACTOR) { return candidate.actorId === id || candidate.participantId === id } - if (participantType === "workspace_member") { + if (participantType === CONVERSATION_PARTICIPANT_TYPE.WORKSPACE_MEMBER) { return candidate.workspaceMemberId === id || candidate.participantId === id } return ( @@ -156,10 +171,12 @@ function matchesMentionTypeAndId( } function preferredMentionId(candidate: ConversationEntityRef): string | null { - if (candidate.participantType === "actor") { + if (candidate.participantType === CONVERSATION_PARTICIPANT_TYPE.ACTOR) { return candidate.actorId || candidate.participantId || null } - if (candidate.participantType === "workspace_member") { + if ( + candidate.participantType === CONVERSATION_PARTICIPANT_TYPE.WORKSPACE_MEMBER + ) { return candidate.workspaceMemberId || candidate.participantId || null } return ( @@ -231,7 +248,9 @@ function resolveMentionFromName( if (GENERIC_USER_KEYS.has(normalized)) { if (options.defaultUser) return { mention: options.defaultUser } const userMatches = candidates.filter( - (candidate) => candidate.participantType === "workspace_member" + (candidate) => + candidate.participantType === + CONVERSATION_PARTICIPANT_TYPE.WORKSPACE_MEMBER ) const match = uniqueMatch(userMatches) if (match) { @@ -446,9 +465,9 @@ export async function resolveInlineReferenceSegments( export function conversationParticipantEntryToEntityRef( participant: ConversationParticipantEntry ): ConversationEntityRef { - if (participant.type === "actor") { + if (participant.type === CONVERSATION_PARTICIPANT_TYPE.ACTOR) { return { - participantType: "actor", + participantType: CONVERSATION_PARTICIPANT_TYPE.ACTOR, actorId: participant.id, participantId: participant.participantId, name: participant.name, @@ -457,9 +476,9 @@ export function conversationParticipantEntryToEntityRef( } } - if (participant.type === "workspace_member") { + if (participant.type === CONVERSATION_PARTICIPANT_TYPE.WORKSPACE_MEMBER) { return { - participantType: "workspace_member", + participantType: CONVERSATION_PARTICIPANT_TYPE.WORKSPACE_MEMBER, workspaceMemberId: participant.id, participantId: participant.participantId, name: participant.name, @@ -469,7 +488,7 @@ export function conversationParticipantEntryToEntityRef( } return { - participantType: "external", + participantType: CONVERSATION_PARTICIPANT_TYPE.EXTERNAL, workspaceMemberId: participant.linkedWorkspaceMemberId, participantId: participant.participantId, externalUserKey: participant.externalUserKey, @@ -485,7 +504,7 @@ export function buildDefaultUserMention(params: { }): ConversationEntityRef | undefined { if (!params.workspaceMemberId) return undefined return { - participantType: "workspace_member", + participantType: CONVERSATION_PARTICIPANT_TYPE.WORKSPACE_MEMBER, workspaceMemberId: params.workspaceMemberId, name: params.userName || "User", } diff --git a/packages/api/src/modules/chat/service.ts b/packages/api/src/modules/chat/service.ts index 5e891906..b7fcd724 100644 --- a/packages/api/src/modules/chat/service.ts +++ b/packages/api/src/modules/chat/service.ts @@ -1,6 +1,18 @@ import { randomUUID } from "crypto" import { buildConversationMessageRef, + CONVERSATION_BOUNDARY, + CONVERSATION_ITEM_SCOPE, + CONVERSATION_ITEM_SCOPES, + CONVERSATION_ITEM_ROLE, + CONVERSATION_ITEM_ROLES, + CONVERSATION_ITEM_SURFACE, + CONVERSATION_ITEM_SURFACES, + CONVERSATION_ITEM_TYPE, + CONVERSATION_ITEM_TYPES, + CONVERSATION_KIND, + CONVERSATION_KINDS, + CONVERSATION_PARTICIPANT_TYPE, normalizeCanonicalContentBlocks, parseConversationMessageRef, extractText, @@ -22,7 +34,9 @@ import { type ChatSyncEventPayloadMap, type ChatSyncEventType, type ChatSyncResponse, + type ConversationBoundary, type ConversationMessageSubtype, + type ConversationParticipantType, type ConversationReplyRef, } from "@synapse/shared" import type { @@ -75,18 +89,12 @@ export interface ChatServiceError extends Error { details?: Record } -type ConversationKind = "group" | "private" | "virtual" -type ConversationBoundary = "internal" | "external" -type ParticipantKind = - | "workspace_member" - | "actor" - | "remote_agent" - | "external" - | "system" -type ItemScope = "shared" | "private" -type ItemSurface = "visible" | "internal" -type ItemType = "message" | "event" | "summary" | "control" -type ItemRole = "user" | "assistant" | "system" | "tool" +type ConversationKind = (typeof CONVERSATION_KINDS)[number] +type ParticipantKind = ConversationParticipantType +type ItemScope = (typeof CONVERSATION_ITEM_SCOPES)[number] +type ItemSurface = (typeof CONVERSATION_ITEM_SURFACES)[number] +type ItemType = (typeof CONVERSATION_ITEM_TYPES)[number] +type ItemRole = (typeof CONVERSATION_ITEM_ROLES)[number] type NonEventItemType = Exclude export interface ConversationItemPartInput { @@ -131,16 +139,16 @@ type ParticipantRow = { left_at: string | Date | null user_id: string | null user_name: string | null - actor_name: string | null - actor_title: string | null - actor_role: string | null + participant_name: string | null + participant_title: string | null + participant_role: string | null actor_docs: unknown actor_can_represent_user: boolean | null actor_specialties: unknown actor_config: unknown actor_current_version: number | string | null - actor_avatar_emoji: string | null - actor_avatar_file_id: string | null + participant_avatar_emoji: string | null + participant_avatar_file_id: string | null user_avatar_file_id: string | null transport_address_id: string | null transport_kind: string | null @@ -484,32 +492,38 @@ function asParticipantTransportKind( } function participantDisplayName(row: ParticipantRow): string { - if (row.participant_kind === "workspace_member") { + if (row.participant_kind === CONVERSATION_PARTICIPANT_TYPE.WORKSPACE_MEMBER) { if (typeof row.user_name === "string" && row.user_name.trim()) { return row.user_name.trim() } } - if (row.participant_kind === "actor") { - if (typeof row.actor_name === "string" && row.actor_name.trim()) { - return row.actor_name.trim() + if (row.participant_kind === CONVERSATION_PARTICIPANT_TYPE.ACTOR) { + if ( + typeof row.participant_name === "string" && + row.participant_name.trim() + ) { + return row.participant_name.trim() } } - if (row.participant_kind === "remote_agent") { - if (typeof row.actor_name === "string" && row.actor_name.trim()) { - return row.actor_name.trim() + if (row.participant_kind === CONVERSATION_PARTICIPANT_TYPE.REMOTE_AGENT) { + if ( + typeof row.participant_name === "string" && + row.participant_name.trim() + ) { + return row.participant_name.trim() } } if ( - (row.participant_kind === "external" || - row.participant_kind === "system") && + (row.participant_kind === CONVERSATION_PARTICIPANT_TYPE.EXTERNAL || + row.participant_kind === CONVERSATION_PARTICIPANT_TYPE.SYSTEM) && typeof row.transport_display_name === "string" && row.transport_display_name.trim() ) { return row.transport_display_name.trim() } if ( - (row.participant_kind === "external" || - row.participant_kind === "system") && + (row.participant_kind === CONVERSATION_PARTICIPANT_TYPE.EXTERNAL || + row.participant_kind === CONVERSATION_PARTICIPANT_TYPE.SYSTEM) && typeof row.display_name === "string" && row.display_name.trim() ) { @@ -521,25 +535,27 @@ function participantDisplayName(row: ParticipantRow): string { if (typeof row.user_name === "string" && row.user_name.trim()) { return row.user_name.trim() } - if (typeof row.actor_name === "string" && row.actor_name.trim()) { - return row.actor_name.trim() + if (typeof row.participant_name === "string" && row.participant_name.trim()) { + return row.participant_name.trim() } if (typeof row.display_name === "string" && row.display_name.trim()) { return row.display_name.trim() } - return row.participant_kind === "system" ? "System" : "Unknown" + return row.participant_kind === CONVERSATION_PARTICIPANT_TYPE.SYSTEM + ? "System" + : "Unknown" } function participantAvatarUrl(row: ParticipantRow): string | undefined { const fileId = - row.actor_avatar_file_id ?? + row.participant_avatar_file_id ?? row.user_avatar_file_id ?? row.linked_user_avatar_file_id return fileId ? getFileUrlById(fileId) : undefined } function participantAvatarEmoji(row: ParticipantRow): string | undefined { - return row.actor_avatar_emoji ?? undefined + return row.participant_avatar_emoji ?? undefined } function previewTextFromItem(item: ChatConversationItem | undefined): string { @@ -565,12 +581,13 @@ function computeConversationTitle(params: { (participant) => participant.state === "active" ) const labels = - params.kind === "private" + params.kind === CONVERSATION_KIND.PRIVATE ? active .filter( (participant) => !( - participant.participantType === "workspace_member" && + participant.participantType === + CONVERSATION_PARTICIPANT_TYPE.WORKSPACE_MEMBER && participant.workspaceMemberId === params.viewerWorkspaceMemberId ) ) @@ -579,7 +596,7 @@ function computeConversationTitle(params: { const uniqueLabels = [...new Set(labels.filter(Boolean))] if (uniqueLabels.length === 0) { - return params.kind === "private" + return params.kind === CONVERSATION_KIND.PRIVATE ? "Direct message" : "Untitled conversation" } @@ -599,34 +616,36 @@ function buildConversationPresentation(params: { (participant) => participant.state === "active" ) const peer = - params.kind === "private" + params.kind === CONVERSATION_KIND.PRIVATE ? (activeParticipants.find( (participant) => !( - participant.participantType === "workspace_member" && + participant.participantType === + CONVERSATION_PARTICIPANT_TYPE.WORKSPACE_MEMBER && participant.workspaceMemberId === params.viewerWorkspaceMemberId ) ) ?? activeParticipants[0]) : undefined const avatarParticipants = - params.kind === "private" + params.kind === CONVERSATION_KIND.PRIVATE ? peer ? [peer] : activeParticipants.slice(0, 1) : activeParticipants.slice(0, 4) - const boundaryLabel = params.boundary === "external" ? "External" : "Internal" + const boundaryLabel = + params.boundary === CONVERSATION_BOUNDARY.EXTERNAL ? "External" : "Internal" return { chatType: - params.kind === "private" + params.kind === CONVERSATION_KIND.PRIVATE ? "direct" - : params.kind === "group" + : params.kind === CONVERSATION_KIND.GROUP ? "group" : "virtual", subtitle: - params.kind === "private" + params.kind === CONVERSATION_KIND.PRIVATE ? `${boundaryLabel} direct chat` - : params.kind === "virtual" + : params.kind === CONVERSATION_KIND.VIRTUAL ? `${boundaryLabel} virtual chat` : `${boundaryLabel} group chat`, avatarParticipantIds: avatarParticipants.map( @@ -824,9 +843,9 @@ async function listConversationParticipantRows( cp.left_at, wm.user_id, u.name AS user_name, - ${actorNameExpr} AS actor_name, - ${actorTitleExpr} AS actor_title, - ${actorRoleExpr} AS actor_role, + ${actorNameExpr} AS participant_name, + ${actorTitleExpr} AS participant_title, + ${actorRoleExpr} AS participant_role, CASE WHEN ra.id IS NOT NULL THEN '[]'::jsonb ELSE COALESCE( @@ -851,8 +870,8 @@ async function listConversationParticipantRows( ${actorSpecialtiesExpr} AS actor_specialties, ${actorConfigExpr} AS actor_config, ${actorCurrentVersionExpr} AS actor_current_version, - a.avatar_emoji AS actor_avatar_emoji, - a.avatar_file_id AS actor_avatar_file_id, + a.avatar_emoji AS participant_avatar_emoji, + a.avatar_file_id AS participant_avatar_file_id, u.avatar_file_id AS user_avatar_file_id, primary_address.id AS transport_address_id, primary_address.transport_kind, @@ -932,16 +951,16 @@ async function getWorkspaceMemberConversationParticipantRow( cp.left_at, wm.user_id, u.name AS user_name, - COALESCE(ra.name, a.name) AS actor_name, - COALESCE(ra.title, a.title) AS actor_title, - COALESCE(CASE WHEN ra.id IS NOT NULL THEN 'remote_agent' END, a.role::text) AS actor_role, + COALESCE(ra.name, a.name) AS participant_name, + COALESCE(ra.title, a.title) AS participant_title, + COALESCE(CASE WHEN ra.id IS NOT NULL THEN 'remote_agent' END, a.role::text) AS participant_role, '[]'::jsonb AS actor_docs, a.can_represent_user AS actor_can_represent_user, a.specialties AS actor_specialties, a.config AS actor_config, a.current_version AS actor_current_version, - COALESCE(ra.avatar_emoji, a.avatar_emoji) AS actor_avatar_emoji, - COALESCE(ra.avatar_file_id, a.avatar_file_id) AS actor_avatar_file_id, + COALESCE(ra.avatar_emoji, a.avatar_emoji) AS participant_avatar_emoji, + COALESCE(ra.avatar_file_id, a.avatar_file_id) AS participant_avatar_file_id, u.avatar_file_id AS user_avatar_file_id, primary_address.id AS transport_address_id, primary_address.transport_kind, @@ -1232,7 +1251,8 @@ async function loadConversationViews( const viewerMembership = conversationParticipants.find( (participant) => participant.state === "active" && - participant.participant_kind === "workspace_member" && + participant.participant_kind === + CONVERSATION_PARTICIPANT_TYPE.WORKSPACE_MEMBER && participant.workspace_member_id === workspaceMemberId ) const viewerConversationRole = @@ -1965,7 +1985,8 @@ async function prepareConversationItemWrite( } ): Promise { const activeParticipants = - params.scope === "shared" && params.surface === "visible" + params.scope === CONVERSATION_ITEM_SCOPE.SHARED && + params.surface === CONVERSATION_ITEM_SURFACE.VISIBLE ? await listConversationParticipantRows( queryable, [params.conversationId], @@ -2513,7 +2534,9 @@ export async function createConversation(params: { id, params.kind, params.boundary ?? "internal", - params.boundary === "external" ? null : (params.workspaceId ?? null), + params.boundary === CONVERSATION_BOUNDARY.EXTERNAL + ? null + : (params.workspaceId ?? null), params.title?.trim() || null, params.createdByWorkspaceMemberId ?? null, JSON.stringify(params.metadata ?? {}), @@ -3257,7 +3280,10 @@ export async function createConversationItem(params: { throw new Error("Failed to hydrate conversation item") } - if (params.scope === "shared" && params.surface === "visible") { + if ( + params.scope === CONVERSATION_ITEM_SCOPE.SHARED && + params.surface === CONVERSATION_ITEM_SURFACE.VISIBLE + ) { await syncVisibleSharedItem({ queryable, workspaceId: params.workspaceId, @@ -3662,8 +3688,8 @@ function participantRowToEntityRef( transportAddressId: participant.transport_address_id ?? undefined, transportKind, name: participantDisplayName(participant), - title: participant.actor_title ?? undefined, - role: participant.actor_role ?? participant.role_key, + title: participant.participant_title ?? undefined, + role: participant.participant_role ?? participant.role_key, avatarUrl: participantAvatarUrl(participant), avatarEmoji: participantAvatarEmoji(participant), } @@ -3740,7 +3766,7 @@ function conversationItemDetailToChatItem( createdAt: item.createdAt, } - if (item.itemType === "event") { + if (item.itemType === CONVERSATION_ITEM_TYPE.EVENT) { return { ...baseItem, itemType: "event", @@ -3929,7 +3955,7 @@ export function conversationItemDetailToFeedItem( Boolean(participant) ) - if (item.itemType === "event") { + if (item.itemType === CONVERSATION_ITEM_TYPE.EVENT) { return conversationEventDetailToFeedItem(item, author, restrictedAudience) } @@ -4851,7 +4877,11 @@ export async function getChatConversationMessages(params: { const runtimeMap = await getConversationRuntimeMap([params.conversationId]) const remoteAgentIds = conversation.participants - .filter((participant) => participant.participantType === "remote_agent") + .filter( + (participant) => + participant.participantType === + CONVERSATION_PARTICIPANT_TYPE.REMOTE_AGENT + ) .map((participant) => participant.remoteAgentId) .filter((value): value is string => Boolean(value)) const runtimeByRemoteAgent: Record = {} diff --git a/packages/api/src/modules/chat/summary-view.ts b/packages/api/src/modules/chat/summary-view.ts index ddd56d2d..1b12e366 100644 --- a/packages/api/src/modules/chat/summary-view.ts +++ b/packages/api/src/modules/chat/summary-view.ts @@ -1,3 +1,8 @@ +import { + CONVERSATION_BOUNDARY, + CONVERSATION_KIND, + CONVERSATION_PARTICIPANT_TYPE, +} from "@synapse/shared" import { getFileUrlById } from "../files/service.js" import { listConversationParticipants } from "./service.js" @@ -5,15 +10,15 @@ export function mapConversationParticipant(row: any) { if (row.remote_agent_id) { return { participantId: row.id, - type: "remote_agent" as const, + participantType: CONVERSATION_PARTICIPANT_TYPE.REMOTE_AGENT, remoteAgentId: row.remote_agent_id, id: row.remote_agent_id, - name: row.actor_name || "Remote Agent", - title: row.actor_title || undefined, - role: row.actor_role || "remote_agent", - emoji: row.actor_avatar_emoji || undefined, - avatarUrl: row.actor_avatar_file_id - ? getFileUrlById(row.actor_avatar_file_id) + name: row.participant_name || "Remote Agent", + title: row.participant_title || undefined, + role: row.participant_role || "remote_agent", + avatarEmoji: row.participant_avatar_emoji || undefined, + avatarUrl: row.participant_avatar_file_id + ? getFileUrlById(row.participant_avatar_file_id) : undefined, state: row.state, } @@ -22,24 +27,24 @@ export function mapConversationParticipant(row: any) { if (row.actor_id) { return { participantId: row.id, - type: "actor" as const, + participantType: CONVERSATION_PARTICIPANT_TYPE.ACTOR, actorId: row.actor_id, id: row.actor_id, - name: row.actor_name || "Unknown", - title: row.actor_title || undefined, - role: row.actor_role || "specialist", - emoji: row.actor_avatar_emoji || undefined, - avatarUrl: row.actor_avatar_file_id - ? getFileUrlById(row.actor_avatar_file_id) + name: row.participant_name || "Unknown", + title: row.participant_title || undefined, + role: row.participant_role || "specialist", + avatarEmoji: row.participant_avatar_emoji || undefined, + avatarUrl: row.participant_avatar_file_id + ? getFileUrlById(row.participant_avatar_file_id) : undefined, state: row.state, } } - if (row.participant_kind === "external") { + if (row.participant_kind === CONVERSATION_PARTICIPANT_TYPE.EXTERNAL) { return { participantId: row.id, - type: "external" as const, + participantType: CONVERSATION_PARTICIPANT_TYPE.EXTERNAL, id: row.id, name: row.transport_display_name || @@ -51,7 +56,7 @@ export function mapConversationParticipant(row: any) { return { participantId: row.id, - type: "workspace_member" as const, + participantType: CONVERSATION_PARTICIPANT_TYPE.WORKSPACE_MEMBER, workspaceMemberId: row.workspace_member_id || undefined, id: row.workspace_member_id, name: row.user_name || "User", @@ -81,23 +86,25 @@ function buildConversationPresentation(params: { (participant) => participant.state === "active" ) const peer = - row.kind === "private" + row.kind === CONVERSATION_KIND.PRIVATE ? activeParticipants.find( (participant) => !( - participant.type === "workspace_member" && + participant.participantType === + CONVERSATION_PARTICIPANT_TYPE.WORKSPACE_MEMBER && participant.workspaceMemberId === viewerWorkspaceMemberId ) ) || activeParticipants[0] : undefined const directPeerNames = - row.kind === "private" + row.kind === CONVERSATION_KIND.PRIVATE ? activeParticipants .filter( (participant) => !( - participant.type === "workspace_member" && + participant.participantType === + CONVERSATION_PARTICIPANT_TYPE.WORKSPACE_MEMBER && participant.workspaceMemberId === viewerWorkspaceMemberId ) ) @@ -106,7 +113,7 @@ function buildConversationPresentation(params: { : [] const title = - row.kind === "private" && directPeerNames.length > 0 + row.kind === CONVERSATION_KIND.PRIVATE && directPeerNames.length > 0 ? directPeerNames.join(", ") : row.title || activeParticipants @@ -117,23 +124,27 @@ function buildConversationPresentation(params: { "Untitled thread" const chatType = - row.kind === "private" - ? "direct" - : row.kind === "group" - ? "group" - : "virtual" - const boundaryLabel = row.boundary === "external" ? "External" : "Internal" - const canRename = row.kind !== "private" && canManageConversation + row.kind === CONVERSATION_KIND.PRIVATE + ? ("direct" as const) + : row.kind === CONVERSATION_KIND.GROUP + ? ("group" as const) + : ("virtual" as const) + const boundaryLabel = + row.boundary === CONVERSATION_BOUNDARY.EXTERNAL ? "External" : "Internal" + const canRename = + row.kind !== CONVERSATION_KIND.PRIVATE && canManageConversation const canManageConversationParticipants = - row.kind !== "private" && canManageParticipants + row.kind !== CONVERSATION_KIND.PRIVATE && canManageParticipants return { chatType, title, avatarUrl: - row.kind === "private" ? peer?.avatarUrl : row.avatar_url || undefined, + row.kind === CONVERSATION_KIND.PRIVATE + ? peer?.avatarUrl + : row.avatar_url || undefined, subtitle: - row.kind === "private" + row.kind === CONVERSATION_KIND.PRIVATE ? `${boundaryLabel} direct chat` : `${boundaryLabel} group chat`, peer, @@ -155,7 +166,8 @@ export async function mapConversationSummaryView( mapConversationParticipant ) const actorParticipants = mappedParticipants.filter( - (participant: any) => participant.type === "actor" + (participant: any) => + participant.participantType === CONVERSATION_PARTICIPANT_TYPE.ACTOR ) const hasOpenLane = conversationParticipants.some( (participant: any) => @@ -174,7 +186,7 @@ export async function mapConversationSummaryView( ? viewerMembership.role_key : "member" const canManageConversation = - row.kind !== "private" && + row.kind !== CONVERSATION_KIND.PRIVATE && (viewerConversationRole === "owner" || viewerConversationRole === "admin") const canManageParticipants = canManageConversation const presentation = buildConversationPresentation({ @@ -189,14 +201,18 @@ export async function mapConversationSummaryView( id: row.id, kind: row.kind, boundary: row.boundary, - status: hasOpenLane ? "active" : "completed", + status: hasOpenLane ? ("active" as const) : ("completed" as const), transportKind: row.transport_kind || undefined, participants: mappedParticipants, + members: mappedParticipants, actorParticipants, lastMessage: row.last_message ? { content: row.last_message, - role: row.last_message_sender_type === "user" ? "user" : "assistant", + role: + row.last_message_sender_type === "user" + ? ("user" as const) + : ("assistant" as const), actorName: row.last_message_sender_name, createdAt: row.last_message_at, } diff --git a/packages/api/src/modules/relationship/controller.ts b/packages/api/src/modules/relationship/controller.ts index bd56d575..bd2fbcce 100644 --- a/packages/api/src/modules/relationship/controller.ts +++ b/packages/api/src/modules/relationship/controller.ts @@ -1,10 +1,14 @@ import type { FastifyInstance, FastifyReply } from "fastify" import { z } from "zod" +import { + CONTACT_HUB_KINDS, + RELATIONSHIP_ACCESS_POLICIES, + RELATIONSHIP_APPROVAL_MODES, +} from "@synapse/shared" import { authMiddleware } from "../../infrastructure/middleware/auth.js" import { workspaceMiddleware } from "../../infrastructure/middleware/workspace.js" import { requireRequestAction } from "../access/guards.js" import { - CONTACT_HUB_KINDS, getActorRelationshipProfile, getContactHub, getContactHubDetail, @@ -26,8 +30,8 @@ import { updateRemoteAgentRelationshipProfile, } from "./service.js" -const approvalModeSchema = z.enum(["auto", "manual"]) -const actorAccessPolicySchema = z.enum(["workspace_open", "approval_required"]) +const approvalModeSchema = z.enum(RELATIONSHIP_APPROVAL_MODES) +const actorAccessPolicySchema = z.enum(RELATIONSHIP_ACCESS_POLICIES) const contactKindSchema = z.enum(CONTACT_HUB_KINDS) const scanSchema = z.object({ diff --git a/packages/api/src/modules/relationship/service.ts b/packages/api/src/modules/relationship/service.ts index 2847cbd3..3fef5803 100644 --- a/packages/api/src/modules/relationship/service.ts +++ b/packages/api/src/modules/relationship/service.ts @@ -1,5 +1,37 @@ import { sql } from "kysely" import { v4 as uuidv4 } from "uuid" +import { + CONTACT_DIRECT_STATE, + CONTACT_HUB_KIND, + CONVERSATION_KIND, + CONVERSATION_PARTICIPANT_TYPE, + DIRECT_CONVERSATION_OPEN_STATUS, + IDENTITY_SEARCH_MATCH_STATE, + IDENTITY_SEARCH_OUTCOME, + RELATIONSHIP_ACCESS_POLICY, + RELATIONSHIP_APPROVAL_MODE, + RELATIONSHIP_PROFILE_SUBJECT_TYPE, + RELATIONSHIP_SCAN_OUTCOME, + type ActorAccessRequestListResponse, + type ContactHubDetailResponse, + type ContactHubEntryView, + type ContactHubKind, + type ContactHubResponse, + type ContactTargetType, + type DirectConversationOpenResponse, + type FriendRequestListResponse, + type IdentitySearchMatchState, + type IdentitySearchOutcome, + type IdentitySearchResponse, + type RelationshipAccessPolicy, + type RelationshipActorSummaryView, + type RelationshipApprovalMode, + type RelationshipMemberSummaryView, + type RelationshipProfileView, + type RelationshipRemoteAgentSummaryView, + type RelationshipScanResponse, + type RemoteAgentAccessRequestListResponse, +} from "@synapse/shared" import { transaction } from "../../infrastructure/database/index.js" import { db, @@ -28,32 +60,8 @@ import { } from "../chat/direct-binding.js" import { mapConversationSummaryView } from "../chat/summary-view.js" -export const CONTACT_HUB_KINDS = [ - "workspace-actor", - "workspace-remote-agent", - "workspace-member", - "friend-actor", - "friend-remote-agent", - "friend-member", -] as const - -export type ContactHubKind = (typeof CONTACT_HUB_KINDS)[number] - -type ApprovalMode = "auto" | "manual" -type AccessPolicy = "workspace_open" | "approval_required" -type RequestStatus = "pending" | "approved" | "rejected" -type ContactTargetType = "member" | "actor" | "remote_agent" -type IdentitySearchOutcome = - | "empty" - | "invalid" - | "self" - | "not_found" - | "found" -type IdentitySearchMatchState = - | "same_workspace_member" - | "friend" - | "pending_request" - | "requestable" +type ApprovalMode = RelationshipApprovalMode +type AccessPolicy = RelationshipAccessPolicy type WorkspaceSummary = { id: string @@ -61,59 +69,10 @@ type WorkspaceSummary = { slug: string } -type WorkspaceMemberSummary = { - workspace: WorkspaceSummary - workspaceMemberId: string - userId: string - name: string - email: string - avatarFileId?: string | null - trustLevel?: string -} - -type ActorSummary = { - workspace: WorkspaceSummary - actorId: string - name: string - title: string - role: string - avatarFileId?: string | null - avatarEmoji?: string | null - accessPolicy: AccessPolicy - isPublicShared: boolean -} - -type RemoteAgentSummary = { - workspace: WorkspaceSummary - remoteAgentId: string - name: string - title: string - runtimeKind: "claude_code" | "codex" - avatarFileId?: string | null - avatarEmoji?: string | null - accessPolicy: AccessPolicy - isPublicShared: boolean -} - -type ContactHubEntry = { - kind: ContactHubKind - id: string - targetType: ContactTargetType - title: string - subtitle?: string - avatarUrl?: string - avatarEmoji?: string - workspace: WorkspaceSummary - workspaceMemberId?: string - userId?: string - actorId?: string - remoteAgentId?: string - relationLabel: string - directState: { - status: "existing" | "available" | "approval_required" | "pending_approval" - conversationId?: string - } -} +type WorkspaceMemberSummary = RelationshipMemberSummaryView +type ActorSummary = RelationshipActorSummaryView +type RemoteAgentSummary = RelationshipRemoteAgentSummaryView +type ContactHubEntry = ContactHubEntryView const IDENTITY_ID_PATTERN = /^[a-z0-9](?:[a-z0-9._-]{3,31})$/ @@ -356,9 +315,9 @@ function mapMemberFriendEntry(params: { conversationId?: string }): ContactHubEntry { return { - kind: "friend-member", + kind: CONTACT_HUB_KIND.FRIEND_MEMBER, id: params.entryId, - targetType: "member", + targetType: RELATIONSHIP_PROFILE_SUBJECT_TYPE.MEMBER, title: params.peer.name || params.peer.email || "Unknown member", subtitle: `${params.peer.workspace.name} · ${params.peer.email}`, avatarUrl: params.peer.avatarFileId @@ -369,8 +328,11 @@ function mapMemberFriendEntry(params: { userId: params.peer.userId, relationLabel: "Friend", directState: params.conversationId - ? { status: "existing", conversationId: params.conversationId } - : { status: "available" }, + ? { + status: CONTACT_DIRECT_STATE.EXISTING, + conversationId: params.conversationId, + } + : { status: CONTACT_DIRECT_STATE.AVAILABLE }, } } @@ -380,9 +342,9 @@ function mapActorFriendEntry(params: { conversationId?: string }): ContactHubEntry { return { - kind: "friend-actor", + kind: CONTACT_HUB_KIND.FRIEND_ACTOR, id: params.entryId, - targetType: "actor", + targetType: RELATIONSHIP_PROFILE_SUBJECT_TYPE.ACTOR, title: params.actor.name, subtitle: `${params.actor.workspace.name} · ${params.actor.title}`, avatarUrl: params.actor.avatarFileId @@ -393,8 +355,11 @@ function mapActorFriendEntry(params: { actorId: params.actor.actorId, relationLabel: "Friend", directState: params.conversationId - ? { status: "existing", conversationId: params.conversationId } - : { status: "available" }, + ? { + status: CONTACT_DIRECT_STATE.EXISTING, + conversationId: params.conversationId, + } + : { status: CONTACT_DIRECT_STATE.AVAILABLE }, } } @@ -404,9 +369,9 @@ function mapRemoteAgentFriendEntry(params: { conversationId?: string }): ContactHubEntry { return { - kind: "friend-remote-agent", + kind: CONTACT_HUB_KIND.FRIEND_REMOTE_AGENT, id: params.entryId, - targetType: "remote_agent", + targetType: RELATIONSHIP_PROFILE_SUBJECT_TYPE.REMOTE_AGENT, title: params.remoteAgent.name, subtitle: `${params.remoteAgent.workspace.name} · ${params.remoteAgent.title}`, avatarUrl: params.remoteAgent.avatarFileId @@ -417,8 +382,11 @@ function mapRemoteAgentFriendEntry(params: { remoteAgentId: params.remoteAgent.remoteAgentId, relationLabel: "Friend", directState: params.conversationId - ? { status: "existing", conversationId: params.conversationId } - : { status: "available" }, + ? { + status: CONTACT_DIRECT_STATE.EXISTING, + conversationId: params.conversationId, + } + : { status: CONTACT_DIRECT_STATE.AVAILABLE }, } } @@ -427,9 +395,9 @@ function mapWorkspaceMemberEntry(params: { conversationId?: string }): ContactHubEntry { return { - kind: "workspace-member", + kind: CONTACT_HUB_KIND.WORKSPACE_MEMBER, id: params.member.workspaceMemberId, - targetType: "member", + targetType: RELATIONSHIP_PROFILE_SUBJECT_TYPE.MEMBER, title: params.member.name || params.member.email || "Unknown member", subtitle: `${params.member.email} · ${params.member.trustLevel || "member"}`, avatarUrl: params.member.avatarFileId @@ -440,24 +408,23 @@ function mapWorkspaceMemberEntry(params: { userId: params.member.userId, relationLabel: "Workspace member", directState: params.conversationId - ? { status: "existing", conversationId: params.conversationId } - : { status: "available" }, + ? { + status: CONTACT_DIRECT_STATE.EXISTING, + conversationId: params.conversationId, + } + : { status: CONTACT_DIRECT_STATE.AVAILABLE }, } } function mapWorkspaceActorEntry(params: { actor: ActorSummary conversationId?: string - accessState: - | "existing" - | "available" - | "approval_required" - | "pending_approval" + accessState: ContactHubEntry["directState"]["status"] }): ContactHubEntry { return { - kind: "workspace-actor", + kind: CONTACT_HUB_KIND.WORKSPACE_ACTOR, id: params.actor.actorId, - targetType: "actor", + targetType: RELATIONSHIP_PROFILE_SUBJECT_TYPE.ACTOR, title: params.actor.name, subtitle: params.actor.title, avatarUrl: params.actor.avatarFileId @@ -468,8 +435,11 @@ function mapWorkspaceActorEntry(params: { actorId: params.actor.actorId, relationLabel: "Workspace actor", directState: - params.accessState === "existing" - ? { status: "existing", conversationId: params.conversationId } + params.accessState === CONTACT_DIRECT_STATE.EXISTING + ? { + status: CONTACT_DIRECT_STATE.EXISTING, + conversationId: params.conversationId, + } : { status: params.accessState }, } } @@ -477,16 +447,12 @@ function mapWorkspaceActorEntry(params: { function mapWorkspaceRemoteAgentEntry(params: { remoteAgent: RemoteAgentSummary conversationId?: string - accessState: - | "existing" - | "available" - | "approval_required" - | "pending_approval" + accessState: ContactHubEntry["directState"]["status"] }): ContactHubEntry { return { - kind: "workspace-remote-agent", + kind: CONTACT_HUB_KIND.WORKSPACE_REMOTE_AGENT, id: params.remoteAgent.remoteAgentId, - targetType: "remote_agent", + targetType: RELATIONSHIP_PROFILE_SUBJECT_TYPE.REMOTE_AGENT, title: params.remoteAgent.name, subtitle: params.remoteAgent.title, avatarUrl: params.remoteAgent.avatarFileId @@ -497,8 +463,11 @@ function mapWorkspaceRemoteAgentEntry(params: { remoteAgentId: params.remoteAgent.remoteAgentId, relationLabel: "Workspace agent", directState: - params.accessState === "existing" - ? { status: "existing", conversationId: params.conversationId } + params.accessState === CONTACT_DIRECT_STATE.EXISTING + ? { + status: CONTACT_DIRECT_STATE.EXISTING, + conversationId: params.conversationId, + } : { status: params.accessState }, } } @@ -1018,7 +987,7 @@ async function getActorAccessState(params: { conversationId?: string pendingRequestActorIds: Set }) { - if (params.conversationId) return "existing" as const + if (params.conversationId) return CONTACT_DIRECT_STATE.EXISTING const canInvoke = await authorizeAction({ subject: await resolveWorkspaceAccessSubject( params.workspaceId, @@ -1027,13 +996,16 @@ async function getActorAccessState(params: { action: "actor.invoke", resourceId: params.actor.actorId, }) - if (canInvoke || params.actor.accessPolicy === "workspace_open") { - return "available" as const + if ( + canInvoke || + params.actor.accessPolicy === RELATIONSHIP_ACCESS_POLICY.WORKSPACE_OPEN + ) { + return CONTACT_DIRECT_STATE.AVAILABLE } if (params.pendingRequestActorIds.has(params.actor.actorId)) { - return "pending_approval" as const + return CONTACT_DIRECT_STATE.PENDING_APPROVAL } - return "approval_required" as const + return CONTACT_DIRECT_STATE.APPROVAL_REQUIRED } async function getRemoteAgentAccessState(params: { @@ -1043,7 +1015,7 @@ async function getRemoteAgentAccessState(params: { conversationId?: string pendingRequestRemoteAgentIds: Set }) { - if (params.conversationId) return "existing" as const + if (params.conversationId) return CONTACT_DIRECT_STATE.EXISTING const canInvoke = await authorizeAction({ subject: await resolveWorkspaceAccessSubject( params.workspaceId, @@ -1052,15 +1024,19 @@ async function getRemoteAgentAccessState(params: { action: "remote_agent.invoke", resourceId: params.remoteAgent.remoteAgentId, }) - if (canInvoke || params.remoteAgent.accessPolicy === "workspace_open") { - return "available" as const + if ( + canInvoke || + params.remoteAgent.accessPolicy === + RELATIONSHIP_ACCESS_POLICY.WORKSPACE_OPEN + ) { + return CONTACT_DIRECT_STATE.AVAILABLE } if ( params.pendingRequestRemoteAgentIds.has(params.remoteAgent.remoteAgentId) ) { - return "pending_approval" as const + return CONTACT_DIRECT_STATE.PENDING_APPROVAL } - return "approval_required" as const + return CONTACT_DIRECT_STATE.APPROVAL_REQUIRED } async function resolveContactReference(params: { @@ -2320,7 +2296,7 @@ export async function requestRelationshipByIdentityProfile(params: { export async function getMemberRelationshipProfile(params: { workspaceId: string userId: string -}) { +}): Promise { const viewerWorkspaceMember = await getWorkspaceMemberIdentity( params.workspaceId, params.userId @@ -2331,11 +2307,11 @@ export async function getMemberRelationshipProfile(params: { const profile = await ensureRelationshipProfile({ workspaceId: params.workspaceId, createdByWorkspaceMemberId: viewerWorkspaceMember.workspaceMemberId, - subjectType: "member", + subjectType: RELATIONSHIP_PROFILE_SUBJECT_TYPE.MEMBER, subjectWorkspaceMemberId: viewerWorkspaceMember.workspaceMemberId, }) return { - subjectType: "member" as const, + subjectType: RELATIONSHIP_PROFILE_SUBJECT_TYPE.MEMBER, approvalMode: profile.approval_mode, qrToken: profile.qr_token, qrUrl: buildRelationshipQrUrl(profile.qr_token), @@ -2350,7 +2326,7 @@ export async function updateMemberRelationshipProfile(params: { approvalMode: ApprovalMode identityId?: string identitySearchEnabled?: boolean -}) { +}): Promise { const viewerWorkspaceMember = await getWorkspaceMemberIdentity( params.workspaceId, params.userId @@ -2361,7 +2337,7 @@ export async function updateMemberRelationshipProfile(params: { const profile = await ensureRelationshipProfile({ workspaceId: params.workspaceId, createdByWorkspaceMemberId: viewerWorkspaceMember.workspaceMemberId, - subjectType: "member", + subjectType: RELATIONSHIP_PROFILE_SUBJECT_TYPE.MEMBER, subjectWorkspaceMemberId: viewerWorkspaceMember.workspaceMemberId, }) try { @@ -2386,7 +2362,7 @@ export async function updateMemberRelationshipProfile(params: { throw new Error("Failed to update relationship profile") } return { - subjectType: "member" as const, + subjectType: RELATIONSHIP_PROFILE_SUBJECT_TYPE.MEMBER, approvalMode: updated.approval_mode, qrToken: updated.qr_token, qrUrl: buildRelationshipQrUrl(updated.qr_token), @@ -2405,7 +2381,7 @@ export async function getActorRelationshipProfile(params: { workspaceId: string actorId: string userId: string -}) { +}): Promise { const actor = await getActorSummary(params.actorId) if (!actor || actor.workspace.id !== params.workspaceId) { throw new Error("Actor not found") @@ -2420,11 +2396,11 @@ export async function getActorRelationshipProfile(params: { const profile = await ensureRelationshipProfile({ workspaceId: params.workspaceId, createdByWorkspaceMemberId: viewerWorkspaceMember.workspaceMemberId, - subjectType: "actor", + subjectType: RELATIONSHIP_PROFILE_SUBJECT_TYPE.ACTOR, subjectActorId: params.actorId, }) return { - subjectType: "actor" as const, + subjectType: RELATIONSHIP_PROFILE_SUBJECT_TYPE.ACTOR, approvalMode: profile.approval_mode, qrToken: profile.qr_token, qrUrl: buildRelationshipQrUrl(profile.qr_token), @@ -2439,7 +2415,7 @@ export async function getRemoteAgentRelationshipProfile(params: { workspaceId: string remoteAgentId: string userId: string -}) { +}): Promise { const remoteAgent = await getRemoteAgentSummary(params.remoteAgentId) if (!remoteAgent || remoteAgent.workspace.id !== params.workspaceId) { throw new Error("Remote agent not found") @@ -2454,11 +2430,11 @@ export async function getRemoteAgentRelationshipProfile(params: { const profile = await ensureRelationshipProfile({ workspaceId: params.workspaceId, createdByWorkspaceMemberId: viewerWorkspaceMember.workspaceMemberId, - subjectType: "remote_agent", + subjectType: RELATIONSHIP_PROFILE_SUBJECT_TYPE.REMOTE_AGENT, subjectRemoteAgentId: params.remoteAgentId, }) return { - subjectType: "remote_agent" as const, + subjectType: RELATIONSHIP_PROFILE_SUBJECT_TYPE.REMOTE_AGENT, approvalMode: profile.approval_mode, qrToken: profile.qr_token, qrUrl: buildRelationshipQrUrl(profile.qr_token), @@ -2478,7 +2454,7 @@ export async function updateActorRelationshipProfile(params: { identitySearchEnabled?: boolean accessPolicy?: AccessPolicy isPublicShared?: boolean -}) { +}): Promise { const viewerWorkspaceMember = await getWorkspaceMemberIdentity( params.workspaceId, params.userId @@ -2489,7 +2465,7 @@ export async function updateActorRelationshipProfile(params: { const profile = await ensureRelationshipProfile({ workspaceId: params.workspaceId, createdByWorkspaceMemberId: viewerWorkspaceMember.workspaceMemberId, - subjectType: "actor", + subjectType: RELATIONSHIP_PROFILE_SUBJECT_TYPE.ACTOR, subjectActorId: params.actorId, }) @@ -2552,13 +2528,13 @@ export async function updateActorRelationshipProfile(params: { } return { - subjectType: "actor" as const, + subjectType: RELATIONSHIP_PROFILE_SUBJECT_TYPE.ACTOR, approvalMode: updatedProfile.approval_mode, qrToken: updatedProfile.qr_token, qrUrl: buildRelationshipQrUrl(updatedProfile.qr_token), identityId: updatedProfile.identity_id, identitySearchEnabled: updatedProfile.identity_search_enabled, - accessPolicy: accessPolicy || "workspace_open", + accessPolicy: accessPolicy || RELATIONSHIP_ACCESS_POLICY.WORKSPACE_OPEN, isPublicShared, } } @@ -2572,7 +2548,7 @@ export async function updateRemoteAgentRelationshipProfile(params: { identitySearchEnabled?: boolean accessPolicy?: AccessPolicy isPublicShared?: boolean -}) { +}): Promise { const viewerWorkspaceMember = await getWorkspaceMemberIdentity( params.workspaceId, params.userId @@ -2583,7 +2559,7 @@ export async function updateRemoteAgentRelationshipProfile(params: { const profile = await ensureRelationshipProfile({ workspaceId: params.workspaceId, createdByWorkspaceMemberId: viewerWorkspaceMember.workspaceMemberId, - subjectType: "remote_agent", + subjectType: RELATIONSHIP_PROFILE_SUBJECT_TYPE.REMOTE_AGENT, subjectRemoteAgentId: params.remoteAgentId, }) @@ -2649,13 +2625,13 @@ export async function updateRemoteAgentRelationshipProfile(params: { } return { - subjectType: "remote_agent" as const, + subjectType: RELATIONSHIP_PROFILE_SUBJECT_TYPE.REMOTE_AGENT, approvalMode: updatedProfile.approval_mode, qrToken: updatedProfile.qr_token, qrUrl: buildRelationshipQrUrl(updatedProfile.qr_token), identityId: updatedProfile.identity_id, identitySearchEnabled: updatedProfile.identity_search_enabled, - accessPolicy: accessPolicy || "workspace_open", + accessPolicy: accessPolicy || RELATIONSHIP_ACCESS_POLICY.WORKSPACE_OPEN, isPublicShared, } } @@ -3222,7 +3198,7 @@ export async function resolveRemoteAgentAccessRequest(params: { export async function getContactHub(params: { workspaceId: string userId: string -}) { +}): Promise { const viewerWorkspaceMember = await getWorkspaceMemberIdentity( params.workspaceId, params.userId @@ -3247,7 +3223,7 @@ export async function getContactHub(params: { }) const groups = await Promise.all( threads - .filter((thread) => thread.kind === "group") + .filter((thread) => thread.kind === CONVERSATION_KIND.GROUP) .map((thread) => mapConversationSummaryView( { @@ -3289,7 +3265,7 @@ export async function getContactHubDetail(params: { userId: string contactKind: ContactHubKind contactId: string -}) { +}): Promise { const hub = await getContactHub({ workspaceId: params.workspaceId, userId: params.userId, @@ -3310,20 +3286,23 @@ export async function getContactHubDetail(params: { if (entry.actorId) { return conversation.participants.some( (participant) => - participant.type === "actor" && participant.actorId === entry.actorId + participant.participantType === CONVERSATION_PARTICIPANT_TYPE.ACTOR && + participant.actorId === entry.actorId ) } if (entry.remoteAgentId) { return conversation.participants.some( (participant) => - participant.type === "remote_agent" && + participant.participantType === + CONVERSATION_PARTICIPANT_TYPE.REMOTE_AGENT && participant.remoteAgentId === entry.remoteAgentId ) } if (entry.workspaceMemberId) { return conversation.participants.some( (participant) => - participant.type === "workspace_member" && + participant.participantType === + CONVERSATION_PARTICIPANT_TYPE.WORKSPACE_MEMBER && participant.workspaceMemberId === entry.workspaceMemberId ) } @@ -3341,7 +3320,7 @@ export async function openDirectConversation(params: { userId: string contactKind: ContactHubKind contactId: string -}) { +}): Promise { const resolved = await resolveContactReference(params) const requesterWorkspaceMember = await getWorkspaceMemberIdentity( params.workspaceId, @@ -3351,11 +3330,11 @@ export async function openDirectConversation(params: { throw new Error("Workspace member not found") } const requesterIdentity: DirectConversationIdentity = { - kind: "member", + kind: RELATIONSHIP_PROFILE_SUBJECT_TYPE.MEMBER, workspaceMemberId: requesterWorkspaceMember.workspaceMemberId, } - if (resolved.kind === "workspace-actor" && resolved.actor) { + if (resolved.kind === CONTACT_HUB_KIND.WORKSPACE_ACTOR && resolved.actor) { const canInvoke = await authorizeAction({ subject: await resolveWorkspaceAccessSubject( params.workspaceId, @@ -3365,14 +3344,18 @@ export async function openDirectConversation(params: { resourceId: resolved.actor.actorId, }) - if (!canInvoke && resolved.actor.accessPolicy === "approval_required") { + if ( + !canInvoke && + resolved.actor.accessPolicy === + RELATIONSHIP_ACCESS_POLICY.APPROVAL_REQUIRED + ) { const profile = await ensureRelationshipProfile({ workspaceId: params.workspaceId, createdByWorkspaceMemberId: requesterWorkspaceMember.workspaceMemberId, - subjectType: "actor", + subjectType: RELATIONSHIP_PROFILE_SUBJECT_TYPE.ACTOR, subjectActorId: resolved.actor.actorId, }) - if (profile.approval_mode === "auto") { + if (profile.approval_mode === RELATIONSHIP_APPROVAL_MODE.AUTO) { await grantActorAccess({ workspaceId: params.workspaceId, actorId: resolved.actor.actorId, @@ -3389,14 +3372,17 @@ export async function openDirectConversation(params: { requesterWorkspaceMember.workspaceMemberId, }) return { - status: "pending_approval" as const, + status: DIRECT_CONVERSATION_OPEN_STATUS.PENDING_APPROVAL, requestId: accessRequest.request.id, } } } } - if (resolved.kind === "workspace-remote-agent" && resolved.remoteAgent) { + if ( + resolved.kind === CONTACT_HUB_KIND.WORKSPACE_REMOTE_AGENT && + resolved.remoteAgent + ) { const canInvoke = await authorizeAction({ subject: await resolveWorkspaceAccessSubject( params.workspaceId, @@ -3408,15 +3394,16 @@ export async function openDirectConversation(params: { if ( !canInvoke && - resolved.remoteAgent.accessPolicy === "approval_required" + resolved.remoteAgent.accessPolicy === + RELATIONSHIP_ACCESS_POLICY.APPROVAL_REQUIRED ) { const profile = await ensureRelationshipProfile({ workspaceId: params.workspaceId, createdByWorkspaceMemberId: requesterWorkspaceMember.workspaceMemberId, - subjectType: "remote_agent", + subjectType: RELATIONSHIP_PROFILE_SUBJECT_TYPE.REMOTE_AGENT, subjectRemoteAgentId: resolved.remoteAgent.remoteAgentId, }) - if (profile.approval_mode === "auto") { + if (profile.approval_mode === RELATIONSHIP_APPROVAL_MODE.AUTO) { await grantRemoteAgentAccess({ workspaceId: params.workspaceId, remoteAgentId: resolved.remoteAgent.remoteAgentId, @@ -3431,7 +3418,7 @@ export async function openDirectConversation(params: { requesterWorkspaceMember.workspaceMemberId, }) return { - status: "pending_approval" as const, + status: DIRECT_CONVERSATION_OPEN_STATUS.PENDING_APPROVAL, requestId: accessRequest.request.id, } } @@ -3444,7 +3431,7 @@ export async function openDirectConversation(params: { ) if (existingConversationId) { return { - status: "ready" as const, + status: DIRECT_CONVERSATION_OPEN_STATUS.READY, created: false, conversationId: existingConversationId, } @@ -3452,23 +3439,27 @@ export async function openDirectConversation(params: { try { const targetWorkspaceMemberId = - resolved.peerIdentity.kind === "member" + resolved.peerIdentity.kind === RELATIONSHIP_PROFILE_SUBJECT_TYPE.MEMBER ? resolved.member?.workspaceMemberId : undefined - if (resolved.peerIdentity.kind === "member" && !targetWorkspaceMemberId) { + if ( + resolved.peerIdentity.kind === RELATIONSHIP_PROFILE_SUBJECT_TYPE.MEMBER && + !targetWorkspaceMemberId + ) { throw new Error("Peer workspace membership not found") } const created = await createChatConversation({ workspaceId: params.workspaceId, userId: params.userId, clientRequestId: uuidv4(), - kind: "private", + kind: CONVERSATION_KIND.PRIVATE, actorIds: - resolved.peerIdentity.kind === "actor" + resolved.peerIdentity.kind === RELATIONSHIP_PROFILE_SUBJECT_TYPE.ACTOR ? [resolved.peerIdentity.actorId] : [], remoteAgentIds: - resolved.peerIdentity.kind === "remote_agent" + resolved.peerIdentity.kind === + RELATIONSHIP_PROFILE_SUBJECT_TYPE.REMOTE_AGENT ? [resolved.peerIdentity.remoteAgentId] : [], workspaceMemberIds: targetWorkspaceMemberId @@ -3490,7 +3481,7 @@ export async function openDirectConversation(params: { .execute() return { - status: "ready" as const, + status: DIRECT_CONVERSATION_OPEN_STATUS.READY, created: true, conversationId: created.conversation.conversationId as string, } @@ -3504,7 +3495,7 @@ export async function openDirectConversation(params: { ) if (!retryConversationId) throw error return { - status: "ready" as const, + status: DIRECT_CONVERSATION_OPEN_STATUS.READY, created: false, conversationId: retryConversationId, } diff --git a/packages/api/src/modules/remote-agents/controller.ts b/packages/api/src/modules/remote-agents/controller.ts index e3802877..eeea131c 100644 --- a/packages/api/src/modules/remote-agents/controller.ts +++ b/packages/api/src/modules/remote-agents/controller.ts @@ -1,12 +1,16 @@ import type { FastifyInstance, FastifyReply } from "fastify" import { z } from "zod" +import { + RELATIONSHIP_ACCESS_POLICIES, + REMOTE_AGENT_RUNTIME_KINDS, +} from "@synapse/shared" import { authMiddleware } from "../../infrastructure/middleware/auth.js" import { workspaceMiddleware } from "../../infrastructure/middleware/workspace.js" import { requireRequestAction } from "../access/guards.js" import { - ackRemoteAgentDeliveries, bindRemoteAgent, checkRemoteAgentMessages, + completeRemoteAgentDeliveries, createRemoteAgent, createRemoteAgentPlanApprovalInteraction, createRemoteAgentMachinePairingSession, @@ -26,8 +30,8 @@ import { updateRemoteAgent, } from "./service.js" -const runtimeKindSchema = z.enum(["claude_code", "codex"]) -const accessPolicySchema = z.enum(["workspace_open", "approval_required"]) +const runtimeKindSchema = z.enum(REMOTE_AGENT_RUNTIME_KINDS) +const accessPolicySchema = z.enum(RELATIONSHIP_ACCESS_POLICIES) const createRemoteAgentSchema = z.object({ name: z.string().trim().min(1).max(255), @@ -88,8 +92,7 @@ const sendMessageSchema = z.object({ metadata: z.record(z.any()).optional(), }) -const ackDeliveriesSchema = z.object({ - remoteAgentId: z.string().uuid(), +const completeDeliveriesSchema = z.object({ deliveryIds: z.array(z.string().uuid()).min(1), }) @@ -507,21 +510,25 @@ export default async function remoteAgentsController(app: FastifyInstance) { ) app.post<{ + Params: { remoteAgentId: string } Body: unknown - }>(`${prefix}/remote-agents/ack-deliveries`, async (request, reply) => { - try { - const body = ackDeliveriesSchema.parse(request.body) - return reply.send( - await ackRemoteAgentDeliveries({ - remoteAgentId: body.remoteAgentId, - machineKey: getMachineKeyFromHeaders(request), - deliveryIds: body.deliveryIds, - }) - ) - } catch (error) { - return sendServiceError(reply, error) + }>( + `${prefix}/remote-agents/:remoteAgentId/complete-deliveries`, + async (request, reply) => { + try { + const body = completeDeliveriesSchema.parse(request.body) + return reply.send( + await completeRemoteAgentDeliveries({ + remoteAgentId: request.params.remoteAgentId, + machineKey: getMachineKeyFromHeaders(request), + deliveryIds: body.deliveryIds, + }) + ) + } catch (error) { + return sendServiceError(reply, error) + } } - }) + ) app.get<{ Params: { remoteAgentId: string; conversationId: string } diff --git a/packages/api/src/modules/remote-agents/service.ts b/packages/api/src/modules/remote-agents/service.ts index e29d1873..447eeabb 100644 --- a/packages/api/src/modules/remote-agents/service.ts +++ b/packages/api/src/modules/remote-agents/service.ts @@ -1,5 +1,25 @@ import crypto, { createHash, randomBytes } from "node:crypto" import type { FastifyInstance } from "fastify" +import { + RELATIONSHIP_ACCESS_POLICY, + REMOTE_AGENT_MACHINE_LIFECYCLE_STATE, + REMOTE_AGENT_MACHINE_TRUST_STATUS, + REMOTE_AGENT_RUNTIME_CATALOG_STATUS, + REMOTE_AGENT_RUNTIME_STATE, + type RemoteAgentAccessPolicy, + type RemoteAgentLifecycleState, + type RemoteAgentMachineDetailView, + type RemoteAgentMachinePairingSessionView, + type RemoteAgentMachineTrustStatus, + type RemoteAgentMachineView, + type RemoteAgentRuntimeCapabilityView, + type RemoteAgentRuntimeCatalogEntryView, + type RemoteAgentRuntimeKind, + type RemoteAgentRuntimeStateType, + type RemoteAgentRuntimeSummaryView, + type RemoteAgentRuntimeState, + type RemoteAgentView, +} from "@synapse/shared" import { config } from "../../config/index.js" import { transaction } from "../../infrastructure/database/index.js" import { @@ -17,24 +37,7 @@ import { import { getFileUrlById } from "../files/service.js" import { requireWorkspaceMemberIdentity } from "../chat/workspace-identity.js" -type RemoteAgentRuntimeKind = "claude_code" | "codex" -type RemoteAgentAccessPolicy = "workspace_open" | "approval_required" -type RemoteAgentRuntimeState = - | "offline" - | "idle" - | "running" - | "waiting_user_input" - | "plan_drafting" - | "waiting_plan_approval" - | "error" - -type RuntimeCapabilities = { - supportsRequestUserInput?: boolean - supportsPlanMode?: boolean - supportsPersistentSession?: boolean - supportsCodexAppServer?: boolean - supportsStructuredIo?: boolean -} +type RuntimeCapabilities = RemoteAgentRuntimeCapabilityView type MachineConnection = { machineId: string @@ -47,12 +50,7 @@ type MachineConnection = { type RuntimeCatalogEntry = { runtimeKind: RemoteAgentRuntimeKind executablePath?: string - status: - | "available" - | "missing_binary" - | "broken_path" - | "unsupported_platform" - | "runtime_error" + status: (typeof REMOTE_AGENT_RUNTIME_CATALOG_STATUS)[keyof typeof REMOTE_AGENT_RUNTIME_CATALOG_STATUS] version?: string metadata?: Record lastError?: string @@ -61,7 +59,7 @@ type RuntimeCatalogEntry = { type RuntimeStatusMessage = { type: "agent:status" remoteAgentId: string - state: RemoteAgentRuntimeState + state: RemoteAgentRuntimeStateType statusText?: string conversationId?: string | null interactionId?: string | null @@ -82,6 +80,8 @@ type DeliveryRow = { } const machineConnections = new Map() +const deliveryInFlightByMachine = new Map>() +const DELIVERY_IN_FLIGHT_TTL_MS = 5_000 function toIso(value: string | Date | null | undefined) { if (typeof value === "string") return value @@ -109,6 +109,56 @@ function safeSend(connection: MachineConnection, payload: unknown) { } } +function pruneInFlightDeliveryMap(machineId: string, now = Date.now()) { + const current = deliveryInFlightByMachine.get(machineId) + if (!current) { + return new Map() + } + for (const [deliveryId, expiresAt] of current) { + if (expiresAt <= now) { + current.delete(deliveryId) + } + } + if (current.size === 0) { + deliveryInFlightByMachine.delete(machineId) + return new Map() + } + return current +} + +function markInFlightDeliveries( + machineId: string, + deliveryIds: string[], + now = Date.now() +) { + if (deliveryIds.length === 0) { + return + } + const current = pruneInFlightDeliveryMap(machineId, now) + for (const deliveryId of deliveryIds) { + current.set(deliveryId, now + DELIVERY_IN_FLIGHT_TTL_MS) + } + if (current.size > 0) { + deliveryInFlightByMachine.set(machineId, current) + } +} + +function clearInFlightDeliveries(machineId: string, deliveryIds: string[]) { + if (deliveryIds.length === 0) { + return + } + const current = deliveryInFlightByMachine.get(machineId) + if (!current) { + return + } + for (const deliveryId of deliveryIds) { + current.delete(deliveryId) + } + if (current.size === 0) { + deliveryInFlightByMachine.delete(machineId) + } +} + async function loadMachineByApiKey(apiKey: string) { const result = await executeSql<{ id: string @@ -155,7 +205,7 @@ async function loadBoundRemoteAgentsForMachine(machineId: string) { async function setMachineLifecycleState( machineId: string, - state: "online" | "offline", + state: RemoteAgentLifecycleState, queryable: Queryable = { query: (text, params) => executeSql(text, params), } @@ -243,6 +293,65 @@ async function startBoundRemoteAgents(machineId: string) { machineId, remoteAgentIds: bindings.map((binding) => binding.remote_agent_id), }) + await replayResolvedRemoteAgentInteractions({ + machineId, + remoteAgentIds: bindings.map((binding) => binding.remote_agent_id), + }) + } +} + +async function replayResolvedRemoteAgentInteractions(params: { + machineId: string + remoteAgentIds: string[] +}) { + if (params.remoteAgentIds.length === 0) { + return + } + const connection = machineConnections.get(params.machineId) + if (!connection) { + return + } + + const rows = await executeSql<{ + remote_agent_id: string + active_interaction_id: string + status: string + }>( + ` + SELECT + binding.remote_agent_id, + binding.active_interaction_id, + interaction.status + FROM remote_agent_bindings binding + INNER JOIN interaction_requests interaction + ON interaction.id = binding.active_interaction_id + WHERE binding.machine_id = $1 + AND binding.remote_agent_id = ANY($2::uuid[]) + AND binding.active_interaction_id IS NOT NULL + AND interaction.status <> 'pending' + `, + [params.machineId, params.remoteAgentIds] + ) + + if (rows.rows.length === 0) { + return + } + + const { getInteractionRequestSummary } = + await import("../interactions/service.js") + for (const row of rows.rows) { + const interaction = await getInteractionRequestSummary( + row.active_interaction_id + ) + if (!interaction) { + continue + } + safeSend(connection, { + type: "agent:interaction:resolved", + remoteAgentId: row.remote_agent_id, + interactionId: row.active_interaction_id, + interaction, + }) } } @@ -254,11 +363,11 @@ async function updateRemoteAgentRuntimeStatus( } ) { const runStatus = - message.state === "offline" + message.state === REMOTE_AGENT_RUNTIME_STATE.OFFLINE ? "cancelled" - : message.state === "error" + : message.state === REMOTE_AGENT_RUNTIME_STATE.ERROR ? "failed" - : message.state === "idle" + : message.state === REMOTE_AGENT_RUNTIME_STATE.IDLE ? "completed" : "running" const runId = @@ -417,10 +526,25 @@ async function notifyPendingRemoteAgentDeliveries(params: { for (const [machineId, deliveries] of grouped) { const connection = machineConnections.get(machineId) if (!connection) continue - safeSend(connection, { + const now = Date.now() + const inFlight = pruneInFlightDeliveryMap(machineId, now) + const pendingForSend = deliveries.filter( + (delivery) => !inFlight.has(delivery.deliveryId) + ) + if (pendingForSend.length === 0) { + continue + } + const sent = safeSend(connection, { type: "agent:deliver", - deliveries, + deliveries: pendingForSend, }) + if (sent) { + markInFlightDeliveries( + machineId, + pendingForSend.map((delivery) => delivery.deliveryId), + now + ) + } } } @@ -450,7 +574,10 @@ async function authenticateMachineForRemoteAgent(params: { throw new Error("Machine key is required") } const machine = await loadMachineByApiKey(params.machineKey) - if (!machine || machine.trust_status !== "active") { + if ( + !machine || + machine.trust_status !== REMOTE_AGENT_MACHINE_TRUST_STATUS.ACTIVE + ) { throw new Error("Machine authentication failed") } @@ -510,7 +637,7 @@ async function loadConversationHostWorkspaceId( function mapRuntimeSummaryFromRow(row: { runtime_kind: RemoteAgentRuntimeKind - runtime_state?: RemoteAgentRuntimeState | null + runtime_state?: RemoteAgentRuntimeStateType | null status_text?: string | null last_session_id?: string | null active_conversation_id?: string | null @@ -522,10 +649,10 @@ function mapRuntimeSummaryFromRow(row: { capabilities?: unknown pending_conversation_count?: string | number | null unread_delivery_count?: string | number | null -}) { +}): RemoteAgentRuntimeSummaryView { return { runtimeKind: row.runtime_kind, - state: row.runtime_state ?? "offline", + state: row.runtime_state ?? REMOTE_AGENT_RUNTIME_STATE.OFFLINE, statusText: row.status_text ?? undefined, sessionId: row.last_session_id ?? undefined, activeConversationId: row.active_conversation_id ?? undefined, @@ -649,7 +776,7 @@ export async function loadRemoteAgentRuntimeSnapshot( const result = await executeSqlOn<{ remote_agent_id: string runtime_kind: RemoteAgentRuntimeKind - runtime_state: RemoteAgentRuntimeState + runtime_state: RemoteAgentRuntimeStateType status_text: string | null active_conversation_id: string | null active_interaction_id: string | null @@ -806,7 +933,7 @@ async function mapRemoteAgentRow(row: { runtime_path?: string | null local_root_path?: string | null machine_lifecycle_state?: string | null - runtime_state?: RemoteAgentRuntimeState | null + runtime_state?: RemoteAgentRuntimeStateType | null status_text?: string | null last_session_id?: string | null active_conversation_id?: string | null @@ -1011,7 +1138,7 @@ export async function createRemoteAgent(params: { params.runtimeKind, params.avatarFileId ?? null, params.avatarEmoji ?? null, - params.accessPolicy ?? "workspace_open", + params.accessPolicy ?? RELATIONSHIP_ACCESS_POLICY.WORKSPACE_OPEN, params.isPublicShared === true, JSON.stringify(params.metadata ?? {}), identity.workspaceMemberId, @@ -1341,12 +1468,7 @@ export async function bindRemoteAgent(params: { const catalogResult = await executeSql<{ executable_path: string | null - status: - | "available" - | "missing_binary" - | "broken_path" - | "unsupported_platform" - | "runtime_error" + status: RemoteAgentRuntimeCatalogEntryView["status"] last_error: string | null }>( ` @@ -1364,7 +1486,7 @@ export async function bindRemoteAgent(params: { if (!catalogEntry) { throw new Error("Machine has not reported runtime availability yet") } - if (catalogEntry.status !== "available") { + if (catalogEntry.status !== REMOTE_AGENT_RUNTIME_CATALOG_STATUS.AVAILABLE) { const detail = catalogEntry.last_error?.trim() ? `: ${catalogEntry.last_error.trim()}` : "" @@ -1569,7 +1691,7 @@ export async function createRemoteAgentUserInputInteraction(params: { await updateRemoteAgentRuntimeStatus(access.machineId, { type: "agent:status", remoteAgentId: params.remoteAgentId, - state: "waiting_user_input", + state: REMOTE_AGENT_RUNTIME_STATE.WAITING_USER_INPUT, statusText: params.title, conversationId: params.conversationId, interactionId: interaction.id, @@ -1624,7 +1746,7 @@ export async function createRemoteAgentPlanApprovalInteraction(params: { await updateRemoteAgentRuntimeStatus(access.machineId, { type: "agent:status", remoteAgentId: params.remoteAgentId, - state: "waiting_plan_approval", + state: REMOTE_AGENT_RUNTIME_STATE.WAITING_PLAN_APPROVAL, statusText: params.title, conversationId: params.conversationId, interactionId: interaction.id, @@ -1802,15 +1924,15 @@ export async function checkRemoteAgentMessages(params: { } } -export async function ackRemoteAgentDeliveries(params: { +export async function completeRemoteAgentDeliveries(params: { remoteAgentId: string machineKey: string deliveryIds: string[] }) { - await authenticateMachineForRemoteAgent(params) + const machine = await authenticateMachineForRemoteAgent(params) const uniqueIds = [...new Set(params.deliveryIds.filter(Boolean))] if (uniqueIds.length === 0) { - return { acked: 0 } + return { completed: 0 } } const rows = await executeSql<{ @@ -1836,7 +1958,7 @@ export async function ackRemoteAgentDeliveries(params: { await executeSql( ` UPDATE remote_agent_message_deliveries - SET status = 'acked', + SET status = 'completed', last_acked_at = NOW(), updated_at = NOW() WHERE remote_agent_id = $1 @@ -1844,6 +1966,10 @@ export async function ackRemoteAgentDeliveries(params: { `, [params.remoteAgentId, uniqueIds] ) + clearInFlightDeliveries( + machine.machineId, + rows.rows.map((row) => row.id) + ) const byConversation = new Map() for (const row of rows.rows) { @@ -1892,7 +2018,7 @@ export async function ackRemoteAgentDeliveries(params: { } return { - acked: uniqueIds.length, + completed: rows.rows.length, } } @@ -2090,6 +2216,7 @@ async function finalizeMachineSession( closeReason?: string ) { machineConnections.delete(connection.machineId) + deliveryInFlightByMachine.delete(connection.machineId) await executeSql( ` UPDATE remote_agent_machine_sessions @@ -2101,7 +2228,10 @@ async function finalizeMachineSession( `, [connection.sessionId, closeReason ?? null] ) - await setMachineLifecycleState(connection.machineId, "offline") + await setMachineLifecycleState( + connection.machineId, + REMOTE_AGENT_MACHINE_LIFECYCLE_STATE.OFFLINE + ) const bindings = await loadBoundRemoteAgentsForMachine(connection.machineId) for (const binding of bindings) { await executeSql( @@ -2110,7 +2240,6 @@ async function finalizeMachineSession( SET runtime_state = 'offline', status_text = $2, active_conversation_id = NULL, - active_interaction_id = NULL, last_run_finished_at = NOW(), updated_at = NOW() WHERE remote_agent_id = $1 @@ -2129,7 +2258,10 @@ export async function handleRemoteAgentDaemonConnection( const requestUrl = new URL(req.url, config.app.baseUrl) const apiKey = requestUrl.searchParams.get("key")?.trim() || "" const machine = await loadMachineByApiKey(apiKey) - if (!machine || machine.trust_status !== "active") { + if ( + !machine || + machine.trust_status !== REMOTE_AGENT_MACHINE_TRUST_STATUS.ACTIVE + ) { try { socket.send( JSON.stringify({ @@ -2171,7 +2303,10 @@ export async function handleRemoteAgentDaemonConnection( ready: false, } machineConnections.set(machine.id, connection) - await setMachineLifecycleState(machine.id, "online") + await setMachineLifecycleState( + machine.id, + REMOTE_AGENT_MACHINE_LIFECYCLE_STATE.ONLINE + ) try { socket.send( @@ -2231,20 +2366,6 @@ export async function handleRemoteAgentDaemonConnection( return } - if ( - message?.type === "agent:deliver:ack" && - Array.isArray(message.deliveryIds) - ) { - await ackRemoteAgentDeliveries({ - remoteAgentId: String(message.remoteAgentId || ""), - machineKey: apiKey, - deliveryIds: message.deliveryIds.filter( - (value: unknown): value is string => typeof value === "string" - ), - }) - return - } - if ( message?.type === "agent:session" && typeof message.remoteAgentId === "string" diff --git a/packages/mobile-app/app/actors/select.tsx b/packages/mobile-app/app/actors/select.tsx index 91b53ba5..a5982757 100644 --- a/packages/mobile-app/app/actors/select.tsx +++ b/packages/mobile-app/app/actors/select.tsx @@ -1,5 +1,10 @@ -import { WorkspaceEntityPickerScreen } from "@/components/workspace-entity-picker-screen" +import { + WorkspaceEntityPickerScreen, + WORKSPACE_ENTITY_PICKER_MODE, +} from "@/components/workspace-entity-picker-screen" export default function ActorSelectScreen() { - return + return ( + + ) } diff --git a/packages/mobile-app/app/chat/[conversationId].tsx b/packages/mobile-app/app/chat/[conversationId].tsx index 5059f979..d00d5e90 100644 --- a/packages/mobile-app/app/chat/[conversationId].tsx +++ b/packages/mobile-app/app/chat/[conversationId].tsx @@ -30,6 +30,8 @@ import { useChat } from "@/providers/chat-provider" import { useWorkspace } from "@/providers/workspace-provider" import { theme } from "@/theme/tokens" import { + CONVERSATION_KIND, + CONVERSATION_PARTICIPANT_TYPE, getActorRuntimePriority, isActorRuntimeActive, isActorRuntimeProcessingWorkspaceMember, @@ -92,9 +94,10 @@ export default function ChatDetailScreen() { ? getConversationDisplayName(conversation, workspaceMemberId) : "聊天" const directActorParticipant = - conversation?.kind === "private" + conversation?.kind === CONVERSATION_KIND.PRIVATE ? (conversation.participants.find( - (participant) => participant.participantType === "actor" + (participant) => + participant.participantType === CONVERSATION_PARTICIPANT_TYPE.ACTOR ) ?? null) : null const directActorRuntime = directActorParticipant?.actorId @@ -391,7 +394,8 @@ export default function ChatDetailScreen() { runtime={runtime} participant={conversation.participants.find( (participant) => - participant.participantType === "actor" && + participant.participantType === + CONVERSATION_PARTICIPANT_TYPE.ACTOR && participant.actorId === runtime.actorId )} /> @@ -427,7 +431,8 @@ export default function ChatDetailScreen() { runtime={runtime} participant={conversation.participants.find( (participant) => - participant.participantType === "actor" && + participant.participantType === + CONVERSATION_PARTICIPANT_TYPE.ACTOR && participant.actorId === runtime.actorId )} /> diff --git a/packages/mobile-app/app/contacts/[contactType]/[contactId].tsx b/packages/mobile-app/app/contacts/[contactType]/[contactId].tsx index edc37747..f2f7dbc4 100644 --- a/packages/mobile-app/app/contacts/[contactType]/[contactId].tsx +++ b/packages/mobile-app/app/contacts/[contactType]/[contactId].tsx @@ -1,6 +1,11 @@ import Feather from "@expo/vector-icons/Feather" import { useLocalSearchParams, useRouter } from "expo-router" import { useEffect, useState } from "react" +import { + CONTACT_DIRECT_STATE, + CONTACT_TARGET_TYPE, + DIRECT_CONVERSATION_OPEN_STATUS, +} from "@shared" import { Pressable, StyleSheet, Text, View } from "react-native" import { @@ -18,19 +23,15 @@ import { useWorkspace } from "@/providers/workspace-provider" import { theme } from "@/theme/tokens" import type { ContactHubDetailResponse, ContactHubEntryView } from "@/types/api" -type ContactType = - | "workspace-actor" - | "workspace-member" - | "friend-actor" - | "friend-member" +type ContactType = ContactHubEntryView["kind"] function directButtonLabel(entry: ContactHubEntryView) { switch (entry.directState.status) { - case "existing": + case CONTACT_DIRECT_STATE.EXISTING: return "进入已有私聊" - case "pending_approval": + case CONTACT_DIRECT_STATE.PENDING_APPROVAL: return "等待批准" - case "approval_required": + case CONTACT_DIRECT_STATE.APPROVAL_REQUIRED: return "申请访问并发起私聊" default: return "发起私聊" @@ -84,7 +85,7 @@ export default function ContactDetailScreen() { if (!workspaceId || !detail?.contact || submitting) return if ( - detail.contact.directState.status === "existing" && + detail.contact.directState.status === CONTACT_DIRECT_STATE.EXISTING && detail.contact.directState.conversationId ) { router.push(`/chat/${detail.contact.directState.conversationId}`) @@ -99,7 +100,7 @@ export default function ContactDetailScreen() { contactId: detail.contact.id, }) - if (result.status === "pending_approval") { + if (result.status === DIRECT_CONVERSATION_OPEN_STATUS.PENDING_APPROVAL) { setActionMessage("已提交申请,等待对方批准后才能发起私聊。") return } @@ -156,7 +157,14 @@ export default function ContactDetailScreen() { name={detail.contact.title} uri={detail.contact.avatarUrl} size={68} - icon={detail.contact.targetType === "actor" ? "cpu" : "user"} + icon={ + detail.contact.targetType === CONTACT_TARGET_TYPE.ACTOR + ? "cpu" + : detail.contact.targetType === + CONTACT_TARGET_TYPE.REMOTE_AGENT + ? "terminal" + : "user" + } /> {detail.contact.title} @@ -183,7 +191,12 @@ export default function ContactDetailScreen() { 类型 - {detail.contact.targetType === "actor" ? "Actor" : "成员"} + {detail.contact.targetType === CONTACT_TARGET_TYPE.ACTOR + ? "Actor" + : detail.contact.targetType === + CONTACT_TARGET_TYPE.REMOTE_AGENT + ? "Remote agent" + : "成员"} @@ -195,12 +208,14 @@ export default function ContactDetailScreen() { 私聊状态 - {detail.contact.directState.status === "existing" + {detail.contact.directState.status === + CONTACT_DIRECT_STATE.EXISTING ? "已有单聊" - : detail.contact.directState.status === "pending_approval" + : detail.contact.directState.status === + CONTACT_DIRECT_STATE.PENDING_APPROVAL ? "等待批准" : detail.contact.directState.status === - "approval_required" + CONTACT_DIRECT_STATE.APPROVAL_REQUIRED ? "需要申请" : "可直接发起"} diff --git a/packages/mobile-app/app/contacts/discover.tsx b/packages/mobile-app/app/contacts/discover.tsx index 325856b0..f51c064e 100644 --- a/packages/mobile-app/app/contacts/discover.tsx +++ b/packages/mobile-app/app/contacts/discover.tsx @@ -1,6 +1,7 @@ import Feather from "@expo/vector-icons/Feather" import { useRouter } from "expo-router" import { useDeferredValue, useEffect, useState } from "react" +import { CONTACT_TARGET_TYPE, IDENTITY_SEARCH_MATCH_STATE } from "@shared" import { Pressable, StyleSheet, Text, TextInput, View } from "react-native" import { @@ -36,17 +37,17 @@ function buildSearchDetailParams(match: IdentitySearchMatchView) { function requestStateLabel(match: IdentitySearchMatchView) { switch (match.state) { - case "same_workspace_member": + case IDENTITY_SEARCH_MATCH_STATE.SAME_WORKSPACE_MEMBER: return "同工作区成员" - case "friend": - case "existing": + case IDENTITY_SEARCH_MATCH_STATE.FRIEND: + case IDENTITY_SEARCH_MATCH_STATE.EXISTING: return "已建立关系" - case "pending_request": - case "pending_approval": + case IDENTITY_SEARCH_MATCH_STATE.PENDING_REQUEST: + case IDENTITY_SEARCH_MATCH_STATE.PENDING_APPROVAL: return "等待处理" - case "approval_required": + case IDENTITY_SEARCH_MATCH_STATE.APPROVAL_REQUIRED: return "需要批准" - case "available": + case IDENTITY_SEARCH_MATCH_STATE.AVAILABLE: return "可直接发起" default: return "可发起连接" @@ -66,8 +67,12 @@ export default function DiscoverContactsScreen() { const [message, setMessage] = useState(null) const deferredSearch = useDeferredValue(search) - const actors = matches.filter((match) => match.targetType === "actor") - const members = matches.filter((match) => match.targetType === "member") + const actors = matches.filter( + (match) => match.targetType === CONTACT_TARGET_TYPE.ACTOR + ) + const members = matches.filter( + (match) => match.targetType === CONTACT_TARGET_TYPE.MEMBER + ) useEffect(() => { let cancelled = false @@ -143,7 +148,11 @@ export default function DiscoverContactsScreen() { setMatches((current) => current.map((item) => item.profileId === match.profileId - ? { ...item, state: "pending_request", requestId: result.requestId } + ? { + ...item, + state: IDENTITY_SEARCH_MATCH_STATE.PENDING_REQUEST, + requestId: result.requestId, + } : item ) ) @@ -247,8 +256,10 @@ export default function DiscoverContactsScreen() { ? "查看详情" : submittingProfileId === actor.profileId ? "处理中..." - : actor.state === "pending_request" || - actor.state === "pending_approval" + : actor.state === + IDENTITY_SEARCH_MATCH_STATE.PENDING_REQUEST || + actor.state === + IDENTITY_SEARCH_MATCH_STATE.PENDING_APPROVAL ? "等待处理" : "发起连接" } @@ -256,8 +267,10 @@ export default function DiscoverContactsScreen() { onPress={() => void handleRequest(actor)} disabled={ !!submittingProfileId || - actor.state === "pending_request" || - actor.state === "pending_approval" + actor.state === + IDENTITY_SEARCH_MATCH_STATE.PENDING_REQUEST || + actor.state === + IDENTITY_SEARCH_MATCH_STATE.PENDING_APPROVAL } style={styles.actionButton} /> @@ -312,8 +325,10 @@ export default function DiscoverContactsScreen() { ? "查看详情" : submittingProfileId === member.profileId ? "处理中..." - : member.state === "pending_request" || - member.state === "pending_approval" + : member.state === + IDENTITY_SEARCH_MATCH_STATE.PENDING_REQUEST || + member.state === + IDENTITY_SEARCH_MATCH_STATE.PENDING_APPROVAL ? "等待处理" : "发起连接" } @@ -321,8 +336,10 @@ export default function DiscoverContactsScreen() { onPress={() => void handleRequest(member)} disabled={ !!submittingProfileId || - member.state === "pending_request" || - member.state === "pending_approval" + member.state === + IDENTITY_SEARCH_MATCH_STATE.PENDING_REQUEST || + member.state === + IDENTITY_SEARCH_MATCH_STATE.PENDING_APPROVAL } style={styles.actionButton} /> diff --git a/packages/mobile-app/app/contacts/group/new.tsx b/packages/mobile-app/app/contacts/group/new.tsx index f5cd9462..54917821 100644 --- a/packages/mobile-app/app/contacts/group/new.tsx +++ b/packages/mobile-app/app/contacts/group/new.tsx @@ -1,5 +1,10 @@ -import { WorkspaceEntityPickerScreen } from "@/components/workspace-entity-picker-screen" +import { + WorkspaceEntityPickerScreen, + WORKSPACE_ENTITY_PICKER_MODE, +} from "@/components/workspace-entity-picker-screen" export default function NewGroupConversationScreen() { - return + return ( + + ) } diff --git a/packages/mobile-app/app/contacts/requests.tsx b/packages/mobile-app/app/contacts/requests.tsx index 0176c8e7..44bb0e37 100644 --- a/packages/mobile-app/app/contacts/requests.tsx +++ b/packages/mobile-app/app/contacts/requests.tsx @@ -1,6 +1,7 @@ import Feather from "@expo/vector-icons/Feather" import { useRouter } from "expo-router" import { useEffect, useState } from "react" +import { CONTACT_TARGET_TYPE } from "@shared" import { Pressable, StyleSheet, Text, View } from "react-native" import { @@ -137,14 +138,18 @@ export default function ContactRequestsScreen() { {request.requester?.name || "未命名用户"} - {request.targetType === "actor" + {request.targetType === CONTACT_TARGET_TYPE.ACTOR ? `申请添加 Actor:${request.targetActor?.name || "未知 Actor"}` : `申请添加好友 · ${request.requester?.workspace.name || ""}`} @@ -241,7 +246,7 @@ export default function ContactRequestsScreen() { {friendOutgoing.map((request) => ( - {request.targetType === "actor" + {request.targetType === CONTACT_TARGET_TYPE.ACTOR ? request.targetActor?.name || "未知 Actor" : request.targetMember?.name || request.targetMember?.email || diff --git a/packages/mobile-app/app/conversations/[conversationId]/details.tsx b/packages/mobile-app/app/conversations/[conversationId]/details.tsx index e6994924..15bb73f5 100644 --- a/packages/mobile-app/app/conversations/[conversationId]/details.tsx +++ b/packages/mobile-app/app/conversations/[conversationId]/details.tsx @@ -1,4 +1,10 @@ import Feather from "@expo/vector-icons/Feather" +import { + CONVERSATION_BOUNDARY, + CONVERSATION_KIND, + CONVERSATION_PARTICIPANT_STATE, + CONVERSATION_PARTICIPANT_TYPE, +} from "@shared" import { useLocalSearchParams, useRouter } from "expo-router" import { useEffect, useMemo } from "react" import { Pressable, StyleSheet, Text, View } from "react-native" @@ -57,7 +63,10 @@ export default function ConversationDetailScreen() { ]) const activeCount = useMemo( - () => members.filter((member) => member.state !== "removed").length, + () => + members.filter( + (member) => member.state !== CONVERSATION_PARTICIPANT_STATE.REMOVED + ).length, [members] ) @@ -98,11 +107,15 @@ export default function ConversationDetailScreen() { {conversation.title} - {`${conversation.kind === "private" ? "单聊" : "群聊"} · ${activeCount} 位成员`} + {`${conversation.kind === CONVERSATION_KIND.PRIVATE ? "单聊" : "群聊"} · ${activeCount} 位成员`} @@ -128,9 +141,11 @@ export default function ConversationDetailScreen() { name={getParticipantDisplayName(member)} uri={member.avatarUrl} icon={ - member.participantType === "actor" + member.participantType === + CONVERSATION_PARTICIPANT_TYPE.ACTOR ? "cpu" - : member.participantType === "external" + : member.participantType === + CONVERSATION_PARTICIPANT_TYPE.EXTERNAL ? "globe" : "user" } @@ -142,16 +157,21 @@ export default function ConversationDetailScreen() { {member.title || - (member.participantType === "workspace_member" + (member.participantType === + CONVERSATION_PARTICIPANT_TYPE.WORKSPACE_MEMBER ? "成员" - : member.participantType === "actor" + : member.participantType === + CONVERSATION_PARTICIPANT_TYPE.ACTOR ? "Actor" : "会话成员")} diff --git a/packages/mobile-app/app/scan.tsx b/packages/mobile-app/app/scan.tsx index d8bdf38e..61cacbcd 100644 --- a/packages/mobile-app/app/scan.tsx +++ b/packages/mobile-app/app/scan.tsx @@ -1,7 +1,11 @@ import { CameraView, useCameraPermissions } from "expo-camera" import { useLocalSearchParams, useRouter } from "expo-router" import { useEffect, useState } from "react" -import { parseSynapseQrPayload, type ParsedSynapseQrPayload } from "@shared" +import { + RELATIONSHIP_SCAN_OUTCOME, + parseSynapseQrPayload, + type ParsedSynapseQrPayload, +} from "@shared" import { Pressable, StyleSheet, Text, View } from "react-native" import { @@ -18,14 +22,17 @@ import type { RelationshipScanResponse } from "@/types/api" function getRelationshipHint(result: RelationshipScanResponse) { switch (result.outcome) { - case "friend_request_created": + case RELATIONSHIP_SCAN_OUTCOME.FRIEND_REQUEST_CREATED: return "好友申请已发出,等待对方处理。" - case "friend_request_pending": + case RELATIONSHIP_SCAN_OUTCOME.FRIEND_REQUEST_PENDING: return "你已经发过好友申请了,等待对方处理。" - case "actor_access_request_created": + case RELATIONSHIP_SCAN_OUTCOME.ACTOR_ACCESS_REQUEST_CREATED: return "已提交 Actor 访问申请,等待批准。" - case "actor_access_pending": + case RELATIONSHIP_SCAN_OUTCOME.ACTOR_ACCESS_PENDING: + case RELATIONSHIP_SCAN_OUTCOME.REMOTE_AGENT_ACCESS_PENDING: return "你已经提交过 Actor 访问申请了。" + case RELATIONSHIP_SCAN_OUTCOME.REMOTE_AGENT_ACCESS_REQUEST_CREATED: + return "已提交 Remote agent 访问申请,等待批准。" default: return "二维码已识别,但当前没有可直接打开的会话。" } @@ -48,9 +55,11 @@ export default function UnifiedScanScreen() { if ( workspaceId && result.contact && - (result.outcome === "same_workspace_member" || - result.outcome === "friend_active" || - result.outcome === "actor_access_granted") + (result.outcome === RELATIONSHIP_SCAN_OUTCOME.SAME_WORKSPACE_MEMBER || + result.outcome === RELATIONSHIP_SCAN_OUTCOME.FRIEND_ACTIVE || + result.outcome === RELATIONSHIP_SCAN_OUTCOME.ACTOR_ACCESS_GRANTED || + result.outcome === + RELATIONSHIP_SCAN_OUTCOME.REMOTE_AGENT_ACCESS_GRANTED) ) { const opened = await api.openDirectConversation(workspaceId, { contactKind: result.contact.kind, @@ -62,7 +71,7 @@ export default function UnifiedScanScreen() { } } - if (result.outcome === "self_scan") { + if (result.outcome === RELATIONSHIP_SCAN_OUTCOME.SELF_SCAN) { setError("不能扫描自己的二维码。") return } diff --git a/packages/mobile-app/app/search.tsx b/packages/mobile-app/app/search.tsx index ac1b1116..25a6780b 100644 --- a/packages/mobile-app/app/search.tsx +++ b/packages/mobile-app/app/search.tsx @@ -1,6 +1,12 @@ import Feather from "@expo/vector-icons/Feather" import { useRouter } from "expo-router" import { useDeferredValue, useEffect, useMemo, useState } from "react" +import { + CONTACT_TARGET_TYPE, + CONVERSATION_KIND, + IDENTITY_SEARCH_MATCH_STATE, + IDENTITY_SEARCH_OUTCOME, +} from "@shared" import { Pressable, StyleSheet, Text, TextInput, View } from "react-native" import { @@ -65,11 +71,11 @@ function buildSearchDetailParams(match: IdentitySearchMatchView) { function friendStateLabel(match: IdentitySearchMatchView) { switch (match.state) { - case "same_workspace_member": + case IDENTITY_SEARCH_MATCH_STATE.SAME_WORKSPACE_MEMBER: return "同 workspace 用户" - case "friend": + case IDENTITY_SEARCH_MATCH_STATE.FRIEND: return "已是好友" - case "pending_request": + case IDENTITY_SEARCH_MATCH_STATE.PENDING_REQUEST: return "好友申请待处理" default: return "可发起好友申请" @@ -126,13 +132,13 @@ export default function GlobalSearchScreen() { .then((result) => { if (!active) return setIdentityResults(result) - if (result.outcome === "invalid") { + if (result.outcome === IDENTITY_SEARCH_OUTCOME.INVALID) { setFriendIdMessage( "好友 ID 需为 4-32 位,只能包含字母、数字、点、下划线或短横线。" ) - } else if (result.outcome === "not_found") { + } else if (result.outcome === IDENTITY_SEARCH_OUTCOME.NOT_FOUND) { setFriendIdMessage("没有匹配的好友 ID。") - } else if (result.outcome === "self") { + } else if (result.outcome === IDENTITY_SEARCH_OUTCOME.SELF) { setFriendIdMessage("这是你自己的好友 ID。") } else { setFriendIdMessage(null) @@ -243,7 +249,11 @@ export default function GlobalSearchScreen() { ))} @@ -288,7 +298,11 @@ export default function GlobalSearchScreen() { diff --git a/packages/mobile-app/src/components/alphabet-indexed-entity-list.tsx b/packages/mobile-app/src/components/alphabet-indexed-entity-list.tsx index 679cefbb..40628f85 100644 --- a/packages/mobile-app/src/components/alphabet-indexed-entity-list.tsx +++ b/packages/mobile-app/src/components/alphabet-indexed-entity-list.tsx @@ -19,12 +19,17 @@ import { import { Avatar } from "@/components/ui" import { theme } from "@/theme/tokens" +export const ALPHABET_ENTITY_TARGET_TYPE = { + ACTOR: "actor", + USER: "user", +} as const + export type AlphabetIndexedEntityItem = { key: string title: string subtitle?: string avatarUrl?: string | null - targetType: "actor" | "user" + targetType: (typeof ALPHABET_ENTITY_TARGET_TYPE)[keyof typeof ALPHABET_ENTITY_TARGET_TYPE] onPress: () => void leadingAccessory?: React.ReactNode trailingAccessory?: React.ReactNode @@ -224,10 +229,14 @@ export function AlphabetIndexedEntityList({ - {item.targetType === "actor" ? ( + {item.targetType === ALPHABET_ENTITY_TARGET_TYPE.ACTOR ? ( { if (!conversationId) { return diff --git a/packages/mobile-app/src/components/workspace-entity-picker-screen.tsx b/packages/mobile-app/src/components/workspace-entity-picker-screen.tsx index 2809b29e..9303c34f 100644 --- a/packages/mobile-app/src/components/workspace-entity-picker-screen.tsx +++ b/packages/mobile-app/src/components/workspace-entity-picker-screen.tsx @@ -1,9 +1,11 @@ import Feather from "@expo/vector-icons/Feather" import { useLocalSearchParams, useRouter } from "expo-router" import { useEffect, useMemo, useRef, useState } from "react" +import { CONTACT_TARGET_TYPE, CONVERSATION_KIND } from "@shared" import { Pressable, RefreshControl, StyleSheet, Text, View } from "react-native" import { + ALPHABET_ENTITY_TARGET_TYPE, AlphabetIndexedEntityList, type AlphabetIndexedEntityItem, } from "@/components/alphabet-indexed-entity-list" @@ -16,14 +18,22 @@ import { theme } from "@/theme/tokens" import type { ContactHubEntryView, ContactHubResponse } from "@/types/api" import type { Actor } from "@shared" -export type WorkspaceEntityPickerMode = "actor" | "group" +export const WORKSPACE_ENTITY_PICKER_MODE = { + ACTOR: "actor", + GROUP: "group", +} as const + +export type WorkspaceEntityPickerMode = + (typeof WORKSPACE_ENTITY_PICKER_MODE)[keyof typeof WORKSPACE_ENTITY_PICKER_MODE] function getEntryKey(entry: ContactHubEntryView) { return `${entry.kind}:${entry.id}` } -function mapEntryTargetType(entry: ContactHubEntryView): "actor" | "user" { - return entry.targetType === "actor" ? "actor" : "user" +function mapEntryTargetType(entry: ContactHubEntryView) { + return entry.targetType === CONTACT_TARGET_TYPE.ACTOR + ? ALPHABET_ENTITY_TARGET_TYPE.ACTOR + : ALPHABET_ENTITY_TARGET_TYPE.USER } function uniqueIds(values: Array) { @@ -151,7 +161,7 @@ export function WorkspaceEntityPickerScreen({ } try { - if (mode === "actor") { + if (mode === WORKSPACE_ENTITY_PICKER_MODE.ACTOR) { const response = await api.getActors(workspaceId) setActors(response.actors.filter((actor) => actor.isActive)) setHub(null) @@ -165,7 +175,7 @@ export function WorkspaceEntityPickerScreen({ setError( nextError instanceof Error ? nextError.message - : mode === "actor" + : mode === WORKSPACE_ENTITY_PICKER_MODE.ACTOR ? "Actor 列表加载失败。" : "可选联系人加载失败。" ) @@ -191,7 +201,11 @@ export function WorkspaceEntityPickerScreen({ ) useEffect(() => { - if (mode !== "group" || initializedSelectionRef.current || loading) { + if ( + mode !== WORKSPACE_ENTITY_PICKER_MODE.GROUP || + initializedSelectionRef.current || + loading + ) { return } @@ -263,7 +277,9 @@ export function WorkspaceEntityPickerScreen({ try { if ( selectedEntries.some( - (entry) => entry.targetType === "member" && !entry.workspaceMemberId + (entry) => + entry.targetType === CONTACT_TARGET_TYPE.MEMBER && + !entry.workspaceMemberId ) ) { throw new Error( @@ -272,7 +288,7 @@ export function WorkspaceEntityPickerScreen({ } const created = await createConversation({ - kind: "group", + kind: CONVERSATION_KIND.GROUP, actorIds: selectedActorIds, workspaceMemberIds: selectedWorkspaceMemberIds, }) @@ -292,14 +308,14 @@ export function WorkspaceEntityPickerScreen({ } const items = useMemo(() => { - if (mode === "actor") { + if (mode === WORKSPACE_ENTITY_PICKER_MODE.ACTOR) { return actors.map((actor) => ({ key: actor.id, title: actor.definition.name, subtitle: actor.definition.role || actor.definition.title || "工作区 Actor", avatarUrl: actor.avatarUrl || null, - targetType: "actor", + targetType: ALPHABET_ENTITY_TARGET_TYPE.ACTOR, onPress: () => void handleSelectActor(actor), })) } @@ -326,14 +342,19 @@ export function WorkspaceEntityPickerScreen({ }) }, [actors, groupEntries, mode, selectedKeys, submitting, workspaceId]) - const title = mode === "actor" ? "选择Actor" : "发起群聊" + const title = + mode === WORKSPACE_ENTITY_PICKER_MODE.ACTOR ? "选择Actor" : "发起群聊" const emptyState = ( router.back()} - confirmVisible={mode === "group"} + confirmVisible={mode === WORKSPACE_ENTITY_PICKER_MODE.GROUP} confirmDisabled={selectedEntries.length === 0 || submitting} confirmLabel="完成" onConfirm={() => void handleCreateGroup()} @@ -357,7 +378,9 @@ export function WorkspaceEntityPickerScreen({ @@ -366,7 +389,9 @@ export function WorkspaceEntityPickerScreen({ diff --git a/packages/mobile-app/src/lib/api.ts b/packages/mobile-app/src/lib/api.ts index 49ee157e..c46f777c 100644 --- a/packages/mobile-app/src/lib/api.ts +++ b/packages/mobile-app/src/lib/api.ts @@ -40,6 +40,7 @@ import type { AuthQrLoginStatusResponse, AuthResponse, ContactHubDetailResponse, + ContactHubEntryView, ContactHubResponse, DirectConversationOpenResponse, FriendIdProfileView, @@ -419,11 +420,7 @@ class ApiClient { getContactHubDetail( workspaceId: string, - contactKind: - | "workspace-actor" - | "workspace-member" - | "friend-actor" - | "friend-member", + contactKind: ContactHubEntryView["kind"], contactId: string ): Promise { return this.request( @@ -476,11 +473,7 @@ class ApiClient { openDirectConversation( workspaceId: string, input: { - contactKind: - | "workspace-actor" - | "workspace-member" - | "friend-actor" - | "friend-member" + contactKind: ContactHubEntryView["kind"] contactId: string } ): Promise { diff --git a/packages/mobile-app/src/lib/chat-data.ts b/packages/mobile-app/src/lib/chat-data.ts index 2e8c527e..5b7514b0 100644 --- a/packages/mobile-app/src/lib/chat-data.ts +++ b/packages/mobile-app/src/lib/chat-data.ts @@ -2,6 +2,11 @@ import Feather from "@expo/vector-icons/Feather" import { isUuid } from "@/lib/ids" import { + CONVERSATION_ITEM_SCOPE, + CONVERSATION_ITEM_SURFACE, + CONVERSATION_ITEM_TYPE, + CONVERSATION_KIND, + CONVERSATION_PARTICIPANT_TYPE, extractText, summarizeConversationEvent, type CanonicalContentBlock, @@ -408,7 +413,8 @@ export function getConversationViewerParticipant( return conversation.participants.find( (participant) => - participant.participantType === "workspace_member" && + participant.participantType === + CONVERSATION_PARTICIPANT_TYPE.WORKSPACE_MEMBER && participant.workspaceMemberId === workspaceMemberId ) } @@ -426,11 +432,11 @@ export function getParticipantDisplayName( } switch (participant?.participantType) { - case "actor": + case CONVERSATION_PARTICIPANT_TYPE.ACTOR: return "Actor" - case "external": + case CONVERSATION_PARTICIPANT_TYPE.EXTERNAL: return "External" - case "workspace_member": + case CONVERSATION_PARTICIPANT_TYPE.WORKSPACE_MEMBER: return "成员" default: return "系统" @@ -446,7 +452,8 @@ function getConversationPeerParticipant( (participant) => participant.state === "active" && !( - participant.participantType === "workspace_member" && + participant.participantType === + CONVERSATION_PARTICIPANT_TYPE.WORKSPACE_MEMBER && participant.workspaceMemberId === workspaceMemberId ) ) ?? conversation.participants[0] @@ -457,7 +464,7 @@ export function getConversationDisplayName( conversation: ChatConversationView, workspaceMemberId?: string | null ) { - if (conversation.kind === "private") { + if (conversation.kind === CONVERSATION_KIND.PRIVATE) { const peer = getConversationPeerParticipant(conversation, workspaceMemberId) return getParticipantDisplayName(peer) || conversation.title || "聊天" } @@ -501,13 +508,15 @@ export function getConversationAvatarSpec( return { name: peer.avatarEmoji } } - if (peer?.participantType === "actor") { + if (peer?.participantType === CONVERSATION_PARTICIPANT_TYPE.ACTOR) { return { name, icon: "cpu" } } - if (peer?.participantType === "workspace_member") { + if ( + peer?.participantType === CONVERSATION_PARTICIPANT_TYPE.WORKSPACE_MEMBER + ) { return { name, icon: "user" } } - if (peer?.participantType === "external") { + if (peer?.participantType === CONVERSATION_PARTICIPANT_TYPE.EXTERNAL) { return { name, icon: "globe" } } @@ -526,11 +535,11 @@ export function buildPreviewTextFromItem( return text } - if (item.itemType === "event") { + if (item.itemType === CONVERSATION_ITEM_TYPE.EVENT) { return summarizeConversationEvent(item.subtype, item.eventPayload) } - if (item.itemType === "message") { + if (item.itemType === CONVERSATION_ITEM_TYPE.MESSAGE) { return "Attachment" } @@ -638,11 +647,11 @@ export function buildOptimisticChatItem( conversationId: outbox.conversationId, sequence: outbox.optimisticSequence, clientMessageId: outbox.clientMessageId, - itemType: "message", + itemType: CONVERSATION_ITEM_TYPE.MESSAGE, role: "user", subtype: "chat.message", - scope: "shared", - surface: "visible", + scope: CONVERSATION_ITEM_SCOPE.SHARED, + surface: CONVERSATION_ITEM_SURFACE.VISIBLE, authorParticipantId: viewerParticipantId?.participantId, author: viewerParticipantId ? { @@ -776,7 +785,7 @@ export function getMentionableConversationParticipants( return (conversation?.participants ?? []).filter( (participant) => participant.state === "active" && - participant.participantType !== "system" && + participant.participantType !== CONVERSATION_PARTICIPANT_TYPE.SYSTEM && participant.participantId !== viewerParticipantId ) } @@ -846,11 +855,11 @@ export function getEntityDisplayName( } switch (entity?.participantType) { - case "actor": + case CONVERSATION_PARTICIPANT_TYPE.ACTOR: return "Actor" - case "external": + case CONVERSATION_PARTICIPANT_TYPE.EXTERNAL: return "External" - case "workspace_member": + case CONVERSATION_PARTICIPANT_TYPE.WORKSPACE_MEMBER: return "成员" default: return "系统" @@ -880,11 +889,11 @@ export function getEntityAvatarSpec( const name = getEntityDisplayName(entity) switch (entity?.participantType) { - case "actor": + case CONVERSATION_PARTICIPANT_TYPE.ACTOR: return { name, icon: "cpu" as const } - case "external": + case CONVERSATION_PARTICIPANT_TYPE.EXTERNAL: return { name, icon: "globe" as const } - case "workspace_member": + case CONVERSATION_PARTICIPANT_TYPE.WORKSPACE_MEMBER: return { name, icon: "user" as const } default: return { name, icon: "message-circle" as const } diff --git a/packages/mobile-app/src/screens/tabs/contacts-tab-screen.tsx b/packages/mobile-app/src/screens/tabs/contacts-tab-screen.tsx index 68a99563..9f694582 100644 --- a/packages/mobile-app/src/screens/tabs/contacts-tab-screen.tsx +++ b/packages/mobile-app/src/screens/tabs/contacts-tab-screen.tsx @@ -1,6 +1,7 @@ import Feather from "@expo/vector-icons/Feather" import { useRouter } from "expo-router" import { useEffect, useMemo, useState } from "react" +import { CONTACT_TARGET_TYPE } from "@shared" import { Modal, Pressable, @@ -11,6 +12,7 @@ import { } from "react-native" import { + ALPHABET_ENTITY_TARGET_TYPE, AlphabetIndexedEntityList, type AlphabetIndexedEntityItem, } from "@/components/alphabet-indexed-entity-list" @@ -29,13 +31,20 @@ import { useWorkspace } from "@/providers/workspace-provider" import { theme } from "@/theme/tokens" import type { ContactHubEntryView, ContactHubResponse } from "@/types/api" -type ContactFilter = "all" | "friend" | "actor" | "workspace-member" +const CONTACT_FILTER = { + ALL: "all", + FRIEND: "friend", + ACTOR: "actor", + WORKSPACE_MEMBER: "workspace-member", +} as const + +type ContactFilter = (typeof CONTACT_FILTER)[keyof typeof CONTACT_FILTER] const FILTER_OPTIONS: Array<{ value: ContactFilter; label: string }> = [ - { value: "all", label: "默认" }, - { value: "friend", label: "好友" }, - { value: "actor", label: "Actor" }, - { value: "workspace-member", label: "Workspace Member" }, + { value: CONTACT_FILTER.ALL, label: "默认" }, + { value: CONTACT_FILTER.FRIEND, label: "好友" }, + { value: CONTACT_FILTER.ACTOR, label: "Actor" }, + { value: CONTACT_FILTER.WORKSPACE_MEMBER, label: "Workspace Member" }, ] function compareText(left: string, right: string) { @@ -72,7 +81,7 @@ export default function ContactsTabScreen() { const [loading, setLoading] = useState(true) const [refreshing, setRefreshing] = useState(false) const [error, setError] = useState(null) - const [filter, setFilter] = useState("all") + const [filter, setFilter] = useState(CONTACT_FILTER.ALL) const [filterMenuOpen, setFilterMenuOpen] = useState(false) async function loadHub(isRefreshing = false) { @@ -113,13 +122,13 @@ export default function ContactsTabScreen() { ...(hub?.friends || []), ] - if (filter === "friend") { + if (filter === CONTACT_FILTER.FRIEND) { return [...(hub?.friends || [])].sort(compareEntries) } - if (filter === "actor") { + if (filter === CONTACT_FILTER.ACTOR) { return [...(hub?.workspaceActors || [])].sort(compareEntries) } - if (filter === "workspace-member") { + if (filter === CONTACT_FILTER.WORKSPACE_MEMBER) { return [...(hub?.workspaceMembers || [])].sort(compareEntries) } return [...all].sort(compareEntries) @@ -132,7 +141,10 @@ export default function ContactsTabScreen() { title: entry.title, subtitle: entry.subtitle || entry.workspace.name, avatarUrl: entry.avatarUrl || null, - targetType: entry.targetType === "actor" ? "actor" : "user", + targetType: + entry.targetType === CONTACT_TARGET_TYPE.ACTOR + ? ALPHABET_ENTITY_TARGET_TYPE.ACTOR + : ALPHABET_ENTITY_TARGET_TYPE.USER, trailingAccessory: isFriendEntry(entry) ? ( ) : undefined, diff --git a/packages/mobile-app/src/types/api.ts b/packages/mobile-app/src/types/api.ts index a8b7aed1..8ae94fc6 100644 --- a/packages/mobile-app/src/types/api.ts +++ b/packages/mobile-app/src/types/api.ts @@ -1,12 +1,22 @@ import type { Actor, + ActorAccessRequestListResponse, AuthQrLoginResolveResponse, AuthQrLoginStatusResponse, AuthResponse, AuthSessionSummary, + ContactHubDetailResponse, + ContactHubEntryView, + ContactHubResponse, ConversationFeedItem, ConversationFeedPage, + DirectConversationOpenResponse, FileRecordView, + FriendRequestListResponse, + IdentitySearchMatchView, + IdentitySearchResponse, + RelationshipProfileView, + RelationshipScanResponse, User, WorkspaceChiefActorPreference, } from "@shared" @@ -126,175 +136,27 @@ export interface UploadAssetInput { file?: Blob | File | null } -export interface RelationshipProfileView { - subjectType: "user" | "actor" - approvalMode: "auto" | "manual" - qrToken: string - qrUrl: string - accessPolicy?: "workspace_open" | "approval_required" -} - export interface FriendIdProfileView { friendId: string searchByIdEnabled: boolean } -export interface ContactHubEntryRef { - kind: - | "workspace-actor" - | "workspace-member" - | "friend-actor" - | "friend-member" - id: string -} - -export interface IdentitySearchMatchView { - profileId: string - targetType: "member" | "actor" - title: string - subtitle?: string - avatarUrl?: string - avatarEmoji?: string - workspace: WorkspaceInfo - workspaceMemberId?: string - userId?: string - actorId?: string - state: - | "same_workspace_member" - | "friend" - | "pending_request" - | "requestable" - | "existing" - | "available" - | "approval_required" - | "pending_approval" - contact?: ContactHubEntryRef - conversationId?: string - requestId?: string -} - -export interface IdentitySearchResponse { - query: string - outcome: "empty" | "invalid" | "self" | "not_found" | "found" - matches: IdentitySearchMatchView[] -} - -export interface ContactHubEntryView { - kind: - | "workspace-actor" - | "workspace-member" - | "friend-actor" - | "friend-member" - id: string - targetType: "member" | "actor" - title: string - subtitle?: string - avatarUrl?: string - avatarEmoji?: string - workspace: WorkspaceInfo - workspaceMemberId?: string - userId?: string - actorId?: string - relationLabel: string - directState: { - status: "existing" | "available" | "approval_required" | "pending_approval" - conversationId?: string - } -} - -export interface RelationshipMemberSummaryView { - workspace: WorkspaceInfo - workspaceMemberId: string - userId: string - name: string - email: string - avatarFileId?: string | null - trustLevel?: string -} - -export interface RelationshipActorSummaryView { - workspace: WorkspaceInfo - actorId: string - name: string - title: string - role: string - avatarStoredName?: string | null - avatarEmoji?: string | null - accessPolicy: "workspace_open" | "approval_required" -} - -export interface FriendRequestView { - id: string - status: "pending" | "approved" | "rejected" - createdAt: string - requester?: RelationshipMemberSummaryView | null - targetType: "member" | "actor" - targetMember?: RelationshipMemberSummaryView | null - targetActor?: RelationshipActorSummaryView | null -} - -export interface FriendRequestListResponse { - incoming: FriendRequestView[] - outgoing: FriendRequestView[] -} - -export interface ActorAccessRequestView { - id: string - status: "pending" | "approved" | "rejected" - createdAt: string - requester?: RelationshipMemberSummaryView | null - actor?: RelationshipActorSummaryView | null -} - -export interface ActorAccessRequestListResponse { - incoming: ActorAccessRequestView[] - outgoing: ActorAccessRequestView[] -} - -export interface ContactHubResponse { - requestSummary: { - friendPendingCount: number - actorAccessPendingCount: number - totalPendingCount: number - } - workspaceActors: ContactHubEntryView[] - workspaceMembers: ContactHubEntryView[] - friends: ContactHubEntryView[] - groups: ConversationSummaryView[] -} - -export interface ContactHubDetailResponse { - contact: ContactHubEntryView - groups: ConversationSummaryView[] -} - -export interface RelationshipScanResponse { - outcome: - | "self_scan" - | "same_workspace_member" - | "friend_active" - | "friend_request_created" - | "friend_request_pending" - | "actor_access_granted" - | "actor_access_request_created" - | "actor_access_pending" - requestId?: string - contact?: ContactHubEntryRef -} - -export interface DirectConversationOpenResponse { - status: "ready" | "pending_approval" - created?: boolean - conversationId?: string - requestId?: string -} - export type { Actor, + ActorAccessRequestListResponse, AuthQrLoginResolveResponse, AuthQrLoginStatusResponse, AuthResponse, + ContactHubDetailResponse, + ContactHubEntryView, + ContactHubResponse, ConversationFeedPage, + DirectConversationOpenResponse, FileRecordView, + FriendRequestListResponse, + IdentitySearchMatchView, + IdentitySearchResponse, + RelationshipProfileView, + RelationshipScanResponse, WorkspaceChiefActorPreference, } diff --git a/packages/remote-agent-daemon/src/chat-bridge.ts b/packages/remote-agent-daemon/src/chat-bridge.ts index 0282577e..b12f8058 100644 --- a/packages/remote-agent-daemon/src/chat-bridge.ts +++ b/packages/remote-agent-daemon/src/chat-bridge.ts @@ -15,6 +15,20 @@ type BridgeConfig = { stateFile?: string } +type ExposedDelivery = { + deliveryId: string + conversationId: string + itemId: string + sequence: number +} + +type BridgeState = { + lastConversationId?: string | null + lastToolName?: string | null + updatedAt?: string + exposedDeliveries?: Record +} + function parseArgs(argv: string[]): BridgeConfig { const args = new Map() for (let index = 0; index < argv.length; index += 1) { @@ -104,32 +118,44 @@ function jsonToolResult>( } } -function updateBridgeState( - config: BridgeConfig, - patch: Partial<{ - lastConversationId: string | null - lastToolName: string | null - updatedAt: string - }> -) { +function loadBridgeState(config: BridgeConfig): BridgeState { + if (!config.stateFile) { + return {} + } + try { + if (!existsSync(config.stateFile)) { + return {} + } + const raw = readFileSync(config.stateFile, "utf8").trim() + if (!raw) { + return {} + } + const parsed = JSON.parse(raw) as BridgeState + return parsed && typeof parsed === "object" ? parsed : {} + } catch { + return {} + } +} + +function writeBridgeState(config: BridgeConfig, state: BridgeState) { + if (!config.stateFile) { + return + } + writeFileSync(config.stateFile, JSON.stringify(state, null, 2), "utf8") +} + +function updateBridgeState(config: BridgeConfig, patch: Partial) { if (!config.stateFile) { return } try { - const current = - existsSync(config.stateFile) && - readFileSync(config.stateFile, "utf8").trim() - ? (JSON.parse(readFileSync(config.stateFile, "utf8")) as Record< - string, - unknown - >) - : {} + const current = loadBridgeState(config) const next = { ...current, ...patch, updatedAt: patch.updatedAt ?? new Date().toISOString(), } - writeFileSync(config.stateFile, JSON.stringify(next, null, 2), "utf8") + writeBridgeState(config, next) } catch {} } @@ -172,8 +198,7 @@ async function main() { server.registerTool( "check_messages", { - description: - "Return pending message deliveries for this remote agent. Calling this also acknowledges the returned deliveries.", + description: "Return pending message deliveries for this remote agent.", inputSchema: { limit: z.number().int().min(1).max(500).optional(), }, @@ -185,29 +210,34 @@ async function main() { updateBridgeState(config, { lastToolName: "check_messages", }) - const result = await requestJson<{ - deliveries: Array<{ deliveryId: string }> - }>( + const result = await requestJson<{ deliveries: ExposedDelivery[] }>( config, `/api/v1/internal/remote-agents/${config.remoteAgentId}/check-messages`, undefined, { limit } ) - if (result.deliveries.length > 0) { - await requestJson( - config, - "/api/v1/internal/remote-agents/ack-deliveries", - { - method: "POST", - body: JSON.stringify({ - remoteAgentId: config.remoteAgentId, - deliveryIds: result.deliveries.map( - (delivery) => delivery.deliveryId - ), - }), + const state = loadBridgeState(config) + const exposedDeliveries = { + ...(state.exposedDeliveries ?? {}), + } + for (const delivery of result.deliveries) { + const current = exposedDeliveries[delivery.conversationId] ?? [] + if ( + !current.some((entry) => entry.deliveryId === delivery.deliveryId) + ) { + current.push(delivery) } - ) + exposedDeliveries[delivery.conversationId] = current.sort( + (left, right) => left.sequence - right.sequence + ) + } + writeBridgeState(config, { + ...state, + lastToolName: "check_messages", + exposedDeliveries, + updatedAt: new Date().toISOString(), + }) } return jsonToolResult(result) @@ -244,6 +274,54 @@ async function main() { limit, } ) + const state = loadBridgeState(config) + const exposed = state.exposedDeliveries?.[conversationId] ?? [] + const itemIds = new Set( + result.items + .map((item) => + item && + typeof item === "object" && + typeof (item as { id?: unknown }).id === "string" + ? (item as { id: string }).id + : null + ) + .filter((itemId): itemId is string => Boolean(itemId)) + ) + const completedDeliveries = exposed.filter((delivery) => + itemIds.has(delivery.itemId) + ) + if (completedDeliveries.length > 0) { + await requestJson( + config, + `/api/v1/internal/remote-agents/${config.remoteAgentId}/complete-deliveries`, + { + method: "POST", + body: JSON.stringify({ + deliveryIds: completedDeliveries.map( + (delivery) => delivery.deliveryId + ), + }), + } + ) + } + if (state.exposedDeliveries) { + const remaining = exposed.filter( + (delivery) => + !completedDeliveries.some( + (entry) => entry.deliveryId === delivery.deliveryId + ) + ) + writeBridgeState(config, { + ...state, + lastConversationId: conversationId, + lastToolName: "read_history", + exposedDeliveries: { + ...state.exposedDeliveries, + [conversationId]: remaining, + }, + updatedAt: new Date().toISOString(), + }) + } return jsonToolResult(result) } ) diff --git a/packages/remote-agent-daemon/src/index.ts b/packages/remote-agent-daemon/src/index.ts index 7a33b575..ef08a1db 100644 --- a/packages/remote-agent-daemon/src/index.ts +++ b/packages/remote-agent-daemon/src/index.ts @@ -118,6 +118,9 @@ interface RuntimeDriver { type SessionState = { sessionId?: string + pendingInteraction?: PendingInteraction + latestPlanDraft?: LatestPlanDraft | null + lastConversationId?: string } const DEFAULT_HEARTBEAT_MS = 30_000 @@ -294,6 +297,53 @@ function buildPlanRevisionPrompt(note?: string) { .join(" ") } +function buildResolvedUserInputPrompt(interaction: Record) { + const title = + typeof interaction.userInput?.title === "string" + ? interaction.userInput.title.trim() + : "User input" + const questions = Array.isArray(interaction.userInput?.questions) + ? interaction.userInput.questions + : [] + const answerLines = questions + .map((question: Record) => { + const prompt = + typeof question.prompt === "string" && question.prompt.trim() + ? question.prompt.trim() + : typeof question.title === "string" && question.title.trim() + ? question.title.trim() + : typeof question.id === "string" + ? question.id + : "Question" + const labels = Array.isArray(question.answer?.selectedOptionLabels) + ? question.answer.selectedOptionLabels.filter( + (value: unknown): value is string => + typeof value === "string" && value.trim().length > 0 + ) + : [] + const selected = labels.length > 0 ? labels.join(", ") : undefined + const text = + typeof question.answer?.text === "string" && question.answer.text.trim() + ? question.answer.text.trim() + : undefined + const otherText = + typeof question.answer?.otherText === "string" && + question.answer.otherText.trim() + ? question.answer.otherText.trim() + : undefined + const value = [selected, text, otherText].filter(Boolean).join(" | ") + return value ? `- ${prompt}: ${value}` : null + }) + .filter((line: string | null): line is string => Boolean(line)) + return [ + `The Synapse user answered your input request: ${title}.`, + answerLines.length > 0 + ? answerLines.join("\n") + : "Review the latest conversation state for the submitted answers.", + "Continue the task using those answers.", + ].join("\n") +} + function buildBootstrapPrompt(params: { remoteAgentId: string runtimeKind: RuntimeKind @@ -1331,6 +1381,11 @@ class ManagedRemoteAgent { const storedState = readSessionState(this.stateFile) this.sessionId = message.sessionId ?? storedState.sessionId ?? this.sessionId + this.pendingInteraction = + storedState.pendingInteraction ?? this.pendingInteraction + this.latestPlanDraft = storedState.latestPlanDraft ?? this.latestPlanDraft + this.lastConversationId = + storedState.lastConversationId ?? this.lastConversationId log( "info", `remote-agent:${this.params.remoteAgentId}`, @@ -1343,6 +1398,17 @@ class ManagedRemoteAgent { sessionId: this.sessionId ?? undefined, } ) + this.persistSession() + if (this.pendingInteraction) { + this.publishStatus( + this.pendingInteraction.kind === "plan_approval" + ? "waiting_plan_approval" + : "waiting_user_input", + "Waiting for a response in Synapse", + this.pendingInteraction.conversationId + ) + return + } this.publishStatus("idle", "Ready", undefined) } @@ -1358,8 +1424,6 @@ class ManagedRemoteAgent { this.driver = null this.running = false this.pendingWake = false - this.pendingInteraction = null - this.latestPlanDraft = null log( "warn", `remote-agent:${this.params.remoteAgentId}`, @@ -1368,6 +1432,7 @@ class ManagedRemoteAgent { reason, } ) + this.persistSession() this.publishStatus("offline", reason, undefined) } @@ -1382,6 +1447,7 @@ class ManagedRemoteAgent { ] if (uniqueConversationIds.length === 1) { this.lastConversationId = uniqueConversationIds[0] + this.persistSession() } log( "debug", @@ -1395,15 +1461,37 @@ class ManagedRemoteAgent { } async resolveInteraction(message: InteractionResolvedMessage) { - if ( - !this.pendingInteraction || - this.pendingInteraction.interactionId !== message.interactionId - ) { + const interaction = message.interaction || {} + const pending = + this.pendingInteraction?.interactionId === message.interactionId + ? this.pendingInteraction + : null + const resolvedConversationId = + typeof interaction.conversationId === "string" && + interaction.conversationId + ? interaction.conversationId + : pending?.conversationId + + if (!pending) { + log( + "warn", + `remote-agent:${this.params.remoteAgentId}`, + "Resolved interaction arrived without a matching pending request; falling back to a synthetic prompt", + { + interactionId: message.interactionId, + status: + typeof interaction.status === "string" + ? interaction.status + : undefined, + } + ) + await this.handleResolvedInteractionFallback( + interaction, + resolvedConversationId + ) return } - const interaction = message.interaction || {} - const pending = this.pendingInteraction log( "info", `remote-agent:${this.params.remoteAgentId}`, @@ -1450,24 +1538,23 @@ class ManagedRemoteAgent { }) if (!wrote) { log( - "error", + "warn", `remote-agent:${this.params.remoteAgentId}`, - "Failed to deliver resolved interaction to runtime", + "Failed to deliver resolved interaction to the live runtime; falling back to a synthetic prompt", { interactionId: message.interactionId, protocol: pending.protocol, requestId: pending.requestId, } ) - this.publishStatus( - "error", - "Resolved interaction could not be delivered to the local runtime", - pending.conversationId, - "Runtime stdin is not writable" + await this.handleResolvedInteractionFallback( + interaction, + pending.conversationId ) return } this.pendingInteraction = null + this.persistSession() log( "info", `remote-agent:${this.params.remoteAgentId}`, @@ -1497,24 +1584,23 @@ class ManagedRemoteAgent { }) if (!wrote) { log( - "error", + "warn", `remote-agent:${this.params.remoteAgentId}`, - "Failed to deliver resolved interaction to runtime", + "Failed to deliver resolved interaction to the live runtime; falling back to a synthetic prompt", { interactionId: message.interactionId, protocol: pending.protocol, requestId: pending.requestId, } ) - this.publishStatus( - "error", - "Resolved interaction could not be delivered to the local runtime", - pending.conversationId, - "Runtime stdin is not writable" + await this.handleResolvedInteractionFallback( + interaction, + pending.conversationId ) return } this.pendingInteraction = null + this.persistSession() log( "info", `remote-agent:${this.params.remoteAgentId}`, @@ -1538,12 +1624,14 @@ class ManagedRemoteAgent { ? interaction.resolutionNote : undefined this.pendingInteraction = null + this.persistSession() if (interaction.status === "approved") { this.pendingSyntheticPrompts.push(buildPlanApprovedPrompt(note)) } else { this.pendingSyntheticPrompts.push(buildPlanRevisionPrompt(note)) } this.latestPlanDraft = null + this.persistSession() log( "info", `remote-agent:${this.params.remoteAgentId}`, @@ -1562,6 +1650,59 @@ class ManagedRemoteAgent { } } + private async handleResolvedInteractionFallback( + interaction: Record, + conversationId?: string + ) { + const kind = typeof interaction.kind === "string" ? interaction.kind : null + const status = + typeof interaction.status === "string" ? interaction.status : null + const note = + typeof interaction.resolutionNote === "string" + ? interaction.resolutionNote + : undefined + + if (kind === "user_input" && status === "answered") { + this.pendingInteraction = null + this.pendingSyntheticPrompts.push( + buildResolvedUserInputPrompt(interaction) + ) + this.persistSession() + this.publishStatus("idle", "Input received; resuming", conversationId) + await this.wake() + return + } + + if ( + kind === "plan_approval" && + (status === "approved" || status === "rejected") + ) { + this.pendingInteraction = null + this.latestPlanDraft = null + this.pendingSyntheticPrompts.push( + status === "approved" + ? buildPlanApprovedPrompt(note) + : buildPlanRevisionPrompt(note) + ) + this.persistSession() + this.publishStatus("idle", "Plan decision received", conversationId) + await this.wake() + return + } + + log( + "warn", + `remote-agent:${this.params.remoteAgentId}`, + "Resolved interaction could not be replayed", + { + interactionId: + typeof interaction.id === "string" ? interaction.id : undefined, + kind: kind ?? undefined, + status: status ?? undefined, + } + ) + } + private workingDirectory() { return this.localRootPath || path.join(this.stateDirectory, "workspace") } @@ -1570,6 +1711,9 @@ class ManagedRemoteAgent { if (!this.stateFile) return writeSessionState(this.stateFile, { sessionId: this.sessionId, + pendingInteraction: this.pendingInteraction ?? undefined, + latestPlanDraft: this.latestPlanDraft ?? undefined, + lastConversationId: this.lastConversationId, }) } @@ -1597,6 +1741,7 @@ class ManagedRemoteAgent { this.running = true this.pendingWake = false this.latestPlanDraft = null + this.persistSession() log( "info", `remote-agent:${this.params.remoteAgentId}`, @@ -1637,6 +1782,7 @@ class ManagedRemoteAgent { this.pendingDeliveries.length = 0 this.pendingDeliveryIds.clear() this.latestPlanDraft = null + this.persistSession() const resolvedRuntime = this.resolveRuntimePathForLaunch() if (!resolvedRuntime.ok) { this.running = false @@ -1963,6 +2109,7 @@ class ManagedRemoteAgent { done: step.status === "completed", })), } + this.persistSession() this.publishStatus( "plan_drafting", "Drafting a plan", @@ -1994,6 +2141,7 @@ class ManagedRemoteAgent { const bridgeState = readBridgeState(this.bridgeStateFile) if (bridgeState.lastConversationId) { this.lastConversationId = bridgeState.lastConversationId + this.persistSession() return bridgeState.lastConversationId } return this.lastConversationId @@ -2140,6 +2288,7 @@ class ManagedRemoteAgent { originalInput: input, }) this.pendingInteraction = interaction + this.persistSession() this.publishStatus( "waiting_user_input", "Waiting for user input", @@ -2167,6 +2316,7 @@ class ManagedRemoteAgent { originalInput: input, }) this.pendingInteraction = interaction + this.persistSession() this.publishStatus( "waiting_plan_approval", "Waiting for plan approval", @@ -2240,6 +2390,7 @@ class ManagedRemoteAgent { : [], }) this.pendingInteraction = interaction + this.persistSession() this.publishStatus( "waiting_user_input", "Waiting for user input", @@ -2426,6 +2577,7 @@ class ManagedRemoteAgent { checklist: planDraft.checklist, }) this.pendingInteraction = interaction + this.persistSession() this.publishStatus( "waiting_plan_approval", "Waiting for plan approval", diff --git a/packages/shared/src/constants/enums.ts b/packages/shared/src/constants/enums.ts index 654c89b9..4f4e0128 100644 --- a/packages/shared/src/constants/enums.ts +++ b/packages/shared/src/constants/enums.ts @@ -37,7 +37,148 @@ export const WORKSPACE_ACCESS_KEYS = [ "conversation_admin", ] as const -export const CONTACT_TARGET_TYPES = ["member", "actor", "remote_agent"] as const +export const RELATIONSHIP_PROFILE_SUBJECT_TYPE = { + MEMBER: "member", + ACTOR: "actor", + REMOTE_AGENT: "remote_agent", +} as const +export const RELATIONSHIP_PROFILE_SUBJECT_TYPES = [ + RELATIONSHIP_PROFILE_SUBJECT_TYPE.MEMBER, + RELATIONSHIP_PROFILE_SUBJECT_TYPE.ACTOR, + RELATIONSHIP_PROFILE_SUBJECT_TYPE.REMOTE_AGENT, +] as const +export const RELATIONSHIP_APPROVAL_MODE = { + AUTO: "auto", + MANUAL: "manual", +} as const +export const RELATIONSHIP_APPROVAL_MODES = [ + RELATIONSHIP_APPROVAL_MODE.AUTO, + RELATIONSHIP_APPROVAL_MODE.MANUAL, +] as const +export const RELATIONSHIP_ACCESS_POLICY = { + WORKSPACE_OPEN: "workspace_open", + APPROVAL_REQUIRED: "approval_required", +} as const +export const RELATIONSHIP_ACCESS_POLICIES = [ + RELATIONSHIP_ACCESS_POLICY.WORKSPACE_OPEN, + RELATIONSHIP_ACCESS_POLICY.APPROVAL_REQUIRED, +] as const +export const RELATIONSHIP_REQUEST_STATUS = { + PENDING: "pending", + APPROVED: "approved", + REJECTED: "rejected", +} as const +export const RELATIONSHIP_REQUEST_STATUSES = [ + RELATIONSHIP_REQUEST_STATUS.PENDING, + RELATIONSHIP_REQUEST_STATUS.APPROVED, + RELATIONSHIP_REQUEST_STATUS.REJECTED, +] as const +export const CONTACT_TARGET_TYPE = { + MEMBER: "member", + ACTOR: "actor", + REMOTE_AGENT: "remote_agent", +} as const +export const CONTACT_TARGET_TYPES = [ + CONTACT_TARGET_TYPE.MEMBER, + CONTACT_TARGET_TYPE.ACTOR, + CONTACT_TARGET_TYPE.REMOTE_AGENT, +] as const +export const CONTACT_HUB_KIND = { + WORKSPACE_ACTOR: "workspace-actor", + WORKSPACE_REMOTE_AGENT: "workspace-remote-agent", + WORKSPACE_MEMBER: "workspace-member", + FRIEND_ACTOR: "friend-actor", + FRIEND_REMOTE_AGENT: "friend-remote-agent", + FRIEND_MEMBER: "friend-member", +} as const +export const CONTACT_HUB_KINDS = [ + CONTACT_HUB_KIND.WORKSPACE_ACTOR, + CONTACT_HUB_KIND.WORKSPACE_REMOTE_AGENT, + CONTACT_HUB_KIND.WORKSPACE_MEMBER, + CONTACT_HUB_KIND.FRIEND_ACTOR, + CONTACT_HUB_KIND.FRIEND_REMOTE_AGENT, + CONTACT_HUB_KIND.FRIEND_MEMBER, +] as const +export const CONTACT_DIRECT_STATE = { + EXISTING: "existing", + AVAILABLE: "available", + APPROVAL_REQUIRED: "approval_required", + PENDING_APPROVAL: "pending_approval", +} as const +export const CONTACT_DIRECT_STATES = [ + CONTACT_DIRECT_STATE.EXISTING, + CONTACT_DIRECT_STATE.AVAILABLE, + CONTACT_DIRECT_STATE.APPROVAL_REQUIRED, + CONTACT_DIRECT_STATE.PENDING_APPROVAL, +] as const +export const IDENTITY_SEARCH_OUTCOME = { + EMPTY: "empty", + INVALID: "invalid", + SELF: "self", + NOT_FOUND: "not_found", + FOUND: "found", +} as const +export const IDENTITY_SEARCH_OUTCOMES = [ + IDENTITY_SEARCH_OUTCOME.EMPTY, + IDENTITY_SEARCH_OUTCOME.INVALID, + IDENTITY_SEARCH_OUTCOME.SELF, + IDENTITY_SEARCH_OUTCOME.NOT_FOUND, + IDENTITY_SEARCH_OUTCOME.FOUND, +] as const +export const IDENTITY_SEARCH_MATCH_STATE = { + SAME_WORKSPACE_MEMBER: "same_workspace_member", + FRIEND: "friend", + PENDING_REQUEST: "pending_request", + REQUESTABLE: "requestable", + EXISTING: CONTACT_DIRECT_STATE.EXISTING, + AVAILABLE: CONTACT_DIRECT_STATE.AVAILABLE, + APPROVAL_REQUIRED: CONTACT_DIRECT_STATE.APPROVAL_REQUIRED, + PENDING_APPROVAL: CONTACT_DIRECT_STATE.PENDING_APPROVAL, +} as const +export const IDENTITY_SEARCH_MATCH_STATES = [ + IDENTITY_SEARCH_MATCH_STATE.SAME_WORKSPACE_MEMBER, + IDENTITY_SEARCH_MATCH_STATE.FRIEND, + IDENTITY_SEARCH_MATCH_STATE.PENDING_REQUEST, + IDENTITY_SEARCH_MATCH_STATE.REQUESTABLE, + IDENTITY_SEARCH_MATCH_STATE.EXISTING, + IDENTITY_SEARCH_MATCH_STATE.AVAILABLE, + IDENTITY_SEARCH_MATCH_STATE.APPROVAL_REQUIRED, + IDENTITY_SEARCH_MATCH_STATE.PENDING_APPROVAL, +] as const +export const RELATIONSHIP_SCAN_OUTCOME = { + SELF_SCAN: "self_scan", + SAME_WORKSPACE_MEMBER: "same_workspace_member", + FRIEND_ACTIVE: "friend_active", + FRIEND_REQUEST_CREATED: "friend_request_created", + FRIEND_REQUEST_PENDING: "friend_request_pending", + ACTOR_ACCESS_GRANTED: "actor_access_granted", + ACTOR_ACCESS_REQUEST_CREATED: "actor_access_request_created", + ACTOR_ACCESS_PENDING: "actor_access_pending", + REMOTE_AGENT_ACCESS_GRANTED: "remote_agent_access_granted", + REMOTE_AGENT_ACCESS_REQUEST_CREATED: "remote_agent_access_request_created", + REMOTE_AGENT_ACCESS_PENDING: "remote_agent_access_pending", +} as const +export const RELATIONSHIP_SCAN_OUTCOMES = [ + RELATIONSHIP_SCAN_OUTCOME.SELF_SCAN, + RELATIONSHIP_SCAN_OUTCOME.SAME_WORKSPACE_MEMBER, + RELATIONSHIP_SCAN_OUTCOME.FRIEND_ACTIVE, + RELATIONSHIP_SCAN_OUTCOME.FRIEND_REQUEST_CREATED, + RELATIONSHIP_SCAN_OUTCOME.FRIEND_REQUEST_PENDING, + RELATIONSHIP_SCAN_OUTCOME.ACTOR_ACCESS_GRANTED, + RELATIONSHIP_SCAN_OUTCOME.ACTOR_ACCESS_REQUEST_CREATED, + RELATIONSHIP_SCAN_OUTCOME.ACTOR_ACCESS_PENDING, + RELATIONSHIP_SCAN_OUTCOME.REMOTE_AGENT_ACCESS_GRANTED, + RELATIONSHIP_SCAN_OUTCOME.REMOTE_AGENT_ACCESS_REQUEST_CREATED, + RELATIONSHIP_SCAN_OUTCOME.REMOTE_AGENT_ACCESS_PENDING, +] as const +export const DIRECT_CONVERSATION_OPEN_STATUS = { + READY: "ready", + PENDING_APPROVAL: "pending_approval", +} as const +export const DIRECT_CONVERSATION_OPEN_STATUSES = [ + DIRECT_CONVERSATION_OPEN_STATUS.READY, + DIRECT_CONVERSATION_OPEN_STATUS.PENDING_APPROVAL, +] as const export const CANONICAL_FILE_CATEGORIES = [ "image", "audio", @@ -125,7 +266,88 @@ export const FILE_PARSE_OUTPUT_KINDS = [ "structured_json", "derived_file", ] as const -export const CONVERSATION_BOUNDARIES = ["internal", "external"] as const +export const CONVERSATION_KIND = { + GROUP: "group", + PRIVATE: "private", + VIRTUAL: "virtual", +} as const +export const CONVERSATION_KINDS = [ + CONVERSATION_KIND.GROUP, + CONVERSATION_KIND.PRIVATE, + CONVERSATION_KIND.VIRTUAL, +] as const +export const CONVERSATION_BOUNDARY = { + INTERNAL: "internal", + EXTERNAL: "external", +} as const +export const CONVERSATION_BOUNDARIES = [ + CONVERSATION_BOUNDARY.INTERNAL, + CONVERSATION_BOUNDARY.EXTERNAL, +] as const +export const CONVERSATION_PARTICIPANT_TYPE = { + WORKSPACE_MEMBER: "workspace_member", + ACTOR: "actor", + REMOTE_AGENT: "remote_agent", + EXTERNAL: "external", + SYSTEM: "system", +} as const +export const CONVERSATION_PARTICIPANT_TYPES = [ + CONVERSATION_PARTICIPANT_TYPE.WORKSPACE_MEMBER, + CONVERSATION_PARTICIPANT_TYPE.ACTOR, + CONVERSATION_PARTICIPANT_TYPE.REMOTE_AGENT, + CONVERSATION_PARTICIPANT_TYPE.EXTERNAL, + CONVERSATION_PARTICIPANT_TYPE.SYSTEM, +] as const +export const CONVERSATION_PARTICIPANT_STATE = { + ACTIVE: "active", + LEFT: "left", + REMOVED: "removed", +} as const +export const CONVERSATION_PARTICIPANT_STATES = [ + CONVERSATION_PARTICIPANT_STATE.ACTIVE, + CONVERSATION_PARTICIPANT_STATE.LEFT, + CONVERSATION_PARTICIPANT_STATE.REMOVED, +] as const +export const CONVERSATION_ITEM_SCOPE = { + SHARED: "shared", + PRIVATE: "private", +} as const +export const CONVERSATION_ITEM_SCOPES = [ + CONVERSATION_ITEM_SCOPE.SHARED, + CONVERSATION_ITEM_SCOPE.PRIVATE, +] as const +export const CONVERSATION_ITEM_SURFACE = { + VISIBLE: "visible", + INTERNAL: "internal", +} as const +export const CONVERSATION_ITEM_SURFACES = [ + CONVERSATION_ITEM_SURFACE.VISIBLE, + CONVERSATION_ITEM_SURFACE.INTERNAL, +] as const +export const CONVERSATION_ITEM_TYPE = { + MESSAGE: "message", + EVENT: "event", + SUMMARY: "summary", + CONTROL: "control", +} as const +export const CONVERSATION_ITEM_TYPES = [ + CONVERSATION_ITEM_TYPE.MESSAGE, + CONVERSATION_ITEM_TYPE.EVENT, + CONVERSATION_ITEM_TYPE.SUMMARY, + CONVERSATION_ITEM_TYPE.CONTROL, +] as const +export const CONVERSATION_ITEM_ROLE = { + USER: "user", + ASSISTANT: "assistant", + SYSTEM: "system", + TOOL: "tool", +} as const +export const CONVERSATION_ITEM_ROLES = [ + CONVERSATION_ITEM_ROLE.USER, + CONVERSATION_ITEM_ROLE.ASSISTANT, + CONVERSATION_ITEM_ROLE.SYSTEM, + CONVERSATION_ITEM_ROLE.TOOL, +] as const export const CONVERSATION_TYPE_KEYS = [ "internal_private", "internal_group", @@ -229,17 +451,106 @@ export const INTERACTION_INPUT_QUESTION_TYPES = [ ] as const export const INTERACTION_DECISIONS = ["approve", "reject"] as const export const PLAN_APPROVAL_DECISIONS = ["approve", "revise"] as const +export const MODEL_GROUP_ROUTING_STRATEGY = { + WEIGHTED_RANDOM: "weighted_random", + ROUND_ROBIN: "round_robin", + PRIORITY_FAILOVER: "priority_failover", +} as const export const MODEL_GROUP_ROUTING_STRATEGIES = [ - "weighted_random", - "round_robin", - "priority_failover", + MODEL_GROUP_ROUTING_STRATEGY.WEIGHTED_RANDOM, + MODEL_GROUP_ROUTING_STRATEGY.ROUND_ROBIN, + MODEL_GROUP_ROUTING_STRATEGY.PRIORITY_FAILOVER, +] as const +export const MODEL_GROUP_OWNER_TYPE = { + PLATFORM: "platform", + WORKSPACE: "workspace", + WORKSPACE_MEMBER: "workspace_member", +} as const +export const MODEL_GROUP_OWNER_TYPES = [ + MODEL_GROUP_OWNER_TYPE.PLATFORM, + MODEL_GROUP_OWNER_TYPE.WORKSPACE, + MODEL_GROUP_OWNER_TYPE.WORKSPACE_MEMBER, +] as const +export const MODEL_GROUP_GRANT_SCOPE = { + PLATFORM: "platform", + WORKSPACE: "workspace", + WORKSPACE_MEMBER: "workspace_member", + ACTOR: "actor", +} as const +export const MODEL_GROUP_GRANT_SCOPES = [ + MODEL_GROUP_GRANT_SCOPE.PLATFORM, + MODEL_GROUP_GRANT_SCOPE.WORKSPACE, + MODEL_GROUP_GRANT_SCOPE.WORKSPACE_MEMBER, + MODEL_GROUP_GRANT_SCOPE.ACTOR, +] as const +export const MODEL_GROUP_GRANT_STATUS = { + ACTIVE: "active", + REVOKED: "revoked", +} as const +export const MODEL_GROUP_GRANT_STATUSES = [ + MODEL_GROUP_GRANT_STATUS.ACTIVE, + MODEL_GROUP_GRANT_STATUS.REVOKED, ] as const -export const MODEL_GROUP_GRANT_SCOPES = [ - "platform", - "workspace", - "workspace_member", - "actor", +export const REMOTE_AGENT_RUNTIME_KIND = { + CLAUDE_CODE: "claude_code", + CODEX: "codex", +} as const +export const REMOTE_AGENT_RUNTIME_KINDS = [ + REMOTE_AGENT_RUNTIME_KIND.CLAUDE_CODE, + REMOTE_AGENT_RUNTIME_KIND.CODEX, +] as const +export const REMOTE_AGENT_RUNTIME_STATE = { + OFFLINE: "offline", + IDLE: "idle", + RUNNING: "running", + WAITING_USER_INPUT: "waiting_user_input", + PLAN_DRAFTING: "plan_drafting", + WAITING_PLAN_APPROVAL: "waiting_plan_approval", + ERROR: "error", +} as const +export const REMOTE_AGENT_RUNTIME_STATES = [ + REMOTE_AGENT_RUNTIME_STATE.OFFLINE, + REMOTE_AGENT_RUNTIME_STATE.IDLE, + REMOTE_AGENT_RUNTIME_STATE.RUNNING, + REMOTE_AGENT_RUNTIME_STATE.WAITING_USER_INPUT, + REMOTE_AGENT_RUNTIME_STATE.PLAN_DRAFTING, + REMOTE_AGENT_RUNTIME_STATE.WAITING_PLAN_APPROVAL, + REMOTE_AGENT_RUNTIME_STATE.ERROR, +] as const +export const REMOTE_AGENT_RUNTIME_CATALOG_STATUS = { + AVAILABLE: "available", + MISSING_BINARY: "missing_binary", + BROKEN_PATH: "broken_path", + UNSUPPORTED_PLATFORM: "unsupported_platform", + RUNTIME_ERROR: "runtime_error", +} as const +export const REMOTE_AGENT_RUNTIME_CATALOG_STATUSES = [ + REMOTE_AGENT_RUNTIME_CATALOG_STATUS.AVAILABLE, + REMOTE_AGENT_RUNTIME_CATALOG_STATUS.MISSING_BINARY, + REMOTE_AGENT_RUNTIME_CATALOG_STATUS.BROKEN_PATH, + REMOTE_AGENT_RUNTIME_CATALOG_STATUS.UNSUPPORTED_PLATFORM, + REMOTE_AGENT_RUNTIME_CATALOG_STATUS.RUNTIME_ERROR, +] as const +export const REMOTE_AGENT_MACHINE_TRUST_STATUS = { + PENDING: "pending", + ACTIVE: "active", + REVOKED: "revoked", + BLOCKED: "blocked", +} as const +export const REMOTE_AGENT_MACHINE_TRUST_STATUSES = [ + REMOTE_AGENT_MACHINE_TRUST_STATUS.PENDING, + REMOTE_AGENT_MACHINE_TRUST_STATUS.ACTIVE, + REMOTE_AGENT_MACHINE_TRUST_STATUS.REVOKED, + REMOTE_AGENT_MACHINE_TRUST_STATUS.BLOCKED, +] as const +export const REMOTE_AGENT_MACHINE_LIFECYCLE_STATE = { + ONLINE: "online", + OFFLINE: "offline", +} as const +export const REMOTE_AGENT_MACHINE_LIFECYCLE_STATES = [ + REMOTE_AGENT_MACHINE_LIFECYCLE_STATE.ONLINE, + REMOTE_AGENT_MACHINE_LIFECYCLE_STATE.OFFLINE, ] as const export const ACTOR_ROLES = [ diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 1917fd98..2434979b 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -3,12 +3,23 @@ import { ACTOR_ROLES, ACCESS_TARGET_TYPES, CAPABILITY_ACCESS_TARGET_TYPES, + CONTACT_DIRECT_STATES, + CONTACT_HUB_KINDS, + CONTACT_TARGET_TYPES, ATTACHMENT_TARGET_TYPES, AUTH_CLIENT_TYPES, AUTH_QR_LOGIN_STATUSES, AUTH_SESSION_PERSISTENCES, AUTH_TRANSPORTS, CANONICAL_FILE_CATEGORIES, + CONVERSATION_ITEM_ROLES, + CONVERSATION_ITEM_SCOPES, + CONVERSATION_ITEM_SURFACES, + CONVERSATION_ITEM_TYPES, + CONVERSATION_KINDS, + CONVERSATION_PARTICIPANT_TYPE, + CONVERSATION_PARTICIPANT_STATES, + CONVERSATION_PARTICIPANT_TYPES, FILE_ORIGIN_SYSTEMS, FILE_ORIGIN_FAMILIES, USER_UPLOAD_FILE_ORIGIN_SYSTEMS, @@ -31,6 +42,8 @@ import { INTERACTION_REQUEST_KIND, INTERACTION_REQUEST_KINDS, INTERACTION_REQUEST_STATUSES, + IDENTITY_SEARCH_MATCH_STATES, + IDENTITY_SEARCH_OUTCOMES, MEMORY_CATEGORIES, MEMORY_INDEX_STATUSES, MEMORY_ITEM_STATES, @@ -45,6 +58,11 @@ import { PLAN_APPROVAL_DECISIONS, TARGETED_INTERACTION_REQUEST_KINDS, REUSE_SCOPES, + RELATIONSHIP_ACCESS_POLICIES, + RELATIONSHIP_APPROVAL_MODES, + RELATIONSHIP_PROFILE_SUBJECT_TYPES, + RELATIONSHIP_REQUEST_STATUSES, + RELATIONSHIP_SCAN_OUTCOMES, RELAY_ACCESS_DENIAL_KINDS, RELAY_ACCESS_DENIAL_RESOLUTIONS, RELAY_AUTHORIZATION_BROWSER_ACTIONS, @@ -69,6 +87,16 @@ import { SESSION_WAKEUP_SOURCE_TYPES, SESSION_WAKEUP_STATUSES, TASK_NOTICE_STATUSES, + DIRECT_CONVERSATION_OPEN_STATUSES, + MODEL_GROUP_GRANT_SCOPES, + MODEL_GROUP_GRANT_STATUSES, + MODEL_GROUP_OWNER_TYPES, + MODEL_GROUP_ROUTING_STRATEGIES, + REMOTE_AGENT_MACHINE_LIFECYCLE_STATES, + REMOTE_AGENT_MACHINE_TRUST_STATUSES, + REMOTE_AGENT_RUNTIME_CATALOG_STATUSES, + REMOTE_AGENT_RUNTIME_KINDS, + REMOTE_AGENT_RUNTIME_STATES, PLAN_CHECKLIST_STEP_STATUSES, TRANSPORT_ACCOUNT_INBOUND_ACTOR_MODES, TRANSPORT_ACCOUNT_OWNER_SCOPES, @@ -1054,18 +1082,39 @@ export interface ActorRuntimeState { updatedAt: Timestamp } +export type RelationshipProfileSubjectType = + (typeof RELATIONSHIP_PROFILE_SUBJECT_TYPES)[number] +export type RelationshipApprovalMode = + (typeof RELATIONSHIP_APPROVAL_MODES)[number] +export type RelationshipAccessPolicy = + (typeof RELATIONSHIP_ACCESS_POLICIES)[number] +export type RelationshipRequestStatus = + (typeof RELATIONSHIP_REQUEST_STATUSES)[number] +export type ContactTargetType = (typeof CONTACT_TARGET_TYPES)[number] +export type ContactHubKind = (typeof CONTACT_HUB_KINDS)[number] +export type ContactDirectStateType = (typeof CONTACT_DIRECT_STATES)[number] +export type IdentitySearchOutcome = (typeof IDENTITY_SEARCH_OUTCOMES)[number] +export type IdentitySearchMatchState = + (typeof IDENTITY_SEARCH_MATCH_STATES)[number] +export type RelationshipScanOutcome = + (typeof RELATIONSHIP_SCAN_OUTCOMES)[number] +export type DirectConversationOpenStatus = + (typeof DIRECT_CONVERSATION_OPEN_STATUSES)[number] + +export type RemoteAgentRuntimeKind = (typeof REMOTE_AGENT_RUNTIME_KINDS)[number] +export type RemoteAgentAccessPolicy = RelationshipAccessPolicy export type RemoteAgentRuntimeStateType = - | "offline" - | "idle" - | "running" - | "waiting_user_input" - | "plan_drafting" - | "waiting_plan_approval" - | "error" + (typeof REMOTE_AGENT_RUNTIME_STATES)[number] +export type RemoteAgentRuntimeCatalogStatus = + (typeof REMOTE_AGENT_RUNTIME_CATALOG_STATUSES)[number] +export type RemoteAgentMachineTrustStatus = + (typeof REMOTE_AGENT_MACHINE_TRUST_STATUSES)[number] +export type RemoteAgentLifecycleState = + (typeof REMOTE_AGENT_MACHINE_LIFECYCLE_STATES)[number] export interface RemoteAgentRuntimeState { remoteAgentId: UUID - runtimeKind: "claude_code" | "codex" + runtimeKind: RemoteAgentRuntimeKind state: RemoteAgentRuntimeStateType statusText?: string activeConversationId?: UUID @@ -1083,6 +1132,348 @@ export interface RemoteAgentRuntimeState { updatedAt: Timestamp } +export interface RelationshipWorkspaceSummary { + id: UUID + name: string + slug: string +} + +export interface RelationshipProfileView { + subjectType: RelationshipProfileSubjectType + approvalMode: RelationshipApprovalMode + qrToken: string + qrUrl: string + identityId: string + identitySearchEnabled: boolean + accessPolicy?: RelationshipAccessPolicy + isPublicShared?: boolean +} + +export interface ContactHubEntryRef { + kind: ContactHubKind + id: string +} + +export interface ContactHubDirectState { + status: ContactDirectStateType + conversationId?: string +} + +export interface ContactHubEntryView { + kind: ContactHubKind + id: string + targetType: ContactTargetType + title: string + subtitle?: string + avatarUrl?: string + avatarEmoji?: string + workspace: RelationshipWorkspaceSummary + workspaceMemberId?: string + userId?: string + actorId?: string + remoteAgentId?: string + relationLabel: string + directState: ContactHubDirectState +} + +export interface IdentitySearchMatchView { + profileId: UUID + targetType: ContactTargetType + title: string + subtitle?: string + avatarUrl?: string + avatarEmoji?: string + workspace: RelationshipWorkspaceSummary + workspaceMemberId?: string + userId?: string + actorId?: string + remoteAgentId?: string + state: IdentitySearchMatchState + contact?: ContactHubEntryRef + conversationId?: UUID + requestId?: UUID +} + +export interface IdentitySearchResponse { + query: string + outcome: IdentitySearchOutcome + matches: IdentitySearchMatchView[] +} + +export interface RelationshipMemberSummaryView { + workspace: RelationshipWorkspaceSummary + workspaceMemberId: UUID + userId: UUID + name: string + email: string + avatarFileId?: UUID | null + trustLevel?: string +} + +export interface RelationshipActorSummaryView { + workspace: RelationshipWorkspaceSummary + actorId: UUID + name: string + title: string + role: string + avatarFileId?: UUID | null + avatarEmoji?: string | null + accessPolicy: RelationshipAccessPolicy + isPublicShared: boolean +} + +export interface RelationshipRemoteAgentSummaryView { + workspace: RelationshipWorkspaceSummary + remoteAgentId: UUID + name: string + title: string + runtimeKind: RemoteAgentRuntimeKind + avatarFileId?: UUID | null + avatarEmoji?: string | null + accessPolicy: RelationshipAccessPolicy + isPublicShared: boolean +} + +export interface FriendRequestView { + id: UUID + status: RelationshipRequestStatus + createdAt: Timestamp + requester?: RelationshipMemberSummaryView | null + targetType: ContactTargetType + targetMember?: RelationshipMemberSummaryView | null + targetActor?: RelationshipActorSummaryView | null + targetRemoteAgent?: RelationshipRemoteAgentSummaryView | null +} + +export interface FriendRequestListResponse { + incoming: FriendRequestView[] + outgoing: FriendRequestView[] +} + +export interface ActorAccessRequestView { + id: UUID + status: RelationshipRequestStatus + createdAt: Timestamp + requester?: RelationshipMemberSummaryView | null + actor?: RelationshipActorSummaryView | null +} + +export interface ActorAccessRequestListResponse { + incoming: ActorAccessRequestView[] + outgoing: ActorAccessRequestView[] +} + +export interface RemoteAgentAccessRequestView { + id: UUID + status: RelationshipRequestStatus + createdAt: Timestamp + requester?: RelationshipMemberSummaryView | null + remoteAgent?: RelationshipRemoteAgentSummaryView | null +} + +export interface RemoteAgentAccessRequestListResponse { + incoming: RemoteAgentAccessRequestView[] + outgoing: RemoteAgentAccessRequestView[] +} + +export interface ConversationParticipantView { + memberId?: UUID + participantId?: UUID + participantType?: Exclude + id?: UUID + workspaceMemberId?: UUID + actorId?: UUID + remoteAgentId?: UUID + name?: string + title?: string + role?: string + conversationRole?: string + avatarUrl?: string + avatarEmoji?: string + state?: (typeof CONVERSATION_PARTICIPANT_STATES)[number] +} + +export interface ConversationMessagePreview { + content: string + role: Exclude<(typeof CONVERSATION_ITEM_ROLES)[number], "tool"> + actorName?: string + createdAt: Timestamp +} + +export interface ConversationPresentationView { + chatType: "direct" | "group" | "virtual" + title: string + avatarUrl?: string + subtitle?: string + peer?: ConversationParticipantView + canRename?: boolean + canManageMembers?: boolean +} + +export interface ConversationSummaryView { + id: UUID + kind: (typeof CONVERSATION_KINDS)[number] + boundary: ConversationBoundary + status: "active" | "completed" + transportKind?: string + participants: ConversationParticipantView[] + members?: ConversationParticipantView[] + lastMessage?: ConversationMessagePreview + unreadCount: number + createdAt: Timestamp + title: string + name: string + avatarUrl?: string + presentation?: ConversationPresentationView + permissions?: { + canManage?: boolean + canManageMembers?: boolean + } + viewerParticipantId?: UUID + viewerWorkspaceMemberId?: UUID +} + +export interface ContactHubResponse { + requestSummary: { + friendPendingCount: number + actorAccessPendingCount: number + remoteAgentAccessPendingCount: number + totalPendingCount: number + } + workspaceActors: ContactHubEntryView[] + workspaceRemoteAgents: ContactHubEntryView[] + workspaceMembers: ContactHubEntryView[] + friends: ContactHubEntryView[] + groups: ConversationSummaryView[] +} + +export interface ContactHubDetailResponse { + contact: ContactHubEntryView + groups: ConversationSummaryView[] +} + +export interface RelationshipScanResponse { + outcome: RelationshipScanOutcome + requestId?: UUID + contact?: ContactHubEntryRef +} + +export interface DirectConversationOpenResponse { + status: DirectConversationOpenStatus + created?: boolean + conversationId?: UUID + requestId?: UUID +} + +export interface RemoteAgentRuntimeCapabilityView { + supportsRequestUserInput?: boolean + supportsPlanMode?: boolean + supportsPersistentSession?: boolean + supportsCodexAppServer?: boolean + supportsStructuredIo?: boolean +} + +export interface RemoteAgentRuntimeSummaryView { + runtimeKind: RemoteAgentRuntimeKind + state: RemoteAgentRuntimeState["state"] + statusText?: string + sessionId?: string + activeConversationId?: UUID + activeInteractionId?: UUID + pendingConversationCount: number + unreadDeliveryCount: number + lastActivityAt?: Timestamp + lastRunStartedAt?: Timestamp + lastRunFinishedAt?: Timestamp + lastError?: string + capabilities?: RemoteAgentRuntimeCapabilityView +} + +export interface RemoteAgentGroupInteractionGrantView { + workspaceMemberId: UUID + grantedByWorkspaceMemberId?: UUID + createdAt?: Timestamp + updatedAt?: Timestamp + userId: UUID + name: string + avatarUrl?: string +} + +export interface RemoteAgentBindingView { + machineId: UUID + machineTitle?: string + status: string + runtimePath?: string + localRootPath?: string + machineLifecycleState?: RemoteAgentLifecycleState + runtimeSummary?: RemoteAgentRuntimeSummaryView +} + +export interface RemoteAgentView { + id: UUID + workspaceId: UUID + name: string + title: string + description?: string + runtimeKind: RemoteAgentRuntimeKind + avatarFileId?: UUID + avatarEmoji?: string + accessPolicy: RemoteAgentAccessPolicy + isActive: boolean + isPublicShared: boolean + metadata: Record + createdByWorkspaceMemberId?: UUID + createdAt?: Timestamp + updatedAt?: Timestamp + runtimeSummary?: RemoteAgentRuntimeSummaryView + binding?: RemoteAgentBindingView +} + +export interface RemoteAgentRuntimeCatalogEntryView { + runtimeKind: RemoteAgentRuntimeKind + executablePath?: string + status: RemoteAgentRuntimeCatalogStatus + version?: string + metadata: Record + lastError?: string + lastSeenAt?: Timestamp +} + +export interface RemoteAgentMachineView { + id: UUID + workspaceId: UUID + title: string + description?: string + trustStatus: RemoteAgentMachineTrustStatus + lifecycleState?: RemoteAgentLifecycleState + bindingCount: number + lastSeenAt?: Timestamp + createdAt?: Timestamp + updatedAt?: Timestamp +} + +export interface RemoteAgentMachineDetailView { + machine: Omit & { + bindingCount?: number + } + runtimeCatalog: RemoteAgentRuntimeCatalogEntryView[] + bindings: Array<{ + remoteAgentId: UUID + name: string + runtimeKind: RemoteAgentRuntimeKind + runtimePath?: string + localRootPath?: string + status: string + runtimeSummary?: RemoteAgentRuntimeSummaryView + }> +} + +export interface RemoteAgentMachinePairingSessionView { + machine: Omit + apiKey: string + daemonCommand: string +} + export interface SessionMessage { id: UUID sessionId: UUID @@ -1140,23 +1531,25 @@ export interface ServerToolSearchResult { } // ============ Model Groups ============ -export type RoutingStrategy = - | "weighted_random" - | "round_robin" - | "priority_failover" +export type ModelGroupRoutingStrategy = + (typeof MODEL_GROUP_ROUTING_STRATEGIES)[number] +export type ModelGroupOwnerType = (typeof MODEL_GROUP_OWNER_TYPES)[number] +export type ModelGroupGrantScope = (typeof MODEL_GROUP_GRANT_SCOPES)[number] +export type ModelGroupGrantStatus = (typeof MODEL_GROUP_GRANT_STATUSES)[number] +export type RoutingStrategy = ModelGroupRoutingStrategy export type ProviderType = string export type AIRequestType = "actor_think" | "ai_complete" export type AIRequestStatus = "success" | "error" | "timeout" export interface ModelGroup { id: UUID - ownerType?: "platform" | "workspace" | "workspace_member" + ownerType?: ModelGroupOwnerType ownerWorkspaceId?: UUID | null ownerWorkspaceMemberId?: UUID | null workspaceId?: UUID name: string description: string - routingStrategy: RoutingStrategy + routingStrategy: ModelGroupRoutingStrategy isDefault: boolean isActive: boolean createdByWorkspaceMemberId?: UUID @@ -1203,11 +1596,11 @@ export interface ActorModelGroup { export interface ModelGroupGrant { id: UUID groupId: UUID - grantScope: "platform" | "workspace" | "workspace_member" | "actor" + grantScope: ModelGroupGrantScope workspaceId?: UUID | null workspaceMemberId?: UUID | null actorId?: UUID | null - status: "active" | "revoked" + status: ModelGroupGrantStatus grantedByWorkspaceMemberId?: UUID | null reason?: string | null createdAt?: Timestamp | null @@ -1272,7 +1665,7 @@ export interface ModelAttemptPolicy { export interface ResolvedModelPlan { groupId: UUID groupName: string - routingStrategy: "weighted_random" | "round_robin" | "priority_failover" + routingStrategy: ModelGroupRoutingStrategy attemptPolicy: ModelAttemptPolicy candidates: ResolvedModelConfig[] } @@ -2732,11 +3125,7 @@ export interface McpSetupStep { } export type ConversationParticipantType = - | "actor" - | "remote_agent" - | "workspace_member" - | "external" - | "system" + (typeof CONVERSATION_PARTICIPANT_TYPES)[number] export type TransportKind = (typeof TRANSPORT_KINDS)[number] export type TransportConnectionMode = @@ -2766,7 +3155,7 @@ export interface ConversationReplyRef { itemId: UUID ref?: string sequence?: number - itemType: "message" | "event" | "summary" | "control" + itemType: (typeof CONVERSATION_ITEM_TYPES)[number] subtype: string author?: ConversationEntityRef previewText: string @@ -2777,7 +3166,7 @@ export interface ConversationReplyRef { export type ConversationParticipantRef = ConversationEntityRef & { participantId: UUID - participantType: "actor" | "remote_agent" | "workspace_member" | "external" + participantType: Exclude } export interface TransportConnectorCapability { @@ -3677,7 +4066,8 @@ export function summarizeConversationEvent( if (interaction.kind === INTERACTION_REQUEST_KIND.USER_INPUT) { const targetName = interaction.target?.name?.trim() || - (interaction.requester?.participantType === "remote_agent" + (interaction.requester?.participantType === + CONVERSATION_PARTICIPANT_TYPE.REMOTE_AGENT ? "the group" : "a user") const prompt = interaction.userInput?.title?.trim() || "A question" @@ -3691,7 +4081,8 @@ export function summarizeConversationEvent( if (interaction.kind === INTERACTION_REQUEST_KIND.PLAN_APPROVAL) { const targetName = interaction.target?.name?.trim() || - (interaction.requester?.participantType === "remote_agent" + (interaction.requester?.participantType === + CONVERSATION_PARTICIPANT_TYPE.REMOTE_AGENT ? "the group" : "a user") const title = interaction.planApproval?.title?.trim() || "Plan approval" @@ -3767,7 +4158,7 @@ export interface ChatParticipantSummary extends Omit< participantType: ConversationParticipantType name: string roleKey: string - state: "active" | "left" | "removed" + state: (typeof CONVERSATION_PARTICIPANT_STATES)[number] metadata: Record joinedAt: Timestamp leftAt?: Timestamp @@ -3782,10 +4173,10 @@ interface ChatConversationItemBase { sessionId?: UUID turnId?: UUID clientMessageId?: UUID - itemType: "message" | "event" | "summary" | "control" - role: "user" | "assistant" | "system" | "tool" - scope: "shared" | "private" - surface: "visible" | "internal" + itemType: (typeof CONVERSATION_ITEM_TYPES)[number] + role: (typeof CONVERSATION_ITEM_ROLES)[number] + scope: (typeof CONVERSATION_ITEM_SCOPES)[number] + surface: (typeof CONVERSATION_ITEM_SURFACES)[number] authorParticipantId?: UUID author?: ConversationEntityRef replyToItemId?: UUID @@ -3843,8 +4234,8 @@ export interface ChatConversationView { conversationId: UUID workspaceId: UUID title: string - kind: "group" | "private" | "virtual" - boundary: "internal" | "external" + kind: (typeof CONVERSATION_KINDS)[number] + boundary: ConversationBoundary status: "active" | "completed" unreadCount: number muted: boolean @@ -3983,8 +4374,8 @@ export interface ChatConversationCreateExternalParticipantRequest { export interface ChatConversationCreateRequest { clientRequestId: UUID - kind: "group" | "private" | "virtual" - boundary?: "internal" | "external" + kind: (typeof CONVERSATION_KINDS)[number] + boundary?: ConversationBoundary title?: string workspaceMemberIds?: UUID[] actorIds?: UUID[] diff --git a/packages/shared/src/utils/index.ts b/packages/shared/src/utils/index.ts index 3c0f5cef..17203301 100644 --- a/packages/shared/src/utils/index.ts +++ b/packages/shared/src/utils/index.ts @@ -1,4 +1,6 @@ import { + CONVERSATION_BOUNDARY, + CONVERSATION_KIND, CONVERSATION_TYPE_KEYS, CONVERSATION_TYPE_MASK_BITS, CONVERSATION_TYPE_MASK_PRESETS, @@ -46,9 +48,9 @@ export function nowISO(): string { return new Date().toISOString() } -export const GROUP_CONVERSATION_KIND = "group" -export const PRIVATE_CONVERSATION_KIND = "private" -export const VIRTUAL_CONVERSATION_KIND = "virtual" +export const GROUP_CONVERSATION_KIND = CONVERSATION_KIND.GROUP +export const PRIVATE_CONVERSATION_KIND = CONVERSATION_KIND.PRIVATE +export const VIRTUAL_CONVERSATION_KIND = CONVERSATION_KIND.VIRTUAL export const THREAD_CONVERSATION_KINDS = [ GROUP_CONVERSATION_KIND, PRIVATE_CONVERSATION_KIND, @@ -124,10 +126,14 @@ export function resolveConversationTypeKey( return "virtual" } if (kind === PRIVATE_CONVERSATION_KIND) { - return boundary === "external" ? "external_private" : "internal_private" + return boundary === CONVERSATION_BOUNDARY.EXTERNAL + ? "external_private" + : "internal_private" } if (kind === GROUP_CONVERSATION_KIND) { - return boundary === "external" ? "external_group" : "internal_group" + return boundary === CONVERSATION_BOUNDARY.EXTERNAL + ? "external_group" + : "internal_group" } return null } diff --git a/packages/web-next/app/dashboard/chat/chat-member-strip.tsx b/packages/web-next/app/dashboard/chat/chat-member-strip.tsx index 38864800..53395ce7 100644 --- a/packages/web-next/app/dashboard/chat/chat-member-strip.tsx +++ b/packages/web-next/app/dashboard/chat/chat-member-strip.tsx @@ -33,30 +33,34 @@ function orderMembers( runtimeByRemoteAgent?: Record ) { return [...members].sort((left, right) => { - const leftIsAgent = left.type === "actor" || left.type === "remote_agent" - const rightIsAgent = right.type === "actor" || right.type === "remote_agent" + const leftIsAgent = + left.participantType === "actor" || + left.participantType === "remote_agent" + const rightIsAgent = + right.participantType === "actor" || + right.participantType === "remote_agent" if (!leftIsAgent || !rightIsAgent) { if (leftIsAgent !== rightIsAgent) { return leftIsAgent ? -1 : 1 } - if (left.type === right.type) return 0 - return left.type.localeCompare(right.type) + if (left.participantType === right.participantType) return 0 + return left.participantType.localeCompare(right.participantType) } const leftPriority = - left.type === "actor" + left.participantType === "actor" ? getActorRuntimePriority(runtimeByActor?.[left.id]) : getRemoteAgentRuntimePriority(runtimeByRemoteAgent?.[left.id]) const rightPriority = - right.type === "actor" + right.participantType === "actor" ? getActorRuntimePriority(runtimeByActor?.[right.id]) : getRemoteAgentRuntimePriority(runtimeByRemoteAgent?.[right.id]) if (leftPriority !== rightPriority) { return leftPriority - rightPriority } - if (left.type !== right.type) { - return left.type === "actor" ? -1 : 1 + if (left.participantType !== right.participantType) { + return left.participantType === "actor" ? -1 : 1 } return left.name.localeCompare(right.name) }) @@ -102,9 +106,9 @@ export default function ChatMemberStrip({ {visibleMembers.map((member) => { const runtime = - member.type === "actor" + member.participantType === "actor" ? runtimeByActor?.[member.id] - : member.type === "remote_agent" + : member.participantType === "remote_agent" ? runtimeByRemoteAgent?.[member.id] : undefined const href = getConversationMemberContactHref(member, contactBasePath) @@ -115,10 +119,10 @@ export default function ChatMemberStrip({ name={member.name} avatarUrl={member.avatarUrl} emoji={member.emoji} - entityType={member.type} + entityType={member.participantType} size={size} statusState={ - member.type === "remote_agent" + member.participantType === "remote_agent" ? remoteAgentRuntimeToAvatarStatus( runtime as RemoteAgentRuntimeState | undefined ) @@ -141,7 +145,7 @@ export default function ChatMemberStrip({ ) return !isMobile ? ( @@ -149,7 +153,7 @@ export default function ChatMemberStrip({ ) : canOpen ? ( ) : (
{avatar} diff --git a/packages/web-next/app/dashboard/chat/chat-mentions-input.tsx b/packages/web-next/app/dashboard/chat/chat-mentions-input.tsx index 3dcf212f..a15616e1 100644 --- a/packages/web-next/app/dashboard/chat/chat-mentions-input.tsx +++ b/packages/web-next/app/dashboard/chat/chat-mentions-input.tsx @@ -12,7 +12,7 @@ import ChatAvatar from "./chat-avatar" export type MentionableParticipant = { id: string name: string - type: "actor" | "remote_agent" | "workspace_member" | "external" + participantType: "actor" | "remote_agent" | "workspace_member" | "external" role?: string avatarUrl?: string emoji?: string @@ -23,7 +23,7 @@ export type MentionableParticipant = { type ParticipantSuggestion = { id: string display: string - type: "actor" | "remote_agent" | "workspace_member" | "external" + participantType: "actor" | "remote_agent" | "workspace_member" | "external" role?: string avatarUrl?: string emoji?: string @@ -121,7 +121,7 @@ export default function ChatMentionsInput({ participants.map((participant) => ({ id: participant.id, display: participant.name, - type: participant.type, + participantType: participant.participantType, role: participant.role, avatarUrl: participant.avatarUrl, emoji: participant.emoji, @@ -197,7 +197,7 @@ export default function ChatMentionsInput({ name={participant.display} avatarUrl={participant.avatarUrl} emoji={participant.emoji} - entityType={participant.type} + entityType={participant.participantType} size="sm" />
@@ -206,11 +206,11 @@ export default function ChatMentionsInput({
{participant.description || - (participant.type === "actor" + (participant.participantType === "actor" ? participant.role || "Actor" - : participant.type === "remote_agent" + : participant.participantType === "remote_agent" ? participant.role || "Remote agent" - : participant.type === "external" + : participant.participantType === "external" ? "External participant" : "Workspace user")}
diff --git a/packages/web-next/app/dashboard/chat/chat-participant-detail-dialog.tsx b/packages/web-next/app/dashboard/chat/chat-participant-detail-dialog.tsx index c9fcfd02..7d707a9b 100644 --- a/packages/web-next/app/dashboard/chat/chat-participant-detail-dialog.tsx +++ b/packages/web-next/app/dashboard/chat/chat-participant-detail-dialog.tsx @@ -1,5 +1,6 @@ "use client" +import { CONVERSATION_PARTICIPANT_TYPE } from "@synapse/shared" import type { ReactNode } from "react" import { useRouter } from "next/navigation" import { ArrowUpRight } from "lucide-react" @@ -56,7 +57,7 @@ function ParticipantDetailBody({ name={member.name} avatarUrl={member.avatarUrl} emoji={member.emoji} - entityType={member.type} + entityType={member.participantType} size="lg" className="size-16 shrink-0" /> @@ -76,22 +77,26 @@ function ParticipantDetailBody({

{subtitle}

- {member.type === "actor" ? ( + {member.participantType === + CONVERSATION_PARTICIPANT_TYPE.ACTOR ? ( {actorRole} - ) : member.type === "remote_agent" ? ( + ) : member.participantType === + CONVERSATION_PARTICIPANT_TYPE.REMOTE_AGENT ? ( {remoteAgentRole} ) : null} - {member.type === "external" && + {member.participantType === + CONVERSATION_PARTICIPANT_TYPE.EXTERNAL && member.linkedWorkspaceMemberName ? ( Linked to {member.linkedWorkspaceMemberName} ) : null} - {member.type === "external" && + {member.participantType === + CONVERSATION_PARTICIPANT_TYPE.EXTERNAL && !member.linkedWorkspaceMemberName ? ( No workspace link @@ -110,12 +115,15 @@ function ParticipantDetailBody({
- {member.type === "actor" ? ( + {member.participantType === + CONVERSATION_PARTICIPANT_TYPE.ACTOR ? ( - ) : member.type === "remote_agent" ? ( + ) : member.participantType === + CONVERSATION_PARTICIPANT_TYPE.REMOTE_AGENT ? ( ) : null} - {member.type === "workspace_member" ? ( + {member.participantType === + CONVERSATION_PARTICIPANT_TYPE.WORKSPACE_MEMBER ? ( ) : null} - {member.type !== "actor" && transportLabel ? ( + {member.participantType !== CONVERSATION_PARTICIPANT_TYPE.ACTOR && + transportLabel ? ( ) : null} - {member.type === "external" && member.externalUserKey ? ( + {member.participantType === + CONVERSATION_PARTICIPANT_TYPE.EXTERNAL && + member.externalUserKey ? ( - {member.type === "external" ? ( + {member.participantType === CONVERSATION_PARTICIPANT_TYPE.EXTERNAL ? (
Workspace link @@ -159,7 +170,7 @@ function ParticipantDetailBody({ diff --git a/packages/web-next/app/dashboard/chat/chat-participant-hover-card.tsx b/packages/web-next/app/dashboard/chat/chat-participant-hover-card.tsx index 195c00e3..cc33fb9a 100644 --- a/packages/web-next/app/dashboard/chat/chat-participant-hover-card.tsx +++ b/packages/web-next/app/dashboard/chat/chat-participant-hover-card.tsx @@ -1,5 +1,6 @@ "use client" +import { CONVERSATION_PARTICIPANT_TYPE } from "@synapse/shared" import type { ComponentProps, ReactNode } from "react" import { useRouter } from "next/navigation" import { ArrowUpRight, Link2 } from "lucide-react" @@ -32,13 +33,13 @@ function CompactDetail({ label, value }: { label: string; value: string }) { } function getCompactNote(member: ConversationMember) { - if (member.type === "actor") { + if (member.participantType === CONVERSATION_PARTICIPANT_TYPE.ACTOR) { return member.title || member.role || "Actor" } - if (member.type === "remote_agent") { + if (member.participantType === CONVERSATION_PARTICIPANT_TYPE.REMOTE_AGENT) { return member.title || member.role || "Remote agent" } - if (member.type === "external") { + if (member.participantType === CONVERSATION_PARTICIPANT_TYPE.EXTERNAL) { if (member.linkedWorkspaceMemberName) { return `Linked to ${member.linkedWorkspaceMemberName}` } @@ -92,7 +93,7 @@ export default function ChatParticipantHoverCard({ name={member.name} avatarUrl={member.avatarUrl} emoji={member.emoji} - entityType={member.type} + entityType={member.participantType} size="lg" className="size-12" /> @@ -120,7 +121,8 @@ export default function ChatParticipantHoverCard({ {transportLabel} ) : null} - {member.type === "external" && + {member.participantType === + CONVERSATION_PARTICIPANT_TYPE.EXTERNAL && member.linkedWorkspaceMemberName ? ( @@ -133,7 +135,9 @@ export default function ChatParticipantHoverCard({
- {member.type === "external" && member.externalUserKey ? ( + {member.participantType === + CONVERSATION_PARTICIPANT_TYPE.EXTERNAL && + member.externalUserKey ? (
{member.externalUserKey}
diff --git a/packages/web-next/app/dashboard/chat/conversation-chat.tsx b/packages/web-next/app/dashboard/chat/conversation-chat.tsx index d8861f30..98540142 100644 --- a/packages/web-next/app/dashboard/chat/conversation-chat.tsx +++ b/packages/web-next/app/dashboard/chat/conversation-chat.tsx @@ -7,9 +7,11 @@ import type { ConversationReplyRef, RemoteAgentRuntimeState, } from "@synapse/shared" +import { CONVERSATION_PARTICIPANT_TYPE } from "@synapse/shared" import { useEffect, useMemo, useRef, useState } from "react" import { toast } from "sonner" import ChatComposer, { + CHAT_COMPOSER_MENTION_TARGET_TYPE, type ChatComposerParticipant, type ChatComposerSubmitPayload, } from "@/components/chat-composer" @@ -54,16 +56,19 @@ interface ConversationChatProps { function summarizeMemberCounts(conversation: ConversationSummary) { const workspaceMemberCount = conversation.members.filter( - (member) => member.type === "workspace_member" + (member) => + member.participantType === CONVERSATION_PARTICIPANT_TYPE.WORKSPACE_MEMBER ).length const actorCount = conversation.members.filter( - (member) => member.type === "actor" + (member) => member.participantType === CONVERSATION_PARTICIPANT_TYPE.ACTOR ).length const remoteAgentCount = conversation.members.filter( - (member) => member.type === "remote_agent" + (member) => + member.participantType === CONVERSATION_PARTICIPANT_TYPE.REMOTE_AGENT ).length const externalCount = conversation.members.filter( - (member) => member.type === "external" + (member) => + member.participantType === CONVERSATION_PARTICIPANT_TYPE.EXTERNAL ).length const workspaceMemberLabel = `${workspaceMemberCount} member${workspaceMemberCount === 1 ? "" : "s"}` const actorLabel = `${actorCount} actor${actorCount === 1 ? "" : "s"}` @@ -262,16 +267,22 @@ export default function ConversationChat({ const mentionableParticipants = useMemo(() => { const inConversationActorIds = new Set( conversation.members - .filter((member) => member.type === "actor") + .filter( + (member) => + member.participantType === CONVERSATION_PARTICIPANT_TYPE.ACTOR + ) .map((member) => member.id) ) const inConversationActors = conversation.members - .filter((member) => member.type === "actor") + .filter( + (member) => + member.participantType === CONVERSATION_PARTICIPANT_TYPE.ACTOR + ) .map((member) => ({ id: member.id, name: member.name, - type: "actor" as const, - targetType: "actor" as const, + participantType: CONVERSATION_PARTICIPANT_TYPE.ACTOR, + targetType: CHAT_COMPOSER_MENTION_TARGET_TYPE.ACTOR, participantId: member.participantId, actorId: member.id, inGroup: true, @@ -283,18 +294,29 @@ export default function ConversationChat({ searchTerms: buildMentionSearchTerms(member), })) const inConversationParticipants = conversation.members - .filter((member) => member.type !== "actor") + .filter( + (member) => + member.participantType !== CONVERSATION_PARTICIPANT_TYPE.ACTOR + ) .map((member) => ({ id: member.participantId, name: member.name, - type: member.type, - targetType: "participant" as const, + participantType: member.participantType, + targetType: CHAT_COMPOSER_MENTION_TARGET_TYPE.PARTICIPANT, participantId: member.participantId, - actorId: member.type === "actor" ? member.id : undefined, + actorId: + member.participantType === CONVERSATION_PARTICIPANT_TYPE.ACTOR + ? member.id + : undefined, workspaceMemberId: - member.type === "workspace_member" ? member.id : undefined, + member.participantType === + CONVERSATION_PARTICIPANT_TYPE.WORKSPACE_MEMBER + ? member.id + : undefined, externalUserKey: - member.type === "external" ? member.externalUserKey : undefined, + member.participantType === CONVERSATION_PARTICIPANT_TYPE.EXTERNAL + ? member.externalUserKey + : undefined, transportAddressId: member.transportAddressId, transportKind: member.transportKind, inGroup: true, @@ -303,11 +325,12 @@ export default function ConversationChat({ avatarUrl: member.avatarUrl, emoji: member.emoji, description: - member.type === "external" + member.participantType === CONVERSATION_PARTICIPANT_TYPE.EXTERNAL ? member.linkedWorkspaceMemberName ? `External participant · linked to ${member.linkedWorkspaceMemberName}` : "External participant" - : member.type === "remote_agent" + : member.participantType === + CONVERSATION_PARTICIPANT_TYPE.REMOTE_AGENT ? member.title || member.role || "Remote agent" : "Workspace user", searchTerms: buildMentionSearchTerms(member), @@ -319,8 +342,8 @@ export default function ConversationChat({ return { id: normalized.id, name: normalized.name, - type: "actor" as const, - targetType: "actor" as const, + participantType: CONVERSATION_PARTICIPANT_TYPE.ACTOR, + targetType: CHAT_COMPOSER_MENTION_TARGET_TYPE.ACTOR, actorId: normalized.id, inGroup: false, role: normalized.role, @@ -347,7 +370,10 @@ export default function ConversationChat({ () => Object.fromEntries( conversation.members - .filter((member) => member.type === "actor") + .filter( + (member) => + member.participantType === CONVERSATION_PARTICIPANT_TYPE.ACTOR + ) .map((member) => [member.id, member]) ), [conversation.members] @@ -703,7 +729,8 @@ export default function ConversationChat({ : undefined } remoteAgentRuntime={ - msg.author?.participantType === "remote_agent" && + msg.author?.participantType === + CONVERSATION_PARTICIPANT_TYPE.REMOTE_AGENT && msg.author.remoteAgentId ? remoteAgentRuntimes?.[msg.author.remoteAgentId] : undefined @@ -711,7 +738,8 @@ export default function ConversationChat({ timestamp={msg.createdAt} isUser={ msg.author - ? msg.author.participantType === "workspace_member" && + ? msg.author.participantType === + CONVERSATION_PARTICIPANT_TYPE.WORKSPACE_MEMBER && msg.author.workspaceMemberId === currentViewerWorkspaceMemberId : msg.role === "user" diff --git a/packages/web-next/app/dashboard/chat/member-utils.ts b/packages/web-next/app/dashboard/chat/member-utils.ts index 2751fddd..495a4378 100644 --- a/packages/web-next/app/dashboard/chat/member-utils.ts +++ b/packages/web-next/app/dashboard/chat/member-utils.ts @@ -1,17 +1,20 @@ "use client" -import type { ConversationEntityRef } from "@synapse/shared" +import { + CONVERSATION_PARTICIPANT_TYPE, + type ConversationEntityRef, +} from "@synapse/shared" import type { ConversationMember } from "@/stores/chat-store" export function getConversationMemberSubtitle(member: ConversationMember) { - if (member.type === "actor") { + if (member.participantType === CONVERSATION_PARTICIPANT_TYPE.ACTOR) { return member.title || member.role || "Actor" } - if (member.type === "remote_agent") { + if (member.participantType === CONVERSATION_PARTICIPANT_TYPE.REMOTE_AGENT) { return member.title || member.role || "Remote agent" } - if (member.type === "external") { + if (member.participantType === CONVERSATION_PARTICIPANT_TYPE.EXTERNAL) { return member.linkedWorkspaceMemberName ? `External participant · linked to ${member.linkedWorkspaceMemberName}` : "External participant" @@ -23,9 +26,12 @@ export function getConversationMemberSubtitle(member: ConversationMember) { } export function getConversationMemberTypeLabel(member: ConversationMember) { - if (member.type === "actor") return "Actor" - if (member.type === "remote_agent") return "Remote agent" - if (member.type === "external") return "External participant" + if (member.participantType === CONVERSATION_PARTICIPANT_TYPE.ACTOR) + return "Actor" + if (member.participantType === CONVERSATION_PARTICIPANT_TYPE.REMOTE_AGENT) + return "Remote agent" + if (member.participantType === CONVERSATION_PARTICIPANT_TYPE.EXTERNAL) + return "External participant" return "Workspace user" } @@ -47,16 +53,21 @@ export function getConversationMemberContactHref( member: ConversationMember, basePath = "/dashboard/contacts" ) { - if (member.type === "actor") { + if (member.participantType === CONVERSATION_PARTICIPANT_TYPE.ACTOR) { return `${basePath}?kind=actor&id=${member.id}` } - if (member.type === "remote_agent") { + if (member.participantType === CONVERSATION_PARTICIPANT_TYPE.REMOTE_AGENT) { return `${basePath}?kind=remote_agent&id=${member.id}` } - if (member.type === "workspace_member") { + if ( + member.participantType === CONVERSATION_PARTICIPANT_TYPE.WORKSPACE_MEMBER + ) { return `${basePath}?kind=member&id=${member.id}` } - if (member.type === "external" && member.linkedWorkspaceMemberId) { + if ( + member.participantType === CONVERSATION_PARTICIPANT_TYPE.EXTERNAL && + member.linkedWorkspaceMemberId + ) { return `${basePath}?kind=member&id=${member.linkedWorkspaceMemberId}` } return undefined @@ -73,32 +84,34 @@ export function resolveAuthorMember( return true } if ( - author.participantType === "actor" && - member.type === "actor" && + author.participantType === CONVERSATION_PARTICIPANT_TYPE.ACTOR && + member.participantType === CONVERSATION_PARTICIPANT_TYPE.ACTOR && author.actorId && member.id === author.actorId ) { return true } if ( - author.participantType === "workspace_member" && - member.type === "workspace_member" && + author.participantType === + CONVERSATION_PARTICIPANT_TYPE.WORKSPACE_MEMBER && + member.participantType === + CONVERSATION_PARTICIPANT_TYPE.WORKSPACE_MEMBER && author.workspaceMemberId && member.id === author.workspaceMemberId ) { return true } if ( - author.participantType === "remote_agent" && - member.type === "remote_agent" && + author.participantType === CONVERSATION_PARTICIPANT_TYPE.REMOTE_AGENT && + member.participantType === CONVERSATION_PARTICIPANT_TYPE.REMOTE_AGENT && author.remoteAgentId && member.id === author.remoteAgentId ) { return true } if ( - author.participantType === "external" && - member.type === "external" && + author.participantType === CONVERSATION_PARTICIPANT_TYPE.EXTERNAL && + member.participantType === CONVERSATION_PARTICIPANT_TYPE.EXTERNAL && author.externalUserKey && member.externalUserKey === author.externalUserKey ) { @@ -117,14 +130,21 @@ export function getAuthorContactHref( if (authorMember) { return getConversationMemberContactHref(authorMember, basePath) } - if (author?.participantType === "actor" && author.actorId) { + if ( + author?.participantType === CONVERSATION_PARTICIPANT_TYPE.ACTOR && + author.actorId + ) { return `${basePath}?kind=actor&id=${author.actorId}` } - if (author?.participantType === "remote_agent" && author.remoteAgentId) { + if ( + author?.participantType === CONVERSATION_PARTICIPANT_TYPE.REMOTE_AGENT && + author.remoteAgentId + ) { return `${basePath}?kind=remote_agent&id=${author.remoteAgentId}` } if ( - author?.participantType === "workspace_member" && + author?.participantType === + CONVERSATION_PARTICIPANT_TYPE.WORKSPACE_MEMBER && author.workspaceMemberId ) { return `${basePath}?kind=member&id=${author.workspaceMemberId}` diff --git a/packages/web-next/app/dashboard/chat/message-bubble.tsx b/packages/web-next/app/dashboard/chat/message-bubble.tsx index 3918f182..81954dce 100644 --- a/packages/web-next/app/dashboard/chat/message-bubble.tsx +++ b/packages/web-next/app/dashboard/chat/message-bubble.tsx @@ -14,7 +14,10 @@ import type { InteractionRequestSummary, RemoteAgentRuntimeState, } from "@synapse/shared" -import { INTERACTION_REQUEST_KIND } from "@synapse/shared" +import { + CONVERSATION_PARTICIPANT_TYPE, + INTERACTION_REQUEST_KIND, +} from "@synapse/shared" import type { RelayAuthorizationGrantSpec, RelayAuthorizationPreset, @@ -1622,19 +1625,20 @@ function resolveMentionMember( (mention.participantId && member.participantId === mention.participantId) || (mention.actorId && - member.type === "actor" && + member.participantType === CONVERSATION_PARTICIPANT_TYPE.ACTOR && member.id === mention.actorId) || (mention.workspaceMemberId && - member.type === "workspace_member" && + member.participantType === + CONVERSATION_PARTICIPANT_TYPE.WORKSPACE_MEMBER && member.id === mention.workspaceMemberId) || (mention.externalUserKey && - member.type === "external" && + member.participantType === CONVERSATION_PARTICIPANT_TYPE.EXTERNAL && member.externalUserKey === mention.externalUserKey) ) || null if (matchedMember) return matchedMember - if (mention.participantType === "actor") { + if (mention.participantType === CONVERSATION_PARTICIPANT_TYPE.ACTOR) { const actor = workspaceActors?.find( (candidate) => candidate.id === mention.actorId ) @@ -1643,7 +1647,7 @@ function resolveMentionMember( return { participantId: mention.participantId || fallbackId, - type: "actor" as const, + participantType: CONVERSATION_PARTICIPANT_TYPE.ACTOR, id: mention.actorId || actor?.id || fallbackId, name: mention.name || actor?.name || "Unknown actor", role: mention.role || actor?.role, @@ -1653,13 +1657,15 @@ function resolveMentionMember( } } - if (mention.participantType === "workspace_member") { + if ( + mention.participantType === CONVERSATION_PARTICIPANT_TYPE.WORKSPACE_MEMBER + ) { const fallbackId = mention.workspaceMemberId || mention.participantId || "unknown-user" return { participantId: mention.participantId || fallbackId, - type: "workspace_member" as const, + participantType: CONVERSATION_PARTICIPANT_TYPE.WORKSPACE_MEMBER, id: mention.workspaceMemberId || fallbackId, name: mention.name || "Unknown user", role: mention.role, @@ -1669,12 +1675,28 @@ function resolveMentionMember( } } + if (mention.participantType === CONVERSATION_PARTICIPANT_TYPE.REMOTE_AGENT) { + const fallbackId = + mention.remoteAgentId || mention.participantId || "unknown-remote-agent" + + return { + participantId: mention.participantId || fallbackId, + participantType: CONVERSATION_PARTICIPANT_TYPE.REMOTE_AGENT, + id: mention.remoteAgentId || fallbackId, + name: mention.name || "Unknown remote agent", + role: mention.role, + title: mention.title, + emoji: mention.avatarEmoji, + avatarUrl: mention.avatarUrl, + } + } + const fallbackId = mention.externalUserKey || mention.participantId || "unknown-external" return { participantId: mention.participantId || fallbackId, - type: "external" as const, + participantType: CONVERSATION_PARTICIPANT_TYPE.EXTERNAL, id: fallbackId, name: mention.name || "Unknown participant", role: mention.role, @@ -2443,7 +2465,8 @@ export default function MessageBubble({ if (!viewerWorkspaceMemberId) return undefined return conversationMembers?.find( (member) => - member.type === "workspace_member" && + member.participantType === + CONVERSATION_PARTICIPANT_TYPE.WORKSPACE_MEMBER && member.id === viewerWorkspaceMemberId ) }, [conversationMembers, viewerWorkspaceMemberId]) @@ -2459,15 +2482,25 @@ export default function MessageBubble({ isMobile && authorMember && onParticipantClick ) const authorEntityType = useMemo(() => { - if (author?.participantType === "external") return "external" as const - if (author?.participantType === "workspace_member") - return "workspace_member" as const - if (author?.participantType === "remote_agent") - return "remote_agent" as const - return "actor" as const + if (author?.participantType === CONVERSATION_PARTICIPANT_TYPE.EXTERNAL) { + return CONVERSATION_PARTICIPANT_TYPE.EXTERNAL + } + if ( + author?.participantType === CONVERSATION_PARTICIPANT_TYPE.WORKSPACE_MEMBER + ) { + return CONVERSATION_PARTICIPANT_TYPE.WORKSPACE_MEMBER + } + if ( + author?.participantType === CONVERSATION_PARTICIPANT_TYPE.REMOTE_AGENT + ) { + return CONVERSATION_PARTICIPANT_TYPE.REMOTE_AGENT + } + return CONVERSATION_PARTICIPANT_TYPE.ACTOR }, [author?.participantType]) const resolvedAuthorName = useMemo(() => { - if (author?.participantType === "workspace_member") { + if ( + author?.participantType === CONVERSATION_PARTICIPANT_TYPE.WORKSPACE_MEMBER + ) { if ( author.workspaceMemberId && author.workspaceMemberId === viewerWorkspaceMemberId @@ -2475,27 +2508,29 @@ export default function MessageBubble({ return "You" return author.name || authorMember?.name || "User" } - if (author?.participantType === "external") { + if (author?.participantType === CONVERSATION_PARTICIPANT_TYPE.EXTERNAL) { return author.name || authorMember?.name || "External participant" } - if (author?.participantType === "remote_agent") { + if ( + author?.participantType === CONVERSATION_PARTICIPANT_TYPE.REMOTE_AGENT + ) { return author.name || authorMember?.name || "Remote agent" } - if (author?.participantType === "actor") { + if (author?.participantType === CONVERSATION_PARTICIPANT_TYPE.ACTOR) { return author.name || actorName || authorMember?.name || "Actor" } return actorName || (isUser ? "You" : "Member") }, [actorName, author, authorMember, isUser, viewerWorkspaceMemberId]) const resolvedAuthorAvatarUrl = - authorEntityType === "actor" + authorEntityType === CONVERSATION_PARTICIPANT_TYPE.ACTOR ? actorAvatarUrl || author?.avatarUrl || authorMember?.avatarUrl : authorMember?.avatarUrl || author?.avatarUrl const resolvedAuthorEmoji = - authorEntityType === "actor" + authorEntityType === CONVERSATION_PARTICIPANT_TYPE.ACTOR ? actorEmoji || author?.avatarEmoji || authorMember?.emoji : authorMember?.emoji || author?.avatarEmoji const resolvedAuthorSubtitle = useMemo(() => { - if (author?.participantType === "actor") { + if (author?.participantType === CONVERSATION_PARTICIPANT_TYPE.ACTOR) { return ( actorRole || author?.title || @@ -2505,12 +2540,14 @@ export default function MessageBubble({ "Actor" ) } - if (author?.participantType === "external") { + if (author?.participantType === CONVERSATION_PARTICIPANT_TYPE.EXTERNAL) { return authorMember ? getConversationMemberSubtitle(authorMember) : "External participant" } - if (author?.participantType === "remote_agent") { + if ( + author?.participantType === CONVERSATION_PARTICIPANT_TYPE.REMOTE_AGENT + ) { return ( author?.title || author?.role || @@ -2519,7 +2556,9 @@ export default function MessageBubble({ "Remote agent" ) } - if (author?.participantType === "workspace_member") { + if ( + author?.participantType === CONVERSATION_PARTICIPANT_TYPE.WORKSPACE_MEMBER + ) { return author.workspaceMemberId && author.workspaceMemberId === viewerWorkspaceMemberId ? "You" @@ -2528,21 +2567,27 @@ export default function MessageBubble({ return undefined }, [actorRole, author, authorMember, viewerWorkspaceMemberId]) const authorStatusState = - authorEntityType === "actor" + authorEntityType === CONVERSATION_PARTICIPANT_TYPE.ACTOR ? runtimeToAvatarStatus(actorRuntime) - : authorEntityType === "remote_agent" + : authorEntityType === CONVERSATION_PARTICIPANT_TYPE.REMOTE_AGENT ? remoteAgentRuntimeToAvatarStatus(remoteAgentRuntime) : undefined const authorStatusLabel = - authorEntityType === "actor" || authorEntityType === "remote_agent" + authorEntityType === CONVERSATION_PARTICIPANT_TYPE.ACTOR || + authorEntityType === CONVERSATION_PARTICIPANT_TYPE.REMOTE_AGENT ? getRuntimeLabel( - authorEntityType === "actor" ? actorRuntime : remoteAgentRuntime + authorEntityType === CONVERSATION_PARTICIPANT_TYPE.ACTOR + ? actorRuntime + : remoteAgentRuntime ) : undefined const authorStatusDetail = - authorEntityType === "actor" || authorEntityType === "remote_agent" + authorEntityType === CONVERSATION_PARTICIPANT_TYPE.ACTOR || + authorEntityType === CONVERSATION_PARTICIPANT_TYPE.REMOTE_AGENT ? getRuntimeDetail( - authorEntityType === "actor" ? actorRuntime : remoteAgentRuntime + authorEntityType === CONVERSATION_PARTICIPANT_TYPE.ACTOR + ? actorRuntime + : remoteAgentRuntime ) : undefined @@ -2948,7 +2993,7 @@ export default function MessageBubble({ ) : isChildResult ? ( diff --git a/packages/web-next/app/dashboard/chat/mobile-conversation-details-dialog.tsx b/packages/web-next/app/dashboard/chat/mobile-conversation-details-dialog.tsx index 5410547e..a6792947 100644 --- a/packages/web-next/app/dashboard/chat/mobile-conversation-details-dialog.tsx +++ b/packages/web-next/app/dashboard/chat/mobile-conversation-details-dialog.tsx @@ -16,13 +16,13 @@ import { function summarizeMemberCounts(conversation: ConversationSummary) { const workspaceMemberCount = conversation.members.filter( - (member) => member.type === "workspace_member" + (member) => member.participantType === "workspace_member" ).length const actorCount = conversation.members.filter( - (member) => member.type === "actor" + (member) => member.participantType === "actor" ).length const externalCount = conversation.members.filter( - (member) => member.type === "external" + (member) => member.participantType === "external" ).length const memberLabel = `${workspaceMemberCount} member${workspaceMemberCount === 1 ? "" : "s"}` const actorLabel = `${actorCount} actor${actorCount === 1 ? "" : "s"}` @@ -32,8 +32,8 @@ function summarizeMemberCounts(conversation: ConversationSummary) { function orderMembers(members: ConversationMember[]) { return [...members].sort((left, right) => { - if (left.type !== right.type) { - return left.type === "actor" ? -1 : 1 + if (left.participantType !== right.participantType) { + return left.participantType === "actor" ? -1 : 1 } return left.name.localeCompare(right.name) @@ -124,7 +124,7 @@ export default function MobileConversationDetailsDialog({ const canOpen = Boolean(onMemberClick || href) return (
- {entry.targetType === "actor" ? ( + {entry.targetType === CONTACT_TARGET_TYPE.ACTOR ? ( - ) : entry.targetType === "remote_agent" ? ( + ) : entry.targetType === CONTACT_TARGET_TYPE.REMOTE_AGENT ? ( ) : ( entry.title.slice(0, 1).toUpperCase() @@ -106,13 +115,68 @@ function filterConversation( .includes(query) } +function toggleApprovalMode(current: RelationshipProfileView["approvalMode"]) { + return current === RELATIONSHIP_APPROVAL_MODE.AUTO + ? RELATIONSHIP_APPROVAL_MODE.MANUAL + : RELATIONSHIP_APPROVAL_MODE.AUTO +} + +function toggleAccessPolicy(current: RelationshipProfileView["accessPolicy"]) { + return current === RELATIONSHIP_ACCESS_POLICY.WORKSPACE_OPEN + ? RELATIONSHIP_ACCESS_POLICY.APPROVAL_REQUIRED + : RELATIONSHIP_ACCESS_POLICY.WORKSPACE_OPEN +} + +function getIdentityMatchStateLabel( + state: IdentitySearchResponse["matches"][number]["state"] +) { + switch (state) { + case IDENTITY_SEARCH_MATCH_STATE.SAME_WORKSPACE_MEMBER: + return "Same workspace member" + case IDENTITY_SEARCH_MATCH_STATE.FRIEND: + return "Already a friend" + case IDENTITY_SEARCH_MATCH_STATE.AVAILABLE: + return "Available for DM" + case IDENTITY_SEARCH_MATCH_STATE.APPROVAL_REQUIRED: + return "Approval required" + case IDENTITY_SEARCH_MATCH_STATE.PENDING_APPROVAL: + return "Approval pending" + case IDENTITY_SEARCH_MATCH_STATE.EXISTING: + return "Already connected" + case IDENTITY_SEARCH_MATCH_STATE.PENDING_REQUEST: + return "Friend request pending" + default: + return "Can send relationship request" + } +} + +function isPendingIdentityMatchState( + state: IdentitySearchResponse["matches"][number]["state"] +) { + return ( + state === IDENTITY_SEARCH_MATCH_STATE.PENDING_REQUEST || + state === IDENTITY_SEARCH_MATCH_STATE.PENDING_APPROVAL + ) +} + +function canOpenIdentityMatch( + state: IdentitySearchResponse["matches"][number]["state"] +) { + return ( + state === IDENTITY_SEARCH_MATCH_STATE.SAME_WORKSPACE_MEMBER || + state === IDENTITY_SEARCH_MATCH_STATE.FRIEND || + state === IDENTITY_SEARCH_MATCH_STATE.AVAILABLE || + state === IDENTITY_SEARCH_MATCH_STATE.EXISTING + ) +} + function statusBadge(entry: ContactHubEntryView) { switch (entry.directState.status) { - case "existing": + case CONTACT_DIRECT_STATE.EXISTING: return Existing DM - case "pending_approval": + case CONTACT_DIRECT_STATE.PENDING_APPROVAL: return Pending approval - case "approval_required": + case CONTACT_DIRECT_STATE.APPROVAL_REQUIRED: return Approval required default: return {entry.relationLabel} @@ -480,7 +544,7 @@ export function ContactHubClient() { async function handleOpenDirect(entry: ContactHubEntryView) { if (!workspaceId) return if ( - entry.directState.status === "existing" && + entry.directState.status === CONTACT_DIRECT_STATE.EXISTING && entry.directState.conversationId ) { startTransition(() => { @@ -495,7 +559,7 @@ export function ContactHubClient() { contactKind: entry.kind, contactId: entry.id, }) - if (result.status === "pending_approval") { + if (result.status === DIRECT_CONVERSATION_OPEN_STATUS.PENDING_APPROVAL) { toast.message( "Request submitted. Wait for approval before starting a DM." ) @@ -565,7 +629,7 @@ export function ContactHubClient() { async function handleToggleMyApprovalMode() { if (!workspaceId || !myProfile) return - const nextMode = myProfile.approvalMode === "auto" ? "manual" : "auto" + const nextMode = toggleApprovalMode(myProfile.approvalMode) const nextProfile = await api.updateMyRelationshipProfile(workspaceId, { approvalMode: nextMode, }) @@ -576,8 +640,7 @@ export function ContactHubClient() { async function handleToggleActorApprovalMode() { if (!workspaceId || !selectedEntry?.actorId || !selectedActorProfile) return - const nextMode = - selectedActorProfile.approvalMode === "auto" ? "manual" : "auto" + const nextMode = toggleApprovalMode(selectedActorProfile.approvalMode) const nextProfile = await api.updateActorRelationshipProfile( workspaceId, selectedEntry.actorId, @@ -592,10 +655,7 @@ export function ContactHubClient() { async function handleToggleActorAccessPolicy() { if (!workspaceId || !selectedEntry?.actorId || !selectedActorProfile) return - const nextPolicy = - selectedActorProfile.accessPolicy === "workspace_open" - ? "approval_required" - : "workspace_open" + const nextPolicy = toggleAccessPolicy(selectedActorProfile.accessPolicy) const nextProfile = await api.updateActorRelationshipProfile( workspaceId, selectedEntry.actorId, @@ -636,8 +696,7 @@ export function ContactHubClient() { !selectedRemoteAgentProfile ) return - const nextMode = - selectedRemoteAgentProfile.approvalMode === "auto" ? "manual" : "auto" + const nextMode = toggleApprovalMode(selectedRemoteAgentProfile.approvalMode) const nextProfile = await api.updateRemoteAgentRelationshipProfile( workspaceId, selectedEntry.remoteAgentId, @@ -657,10 +716,9 @@ export function ContactHubClient() { !selectedRemoteAgentProfile ) return - const nextPolicy = - selectedRemoteAgentProfile.accessPolicy === "workspace_open" - ? "approval_required" - : "workspace_open" + const nextPolicy = toggleAccessPolicy( + selectedRemoteAgentProfile.accessPolicy + ) const nextProfile = await api.updateRemoteAgentRelationshipProfile( workspaceId, selectedEntry.remoteAgentId, @@ -792,7 +850,7 @@ export function ContactHubClient() { {request.requester?.name || "Unknown user"}
- {request.targetType === "actor" + {request.targetType === CONTACT_TARGET_TYPE.ACTOR ? `Requested actor ${request.targetActor?.name || "Unknown actor"}` : `Requested friendship from ${request.requester?.workspace.name || "another workspace"}`}
@@ -1010,21 +1068,7 @@ export function ContactHubClient() { {match.subtitle}
- {match.state === "same_workspace_member" - ? "Same workspace member" - : match.state === "friend" - ? "Already a friend" - : match.state === "available" - ? "Available for DM" - : match.state === "approval_required" - ? "Approval required" - : match.state === "pending_approval" - ? "Approval pending" - : match.state === "existing" - ? "Already connected" - : match.state === "pending_request" - ? "Friend request pending" - : "Can send relationship request"} + {getIdentityMatchStateLabel(match.state)}
@@ -1316,7 +1356,9 @@ export function ContactHubClient() { )}

- Approval mode: {myProfile?.approvalMode || "manual"} + Approval mode:{" "} + {myProfile?.approvalMode || + RELATIONSHIP_APPROVAL_MODE.MANUAL}

- {selectedEntry.kind === "workspace-actor" && + {selectedEntry.kind === CONTACT_HUB_KIND.WORKSPACE_ACTOR && selectedActorProfile ? ( @@ -1360,9 +1405,9 @@ export function ContactHubClient() { onClick={() => void handleToggleActorApprovalMode()} > Switch approval to{" "} - {selectedActorProfile.approvalMode === "auto" - ? "manual" - : "auto"} + {toggleApprovalMode( + selectedActorProfile.approvalMode + )}
@@ -1022,7 +1063,9 @@ export default function RemoteAgentDetailPage() { disabled={savingProfile} > Switch to{" "} - {profile.approvalMode === "auto" ? "manual" : "auto"} + {approvalModeLabel( + toggleApprovalMode(profile.approvalMode) + )} +
pending
+ + + +EOF +} + +write_validation_config() { + local target_file="$1" + local browser_bin="$2" + local include_cua="$3" + local workspace_dir="$4" + + mkdir -p "${workspace_dir}" + : >"${workspace_dir}/device-key.pem" + + cat >"${target_file}" <>"${target_file}" <"${log_file}" 2>&1 & + printf '%s\n' "$!" +} + +wait_for_http() { + local url="$1" + local attempts="${2:-80}" + local delay="${3:-0.25}" + local i + for ((i = 0; i < attempts; i += 1)); do + if curl -fsS "${url}" >/dev/null 2>&1; then + return 0 + fi + sleep "${delay}" + done + die "timed out waiting for ${url}" +} + +wait_for_mount_ready() { + local mountpoint="$1" + local attempts="${2:-120}" + local delay="${3:-0.25}" + local i + for ((i = 0; i < attempts; i += 1)); do + if [[ -d "${mountpoint}/browser" && -d "${mountpoint}/cua" ]]; then + return 0 + fi + sleep "${delay}" + done + die "timed out waiting for FUSE mount at ${mountpoint}" +} + +build_validation_binaries() { + if [[ "${VALIDATE_SKIP_BUILD:-0}" == "1" && "${VALIDATION_RUNTIME_CHANGED}" != "1" ]]; then + log "skipping binary build because VALIDATE_SKIP_BUILD=1" + return 0 + fi + if [[ "${VALIDATE_SKIP_BUILD:-0}" == "1" && "${VALIDATION_RUNTIME_CHANGED}" == "1" ]]; then + log "runtime bundle changed; rebuilding binaries even though VALIDATE_SKIP_BUILD=1" + fi + ( + cd "${RELAY_ROOT}" + make cli cli-desktop mount-fuse + ) +} + +host_platform() { + local goos + local goarch + goos="$(go env GOOS)" + goarch="$(go env GOARCH)" + printf '%s-%s\n' "${goos}" "${goarch}" +} + +browser_runtime_ready() { + local target_platform="$1" + python3 - "${RELAY_ROOT}/internal/nodebundle/assets/manifest.json" "${RELAY_ROOT}/internal/chromemcpbundle/assets/manifest.json" "${target_platform}" "${BUNDLED_NODE_VERSION}" "${CHROME_DEVTOOLS_MCP_VERSION}" <<'PY' +import json +import pathlib +import sys + +node_manifest_path = pathlib.Path(sys.argv[1]) +chrome_manifest_path = pathlib.Path(sys.argv[2]) +target_platform = sys.argv[3] +node_version = sys.argv[4] +chrome_package_version = sys.argv[5] + +expected_node_asset = f"node-{node_version}-{target_platform}" + +try: + node_manifest = json.loads(node_manifest_path.read_text(encoding="utf-8")) + chrome_manifest = json.loads(chrome_manifest_path.read_text(encoding="utf-8")) +except FileNotFoundError: + raise SystemExit(1) + +if not node_manifest.get("prepared"): + raise SystemExit(1) +if node_manifest.get("platform") != target_platform: + raise SystemExit(1) +if node_manifest.get("assetVersion") != expected_node_asset: + raise SystemExit(1) +if not chrome_manifest.get("prepared"): + raise SystemExit(1) +if chrome_manifest.get("platform") != target_platform: + raise SystemExit(1) +if chrome_manifest.get("nodeAssetVersion") != expected_node_asset: + raise SystemExit(1) +if chrome_manifest.get("packageVersion") != chrome_package_version: + raise SystemExit(1) +PY +} + +prepare_validation_browser_runtime() { + require_cmd go + require_cmd node + + local target_platform + target_platform="$(host_platform)" + + if browser_runtime_ready "${target_platform}"; then + log "bundled browser runtime already prepared for ${target_platform}" + return 0 + fi + + log "preparing bundled browser runtime for ${target_platform}" + ( + cd "${RELAY_ROOT}" + node scripts/prepare-node-bundle.mjs --target-platform="${target_platform}" --node-version="${BUNDLED_NODE_VERSION}" + node scripts/prepare-chrome-devtools-bundle.mjs --target-platform="${target_platform}" --node-version="${BUNDLED_NODE_VERSION}" --package-version="${CHROME_DEVTOOLS_MCP_VERSION}" + ) + VALIDATION_RUNTIME_CHANGED="1" +} + +vfs_list_first_name() { + local binary="$1" + local config_file="$2" + local target="$3" + "${binary}" vfs -c "${config_file}" --json ls "${target}" | python3 -c ' +import json +import sys + +entries = json.load(sys.stdin) +if not entries: + raise SystemExit("no entries found") +print(entries[0]["name"]) +' +} + +browser_tree_action_paths() { + local tree_json="$1" + python3 - "${tree_json}" "${VALIDATION_PLACEHOLDER}" "${VALIDATION_BUTTON_TEXT}" <<'PY' +import json +import sys + +tree_path, placeholder, button_text = sys.argv[1:] +with open(tree_path, encoding="utf-8") as handle: + payload = json.load(handle) + +if payload.get("backend") != "dom": + raise SystemExit(f"expected backend=dom, got {payload.get('backend')!r}") +if int(payload.get("nodeCount", 0)) <= 0: + raise SystemExit("expected nodeCount > 0") + +fill_path = None +click_path = None +for node in payload.get("nodes", []): + actions = set(node.get("actions") or []) + attrs = node.get("attributes") or {} + tag = (node.get("tag") or "").lower() + summary_parts = [ + str(node.get("name") or ""), + str(node.get("text") or ""), + str(node.get("summary") or ""), + ] + summary = " ".join(summary_parts) + path_id = node.get("pathId") or node.get("id") + + if fill_path is None and "fill" in actions and tag == "input" and attrs.get("placeholder") == placeholder: + fill_path = path_id + if click_path is None and "click" in actions and tag == "button" and button_text in summary: + click_path = path_id + +if not fill_path: + raise SystemExit("could not find fillable validation input node") +if not click_path: + raise SystemExit("could not find clickable validation button node") + +print(fill_path) +print(click_path) +PY +} + +assert_tree_contains_marker() { + local tree_json="$1" + local marker="${2:-${VALIDATION_MARKER}}" + python3 - "${tree_json}" "${marker}" <<'PY' +import json +import sys + +tree_path, marker = sys.argv[1:] +with open(tree_path, encoding="utf-8") as handle: + payload = json.load(handle) + +for node in payload.get("nodes", []): + haystacks = [ + str(node.get("name") or ""), + str(node.get("text") or ""), + str(node.get("summary") or ""), + ] + if any(marker in value for value in haystacks): + raise SystemExit(0) + +raise SystemExit(f"marker {marker!r} not found in DOM tree") +PY +} + +assert_cua_headless_fallback() { + local json_file="$1" + python3 - "${json_file}" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + payload = json.load(handle) + +if payload.get("supported") not in (False, 0): + raise SystemExit(f"expected supported=false, got {payload.get('supported')!r}") +if payload.get("backend") != "atspi": + raise SystemExit(f"expected backend='atspi', got {payload.get('backend')!r}") +message = str(payload.get("message") or "") +if "AT-SPI" not in message and "pyatspi" not in message: + raise SystemExit(f"unexpected CUA fallback message: {message!r}") +PY +} diff --git a/relay/scripts/validate-vfs-browser-headless.go b/relay/scripts/validate-vfs-browser-headless.go new file mode 100644 index 00000000..f4925378 --- /dev/null +++ b/relay/scripts/validate-vfs-browser-headless.go @@ -0,0 +1,239 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "os" + "strings" + "time" + + "github.com/PekingSpades/Synapse/relay/internal/config" + "github.com/PekingSpades/Synapse/relay/internal/relaypaths" + "github.com/PekingSpades/Synapse/relay/internal/vfs" +) + +type browserTreeSnapshot struct { + Backend string `json:"backend"` + URL string `json:"url"` + NodeCount int `json:"nodeCount"` + Nodes []browserTreeNodeEntry `json:"nodes"` +} + +type browserTreeNodeEntry struct { + ID string `json:"id"` + PathID string `json:"pathId"` + Tag string `json:"tag"` + Name string `json:"name"` + Text string `json:"text"` + Summary string `json:"summary"` + Actions []string `json:"actions"` + Attributes map[string]string `json:"attributes"` +} + +func main() { + configPath := flag.String("config", "", "relay config path") + targetURL := flag.String("url", "", "fixture url") + marker := flag.String("marker", "relayfs-dom-ok", "marker text to write into the page") + placeholder := flag.String("placeholder", "RelayFS input marker", "input placeholder used to find the fill node") + buttonText := flag.String("button-text", "Apply marker", "button text used to find the click node") + flag.Parse() + + if strings.TrimSpace(*configPath) == "" { + fatalf("missing --config") + } + if strings.TrimSpace(*targetURL) == "" { + fatalf("missing --url") + } + + cfg, err := config.Load(*configPath) + if err != nil { + fatalf("load config: %v", err) + } + + paths := relaypaths.ResolveStandaloneProfile(relaypaths.DefaultHostPaths(relaypaths.HostCLI)) + relaypaths.SetCurrent(paths) + + service, err := vfs.New(paths, cfg) + if err != nil { + fatalf("init relay vfs: %v", err) + } + startCtx, startCancel := context.WithTimeout(context.Background(), 45*time.Second) + defer startCancel() + if err := service.Start(startCtx); err != nil { + fatalf("start relay vfs: %v", err) + } + defer service.Close() + + exposureKey, err := firstExposureName(service, "/browser") + if err != nil { + fatalf("discover browser exposure: %v", err) + } + sessionRoot := "/browser/" + exposureKey + "/sessions/default" + + if _, err := service.Write(sessionRoot+"/actions/new_page", []byte(*targetURL)); err != nil { + fatalf("open fixture page: %v", err) + } + + tree, err := waitForTree(service, sessionRoot, func(tree browserTreeSnapshot) error { + if tree.Backend != "dom" { + return fmt.Errorf("expected backend=dom, got %q", tree.Backend) + } + if tree.NodeCount <= 0 { + return fmt.Errorf("expected nodeCount > 0") + } + if !sameTargetURL(tree.URL, *targetURL) { + return fmt.Errorf("expected tree URL %q, got %q", *targetURL, tree.URL) + } + return nil + }) + if err != nil { + fatalf("wait for browser tree: %v", err) + } + + fillNode, clickNode, err := findActionNodes(tree, *placeholder, *buttonText) + if err != nil { + fatalf("find browser nodes: %v", err) + } + + if _, err := service.Write(sessionRoot+"/tree/nodes/"+fillNode+"/actions/fill", []byte(*marker)); err != nil { + fatalf("fill DOM node: %v", err) + } + if _, err := service.Write(sessionRoot+"/tree/nodes/"+clickNode+"/actions/click", []byte("{}")); err != nil { + fatalf("click DOM node: %v", err) + } + + if _, err := waitForTree(service, sessionRoot, func(tree browserTreeSnapshot) error { + if !treeContainsMarker(tree, *marker) { + return fmt.Errorf("marker %q not found in DOM tree", *marker) + } + return nil + }); err != nil { + fatalf("wait for DOM marker: %v", err) + } + + if err := assertNonEmptyRead(service, sessionRoot+"/pages/list.json"); err != nil { + fatalf("pages/list.json: %v", err) + } + if err := assertNonEmptyRead(service, sessionRoot+"/current/page.json"); err != nil { + fatalf("current/page.json: %v", err) + } + if err := assertNonEmptyRead(service, sessionRoot+"/tree/index.json"); err != nil { + fatalf("tree/index.json: %v", err) + } + + fmt.Printf("validated browser exposure %s via persistent VFS service\n", exposureKey) +} + +func firstExposureName(service *vfs.Service, target string) (string, error) { + entries, err := service.List(target) + if err != nil { + return "", err + } + if len(entries) == 0 { + return "", fmt.Errorf("no entries under %s", target) + } + return entries[0].Name, nil +} + +func waitForTree(service *vfs.Service, sessionRoot string, validate func(browserTreeSnapshot) error) (browserTreeSnapshot, error) { + deadline := time.Now().Add(20 * time.Second) + var lastErr error + for time.Now().Before(deadline) { + tree, err := readTree(service, sessionRoot) + if err == nil { + if validateErr := validate(tree); validateErr == nil { + return tree, nil + } else { + lastErr = validateErr + } + } else { + lastErr = err + } + time.Sleep(300 * time.Millisecond) + } + if lastErr == nil { + lastErr = fmt.Errorf("timed out waiting for browser tree") + } + return browserTreeSnapshot{}, lastErr +} + +func readTree(service *vfs.Service, sessionRoot string) (browserTreeSnapshot, error) { + result, err := service.Read(sessionRoot + "/tree/index.json") + if err != nil { + return browserTreeSnapshot{}, err + } + var tree browserTreeSnapshot + if err := json.Unmarshal(result.Data, &tree); err != nil { + return browserTreeSnapshot{}, err + } + return tree, nil +} + +func findActionNodes(tree browserTreeSnapshot, placeholder string, buttonText string) (string, string, error) { + var fillNode string + var clickNode string + for _, node := range tree.Nodes { + actions := make(map[string]bool, len(node.Actions)) + for _, action := range node.Actions { + actions[action] = true + } + if fillNode == "" && actions["fill"] && strings.EqualFold(node.Tag, "input") && node.Attributes["placeholder"] == placeholder { + fillNode = node.PathID + if fillNode == "" { + fillNode = node.ID + } + } + if clickNode == "" && actions["click"] && strings.EqualFold(node.Tag, "button") { + haystack := node.Name + " " + node.Text + " " + node.Summary + if strings.Contains(haystack, buttonText) { + clickNode = node.PathID + if clickNode == "" { + clickNode = node.ID + } + } + } + } + if fillNode == "" { + return "", "", fmt.Errorf("could not find input node with placeholder %q", placeholder) + } + if clickNode == "" { + return "", "", fmt.Errorf("could not find button node with text %q", buttonText) + } + return fillNode, clickNode, nil +} + +func treeContainsMarker(tree browserTreeSnapshot, marker string) bool { + for _, node := range tree.Nodes { + haystacks := []string{node.Name, node.Text, node.Summary} + for _, value := range haystacks { + if strings.Contains(value, marker) { + return true + } + } + } + return false +} + +func assertNonEmptyRead(service *vfs.Service, target string) error { + result, err := service.Read(target) + if err != nil { + return err + } + if len(result.Data) == 0 { + return fmt.Errorf("read returned empty payload") + } + return nil +} + +func sameTargetURL(actual string, expected string) bool { + actual = strings.TrimSpace(actual) + expected = strings.TrimSpace(expected) + return actual == expected || strings.TrimSuffix(actual, "/") == strings.TrimSuffix(expected, "/") +} + +func fatalf(format string, args ...interface{}) { + fmt.Fprintf(os.Stderr, format+"\n", args...) + os.Exit(1) +} diff --git a/relay/scripts/validate-vfs-browser-headless.sh b/relay/scripts/validate-vfs-browser-headless.sh new file mode 100755 index 00000000..7d165966 --- /dev/null +++ b/relay/scripts/validate-vfs-browser-headless.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/lib/validation-common.sh" + +require_cmd curl +require_cmd go +require_cmd python3 + +prepare_validation_browser_runtime + +WORK_DIR="$(make_validation_dir)" +HTTP_PID="" +cleanup() { + if [[ -n "${HTTP_PID}" ]]; then + kill "${HTTP_PID}" >/dev/null 2>&1 || true + wait "${HTTP_PID}" >/dev/null 2>&1 || true + fi + if [[ "${VALIDATE_KEEP_TMP:-0}" != "1" ]]; then + rm -rf "${WORK_DIR}" + else + log "preserved validation workspace at ${WORK_DIR}" + fi +} +trap cleanup EXIT + +BROWSER_BIN="$(detect_browser_bin)" +PORT="$(pick_free_port)" +FIXTURE_DIR="${WORK_DIR}/fixture" +CONFIG_FILE="${WORK_DIR}/config.yaml" +SERVER_LOG="${WORK_DIR}/http.log" +FIXTURE_URL="http://127.0.0.1:${PORT}/" + +write_validation_page "${FIXTURE_DIR}" +write_validation_config "${CONFIG_FILE}" "${BROWSER_BIN}" 0 "${WORK_DIR}" + +HTTP_PID="$(start_http_server "${FIXTURE_DIR}" "${PORT}" "${SERVER_LOG}")" +wait_for_http "${FIXTURE_URL}" + +log "running browser DOM validation against ${FIXTURE_URL}" + +( + cd "${RELAY_ROOT}" + go run ./scripts/validate-vfs-browser-headless.go \ + --config "${CONFIG_FILE}" \ + --url "${FIXTURE_URL}" \ + --marker "${VALIDATION_MARKER}" \ + --placeholder "${VALIDATION_PLACEHOLDER}" \ + --button-text "${VALIDATION_BUTTON_TEXT}" +) + +log "browser DOM validation passed" diff --git a/relay/scripts/validate-vfs-fuse-linux-headless.sh b/relay/scripts/validate-vfs-fuse-linux-headless.sh new file mode 100755 index 00000000..65b543ae --- /dev/null +++ b/relay/scripts/validate-vfs-fuse-linux-headless.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/lib/validation-common.sh" + +[[ "$(uname -s)" == "Linux" ]] || die "this script is for Linux only" + +require_cmd curl +require_cmd python3 +require_cmd stat + +prepare_validation_browser_runtime +build_validation_binaries + +WORK_DIR="$(make_validation_dir)" +HTTP_PID="" +MOUNT_PID="" +cleanup() { + if [[ -n "${MOUNT_PID}" ]]; then + "${RELAY_ROOT}/synapse-relay-mount" unmount "${WORK_DIR}/mount" >/dev/null 2>&1 || true + kill "${MOUNT_PID}" >/dev/null 2>&1 || true + wait "${MOUNT_PID}" >/dev/null 2>&1 || true + fi + if [[ -n "${HTTP_PID}" ]]; then + kill "${HTTP_PID}" >/dev/null 2>&1 || true + wait "${HTTP_PID}" >/dev/null 2>&1 || true + fi + if [[ "${VALIDATE_KEEP_TMP:-0}" != "1" ]]; then + rm -rf "${WORK_DIR}" + else + log "preserved validation workspace at ${WORK_DIR}" + fi +} +trap cleanup EXIT + +BROWSER_BIN="$(detect_browser_bin)" +PORT="$(pick_free_port)" +FIXTURE_DIR="${WORK_DIR}/fixture" +CONFIG_FILE="${WORK_DIR}/config.yaml" +SERVER_LOG="${WORK_DIR}/http.log" +MOUNT_LOG="${WORK_DIR}/mount.log" +DOCTOR_JSON="${WORK_DIR}/doctor.json" +TREE_JSON="${WORK_DIR}/tree.json" +TREE_AFTER_JSON="${WORK_DIR}/tree-after.json" +CUA_ROOT_JSON="${WORK_DIR}/cua-root.json" +CUA_FOCUSED_JSON="${WORK_DIR}/cua-focused.json" +MOUNT_DIR="${WORK_DIR}/mount" +FIXTURE_URL="http://127.0.0.1:${PORT}/" + +mkdir -p "${MOUNT_DIR}" +write_validation_page "${FIXTURE_DIR}" +write_validation_config "${CONFIG_FILE}" "${BROWSER_BIN}" 1 "${WORK_DIR}" + +"${RELAY_ROOT}/synapse-relay-mount" doctor >"${DOCTOR_JSON}" +python3 - "${DOCTOR_JSON}" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + payload = json.load(handle) + +if payload.get("platform") != "linux": + raise SystemExit(f"expected platform=linux, got {payload.get('platform')!r}") +checks = payload.get("dependencyChecks") or [] +failed = [check for check in checks if not check.get("ok")] +if failed: + raise SystemExit(f"doctor reported failed checks: {failed!r}") +PY + +HTTP_PID="$(start_http_server "${FIXTURE_DIR}" "${PORT}" "${SERVER_LOG}")" +wait_for_http "${FIXTURE_URL}" + +log "mounting relay FUSE filesystem at ${MOUNT_DIR}" +"${RELAY_ROOT}/synapse-relay-mount" mount -c "${CONFIG_FILE}" "${MOUNT_DIR}" >"${MOUNT_LOG}" 2>&1 & +MOUNT_PID="$!" +wait_for_mount_ready "${MOUNT_DIR}" + +BROWSER_KEY="$(basename "$(find "${MOUNT_DIR}/browser" -mindepth 1 -maxdepth 1 -type d | sort | head -n 1)")" +CUA_KEY="$(basename "$(find "${MOUNT_DIR}/cua" -mindepth 1 -maxdepth 1 -type d | sort | head -n 1)")" +[[ -n "${BROWSER_KEY}" ]] || die "browser exposure was not mounted" +[[ -n "${CUA_KEY}" ]] || die "cua exposure was not mounted" + +BROWSER_SESSION_ROOT="${MOUNT_DIR}/browser/${BROWSER_KEY}/sessions/default" +CUA_SESSION_ROOT="${MOUNT_DIR}/cua/${CUA_KEY}/sessions/default" + +printf '%s\n' "${FIXTURE_URL}" >"${BROWSER_SESSION_ROOT}/actions/new_page" +python3 - "${BROWSER_SESSION_ROOT}/state.json" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + payload = json.load(handle) + +if int(payload.get("selectedPageId", 0)) <= 0: + raise SystemExit(f"expected selectedPageId > 0, got {payload.get('selectedPageId')!r}") +PY + +cp "${BROWSER_SESSION_ROOT}/tree/index.json" "${TREE_JSON}" +mapfile -t NODE_PATHS < <(browser_tree_action_paths "${TREE_JSON}") +FILL_NODE_PATH_ID="${NODE_PATHS[0]}" +CLICK_NODE_PATH_ID="${NODE_PATHS[1]}" + +printf '%s\n' "${VALIDATION_MARKER}" >"${BROWSER_SESSION_ROOT}/tree/nodes/${FILL_NODE_PATH_ID}/actions/fill" +printf '{}\n' >"${BROWSER_SESSION_ROOT}/tree/nodes/${CLICK_NODE_PATH_ID}/actions/click" +cp "${BROWSER_SESSION_ROOT}/tree/index.json" "${TREE_AFTER_JSON}" + +assert_tree_contains_marker "${TREE_AFTER_JSON}" "${VALIDATION_MARKER}" + +python3 - "${BROWSER_SESSION_ROOT}/pages/list.json" "${BROWSER_SESSION_ROOT}/current/page.json" "${BROWSER_SESSION_ROOT}/tree/index.json" <<'PY' +import pathlib +import sys + +for candidate in sys.argv[1:]: + path = pathlib.Path(candidate) + if not path.is_file(): + raise SystemExit(f"expected file to exist: {path}") + if path.stat().st_size <= 0: + raise SystemExit(f"expected file to be non-empty: {path}") +PY + +cp "${CUA_SESSION_ROOT}/tree/root.json" "${CUA_ROOT_JSON}" +cp "${CUA_SESSION_ROOT}/focused/props.json" "${CUA_FOCUSED_JSON}" +assert_cua_headless_fallback "${CUA_ROOT_JSON}" +assert_cua_headless_fallback "${CUA_FOCUSED_JSON}" + +log "FUSE browser/CUA headless validation passed" diff --git a/relay/scripts/validate-vfs-linux-headless.sh b/relay/scripts/validate-vfs-linux-headless.sh new file mode 100755 index 00000000..871e6abd --- /dev/null +++ b/relay/scripts/validate-vfs-linux-headless.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +"${SCRIPT_DIR}/validate-vfs-unit.sh" +VALIDATE_SKIP_BUILD=1 "${SCRIPT_DIR}/validate-vfs-browser-headless.sh" +VALIDATE_SKIP_BUILD=1 "${SCRIPT_DIR}/validate-vfs-fuse-linux-headless.sh" diff --git a/relay/scripts/validate-vfs-unit.sh b/relay/scripts/validate-vfs-unit.sh new file mode 100755 index 00000000..acebb306 --- /dev/null +++ b/relay/scripts/validate-vfs-unit.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/lib/validation-common.sh" + +require_cmd go +require_cmd make + +log "running relay VFS unit/build validation" + +( + cd "${RELAY_ROOT}" + go test ./internal/config ./internal/builtinmcp/chrome ./internal/vfs ./internal/vfscli ./internal/relaycontroller ./internal/relayagent ./cmd/synapse-relay + go test -tags 'relay_fuse,desktop_cua' ./internal/vfsmount ./cmd/synapse-relay-mount + make cli + make cli-desktop + make mount-fuse +) + +log "relay VFS unit/build validation passed" diff --git a/scripts/audit-business-enum-literals.mjs b/scripts/audit-business-enum-literals.mjs new file mode 100644 index 00000000..30457451 --- /dev/null +++ b/scripts/audit-business-enum-literals.mjs @@ -0,0 +1,150 @@ +import fs from "node:fs" +import path from "node:path" + +const repoRoot = process.cwd() + +const auditedPaths = [ + "packages/api/src/modules/relationship/controller.ts", + "packages/api/src/modules/remote-agents/controller.ts", + "packages/api/src/modules/chat/service.ts", + "packages/api/src/modules/chat/summary-view.ts", + "packages/api/src/modules/ai/index.ts", + "packages/api/src/modules/ai/context-compiler.ts", + "packages/api/src/modules/ai/inline-ref-resolver.ts", + "packages/web-next/app/dashboard/chat/member-utils.ts", + "packages/web-next/app/dashboard/chat/conversation-chat.tsx", + "packages/web-next/app/dashboard/chat/chat-participant-detail-dialog.tsx", + "packages/web-next/app/dashboard/chat/chat-participant-hover-card.tsx", + "packages/web-next/app/dashboard/chat/mobile-participant-picker-screen.tsx", + "packages/web-next/app/dashboard/chat/message-bubble.tsx", + "packages/web-next/app/dashboard/contacts/contact-hub-client.tsx", + "packages/web-next/app/dashboard/remote-agents/agents/[remoteAgentId]/page.tsx", + "packages/web-next/components/chat-composer.tsx", + "packages/web-next/app/dashboard/settings/model-group-list.tsx", + "packages/web-next/app/dashboard/settings/model-group-dialog.tsx", + "packages/web-next/app/dashboard/settings/model-group-detail.tsx", + "packages/web-next/app/dashboard/settings/model-group-browser.tsx", + "packages/web-next/app/dashboard/settings/model-settings-workbench.tsx", + "packages/mobile-app/src/lib/chat-data.ts", + "packages/mobile-app/app/contacts/[contactType]/[contactId].tsx", + "packages/mobile-app/app/contacts/discover.tsx", + "packages/mobile-app/app/contacts/requests.tsx", + "packages/mobile-app/app/search.tsx", + "packages/mobile-app/app/scan.tsx", + "packages/mobile-app/app/chat/[conversationId].tsx", + "packages/mobile-app/app/conversations/[conversationId]/details.tsx", + "packages/mobile-app/app/actors/select.tsx", + "packages/mobile-app/app/contacts/group/new.tsx", + "packages/mobile-app/src/components/chat-mention-picker-screen.tsx", + "packages/mobile-app/src/components/workspace-entity-picker-screen.tsx", + "packages/mobile-app/src/screens/tabs/contacts-tab-screen.tsx", +] + +const checks = [ + { + kind: "raw participantType comparison", + regex: + /participantType\s*===\s*"(?actor|workspace_member|remote_agent|external|system)"/g, + }, + { + kind: "raw targetType comparison", + regex: /targetType\s*===\s*"(?member|actor|remote_agent)"/g, + }, + { + kind: "raw participant.type comparison", + regex: + /participant\.type\s*===\s*"(?actor|workspace_member|remote_agent|external|system)"/g, + }, + { + kind: "raw approvalMode comparison", + regex: /approvalMode\s*===\s*"(?auto|manual)"/g, + }, + { + kind: "raw accessPolicy comparison", + regex: /accessPolicy\s*===\s*"(?workspace_open|approval_required)"/g, + }, + { + kind: "raw identity match state comparison", + regex: + /match\.state\s*===\s*"(?same_workspace_member|friend|pending_request|requestable|existing|available|approval_required|pending_approval)"/g, + }, + { + kind: "raw direct state comparison", + regex: + /directState\.status\s*===\s*"(?existing|available|approval_required|pending_approval)"/g, + }, + { + kind: "raw conversation kind comparison", + regex: /conversation\.kind\s*===\s*"(?group|private|virtual)"/g, + }, + { + kind: "raw conversation boundary comparison", + regex: /conversation\.boundary\s*===\s*"(?internal|external)"/g, + }, + { + kind: "raw model-group scope comparison", + regex: + /\b(?:scope|groupScope|resolvedScope)\s*(?:===|!==)\s*"(?platform|workspace|workspace_member)"/g, + }, + { + kind: "raw model-group grant scope comparison", + regex: + /\bgrantScope\s*(?:===|!==)\s*"(?platform|workspace|workspace_member|actor)"/g, + }, + { + kind: "raw model-group grant status comparison", + regex: /grant\.status\s*===\s*"(?active|revoked)"/g, + }, + { + kind: "raw z.enum business values", + regex: + /z\.enum\(\[\s*"(?auto|manual|workspace_open|approval_required|claude_code|codex)"/g, + }, +] + +function lineNumberForIndex(text, index) { + let line = 1 + for (let i = 0; i < index; i += 1) { + if (text[i] === "\n") line += 1 + } + return line +} + +const findings = [] + +for (const relativePath of auditedPaths) { + const absolutePath = path.join(repoRoot, relativePath) + if (!fs.existsSync(absolutePath)) { + continue + } + + const text = fs.readFileSync(absolutePath, "utf8") + + for (const check of checks) { + check.regex.lastIndex = 0 + let match + while ((match = check.regex.exec(text)) !== null) { + const line = lineNumberForIndex(text, match.index) + findings.push({ + file: relativePath, + line, + kind: check.kind, + snippet: match[0], + }) + } + } +} + +if (findings.length > 0) { + console.error("Found forbidden raw business enum literals:") + for (const finding of findings) { + console.error( + `- ${finding.file}:${finding.line} [${finding.kind}] ${finding.snippet}` + ) + } + process.exit(1) +} + +console.log( + `Business enum audit passed for ${auditedPaths.length} first-wave files.` +) From 96ba9f2d1555e9dce5de9cb9f3b7d715acb7d09b Mon Sep 17 00:00:00 2001 From: Yihang Huang <180665176+PekingSpades@users.noreply.github.com> Date: Fri, 15 May 2026 05:12:48 +0000 Subject: [PATCH 2/2] chore: import fix/relay-auto-skill-discovery-order from PekingSpades fork Squashed import of 1 commit ahead of PekingSpades/synapse:main: 9ce134a fix: prioritize relay auto-loaded skills in discovery Method: pre-formatted both peking/main and peking/fix versions of the 5 skill files with zai-org's prettier+gofmt style, diffed the formatted trees scoped to those files, then applied with git apply --3way. No conflicts. Source: https://github.com/PekingSpades/synapse/tree/fix/relay-auto-skill-discovery-order --- packages/api/src/modules/ai/prompt-builder.ts | 4 +- packages/api/src/modules/ai/session-tools.ts | 21 +++++++--- .../modules/skills/discovery-order.test.ts | 41 +++++++++++++++++++ .../api/src/modules/skills/discovery-order.ts | 33 +++++++++++++++ packages/api/src/modules/skills/service.ts | 5 +-- 5 files changed, 95 insertions(+), 9 deletions(-) create mode 100644 packages/api/src/modules/skills/discovery-order.test.ts create mode 100644 packages/api/src/modules/skills/discovery-order.ts diff --git a/packages/api/src/modules/ai/prompt-builder.ts b/packages/api/src/modules/ai/prompt-builder.ts index 0f5fb5da..9bad0611 100644 --- a/packages/api/src/modules/ai/prompt-builder.ts +++ b/packages/api/src/modules/ai/prompt-builder.ts @@ -13,6 +13,7 @@ import { isPlanCollaborationMode, isPlanDraftingCollaborationMode, } from "@synapse/shared/utils" +import { sortAvailableSkillsForDiscovery } from "../skills/discovery-order.js" import { buildReplyToRefUsageGuidance } from "./session-tool-guidance.js" export interface ConversationParticipantInfo { @@ -388,11 +389,12 @@ export function buildActorPrompt( ) if (availableSkills && availableSkills.length > 0) { + const orderedSkills = sortAvailableSkillsForDiscovery(availableSkills) parts.push( `# Available Skills\n` + `These skills are available on demand. Do not assume their detailed contents are already loaded.\n` + `If one skill clearly matches the task, call \`read_skill\` to read its description or a referenced attachment before using it.\n` + - availableSkills + orderedSkills .map( (skill) => `- \`${skill.slug}\`${skill.sourceKind === "relay_auto_loaded" ? " (relay auto-loaded)" : ""}: ${skill.description}` diff --git a/packages/api/src/modules/ai/session-tools.ts b/packages/api/src/modules/ai/session-tools.ts index 0bb0c325..1f0fe2e0 100644 --- a/packages/api/src/modules/ai/session-tools.ts +++ b/packages/api/src/modules/ai/session-tools.ts @@ -67,6 +67,7 @@ import { import { buildNormalizedMessageContent } from "../chat/message-content.js" import { buildDefaultUserMention } from "./inline-ref-resolver.js" import { runMemorySearch } from "../memory/service.js" +import { sortAvailableSkillsForDiscovery } from "../skills/discovery-order.js" import { readVisibleSkill } from "../skills/service.js" import { listAutomationEventSources, @@ -1140,21 +1141,31 @@ export function registerCallableToolPlugins(): void { }, }, resolve: (ctx) => { - const availableSkills = ctx.availableSkills || [] + const availableSkills = sortAvailableSkillsForDiscovery( + ctx.availableSkills || [] + ) if (availableSkills.length === 0) { return { active: false, definition: null as any } } const skillNames: string[] = Array.from( new Set(availableSkills.map((skill) => skill.slug)) ) - const skillList = availableSkills - .map((skill) => `\`${skill.slug}\`: ${skill.description}`) - .join("; ") + const previewSkills = skillNames + .slice(0, 12) + .map((skill) => `\`${skill}\``) + const moreCount = skillNames.length - previewSkills.length + const availabilityHint = + moreCount > 0 + ? `${previewSkills.join(", ")}, and ${moreCount} more listed in the Available Skills section.` + : `${previewSkills.join(", ")}.` return { active: true, definition: { name: "read_skill", - description: `Read the contents of an available skill package. Available skills: ${skillList}`, + description: + `Read the contents of an available skill package. ` + + `Use the exact slug from the Available Skills section. ` + + `Currently available: ${availabilityHint}`, parameters: { type: "object", properties: { diff --git a/packages/api/src/modules/skills/discovery-order.test.ts b/packages/api/src/modules/skills/discovery-order.test.ts new file mode 100644 index 00000000..f1aed236 --- /dev/null +++ b/packages/api/src/modules/skills/discovery-order.test.ts @@ -0,0 +1,41 @@ +import test from "node:test" +import assert from "node:assert/strict" +import type { AvailableSkillSummary } from "@synapse/shared" +import { sortAvailableSkillsForDiscovery } from "./discovery-order.js" + +function buildSkill( + slug: string, + sourceKind: AvailableSkillSummary["sourceKind"] +): AvailableSkillSummary { + return { + instanceId: slug, + packageId: slug, + revisionId: `${slug}@test`, + slug, + name: slug, + description: slug, + version: "test", + sourceKind, + entryPoint: slug, + accessTarget: { type: "workspace" }, + } +} + +test("sortAvailableSkillsForDiscovery prioritizes non-cli-anything relay auto-loaded skills", () => { + const sorted = sortAvailableSkillsForDiscovery([ + buildSkill("cli-anything-adguardhome", "relay_auto_loaded"), + buildSkill("custom-internal-skill", "workspace_installed"), + buildSkill("xiaohongshu-cli", "relay_auto_loaded"), + buildSkill("discord-cli", "relay_auto_loaded"), + ]) + + assert.deepEqual( + sorted.map((skill) => skill.slug), + [ + "discord-cli", + "xiaohongshu-cli", + "custom-internal-skill", + "cli-anything-adguardhome", + ] + ) +}) diff --git a/packages/api/src/modules/skills/discovery-order.ts b/packages/api/src/modules/skills/discovery-order.ts new file mode 100644 index 00000000..4832439a --- /dev/null +++ b/packages/api/src/modules/skills/discovery-order.ts @@ -0,0 +1,33 @@ +import type { AvailableSkillSummary } from "@synapse/shared" + +function skillDiscoveryPriority(skill: AvailableSkillSummary) { + const slug = skill.slug.trim().toLowerCase() + if ( + skill.sourceKind === "relay_auto_loaded" && + !slug.startsWith("cli-anything-") + ) { + return 0 + } + if (skill.sourceKind !== "relay_auto_loaded") { + return 1 + } + return 2 +} + +export function compareAvailableSkillDiscoveryOrder( + left: AvailableSkillSummary, + right: AvailableSkillSummary +) { + const leftPriority = skillDiscoveryPriority(left) + const rightPriority = skillDiscoveryPriority(right) + if (leftPriority !== rightPriority) { + return leftPriority - rightPriority + } + return left.slug.localeCompare(right.slug) +} + +export function sortAvailableSkillsForDiscovery( + skills: AvailableSkillSummary[] +) { + return [...skills].sort(compareAvailableSkillDiscoveryOrder) +} diff --git a/packages/api/src/modules/skills/service.ts b/packages/api/src/modules/skills/service.ts index 9ea6f04d..cbe72dff 100644 --- a/packages/api/src/modules/skills/service.ts +++ b/packages/api/src/modules/skills/service.ts @@ -62,6 +62,7 @@ import { listRelayAutoLoadedSkills, readRelayAutoLoadedSkill, } from "./relay-auto-skills.js" +import { compareAvailableSkillDiscoveryOrder } from "./discovery-order.js" import { SKILL_ENTRY_PATH, buildSyntheticEntryFile, @@ -3612,9 +3613,7 @@ export async function listVisibleSkills(input: { } } - return Array.from(combined.values()).sort((left, right) => - left.slug.localeCompare(right.slug) - ) + return Array.from(combined.values()).sort(compareAvailableSkillDiscoveryOrder) } export async function readVisibleSkill(input: {