@agentsdock/server 1.0.7-beta.5
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.
- package/LICENSE +202 -0
- package/NOTICE +19 -0
- package/README.md +60 -0
- package/npm/cli.cjs +289 -0
- package/package.json +32 -0
- package/server/LICENSE +202 -0
- package/server/NOTICE +19 -0
- package/server/VERSION +1 -0
- package/server/activation_transaction.py +2458 -0
- package/server/agent_server.py +93226 -0
- package/server/agentsdock_chats.py +1237 -0
- package/server/agentsdock_emergency.py +338 -0
- package/server/agentsdock_jobs.py +640 -0
- package/server/agentsdock_mail.py +325 -0
- package/server/agentsdock_publish.py +404 -0
- package/server/agentsdock_team.py +793 -0
- package/server/agentsdock_team_hub/__init__.py +33 -0
- package/server/agentsdock_team_hub/auth.py +710 -0
- package/server/agentsdock_team_hub/cli.py +647 -0
- package/server/agentsdock_team_hub/database.py +310 -0
- package/server/agentsdock_team_hub/mail_hint_streams.py +176 -0
- package/server/agentsdock_team_hub/mail_hints.py +201 -0
- package/server/agentsdock_team_hub/migrations/0001_identity_auth.sql +690 -0
- package/server/agentsdock_team_hub/migrations/0002_teamspace_ledger.sql +1336 -0
- package/server/agentsdock_team_hub/migrations/0003_service_runtime.sql +361 -0
- package/server/agentsdock_team_hub/migrations/0004_managed_host_binding.sql +22 -0
- package/server/agentsdock_team_hub/migrations/0005_tailnet_bootstrap_delegations.sql +63 -0
- package/server/agentsdock_team_hub/migrations/0006_team_network_mailbox.sql +423 -0
- package/server/agentsdock_team_hub/migrations/0007_local_agent_mail.sql +63 -0
- package/server/agentsdock_team_hub/migrations/0008_managed_server_session.sql +73 -0
- package/server/agentsdock_team_hub/migrations/0009_team_messages.sql +324 -0
- package/server/agentsdock_team_hub/migrations/0010_team_attachment_orphan_reclamation.sql +52 -0
- package/server/agentsdock_team_hub/migrations/0011_human_admin_paging.sql +65 -0
- package/server/agentsdock_team_hub/migrations/0012_network_content_deletions.sql +69 -0
- package/server/agentsdock_team_hub/migrations/0013_team_message_revisions.sql +48 -0
- package/server/agentsdock_team_hub/migrations/0014_managed_network_owner.sql +50 -0
- package/server/agentsdock_team_hub/migrations/0015_team_message_inbox_dismissals.sql +3 -0
- package/server/agentsdock_team_hub/migrations/0016_team_message_all_servers.sql +4 -0
- package/server/agentsdock_team_hub/migrations/0017_skill_announcement_deletions.sql +16 -0
- package/server/agentsdock_team_hub/migrations/0018_team_mail_subjects.sql +8 -0
- package/server/agentsdock_team_hub/migrations/0019_team_mailbox_state.sql +5 -0
- package/server/agentsdock_team_hub/migrations/0020_team_mail_arrivals.sql +44 -0
- package/server/agentsdock_team_hub/migrations/0021_team_mail_threads.sql +4 -0
- package/server/agentsdock_team_hub/migrations/0022_team_bulletin_changes.sql +49 -0
- package/server/agentsdock_team_hub/migrations/0023_team_message_search.sql +93 -0
- package/server/agentsdock_team_hub/migrations/__init__.py +1 -0
- package/server/agentsdock_team_hub/notification_hints.py +306 -0
- package/server/agentsdock_team_hub/secure_peer.py +11215 -0
- package/server/agentsdock_team_hub/secure_peer_hub.py +1372 -0
- package/server/agentsdock_team_hub/security.py +318 -0
- package/server/agentsdock_team_hub/service.py +1737 -0
- package/server/agentsdock_team_hub/store.py +16276 -0
- package/server/chat_mailbox.py +448 -0
- package/server/claude_background_reconciliation.py +92 -0
- package/server/claude_goals.py +245 -0
- package/server/claude_history_provenance.py +137 -0
- package/server/claude_history_repair.py +1601 -0
- package/server/claude_sdk_client.py +3731 -0
- package/server/claude_side_question.py +212 -0
- package/server/codex_app_server.py +3030 -0
- package/server/codex_auth.py +93 -0
- package/server/codex_history_repair.py +1247 -0
- package/server/codex_provider.py +1120 -0
- package/server/codex_side_question.py +301 -0
- package/server/cursor_agent_client.py +654 -0
- package/server/cursor_process_guard.py +347 -0
- package/server/execution_activation.py +610 -0
- package/server/execution_control.py +233 -0
- package/server/execution_durability.py +184 -0
- package/server/execution_http.py +256 -0
- package/server/execution_install.py +792 -0
- package/server/execution_legacy_runner.py +406 -0
- package/server/execution_maintenance.py +137 -0
- package/server/execution_manage.py +615 -0
- package/server/execution_ownership.py +83 -0
- package/server/execution_preparation.py +362 -0
- package/server/execution_recovery.py +845 -0
- package/server/execution_recovery_status.py +195 -0
- package/server/execution_service.py +387 -0
- package/server/execution_transport.py +622 -0
- package/server/execution_uninstall.py +406 -0
- package/server/execution_update_status.py +49 -0
- package/server/install.sh +7074 -0
- package/server/interactive_chat_controls.py +153 -0
- package/server/interactive_chat_native.py +66 -0
- package/server/interactive_chat_projection.py +152 -0
- package/server/interactive_chat_runtime.py +132 -0
- package/server/interactive_chat_share_routes.py +534 -0
- package/server/interactive_chat_share_web.py +12 -0
- package/server/interactive_chat_shares.py +238 -0
- package/server/provider_commands.py +425 -0
- package/server/public_chat_share_routes.py +426 -0
- package/server/public_chat_shares.py +759 -0
- package/server/public_chat_transcript.py +357 -0
- package/server/pyproject.toml +29 -0
- package/server/release-public-key.pem +3 -0
- package/server/secure_peer_delivery.py +1236 -0
- package/server/secure_peer_runtime.py +7694 -0
- package/server/shared_chat_video_stream.py +262 -0
- package/server/shared_chat_videos.py +155 -0
- package/server/side_questions.py +594 -0
- package/server/team_hub_host.py +1354 -0
- package/server/team_mail_grants.py +136 -0
- package/server/team_mail_runtime.py +302 -0
- package/server/team_mail_websocket.py +211 -0
- package/server/title_generation.py +333 -0
- package/server/uninstall.sh +359 -0
- package/server/update_handoff.py +237 -0
- package/server/update_preparation.py +264 -0
- package/server/update_recovery.py +140 -0
- package/server/update_runner.py +1819 -0
- package/server/uv.lock +1101 -0
- package/server/workspace_git.py +545 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Explicit one-chat controls; no server imports, HTTP proxy, or authority lookup.
|
|
2
|
+
|
|
3
|
+
The router authenticates the share and supplies its chat identity. The owner
|
|
4
|
+
injects native models/callbacks and projects their results for the guest. Native
|
|
5
|
+
exceptions are deliberately not converted to safe denials: a callback may have
|
|
6
|
+
already committed work. Only ChatControlError is known to precede invocation.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
from types import MappingProxyType
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
SETTINGS_FIELDS = frozenset({
|
|
16
|
+
"title", "backend", "model", "effort", "system_prompt",
|
|
17
|
+
"claude_permission_mode", "codex_approval_policy", "codex_sandbox_mode",
|
|
18
|
+
"codex_permission_profile", "codex_approvals_reviewer", "cursor_permission_mode",
|
|
19
|
+
"provider_jobs_access",
|
|
20
|
+
})
|
|
21
|
+
GOAL_FIELDS = frozenset({"objective", "status", "token_budget", "time_budget_seconds"})
|
|
22
|
+
JOB_FIELDS = frozenset({
|
|
23
|
+
"title", "prompt", "schedule_kind", "interval_seconds", "cron_expression",
|
|
24
|
+
"rrule", "timezone", "loop", "max_runs", "enabled", "backend",
|
|
25
|
+
})
|
|
26
|
+
ACTIONS = frozenset({
|
|
27
|
+
"state", "turn.stop", "turn.steer", "queue.run_now", "queue.edit", "queue.delete",
|
|
28
|
+
"queue.move", "settings.update", "goal.set", "goal.resume", "goal.pause", "goal.delete",
|
|
29
|
+
"job.create", "job.update", "job.delete", "job.toggle", "job.run", "approval.respond",
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ChatControlError(ValueError):
|
|
34
|
+
"""A bounded rejection before any native callback or mutation."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, code="invalid_request"):
|
|
37
|
+
self.code = code
|
|
38
|
+
super().__init__("This chat control request is not permitted" if code == "forbidden"
|
|
39
|
+
else "Invalid chat control request")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _identity(value):
|
|
43
|
+
if not isinstance(value, str) or re.fullmatch(r"[A-Za-z0-9_.-]{1,128}", value) is None:
|
|
44
|
+
raise ChatControlError()
|
|
45
|
+
return value
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _fields(payload, allowed=(), required=(), *, nonempty=False):
|
|
49
|
+
if set(payload) - set(allowed) or not set(required).issubset(payload) or nonempty and not payload:
|
|
50
|
+
raise ChatControlError()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class InteractiveChatControls:
|
|
54
|
+
"""Dispatch to fixed native functions with a server-bound first argument.
|
|
55
|
+
|
|
56
|
+
Callback keys normally equal action names; approval uses approval.codex or
|
|
57
|
+
approval.claude. Native models are keyed by their class names. turn.steer is
|
|
58
|
+
the owner's enqueue/exact-promotion wrapper; job.run is the owner's atomic
|
|
59
|
+
expected-session wrapper, never the global unscoped run_job endpoint.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(self, callbacks, models):
|
|
63
|
+
self.callbacks = MappingProxyType(dict(callbacks))
|
|
64
|
+
self.models = MappingProxyType(dict(models))
|
|
65
|
+
|
|
66
|
+
def _model(self, name, fields):
|
|
67
|
+
model = self.models.get(name)
|
|
68
|
+
if model is None:
|
|
69
|
+
raise ChatControlError("forbidden")
|
|
70
|
+
try:
|
|
71
|
+
return model.model_validate(fields)
|
|
72
|
+
except ValueError:
|
|
73
|
+
# Never expose native validation input, paths or object reprs.
|
|
74
|
+
raise ChatControlError() from None
|
|
75
|
+
|
|
76
|
+
async def dispatch(self, session_id, action, payload, *, share_id=None, request_id=None):
|
|
77
|
+
_identity(session_id)
|
|
78
|
+
if not isinstance(action, str) or action not in ACTIONS:
|
|
79
|
+
raise ChatControlError("forbidden")
|
|
80
|
+
if not isinstance(payload, dict) or any(not isinstance(key, str) for key in payload):
|
|
81
|
+
raise ChatControlError()
|
|
82
|
+
try:
|
|
83
|
+
encoded = json.dumps(payload, ensure_ascii=False, allow_nan=False).encode("utf-8")
|
|
84
|
+
except (TypeError, ValueError, UnicodeError, RecursionError):
|
|
85
|
+
raise ChatControlError() from None
|
|
86
|
+
if len(encoded) > 64 * 1024:
|
|
87
|
+
raise ChatControlError()
|
|
88
|
+
payload = json.loads(encoded) # Callback sees an owned immutable-in-transit value.
|
|
89
|
+
callback_key, args, kwargs = action, [session_id], {}
|
|
90
|
+
|
|
91
|
+
if action in {"state", "turn.stop", "goal.delete", "goal.resume", "goal.pause"}:
|
|
92
|
+
_fields(payload)
|
|
93
|
+
if action in {"goal.resume", "goal.pause"}:
|
|
94
|
+
args.append(self._model("CodexGoalRequest", {"status": "active" if action == "goal.resume" else "paused"}))
|
|
95
|
+
elif action == "turn.steer":
|
|
96
|
+
_fields(payload, {"prompt"}, {"prompt"})
|
|
97
|
+
if not isinstance(payload["prompt"], str) or not payload["prompt"].strip():
|
|
98
|
+
raise ChatControlError()
|
|
99
|
+
if not isinstance(share_id, str) or re.fullmatch(r"interactive_[a-f0-9]{32}", share_id) is None:
|
|
100
|
+
raise ChatControlError("forbidden")
|
|
101
|
+
if not isinstance(request_id, str) or re.fullmatch(r"[A-Za-z0-9_-]{16,128}", request_id) is None:
|
|
102
|
+
raise ChatControlError()
|
|
103
|
+
args.append(payload["prompt"])
|
|
104
|
+
kwargs = {"share_id": share_id, "request_id": request_id}
|
|
105
|
+
elif action in {"queue.delete", "queue.run_now", "job.delete", "job.run"}:
|
|
106
|
+
_fields(payload, {"id"}, {"id"})
|
|
107
|
+
args.append(_identity(payload["id"]))
|
|
108
|
+
if action == "queue.run_now":
|
|
109
|
+
args.append(self._model("RunQueuedTurnNowRequest", {"accept_deferred_queue_response": True}))
|
|
110
|
+
elif action == "queue.edit":
|
|
111
|
+
_fields(payload, {"id", "prompt", "expected_message_revision"}, {"id", "prompt"})
|
|
112
|
+
args.append(_identity(payload.pop("id")))
|
|
113
|
+
if not isinstance(payload["prompt"], str):
|
|
114
|
+
raise ChatControlError()
|
|
115
|
+
args.append(self._model("UpdateQueuedTurnRequest", payload))
|
|
116
|
+
elif action == "queue.move":
|
|
117
|
+
_fields(payload, {"id", "direction", "expected_adjacent_queued_id"}, {"id", "direction"})
|
|
118
|
+
args.append(_identity(payload.pop("id")))
|
|
119
|
+
if not isinstance(payload["direction"], str) or payload["direction"] not in {"up", "down"}:
|
|
120
|
+
raise ChatControlError()
|
|
121
|
+
if "expected_adjacent_queued_id" in payload:
|
|
122
|
+
_identity(payload["expected_adjacent_queued_id"])
|
|
123
|
+
args.append(self._model("MoveQueuedTurnRequest", payload))
|
|
124
|
+
elif action == "settings.update":
|
|
125
|
+
_fields(payload, SETTINGS_FIELDS, nonempty=True)
|
|
126
|
+
args.append(self._model("UpdateSessionRequest", payload))
|
|
127
|
+
elif action == "goal.set":
|
|
128
|
+
_fields(payload, GOAL_FIELDS, nonempty=True)
|
|
129
|
+
args.append(self._model("CodexGoalRequest", payload))
|
|
130
|
+
elif action == "job.create":
|
|
131
|
+
_fields(payload, JOB_FIELDS | {"first_run_at"}, {"title", "prompt"})
|
|
132
|
+
args.append(self._model("CreateScopedJobRequest", {**payload, "context_mode": "chat"}))
|
|
133
|
+
elif action in {"job.update", "job.toggle"}:
|
|
134
|
+
allowed = {"id", "enabled"} if action == "job.toggle" else JOB_FIELDS | {"id", "next_run_at"}
|
|
135
|
+
_fields(payload, allowed, {"id", "enabled"} if action == "job.toggle" else {"id"})
|
|
136
|
+
args.append(_identity(payload.pop("id")))
|
|
137
|
+
if not payload or action == "job.toggle" and type(payload["enabled"]) is not bool:
|
|
138
|
+
raise ChatControlError()
|
|
139
|
+
args.append(self._model("UpdateJobRequest", payload))
|
|
140
|
+
elif action == "approval.respond":
|
|
141
|
+
_fields(payload, {"id", "backend", "response"}, {"id", "backend", "response"})
|
|
142
|
+
backend = payload["backend"]
|
|
143
|
+
if backend not in ("codex", "claude") or not isinstance(payload["response"], dict):
|
|
144
|
+
raise ChatControlError()
|
|
145
|
+
args.append(_identity(payload["id"]))
|
|
146
|
+
args.append(self._model("CodexInteractionResponseRequest" if backend == "codex" else "ClaudeInteractionResponseRequest",
|
|
147
|
+
{"response": payload["response"]}))
|
|
148
|
+
callback_key = "approval." + backend
|
|
149
|
+
|
|
150
|
+
callback = self.callbacks.get(callback_key)
|
|
151
|
+
if not callable(callback):
|
|
152
|
+
raise ChatControlError("forbidden")
|
|
153
|
+
return await callback(*args, **kwargs)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Shared-chat DTOs reuse native UI contracts without file or server authority."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import math
|
|
5
|
+
from shared_chat_videos import normalize_shared_chat_videos
|
|
6
|
+
|
|
7
|
+
PRIVATE_FIELDS = frozenset({
|
|
8
|
+
"cwd", "path", "source_path", "root", "file", "files", "artifact", "artifacts",
|
|
9
|
+
"file_ids", "display_file_ids", "file_path", "notebook_path", "old_path", "new_path",
|
|
10
|
+
"attachments", "env", "environment", "runtime_env", "runtime_context", "authorization", "headers",
|
|
11
|
+
"token", "access_token", "refresh_token", "api_key", "password", "secret",
|
|
12
|
+
"authority", "authority_file", "authority_path", "request_prompt", "provider_cross_chat_route_snapshot",
|
|
13
|
+
"secure_peer_route_snapshots", "chat_references", "team_references",
|
|
14
|
+
"claude_session_id", "codex_thread_id", "cursor_session_id",
|
|
15
|
+
})
|
|
16
|
+
SESSION_FIELDS = frozenset({
|
|
17
|
+
"id", "title", "backend", "model", "effort", "system_prompt", "backend_locked",
|
|
18
|
+
"claude_permission_mode", "codex_approval_policy", "codex_sandbox_mode",
|
|
19
|
+
"codex_permission_profile", "codex_approvals_reviewer", "cursor_permission_mode",
|
|
20
|
+
"provider_jobs_access", "codex_goal", "codex_goal_time_budget_seconds",
|
|
21
|
+
"codex_goal_time_budget_exhausted", "codex_thread_status",
|
|
22
|
+
"codex_pending_interaction_count", "codex_needs_user_action",
|
|
23
|
+
"claude_pending_interaction_count", "claude_needs_user_action",
|
|
24
|
+
"claude_stop_fence_pending", "created_at", "updated_at", "archived",
|
|
25
|
+
"latest_event_seq", "latest_event_at", "latest_agent_event_seq", "latest_agent_event_at",
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def shared_native_value(value, depth=0):
|
|
30
|
+
"""Remove transport/file metadata, not words deliberately present in chat text.
|
|
31
|
+
|
|
32
|
+
This is an egress filter, never an authorization decision. Every callback
|
|
33
|
+
has already selected a single chat and used the native ownership checks.
|
|
34
|
+
"""
|
|
35
|
+
if depth > 20:
|
|
36
|
+
raise ValueError("Shared chat data is too deeply nested")
|
|
37
|
+
if isinstance(value, dict):
|
|
38
|
+
return {key: shared_native_value(item, depth + 1) for key, item in value.items()
|
|
39
|
+
if isinstance(key, str) and not key.startswith("_")
|
|
40
|
+
and key.lower() not in PRIVATE_FIELDS}
|
|
41
|
+
if isinstance(value, (list, tuple)):
|
|
42
|
+
return [shared_native_value(item, depth + 1) for item in value]
|
|
43
|
+
if type(value) is float and not math.isfinite(value):
|
|
44
|
+
raise ValueError("Shared chat data contains an invalid number")
|
|
45
|
+
if value is None or type(value) in (str, int, float, bool):
|
|
46
|
+
return value
|
|
47
|
+
raise ValueError("Shared chat data is not serializable")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def shared_session(value):
|
|
51
|
+
return shared_native_value({key: item for key, item in value.items() if key in SESSION_FIELDS})
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def shared_events(events, session_id):
|
|
55
|
+
output = []
|
|
56
|
+
for event in events:
|
|
57
|
+
if not isinstance(event, dict) or event.get("session_id") not in (None, "", session_id):
|
|
58
|
+
raise ValueError("Shared chat event ownership is invalid")
|
|
59
|
+
kind = str(event.get("type") or "")
|
|
60
|
+
# Only explicitly projected video descriptors cross the media boundary.
|
|
61
|
+
# Tool output is chat content, not direct filesystem access.
|
|
62
|
+
videos = normalize_shared_chat_videos(event.get("shared_videos", []))
|
|
63
|
+
if kind.startswith(("file_", "artifact_", "workspace_", "terminal_")) and not (kind == "artifact_created" and len(videos) == 1):
|
|
64
|
+
continue
|
|
65
|
+
output.append(shared_native_value(event))
|
|
66
|
+
return output
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Incremental, bounded text-only chat projection; no server imports or timers."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import stat
|
|
8
|
+
import threading
|
|
9
|
+
from typing import Callable
|
|
10
|
+
|
|
11
|
+
from public_chat_transcript import (
|
|
12
|
+
MAX_LINE_BYTES, MAX_LOG_BYTES, MAX_MESSAGE_BYTES, MAX_MESSAGES,
|
|
13
|
+
MAX_RECORDS, MAX_TEXT_BYTES, PublicTranscriptError,
|
|
14
|
+
_is_goal_followup, _public_timestamp,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class IncrementalChatTranscript:
|
|
19
|
+
"""Use one fresh privacy projector for the lifetime of an append-only log.
|
|
20
|
+
|
|
21
|
+
The owner must discard this reader after a known history rewrite or privacy
|
|
22
|
+
projection change. Stat checks detect replacement, truncation and same-size
|
|
23
|
+
changes; no incremental reader can prove an earlier prefix was not changed
|
|
24
|
+
in place while also growing without rereading that prefix.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, path: Path, projector: Callable[[dict], dict | None]):
|
|
28
|
+
self.path = Path(path)
|
|
29
|
+
self.projector = projector
|
|
30
|
+
self._lock = threading.Lock()
|
|
31
|
+
self._stamp = None
|
|
32
|
+
self._offset = 0
|
|
33
|
+
self._revision = 0
|
|
34
|
+
self._records = 0
|
|
35
|
+
self._total_text = 0
|
|
36
|
+
self._messages: list[dict] = []
|
|
37
|
+
self._outputs: dict[str, list[str]] = {}
|
|
38
|
+
self._failed = False
|
|
39
|
+
|
|
40
|
+
def _snapshot(self) -> dict:
|
|
41
|
+
return {"messages": [dict(message) for message in self._messages],
|
|
42
|
+
"through_bytes": self._offset, "revision": str(self._revision)}
|
|
43
|
+
|
|
44
|
+
@staticmethod
|
|
45
|
+
def _file_stamp(value):
|
|
46
|
+
return (value.st_dev, value.st_ino, value.st_size, value.st_mtime_ns, value.st_ctime_ns)
|
|
47
|
+
|
|
48
|
+
def load(self) -> dict:
|
|
49
|
+
"""Read only new complete records; a detected error requires a new reader."""
|
|
50
|
+
with self._lock:
|
|
51
|
+
if self._failed:
|
|
52
|
+
raise PublicTranscriptError("Chat history changed; reopen the conversation")
|
|
53
|
+
try:
|
|
54
|
+
return self._load()
|
|
55
|
+
except Exception as exc:
|
|
56
|
+
# The supplied projector has private state and cannot be rolled
|
|
57
|
+
# back after a partially consumed bad page. Never reuse it.
|
|
58
|
+
self._failed = True
|
|
59
|
+
if isinstance(exc, PublicTranscriptError):
|
|
60
|
+
raise
|
|
61
|
+
raise PublicTranscriptError("Chat history is unavailable") from exc
|
|
62
|
+
|
|
63
|
+
def _load(self) -> dict:
|
|
64
|
+
try:
|
|
65
|
+
fd = os.open(self.path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
|
66
|
+
except FileNotFoundError:
|
|
67
|
+
if self._stamp is None:
|
|
68
|
+
return self._snapshot()
|
|
69
|
+
raise PublicTranscriptError("Chat history changed; reopen the conversation") from None
|
|
70
|
+
with os.fdopen(fd, "rb") as stream:
|
|
71
|
+
initial = os.fstat(stream.fileno())
|
|
72
|
+
if not stat.S_ISREG(initial.st_mode):
|
|
73
|
+
raise PublicTranscriptError("Chat history is unavailable")
|
|
74
|
+
stamp = self._file_stamp(initial)
|
|
75
|
+
if initial.st_size > MAX_LOG_BYTES:
|
|
76
|
+
raise PublicTranscriptError("Chat exceeds the 64 MiB sharing limit")
|
|
77
|
+
if self._stamp is not None:
|
|
78
|
+
if (stamp[:2] != self._stamp[:2] or stamp[2] < self._stamp[2]
|
|
79
|
+
or stamp[2] == self._stamp[2] and stamp[3:] != self._stamp[3:]):
|
|
80
|
+
raise PublicTranscriptError("Chat history changed; reopen the conversation")
|
|
81
|
+
if stamp == self._stamp:
|
|
82
|
+
return self._snapshot()
|
|
83
|
+
stream.seek(self._offset)
|
|
84
|
+
while self._offset < initial.st_size:
|
|
85
|
+
line = stream.readline(min(MAX_LINE_BYTES + 1, initial.st_size - self._offset))
|
|
86
|
+
if len(line) > MAX_LINE_BYTES:
|
|
87
|
+
raise PublicTranscriptError("A chat record exceeds the sharing limit")
|
|
88
|
+
if not line.endswith(b"\n"):
|
|
89
|
+
break # Re-read this uncommitted tail only after it grows.
|
|
90
|
+
self._offset += len(line)
|
|
91
|
+
self._records += 1
|
|
92
|
+
if self._records > MAX_RECORDS:
|
|
93
|
+
raise PublicTranscriptError("Chat exceeds the sharing record limit")
|
|
94
|
+
try:
|
|
95
|
+
raw = json.loads(line)
|
|
96
|
+
except (ValueError, UnicodeError, RecursionError) as exc:
|
|
97
|
+
raise PublicTranscriptError("Chat history contains an unreadable record") from exc
|
|
98
|
+
if not isinstance(raw, dict) or not isinstance(raw.get("type"), str):
|
|
99
|
+
raise PublicTranscriptError("Chat history contains an invalid event")
|
|
100
|
+
self._consume(raw)
|
|
101
|
+
final = os.fstat(stream.fileno())
|
|
102
|
+
current = self.path.stat(follow_symlinks=False)
|
|
103
|
+
if (not stat.S_ISREG(current.st_mode)
|
|
104
|
+
or (current.st_dev, current.st_ino) != stamp[:2]
|
|
105
|
+
or final.st_size < initial.st_size
|
|
106
|
+
or final.st_size == initial.st_size and self._file_stamp(final)[3:] != stamp[3:]):
|
|
107
|
+
raise PublicTranscriptError("Chat history changed; reopen the conversation")
|
|
108
|
+
# Save the boundary actually read, not a later concurrent append.
|
|
109
|
+
self._stamp = stamp
|
|
110
|
+
return self._snapshot()
|
|
111
|
+
|
|
112
|
+
def _consume(self, raw: dict) -> None:
|
|
113
|
+
kind = raw["type"]
|
|
114
|
+
if kind not in {"turn_started", "assistant_text", "turn_finished", "reasoning_summary"} and not _is_goal_followup(raw):
|
|
115
|
+
return
|
|
116
|
+
if kind == "reasoning_summary" and raw.get("phase") != "commentary":
|
|
117
|
+
return
|
|
118
|
+
event = self.projector(raw)
|
|
119
|
+
if not event:
|
|
120
|
+
return
|
|
121
|
+
if not isinstance(event, dict):
|
|
122
|
+
raise PublicTranscriptError("Chat history projection is invalid")
|
|
123
|
+
run = str(event.get("run_id") or "")
|
|
124
|
+
if kind == "turn_started" or _is_goal_followup(event):
|
|
125
|
+
self._outputs[run] = []
|
|
126
|
+
role, text = "user", event.get("prompt")
|
|
127
|
+
else:
|
|
128
|
+
role = "assistant"
|
|
129
|
+
text = event.get("result_text") if kind == "turn_finished" else event.get("text")
|
|
130
|
+
if not isinstance(text, str) or not text.strip():
|
|
131
|
+
return
|
|
132
|
+
try:
|
|
133
|
+
size = len(text.encode("utf-8"))
|
|
134
|
+
except UnicodeEncodeError as exc:
|
|
135
|
+
raise PublicTranscriptError("Chat history contains invalid Unicode text") from exc
|
|
136
|
+
if size > MAX_MESSAGE_BYTES:
|
|
137
|
+
raise PublicTranscriptError("A message exceeds the 256 KiB sharing limit")
|
|
138
|
+
normalized = " ".join(text.split())
|
|
139
|
+
previous = self._outputs.setdefault(run, [])
|
|
140
|
+
if kind == "turn_finished" and (normalized in previous or normalized == " ".join(previous)):
|
|
141
|
+
return
|
|
142
|
+
if kind == "assistant_text":
|
|
143
|
+
previous.append(normalized)
|
|
144
|
+
self._total_text += size
|
|
145
|
+
if self._total_text > MAX_TEXT_BYTES or len(self._messages) >= MAX_MESSAGES:
|
|
146
|
+
raise PublicTranscriptError("Chat exceeds the 1,000-message / 2 MiB sharing limit")
|
|
147
|
+
message = {"role": role, "text": text}
|
|
148
|
+
timestamp = _public_timestamp(event.get("ts"))
|
|
149
|
+
if timestamp is not None:
|
|
150
|
+
message["timestamp"] = timestamp
|
|
151
|
+
self._messages.append(message)
|
|
152
|
+
self._revision = self._offset
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Demand-driven live projections for open shared-chat pages, never a poller."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
from collections import OrderedDict
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
import secrets
|
|
8
|
+
|
|
9
|
+
from public_chat_transcript import PublicTranscriptError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class _OpenChat:
|
|
14
|
+
reader: object
|
|
15
|
+
generation: int = 0
|
|
16
|
+
identity: str = field(default_factory=lambda: secrets.token_hex(8))
|
|
17
|
+
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
|
18
|
+
waiters: set = field(default_factory=set)
|
|
19
|
+
reset_reader: bool = False
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def revision(self):
|
|
23
|
+
return f"{self.identity}:{self.generation}"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class InteractiveChatLiveState:
|
|
27
|
+
"""One bounded incremental reader per recently viewed chat.
|
|
28
|
+
|
|
29
|
+
The existing event hub calls notify synchronously. It does only an O(1)
|
|
30
|
+
lookup for unrelated chats and never reads disk, sends a socket, or waits
|
|
31
|
+
for a guest while the owner's event delivery lock is held.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, reader_factory, session_exists, public_state=None, *, max_cached=16, native_snapshot=None):
|
|
35
|
+
self.reader_factory = reader_factory
|
|
36
|
+
self.session_exists = session_exists
|
|
37
|
+
self.public_state = public_state
|
|
38
|
+
self.native_snapshot = native_snapshot
|
|
39
|
+
self.max_cached = max_cached
|
|
40
|
+
self.entries = OrderedDict()
|
|
41
|
+
|
|
42
|
+
def notify(self, session_id, event):
|
|
43
|
+
entry = self.entries.get(session_id)
|
|
44
|
+
if entry is None:
|
|
45
|
+
return
|
|
46
|
+
kind = event.get("type", "")
|
|
47
|
+
if not isinstance(kind, str):
|
|
48
|
+
return
|
|
49
|
+
if self.native_snapshot is None and not (
|
|
50
|
+
kind in {"turn_started", "turn_finished", "turn_stopped", "turn_steered",
|
|
51
|
+
"assistant_text", "history_imported", "history_reconciled", "session_deleted", "turn_unqueued"}
|
|
52
|
+
or kind.startswith("turn_queue")
|
|
53
|
+
or kind == "reasoning_summary" and event.get("phase") == "commentary"
|
|
54
|
+
):
|
|
55
|
+
return
|
|
56
|
+
if kind in {"history_reconciled", "history_imported"}:
|
|
57
|
+
# Existing bytes can acquire stricter provenance without changing
|
|
58
|
+
# their size. Rebuild only when a viewer next asks for the text.
|
|
59
|
+
entry.reset_reader = True
|
|
60
|
+
if self.native_snapshot is not None:
|
|
61
|
+
# The native renderer must replace an older history prefix,
|
|
62
|
+
# not merge repaired same-ID events into a stale generation.
|
|
63
|
+
entry.identity = secrets.token_hex(8)
|
|
64
|
+
entry.generation += 1
|
|
65
|
+
for waiter in tuple(entry.waiters):
|
|
66
|
+
if not waiter.done():
|
|
67
|
+
waiter.set_result(True)
|
|
68
|
+
|
|
69
|
+
async def load(self, session_id):
|
|
70
|
+
if not self.session_exists(session_id):
|
|
71
|
+
raise PublicTranscriptError("Chat is unavailable")
|
|
72
|
+
entry = self.entries.get(session_id)
|
|
73
|
+
if entry is None:
|
|
74
|
+
if len(self.entries) >= self.max_cached:
|
|
75
|
+
candidate = next((key for key, item in self.entries.items()
|
|
76
|
+
if not item.waiters and not item.lock.locked()), None)
|
|
77
|
+
if candidate is None:
|
|
78
|
+
raise PublicTranscriptError("Shared chat viewers are busy; retry shortly")
|
|
79
|
+
self.entries.pop(candidate)
|
|
80
|
+
entry = _OpenChat(None if self.native_snapshot is not None else self.reader_factory(session_id))
|
|
81
|
+
self.entries[session_id] = entry
|
|
82
|
+
self.entries.move_to_end(session_id)
|
|
83
|
+
async with entry.lock:
|
|
84
|
+
if entry.reset_reader:
|
|
85
|
+
if self.native_snapshot is None:
|
|
86
|
+
entry.reader = self.reader_factory(session_id)
|
|
87
|
+
entry.reset_reader = False
|
|
88
|
+
# Capture before I/O: a concurrent append then makes wait() return
|
|
89
|
+
# immediately, closing the initial snapshot-to-subscription gap.
|
|
90
|
+
revision = entry.revision
|
|
91
|
+
# Native views need tool/progress/settings/approval updates too;
|
|
92
|
+
# notify still performs no I/O and unopened chats have no entry.
|
|
93
|
+
task = asyncio.create_task(self.native_snapshot(session_id) if self.native_snapshot is not None
|
|
94
|
+
else asyncio.to_thread(entry.reader.load))
|
|
95
|
+
try:
|
|
96
|
+
snapshot = await asyncio.shield(task)
|
|
97
|
+
except asyncio.CancelledError:
|
|
98
|
+
# Do not release the reader's lock while its thread is alive.
|
|
99
|
+
while not task.done():
|
|
100
|
+
try:
|
|
101
|
+
await asyncio.shield(task)
|
|
102
|
+
except asyncio.CancelledError:
|
|
103
|
+
continue
|
|
104
|
+
except Exception:
|
|
105
|
+
break
|
|
106
|
+
if not task.cancelled():
|
|
107
|
+
task.exception()
|
|
108
|
+
raise
|
|
109
|
+
except Exception:
|
|
110
|
+
self.entries.pop(session_id, None)
|
|
111
|
+
raise
|
|
112
|
+
if not self.session_exists(session_id):
|
|
113
|
+
self.entries.pop(session_id, None)
|
|
114
|
+
raise PublicTranscriptError("Chat is unavailable")
|
|
115
|
+
if self.native_snapshot is not None:
|
|
116
|
+
return {**snapshot, "revision": revision}
|
|
117
|
+
state = self.public_state(session_id)
|
|
118
|
+
return {"messages": [*snapshot["messages"], *state.get("queued", [])],
|
|
119
|
+
"revision": revision, "busy": state.get("busy") is True}
|
|
120
|
+
|
|
121
|
+
async def wait(self, session_id, revision, timeout):
|
|
122
|
+
entry = self.entries.get(session_id)
|
|
123
|
+
if entry is None or entry.revision != revision:
|
|
124
|
+
return True
|
|
125
|
+
waiter = asyncio.get_running_loop().create_future()
|
|
126
|
+
entry.waiters.add(waiter)
|
|
127
|
+
try:
|
|
128
|
+
return await asyncio.wait_for(waiter, timeout=timeout)
|
|
129
|
+
except asyncio.TimeoutError:
|
|
130
|
+
return False
|
|
131
|
+
finally:
|
|
132
|
+
entry.waiters.discard(waiter)
|