/** * [ref] —— core `RunnerDeps.peerDirectory` 的 server 部署席(跨会话互传消息的 server 半场,设计稿 * `sema-internal/server/designs/2026-09-19-s72-peer-directory-seat-v1.md` v1.1)。 * * ## 一句话 * * **目录不是一张要人维护的注册表,是会话列表面那一张派生视图的第二个读者。** 零新注册表、零新写家: * `listPeerSessions({scope})` 每次经**会话枚举面那只同一个函数**(`GET /v1/sessions` 用的那只 * `listSessions`)读出来,再把 mailbox 侧的墓碑行(已删会话)并进去。 * * ## 为什么复用那只 lister,而不是自己写一条 JOIN(设计稿 §2 给的是 SQL 形,这里收得更紧) * * 设计稿 §4-1 的承重条款是「**成员资格 = 会话读面的那一只属主可读谓词,一字不另写**」。自己写一条 JOIN * 就必须自己带一份属主谓词 —— 那正是兔子洞 S-487「属主判定单一存取器」要消灭的第 10 处手抄,而且两份 * 谓词**一定**会漂(存量 `owner IS NULL` 的窄互认臂、`placement_kind IS NULL` 的 [ref] 义务、keyset 排序 * 口径三条都要各抄一遍)。改读同一只 `listSessions` 之后,**成员资格**不再是一条需要测试盯住的断言, * 而是结构上的同一次调用;SQL 面因此也一个字节都不用改。 * * ⚠️ **共享的是谓词,不是最终集合**(codex 对抗复审 r1 ② 采纳,如实收窄 —— 稿里与本注初版都写成了 * 「两面集合相等」,那是**过度承诺**):两面在下面这些地方按设计给出不同的集合,逐条都不是 bug —— * · **fleet-wide 调用方**:`GET /v1/sessions` 对 service-token / dev-open 形不钉 owner、还认 `?owner=` * (运维视图);目录**只**按 core 递进来的那一个 scope 分区,永不跨; * · **分页与搜索**:那条路由有 `?limit`/`?cursor`/`?q=`,目录是一次定量读(见 {@link PEER_DIRECTORY_CAP}); * · **peer 可寻址性过滤**:语法外 / 非小写规范形 / 与别条会话折到同一只箱的 id 都不入目录(下面那道 * 单射门),那条路由照列; * · **墓碑**:目录会多出「正在删除中」的 `deleted` 记录,那条路由不列; * · **席位差**:那条路由还有 `runStore.listSessions` 的 legacy 兜底,本席刻意不并(见装配点的注)。 * 机器钉因此只钉「同一 principal、无 q、无分页、量在 cap 内」那一格的集合相等(它是那条承重条款的 * 可执行投影),别把它读成两面恒等。 * * ⚠️ 与设计稿的第二处收紧(亲读实现推翻稿面):稿里把谓词写成 `principal-gate.ts runOwnerReadable`。 * 亲读两只函数后不成立 —— `runOwnerReadable(p, owner, cfg)` 对 `owner === null` **无条件**放行 * (`principal-gate.ts:136-137`,函数体第一行),而**列表面**的第三条件由 store 的 * `owner = ? OR owner IS NULL` 承载,开关是 `legacyUnownedAmnestyOpen(requirePrincipal, gateOwner)` * (`security.ts:119`,它的头注逐字: * 「单独存在的唯一理由是**列表面**……单实体读/写点一律用三条件齐的 `legacyUnownedSessionOk`」)。 * 拿 `runOwnerReadable` 当列表谓词会让目录比 `GET /v1/sessions` **多列出**别人的无主会话 —— 正好违反 * 稿子自己那条对拍。所以这里用的是列表面那一只:`legacyUnownedAmnestyOpen` + 同一个 lister。 * * ## scope 轴 * * core 递进来的 `access.scope` = `taskScope` = `internals?.registryScope ?? spec.principal ?? "default"` * (core `prepare-task.js`)× server `resolve-spec.ts:701 principal: auth?.principal` ⇒ **裸 principal 串**, * 无 principal 的部署上是字面量 `"default"`,而同一情形下 `session_meta.owner` 是 SQL NULL —— * 这正是窄互认臂(`LEGACY_UNOWNED_AMNESTY_TENANT === "default"`)要接住的那一格,不必另铸编解码。 * * ## 各字段的**唯一**来源(设计稿 §2 表;这里不许出现第二处算法) * * · `schemaVersion` = core 常量 `PEER_SESSION_RECORD_SCHEMA_VERSION`(不手抄数字); * · `sessionId` = 会话行 id;过不了 core `isPeerSessionId` 的行、以及**箱句柄不是本 scope 独占**的行 * (非小写规范形 / 与别的会话折到同一只箱)**不入视图并计数披露**(不静默,见下方单射门); * · `scope` = 查询参数原样(视图永不跨 scope —— 这就是设计稿 §4-3 那堵墙的全部实现); * · `name` = `title`,空 ⇒ `session-`,`nameSource: "auto"`; * · `startedAt`/`updatedAt` = 会话行的 `firstActivityAt`/`lastActivityAt`; * · `liveness` = **会话行在 ⇒ `"live"`**;`"deleted"` 来自 mailbox 墓碑(= 删除级联**在飞**那段窗口; * 墓碑随箱亡,级联跑完之后那个 id 就是「不存在」—— 理由全文在 `plugins/mailbox-recipient-lifecycle.ts`), * 同句柄上**墓碑压过活行**;**`"dead"` v1 不铸** * (core `list-agents-tool.js` 的清单过滤:deleted 行跳过、dead 行在缺省 `includeDead` 下也跳过 * —— ⚠️ S-516:这里**不写 dist 行号**,那批坐标每次提货都漂(7.23.5→7.24.2 就整段搬了家); * 要复核请按符号名搜。⇒ 把闲置会话映成 dead * 等于把用户自己的闲置会话从 `ListAgents` 里藏掉,正是本席要解的那个面。忙闲一律走 `tempo`); * · `tempo` = `sessionTempoOfRunStatus(lastStatus)`(`plugins/store-contracts.ts` 的那**一张**表, * `GET /v1/sessions` 的 `tempo` 列读的是同一只函数 —— 一处定义两处读); * · `inboundPosture` = managed 层 `crossSessionInbound === "refuse"` ⇒ `"unavailable"`,否则 `"available"`; * · `pid` / `procStartMs` / `sockPath` / `cwd` = **恒缺席**(server 会话是耐久可寻址实体,不是进程; * 缺席即 core 的探活臂按 `isPeerSessionProcessAlive` 直接跳过); * · `peerProtocol` / `peerFeatures` = 不铸(v1 不声明 CC 能力位;core 侧全 optional)。 */ import { type PeerDirectory } from "@sema-agent/core"; import type { CrossSessionInboundSettingLayers } from "@sema-agent/core"; import type { Logger } from "./observability/logger.js"; import type { MailboxRecipientLifecycle } from "./plugins/mailbox-recipient-lifecycle.js"; import { type SessionListItem } from "./security.js"; /** 会话枚举面(= `GET /v1/sessions` 用的那只 `OwnerAwareSessionStore.listSessions`)。 */ export type PeerSessionLister = (opts: { owner?: string; includeUnowned?: boolean; cursor?: { lastActivityAt: string; sessionId: string; }; limit: number; q?: string; }) => Promise; /** * 一次 `listPeerSessions` 最多铸多少行。 * * 🔴 为什么要有上界、而且是**常量**:core 的 ref 铸造(`mintPeerSessionCandidates`)跑在**全集**上,而 * `ListAgents` 的结果还有 10 000 字符的硬上界(`LIST_AGENTS_MAX_RESULT_CHARS`)—— 没有上界的话一个有 * 几千条会话的 principal 会让每一次 turn 边界都拉一张巨表。做成部署旋钮会多一个「两台机器答案不同」的 * 轴(ref 的稳定性依赖集合本身),而这个数字不是策略、是防呆:超出即**响亮截断并计数**(见 * `peer_directory_truncated`),不静默。 */ export declare const PEER_DIRECTORY_CAP = 200; export interface PeerDirectorySeatCtx { /** 会话枚举面;缺席 ⇒ 不给席(装配点判,见 `boot/runner-deps.ts`)。 */ lister: PeerSessionLister; /** mailbox 的收件人生命周期面(墓碑读 + 被活行压过的墓碑撤回)。 */ lifecycle: MailboxRecipientLifecycle; /** `config.requirePrincipal` —— 窄互认臂的条件①(与列表面同一个开关)。 */ requirePrincipal: boolean; /** managed 层的 `crossSessionInbound` 供层(`cross-session-settings.ts` 的既有解析,不新写)。 */ crossSessionInbound: CrossSessionInboundSettingLayers | undefined; logger: Logger; } /** * peer 目录席 —— `create*`(带行为的对象:每次调用都真读一次两个面)。 * * 只实现 `listPeerSessions`。**`restorePeerSession` 刻意不实现**:core 的接口注逐字写着「a directory that * cannot write (a server session table the engine only reads) omits it, and the residual window is disclosed * in the receipt instead」—— 本席读的是会话账,会话行不是由 peer 车道创建的,写回去等于让 peer 车道 * 凭一次投递**造出**一条会话行。 */ export declare function createPeerDirectory(ctx: PeerDirectorySeatCtx): PeerDirectory; //# sourceMappingURL=peer-directory.d.ts.map