crabcode 0.1.6__py3-none-win_amd64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- crabcode-0.1.6.dist-info/METADATA +63 -0
- crabcode-0.1.6.dist-info/RECORD +205 -0
- crabcode-0.1.6.dist-info/WHEEL +4 -0
- crabcode-0.1.6.dist-info/entry_points.txt +2 -0
- crabcode_cli/__init__.py +7 -0
- crabcode_cli/__main__.py +4 -0
- crabcode_cli/_native/crabcode-computer-use-host.exe +0 -0
- crabcode_cli/_native/runtime.json +6 -0
- crabcode_cli/app.py +776 -0
- crabcode_cli/banner.py +39 -0
- crabcode_cli/computer_use.py +286 -0
- crabcode_cli/model_manager.py +649 -0
- crabcode_cli/model_picker.py +168 -0
- crabcode_cli/pipe.py +148 -0
- crabcode_cli/render/__init__.py +0 -0
- crabcode_cli/render/messages.py +19 -0
- crabcode_cli/repl.py +4841 -0
- crabcode_cli/session_picker.py +239 -0
- crabcode_core/__init__.py +7 -0
- crabcode_core/_version.py +3 -0
- crabcode_core/_windows_process.py +68 -0
- crabcode_core/agent_manager.py +1731 -0
- crabcode_core/api/__init__.py +20 -0
- crabcode_core/api/anthropic_adapter.py +648 -0
- crabcode_core/api/azure_adapter.py +69 -0
- crabcode_core/api/base.py +233 -0
- crabcode_core/api/codex_adapter.py +1327 -0
- crabcode_core/api/error_logging.py +132 -0
- crabcode_core/api/gemini_adapter.py +26 -0
- crabcode_core/api/model_info.py +257 -0
- crabcode_core/api/network.py +62 -0
- crabcode_core/api/ollama_adapter.py +27 -0
- crabcode_core/api/openai_adapter.py +323 -0
- crabcode_core/api/registry.py +58 -0
- crabcode_core/command_line.py +18 -0
- crabcode_core/compact/__init__.py +17 -0
- crabcode_core/compact/compact.py +514 -0
- crabcode_core/compact/context_tokens.py +165 -0
- crabcode_core/computer_use_mode.py +38 -0
- crabcode_core/computer_use_observation.py +144 -0
- crabcode_core/config/__init__.py +3 -0
- crabcode_core/config/claudemd.py +61 -0
- crabcode_core/config/manager.py +237 -0
- crabcode_core/config/model_catalog.py +256 -0
- crabcode_core/events.py +4896 -0
- crabcode_core/file_lock.py +81 -0
- crabcode_core/filesystem.py +22 -0
- crabcode_core/goal.py +149 -0
- crabcode_core/hooks/__init__.py +5 -0
- crabcode_core/hooks/manager.py +290 -0
- crabcode_core/image_io.py +111 -0
- crabcode_core/io_worker.py +211 -0
- crabcode_core/logging_utils.py +168 -0
- crabcode_core/lsp/__init__.py +6 -0
- crabcode_core/lsp/client.py +786 -0
- crabcode_core/lsp/diagnostics.py +150 -0
- crabcode_core/lsp/manager.py +583 -0
- crabcode_core/mcp/__init__.py +4 -0
- crabcode_core/mcp/client.py +169 -0
- crabcode_core/mcp/config.py +57 -0
- crabcode_core/path_validation.py +34 -0
- crabcode_core/paths.py +24 -0
- crabcode_core/peer/__init__.py +10 -0
- crabcode_core/peer/runtime.py +518 -0
- crabcode_core/permissions/__init__.py +7 -0
- crabcode_core/permissions/ai_reviewer.py +184 -0
- crabcode_core/permissions/manager.py +187 -0
- crabcode_core/plan/__init__.py +5 -0
- crabcode_core/plan/executor.py +270 -0
- crabcode_core/plan/types.py +169 -0
- crabcode_core/prompts/__init__.py +0 -0
- crabcode_core/prompts/blocks.py +10 -0
- crabcode_core/prompts/context.py +229 -0
- crabcode_core/prompts/library.py +92 -0
- crabcode_core/prompts/profile.py +79 -0
- crabcode_core/prompts/system.py +356 -0
- crabcode_core/prompts/templates.py +104 -0
- crabcode_core/query/__init__.py +0 -0
- crabcode_core/query/computer_use_history.py +83 -0
- crabcode_core/query/loop.py +2446 -0
- crabcode_core/query/retry.py +116 -0
- crabcode_core/schedule/__init__.py +13 -0
- crabcode_core/schedule/manager.py +631 -0
- crabcode_core/schedule/models.py +204 -0
- crabcode_core/schedule/store.py +558 -0
- crabcode_core/schedule/timing.py +287 -0
- crabcode_core/session/__init__.py +12 -0
- crabcode_core/session/export.py +141 -0
- crabcode_core/session/meta_db.py +684 -0
- crabcode_core/session/storage.py +2032 -0
- crabcode_core/session/title_gen.py +52 -0
- crabcode_core/skills/__init__.py +6 -0
- crabcode_core/skills/context.py +23 -0
- crabcode_core/skills/loader.py +214 -0
- crabcode_core/skills/matcher.py +203 -0
- crabcode_core/snapshot/__init__.py +21 -0
- crabcode_core/snapshot/snapshot.py +436 -0
- crabcode_core/snapshot/tracker.py +359 -0
- crabcode_core/subprocess_utils.py +260 -0
- crabcode_core/team/__init__.py +27 -0
- crabcode_core/team/bridge.py +66 -0
- crabcode_core/team/inbox.py +237 -0
- crabcode_core/team/manager.py +1660 -0
- crabcode_core/team/message_bus.py +821 -0
- crabcode_core/team/models.py +191 -0
- crabcode_core/team/recovery.py +192 -0
- crabcode_core/text_io.py +52 -0
- crabcode_core/tools/__init__.py +112 -0
- crabcode_core/tools/_input_helpers.py +39 -0
- crabcode_core/tools/_input_schema.py +130 -0
- crabcode_core/tools/agent.py +413 -0
- crabcode_core/tools/apply_patch.py +587 -0
- crabcode_core/tools/ask_user.py +122 -0
- crabcode_core/tools/bash.py +154 -0
- crabcode_core/tools/browser.py +678 -0
- crabcode_core/tools/checklist.py +241 -0
- crabcode_core/tools/checkpoint.py +78 -0
- crabcode_core/tools/computer_use.py +599 -0
- crabcode_core/tools/diff_utils.py +123 -0
- crabcode_core/tools/file_edit.py +240 -0
- crabcode_core/tools/file_read.py +187 -0
- crabcode_core/tools/file_write.py +154 -0
- crabcode_core/tools/glob.py +123 -0
- crabcode_core/tools/goal.py +134 -0
- crabcode_core/tools/grep.py +247 -0
- crabcode_core/tools/image.py +173 -0
- crabcode_core/tools/image_generate.py +115 -0
- crabcode_core/tools/lint.py +388 -0
- crabcode_core/tools/loading.py +187 -0
- crabcode_core/tools/memory.py +456 -0
- crabcode_core/tools/monitor.py +677 -0
- crabcode_core/tools/peer.py +103 -0
- crabcode_core/tools/revert.py +90 -0
- crabcode_core/tools/schedule.py +683 -0
- crabcode_core/tools/skill.py +108 -0
- crabcode_core/tools/switch_mode.py +213 -0
- crabcode_core/tools/team.py +329 -0
- crabcode_core/tools/tool_search.py +64 -0
- crabcode_core/tools/web_search.py +444 -0
- crabcode_core/turn_details.py +64 -0
- crabcode_core/types/__init__.py +95 -0
- crabcode_core/types/config.py +434 -0
- crabcode_core/types/event.py +334 -0
- crabcode_core/types/message.py +263 -0
- crabcode_core/types/tool.py +208 -0
- crabcode_core/usage.py +289 -0
- crabcode_core/utf8_sanitize.py +29 -0
- crabcode_debugger/__init__.py +5 -0
- crabcode_debugger/adapters.py +332 -0
- crabcode_debugger/dap.py +304 -0
- crabcode_debugger/memory.py +1806 -0
- crabcode_debugger/process.py +707 -0
- crabcode_debugger/runtime.py +58 -0
- crabcode_debugger/sessions.py +387 -0
- crabcode_debugger/tool.py +731 -0
- crabcode_gateway/__init__.py +18 -0
- crabcode_gateway/acp/__init__.py +13 -0
- crabcode_gateway/acp/agent.py +1581 -0
- crabcode_gateway/acp/session.py +114 -0
- crabcode_gateway/acp/transport.py +39 -0
- crabcode_gateway/acp/types.py +90 -0
- crabcode_gateway/adapter.py +30 -0
- crabcode_gateway/auth.py +139 -0
- crabcode_gateway/computer_use.py +364 -0
- crabcode_gateway/document_engine.py +637 -0
- crabcode_gateway/document_worker.py +301 -0
- crabcode_gateway/event_bus.py +655 -0
- crabcode_gateway/grpc/__init__.py +0 -0
- crabcode_gateway/grpc/crabcode_pb2.py +70 -0
- crabcode_gateway/grpc/crabcode_pb2_grpc.py +474 -0
- crabcode_gateway/grpc/proto/crabcode.proto +160 -0
- crabcode_gateway/grpc/server.py +765 -0
- crabcode_gateway/middleware.py +344 -0
- crabcode_gateway/protocol.py +17 -0
- crabcode_gateway/routes/__init__.py +0 -0
- crabcode_gateway/routes/agent.py +392 -0
- crabcode_gateway/routes/auth.py +150 -0
- crabcode_gateway/routes/computer_use.py +101 -0
- crabcode_gateway/routes/config.py +1698 -0
- crabcode_gateway/routes/document.py +1810 -0
- crabcode_gateway/routes/event.py +3801 -0
- crabcode_gateway/routes/git_review.py +232 -0
- crabcode_gateway/routes/health.py +14 -0
- crabcode_gateway/routes/peer.py +78 -0
- crabcode_gateway/routes/permission.py +121 -0
- crabcode_gateway/routes/schedule.py +300 -0
- crabcode_gateway/routes/session.py +1496 -0
- crabcode_gateway/routes/snapshot.py +211 -0
- crabcode_gateway/routes/tasks.py +318 -0
- crabcode_gateway/routes/team.py +392 -0
- crabcode_gateway/routes/usage.py +49 -0
- crabcode_gateway/routes/workspace.py +299 -0
- crabcode_gateway/safe_file.py +63 -0
- crabcode_gateway/schemas.py +1820 -0
- crabcode_gateway/server.py +514 -0
- crabcode_gateway/session_registry.py +36 -0
- crabcode_gateway/task_registry.py +708 -0
- crabcode_gateway/turn_details.py +60 -0
- crabcode_search/__init__.py +11 -0
- crabcode_search/background.py +368 -0
- crabcode_search/chunker.py +517 -0
- crabcode_search/embedder.py +497 -0
- crabcode_search/indexer.py +215 -0
- crabcode_search/store.py +222 -0
- crabcode_search/tool.py +268 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""TeamBridge — controlled cross-team communication.
|
|
2
|
+
|
|
3
|
+
Allows teams to send messages to each other through a bridge policy.
|
|
4
|
+
Policies: allow_all, allow_tagged, deny (default).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any, Awaitable, Callable
|
|
10
|
+
|
|
11
|
+
from crabcode_core.logging_utils import get_logger
|
|
12
|
+
from crabcode_core.team.manager import TeamManager
|
|
13
|
+
from crabcode_core.team.models import BridgePolicy, CrossTeamMessage
|
|
14
|
+
|
|
15
|
+
logger = get_logger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TeamBridge:
|
|
19
|
+
"""Manages cross-team communication with policy-based access control.
|
|
20
|
+
|
|
21
|
+
Usage:
|
|
22
|
+
bridge = TeamBridge(team_manager)
|
|
23
|
+
bridge.register("team-a", "team-b", BridgePolicy.ALLOW_TAGGED)
|
|
24
|
+
await bridge.send("team-a", "agent-1", "team-b", "agent-2", "Hello!")
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, team_manager: TeamManager) -> None:
|
|
28
|
+
self._manager = team_manager
|
|
29
|
+
|
|
30
|
+
def register(
|
|
31
|
+
self,
|
|
32
|
+
team_a: str,
|
|
33
|
+
team_b: str,
|
|
34
|
+
policy: BridgePolicy = BridgePolicy.ALLOW_TAGGED,
|
|
35
|
+
) -> None:
|
|
36
|
+
"""Register a bridge policy between two teams."""
|
|
37
|
+
self._manager.register_bridge(team_a, team_b, policy)
|
|
38
|
+
|
|
39
|
+
def get_policy(self, team_a: str, team_b: str) -> BridgePolicy:
|
|
40
|
+
"""Get the bridge policy between two teams."""
|
|
41
|
+
return self._manager.get_bridge_policy(team_a, team_b)
|
|
42
|
+
|
|
43
|
+
async def send(
|
|
44
|
+
self,
|
|
45
|
+
from_team: str,
|
|
46
|
+
from_agent: str,
|
|
47
|
+
to_team: str,
|
|
48
|
+
to_agent: str,
|
|
49
|
+
text: str,
|
|
50
|
+
) -> CrossTeamMessage | None:
|
|
51
|
+
"""Send a cross-team message. Returns None if the bridge policy denies it."""
|
|
52
|
+
return await self._manager.send_cross_team(
|
|
53
|
+
from_team, from_agent, to_team, to_agent, text,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
async def broadcast_to_team(
|
|
57
|
+
self,
|
|
58
|
+
from_team: str,
|
|
59
|
+
from_agent: str,
|
|
60
|
+
to_team: str,
|
|
61
|
+
text: str,
|
|
62
|
+
) -> CrossTeamMessage | None:
|
|
63
|
+
"""Broadcast a message to all members of another team."""
|
|
64
|
+
return await self._manager.send_cross_team(
|
|
65
|
+
from_team, from_agent, to_team, "", text,
|
|
66
|
+
)
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Inbox persistence — JSONL-based message storage for team inboxes.
|
|
2
|
+
|
|
3
|
+
Provides O(1) append writes and bulk read/mark_read operations.
|
|
4
|
+
This is the low-level storage layer; the TeamMessageBus uses it internally.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import tempfile
|
|
13
|
+
import threading
|
|
14
|
+
from contextlib import contextmanager
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Iterator
|
|
17
|
+
|
|
18
|
+
from crabcode_core.file_lock import file_lock
|
|
19
|
+
from crabcode_core.logging_utils import get_logger
|
|
20
|
+
from crabcode_core.path_validation import validate_path_component
|
|
21
|
+
from crabcode_core.team.models import TeamMessage
|
|
22
|
+
|
|
23
|
+
logger = get_logger(__name__)
|
|
24
|
+
|
|
25
|
+
class InboxStorage:
|
|
26
|
+
"""Manages JSONL inbox files for a team's agents.
|
|
27
|
+
|
|
28
|
+
Directory structure:
|
|
29
|
+
<root>/<team_name>/<agent_id>.jsonl
|
|
30
|
+
|
|
31
|
+
Each line in the JSONL file is a serialized TeamMessage.
|
|
32
|
+
Writes are O(1) (append). mark_read rewrites the full file,
|
|
33
|
+
but only fires once per prompt loop completion, not per message.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, root: Path) -> None:
|
|
37
|
+
self._root = root
|
|
38
|
+
self._locks: dict[tuple[str, str], threading.Lock] = {}
|
|
39
|
+
|
|
40
|
+
@staticmethod
|
|
41
|
+
def _validate_component(value: str, label: str) -> None:
|
|
42
|
+
validate_path_component(value, label)
|
|
43
|
+
|
|
44
|
+
def _lock_for(self, team_name: str, agent_id: str) -> threading.Lock:
|
|
45
|
+
return self._locks.setdefault((team_name, agent_id), threading.Lock())
|
|
46
|
+
|
|
47
|
+
@contextmanager
|
|
48
|
+
def _team_file_lock(self, team_name: str, *, shared: bool = False) -> Iterator[None]:
|
|
49
|
+
"""Coordinate every operation for a team, including directory removal.
|
|
50
|
+
|
|
51
|
+
The lock lives beside the team directory rather than inside it. A
|
|
52
|
+
lock file inside the directory would be deleted by ``delete_team``;
|
|
53
|
+
a writer that resumed afterward could then create a new lock inode and
|
|
54
|
+
recreate the supposedly deleted inbox.
|
|
55
|
+
"""
|
|
56
|
+
self._root.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
lock_path = self._root / f".{team_name}.team.lock"
|
|
58
|
+
with file_lock(lock_path, exclusive=not shared):
|
|
59
|
+
yield
|
|
60
|
+
|
|
61
|
+
def inbox_path(self, team_name: str, agent_id: str) -> Path:
|
|
62
|
+
self._validate_component(team_name, "team name")
|
|
63
|
+
self._validate_component(agent_id, "agent id")
|
|
64
|
+
return self._root / team_name / f"{agent_id}.jsonl"
|
|
65
|
+
|
|
66
|
+
def write(self, team_name: str, agent_id: str, message: TeamMessage) -> None:
|
|
67
|
+
"""Append a single message to an agent's inbox (O(1))."""
|
|
68
|
+
path = self.inbox_path(team_name, agent_id)
|
|
69
|
+
with self._lock_for(team_name, agent_id):
|
|
70
|
+
with self._team_file_lock(team_name):
|
|
71
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
line = message.model_dump_json() + "\n"
|
|
73
|
+
lock_path = path.with_name(f".{path.name}.lock")
|
|
74
|
+
with file_lock(lock_path):
|
|
75
|
+
with open(path, "a", encoding="utf-8") as f:
|
|
76
|
+
f.write(line)
|
|
77
|
+
f.flush()
|
|
78
|
+
os.fsync(f.fileno())
|
|
79
|
+
|
|
80
|
+
async def async_write(self, team_name: str, agent_id: str, message: TeamMessage) -> None:
|
|
81
|
+
"""Async version of write."""
|
|
82
|
+
loop = asyncio.get_event_loop()
|
|
83
|
+
operation = loop.run_in_executor(None, self.write, team_name, agent_id, message)
|
|
84
|
+
try:
|
|
85
|
+
await asyncio.shield(operation)
|
|
86
|
+
except asyncio.CancelledError:
|
|
87
|
+
# A thread-pool write cannot be cancelled safely. Drain it before
|
|
88
|
+
# propagating cancellation so a caller cannot delete or replace the
|
|
89
|
+
# inbox while the append is still in flight.
|
|
90
|
+
await asyncio.shield(operation)
|
|
91
|
+
raise
|
|
92
|
+
|
|
93
|
+
def read_all(self, team_name: str, agent_id: str) -> list[TeamMessage]:
|
|
94
|
+
"""Read all messages from an agent's inbox."""
|
|
95
|
+
path = self.inbox_path(team_name, agent_id)
|
|
96
|
+
with self._lock_for(team_name, agent_id):
|
|
97
|
+
with self._team_file_lock(team_name, shared=True):
|
|
98
|
+
try:
|
|
99
|
+
return self._read_all_locked(path)
|
|
100
|
+
except FileNotFoundError:
|
|
101
|
+
# Team cleanup may remove the file before a shared reader
|
|
102
|
+
# acquires the per-file lock.
|
|
103
|
+
return []
|
|
104
|
+
|
|
105
|
+
@staticmethod
|
|
106
|
+
def _read_all_locked(path: Path) -> list[TeamMessage]:
|
|
107
|
+
"""Read a file while its caller holds the logical inbox lock."""
|
|
108
|
+
messages: list[TeamMessage] = []
|
|
109
|
+
lock_path = path.with_name(f".{path.name}.lock")
|
|
110
|
+
with file_lock(lock_path, exclusive=False):
|
|
111
|
+
with open(path, encoding="utf-8") as f:
|
|
112
|
+
for line in f:
|
|
113
|
+
line = line.strip()
|
|
114
|
+
if not line:
|
|
115
|
+
continue
|
|
116
|
+
try:
|
|
117
|
+
messages.append(TeamMessage.model_validate_json(line))
|
|
118
|
+
except Exception:
|
|
119
|
+
logger.debug("Skipping invalid inbox line", exc_info=True)
|
|
120
|
+
return messages
|
|
121
|
+
|
|
122
|
+
def mark_read(self, team_name: str, agent_id: str, message_ids: set[str] | None = None) -> int:
|
|
123
|
+
"""Mark messages as read and rewrite the inbox file.
|
|
124
|
+
|
|
125
|
+
If message_ids is None, marks all as read.
|
|
126
|
+
Returns the count of messages marked read.
|
|
127
|
+
"""
|
|
128
|
+
path = self.inbox_path(team_name, agent_id)
|
|
129
|
+
with self._lock_for(team_name, agent_id):
|
|
130
|
+
with self._team_file_lock(team_name):
|
|
131
|
+
try:
|
|
132
|
+
messages = self._read_all_locked(path)
|
|
133
|
+
except FileNotFoundError:
|
|
134
|
+
return 0
|
|
135
|
+
count = 0
|
|
136
|
+
for msg in messages:
|
|
137
|
+
if msg.read:
|
|
138
|
+
continue
|
|
139
|
+
if message_ids is not None and msg.id not in message_ids:
|
|
140
|
+
continue
|
|
141
|
+
msg.read = True
|
|
142
|
+
count += 1
|
|
143
|
+
|
|
144
|
+
if count > 0:
|
|
145
|
+
self._rewrite(path, messages)
|
|
146
|
+
return count
|
|
147
|
+
|
|
148
|
+
def delete_team(self, team_name: str) -> None:
|
|
149
|
+
"""Delete all inbox files for a team."""
|
|
150
|
+
self._validate_component(team_name, "team name")
|
|
151
|
+
import shutil
|
|
152
|
+
with self._team_file_lock(team_name):
|
|
153
|
+
team_dir = self._root / team_name
|
|
154
|
+
if team_dir.exists():
|
|
155
|
+
shutil.rmtree(team_dir, ignore_errors=True)
|
|
156
|
+
|
|
157
|
+
def _rewrite(self, path: Path, messages: list[TeamMessage]) -> None:
|
|
158
|
+
"""Rewrite the full inbox file (used by mark_read)."""
|
|
159
|
+
lines = [msg.model_dump_json() + "\n" for msg in messages]
|
|
160
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
161
|
+
lock_path = path.with_name(f".{path.name}.lock")
|
|
162
|
+
with file_lock(lock_path):
|
|
163
|
+
current = ""
|
|
164
|
+
if path.exists():
|
|
165
|
+
try:
|
|
166
|
+
current = path.read_text(encoding="utf-8")
|
|
167
|
+
except OSError:
|
|
168
|
+
current = ""
|
|
169
|
+
desired = "".join(lines)
|
|
170
|
+
merged = self._merge_jsonl(current, desired)
|
|
171
|
+
tmp_path: str | None = None
|
|
172
|
+
try:
|
|
173
|
+
with tempfile.NamedTemporaryFile(
|
|
174
|
+
mode="w",
|
|
175
|
+
encoding="utf-8",
|
|
176
|
+
dir=path.parent,
|
|
177
|
+
prefix=f".{path.name}.",
|
|
178
|
+
suffix=".tmp",
|
|
179
|
+
delete=False,
|
|
180
|
+
) as f:
|
|
181
|
+
tmp_path = f.name
|
|
182
|
+
f.write(merged)
|
|
183
|
+
f.flush()
|
|
184
|
+
os.fsync(f.fileno())
|
|
185
|
+
os.replace(tmp_path, path)
|
|
186
|
+
tmp_path = None
|
|
187
|
+
finally:
|
|
188
|
+
if tmp_path is not None:
|
|
189
|
+
try:
|
|
190
|
+
os.unlink(tmp_path)
|
|
191
|
+
except OSError:
|
|
192
|
+
pass
|
|
193
|
+
|
|
194
|
+
@staticmethod
|
|
195
|
+
def _merge_jsonl(current: str, desired: str) -> str:
|
|
196
|
+
desired_by_id: dict[str, str] = {}
|
|
197
|
+
for raw in desired.splitlines():
|
|
198
|
+
if not raw.strip():
|
|
199
|
+
continue
|
|
200
|
+
try:
|
|
201
|
+
message_id = str(json.loads(raw).get("id", ""))
|
|
202
|
+
except Exception:
|
|
203
|
+
message_id = ""
|
|
204
|
+
if message_id:
|
|
205
|
+
desired_by_id[message_id] = raw
|
|
206
|
+
|
|
207
|
+
merged: list[str] = []
|
|
208
|
+
for raw in current.splitlines():
|
|
209
|
+
if not raw.strip():
|
|
210
|
+
continue
|
|
211
|
+
try:
|
|
212
|
+
message_id = str(json.loads(raw).get("id", ""))
|
|
213
|
+
except Exception:
|
|
214
|
+
message_id = ""
|
|
215
|
+
if message_id and message_id in desired_by_id:
|
|
216
|
+
merged.append(
|
|
217
|
+
InboxStorage._merge_json_line(
|
|
218
|
+
raw,
|
|
219
|
+
desired_by_id.pop(message_id),
|
|
220
|
+
)
|
|
221
|
+
)
|
|
222
|
+
else:
|
|
223
|
+
merged.append(raw)
|
|
224
|
+
merged.extend(desired_by_id.values())
|
|
225
|
+
return "".join(f"{line}\n" for line in merged)
|
|
226
|
+
|
|
227
|
+
@staticmethod
|
|
228
|
+
def _merge_json_line(current: str, desired: str) -> str:
|
|
229
|
+
"""Merge a line without allowing a read acknowledgement to regress."""
|
|
230
|
+
try:
|
|
231
|
+
current_obj = json.loads(current)
|
|
232
|
+
desired_obj = json.loads(desired)
|
|
233
|
+
if current_obj.get("read"):
|
|
234
|
+
desired_obj["read"] = True
|
|
235
|
+
return json.dumps(desired_obj, ensure_ascii=False, separators=(",", ":"))
|
|
236
|
+
except Exception:
|
|
237
|
+
return desired
|