Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -742,7 +742,22 @@ origin, owner or provider claims. The TS binding/source owners decide admission
and provenance; Python observes identity and commits under the binding fence.
Steward ingress uses the registered coordination root and existing source policy,
separately from execution grants. File/SQLite HTTP regressions cover full text,
replay, FIFO and revocation races. This is a Core/API prerequisite: packaged
replay, FIFO and revocation races. The existing session-list endpoint also accepts
`GET /api/chat/sessions?conversation_binding_id=<binding_id>` to discover existing
private Sessions through that same fresh binding owner. It returns the existing
public Session summaries, retaining a project's null Goal and the original
Session/channel; it does not create or resume a thread, enqueue work or grant
execution. Group, unavailable and changed sources refuse; closed Sessions are
not offered as current targets and their history remains stored. An empty,
duplicate or mixed selector cannot fall back to the ordinary unfiltered list.
The filtered response includes the matching `conversation_binding_id`; callers
must require that match before adopting its targets. Older services may ignore
the query and return an ordinary list without it, which does not qualify this
discovery operation.
Provider observation stays outside the publication fence, and ordinary session
listing adds no provider IO. The product selector must use this verified original
identity rather than equating an ordinary workspace channel with a bound Chat.
This is a Core/API prerequisite: packaged
frontend discovery, live shared-Lark adoption, project-recipient collaboration
and original-conversation result return still require their existing acceptance.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,15 @@ CLI/SSH 导出使用 `goal-portfolio --manager-view todos --goal-id GOAL --todo-
TS binding/source owner 决定准入和来源关联,Python 观测身份并在 binding 发布锁内落队列。
管家 ingress 沿注册的 coordination root 和现有 source policy,执行授权仍独立。
真实 File/SQLite HTTP 回归覆盖完整文本、重放、FIFO 和撤权竞态。
现有会话列表也支持 `GET /api/chat/sessions?conversation_binding_id=<binding_id>`,
由同一 binding owner 重新核验后发现已经存在的本人私聊 Session。
返回既有公开 Session 摘要,保留项目的空 Goal 和原 Session/channel;不创建或恢复线程、
不排入工作,也不授予执行权限。群、不可用或已经变化的来源明确拒绝;关闭会话不再作为
当前目标提供,历史继续保存。空、重复或混合筛选不能退回普通的未筛选列表。
筛选响应包含相同的 `conversation_binding_id`;调用方采用目标前必须核对一致。
旧服务可能忽略这个 query,返回不带该字段的普通列表;这不算已经完成此发现操作。
Provider 观测在发布锁外完成;普通会话列表不增加 provider IO。产品选择器须采用
这个已核验的原会话身份,不能把普通工作区 channel 当作已经绑定的 Chat。
此项是 Core/API 前置:打包前端发现、真实共享飞书会话采用、项目接收者协作与原会话
结果返回仍须完成各自现有验收,不以合成测试宣称整条用户旅程完成。

Expand Down
40 changes: 40 additions & 0 deletions loopx/capabilities/native_chat/conversation_bindings.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,46 @@ def local_input(self, saved: dict[str, Any]):
raise ValueError("conversation binding changed before input admission")
yield self._resolve_observed(current, observed["binding"], observed["observation"], source)

def local_sessions(self, binding_id: str) -> list[dict[str, Any]]:
"""Read original targets; metadata hints never replace Core's grant.

Provider IO and store inventory stay outside the publication fence.
Discovery neither restores a thread nor creates a Session or Turn.
"""
if self.controller is None:
raise ValueError("the bound conversation store is unavailable")
row = next((item for item in self.read()["bindings"] if item["binding_id"] == binding_id), None)
if row is None:
raise ValueError("conversation binding is no longer authorized")
observation = dict(self.observe(row["transport_ref"]))
store = self.controller.store
sessions: list[dict[str, Any]] = []
for summary in store.list_sessions():
session = store.load_session(summary["session_id"])
# Narrow the IO payload only. Core independently validates every
# matching context, audience and canonical Session identity.
if session and any(isinstance(session.get(key), dict)
and session[key].get("binding_id") == binding_id
for key in ("project_context", "steward_context")):
sessions.append(session)
with exclusive_file_lock(self.path, operation="read_local_bound_sessions"):
current = self.read()
if next((item for item in current["bindings"] if item["binding_id"] == binding_id), None) != row:
raise ValueError("conversation binding changed during identity verification")
result = self._core("collaboration.conversation.local_sessions", {
"current": current, "binding_id": binding_id, "observation": observation,
"available_projects": self.projects.available(),
"sessions": [{key: session.get(key) for key in (
"session_id", "goal_id", "agent_id", "executor_endpoint_id", "channel_id",
"session_mode", "status", "project_context", "steward_context")}
for session in sessions],
**({"available_goal_ids": self.goal_scope_ids(row)} if row.get("goal_scope") == "all_registered" else {}),
})
# Build the lookup only after Core has rejected duplicate identity;
# an IO dictionary must not silently turn ambiguity into one target.
by_id = {session["session_id"]: session for session in sessions}
return [store.public_session(by_id[sid]) for sid in result["session_ids"]]

def goal_scope_ids(self, binding: dict[str, Any]) -> list[str]:
if binding.get("goal_scope") != "all_registered":
return list(binding.get("goal_ids", []))
Expand Down
18 changes: 17 additions & 1 deletion loopx/chat_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -946,7 +946,23 @@ def _session_snapshot(self, session_id: str) -> None:
self._send_error("chat session was not found", status=404)

def _list_sessions(self) -> None:
query = parse_qs(urlparse(self.path).query)
query = parse_qs(urlparse(self.path).query, keep_blank_values=True)
if "conversation_binding_id" in query:
if set(query) != {"conversation_binding_id"} or len(query["conversation_binding_id"]) != 1 or not query["conversation_binding_id"][0]:
return self._send_error("select one exact conversation binding without other Session filters", status=400)
bindings = self.server.runtime_controller.project_contexts.conversation_bindings
try:
if bindings is None:
raise ValueError("conversation bindings unavailable")
sessions = bindings.local_sessions(query["conversation_binding_id"][0])
except (ValueError, OSError, KeyError):
# A failed provider/store read may contain private diagnostics.
# Refuse this selector; never fall back to an unfiltered list.
return self._send_error("Original private conversation is unavailable; verify its App, owner and workspace binding.", status=409)
# Older services ignore this new query. A matching selector in the
# response lets callers reject their ordinary, unfiltered result.
return self._send_json({"ok": True, "schema_version": "loopx_chat_session_list_v1",
"conversation_binding_id": query["conversation_binding_id"][0], "sessions": sessions})
goal_id = _compact_text((query.get("goal_id") or [""])[0], limit=160) or None
agent_id = _compact_text((query.get("agent_id") or [""])[0], limit=80) or None
channel_id = _compact_text((query.get("channel_id") or [""])[0], limit=160) or None
Expand Down
54 changes: 46 additions & 8 deletions loopx/control_plane/collaboration/conversation_binding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,16 @@ export function planConversationBinding(params: JsonObject): JsonObject {
return {changed: true, state: next};
}

function boundProject(params: JsonObject, row: JsonObject): Record<string, string> {
if (!Array.isArray(params.available_projects)) throw new EffectRuntimeRequestError("workspace grants unavailable");
const projects = params.available_projects.map(normalizeProjectContext).filter(project => project.project_ref === row.project_ref);
if (projects.length !== 1) throw new EffectRuntimeRequestError("workspace grant is unavailable or ambiguous");
if (row.grant === "workspace_write" && projects[0].grant !== "workspace_write") {
throw new EffectRuntimeRequestError("workspace write grant is no longer available");
}
return projects[0];
}

export function resolveBoundConversation(params: JsonObject): JsonObject {
const current = state(params.current);
const id = ref(params.binding_id, "binding identity");
Expand Down Expand Up @@ -307,15 +317,10 @@ export function resolveBoundConversation(params: JsonObject): JsonObject {
|| params.group_ref !== undefined || params.topic_ref !== undefined || params.group_human_message === true) {
throw new EffectRuntimeRequestError("message audience is outside the binding grant");
}
if (!Array.isArray(params.available_projects)) throw new EffectRuntimeRequestError("workspace grants unavailable");
const projects = params.available_projects.map(normalizeProjectContext).filter(project => project.project_ref === row.project_ref);
if (projects.length !== 1) throw new EffectRuntimeRequestError("workspace grant is unavailable or ambiguous");
if (row.grant === "workspace_write" && projects[0].grant !== "workspace_write") {
throw new EffectRuntimeRequestError("workspace write grant is no longer available");
}
const project = boundProject(params, row);
const goals = row.context_kind === "steward" && row.goal_scope === "all_registered"
? normalizeStewardGoalScope(params.available_goal_ids) : row.goal_ids;
const rawContext = {...projects[0], ...topic, audience: group ? "bound_group" : "bound_owner", binding_id: id, source_ref: source,
const rawContext = {...project, ...topic, audience: group ? "bound_group" : "bound_owner", binding_id: id, source_ref: source,
provider_ref: row.provider_ref, operator_ref: row.operator_ref, grant: row.grant,
...(row.context_kind === "steward" ? {kind: "bound_steward", grant: "portfolio_read", goal_ids: goals} : {})};
const context = group ? normalizeProjectContext(rawContext) : rawContext;
Expand All @@ -333,11 +338,44 @@ export function resolveBoundConversation(params: JsonObject): JsonObject {
// verified owner in this private source may inherit an explicit machine
// owner grant. Portfolio scope and project write grants do not imply it.
return {binding: row, context, owner_manager_audience: row.context_kind === "steward"
&& projects[0].filesystem_scope !== "workspace_only",
&& project.filesystem_scope !== "workspace_only",
channel_id: row.context_kind === "steward"
? `manager.external.native.${id}.${source}` : `project.external.${id}.${source}`};
}

/** Discover existing private targets from canonical metadata. This is a read
* projection: the next input still needs fresh source and execution admission.
*/
export function listLocalBoundSessions(params: JsonObject): JsonObject {
const current = state(params.current);
const id = ref(params.binding_id, "binding identity");
const row = current.bindings.find(item => item.binding_id === id);
if (!row) throw new EffectRuntimeRequestError("conversation binding is no longer authorized");
observed(row, params.observation);
if (row.audience === "group") throw new EffectRuntimeRequestError("local discovery requires a private binding");
boundProject(params, row);
if (!Array.isArray(params.sessions)) throw new EffectRuntimeRequestError("canonical Session observations unavailable");
const ids: string[] = [];
for (const value of params.sessions) {
const session = requireJsonObject(value, "canonical Session");
const contexts = [session.project_context, session.steward_context];
if (!contexts.some(value => value && typeof value === "object" && !Array.isArray(value)
&& (value as JsonObject).binding_id === id) || session.status === "closed") continue;
const steward = row.context_kind === "steward";
const saved = requireJsonObject(steward ? session.steward_context : session.project_context, "bound Session context");
const selected = resolveBoundConversation({...params, source_ref: saved.source_ref,
sender_ref: row.operator_ref, private_human_message: false, origin: "web", session_context: saved});
if (session.channel_id !== selected.channel_id || session.goal_id !== (steward ? "loopx-manager" : null)
|| session.agent_id !== row.executor_endpoint_id || session.executor_endpoint_id !== row.executor_endpoint_id
|| session.session_mode !== "managed_runtime" || (steward ? session.project_context : session.steward_context) != null) {
throw new EffectRuntimeRequestError("canonical bound Session identity changed");
}
ids.push(sessionIdentity(session.session_id, "bound Session identity"));
}
if (new Set(ids).size !== ids.length) throw new EffectRuntimeRequestError("canonical bound Session identity is ambiguous");
return {session_ids: ids};
}

/** Shared native command projection. Provider grammar carries the explicit
* command; Core binds its exact target before IO. Replays retain that target.
*/
Expand Down
1 change: 1 addition & 0 deletions loopx/control_plane/effect_runtime_handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -617,6 +617,7 @@ export function createEffectRuntimeHandlers(
["collaboration.steward.authorize_creation", lazyHandler(() => import("./collaboration/conversation_binding.ts"), ({authorizeStewardCreation}) => authorizeStewardCreation)],
["collaboration.conversation.binding", lazyHandler(() => import("./collaboration/conversation_binding.ts"), ({planConversationBinding}) => planConversationBinding)],
["collaboration.conversation.bound_context", lazyHandler(() => import("./collaboration/conversation_binding.ts"), ({resolveBoundConversation}) => resolveBoundConversation)],
["collaboration.conversation.local_sessions", lazyHandler(() => import("./collaboration/conversation_binding.ts"), ({listLocalBoundSessions}) => listLocalBoundSessions)],
["collaboration.conversation.request", lazyHandler(() => import("./collaboration/conversation_binding.ts"), ({planBoundConversationRequest}) => planBoundConversationRequest)],
["collaboration.conversation.agent_target", lazyHandler(() => import("./collaboration/conversation_binding.ts"), ({resolveConversationAgentTarget}) => resolveConversationAgentTarget)],
["collaboration.peer.context_access", lazyHandler(() => import("./collaboration/peer_context.ts"), ({requirePeerContextAccess}) => requirePeerContextAccess)],
Expand Down
Loading
Loading