netizen-cli 0.10.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.
- netizen_cli/__init__.py +3 -0
- netizen_cli/__main__.py +4 -0
- netizen_cli/admin/__init__.py +1 -0
- netizen_cli/admin/auth.py +928 -0
- netizen_cli/admin/errors.py +9 -0
- netizen_cli/admin/port_config.py +115 -0
- netizen_cli/admin/presentation.py +257 -0
- netizen_cli/admin/queries.py +337 -0
- netizen_cli/admin/static/admin.css +260 -0
- netizen_cli/admin/static/admin.js +2898 -0
- netizen_cli/admin/static/index.html +327 -0
- netizen_cli/admin/transport.py +935 -0
- netizen_cli/admin/web.py +2717 -0
- netizen_cli/bindings.py +3215 -0
- netizen_cli/builtin_skills.py +93 -0
- netizen_cli/cards/__init__.py +105 -0
- netizen_cli/cards/callbacks.py +565 -0
- netizen_cli/cards/controls.py +2273 -0
- netizen_cli/cards/defaults.py +213 -0
- netizen_cli/cards/model_info.py +80 -0
- netizen_cli/cards/questions.py +220 -0
- netizen_cli/cards/reply.py +2247 -0
- netizen_cli/cards/scheduled.py +836 -0
- netizen_cli/channel/__init__.py +1 -0
- netizen_cli/channel/completion_mentions.py +60 -0
- netizen_cli/channel/input_preparation.py +644 -0
- netizen_cli/channel/messages.py +57 -0
- netizen_cli/channel/ports.py +52 -0
- netizen_cli/channel/question_inputs.py +51 -0
- netizen_cli/channel/reactions.py +293 -0
- netizen_cli/channel/reply_presenter.py +1505 -0
- netizen_cli/channel/topics.py +70 -0
- netizen_cli/channel_app.py +6593 -0
- netizen_cli/cli.py +287 -0
- netizen_cli/cli_data.py +536 -0
- netizen_cli/cli_packages.py +526 -0
- netizen_cli/cli_services.py +651 -0
- netizen_cli/cli_setup.py +242 -0
- netizen_cli/cli_update.py +303 -0
- netizen_cli/cli_update_restore.py +53 -0
- netizen_cli/cli_update_worker.py +333 -0
- netizen_cli/codex_runtime.py +7125 -0
- netizen_cli/completion_mention.py +16 -0
- netizen_cli/database_migrations.py +218 -0
- netizen_cli/defaults/__init__.py +5 -0
- netizen_cli/defaults/models.py +39 -0
- netizen_cli/defaults/service.py +232 -0
- netizen_cli/defaults/store.py +260 -0
- netizen_cli/deployment/__init__.py +1 -0
- netizen_cli/deployment/restart_worker.py +134 -0
- netizen_cli/deployment/update_executor.py +258 -0
- netizen_cli/deployment/update_protocol.py +281 -0
- netizen_cli/domain.py +416 -0
- netizen_cli/error_messages.py +124 -0
- netizen_cli/experience.py +531 -0
- netizen_cli/feishu_app_onboarding.py +187 -0
- netizen_cli/feishu_app_permissions.py +123 -0
- netizen_cli/git_status.py +63 -0
- netizen_cli/image_inputs.py +579 -0
- netizen_cli/instance.py +84 -0
- netizen_cli/lark_app.py +125 -0
- netizen_cli/main.py +903 -0
- netizen_cli/management/__init__.py +83 -0
- netizen_cli/management/blocking_io.py +352 -0
- netizen_cli/management/chat_labels.py +266 -0
- netizen_cli/management/coordination.py +32 -0
- netizen_cli/management/service.py +2187 -0
- netizen_cli/management/updates.py +214 -0
- netizen_cli/markdown_images.py +78 -0
- netizen_cli/message_content.py +786 -0
- netizen_cli/message_history.py +643 -0
- netizen_cli/message_preparation.py +60 -0
- netizen_cli/message_projection.py +923 -0
- netizen_cli/migrations/__init__.py +1 -0
- netizen_cli/migrations/schema.py +103 -0
- netizen_cli/migrations/v14.py +438 -0
- netizen_cli/model_settings.py +269 -0
- netizen_cli/package_resources.py +22 -0
- netizen_cli/projects.py +327 -0
- netizen_cli/prompt_projection.py +327 -0
- netizen_cli/quoted_context.py +312 -0
- netizen_cli/resources/config.example.yaml +35 -0
- netizen_cli/resources/skills/netizen-lark/SKILL.md +64 -0
- netizen_cli/resources/skills/netizen-user-guide/SKILL.md +37 -0
- netizen_cli/resources/skills/netizen-user-guide/references/user-guide.md +842 -0
- netizen_cli/result_images.py +123 -0
- netizen_cli/runtime/__init__.py +1 -0
- netizen_cli/runtime/contracts.py +792 -0
- netizen_cli/runtime/name_writes.py +67 -0
- netizen_cli/runtime/thread_naming.py +451 -0
- netizen_cli/schedules/__init__.py +1 -0
- netizen_cli/schedules/mcp.py +535 -0
- netizen_cli/schedules/models.py +394 -0
- netizen_cli/schedules/scheduler.py +374 -0
- netizen_cli/schedules/service.py +766 -0
- netizen_cli/schedules/store.py +771 -0
- netizen_cli/sdk_gap_adapter.py +1151 -0
- netizen_cli/service_launcher.py +583 -0
- netizen_cli/session_settings.py +126 -0
- netizen_cli/settings.py +216 -0
- netizen_cli/skill_references.py +40 -0
- netizen_cli/terminal_cleanup.py +155 -0
- netizen_cli/turn_activity.py +688 -0
- netizen_cli/turn_files.py +812 -0
- netizen_cli/turn_patch_children.py +254 -0
- netizen_cli/turn_plan_observer.py +315 -0
- netizen_cli/user_questions.py +106 -0
- netizen_cli-0.10.0.dist-info/METADATA +18 -0
- netizen_cli-0.10.0.dist-info/RECORD +112 -0
- netizen_cli-0.10.0.dist-info/WHEEL +5 -0
- netizen_cli-0.10.0.dist-info/entry_points.txt +2 -0
- netizen_cli-0.10.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,766 @@
|
|
|
1
|
+
"""One management boundary for the MCP tool, Feishu cards and Admin."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import base64
|
|
7
|
+
import binascii
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import time
|
|
12
|
+
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
|
13
|
+
from dataclasses import asdict
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
from urllib.parse import urlencode
|
|
18
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
19
|
+
|
|
20
|
+
from ..bindings import BindingNotFound, BindingQuery, BindingQueryBusy, BindingQueryClosed, BindingQueryTimeout, BindingStore, ProjectConflict, ProjectDeleting, ProjectDisabled, ProjectNotFound, ScopeNotFound
|
|
21
|
+
from ..channel.messages import public_chat_kind
|
|
22
|
+
from ..model_settings import ModelCatalog, ModelCatalogError, STANDARD_SERVICE_TIER_ID
|
|
23
|
+
from ..session_settings import SessionSettings, SessionSettingsError
|
|
24
|
+
from .models import MutationResult, Plan, Run, ScheduleError, ScheduleNotFound, ScheduleRule, plan_lifecycle
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_NATIVE_STATUSES = frozenset({"inProgress", "completed", "interrupted", "failed"})
|
|
28
|
+
_TERMINAL = _NATIVE_STATUSES - {"inProgress"}
|
|
29
|
+
_READ_CONCURRENCY = 4
|
|
30
|
+
_READ_TIMEOUT_SECONDS = 5.0
|
|
31
|
+
_FIELDS = {
|
|
32
|
+
"options": {"chat_id", "binding_query"},
|
|
33
|
+
"list": {"chat_id", "project", "enabled", "ended", "all", "cursor", "limit", "name", "target_kind", "target_binding_id"},
|
|
34
|
+
"view": {"plan_id"},
|
|
35
|
+
"create": {"name", "instructions", "project", "chat_id", "schedule", "timezone", "enabled", "request_id", "session_settings", "target_kind", "target_binding_id"},
|
|
36
|
+
"update": {"plan_id", "expected_revision", "request_id", "name", "instructions", "project", "chat_id", "schedule", "timezone", "enabled", "session_settings", "target_kind", "target_binding_id"},
|
|
37
|
+
"delete": {"plan_id", "expected_revision", "request_id"},
|
|
38
|
+
"run_now": {"plan_id", "expected_revision", "request_id"},
|
|
39
|
+
"runs": {"plan_id", "cursor", "limit"},
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _InputError(ScheduleError):
|
|
44
|
+
def __init__(self, code: str, message: str) -> None:
|
|
45
|
+
super().__init__(message)
|
|
46
|
+
self.code = code
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def local_timezone() -> str | None:
|
|
50
|
+
"""Resolve an IANA name once; never guess from an ambiguous abbreviation."""
|
|
51
|
+
candidates = [os.environ.get("TZ", "").lstrip(":")]
|
|
52
|
+
try:
|
|
53
|
+
target = str(Path("/etc/localtime").resolve(strict=True))
|
|
54
|
+
if "/zoneinfo/" in target:
|
|
55
|
+
candidates.append(target.split("/zoneinfo/", 1)[1])
|
|
56
|
+
except OSError:
|
|
57
|
+
pass
|
|
58
|
+
try:
|
|
59
|
+
candidates.append(Path("/etc/timezone").read_text().strip())
|
|
60
|
+
except OSError:
|
|
61
|
+
pass
|
|
62
|
+
for candidate in candidates:
|
|
63
|
+
try:
|
|
64
|
+
if candidate:
|
|
65
|
+
ZoneInfo(candidate)
|
|
66
|
+
return candidate
|
|
67
|
+
except (ZoneInfoNotFoundError, ValueError):
|
|
68
|
+
continue
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ScheduleService:
|
|
73
|
+
def __init__(
|
|
74
|
+
self, *, bindings: BindingStore, runtime: Any, app_id: str,
|
|
75
|
+
chat_info: Any = None, wall_clock: Callable[[], float] = time.time,
|
|
76
|
+
default_timezone: str | None = None,
|
|
77
|
+
) -> None:
|
|
78
|
+
self.app_id = app_id
|
|
79
|
+
self._bindings = bindings
|
|
80
|
+
self._store = bindings.schedules
|
|
81
|
+
self._runtime = runtime
|
|
82
|
+
self._chat_info = chat_info
|
|
83
|
+
self._clock = wall_clock
|
|
84
|
+
self.default_timezone = default_timezone or local_timezone()
|
|
85
|
+
self._wake: Callable[[], None] = lambda: None
|
|
86
|
+
self._refresh: Callable[[str], Awaitable[str | None]] | None = None
|
|
87
|
+
self._run_now: Callable[[str, int, str, object], MutationResult] | None = None
|
|
88
|
+
self._accepting = True
|
|
89
|
+
|
|
90
|
+
def set_wake_handler(self, callback: Callable[[], None]) -> None:
|
|
91
|
+
self._wake = callback
|
|
92
|
+
|
|
93
|
+
def set_refresh_handler(self, callback: Callable[[str], Awaitable[str | None]]) -> None:
|
|
94
|
+
self._refresh = callback
|
|
95
|
+
|
|
96
|
+
def set_run_now_handler(self, callback: Callable[[str, int, str, object], MutationResult]) -> None:
|
|
97
|
+
self._run_now = callback
|
|
98
|
+
|
|
99
|
+
def close_admission(self) -> None:
|
|
100
|
+
self._accepting = False
|
|
101
|
+
|
|
102
|
+
def _source(self, native_thread_id: str | None, scope_key: str | None) -> tuple[Any, Any]:
|
|
103
|
+
binding = None
|
|
104
|
+
scope = None
|
|
105
|
+
if native_thread_id:
|
|
106
|
+
binding = self._bindings.find_by_native_thread_id(native_thread_id)
|
|
107
|
+
if binding is not None:
|
|
108
|
+
scope = self._bindings.get_scope(binding.scope_key)
|
|
109
|
+
elif scope_key:
|
|
110
|
+
try:
|
|
111
|
+
scope = self._bindings.get_scope(scope_key)
|
|
112
|
+
binding = self._bindings.active_binding(scope_key)
|
|
113
|
+
except ScopeNotFound:
|
|
114
|
+
pass
|
|
115
|
+
if scope is None or scope.app_id != self.app_id:
|
|
116
|
+
return None, None
|
|
117
|
+
return scope, binding
|
|
118
|
+
|
|
119
|
+
async def manage(
|
|
120
|
+
self, request: Mapping[str, Any], *, native_thread_id: str | None = None,
|
|
121
|
+
scope_key: str | None = None, source: str = "mcp",
|
|
122
|
+
) -> dict[str, Any]:
|
|
123
|
+
try:
|
|
124
|
+
if not self._accepting:
|
|
125
|
+
raise _InputError("unavailable", "服务正在停止,暂不接受定时任务管理。")
|
|
126
|
+
if not isinstance(request, Mapping):
|
|
127
|
+
raise ScheduleError("管理请求必须是对象。")
|
|
128
|
+
mode = request.get("mode")
|
|
129
|
+
if not isinstance(mode, str) or mode not in _FIELDS:
|
|
130
|
+
raise ScheduleError("请选择 options/list/view/create/update/delete/run_now/runs 操作。")
|
|
131
|
+
# Optional arguments treat null as omission, except the settings
|
|
132
|
+
# object. False remains a real pause/filter value.
|
|
133
|
+
data = {key: value for key, value in request.items()
|
|
134
|
+
if mode == "run_now" or value is not None or key == "session_settings"}
|
|
135
|
+
if set(data) - (_FIELDS[mode] | {"mode"}):
|
|
136
|
+
raise ScheduleError("该操作包含不支持的字段。")
|
|
137
|
+
now = self._clock()
|
|
138
|
+
# Hash the original structured intent, before clock-dependent
|
|
139
|
+
# anchors or context defaults are materialized. Replays must also
|
|
140
|
+
# work after the plan was edited/deleted or its once time passed.
|
|
141
|
+
request_payload = {
|
|
142
|
+
"app_id": self.app_id, "source": source,
|
|
143
|
+
"context": [native_thread_id, scope_key],
|
|
144
|
+
"request": {key: value for key, value in data.items() if key != "request_id"},
|
|
145
|
+
}
|
|
146
|
+
if mode in {"create", "update", "delete", "run_now"}:
|
|
147
|
+
request_id = _text(data.get("request_id"), "request_id")
|
|
148
|
+
replay = self._store.lookup_request(request_id, mode, request_payload, now=now)
|
|
149
|
+
if replay is not None:
|
|
150
|
+
if mode == "run_now":
|
|
151
|
+
return await self._run_receipt(replay)
|
|
152
|
+
return await self._mutation(replay, now)
|
|
153
|
+
scope, binding = self._source(native_thread_id, scope_key)
|
|
154
|
+
chat_default = scope.chat_id if scope else None
|
|
155
|
+
project_default = binding.project_alias if binding else None
|
|
156
|
+
if mode == "options":
|
|
157
|
+
return await self.options(native_thread_id=native_thread_id, scope_key=scope_key, **{key: data[key] for key in ("chat_id", "binding_query") if key in data})
|
|
158
|
+
if mode == "list":
|
|
159
|
+
all_plans = data.get("all", False)
|
|
160
|
+
_boolean(all_plans, "all")
|
|
161
|
+
chat = data["chat_id"] if "chat_id" in data else (None if all_plans else chat_default)
|
|
162
|
+
if not all_plans and not chat:
|
|
163
|
+
raise _InputError("context_required", "无法确定当前会话;请提供 chat_id 或 all=true。")
|
|
164
|
+
filters = {"app_id": self.app_id, "chat_id": chat,
|
|
165
|
+
"project_alias": data.get("project"), "enabled": data.get("enabled"),
|
|
166
|
+
"ended": data.get("ended"), "target_kind": data.get("target_kind"),
|
|
167
|
+
"target_binding_id": data.get("target_binding_id")}
|
|
168
|
+
if filters["target_kind"] is not None:
|
|
169
|
+
filters["target_kind"] = _target_kind(filters["target_kind"])
|
|
170
|
+
for field in ("chat_id", "project_alias", "target_binding_id"):
|
|
171
|
+
if filters[field] is not None:
|
|
172
|
+
filters[field] = _text(filters[field], field)
|
|
173
|
+
for field in ("enabled", "ended"):
|
|
174
|
+
if filters[field] is not None:
|
|
175
|
+
_boolean(filters[field], field)
|
|
176
|
+
name = data.get("name")
|
|
177
|
+
if name is not None:
|
|
178
|
+
_text(name, "name")
|
|
179
|
+
size = _page_size(data)
|
|
180
|
+
identity = {**filters, "name": name}
|
|
181
|
+
after = _decode_cursor(data.get("cursor"), identity)
|
|
182
|
+
plans = self._store.list(**filters, now=now, name=name, after=after, limit=size + 1)
|
|
183
|
+
return {"ok": True, "plans": await self._plans(plans[:size], now), "snapshot_at": now,
|
|
184
|
+
"next_cursor": _encode_cursor(plans[size - 1].id, identity) if len(plans) > size else None,
|
|
185
|
+
"default_timezone": self.default_timezone}
|
|
186
|
+
if mode == "create":
|
|
187
|
+
target_kind = _target_kind(data.get("target_kind", "new_topic"))
|
|
188
|
+
target_binding_id = None
|
|
189
|
+
if target_kind == "binding":
|
|
190
|
+
self._reject_binding_overrides(data)
|
|
191
|
+
target_scope, target = self._binding_target(data, binding=binding)
|
|
192
|
+
target_binding_id = target.id
|
|
193
|
+
chat, project = target_scope.chat_id, target.project_alias
|
|
194
|
+
await self._validate_chat(chat)
|
|
195
|
+
settings = None
|
|
196
|
+
else:
|
|
197
|
+
if "target_binding_id" in data:
|
|
198
|
+
raise ScheduleError("新话题模式不接受 target_binding_id。")
|
|
199
|
+
chat = _text(data["chat_id"] if "chat_id" in data else chat_default, "chat_id")
|
|
200
|
+
project = _text(data["project"] if "project" in data else project_default, "project")
|
|
201
|
+
chat_kind = await self._validate_chat(chat)
|
|
202
|
+
settings = await self._resolve_session_settings(data, binding=binding, chat_kind=chat_kind)
|
|
203
|
+
rule = self._rule(data, now=now)
|
|
204
|
+
enabled = data.get("enabled", True)
|
|
205
|
+
_boolean(enabled, "enabled")
|
|
206
|
+
result = self._store.create(
|
|
207
|
+
name=_text(data.get("name"), "name"),
|
|
208
|
+
instructions=_text(data.get("instructions"), "instructions"),
|
|
209
|
+
project_alias=project, app_id=self.app_id, chat_id=chat,
|
|
210
|
+
schedule=rule, enabled=enabled, source=source,
|
|
211
|
+
session_settings=settings,
|
|
212
|
+
target_kind=target_kind, target_binding_id=target_binding_id,
|
|
213
|
+
request_id=_text(data.get("request_id"), "request_id"), now=now,
|
|
214
|
+
request_payload=request_payload,
|
|
215
|
+
)
|
|
216
|
+
self._wake()
|
|
217
|
+
return await self._mutation(result, now)
|
|
218
|
+
plan = self._store.get(
|
|
219
|
+
_text(data.get("plan_id"), "plan_id"), include_deleted=mode == "runs",
|
|
220
|
+
)
|
|
221
|
+
if plan.app_id != self.app_id:
|
|
222
|
+
raise ScheduleNotFound("当前应用下没有这个定时计划。")
|
|
223
|
+
if mode == "runs":
|
|
224
|
+
size = _page_size(data)
|
|
225
|
+
identity = {"plan_id": plan.id}
|
|
226
|
+
after = _decode_cursor(data.get("cursor"), identity)
|
|
227
|
+
observed: dict[str, str] = {}
|
|
228
|
+
if mode in {"view", "runs"}:
|
|
229
|
+
observed = await self._refresh_pending(plan)
|
|
230
|
+
plan = self._store.get(plan.id, include_deleted=mode == "runs")
|
|
231
|
+
now = self._clock()
|
|
232
|
+
if mode == "view":
|
|
233
|
+
return await self._detail(plan, now, observed=observed)
|
|
234
|
+
if mode == "runs":
|
|
235
|
+
runs = self._store.list_runs(plan.id, after=after, limit=size + 1)
|
|
236
|
+
pending = self._store.pending_for_plan(plan.id)
|
|
237
|
+
lifecycle = plan_lifecycle(plan, now=now, has_pending=pending is not None)
|
|
238
|
+
selected = pending or (runs[0] if runs and not data.get("cursor") else None)
|
|
239
|
+
timezone = plan.schedule.timezone if plan.schedule else "UTC"
|
|
240
|
+
projections = await self._observe_runs([(run, timezone) for run in runs[:size]], observed=observed)
|
|
241
|
+
for value in projections:
|
|
242
|
+
value["is_last"] = not lifecycle.has_trigger and selected is not None and value["id"] == selected.id
|
|
243
|
+
return {"ok": True, "runs": projections, "snapshot_at": now,
|
|
244
|
+
"next_cursor": _encode_cursor(runs[size - 1].id, identity) if len(runs) > size else None}
|
|
245
|
+
revision = data.get("expected_revision")
|
|
246
|
+
if type(revision) is not int or revision < 1:
|
|
247
|
+
raise ScheduleError("修改、删除或立即运行必须提供当前 expected_revision。")
|
|
248
|
+
request_id = _text(data.get("request_id"), "request_id")
|
|
249
|
+
if mode == "run_now":
|
|
250
|
+
if self._run_now is None:
|
|
251
|
+
raise _InputError("unavailable", "调度服务尚未就绪,暂不能立即运行。")
|
|
252
|
+
# The synchronous handler rechecks exact identity, revision and
|
|
253
|
+
# admission, then owns dispatch before this request can cancel.
|
|
254
|
+
result = self._run_now(plan.id, revision, request_id, request_payload)
|
|
255
|
+
return await self._run_receipt(result)
|
|
256
|
+
if mode == "delete":
|
|
257
|
+
inflight = self._store.pending_for_plan(plan.id) is not None
|
|
258
|
+
result = self._store.delete(plan.id, expected_revision=revision, request_id=request_id, now=now, request_payload=request_payload)
|
|
259
|
+
self._wake()
|
|
260
|
+
return {"ok": True, "plan_id": result.plan_id, "revision": result.revision,
|
|
261
|
+
"inflight": inflight, "replayed": result.replayed}
|
|
262
|
+
self._validate_target_update(data, plan)
|
|
263
|
+
changes = {key: data[key] for key in ("name", "instructions", "chat_id", "enabled") if key in data}
|
|
264
|
+
if "enabled" in changes:
|
|
265
|
+
_boolean(changes["enabled"], "enabled")
|
|
266
|
+
if "project" in data:
|
|
267
|
+
changes["project_alias"] = _text(data["project"], "project")
|
|
268
|
+
if "session_settings" in data or ("chat_id" in changes and changes["chat_id"] != plan.chat_id):
|
|
269
|
+
chat_kind = await self._validate_chat(_text(changes.get("chat_id", plan.chat_id), "chat_id"))
|
|
270
|
+
changes["session_settings"] = await self._resolve_session_settings(data, previous=plan.session_settings, chat_kind=chat_kind)
|
|
271
|
+
if "schedule" in data or "timezone" in data:
|
|
272
|
+
changes["schedule"] = self._rule(data, now=now, previous=plan.schedule)
|
|
273
|
+
result = self._store.update(plan.id, expected_revision=revision, request_id=request_id, changes=changes, now=now, request_payload=request_payload)
|
|
274
|
+
self._wake()
|
|
275
|
+
return await self._mutation(result, now)
|
|
276
|
+
except (ScheduleError, SessionSettingsError, BindingNotFound, ScopeNotFound, ProjectNotFound, ProjectConflict) as error:
|
|
277
|
+
return _failure(error)
|
|
278
|
+
|
|
279
|
+
async def preview(self, request: Mapping[str, Any], *, native_thread_id: str | None = None, scope_key: str | None = None) -> dict[str, Any]:
|
|
280
|
+
try:
|
|
281
|
+
if not isinstance(request, Mapping):
|
|
282
|
+
raise ScheduleError("预览请求必须是对象。")
|
|
283
|
+
now = self._clock()
|
|
284
|
+
scope, binding = self._source(native_thread_id, scope_key)
|
|
285
|
+
plan = self._store.get(_text(request["plan_id"], "plan_id")) if request.get("plan_id") else None
|
|
286
|
+
if plan is not None and plan.app_id != self.app_id:
|
|
287
|
+
raise ScheduleNotFound("当前应用下没有这个定时计划。")
|
|
288
|
+
target_kind = _target_kind(request.get("target_kind", plan.target_kind if plan else "new_topic"))
|
|
289
|
+
if plan is not None:
|
|
290
|
+
self._validate_target_update(request, plan)
|
|
291
|
+
if target_kind == "binding":
|
|
292
|
+
self._reject_binding_overrides(request)
|
|
293
|
+
target_scope, target = self._binding_target(
|
|
294
|
+
{"target_binding_id": plan.target_binding_id} if plan else request, binding=binding,
|
|
295
|
+
)
|
|
296
|
+
await self._validate_chat(target_scope.chat_id)
|
|
297
|
+
settings = None
|
|
298
|
+
effective = SessionSettings.from_binding(target).to_dict()
|
|
299
|
+
else:
|
|
300
|
+
if request.get("target_binding_id") is not None:
|
|
301
|
+
raise ScheduleError("新话题模式不接受 target_binding_id。")
|
|
302
|
+
chat = request["chat_id"] if request.get("chat_id") is not None else (plan.chat_id if plan else scope.chat_id if scope else None)
|
|
303
|
+
kind = await self._validate_chat(_text(chat, "chat_id")) if chat is not None else None
|
|
304
|
+
settings = await self._resolve_session_settings(request, binding=binding, previous=plan.session_settings if plan else None, chat_kind=kind)
|
|
305
|
+
effective = settings.to_dict()
|
|
306
|
+
rule = self._rule(request, now=now, previous=plan.schedule if plan else None)
|
|
307
|
+
boundary = max(now, plan.processed_through or now) if plan else now
|
|
308
|
+
return {"ok": True, "schedule": rule.to_dict(),
|
|
309
|
+
"preview": _preview(rule, boundary),
|
|
310
|
+
"target_kind": target_kind,
|
|
311
|
+
"session_settings": settings.to_dict() if settings is not None else None,
|
|
312
|
+
"effective_session_settings": effective, "default_timezone": self.default_timezone}
|
|
313
|
+
except (ScheduleError, SessionSettingsError, BindingNotFound, ScopeNotFound, ProjectNotFound, ProjectConflict) as error:
|
|
314
|
+
return _failure(error)
|
|
315
|
+
|
|
316
|
+
@staticmethod
|
|
317
|
+
def _reject_binding_overrides(request: Mapping[str, Any]) -> None:
|
|
318
|
+
if {"project", "chat_id", "session_settings"}.intersection(request):
|
|
319
|
+
raise ScheduleError("原会话计划沿用目标 Binding 的 Project、位置和配置,不接受独立覆盖。")
|
|
320
|
+
|
|
321
|
+
def _binding_target(self, request: Mapping[str, Any], *, binding: Any = None) -> tuple[Any, Any]:
|
|
322
|
+
target_id = request.get("target_binding_id")
|
|
323
|
+
if target_id is None:
|
|
324
|
+
if binding is None:
|
|
325
|
+
raise _InputError("context_required", "无法确定原会话;请提供 options 返回的准确 target_binding_id。")
|
|
326
|
+
target_id = binding.id
|
|
327
|
+
target = self._bindings.get(_text(target_id, "target_binding_id"))
|
|
328
|
+
scope = self._bindings.get_scope(target.scope_key)
|
|
329
|
+
if scope.app_id != self.app_id:
|
|
330
|
+
raise BindingNotFound("当前应用下没有这个目标会话。")
|
|
331
|
+
return scope, target
|
|
332
|
+
|
|
333
|
+
def _validate_target_update(self, request: Mapping[str, Any], plan: Plan) -> None:
|
|
334
|
+
if "target_kind" in request and _target_kind(request["target_kind"]) != plan.target_kind:
|
|
335
|
+
raise ScheduleError("计划的执行目标类型不可修改;请新建计划。")
|
|
336
|
+
if "target_binding_id" in request and request["target_binding_id"] != plan.target_binding_id:
|
|
337
|
+
raise ScheduleError("计划的目标 Binding 不可修改;请新建计划。")
|
|
338
|
+
if plan.target_kind == "binding":
|
|
339
|
+
self._reject_binding_overrides(request)
|
|
340
|
+
|
|
341
|
+
async def _catalog(self) -> tuple[ModelCatalog | None, dict[str, str] | None]:
|
|
342
|
+
try:
|
|
343
|
+
async with asyncio.timeout(5):
|
|
344
|
+
return await self._runtime.model_catalog(), None
|
|
345
|
+
except Exception:
|
|
346
|
+
return None, {"code": "model_catalog_unavailable", "message": "Codex 模型目录暂不可用;可保留已有模型设置或选择继承 Codex,稍后再选择新模型。"}
|
|
347
|
+
|
|
348
|
+
async def _resolve_session_settings(self, request: Mapping[str, Any], *, binding: Any = None,
|
|
349
|
+
previous: SessionSettings | None = None, chat_kind: str | None = None) -> SessionSettings:
|
|
350
|
+
patch = request.get("session_settings", {})
|
|
351
|
+
SessionSettings().merge(patch) # Reject malformed input before native I/O.
|
|
352
|
+
catalog = None
|
|
353
|
+
catalog_error = None
|
|
354
|
+
if previous is not None:
|
|
355
|
+
base = previous
|
|
356
|
+
elif binding is not None:
|
|
357
|
+
base = SessionSettings.from_binding(binding)
|
|
358
|
+
elif "turn_settings" not in patch:
|
|
359
|
+
catalog, catalog_error = await self._catalog()
|
|
360
|
+
base = SessionSettings.new_defaults(catalog)
|
|
361
|
+
else:
|
|
362
|
+
base = SessionSettings.new_defaults(None)
|
|
363
|
+
settings = base.merge(patch)
|
|
364
|
+
if chat_kind == "p2p" and settings.message_context_mode.value == "catch-up":
|
|
365
|
+
if "message_context_mode" in patch:
|
|
366
|
+
raise SessionSettingsError("私聊目标只支持 current-only;请将 message_context_mode 设为 current-only。")
|
|
367
|
+
settings = settings.merge({"message_context_mode": "current-only"})
|
|
368
|
+
if settings.turn_settings is not None and (previous is None or settings.turn_settings != previous.turn_settings):
|
|
369
|
+
if catalog is None and catalog_error is None:
|
|
370
|
+
catalog, catalog_error = await self._catalog()
|
|
371
|
+
if catalog is None:
|
|
372
|
+
assert catalog_error is not None
|
|
373
|
+
raise _InputError(catalog_error["code"], catalog_error["message"])
|
|
374
|
+
try:
|
|
375
|
+
settings.validate_catalog(catalog)
|
|
376
|
+
except ModelCatalogError as error:
|
|
377
|
+
raise _InputError("invalid_model_settings", "所选模型、思考强度或速度组合已不可用。请通过 options 读取当前可选项后重新选择;已有计划的其他设置可以单独修改。") from error
|
|
378
|
+
return settings
|
|
379
|
+
|
|
380
|
+
async def options(self, *, native_thread_id: str | None = None, scope_key: str | None = None, chat_id: str | None = None, binding_query: str | None = None) -> dict[str, Any]:
|
|
381
|
+
result, _ = await self.form_options(native_thread_id=native_thread_id, scope_key=scope_key, chat_id=chat_id, binding_query=binding_query)
|
|
382
|
+
return result
|
|
383
|
+
|
|
384
|
+
async def form_options(self, *, native_thread_id: str | None = None, scope_key: str | None = None, chat_id: str | None = None, binding_query: str | None = None) -> tuple[dict[str, Any], ModelCatalog | None]:
|
|
385
|
+
try:
|
|
386
|
+
if binding_query is not None:
|
|
387
|
+
if not isinstance(binding_query, str) or len(binding_query) > 200:
|
|
388
|
+
raise ScheduleError("binding_query 须为最多 200 字符的查询文本。")
|
|
389
|
+
binding_query = binding_query.strip() or None
|
|
390
|
+
scope, binding = self._source(native_thread_id, scope_key)
|
|
391
|
+
chat = _text(chat_id, "chat_id") if chat_id is not None else scope.chat_id if scope else None
|
|
392
|
+
kind = await self._validate_chat(chat) if chat is not None else None
|
|
393
|
+
catalog, error = await self._catalog()
|
|
394
|
+
settings = SessionSettings.from_binding(binding) if binding else SessionSettings.new_defaults(catalog)
|
|
395
|
+
if kind == "p2p":
|
|
396
|
+
settings = settings.merge({"message_context_mode": "current-only"})
|
|
397
|
+
binding_error = None
|
|
398
|
+
try:
|
|
399
|
+
targets, truncated = await self._binding_options(chat=chat, binding=binding, search=binding_query)
|
|
400
|
+
except (BindingQueryBusy, BindingQueryClosed, BindingQueryTimeout):
|
|
401
|
+
targets, truncated = [], False
|
|
402
|
+
binding_error = {"code": "binding_options_unavailable", "message": "会话选项暂不可用,请稍后重试。"}
|
|
403
|
+
return {"ok": True, "session_settings": settings.to_dict(), "models": _models(catalog),
|
|
404
|
+
"source_binding_id": binding.id if binding else None,
|
|
405
|
+
"binding_targets": targets, "bindings_truncated": truncated,
|
|
406
|
+
"binding_options_error": binding_error,
|
|
407
|
+
"context_mode_available": kind != "p2p" if kind is not None else None,
|
|
408
|
+
"model_catalog_error": error}, catalog
|
|
409
|
+
except (ScheduleError, SessionSettingsError, BindingNotFound, ScopeNotFound) as error:
|
|
410
|
+
return _failure(error), None
|
|
411
|
+
|
|
412
|
+
async def _binding_options(self, *, chat: str | None, binding: Any, search: str | None) -> tuple[list[dict[str, Any]], bool]:
|
|
413
|
+
page = await self._bindings.query_bindings(
|
|
414
|
+
query=BindingQuery(chat_id=chat, app_id=self.app_id, search=search), limit=100,
|
|
415
|
+
)
|
|
416
|
+
targets = {}
|
|
417
|
+
if binding is not None:
|
|
418
|
+
scope = self._bindings.get_scope(binding.scope_key)
|
|
419
|
+
matches = search is None or any(
|
|
420
|
+
search.lower() in value.lower()
|
|
421
|
+
for value in (binding.id, binding.project_alias, scope.chat_id, scope.topic_id or "")
|
|
422
|
+
)
|
|
423
|
+
if matches and (chat is None or scope.chat_id == chat):
|
|
424
|
+
targets[binding.id] = (scope, binding)
|
|
425
|
+
targets.update((item.binding.id, (item.scope, item.binding)) for item in page.items)
|
|
426
|
+
result = []
|
|
427
|
+
for scope, target in targets.values():
|
|
428
|
+
available = False
|
|
429
|
+
try:
|
|
430
|
+
available = (
|
|
431
|
+
self._bindings.get_project(target.project_alias).enabled and target.active
|
|
432
|
+
and not self._bindings.project_delete_in_progress(target.project_alias)
|
|
433
|
+
)
|
|
434
|
+
except ProjectNotFound:
|
|
435
|
+
pass
|
|
436
|
+
result.append({
|
|
437
|
+
"id": target.id, "label": self._binding_label(scope, target),
|
|
438
|
+
"scope_key": scope.scope_key, "chat_id": scope.chat_id,
|
|
439
|
+
"project_alias": target.project_alias, "current": target.active,
|
|
440
|
+
"available": available,
|
|
441
|
+
})
|
|
442
|
+
return result, page.next_cursor is not None
|
|
443
|
+
|
|
444
|
+
@staticmethod
|
|
445
|
+
def _binding_label(scope: Any, binding: Any) -> str:
|
|
446
|
+
location = scope.chat_id + (f" / {scope.topic_id}" if scope.topic_id else "")
|
|
447
|
+
return f"{binding.project_alias} · {binding.short_id} · {location}"
|
|
448
|
+
|
|
449
|
+
def _rule(self, data: Mapping[str, Any], *, now: float, previous: ScheduleRule | None = None) -> ScheduleRule:
|
|
450
|
+
raw = data.get("schedule")
|
|
451
|
+
if raw is None and previous is not None:
|
|
452
|
+
raw = previous.to_dict()
|
|
453
|
+
if data.get("timezone"):
|
|
454
|
+
raw.pop("timezone", None)
|
|
455
|
+
if not isinstance(raw, dict):
|
|
456
|
+
raise ScheduleError("请提供结构化 schedule。")
|
|
457
|
+
raw = dict(raw)
|
|
458
|
+
timezone = data.get("timezone")
|
|
459
|
+
if timezone is not None and not isinstance(timezone, str):
|
|
460
|
+
raise ScheduleError("timezone 必须是 IANA 时区名称。")
|
|
461
|
+
if raw.get("timezone") is not None and not isinstance(raw["timezone"], str):
|
|
462
|
+
raise ScheduleError("schedule.timezone 必须是 IANA 时区名称。")
|
|
463
|
+
if timezone:
|
|
464
|
+
if raw.get("timezone") and raw["timezone"] != timezone:
|
|
465
|
+
raise ScheduleError("schedule 与外层 timezone 不一致。")
|
|
466
|
+
raw["timezone"] = timezone
|
|
467
|
+
if not raw.get("timezone"):
|
|
468
|
+
raw["timezone"] = previous.timezone if previous else self.default_timezone
|
|
469
|
+
if not raw["timezone"]:
|
|
470
|
+
raise _InputError("timezone_required", "无法确定服务的本地时区,请显式选择 IANA 时区。")
|
|
471
|
+
if raw.get("kind") == "interval" and raw.get("anchor") is None:
|
|
472
|
+
raw["anchor"] = previous.anchor if previous and previous.kind == "interval" else now
|
|
473
|
+
rule = ScheduleRule.from_dict(raw)
|
|
474
|
+
# An unchanged ended definition remains editable. A recurring rule may
|
|
475
|
+
# also be deliberately shortened to end now; only a new definition or
|
|
476
|
+
# a changed one-shot time must provide a future opportunity.
|
|
477
|
+
if (previous is None or (rule.kind == "once" and rule != previous)) and rule.next_after(now) is None:
|
|
478
|
+
raise ScheduleError("时间规则没有未来触发,请检查执行时间和截止条件。")
|
|
479
|
+
return rule
|
|
480
|
+
|
|
481
|
+
async def _validate_chat(self, chat_id: str) -> str:
|
|
482
|
+
if self._chat_info is None:
|
|
483
|
+
raise _InputError("unavailable", "会话信息服务暂不可用。")
|
|
484
|
+
try:
|
|
485
|
+
async with asyncio.timeout(5):
|
|
486
|
+
info = await self._chat_info.get_chat_info(chat_id)
|
|
487
|
+
except Exception as error:
|
|
488
|
+
raise _InputError("chat_unavailable", "无法访问目标会话,请检查 chat_id 和机器人是否可访问该会话。") from error
|
|
489
|
+
kind = public_chat_kind(info)
|
|
490
|
+
if kind is None:
|
|
491
|
+
raise _InputError("chat_kind_unknown", "无法确认目标会话类型,请稍后重试或检查 chat_id;这不代表当前会话是私聊。")
|
|
492
|
+
return kind
|
|
493
|
+
|
|
494
|
+
async def _run_receipt(self, result: MutationResult) -> dict[str, Any]:
|
|
495
|
+
if not result.run_id:
|
|
496
|
+
raise ScheduleError("手动执行回执缺少 Run ID,请检查数据完整性;不能重新触发。")
|
|
497
|
+
receipt: dict[str, Any] = {
|
|
498
|
+
"ok": True, "accepted": True, "plan_id": result.plan_id,
|
|
499
|
+
"revision": result.revision, "run_id": result.run_id,
|
|
500
|
+
"replayed": result.replayed, "run": None,
|
|
501
|
+
}
|
|
502
|
+
try:
|
|
503
|
+
run = self._store.get_run(result.run_id)
|
|
504
|
+
except ScheduleNotFound:
|
|
505
|
+
# A bounded request receipt can outlive a released Run. It still
|
|
506
|
+
# proves acceptance of that exact run, never permission to rerun.
|
|
507
|
+
return receipt
|
|
508
|
+
plan = self._store.get(result.plan_id, include_deleted=True)
|
|
509
|
+
receipt["run"] = await self._run(
|
|
510
|
+
run, timezone=plan.schedule.timezone if plan.schedule else "UTC",
|
|
511
|
+
deadline=asyncio.get_running_loop().time() + _READ_TIMEOUT_SECONDS,
|
|
512
|
+
slots=asyncio.Semaphore(1),
|
|
513
|
+
)
|
|
514
|
+
return receipt
|
|
515
|
+
|
|
516
|
+
async def _mutation(self, result: Any, now: float) -> dict[str, Any]:
|
|
517
|
+
plan = self._store.get(result.plan_id, include_deleted=True)
|
|
518
|
+
if plan.deleted:
|
|
519
|
+
return {"ok": True, "plan_id": result.plan_id, "revision": result.revision,
|
|
520
|
+
"replayed": result.replayed, "deleted": True,
|
|
521
|
+
"inflight": self._store.pending_for_plan(plan.id) is not None}
|
|
522
|
+
return {**await self._detail(plan, now), "replayed": result.replayed,
|
|
523
|
+
"operation_revision": result.revision}
|
|
524
|
+
|
|
525
|
+
async def _detail(self, plan: Plan, now: float, *, observed: Mapping[str, str] | None = None) -> dict[str, Any]:
|
|
526
|
+
value, = await self._plans((plan,), now, observed=observed)
|
|
527
|
+
return {"ok": True, "plan": value, "snapshot_at": now,
|
|
528
|
+
"preview": _preview(plan.schedule, max(now, plan.processed_through or now)) if plan.schedule else [],
|
|
529
|
+
"inflight": value["inflight"]}
|
|
530
|
+
|
|
531
|
+
async def _plans(self, plans: Sequence[Plan], now: float, *, observed: Mapping[str, str] | None = None) -> list[dict[str, Any]]:
|
|
532
|
+
# Snapshot all Channel-owned lifecycle inputs before the first await.
|
|
533
|
+
# Native observation enriches execution only; it never changes barriers
|
|
534
|
+
# or the ended predicate used to select this page.
|
|
535
|
+
snapshots = [self._plan(plan, now) for plan in plans]
|
|
536
|
+
projections = await self._observe_runs([
|
|
537
|
+
(run, plan.schedule.timezone if plan.schedule else "UTC")
|
|
538
|
+
for plan, (_value, run) in zip(plans, snapshots) if run is not None
|
|
539
|
+
], observed=observed)
|
|
540
|
+
by_id = {run["id"]: run for run in projections}
|
|
541
|
+
for value, run in snapshots:
|
|
542
|
+
if run is not None:
|
|
543
|
+
projection = by_id[run.id]
|
|
544
|
+
value["execution"].update({
|
|
545
|
+
key: projection[key] for key in (
|
|
546
|
+
"status", "native_thread_id", "scope_key", "feishu_url",
|
|
547
|
+
)
|
|
548
|
+
})
|
|
549
|
+
return [value for value, _run in snapshots]
|
|
550
|
+
|
|
551
|
+
def _plan(self, plan: Plan, now: float) -> tuple[dict[str, Any], Run | None]:
|
|
552
|
+
value = asdict(plan)
|
|
553
|
+
value["session_settings"] = plan.session_settings.to_dict() if plan.session_settings is not None else None
|
|
554
|
+
value["effective_session_settings"] = value["session_settings"]
|
|
555
|
+
value["target_label"] = plan.chat_id
|
|
556
|
+
value["schedule"] = plan.schedule.to_dict() if plan.schedule else None
|
|
557
|
+
timezone = plan.schedule.timezone if plan.schedule else "UTC"
|
|
558
|
+
value["next_due_local"] = _iso(plan.next_due_at, timezone) if plan.next_due_at is not None else None
|
|
559
|
+
pending = self._store.pending_for_plan(plan.id)
|
|
560
|
+
lifecycle = plan_lifecycle(plan, now=now, has_pending=pending is not None)
|
|
561
|
+
value["lifecycle"] = asdict(lifecycle)
|
|
562
|
+
value["status"] = "ended" if lifecycle.ended else "enabled" if plan.enabled else "paused"
|
|
563
|
+
value["inflight"] = pending is not None
|
|
564
|
+
value["blocked_reason"] = "blocked_unknown" if pending and pending.barrier == "unknown" else None
|
|
565
|
+
latest = self._store.list_runs(plan.id, limit=1)
|
|
566
|
+
value["latest_run"] = None
|
|
567
|
+
if latest:
|
|
568
|
+
run = latest[0]
|
|
569
|
+
value["latest_run"] = {
|
|
570
|
+
"id": run.id, "due_at": run.due_at, "phase": run.phase,
|
|
571
|
+
"trigger_source": run.trigger_source,
|
|
572
|
+
"error_code": run.error_code, "binding_id": run.binding_id,
|
|
573
|
+
"due_local": _iso(run.due_at, timezone),
|
|
574
|
+
}
|
|
575
|
+
selected = pending or (latest[0] if latest else None)
|
|
576
|
+
value["execution"] = {
|
|
577
|
+
"kind": "current" if pending else "latest" if selected else "none",
|
|
578
|
+
"status": "expired" if lifecycle.ended else "not_started",
|
|
579
|
+
"is_last": selected is not None and not lifecycle.has_trigger,
|
|
580
|
+
"run_id": selected.id if selected else None,
|
|
581
|
+
"trigger_source": selected.trigger_source if selected else None,
|
|
582
|
+
"due_at": selected.due_at if selected else None,
|
|
583
|
+
"due_local": _iso(selected.due_at, timezone) if selected else None,
|
|
584
|
+
"native_thread_id": None, "scope_key": None, "feishu_url": None,
|
|
585
|
+
}
|
|
586
|
+
try:
|
|
587
|
+
if not self._bindings.get_project(plan.project_alias).enabled:
|
|
588
|
+
value["blocked_reason"] = "project_disabled"
|
|
589
|
+
except ProjectNotFound:
|
|
590
|
+
value["blocked_reason"] = "project_unavailable"
|
|
591
|
+
if self._bindings.project_delete_in_progress(plan.project_alias):
|
|
592
|
+
value["blocked_reason"] = "project_deleting"
|
|
593
|
+
value["suspended_reason"] = value["blocked_reason"] if value["blocked_reason"] in {"project_disabled", "project_unavailable", "project_deleting"} else None
|
|
594
|
+
if plan.target_kind == "binding":
|
|
595
|
+
reason = self._store.binding_target_error(plan)
|
|
596
|
+
if reason is not None:
|
|
597
|
+
value["suspended_reason"] = reason
|
|
598
|
+
value["blocked_reason"] = reason
|
|
599
|
+
try:
|
|
600
|
+
scope, binding = self._binding_target({"target_binding_id": plan.target_binding_id})
|
|
601
|
+
value["target_label"] = self._binding_label(scope, binding)
|
|
602
|
+
value["effective_session_settings"] = SessionSettings.from_binding(binding).to_dict()
|
|
603
|
+
except (BindingNotFound, ScopeNotFound):
|
|
604
|
+
pass
|
|
605
|
+
value["can_run_now"] = not plan.deleted and not value["blocked_reason"] and not value["inflight"]
|
|
606
|
+
return value, selected
|
|
607
|
+
|
|
608
|
+
async def _refresh_pending(self, plan: Plan) -> dict[str, str]:
|
|
609
|
+
"""Keep explicit detail/history refresh as the existing recovery entry."""
|
|
610
|
+
if plan.target_kind == "binding":
|
|
611
|
+
# A Turn's later state never establishes whether this input was
|
|
612
|
+
# accepted. Binding runs describe input handoff only.
|
|
613
|
+
return {}
|
|
614
|
+
pending = self._store.pending_for_plan(plan.id)
|
|
615
|
+
if pending is None:
|
|
616
|
+
return {}
|
|
617
|
+
if self._refresh is not None:
|
|
618
|
+
return {pending.id: await self._refresh(plan.id) or "unavailable"}
|
|
619
|
+
if pending.initial_turn_id is None:
|
|
620
|
+
return {}
|
|
621
|
+
timezone = plan.schedule.timezone if plan.schedule else "UTC"
|
|
622
|
+
value, = await self._observe_runs([(pending, timezone)])
|
|
623
|
+
status = value["status"]
|
|
624
|
+
try:
|
|
625
|
+
if status in _TERMINAL:
|
|
626
|
+
self._store.release(pending.id)
|
|
627
|
+
self._wake()
|
|
628
|
+
elif status == "unavailable":
|
|
629
|
+
self._store.set_run(pending.id, barrier="unknown", error_code="observation_unavailable")
|
|
630
|
+
except ScheduleNotFound:
|
|
631
|
+
pass
|
|
632
|
+
return {pending.id: status}
|
|
633
|
+
|
|
634
|
+
async def _observe_runs(self, runs: Sequence[tuple[Run, str]], *, observed: Mapping[str, str] | None = None) -> list[dict[str, Any]]:
|
|
635
|
+
deadline = asyncio.get_running_loop().time() + _READ_TIMEOUT_SECONDS
|
|
636
|
+
slots = asyncio.Semaphore(_READ_CONCURRENCY)
|
|
637
|
+
return list(await asyncio.gather(*(
|
|
638
|
+
self._run(run, timezone=timezone, deadline=deadline, slots=slots, observed=(observed or {}).get(run.id))
|
|
639
|
+
for run, timezone in runs
|
|
640
|
+
)))
|
|
641
|
+
|
|
642
|
+
async def _run(self, run: Run, *, timezone: str, deadline: float, slots: asyncio.Semaphore, observed: str | None = None) -> dict[str, Any]:
|
|
643
|
+
value = asdict(run)
|
|
644
|
+
status = run.error_code or ("starting" if run.barrier == "held" else "unknown")
|
|
645
|
+
if run.barrier == "unknown" or run.error_code == "publishing_unknown":
|
|
646
|
+
status = "unknown"
|
|
647
|
+
value.update(status=status, due_local=_iso(run.due_at, timezone), native_thread_id=None, scope_key=None, feishu_url=None)
|
|
648
|
+
anchor_id = run.origin_message_id if run.target_kind == "binding" else run.root_message_id
|
|
649
|
+
if anchor_id:
|
|
650
|
+
value["feishu_url"] = "https://applink.feishu.cn/client/chat/open?" + urlencode({"openChatId": run.chat_id, "messageId": anchor_id})
|
|
651
|
+
if run.binding_removed:
|
|
652
|
+
value["status"] = "deleted"
|
|
653
|
+
return value
|
|
654
|
+
if run.target_kind == "binding":
|
|
655
|
+
if run.disposition in {"started", "steered"}:
|
|
656
|
+
value["status"] = "input_" + run.disposition
|
|
657
|
+
elif status == "unknown" or run.error_code in {"input_unknown", "publishing_unknown"}:
|
|
658
|
+
value["status"] = "input_unknown"
|
|
659
|
+
if run.binding_id:
|
|
660
|
+
try:
|
|
661
|
+
binding = self._bindings.get(run.binding_id)
|
|
662
|
+
value.update(native_thread_id=binding.native_thread_id, scope_key=binding.scope_key)
|
|
663
|
+
except BindingNotFound:
|
|
664
|
+
pass
|
|
665
|
+
return value
|
|
666
|
+
if run.binding_id:
|
|
667
|
+
try:
|
|
668
|
+
binding = self._bindings.get(run.binding_id)
|
|
669
|
+
except BindingNotFound:
|
|
670
|
+
value["status"] = "unavailable"
|
|
671
|
+
return value
|
|
672
|
+
value.update(native_thread_id=binding.native_thread_id, scope_key=binding.scope_key)
|
|
673
|
+
if run.initial_turn_id:
|
|
674
|
+
if observed is not None:
|
|
675
|
+
value["status"] = observed
|
|
676
|
+
return value
|
|
677
|
+
try:
|
|
678
|
+
async with asyncio.timeout_at(deadline):
|
|
679
|
+
async with slots:
|
|
680
|
+
status = await self._runtime.read_scheduled_turn(binding.id, run.initial_turn_id, deadline=deadline)
|
|
681
|
+
value["status"] = status if status in _NATIVE_STATUSES else "unknown"
|
|
682
|
+
except Exception:
|
|
683
|
+
value["status"] = "unavailable"
|
|
684
|
+
return value
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _models(catalog: ModelCatalog | None) -> list[dict[str, Any]]:
|
|
688
|
+
if catalog is None:
|
|
689
|
+
return []
|
|
690
|
+
return [{
|
|
691
|
+
"id": model.id, "model": model.model, "display_name": model.display_name,
|
|
692
|
+
"description": model.description, "is_default": model.is_default,
|
|
693
|
+
"default_effort_id": model.default_effort_id, "default_service_tier_id": model.default_service_tier_id,
|
|
694
|
+
"efforts": [{"id": effort.id, "description": effort.description} for effort in model.efforts],
|
|
695
|
+
"service_tiers": [{"id": STANDARD_SERVICE_TIER_ID, "name": "Standard", "description": "Codex 标准服务层"},
|
|
696
|
+
*[{"id": tier.id, "name": tier.name, "description": tier.description}
|
|
697
|
+
for tier in model.service_tiers if tier.id != STANDARD_SERVICE_TIER_ID]],
|
|
698
|
+
} for model in catalog.models]
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def _text(value: Any, field: str) -> str:
|
|
702
|
+
if not isinstance(value, str) or not value.strip():
|
|
703
|
+
raise ScheduleError(f"请提供 {field}。")
|
|
704
|
+
return value.strip()
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def _target_kind(value: Any) -> str:
|
|
708
|
+
if not isinstance(value, str) or value not in {"new_topic", "binding"}:
|
|
709
|
+
raise ScheduleError("target_kind 必须为 new_topic 或 binding。")
|
|
710
|
+
return value
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def _boolean(value: Any, field: str) -> None:
|
|
714
|
+
if type(value) is not bool:
|
|
715
|
+
raise ScheduleError(f"{field} 必须为 true 或 false。")
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def _page_size(data: Mapping[str, Any]) -> int:
|
|
719
|
+
size = data.get("limit", 20)
|
|
720
|
+
if type(size) is not int or not 1 <= size <= 50:
|
|
721
|
+
raise ScheduleError("每页数量须为 1 到 50。")
|
|
722
|
+
return size
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def _fingerprint(value: object) -> str:
|
|
726
|
+
return hashlib.sha256(json.dumps(value, sort_keys=True, ensure_ascii=False).encode()).hexdigest()[:24]
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def _encode_cursor(after: str, identity: object) -> str:
|
|
730
|
+
return base64.urlsafe_b64encode(json.dumps([after, _fingerprint(identity)]).encode()).decode().rstrip("=")
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def _decode_cursor(value: Any, identity: object) -> str | None:
|
|
734
|
+
if value is None:
|
|
735
|
+
return None
|
|
736
|
+
try:
|
|
737
|
+
if not isinstance(value, str) or len(value) > 512:
|
|
738
|
+
raise ValueError
|
|
739
|
+
decoded = json.loads(base64.b64decode(value + "=" * (-len(value) % 4), altchars=b"-_", validate=True))
|
|
740
|
+
if not isinstance(decoded, list) or len(decoded) != 2 or not isinstance(decoded[0], str) or decoded[1] != _fingerprint(identity):
|
|
741
|
+
raise ValueError
|
|
742
|
+
return decoded[0]
|
|
743
|
+
except (ValueError, binascii.Error, UnicodeDecodeError) as error:
|
|
744
|
+
raise ScheduleError("分页游标无效或筛选条件已改变,请从第一页重新查询。") from error
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def _iso(stamp: float, timezone: str = "UTC") -> str:
|
|
748
|
+
return datetime.fromtimestamp(stamp, ZoneInfo(timezone)).isoformat(timespec="minutes")
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def _preview(rule: ScheduleRule, now: float) -> list[dict[str, str]]:
|
|
752
|
+
return [{"utc": _iso(stamp), "local": _iso(stamp, rule.timezone)}
|
|
753
|
+
for stamp in rule.preview(now)]
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def _failure(error: Exception) -> dict[str, Any]:
|
|
757
|
+
if isinstance(error, ProjectDeleting):
|
|
758
|
+
return {"ok": False, "error": {"code": "project_deleting", "message": "该 Project 正在删除,请等待操作完成后刷新,或选择其他可用的 Project。"}}
|
|
759
|
+
if isinstance(error, ProjectDisabled):
|
|
760
|
+
return {"ok": False, "error": {"code": "project_disabled", "message": "该 Project 已停用,请选择其他可用的 Project,或通过 /settings 重新启用后重试。"}}
|
|
761
|
+
if isinstance(error, ProjectNotFound):
|
|
762
|
+
return {"ok": False, "error": {"code": "not_found", "message": "找不到该 Project,请选择已登记的 Project,或先通过 /settings 登记并启用。"}}
|
|
763
|
+
code = getattr(error, "code", "invalid_schedule")
|
|
764
|
+
if isinstance(error, (ScheduleNotFound, ProjectNotFound, BindingNotFound, ScopeNotFound)):
|
|
765
|
+
code = "not_found"
|
|
766
|
+
return {"ok": False, "error": {"code": code, "message": str(error)}}
|