fix(gateway): scope per-run reads to the run's data identity (#6282)

* fix(gateway): scope per-run message and event reads to the run's data identity

start_run stamps run-event rows with the raw trusted owner id, but
GET /runs/{rid}/messages and /runs/{rid}/events called the event store with
user_id=AUTO, which resolves to the internal caller's make_safe_user_id form.
On the database event store, owners whose id contains characters outside
[A-Za-z0-9_-] (IM channel ids such as "feishu:owner-777") therefore got an
empty list. The regenerate source-run scan had the same mismatch and fell
back or returned 409.

Pass _run_scope_user_id() explicitly, as the thread message routes already
do (#5448), and add user_id to RunEventStore.list_messages_by_run() so every
backend accepts it like list_messages()/list_events(). Browser and API
sessions keep their per-user filter.

* fix(gateway): scope workspace-changes and archive reads to the run's data identity

The same raw-owner mismatch reached two more run-scoped routes. On the SQL
stores, GET /runs/{rid}/workspace-changes reported no changes because its
event lookup used user_id=AUTO, and GET /runs/{rid}/artifacts/archive
answered 404 because both its run-row lookup and its run.delivery event
lookup did.

Pass _run_scope_user_id() to both, and let get_workspace_changes_response()
forward an explicit user_id to the event store.

* docs(changelog): reference #6282 in the run-scope read fix entries

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Hyeonsang Cho 2026-10-05 11:23:55 +09:00 committed by GitHub
parent 56aa71c583
commit c540399a5e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 306 additions and 16 deletions

View File

@ -466,6 +466,17 @@ This release closes that milestone with **301 merged pull requests**.
### Fixed
- **gateway:** Per-run reads now return the rows of IM-channel owners.
`start_run` stamps run rows and run events with the raw trusted owner id (for
example `feishu:owner-777`), but several run-scoped routes filtered by the
internal caller's normalized id, so on the SQL stores any owner id containing
characters outside `[A-Za-z0-9_-]` matched nothing:
`GET /api/threads/{id}/runs/{rid}/messages` and `/events` returned an empty
list, `/workspace-changes` reported no changes, `/artifacts/archive` answered
404, and the regenerate source-run lookup fell back or failed with 409. These
reads now use the same data identity as the thread message routes (#5448),
and every event store accepts `user_id` on `list_messages_by_run()`. Browser
and API sessions keep their per-user filter. ([#6282])
- **runtime:** A multi-worker run that finished successfully is no longer
reclaimed as an orphan `error` while its worker is still finalizing. With an
event store, the worker records the terminal status in memory first and
@ -7722,3 +7733,4 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
[#6238]: https://github.com/bytedance/deer-flow/pull/6238
[#6255]: https://github.com/bytedance/deer-flow/pull/6255
[#6263]: https://github.com/bytedance/deer-flow/pull/6263
[#6282]: https://github.com/bytedance/deer-flow/pull/6282

View File

@ -423,6 +423,15 @@
### 修复
- **网关:** 单次运行的读取现在能返回 IM 渠道所有者的数据。`start_run` 用原始
受信所有者 ID(例如 `feishu:owner-777`)标记运行行与运行事件,但多个运行级路由
按内部调用方规范化后的 ID 过滤,因此在 SQL 存储上,只要所有者 ID 含有
`[A-Za-z0-9_-]` 以外的字符就匹配不到任何数据:
`GET /api/threads/{id}/runs/{rid}/messages` 与 `/events` 返回空列表,
`/workspace-changes` 报告没有变更,`/artifacts/archive` 返回 404,重新生成的
源运行查找退回兜底路径或返回 409。这些读取现在使用与线程消息路由(#5448)相同
的数据身份,所有事件存储的 `list_messages_by_run()` 都接受 `user_id`。浏览器与
API 会话仍保留按用户过滤。([#6282])
- **运行时:** 多 worker 部署中已成功结束的运行,不会再在其 worker 仍在收尾时被
当作孤儿运行回收为 `error`。配置了事件存储时,worker 先在内存中记录终态,直到
journal 刷新、交付回执、工作区扫描与时长 checkpoint 写入完成后才写入运行存储。
@ -6445,3 +6454,4 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
[#6238]: https://github.com/bytedance/deer-flow/pull/6238
[#6255]: https://github.com/bytedance/deer-flow/pull/6255
[#6263]: https://github.com/bytedance/deer-flow/pull/6263
[#6282]: https://github.com/bytedance/deer-flow/pull/6282

View File

@ -604,8 +604,9 @@ async def _find_target_run_id(
source_human: Any,
request: Request,
) -> str:
user_id = await _run_scope_user_id(request, thread_id)
event_store = get_run_event_store(request)
rows = await event_store.list_messages(thread_id, limit=REGENERATE_HISTORY_SCAN_LIMIT)
rows = await event_store.list_messages(thread_id, limit=REGENERATE_HISTORY_SCAN_LIMIT, user_id=user_id)
for row in reversed(rows):
if row.get("event_type") not in {"ai_message", "llm.ai.response"}:
continue
@ -619,7 +620,6 @@ async def _find_target_run_id(
return source_run_id
run_mgr = get_run_manager(request)
user_id = await _run_scope_user_id(request, thread_id)
records = await run_mgr.list_by_thread(thread_id, user_id=user_id, limit=10)
fallback_record = next(
(record for record in records if record.status == RunStatus.success and _run_last_ai_matches_message(record, target_message)),
@ -1159,10 +1159,12 @@ async def _require_run_visible_to_scope(run_id: str, thread_id: str, request: Re
"""Gate run-scoped sub-resource reads and writes (events, messages, join,
stream, cancel, artifact archive).
These routes query or mutate by ``(thread_id, run_id)`` without a
per-user filter of their own. For trusted internal callers on threads
without established ownership, that let an internal caller acting for
owner A read or cancel owner B's run by id (#5448 review P1 follow-up).
These routes query or mutate by ``(thread_id, run_id)``; events and
messages add only the ``_run_scope_user_id`` data filter, which matches
row stamps rather than gating the run itself. For trusted internal
callers on threads without established ownership, a missing gate let an
internal caller acting for owner A read or cancel owner B's run by id
(#5448 review P1 follow-up).
The run's own stamp must therefore match the acting owner's raw value (or
the legacy ``"default"`` stamp); every other caller and every
established-ownership thread keeps its existing semantics.
@ -1638,6 +1640,7 @@ async def list_run_messages(
Response: { data: [...], has_more: bool }
"""
await _require_run_visible_to_scope(run_id, thread_id, request)
user_id = await _run_scope_user_id(request, thread_id)
event_store = get_run_event_store(request)
rows = await event_store.list_messages_by_run(
thread_id,
@ -1645,6 +1648,7 @@ async def list_run_messages(
limit=limit + 1,
before_seq=before_seq,
after_seq=after_seq,
user_id=user_id,
)
data, has_more = trim_run_message_page(rows, limit=limit, after_seq=after_seq)
@ -1718,7 +1722,8 @@ def _presented_files_from_delivery(events: list[dict]) -> list[str]:
async def _archive_presented_paths(thread_id: ThreadId, run_id: str, request: Request) -> list[str]:
run = await get_run_store(request).get(run_id)
user_id = await _run_scope_user_id(request, thread_id)
run = await get_run_store(request).get(run_id, user_id=user_id)
if run is None or run.get("thread_id") != thread_id or run.get("operation_kind", "run") != "run":
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
if run.get("status") in {RunStatus.pending.value, RunStatus.running.value}:
@ -1729,6 +1734,7 @@ async def _archive_presented_paths(thread_id: ThreadId, run_id: str, request: Re
run_id,
event_types=["run.delivery"],
limit=2,
user_id=user_id,
)
return _presented_files_from_delivery(events)
@ -1825,6 +1831,7 @@ async def list_run_events(
task's persisted steps without the run-wide ``limit`` truncating the tail (#3779).
"""
await _require_run_visible_to_scope(run_id, thread_id, request)
user_id = await _run_scope_user_id(request, thread_id)
event_store = get_run_event_store(request)
types = event_types.split(",") if event_types else None
events = await event_store.list_events(
@ -1834,6 +1841,7 @@ async def list_run_events(
task_id=task_id,
limit=limit,
after_seq=after_seq,
user_id=user_id,
)
return [
{
@ -1857,6 +1865,7 @@ async def get_run_workspace_changes(
) -> dict:
"""Return workspace/output file changes recorded for one run."""
await _require_run_visible_to_scope(run_id, thread_id, request)
user_id = await _run_scope_user_id(request, thread_id)
event_store = get_run_event_store(request)
return await get_workspace_changes_response(
event_store,
@ -1864,6 +1873,7 @@ async def get_run_workspace_changes(
run_id,
include_files=include_files,
include_diff=include_diff,
user_id=user_id,
)

View File

@ -132,6 +132,13 @@ writer enter the supposedly stable snapshot. The default and JSONL paths share t
`normalize_message_ids()` and `match_ai_message_run_id()` helpers from
`events/store/base.py`. Database owner filtering is inherited on every page.
**Run-event read identity**: `list_messages`, `list_events` and
`list_messages_by_run` accept `user_id` on every backend (DB filters;
memory/JSONL accept it for parity). `start_run` stamps rows with the raw trusted
owner, but `AUTO` resolves to the internal user's `make_safe_user_id` form, so
Gateway thread/run reads (including run-row lookups) must pass
`_run_scope_user_id()` explicitly.
**Event-store mutation fence** (`runtime/events/store/`): every thread mutation —
`put`, `put_batch`, `put_if_absent`, `delete_by_thread`, `delete_by_run` — shares
one serialization domain: the per-thread `asyncio` lock, plus (on PostgreSQL) the

View File

@ -218,6 +218,7 @@ class RunEventStore(abc.ABC):
limit: int = 50,
before_seq: int | None = None,
after_seq: int | None = None,
user_id: str | None | _AutoSentinel = AUTO,
) -> list[dict]:
"""Return displayable messages (category=message) for a specific run, ordered by seq ascending.
@ -228,6 +229,8 @@ class RunEventStore(abc.ABC):
When both cursors are supplied, return the first ``limit`` records in
the exclusive window ``after_seq < seq < before_seq`` (ascending).
``user_id`` follows the same explicit-caller semantics as
:meth:`list_messages`.
"""
@abc.abstractmethod

View File

@ -399,7 +399,7 @@ class JsonlRunEventStore(RunEventStore):
events = [e for e in events if e.get("seq", 0) > after_seq]
return events[:limit]
async def list_messages_by_run(self, thread_id, run_id, *, limit=50, before_seq=None, after_seq=None):
async def list_messages_by_run(self, thread_id, run_id, *, limit=50, before_seq=None, after_seq=None, user_id: str | None | _AutoSentinel = AUTO):
events = await asyncio.to_thread(self._read_run_events, thread_id, run_id)
filtered = [e for e in events if e.get("category") == "message"]
if before_seq is not None:

View File

@ -164,7 +164,7 @@ class MemoryRunEventStore(RunEventStore):
run_events = [e for e in run_events if e.get("seq", 0) > after_seq]
return run_events[:limit]
async def list_messages_by_run(self, thread_id, run_id, *, limit=50, before_seq=None, after_seq=None):
async def list_messages_by_run(self, thread_id, run_id, *, limit=50, before_seq=None, after_seq=None, user_id: str | None | _AutoSentinel = AUTO):
# Per-run, messages-only, seq-sorted: the seq window is a contiguous
# slice located with bisect (O(log m_run)) over only this run's
# messages, instead of re-scanning the whole thread's event log.

View File

@ -2,6 +2,8 @@ from __future__ import annotations
from typing import Any
from deerflow.runtime.user_context import AUTO, _AutoSentinel
from .types import WORKSPACE_CHANGES_EVENT_TYPE, WORKSPACE_CHANGES_METADATA_KEY
EMPTY_SUMMARY = {
@ -22,12 +24,14 @@ async def get_workspace_changes_response(
*,
include_files: bool = True,
include_diff: bool = True,
user_id: str | None | _AutoSentinel = AUTO,
) -> dict[str, Any]:
events = await event_store.list_events(
thread_id,
run_id,
event_types=[WORKSPACE_CHANGES_EVENT_TYPE],
limit=10,
user_id=user_id,
)
if not events:
return _empty_response()

View File

@ -393,6 +393,41 @@ class TestListMessagesByRun:
assert messages[0]["run_id"] == "r1"
assert messages[0]["category"] == "message"
@pytest.mark.anyio
@pytest.mark.parametrize("backend", ["memory", "jsonl", "db"])
async def test_explicit_user_id_follows_list_messages_semantics(self, tmp_path, backend):
"""Run-scoped Gateway reads pass an explicit data identity on every backend.
Only the SQL store isolates by user; the others accept and ignore it,
as they do for ``list_messages`` and ``list_events``.
"""
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
from deerflow.runtime.events.store.db import DbRunEventStore
from deerflow.runtime.events.store.jsonl import JsonlRunEventStore
from deerflow.runtime.user_context import reset_current_user, set_current_user
if backend == "db":
await init_engine("sqlite", url=f"sqlite+aiosqlite:///{tmp_path / 'test.db'}", sqlite_dir=str(tmp_path))
s = DbRunEventStore(get_session_factory())
elif backend == "jsonl":
s = JsonlRunEventStore(base_dir=tmp_path / "jsonl")
else:
s = MemoryRunEventStore()
try:
token = set_current_user(type("Owner", (), {"id": "feishu:owner"})())
try:
await s.put(thread_id="t1", run_id="r1", event_type="ai_message", category="message")
finally:
reset_current_user(token)
assert len(await s.list_messages_by_run("t1", "r1", user_id="feishu:owner")) == 1
assert len(await s.list_messages_by_run("t1", "r1", user_id=None)) == 1
other = await s.list_messages_by_run("t1", "r1", user_id="someone-else")
assert len(other) == (0 if backend == "db" else 1)
finally:
if backend == "db":
await close_engine()
# -- count_messages --

View File

@ -35,8 +35,8 @@ async def test_list_run_events_forwards_task_id_and_after_seq():
calls: dict = {}
class FakeStore:
async def list_events(self, thread_id, run_id, *, event_types=None, task_id=None, limit=500, after_seq=None):
calls.update(thread_id=thread_id, run_id=run_id, event_types=event_types, task_id=task_id, limit=limit, after_seq=after_seq)
async def list_events(self, thread_id, run_id, *, event_types=None, task_id=None, limit=500, after_seq=None, user_id=None):
calls.update(thread_id=thread_id, run_id=run_id, event_types=event_types, task_id=task_id, limit=limit, after_seq=after_seq, user_id=user_id)
return [{"seq": 1, "event_type": "subagent.step"}]
class FakeState:
@ -48,6 +48,7 @@ async def test_list_run_events_forwards_task_id_and_after_seq():
class FakeRequest:
app = FakeApp()
_deerflow_test_bypass_auth = True
cookies: dict = {}
result = await list_run_events(
thread_id="t1",
@ -63,6 +64,7 @@ async def test_list_run_events_forwards_task_id_and_after_seq():
assert calls["task_id"] == "task-A"
assert calls["after_seq"] == 7
assert calls["event_types"] == ["subagent.start", "subagent.step", "subagent.end"]
assert calls["user_id"] is None
@pytest.mark.anyio
@ -80,7 +82,7 @@ async def test_list_run_events_redacts_historical_run_start_metadata():
}
class FakeStore:
async def list_events(self, thread_id, run_id, *, event_types=None, task_id=None, limit=500, after_seq=None):
async def list_events(self, thread_id, run_id, *, event_types=None, task_id=None, limit=500, after_seq=None, user_id=None):
return [stored_row]
class FakeState:
@ -92,6 +94,7 @@ async def test_list_run_events_redacts_historical_run_start_metadata():
class FakeRequest:
app = FakeApp()
_deerflow_test_bypass_auth = True
cookies: dict = {}
events = await list_run_events(
thread_id="legacy-thread",
@ -175,6 +178,7 @@ async def test_effective_memory_flows_from_injection_to_the_existing_debug_api()
class FakeRequest_:
app = FakeApp()
_deerflow_test_bypass_auth = True
cookies: dict = {}
events = await list_run_events(
thread_id="t1",

View File

@ -181,7 +181,7 @@ class FakeEventStore:
def __init__(self, rows):
self.rows = rows
async def list_messages(self, thread_id, *, limit=50, before_seq=None, after_seq=None):
async def list_messages(self, thread_id, *, limit=50, before_seq=None, after_seq=None, user_id=None):
return self.rows[-limit:]

View File

@ -164,6 +164,7 @@ def test_after_seq_forwarded_to_event_store():
limit=51, # default limit(50) + 1
before_seq=None,
after_seq=5,
user_id=None,
)
@ -181,6 +182,7 @@ def test_before_seq_forwarded_to_event_store():
limit=51,
before_seq=10,
after_seq=None,
user_id=None,
)
@ -198,6 +200,7 @@ def test_custom_limit_forwarded_to_event_store():
limit=11, # 10 + 1
before_seq=None,
after_seq=None,
user_id=None,
)

View File

@ -86,6 +86,7 @@ def test_read_endpoints_accept_positive_limits_and_hit_store():
limit=2,
before_seq=None,
after_seq=None,
user_id=None,
)
app.state.run_event_store.list_events.assert_awaited_once_with(
"thread-1",
@ -94,6 +95,7 @@ def test_read_endpoints_accept_positive_limits_and_hit_store():
task_id=None,
limit=1,
after_seq=None,
user_id=None,
)

View File

@ -16,6 +16,7 @@ import asyncio
from types import SimpleNamespace
from uuid import UUID
import anyio
import pytest
from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.testclient import TestClient
@ -27,10 +28,15 @@ from app.gateway.auth_disabled import AUTH_SOURCE_INTERNAL, AUTH_SOURCE_SESSION
from app.gateway.authz import AuthContext, Permissions
from app.gateway.internal_auth import INTERNAL_OWNER_USER_ID_HEADER_NAME, get_internal_user
from app.gateway.routers import thread_runs
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
from deerflow.persistence.run.sql import RunRepository
from deerflow.persistence.thread_meta.memory import MemoryThreadMetaStore
from deerflow.runtime.events.store.db import DbRunEventStore
from deerflow.runtime.events.store.memory import MemoryRunEventStore
from deerflow.runtime.runs.manager import RunManager
from deerflow.runtime.runs.store.memory import MemoryRunStore
from deerflow.runtime.user_context import reset_current_user, set_current_user
from deerflow.workspace_changes.types import WORKSPACE_CHANGES_EVENT_TYPE
THREAD_ID = "thread-scope"
BROWSER_USER_ID = UUID("00000000-0000-0000-0000-00000000000a")
@ -49,7 +55,11 @@ _STUB_PERMISSIONS: list[str] = [
class _ScopeAuthMiddleware(BaseHTTPMiddleware):
"""Stamp the same state trio production ``AuthMiddleware`` stamps."""
"""Stamp the state trio and user ContextVar production ``AuthMiddleware`` sets.
The ContextVar is what ``user_id=AUTO`` repository reads resolve against,
so SQL-backed stores see the same identity they would in the Gateway.
"""
def __init__(self, app, *, user, auth_source: str) -> None:
super().__init__(app)
@ -60,7 +70,11 @@ class _ScopeAuthMiddleware(BaseHTTPMiddleware):
request.state.user = self._user
request.state.auth_source = self._auth_source
request.state.auth = AuthContext(user=self._user, permissions=list(_STUB_PERMISSIONS))
return await call_next(request)
token = set_current_user(self._user)
try:
return await call_next(request)
finally:
reset_current_user(token)
class _PermissiveThreadStore:
@ -778,3 +792,188 @@ def test_token_usage_unfiltered_on_established_ownership_for_internal_callers()
assert response.status_code == 200
body = response.json()
assert body["total_tokens"] == 166 # both stamps fold when ownership is established
# ---------------------------------------------------------------------------
# SQL-backed run-event reads
# ---------------------------------------------------------------------------
#
# ``MemoryRunEventStore`` ignores ``user_id``, so the identity mismatch only
# surfaces on the SQL store: ``start_run`` stamps run-event rows with the raw
# trusted owner, while an ``AUTO`` read filters by the internal user's
# ``make_safe_user_id``-normalized id and matches nothing.
@pytest.fixture()
def db_event_store(tmp_path):
async def _init() -> DbRunEventStore:
await init_engine("sqlite", url=f"sqlite+aiosqlite:///{tmp_path / 'run_events.db'}", sqlite_dir=str(tmp_path))
return DbRunEventStore(get_session_factory())
yield anyio.run(_init)
anyio.run(close_engine)
def _as_owner(owner: str, write) -> None:
"""Run ``write`` under ``owner``'s user context, as ``start_run`` does."""
async def _run() -> None:
token = set_current_user(SimpleNamespace(id=owner))
try:
await write()
finally:
reset_current_user(token)
anyio.run(_run)
def _seed_owner_stamped_message(event_store: DbRunEventStore, run_id: str, message_id: str, *, owner: str) -> None:
_as_owner(
owner,
lambda: event_store.put(
thread_id=THREAD_ID,
run_id=run_id,
event_type="llm.ai.response",
category="message",
content={"type": "ai", "id": message_id, "content": message_id, "additional_kwargs": {}},
metadata={},
),
)
@pytest.mark.parametrize("ownership", ["established", "missing_meta"])
def test_internal_caller_reads_raw_owner_stamped_run_events(db_event_store: DbRunEventStore, ownership: str) -> None:
"""Run messages/events resolve the same data identity as thread messages."""
thread_store = _PermissiveThreadStore() if ownership == "established" else MemoryThreadMetaStore(InMemoryStore())
run_store = _RecordingRunStore()
_seed_run(run_store, RUN_OWNER, user_id=OWNER_RAW)
_seed_owner_stamped_message(db_event_store, RUN_OWNER, "msg-owner", owner=OWNER_RAW)
client = _make_app(
user=_internal_user(OWNER_RAW),
auth_source=AUTH_SOURCE_INTERNAL,
run_store=run_store,
event_store=db_event_store,
thread_store=thread_store,
)
headers = {INTERNAL_OWNER_USER_ID_HEADER_NAME: OWNER_RAW}
base = f"/api/threads/{THREAD_ID}/runs/{RUN_OWNER}"
with client:
messages = client.get(base + "/messages", headers=headers)
events = client.get(base + "/events", headers=headers)
assert messages.status_code == 200
assert [row["content"]["id"] for row in messages.json()["data"]] == ["msg-owner"]
assert events.status_code == 200
assert [event["content"]["id"] for event in events.json()] == ["msg-owner"]
def test_browser_session_run_events_keep_per_user_filter(db_event_store: DbRunEventStore) -> None:
"""Browser sessions still read only rows stamped with their own id."""
run_store = _RecordingRunStore()
_seed_run(run_store, RUN_OWNER, user_id=OWNER_RAW)
_seed_owner_stamped_message(db_event_store, RUN_OWNER, "msg-owner", owner=OWNER_RAW)
client = _make_app(
user=_browser_user(),
auth_source=AUTH_SOURCE_SESSION,
run_store=run_store,
event_store=db_event_store,
)
base = f"/api/threads/{THREAD_ID}/runs/{RUN_OWNER}"
with client:
messages = client.get(base + "/messages")
events = client.get(base + "/events")
assert messages.status_code == 200
assert messages.json()["data"] == []
assert events.status_code == 200
assert events.json() == []
def test_regenerate_target_lookup_reads_raw_owner_stamped_events(db_event_store: DbRunEventStore) -> None:
"""The regenerate source-run scan finds the AI message by its raw stamp.
The run store is empty, so only the event-store scan can resolve the run.
"""
_seed_owner_stamped_message(db_event_store, RUN_OWNER, "msg-owner", owner=OWNER_RAW)
user = _internal_user(OWNER_RAW)
request = _helper_request(
user=user,
auth_source=AUTH_SOURCE_INTERNAL,
run_store=_RecordingRunStore(),
event_store=db_event_store,
owner_header=OWNER_RAW,
)
request.app.state.thread_store = _PermissiveThreadStore()
async def _lookup() -> str:
token = set_current_user(user)
try:
return await thread_runs._find_target_run_id(
THREAD_ID,
"msg-owner",
{"content": "msg-owner"},
{"additional_kwargs": {}},
request,
)
finally:
reset_current_user(token)
assert anyio.run(_lookup) == RUN_OWNER
@pytest.mark.parametrize("ownership", ["established", "null_owner"])
def test_internal_caller_reads_raw_owner_stamped_run_outputs(db_event_store: DbRunEventStore, ownership: str) -> None:
"""Workspace changes and the archive manifest resolve the raw owner stamp.
Both read SQL rows stamped by ``start_run``: the archive manifest also
looks the run row up itself, so it needs the SQL run store here.
"""
if ownership == "established":
thread_store = _PermissiveThreadStore()
else:
thread_store = MemoryThreadMetaStore(InMemoryStore())
asyncio.run(thread_store.create(THREAD_ID, assistant_id=None, user_id=None))
run_store = RunRepository(get_session_factory())
async def _seed() -> None:
await run_store.put(RUN_OWNER, thread_id=THREAD_ID, status="success")
await db_event_store.put(
thread_id=THREAD_ID,
run_id=RUN_OWNER,
event_type="run.delivery",
category="outputs",
content={"presented": 1, "by_tool": {"present_files": ["/mnt/user-data/outputs/a.txt"]}},
)
await db_event_store.put(
thread_id=THREAD_ID,
run_id=RUN_OWNER,
event_type=WORKSPACE_CHANGES_EVENT_TYPE,
category="outputs",
content={"summary": {"created": 1}, "files": []},
)
_as_owner(OWNER_RAW, _seed)
client = _make_app(
user=_internal_user(OWNER_RAW),
auth_source=AUTH_SOURCE_INTERNAL,
run_store=run_store,
event_store=db_event_store,
thread_store=thread_store,
)
headers = {INTERNAL_OWNER_USER_ID_HEADER_NAME: OWNER_RAW}
base = f"/api/threads/{THREAD_ID}/runs/{RUN_OWNER}"
with client:
changes = client.get(base + "/workspace-changes", headers=headers)
manifest = client.get(base + "/artifacts/archive", headers=headers)
assert changes.status_code == 200
assert changes.json()["available"] is True
assert changes.json()["summary"]["created"] == 1
assert manifest.status_code == 200, manifest.text
assert manifest.json()["file_count"] == 1

View File

@ -703,7 +703,7 @@ async def test_workspace_changes_route_forwards_include_files_flag():
calls: dict = {}
class FakeStore:
async def list_events(self, thread_id, run_id, *, event_types=None, task_id=None, limit=500, after_seq=None):
async def list_events(self, thread_id, run_id, *, event_types=None, task_id=None, limit=500, after_seq=None, user_id=None):
calls.update(thread_id=thread_id, run_id=run_id, event_types=event_types)
return [
{
@ -734,6 +734,7 @@ async def test_workspace_changes_route_forwards_include_files_flag():
class FakeRequest:
app = FakeApp()
_deerflow_test_bypass_auth = True
cookies: dict = {}
response = await get_run_workspace_changes(
thread_id="thread-1",