sightmesh 0.13.0__py3-none-any.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.
- sightmesh/__init__.py +48 -0
- sightmesh/approvals.py +249 -0
- sightmesh/bridge.py +368 -0
- sightmesh/cdesktop.py +1020 -0
- sightmesh/cli/__init__.py +128 -0
- sightmesh/cli/approvals_commands.py +522 -0
- sightmesh/cli/bridge_commands.py +79 -0
- sightmesh/cli/common.py +243 -0
- sightmesh/cli/diagnostics.py +656 -0
- sightmesh/cli/fleet.py +284 -0
- sightmesh/cli/messaging.py +393 -0
- sightmesh/cli/pool_commands.py +521 -0
- sightmesh/cli/routing_commands.py +315 -0
- sightmesh/cli/service_updates.py +140 -0
- sightmesh/cli/spawn.py +617 -0
- sightmesh/cli/tasks.py +248 -0
- sightmesh/cli/workspaces.py +467 -0
- sightmesh/conductor_migrate.py +802 -0
- sightmesh/durable.py +1017 -0
- sightmesh/effects.py +558 -0
- sightmesh/escalation.py +1189 -0
- sightmesh/execution_routing.py +744 -0
- sightmesh/external_runs.py +447 -0
- sightmesh/fence.py +26 -0
- sightmesh/fleet.py +626 -0
- sightmesh/leases.py +511 -0
- sightmesh/liveness.py +835 -0
- sightmesh/migration.py +220 -0
- sightmesh/observability.py +288 -0
- sightmesh/pool/__init__.py +79 -0
- sightmesh/pool/core.py +673 -0
- sightmesh/pool/server.py +355 -0
- sightmesh/pool/ui.html +414 -0
- sightmesh/profiles.py +160 -0
- sightmesh/repowire.py +52 -0
- sightmesh/routing.py +82 -0
- sightmesh/runtime-lock.json +16 -0
- sightmesh/runtime_lock.py +171 -0
- sightmesh/sdk.py +1336 -0
- sightmesh/service.py +461 -0
- sightmesh/service_process.py +146 -0
- sightmesh/stall_settings.py +30 -0
- sightmesh/stalls.py +44 -0
- sightmesh/succession.py +610 -0
- sightmesh/task_store.py +1159 -0
- sightmesh/updates.py +716 -0
- sightmesh/wakes.py +604 -0
- sightmesh-0.13.0.dist-info/METADATA +165 -0
- sightmesh-0.13.0.dist-info/RECORD +52 -0
- sightmesh-0.13.0.dist-info/WHEEL +4 -0
- sightmesh-0.13.0.dist-info/entry_points.txt +2 -0
- sightmesh-0.13.0.dist-info/licenses/LICENSE +201 -0
sightmesh/__init__.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Local orchestration for visible Claude and Codex workers."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
from typing import TYPE_CHECKING, Any
|
|
5
|
+
|
|
6
|
+
_SDK_EXPORTS = frozenset(
|
|
7
|
+
{
|
|
8
|
+
"BatchResult",
|
|
9
|
+
"Command",
|
|
10
|
+
"SightMesh",
|
|
11
|
+
"SightMeshError",
|
|
12
|
+
"Worker",
|
|
13
|
+
"WorkerSpec",
|
|
14
|
+
}
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from .sdk import BatchResult, Command, SightMesh, SightMeshError, Worker, WorkerSpec
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
__version__ = version("sightmesh")
|
|
22
|
+
except PackageNotFoundError:
|
|
23
|
+
__version__ = "0+unknown"
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"BatchResult",
|
|
27
|
+
"Command",
|
|
28
|
+
"SightMesh",
|
|
29
|
+
"SightMeshError",
|
|
30
|
+
"Worker",
|
|
31
|
+
"WorkerSpec",
|
|
32
|
+
"__version__",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def __getattr__(name: str) -> Any:
|
|
37
|
+
"""Load the SDK only for the package-root convenience exports."""
|
|
38
|
+
if name not in _SDK_EXPORTS:
|
|
39
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
40
|
+
from . import sdk
|
|
41
|
+
|
|
42
|
+
value = getattr(sdk, name)
|
|
43
|
+
globals()[name] = value
|
|
44
|
+
return value
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def __dir__() -> list[str]:
|
|
48
|
+
return sorted(set(globals()) | _SDK_EXPORTS)
|
sightmesh/approvals.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import os
|
|
5
|
+
import sqlite3
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .service import state_dir
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ApprovalAuditError(RuntimeError):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def approval_db_path() -> Path:
|
|
20
|
+
return state_dir() / "approvals.sqlite3"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class ApprovalDecision:
|
|
25
|
+
decision_id: str
|
|
26
|
+
approval_id: str
|
|
27
|
+
execution_process_id: str
|
|
28
|
+
session_id: str | None
|
|
29
|
+
workspace_id: str | None
|
|
30
|
+
tool_name: str
|
|
31
|
+
decision: str
|
|
32
|
+
reviewer_kind: str
|
|
33
|
+
reviewer_id: str
|
|
34
|
+
reason_sha256: str | None
|
|
35
|
+
status: str
|
|
36
|
+
error: str | None
|
|
37
|
+
created_at: float
|
|
38
|
+
completed_at: float | None
|
|
39
|
+
|
|
40
|
+
def to_dict(self) -> dict[str, Any]:
|
|
41
|
+
return {
|
|
42
|
+
"decision_id": self.decision_id,
|
|
43
|
+
"approval_id": self.approval_id,
|
|
44
|
+
"execution_process_id": self.execution_process_id,
|
|
45
|
+
"session_id": self.session_id,
|
|
46
|
+
"workspace_id": self.workspace_id,
|
|
47
|
+
"tool_name": self.tool_name,
|
|
48
|
+
"decision": self.decision,
|
|
49
|
+
"reviewer_kind": self.reviewer_kind,
|
|
50
|
+
"reviewer_id": self.reviewer_id,
|
|
51
|
+
"reason_sha256": self.reason_sha256,
|
|
52
|
+
"status": self.status,
|
|
53
|
+
"error": self.error,
|
|
54
|
+
"created_at": self.created_at,
|
|
55
|
+
"completed_at": self.completed_at,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ApprovalAuditStore:
|
|
60
|
+
def __init__(self, path: Path | None = None) -> None:
|
|
61
|
+
self.path = path or approval_db_path()
|
|
62
|
+
self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
63
|
+
self.path.parent.chmod(0o700)
|
|
64
|
+
self._initialize()
|
|
65
|
+
|
|
66
|
+
def _connect(self) -> sqlite3.Connection:
|
|
67
|
+
try:
|
|
68
|
+
conn = sqlite3.connect(self.path, timeout=30, isolation_level=None)
|
|
69
|
+
conn.row_factory = sqlite3.Row
|
|
70
|
+
conn.execute("PRAGMA busy_timeout = 30000")
|
|
71
|
+
conn.execute("PRAGMA journal_mode = WAL")
|
|
72
|
+
for path in (
|
|
73
|
+
self.path,
|
|
74
|
+
self.path.with_name(f"{self.path.name}-wal"),
|
|
75
|
+
self.path.with_name(f"{self.path.name}-shm"),
|
|
76
|
+
):
|
|
77
|
+
if path.exists():
|
|
78
|
+
os.chmod(path, 0o600)
|
|
79
|
+
return conn
|
|
80
|
+
except sqlite3.Error as exc:
|
|
81
|
+
raise ApprovalAuditError(
|
|
82
|
+
f"Cannot open approval audit store {self.path}: {exc}"
|
|
83
|
+
) from exc
|
|
84
|
+
|
|
85
|
+
def _initialize(self) -> None:
|
|
86
|
+
try:
|
|
87
|
+
with self._connect() as conn:
|
|
88
|
+
conn.execute(
|
|
89
|
+
"""
|
|
90
|
+
CREATE TABLE IF NOT EXISTS approval_decisions (
|
|
91
|
+
decision_id TEXT PRIMARY KEY,
|
|
92
|
+
approval_id TEXT NOT NULL,
|
|
93
|
+
execution_process_id TEXT NOT NULL,
|
|
94
|
+
session_id TEXT,
|
|
95
|
+
workspace_id TEXT,
|
|
96
|
+
tool_name TEXT NOT NULL,
|
|
97
|
+
decision TEXT NOT NULL
|
|
98
|
+
CHECK (decision IN ('approved', 'denied')),
|
|
99
|
+
reviewer_kind TEXT NOT NULL
|
|
100
|
+
CHECK (reviewer_kind IN ('human', 'session')),
|
|
101
|
+
reviewer_id TEXT NOT NULL,
|
|
102
|
+
reason_sha256 TEXT,
|
|
103
|
+
status TEXT NOT NULL
|
|
104
|
+
CHECK (status IN ('submitting', 'responded', 'failed')),
|
|
105
|
+
error TEXT,
|
|
106
|
+
created_at REAL NOT NULL,
|
|
107
|
+
completed_at REAL
|
|
108
|
+
)
|
|
109
|
+
"""
|
|
110
|
+
)
|
|
111
|
+
conn.execute(
|
|
112
|
+
"CREATE INDEX IF NOT EXISTS idx_approval_decisions_created "
|
|
113
|
+
"ON approval_decisions(created_at DESC)"
|
|
114
|
+
)
|
|
115
|
+
conn.execute(
|
|
116
|
+
"CREATE INDEX IF NOT EXISTS idx_approval_decisions_approval "
|
|
117
|
+
"ON approval_decisions(approval_id, created_at DESC)"
|
|
118
|
+
)
|
|
119
|
+
except sqlite3.DatabaseError as exc:
|
|
120
|
+
raise ApprovalAuditError(
|
|
121
|
+
f"Cannot initialize approval audit store {self.path}: {exc}"
|
|
122
|
+
) from exc
|
|
123
|
+
|
|
124
|
+
def begin(
|
|
125
|
+
self,
|
|
126
|
+
*,
|
|
127
|
+
approval: dict[str, Any],
|
|
128
|
+
decision: str,
|
|
129
|
+
reviewer_kind: str,
|
|
130
|
+
reviewer_id: str,
|
|
131
|
+
reason: str | None,
|
|
132
|
+
) -> ApprovalDecision:
|
|
133
|
+
now = time.time()
|
|
134
|
+
record = ApprovalDecision(
|
|
135
|
+
decision_id=str(uuid.uuid4()),
|
|
136
|
+
approval_id=str(approval["approval_id"]),
|
|
137
|
+
execution_process_id=str(approval["execution_process_id"]),
|
|
138
|
+
session_id=_optional_string(approval.get("session_id")),
|
|
139
|
+
workspace_id=_optional_string(approval.get("workspace_id")),
|
|
140
|
+
tool_name=str(approval["tool_name"]),
|
|
141
|
+
decision=decision,
|
|
142
|
+
reviewer_kind=reviewer_kind,
|
|
143
|
+
reviewer_id=reviewer_id,
|
|
144
|
+
reason_sha256=(
|
|
145
|
+
hashlib.sha256(reason.encode("utf-8")).hexdigest() if reason else None
|
|
146
|
+
),
|
|
147
|
+
status="submitting",
|
|
148
|
+
error=None,
|
|
149
|
+
created_at=now,
|
|
150
|
+
completed_at=None,
|
|
151
|
+
)
|
|
152
|
+
try:
|
|
153
|
+
with self._connect() as conn:
|
|
154
|
+
conn.execute(
|
|
155
|
+
"""
|
|
156
|
+
INSERT INTO approval_decisions (
|
|
157
|
+
decision_id, approval_id, execution_process_id, session_id,
|
|
158
|
+
workspace_id, tool_name, decision, reviewer_kind,
|
|
159
|
+
reviewer_id, reason_sha256, status, error, created_at,
|
|
160
|
+
completed_at
|
|
161
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
162
|
+
""",
|
|
163
|
+
(
|
|
164
|
+
record.decision_id,
|
|
165
|
+
record.approval_id,
|
|
166
|
+
record.execution_process_id,
|
|
167
|
+
record.session_id,
|
|
168
|
+
record.workspace_id,
|
|
169
|
+
record.tool_name,
|
|
170
|
+
record.decision,
|
|
171
|
+
record.reviewer_kind,
|
|
172
|
+
record.reviewer_id,
|
|
173
|
+
record.reason_sha256,
|
|
174
|
+
record.status,
|
|
175
|
+
record.error,
|
|
176
|
+
record.created_at,
|
|
177
|
+
record.completed_at,
|
|
178
|
+
),
|
|
179
|
+
)
|
|
180
|
+
except sqlite3.DatabaseError as exc:
|
|
181
|
+
raise ApprovalAuditError(f"Cannot record approval attempt: {exc}") from exc
|
|
182
|
+
return record
|
|
183
|
+
|
|
184
|
+
def finish(
|
|
185
|
+
self, decision_id: str, *, succeeded: bool, error: str | None = None
|
|
186
|
+
) -> ApprovalDecision:
|
|
187
|
+
status = "responded" if succeeded else "failed"
|
|
188
|
+
completed_at = time.time()
|
|
189
|
+
try:
|
|
190
|
+
with self._connect() as conn:
|
|
191
|
+
cursor = conn.execute(
|
|
192
|
+
"""
|
|
193
|
+
UPDATE approval_decisions
|
|
194
|
+
SET status = ?, error = ?, completed_at = ?
|
|
195
|
+
WHERE decision_id = ? AND status = 'submitting'
|
|
196
|
+
""",
|
|
197
|
+
(status, error, completed_at, decision_id),
|
|
198
|
+
)
|
|
199
|
+
if cursor.rowcount != 1:
|
|
200
|
+
raise ApprovalAuditError(
|
|
201
|
+
f"Approval audit attempt is missing or already finished: {decision_id}"
|
|
202
|
+
)
|
|
203
|
+
row = conn.execute(
|
|
204
|
+
"SELECT * FROM approval_decisions WHERE decision_id = ?",
|
|
205
|
+
(decision_id,),
|
|
206
|
+
).fetchone()
|
|
207
|
+
except sqlite3.DatabaseError as exc:
|
|
208
|
+
raise ApprovalAuditError(f"Cannot finish approval audit: {exc}") from exc
|
|
209
|
+
if row is None:
|
|
210
|
+
raise ApprovalAuditError(
|
|
211
|
+
f"Approval audit attempt is missing: {decision_id}"
|
|
212
|
+
)
|
|
213
|
+
return _from_row(row)
|
|
214
|
+
|
|
215
|
+
def history(self, *, limit: int = 50) -> list[ApprovalDecision]:
|
|
216
|
+
if limit < 1 or limit > 500:
|
|
217
|
+
raise ValueError("Approval history limit must be between 1 and 500")
|
|
218
|
+
try:
|
|
219
|
+
with self._connect() as conn:
|
|
220
|
+
rows = conn.execute(
|
|
221
|
+
"SELECT * FROM approval_decisions ORDER BY created_at DESC LIMIT ?",
|
|
222
|
+
(limit,),
|
|
223
|
+
).fetchall()
|
|
224
|
+
except sqlite3.DatabaseError as exc:
|
|
225
|
+
raise ApprovalAuditError(f"Cannot read approval history: {exc}") from exc
|
|
226
|
+
return [_from_row(row) for row in rows]
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _optional_string(value: Any) -> str | None:
|
|
230
|
+
return str(value) if value is not None else None
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _from_row(row: sqlite3.Row) -> ApprovalDecision:
|
|
234
|
+
return ApprovalDecision(
|
|
235
|
+
decision_id=row["decision_id"],
|
|
236
|
+
approval_id=row["approval_id"],
|
|
237
|
+
execution_process_id=row["execution_process_id"],
|
|
238
|
+
session_id=row["session_id"],
|
|
239
|
+
workspace_id=row["workspace_id"],
|
|
240
|
+
tool_name=row["tool_name"],
|
|
241
|
+
decision=row["decision"],
|
|
242
|
+
reviewer_kind=row["reviewer_kind"],
|
|
243
|
+
reviewer_id=row["reviewer_id"],
|
|
244
|
+
reason_sha256=row["reason_sha256"],
|
|
245
|
+
status=row["status"],
|
|
246
|
+
error=row["error"],
|
|
247
|
+
created_at=row["created_at"],
|
|
248
|
+
completed_at=row["completed_at"],
|
|
249
|
+
)
|
sightmesh/bridge.py
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import websockets
|
|
14
|
+
|
|
15
|
+
from . import leases
|
|
16
|
+
from .cdesktop import CdesktopClient, CdesktopError
|
|
17
|
+
from .durable import DurableExecutionReconciler
|
|
18
|
+
from .execution_routing import ExecutionRoutingError
|
|
19
|
+
from .fence import open_transport
|
|
20
|
+
from .pool.core import PoolError
|
|
21
|
+
from .routing import (
|
|
22
|
+
clear_peer_identity,
|
|
23
|
+
enabled_workspaces,
|
|
24
|
+
peer_identity,
|
|
25
|
+
set_peer_identity,
|
|
26
|
+
)
|
|
27
|
+
from .sdk import SightMesh, SightMeshError
|
|
28
|
+
from .succession import SuccessionError
|
|
29
|
+
from .task_store import TaskStoreError
|
|
30
|
+
|
|
31
|
+
LOGGER = logging.getLogger("sightmesh.bridge")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _peer_name(workspace: dict[str, Any], session: dict[str, Any]) -> str:
|
|
35
|
+
raw = f"cd-{workspace.get('name') or 'workspace'}-{session['id'][:6]}"
|
|
36
|
+
normalized = re.sub(r"[^A-Za-z0-9_.-]+", "-", raw).strip("-.")
|
|
37
|
+
normalized = re.sub(r"-+", "-", normalized)
|
|
38
|
+
return normalized[:48] or f"cd-session-{session['id'][:6]}"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
_BACKENDS = {"CLAUDE_CODE": "claude-code", "CODEX": "codex", "OPENCODE": "opencode"}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _backend(executor: str | None) -> str:
|
|
45
|
+
return _BACKENDS.get(executor or "", "codex")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _repo_path(client: CdesktopClient, workspace: dict[str, Any]) -> str:
|
|
49
|
+
repos = client.workspace_repos(workspace["id"])
|
|
50
|
+
if not repos:
|
|
51
|
+
raise CdesktopError(f"Workspace {workspace['id']} has no repository")
|
|
52
|
+
repo = repos[0]
|
|
53
|
+
if workspace.get("use_worktree"):
|
|
54
|
+
container = workspace.get("container_ref")
|
|
55
|
+
if not container:
|
|
56
|
+
raise CdesktopError(f"Workspace {workspace['id']} has no container path")
|
|
57
|
+
return str(Path(container) / repo["name"])
|
|
58
|
+
return str(Path(repo["path"]).expanduser().resolve())
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class BridgedSession:
|
|
63
|
+
workspace: dict[str, Any]
|
|
64
|
+
session: dict[str, Any]
|
|
65
|
+
path: str
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class RepowireSessionBridge:
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
client: CdesktopClient,
|
|
72
|
+
bridged: BridgedSession,
|
|
73
|
+
repowire_url: str,
|
|
74
|
+
) -> None:
|
|
75
|
+
self.client = client
|
|
76
|
+
self.bridged = bridged
|
|
77
|
+
self.repowire_url = repowire_url
|
|
78
|
+
self.assigned_name = _peer_name(bridged.workspace, bridged.session)
|
|
79
|
+
|
|
80
|
+
async def run(self) -> None:
|
|
81
|
+
delay = 1.0
|
|
82
|
+
while True:
|
|
83
|
+
try:
|
|
84
|
+
await self._connection()
|
|
85
|
+
delay = 1.0
|
|
86
|
+
except asyncio.CancelledError:
|
|
87
|
+
raise
|
|
88
|
+
except Exception as exc: # noqa: BLE001 - isolate one bridge connection
|
|
89
|
+
LOGGER.warning("Bridge %s disconnected: %s", self.assigned_name, exc)
|
|
90
|
+
await asyncio.sleep(delay)
|
|
91
|
+
delay = min(delay * 2, 15.0)
|
|
92
|
+
|
|
93
|
+
async def _connection(self) -> None:
|
|
94
|
+
async with open_transport(
|
|
95
|
+
websockets.connect, self.repowire_url, ping_interval=20, ping_timeout=20
|
|
96
|
+
) as ws:
|
|
97
|
+
connect: dict[str, Any] = {
|
|
98
|
+
"type": "connect",
|
|
99
|
+
"display_name": self.assigned_name,
|
|
100
|
+
"circle": "default",
|
|
101
|
+
"backend": _backend(self.bridged.session.get("executor")),
|
|
102
|
+
"path": self.bridged.path,
|
|
103
|
+
"role": "agent",
|
|
104
|
+
"hook_version": 1,
|
|
105
|
+
"capabilities": ["cdesktop_followup_bridge"],
|
|
106
|
+
}
|
|
107
|
+
existing_peer_id = peer_identity(self.bridged.session["id"])
|
|
108
|
+
if existing_peer_id:
|
|
109
|
+
connect["peer_id"] = existing_peer_id
|
|
110
|
+
auth_token = os.environ.get("REPOWIRE_AUTH_TOKEN")
|
|
111
|
+
if auth_token:
|
|
112
|
+
connect["auth_token"] = auth_token
|
|
113
|
+
await ws.send(json.dumps(connect))
|
|
114
|
+
connected = json.loads(await ws.recv())
|
|
115
|
+
if connected.get("code") == "peer_retired" and existing_peer_id:
|
|
116
|
+
clear_peer_identity(self.bridged.session["id"])
|
|
117
|
+
if connected.get("type") != "connected":
|
|
118
|
+
raise RuntimeError(str(connected))
|
|
119
|
+
set_peer_identity(self.bridged.session["id"], connected["session_id"])
|
|
120
|
+
self.assigned_name = connected.get("display_name") or self.assigned_name
|
|
121
|
+
await ws.send(
|
|
122
|
+
json.dumps({"type": "status", "status": "online", "turn_state": "idle"})
|
|
123
|
+
)
|
|
124
|
+
LOGGER.info(
|
|
125
|
+
"Bridged %s to cdesktop session %s",
|
|
126
|
+
self.assigned_name,
|
|
127
|
+
self.bridged.session["id"],
|
|
128
|
+
)
|
|
129
|
+
async for raw in ws:
|
|
130
|
+
message = json.loads(raw)
|
|
131
|
+
await self._handle(ws, message)
|
|
132
|
+
|
|
133
|
+
async def _handle(self, ws: Any, message: dict[str, Any]) -> None:
|
|
134
|
+
message_type = message.get("type")
|
|
135
|
+
if message_type == "ping":
|
|
136
|
+
await ws.send(json.dumps({"type": "pong"}))
|
|
137
|
+
return
|
|
138
|
+
if message_type not in {"ask", "notify", "broadcast"}:
|
|
139
|
+
if message_type == "query":
|
|
140
|
+
await ws.send(
|
|
141
|
+
json.dumps(
|
|
142
|
+
{
|
|
143
|
+
"type": "error",
|
|
144
|
+
"correlation_id": message.get("correlation_id"),
|
|
145
|
+
"error": "Legacy query is unsupported by the cdesktop bridge; use ask",
|
|
146
|
+
}
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
from_peer = message.get("from_peer", "unknown")
|
|
152
|
+
text = message.get("text", "")
|
|
153
|
+
correlation_id = message.get("correlation_id")
|
|
154
|
+
if message_type == "ask":
|
|
155
|
+
question_flag = " --question" if message.get("question") else ""
|
|
156
|
+
prompt = (
|
|
157
|
+
f"## Request from @{from_peer}\n\n{text}\n\n"
|
|
158
|
+
"When complete, send a concise result back with:\n\n"
|
|
159
|
+
f"sightmesh bridge-reply {correlation_id} --from-peer {self.assigned_name}"
|
|
160
|
+
f"{question_flag} --message 'RESULT'\n\n"
|
|
161
|
+
"Do not delegate to hidden or native subagents."
|
|
162
|
+
)
|
|
163
|
+
else:
|
|
164
|
+
prompt = (
|
|
165
|
+
f"## {message_type.title()} from @{from_peer}\n\n{text}\n\n"
|
|
166
|
+
"Do not delegate to hidden or native subagents."
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
await asyncio.to_thread(
|
|
171
|
+
self.client.send,
|
|
172
|
+
self.bridged.session["id"],
|
|
173
|
+
prompt,
|
|
174
|
+
None,
|
|
175
|
+
dedupe_key=_dedupe_key(
|
|
176
|
+
self.bridged.session["id"], message_type, message
|
|
177
|
+
),
|
|
178
|
+
)
|
|
179
|
+
except (CdesktopError, OSError, RuntimeError) as exc:
|
|
180
|
+
await self._send_delivery_failure(ws, message, str(exc))
|
|
181
|
+
return
|
|
182
|
+
await self._send_delivery_ack(ws, message)
|
|
183
|
+
|
|
184
|
+
async def _send_delivery_ack(self, ws: Any, message: dict[str, Any]) -> None:
|
|
185
|
+
delivery_id = message.get("delivery_id")
|
|
186
|
+
if not delivery_id:
|
|
187
|
+
return
|
|
188
|
+
await ws.send(
|
|
189
|
+
json.dumps(
|
|
190
|
+
{
|
|
191
|
+
"type": "delivery_ack",
|
|
192
|
+
"delivery_id": delivery_id,
|
|
193
|
+
"message_type": message.get("type"),
|
|
194
|
+
"status": "injected",
|
|
195
|
+
}
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
async def _send_delivery_failure(
|
|
200
|
+
self, ws: Any, message: dict[str, Any], detail: str
|
|
201
|
+
) -> None:
|
|
202
|
+
delivery_id = message.get("delivery_id")
|
|
203
|
+
if delivery_id:
|
|
204
|
+
await ws.send(
|
|
205
|
+
json.dumps(
|
|
206
|
+
{
|
|
207
|
+
"type": "delivery_ack",
|
|
208
|
+
"delivery_id": delivery_id,
|
|
209
|
+
"message_type": message.get("type"),
|
|
210
|
+
"status": "failed",
|
|
211
|
+
"detail": detail,
|
|
212
|
+
}
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
correlation_id = message.get("correlation_id")
|
|
216
|
+
if correlation_id:
|
|
217
|
+
await self._send_error(ws, correlation_id, detail)
|
|
218
|
+
|
|
219
|
+
async def _send_error(self, ws: Any, correlation_id: str, error: str) -> None:
|
|
220
|
+
await ws.send(
|
|
221
|
+
json.dumps(
|
|
222
|
+
{"type": "error", "correlation_id": correlation_id, "error": error}
|
|
223
|
+
)
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class BridgeSupervisor:
|
|
228
|
+
def __init__(
|
|
229
|
+
self,
|
|
230
|
+
client: CdesktopClient,
|
|
231
|
+
repowire_url: str,
|
|
232
|
+
) -> None:
|
|
233
|
+
self.client = client
|
|
234
|
+
self.repowire_url = repowire_url
|
|
235
|
+
self.tasks: dict[str, asyncio.Task[None]] = {}
|
|
236
|
+
self.reconciler = DurableExecutionReconciler(client)
|
|
237
|
+
self.managed_tasks = SightMesh(
|
|
238
|
+
client=client,
|
|
239
|
+
store=self.reconciler.task_store,
|
|
240
|
+
ownership=self.reconciler.ownership,
|
|
241
|
+
)
|
|
242
|
+
async def run(self) -> None:
|
|
243
|
+
while True:
|
|
244
|
+
try:
|
|
245
|
+
await self.reconcile()
|
|
246
|
+
except asyncio.CancelledError:
|
|
247
|
+
raise
|
|
248
|
+
except Exception as exc: # noqa: BLE001 - one tick never ends the loop
|
|
249
|
+
LOGGER.warning("Bridge reconcile tick failed: %s", exc)
|
|
250
|
+
await asyncio.sleep(2)
|
|
251
|
+
|
|
252
|
+
async def reconcile(self) -> None:
|
|
253
|
+
enabled = enabled_workspaces()
|
|
254
|
+
desired: dict[str, BridgedSession] = {}
|
|
255
|
+
# Wake delivery reads task rows, not workspaces, so it runs once per
|
|
256
|
+
# tick and cannot be starved behind a slow or failing workspace scan.
|
|
257
|
+
try:
|
|
258
|
+
await asyncio.to_thread(self.reconciler.reconcile_kernel)
|
|
259
|
+
except Exception as exc: # noqa: BLE001 - a kernel pass never ends a tick
|
|
260
|
+
LOGGER.warning("Cannot reconcile the task kernel: %s", exc)
|
|
261
|
+
# A launch rejected before its task ever activated holds no session, so
|
|
262
|
+
# the per-session pass below can never reach it. This journal-keyed
|
|
263
|
+
# sweep is what advances it past its typed provider outcome.
|
|
264
|
+
try:
|
|
265
|
+
await asyncio.to_thread(self.managed_tasks.reconcile_provider_outcomes)
|
|
266
|
+
except (
|
|
267
|
+
CdesktopError,
|
|
268
|
+
ExecutionRoutingError,
|
|
269
|
+
PoolError,
|
|
270
|
+
SightMeshError,
|
|
271
|
+
SuccessionError,
|
|
272
|
+
TaskStoreError,
|
|
273
|
+
) as exc:
|
|
274
|
+
LOGGER.warning("Cannot reconcile typed provider outcomes: %s", exc)
|
|
275
|
+
try:
|
|
276
|
+
await asyncio.to_thread(
|
|
277
|
+
leases.sync_active_workspaces,
|
|
278
|
+
self.client,
|
|
279
|
+
on_error=lambda detail: LOGGER.warning(
|
|
280
|
+
"Cannot reconcile one cdesktop workspace: %s", detail
|
|
281
|
+
),
|
|
282
|
+
)
|
|
283
|
+
workspaces = await asyncio.to_thread(self.client.workspaces)
|
|
284
|
+
for workspace in workspaces:
|
|
285
|
+
if workspace.get("archived"):
|
|
286
|
+
continue
|
|
287
|
+
try:
|
|
288
|
+
sessions = await asyncio.to_thread(
|
|
289
|
+
self.client.sessions, workspace["id"]
|
|
290
|
+
)
|
|
291
|
+
except CdesktopError as exc:
|
|
292
|
+
LOGGER.warning(
|
|
293
|
+
"Cannot inspect workspace %s sessions: %s", workspace["id"], exc
|
|
294
|
+
)
|
|
295
|
+
continue
|
|
296
|
+
await asyncio.to_thread(self.reconciler.reconcile_sessions, sessions)
|
|
297
|
+
for session in sessions:
|
|
298
|
+
try:
|
|
299
|
+
await asyncio.to_thread(
|
|
300
|
+
self.managed_tasks.reconcile_provider_outcome,
|
|
301
|
+
str(session["id"]),
|
|
302
|
+
)
|
|
303
|
+
except (
|
|
304
|
+
CdesktopError,
|
|
305
|
+
ExecutionRoutingError,
|
|
306
|
+
PoolError,
|
|
307
|
+
SightMeshError,
|
|
308
|
+
SuccessionError,
|
|
309
|
+
TaskStoreError,
|
|
310
|
+
) as exc:
|
|
311
|
+
LOGGER.warning(
|
|
312
|
+
"Cannot reconcile managed task %s: %s",
|
|
313
|
+
session.get("id"),
|
|
314
|
+
exc,
|
|
315
|
+
)
|
|
316
|
+
if workspace["id"] not in enabled:
|
|
317
|
+
continue
|
|
318
|
+
try:
|
|
319
|
+
path = await asyncio.to_thread(_repo_path, self.client, workspace)
|
|
320
|
+
except CdesktopError as exc:
|
|
321
|
+
LOGGER.warning(
|
|
322
|
+
"Cannot bridge workspace %s: %s", workspace["id"], exc
|
|
323
|
+
)
|
|
324
|
+
continue
|
|
325
|
+
for session in sessions:
|
|
326
|
+
# A quarantined (retired/superseded) session never gets a
|
|
327
|
+
# peer bridge; injected follow-ups would auto-resume it
|
|
328
|
+
# into a worktree its successor now owns.
|
|
329
|
+
if self.reconciler.ownership.is_quarantined(session["id"]):
|
|
330
|
+
continue
|
|
331
|
+
desired[session["id"]] = BridgedSession(workspace, session, path)
|
|
332
|
+
except (CdesktopError, leases.LeaseError) as exc:
|
|
333
|
+
LOGGER.warning("Cannot reconcile cdesktop ownership: %s", exc)
|
|
334
|
+
return
|
|
335
|
+
|
|
336
|
+
for session_id in set(self.tasks) - set(desired):
|
|
337
|
+
self.tasks.pop(session_id).cancel()
|
|
338
|
+
for session_id, bridged in desired.items():
|
|
339
|
+
task = self.tasks.get(session_id)
|
|
340
|
+
if task is None or task.done():
|
|
341
|
+
self.tasks[session_id] = asyncio.create_task(
|
|
342
|
+
RepowireSessionBridge(
|
|
343
|
+
self.client,
|
|
344
|
+
bridged,
|
|
345
|
+
self.repowire_url,
|
|
346
|
+
).run(),
|
|
347
|
+
name=f"bridge-{session_id}",
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
async def run_bridge(cdesktop_url: str | None, repowire_url: str) -> None:
|
|
352
|
+
client = CdesktopClient(cdesktop_url)
|
|
353
|
+
await BridgeSupervisor(client, repowire_url).run()
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _dedupe_key(session_id: str, message_type: str, message: dict[str, Any]) -> str:
|
|
357
|
+
source = message.get("delivery_id") or message.get("correlation_id")
|
|
358
|
+
if not source:
|
|
359
|
+
source = hashlib.sha256(
|
|
360
|
+
json.dumps(
|
|
361
|
+
{
|
|
362
|
+
"from_peer": message.get("from_peer"),
|
|
363
|
+
"text": message.get("text"),
|
|
364
|
+
},
|
|
365
|
+
sort_keys=True,
|
|
366
|
+
).encode("utf-8")
|
|
367
|
+
).hexdigest()
|
|
368
|
+
return f"repowire:{session_id}:{message_type}:{source}"
|