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,771 @@
|
|
|
1
|
+
"""Scheduled metadata using the BindingStore's writer, lock and transactions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import sqlite3
|
|
8
|
+
import uuid
|
|
9
|
+
from dataclasses import replace
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
from ..session_settings import SessionSettings, SessionSettingsError
|
|
13
|
+
|
|
14
|
+
from .models import (
|
|
15
|
+
Claim, MutationResult, Plan, Run, ScheduleBlockedUnknown, ScheduleConflict, ScheduleError,
|
|
16
|
+
ScheduleNotFound, ScheduleRequestConflict, ScheduleRevisionConflict,
|
|
17
|
+
ScheduleRule, ScheduleRunInProgress, has_trigger_opportunity,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from ..bindings import BindingStore
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
MAX_INSTRUCTIONS_JSON_BYTES = 48 * 1024
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_RUN_TABLE = """CREATE TABLE schedule_runs (
|
|
28
|
+
run_id TEXT PRIMARY KEY, plan_id TEXT NOT NULL REFERENCES schedule_plans(plan_id),
|
|
29
|
+
plan_revision INTEGER NOT NULL, due_at REAL NOT NULL,
|
|
30
|
+
project_alias TEXT NOT NULL, app_id TEXT NOT NULL, chat_id TEXT NOT NULL,
|
|
31
|
+
phase TEXT NOT NULL CHECK(phase IN ('claimed','publishing_topic','binding_ready','starting_turn','handed_off','released')),
|
|
32
|
+
barrier TEXT NOT NULL CHECK(barrier IN ('held','unknown','released')),
|
|
33
|
+
root_uuid TEXT NOT NULL UNIQUE, seed_uuid TEXT NOT NULL UNIQUE,
|
|
34
|
+
root_message_id TEXT, topic_id TEXT, origin_message_id TEXT,
|
|
35
|
+
binding_id TEXT, initial_turn_id TEXT, error_code TEXT, delivery_state TEXT,
|
|
36
|
+
created_at REAL NOT NULL, updated_at REAL NOT NULL,
|
|
37
|
+
missed_from REAL, missed_count INTEGER NOT NULL DEFAULT 0,
|
|
38
|
+
binding_removed INTEGER NOT NULL DEFAULT 0 CHECK(binding_removed IN (0,1)),
|
|
39
|
+
trigger_source TEXT NOT NULL DEFAULT 'scheduled' CHECK(trigger_source IN ('scheduled','manual')),
|
|
40
|
+
target_kind TEXT NOT NULL DEFAULT 'new_topic' CHECK(target_kind IN ('new_topic','binding')),
|
|
41
|
+
disposition TEXT CHECK(disposition IN ('started','steered')),
|
|
42
|
+
CHECK(target_kind = 'new_topic' OR binding_id IS NOT NULL),
|
|
43
|
+
CHECK(disposition IS NULL OR (target_kind = 'binding' AND initial_turn_id IS NOT NULL AND barrier = 'released'))
|
|
44
|
+
)"""
|
|
45
|
+
_RUN_INDEXES = (
|
|
46
|
+
"CREATE UNIQUE INDEX schedule_runs_due ON schedule_runs(plan_id, due_at) WHERE trigger_source = 'scheduled'",
|
|
47
|
+
"CREATE INDEX schedule_runs_plan ON schedule_runs(plan_id, due_at DESC, run_id DESC)",
|
|
48
|
+
"CREATE UNIQUE INDEX schedule_runs_binding ON schedule_runs(binding_id) WHERE binding_id IS NOT NULL AND target_kind = 'new_topic'",
|
|
49
|
+
"CREATE UNIQUE INDEX schedule_runs_root ON schedule_runs(app_id, chat_id, root_message_id) WHERE root_message_id IS NOT NULL AND target_kind = 'new_topic'",
|
|
50
|
+
"CREATE UNIQUE INDEX schedule_runs_topic ON schedule_runs(app_id, chat_id, topic_id) WHERE topic_id IS NOT NULL AND target_kind = 'new_topic'",
|
|
51
|
+
"CREATE UNIQUE INDEX schedule_runs_barrier ON schedule_runs(plan_id) WHERE barrier != 'released'",
|
|
52
|
+
)
|
|
53
|
+
SCHEMA = (
|
|
54
|
+
"""CREATE TABLE schedule_plans (
|
|
55
|
+
plan_id TEXT PRIMARY KEY, revision INTEGER NOT NULL CHECK(revision >= 1),
|
|
56
|
+
name TEXT NOT NULL, instructions TEXT NOT NULL, project_alias TEXT NOT NULL,
|
|
57
|
+
app_id TEXT NOT NULL, chat_id TEXT NOT NULL, schedule_json TEXT,
|
|
58
|
+
enabled INTEGER NOT NULL CHECK(enabled IN (0,1)), next_due_at REAL,
|
|
59
|
+
processed_through REAL, created_at REAL NOT NULL, updated_at REAL NOT NULL,
|
|
60
|
+
source TEXT NOT NULL, deleted INTEGER NOT NULL DEFAULT 0 CHECK(deleted IN (0,1)),
|
|
61
|
+
session_settings_json TEXT,
|
|
62
|
+
target_kind TEXT NOT NULL DEFAULT 'new_topic' CHECK(target_kind IN ('new_topic','binding')),
|
|
63
|
+
target_binding_id TEXT,
|
|
64
|
+
CHECK((target_kind = 'new_topic' AND target_binding_id IS NULL) OR
|
|
65
|
+
(target_kind = 'binding' AND target_binding_id IS NOT NULL AND session_settings_json IS NULL)),
|
|
66
|
+
CHECK(deleted = 0 OR (enabled = 0 AND instructions = '' AND schedule_json IS NULL))
|
|
67
|
+
)""",
|
|
68
|
+
"CREATE INDEX schedule_plans_due ON schedule_plans(app_id, enabled, next_due_at) WHERE deleted = 0",
|
|
69
|
+
"CREATE INDEX schedule_plans_project ON schedule_plans(project_alias, deleted)",
|
|
70
|
+
_RUN_TABLE,
|
|
71
|
+
*_RUN_INDEXES,
|
|
72
|
+
"""CREATE TABLE schedule_requests (
|
|
73
|
+
request_id TEXT PRIMARY KEY, operation TEXT NOT NULL, payload_digest TEXT NOT NULL,
|
|
74
|
+
plan_id TEXT NOT NULL, revision INTEGER NOT NULL, expires_at REAL NOT NULL,
|
|
75
|
+
run_id TEXT
|
|
76
|
+
)""",
|
|
77
|
+
)
|
|
78
|
+
TABLE_COLUMNS = {
|
|
79
|
+
"schedule_plans": {
|
|
80
|
+
"plan_id", "revision", "name", "instructions", "project_alias", "app_id", "chat_id",
|
|
81
|
+
"schedule_json", "enabled", "next_due_at", "processed_through", "created_at",
|
|
82
|
+
"updated_at", "source", "deleted", "session_settings_json", "target_kind", "target_binding_id",
|
|
83
|
+
},
|
|
84
|
+
"schedule_runs": {
|
|
85
|
+
"run_id", "plan_id", "plan_revision", "due_at", "project_alias", "app_id", "chat_id",
|
|
86
|
+
"phase", "barrier", "root_uuid", "seed_uuid", "root_message_id", "topic_id",
|
|
87
|
+
"origin_message_id", "binding_id", "initial_turn_id", "error_code", "delivery_state",
|
|
88
|
+
"created_at", "updated_at", "missed_from", "missed_count", "binding_removed", "trigger_source", "target_kind", "disposition",
|
|
89
|
+
},
|
|
90
|
+
"schedule_requests": {"request_id", "operation", "payload_digest", "plan_id", "revision", "expires_at", "run_id"},
|
|
91
|
+
}
|
|
92
|
+
_RUN_UNIQUE_INDEXES = {
|
|
93
|
+
"schedule_runs_due": (("plan_id", "due_at"), "trigger_source = 'scheduled'"),
|
|
94
|
+
"schedule_runs_binding": (("binding_id",), "binding_id is not null and target_kind = 'new_topic'"),
|
|
95
|
+
"schedule_runs_root": (("app_id", "chat_id", "root_message_id"), "root_message_id is not null and target_kind = 'new_topic'"),
|
|
96
|
+
"schedule_runs_topic": (("app_id", "chat_id", "topic_id"), "topic_id is not null and target_kind = 'new_topic'"),
|
|
97
|
+
"schedule_runs_barrier": (("plan_id",), "barrier != 'released'"),
|
|
98
|
+
}
|
|
99
|
+
PHASES = {name: index for index, name in enumerate(("claimed", "publishing_topic", "binding_ready", "starting_turn", "handed_off", "released"))}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def create_schema(connection: sqlite3.Connection) -> None:
|
|
103
|
+
for statement in SCHEMA:
|
|
104
|
+
connection.execute(statement)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def require_schema(connection: sqlite3.Connection) -> None:
|
|
108
|
+
_require_table_schema(connection)
|
|
109
|
+
trigger = next(row for row in connection.execute("PRAGMA table_info(schedule_runs)") if row["name"] == "trigger_source")
|
|
110
|
+
if trigger["type"].upper() != "TEXT" or trigger["notnull"] != 1 or trigger["dflt_value"] != "'scheduled'":
|
|
111
|
+
raise RuntimeError("current scheduling trigger source has invalid shape")
|
|
112
|
+
if connection.execute("SELECT 1 FROM schedule_runs WHERE trigger_source NOT IN ('scheduled','manual') LIMIT 1").fetchone():
|
|
113
|
+
raise RuntimeError("current scheduling trigger source values are invalid")
|
|
114
|
+
request_run = next(row for row in connection.execute("PRAGMA table_info(schedule_requests)") if row["name"] == "run_id")
|
|
115
|
+
if request_run["type"].upper() != "TEXT" or request_run["notnull"] != 0:
|
|
116
|
+
raise RuntimeError("current scheduling request Run reference has invalid shape")
|
|
117
|
+
columns = {row["name"]: row for row in connection.execute("PRAGMA table_info(schedule_plans)")}
|
|
118
|
+
if "session_settings_json" not in columns or columns["session_settings_json"]["type"].upper() != "TEXT":
|
|
119
|
+
raise RuntimeError("current Channel database is missing scheduled session settings")
|
|
120
|
+
for table in ("schedule_plans", "schedule_runs"):
|
|
121
|
+
target = next(row for row in connection.execute(f"PRAGMA table_info({table})") if row["name"] == "target_kind")
|
|
122
|
+
if target["type"].upper() != "TEXT" or target["notnull"] != 1 or target["dflt_value"] != "'new_topic'":
|
|
123
|
+
raise RuntimeError("current scheduling target kind has invalid shape")
|
|
124
|
+
if connection.execute(f"SELECT 1 FROM {table} WHERE target_kind NOT IN ('new_topic','binding') LIMIT 1").fetchone():
|
|
125
|
+
raise RuntimeError("current scheduling target kind values are invalid")
|
|
126
|
+
if connection.execute(
|
|
127
|
+
"SELECT 1 FROM schedule_runs WHERE (target_kind='binding' AND (binding_id IS NULL OR binding_id='')) "
|
|
128
|
+
"OR (disposition IS NOT NULL AND (disposition NOT IN ('started','steered') OR target_kind!='binding' "
|
|
129
|
+
"OR initial_turn_id IS NULL OR initial_turn_id='' OR barrier!='released')) LIMIT 1"
|
|
130
|
+
).fetchone():
|
|
131
|
+
raise RuntimeError("current scheduling input receipt is invalid")
|
|
132
|
+
for row in connection.execute("SELECT deleted, session_settings_json, target_kind, target_binding_id FROM schedule_plans"):
|
|
133
|
+
raw = row["session_settings_json"]
|
|
134
|
+
if (row["target_kind"] == "binding") != bool(row["target_binding_id"]):
|
|
135
|
+
raise RuntimeError("current scheduling target identity is invalid")
|
|
136
|
+
if row["target_kind"] == "binding":
|
|
137
|
+
if raw is not None:
|
|
138
|
+
raise RuntimeError("current Binding plan retains session settings")
|
|
139
|
+
continue
|
|
140
|
+
if row["deleted"]:
|
|
141
|
+
if raw is not None:
|
|
142
|
+
raise RuntimeError("current deleted plan retains session settings")
|
|
143
|
+
continue
|
|
144
|
+
try:
|
|
145
|
+
SessionSettings.from_dict(json.loads(raw))
|
|
146
|
+
except (TypeError, ValueError) as error:
|
|
147
|
+
raise RuntimeError("current scheduled session settings are invalid") from error
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _require_table_schema(connection: sqlite3.Connection) -> None:
|
|
151
|
+
primary_keys = {"schedule_plans": "plan_id", "schedule_runs": "run_id", "schedule_requests": "request_id"}
|
|
152
|
+
for table, required in TABLE_COLUMNS.items():
|
|
153
|
+
schema = connection.execute(f"PRAGMA table_info({table})").fetchall()
|
|
154
|
+
columns = {row["name"] for row in schema}
|
|
155
|
+
if not required <= columns:
|
|
156
|
+
raise RuntimeError(f"current Channel database is missing {table} columns")
|
|
157
|
+
if {row["name"] for row in schema if row["pk"]} != {primary_keys[table]}:
|
|
158
|
+
raise RuntimeError(f"current scheduling primary key has invalid shape: {table}")
|
|
159
|
+
foreign_keys = connection.execute("PRAGMA foreign_key_list(schedule_runs)").fetchall()
|
|
160
|
+
if len(foreign_keys) != 1 or any(
|
|
161
|
+
foreign_keys[0][key] != value for key, value in {
|
|
162
|
+
"table": "schedule_plans", "from": "plan_id", "to": "plan_id",
|
|
163
|
+
"on_update": "NO ACTION", "on_delete": "NO ACTION",
|
|
164
|
+
}.items()
|
|
165
|
+
):
|
|
166
|
+
raise RuntimeError("current scheduling plan foreign key has invalid shape")
|
|
167
|
+
indexes = {
|
|
168
|
+
row["name"]: row for row in connection.execute("PRAGMA index_list(schedule_runs)")
|
|
169
|
+
}
|
|
170
|
+
full_unique_keys = set()
|
|
171
|
+
for name, index in indexes.items():
|
|
172
|
+
# Names come from SQLite, but are quoted as identifiers nonetheless.
|
|
173
|
+
quoted = '"' + name.replace('"', '""') + '"'
|
|
174
|
+
columns = tuple(row["name"] for row in connection.execute(f"PRAGMA index_info({quoted})"))
|
|
175
|
+
if index["unique"] and not index["partial"]:
|
|
176
|
+
full_unique_keys.add(columns)
|
|
177
|
+
expected = _RUN_UNIQUE_INDEXES.get(name)
|
|
178
|
+
if expected is not None:
|
|
179
|
+
sql = connection.execute("SELECT sql FROM sqlite_master WHERE type='index' AND name=?", (name,)).fetchone()[0]
|
|
180
|
+
normalized = " ".join((sql or "").lower().split())
|
|
181
|
+
predicate = normalized.partition(" where ")[2]
|
|
182
|
+
if not index["unique"] or not index["partial"] or columns != expected[0] or predicate != expected[1]:
|
|
183
|
+
raise RuntimeError(f"current scheduling identity index has invalid shape: {name}")
|
|
184
|
+
missing = _RUN_UNIQUE_INDEXES.keys() - indexes.keys()
|
|
185
|
+
if missing:
|
|
186
|
+
raise RuntimeError("current scheduling identity indexes are missing: " + ", ".join(sorted(missing)))
|
|
187
|
+
required_unique = {("run_id",), ("root_uuid",), ("seed_uuid",)}
|
|
188
|
+
if ("plan_id", "due_at") in full_unique_keys:
|
|
189
|
+
raise RuntimeError("current scheduling occurrence uniqueness excludes manual runs")
|
|
190
|
+
if not required_unique <= full_unique_keys:
|
|
191
|
+
raise RuntimeError("current scheduling occurrence or publication UUID uniqueness is missing")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _json(value: object) -> str:
|
|
195
|
+
try:
|
|
196
|
+
return json.dumps(value, sort_keys=True, ensure_ascii=False, separators=(",", ":"), allow_nan=False)
|
|
197
|
+
except (TypeError, ValueError, OverflowError) as error:
|
|
198
|
+
raise ScheduleError("管理参数必须是有效 JSON,数值必须有限。") from error
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _plan(row: sqlite3.Row) -> Plan:
|
|
202
|
+
return Plan(
|
|
203
|
+
id=row["plan_id"], revision=row["revision"], name=row["name"],
|
|
204
|
+
instructions=row["instructions"], project_alias=row["project_alias"],
|
|
205
|
+
app_id=row["app_id"], chat_id=row["chat_id"],
|
|
206
|
+
schedule=ScheduleRule.from_dict(json.loads(row["schedule_json"])) if row["schedule_json"] else None,
|
|
207
|
+
enabled=bool(row["enabled"]), next_due_at=row["next_due_at"],
|
|
208
|
+
processed_through=row["processed_through"], created_at=row["created_at"],
|
|
209
|
+
updated_at=row["updated_at"], source=row["source"], deleted=bool(row["deleted"]),
|
|
210
|
+
session_settings=(SessionSettings.from_dict(json.loads(row["session_settings_json"]))
|
|
211
|
+
if row["session_settings_json"] is not None
|
|
212
|
+
else SessionSettings() if row["target_kind"] == "new_topic" else None),
|
|
213
|
+
target_kind=row["target_kind"], target_binding_id=row["target_binding_id"],
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _run(row: sqlite3.Row) -> Run:
|
|
218
|
+
fields = dict(row)
|
|
219
|
+
fields["id"] = fields.pop("run_id")
|
|
220
|
+
fields["binding_removed"] = bool(fields["binding_removed"])
|
|
221
|
+
return Run(**fields)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _has_trigger(
|
|
225
|
+
schedule_json: str | None, enabled: int, next_due_at: float | None,
|
|
226
|
+
processed_through: float | None, now: float,
|
|
227
|
+
) -> bool:
|
|
228
|
+
rule = ScheduleRule.from_dict(json.loads(schedule_json)) if schedule_json is not None else None
|
|
229
|
+
return has_trigger_opportunity(
|
|
230
|
+
rule, enabled=bool(enabled), next_due_at=next_due_at,
|
|
231
|
+
processed_through=processed_through, now=now,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class ScheduleStore:
|
|
236
|
+
"""A component of one Store; never opens or owns a database connection."""
|
|
237
|
+
|
|
238
|
+
def __init__(self, owner: BindingStore) -> None:
|
|
239
|
+
self.owner = owner
|
|
240
|
+
self._db.create_function("netizen_schedule_has_trigger", 5, _has_trigger, deterministic=True)
|
|
241
|
+
|
|
242
|
+
@property
|
|
243
|
+
def _db(self) -> sqlite3.Connection:
|
|
244
|
+
return self.owner._connection
|
|
245
|
+
|
|
246
|
+
def _now(self, now: float | None) -> float:
|
|
247
|
+
return self.owner._wall_clock() if now is None else now
|
|
248
|
+
|
|
249
|
+
def _project(self, alias: str) -> None:
|
|
250
|
+
from ..bindings import ProjectDisabled
|
|
251
|
+
self.owner.require_project_not_deleting(alias)
|
|
252
|
+
if not self.owner.get_project(alias).enabled:
|
|
253
|
+
raise ProjectDisabled(alias)
|
|
254
|
+
|
|
255
|
+
def binding_target_error(self, plan: Plan) -> str | None:
|
|
256
|
+
"""Project eligibility from exact ownership and the live Scope pointer."""
|
|
257
|
+
if plan.target_kind != "binding":
|
|
258
|
+
return None
|
|
259
|
+
return self._binding_target_error(plan.target_binding_id, plan.project_alias, plan.app_id, plan.chat_id)
|
|
260
|
+
|
|
261
|
+
def _binding_target_error(self, binding_id: str | None, project_alias: str, app_id: str, chat_id: str) -> str | None:
|
|
262
|
+
with self.owner._lock:
|
|
263
|
+
row = self._db.execute(
|
|
264
|
+
"SELECT b.project_alias, s.app_id, s.chat_id, s.active_binding_id "
|
|
265
|
+
"FROM bindings b JOIN scopes s ON s.scope_key=b.scope_key WHERE b.binding_id=?",
|
|
266
|
+
(binding_id,),
|
|
267
|
+
).fetchone()
|
|
268
|
+
if row is None:
|
|
269
|
+
return "target_missing"
|
|
270
|
+
if (row["project_alias"], row["app_id"], row["chat_id"]) != (project_alias, app_id, chat_id):
|
|
271
|
+
return "target_mismatch"
|
|
272
|
+
return None if row["active_binding_id"] == binding_id else "target_inactive"
|
|
273
|
+
|
|
274
|
+
def require_binding_target(self, plan: Plan, *, require_active: bool = True) -> None:
|
|
275
|
+
reason = self.binding_target_error(plan)
|
|
276
|
+
if reason is not None and (require_active or reason != "target_inactive"):
|
|
277
|
+
error = ScheduleConflict("目标会话不可用、已切换或已归档,请恢复原会话后再执行。")
|
|
278
|
+
error.code = reason
|
|
279
|
+
raise error
|
|
280
|
+
|
|
281
|
+
def _request(self, request_id: str, operation: str, payload: object, now: float) -> MutationResult | None:
|
|
282
|
+
if not isinstance(request_id, str) or not request_id or len(request_id) > 200:
|
|
283
|
+
raise ScheduleError("写操作需要稳定的 request_id(最多 200 字符)。")
|
|
284
|
+
self._db.execute("DELETE FROM schedule_requests WHERE expires_at <= ?", (now,))
|
|
285
|
+
digest = hashlib.sha256(_json(payload).encode()).hexdigest()
|
|
286
|
+
row = self._db.execute("SELECT * FROM schedule_requests WHERE request_id = ?", (request_id,)).fetchone()
|
|
287
|
+
if row is not None:
|
|
288
|
+
if row["operation"] != operation or row["payload_digest"] != digest:
|
|
289
|
+
raise ScheduleRequestConflict("request_id 已用于不同的操作,请核查原操作。")
|
|
290
|
+
return MutationResult(row["plan_id"], row["revision"], True, row["run_id"])
|
|
291
|
+
return None
|
|
292
|
+
|
|
293
|
+
def _remember(self, request_id: str, operation: str, payload: object, result: MutationResult, now: float) -> None:
|
|
294
|
+
self._db.execute(
|
|
295
|
+
"INSERT INTO schedule_requests(request_id,operation,payload_digest,plan_id,revision,expires_at,run_id) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
296
|
+
(request_id, operation, hashlib.sha256(_json(payload).encode()).hexdigest(), result.plan_id, result.revision, now + 7 * 86400, result.run_id),
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
def lookup_request(self, request_id: str, operation: str, payload: object, *, now: float | None = None) -> MutationResult | None:
|
|
300
|
+
with self.owner._transaction():
|
|
301
|
+
return self._request(request_id, operation, payload, self._now(now))
|
|
302
|
+
|
|
303
|
+
@staticmethod
|
|
304
|
+
def _validate(plan: Plan, now: float, *, require_future: bool = True) -> None:
|
|
305
|
+
for name, value, maximum in (("名称", plan.name, 200), ("执行指令", plan.instructions, 32000), ("Project", plan.project_alias, 200), ("App ID", plan.app_id, 200), ("群 ID", plan.chat_id, 200)):
|
|
306
|
+
if not isinstance(value, str) or not value.strip() or len(value) > maximum:
|
|
307
|
+
raise ScheduleError(f"{name}不能为空且不能超过 {maximum} 字符。")
|
|
308
|
+
if len(_json(plan.instructions).encode("utf-8")) > MAX_INSTRUCTIONS_JSON_BYTES:
|
|
309
|
+
raise ScheduleError("执行指令过长,请缩短或引用文件/文档。")
|
|
310
|
+
if type(plan.enabled) is not bool or not isinstance(plan.schedule, ScheduleRule):
|
|
311
|
+
raise ScheduleError("计划需要有效规则和布尔 enabled。")
|
|
312
|
+
if plan.target_kind == "new_topic":
|
|
313
|
+
if plan.target_binding_id is not None or not isinstance(plan.session_settings, SessionSettings):
|
|
314
|
+
raise ScheduleError("新话题计划需要独立会话设置,不能指定原会话。")
|
|
315
|
+
elif plan.target_kind == "binding":
|
|
316
|
+
if not isinstance(plan.target_binding_id, str) or not plan.target_binding_id or plan.session_settings is not None:
|
|
317
|
+
raise ScheduleError("原会话计划需要准确 Binding,并沿用该会话设置。")
|
|
318
|
+
else:
|
|
319
|
+
raise ScheduleError("执行目标仅支持新话题或原会话。")
|
|
320
|
+
if not isinstance(plan.source, str) or len(plan.source) > 1000:
|
|
321
|
+
raise ScheduleError("来源说明最多 1000 字符。")
|
|
322
|
+
boundary = max(now, plan.processed_through) if plan.processed_through is not None else now
|
|
323
|
+
if require_future and plan.schedule.next_after(boundary) is None:
|
|
324
|
+
raise ScheduleError("计划没有可用的未来触发时间;请调整时间后启用。")
|
|
325
|
+
# Saving a definition must not succeed and then fail when the response
|
|
326
|
+
# renders its common three-occurrence preview.
|
|
327
|
+
plan.schedule.preview(boundary)
|
|
328
|
+
|
|
329
|
+
def create(self, *, name: str, instructions: str, project_alias: str, app_id: str, chat_id: str, schedule: ScheduleRule, request_id: str, enabled: bool = True, source: str = "", now: float | None = None, request_payload: object | None = None, session_settings: SessionSettings | None = SessionSettings(), target_kind: str = "new_topic", target_binding_id: str | None = None) -> MutationResult:
|
|
330
|
+
now = self._now(now)
|
|
331
|
+
if session_settings is not None and not isinstance(session_settings, SessionSettings):
|
|
332
|
+
raise ScheduleError("计划需要有效的会话设置。")
|
|
333
|
+
payload = dict(name=name, instructions=instructions, project_alias=project_alias, app_id=app_id, chat_id=chat_id, schedule=schedule.to_dict(), enabled=enabled, source=source, target_kind=target_kind, target_binding_id=target_binding_id)
|
|
334
|
+
payload["session_settings"] = session_settings.to_dict() if session_settings is not None else None
|
|
335
|
+
if request_payload is not None:
|
|
336
|
+
payload = request_payload
|
|
337
|
+
with self.owner._transaction():
|
|
338
|
+
if result := self._request(request_id, "create", payload, now):
|
|
339
|
+
return result
|
|
340
|
+
plan = Plan(str(uuid.uuid4()), 1, name, instructions, project_alias, app_id, chat_id, schedule, enabled, schedule.next_after(now) if enabled else None, None, now, now, source, session_settings=session_settings, target_kind=target_kind, target_binding_id=target_binding_id)
|
|
341
|
+
self._validate(plan, now)
|
|
342
|
+
self._project(project_alias)
|
|
343
|
+
self.require_binding_target(plan, require_active=False)
|
|
344
|
+
self._db.execute(
|
|
345
|
+
"INSERT INTO schedule_plans(plan_id,revision,name,instructions,project_alias,app_id,chat_id,schedule_json,enabled,next_due_at,processed_through,created_at,updated_at,source,deleted,session_settings_json,target_kind,target_binding_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?)",
|
|
346
|
+
(plan.id, 1, name, instructions, project_alias, app_id, chat_id, _json(schedule.to_dict()), int(enabled), plan.next_due_at, None, now, now, source, _json(session_settings.to_dict()) if session_settings is not None else None, target_kind, target_binding_id),
|
|
347
|
+
)
|
|
348
|
+
result = MutationResult(plan.id, 1)
|
|
349
|
+
self._remember(request_id, "create", payload, result, now)
|
|
350
|
+
return result
|
|
351
|
+
|
|
352
|
+
def update(self, plan_id: str, *, expected_revision: int, request_id: str, changes: dict[str, Any], now: float | None = None, request_payload: object | None = None) -> MutationResult:
|
|
353
|
+
allowed = {"name", "instructions", "project_alias", "chat_id", "schedule", "enabled", "session_settings", "target_kind", "target_binding_id"}
|
|
354
|
+
if not isinstance(changes, dict) or not changes or set(changes) - allowed:
|
|
355
|
+
raise ScheduleError("更新字段为空或包含不可修改字段。")
|
|
356
|
+
changes = dict(changes)
|
|
357
|
+
if isinstance(changes.get("schedule"), dict):
|
|
358
|
+
changes["schedule"] = ScheduleRule.from_dict(changes["schedule"])
|
|
359
|
+
payload_changes = {key: value.to_dict() if isinstance(value, (ScheduleRule, SessionSettings)) else value for key, value in changes.items()}
|
|
360
|
+
payload = dict(plan_id=plan_id, expected_revision=expected_revision, changes=payload_changes)
|
|
361
|
+
if request_payload is not None:
|
|
362
|
+
payload = request_payload
|
|
363
|
+
now = self._now(now)
|
|
364
|
+
with self.owner._transaction():
|
|
365
|
+
if result := self._request(request_id, "update", payload, now):
|
|
366
|
+
return result
|
|
367
|
+
current = self.get(plan_id)
|
|
368
|
+
if type(expected_revision) is not int or current.revision != expected_revision:
|
|
369
|
+
raise ScheduleRevisionConflict("计划已经修改,请刷新后重试。")
|
|
370
|
+
if any(field in changes and changes[field] != getattr(current, field)
|
|
371
|
+
for field in ("target_kind", "target_binding_id")):
|
|
372
|
+
raise ScheduleConflict("计划的执行目标类型和原会话不可修改,请新建计划。")
|
|
373
|
+
if "session_settings" in changes and isinstance(changes["session_settings"], dict):
|
|
374
|
+
try:
|
|
375
|
+
changes["session_settings"] = (current.session_settings or SessionSettings()).merge(changes["session_settings"])
|
|
376
|
+
except SessionSettingsError as error:
|
|
377
|
+
raise ScheduleError(str(error)) from error
|
|
378
|
+
changed = replace(current, **changes, revision=current.revision + 1, updated_at=now)
|
|
379
|
+
schedule_changed = changed.schedule != current.schedule
|
|
380
|
+
enabled_changed = changed.enabled != current.enabled
|
|
381
|
+
enabling = enabled_changed and changed.enabled
|
|
382
|
+
# A full form may carry unchanged fields. Only a real enable or a
|
|
383
|
+
# new once time requires the future; editing a recurring cutoff may
|
|
384
|
+
# intentionally end future triggering.
|
|
385
|
+
self._validate(
|
|
386
|
+
changed, now,
|
|
387
|
+
require_future=enabling or (schedule_changed and isinstance(changed.schedule, ScheduleRule) and changed.schedule.kind == "once"),
|
|
388
|
+
)
|
|
389
|
+
self.require_binding_target(changed, require_active=False)
|
|
390
|
+
if changed.project_alias != current.project_alias or enabling:
|
|
391
|
+
self._project(changed.project_alias)
|
|
392
|
+
else:
|
|
393
|
+
self.owner.require_project_not_deleting(changed.project_alias)
|
|
394
|
+
self.owner.get_project(changed.project_alias)
|
|
395
|
+
assert changed.schedule is not None
|
|
396
|
+
boundary = max(now, current.processed_through if current.processed_through is not None else now)
|
|
397
|
+
if schedule_changed or enabled_changed:
|
|
398
|
+
due = changed.schedule.next_after(boundary) if changed.enabled else None
|
|
399
|
+
else:
|
|
400
|
+
# Metadata-only changes must not erase an unclaimed occurrence
|
|
401
|
+
# that is still within the normal scheduler grace period.
|
|
402
|
+
due = current.next_due_at
|
|
403
|
+
self._db.execute(
|
|
404
|
+
"UPDATE schedule_plans SET revision=?, name=?, instructions=?, project_alias=?, chat_id=?, schedule_json=?, enabled=?, next_due_at=?, updated_at=?, session_settings_json=?, target_kind=?, target_binding_id=? WHERE plan_id=?",
|
|
405
|
+
(changed.revision, changed.name, changed.instructions, changed.project_alias, changed.chat_id, _json(changed.schedule.to_dict()), int(changed.enabled), due, now, _json(changed.session_settings.to_dict()) if changed.session_settings is not None else None, changed.target_kind, changed.target_binding_id, plan_id),
|
|
406
|
+
)
|
|
407
|
+
result = MutationResult(plan_id, changed.revision)
|
|
408
|
+
self._remember(request_id, "update", payload, result, now)
|
|
409
|
+
return result
|
|
410
|
+
|
|
411
|
+
def delete(self, plan_id: str, *, expected_revision: int, request_id: str, now: float | None = None, request_payload: object | None = None) -> MutationResult:
|
|
412
|
+
now = self._now(now)
|
|
413
|
+
payload = dict(plan_id=plan_id, expected_revision=expected_revision)
|
|
414
|
+
if request_payload is not None:
|
|
415
|
+
payload = request_payload
|
|
416
|
+
with self.owner._transaction():
|
|
417
|
+
if result := self._request(request_id, "delete", payload, now):
|
|
418
|
+
return result
|
|
419
|
+
current = self.get(plan_id)
|
|
420
|
+
self.owner.require_project_not_deleting(current.project_alias)
|
|
421
|
+
if type(expected_revision) is not int or current.revision != expected_revision:
|
|
422
|
+
raise ScheduleRevisionConflict("计划已经修改,请刷新后重试。")
|
|
423
|
+
self._tombstone("plan_id = ?", (plan_id,), now)
|
|
424
|
+
result = MutationResult(plan_id, current.revision + 1)
|
|
425
|
+
self._remember(request_id, "delete", payload, result, now)
|
|
426
|
+
return result
|
|
427
|
+
|
|
428
|
+
def _tombstone(self, where: str, parameters: tuple[object, ...], now: float) -> None:
|
|
429
|
+
self._db.execute(
|
|
430
|
+
"UPDATE schedule_plans SET deleted=1, enabled=0, name='', instructions='', schedule_json=NULL, session_settings_json=NULL, source='', next_due_at=NULL, revision=revision+1, updated_at=? WHERE deleted=0 AND " + where,
|
|
431
|
+
(now, *parameters),
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
def get(self, plan_id: str, *, include_deleted: bool = False) -> Plan:
|
|
435
|
+
with self.owner._lock:
|
|
436
|
+
row = self._db.execute("SELECT * FROM schedule_plans WHERE plan_id=?" + ("" if include_deleted else " AND deleted=0"), (plan_id,)).fetchone()
|
|
437
|
+
if row is None:
|
|
438
|
+
raise ScheduleNotFound("定时计划不存在或已删除。")
|
|
439
|
+
return _plan(row)
|
|
440
|
+
|
|
441
|
+
def list(self, *, app_id: str, chat_id: str | None = None, project_alias: str | None = None, enabled: bool | None = None, ended: bool | None = None, now: float | None = None, name: str | None = None, after: str | None = None, limit: int = 20, target_kind: str | None = None, target_binding_id: str | None = None) -> tuple[Plan, ...]:
|
|
442
|
+
if type(limit) is not int or not 1 <= limit <= 1001:
|
|
443
|
+
raise ScheduleError("列表数量必须在 1 到 1001 之间。")
|
|
444
|
+
if ended is not None and type(ended) is not bool:
|
|
445
|
+
raise ScheduleError("ended 必须为 true、false 或 null。")
|
|
446
|
+
where, values = ["app_id=?", "deleted=0"], [app_id]
|
|
447
|
+
for key, value in (("chat_id", chat_id), ("project_alias", project_alias), ("enabled", enabled), ("target_kind", target_kind), ("target_binding_id", target_binding_id)):
|
|
448
|
+
if value is not None:
|
|
449
|
+
where.append(f"{key}=?")
|
|
450
|
+
values.append(value)
|
|
451
|
+
if ended is not None:
|
|
452
|
+
# The same pure time calculation serves list filtering and the
|
|
453
|
+
# management projection. Any pending Run keeps the plan unfinished,
|
|
454
|
+
# even when a more recent occurrence was skipped. Filter before LIMIT.
|
|
455
|
+
where.append(
|
|
456
|
+
"(netizen_schedule_has_trigger(schedule_json, enabled, next_due_at, processed_through, ?) = 0 "
|
|
457
|
+
"AND NOT EXISTS (SELECT 1 FROM schedule_runs WHERE schedule_runs.plan_id=schedule_plans.plan_id "
|
|
458
|
+
"AND barrier != 'released')) = ?"
|
|
459
|
+
)
|
|
460
|
+
values.extend((self._now(now), ended))
|
|
461
|
+
if after:
|
|
462
|
+
where.append("plan_id > ?")
|
|
463
|
+
values.append(after)
|
|
464
|
+
if name is not None:
|
|
465
|
+
where.append("instr(name, ?) > 0")
|
|
466
|
+
values.append(name)
|
|
467
|
+
with self.owner._lock:
|
|
468
|
+
rows = self._db.execute("SELECT * FROM schedule_plans WHERE " + " AND ".join(where) + " ORDER BY plan_id LIMIT ?", (*values, limit)).fetchall()
|
|
469
|
+
return tuple(_plan(row) for row in rows)
|
|
470
|
+
|
|
471
|
+
def due_plans(self, *, app_id: str, now: float | None = None, limit: int = 100) -> tuple[Plan, ...]:
|
|
472
|
+
with self.owner._lock:
|
|
473
|
+
rows = self._db.execute("SELECT * FROM schedule_plans WHERE app_id=? AND deleted=0 AND enabled=1 AND next_due_at <= ? ORDER BY next_due_at, plan_id LIMIT ?", (app_id, self._now(now), limit)).fetchall()
|
|
474
|
+
return tuple(_plan(row) for row in rows)
|
|
475
|
+
|
|
476
|
+
def get_run(self, run_id: str) -> Run:
|
|
477
|
+
with self.owner._lock:
|
|
478
|
+
row = self._db.execute("SELECT * FROM schedule_runs WHERE run_id=?", (run_id,)).fetchone()
|
|
479
|
+
if row is None:
|
|
480
|
+
raise ScheduleNotFound("定时执行不存在。")
|
|
481
|
+
return _run(row)
|
|
482
|
+
|
|
483
|
+
def list_runs(self, plan_id: str, *, after: str | None = None, limit: int = 20) -> tuple[Run, ...]:
|
|
484
|
+
if type(limit) is not int or not 1 <= limit <= 1001:
|
|
485
|
+
raise ScheduleError("列表数量必须在 1 到 1001 之间。")
|
|
486
|
+
where, values = "plan_id=?", [plan_id]
|
|
487
|
+
with self.owner._lock:
|
|
488
|
+
if after:
|
|
489
|
+
cursor = self.get_run(after)
|
|
490
|
+
if cursor.plan_id != plan_id:
|
|
491
|
+
raise ScheduleError("执行游标不属于该计划。")
|
|
492
|
+
where += " AND (due_at < ? OR (due_at = ? AND run_id < ?))"
|
|
493
|
+
values.extend((cursor.due_at, cursor.due_at, cursor.id))
|
|
494
|
+
rows = self._db.execute("SELECT * FROM schedule_runs WHERE " + where + " ORDER BY due_at DESC, run_id DESC LIMIT ?", (*values, limit)).fetchall()
|
|
495
|
+
return tuple(_run(row) for row in rows)
|
|
496
|
+
|
|
497
|
+
def pending_for_plan(self, plan_id: str) -> Run | None:
|
|
498
|
+
with self.owner._lock:
|
|
499
|
+
row = self._db.execute("SELECT * FROM schedule_runs WHERE plan_id=? AND barrier != 'released' LIMIT 1", (plan_id,)).fetchone()
|
|
500
|
+
return _run(row) if row else None
|
|
501
|
+
|
|
502
|
+
def project_pending_runs(self, alias: str) -> tuple[Run, ...]:
|
|
503
|
+
with self.owner._lock:
|
|
504
|
+
rows = self._db.execute(
|
|
505
|
+
"SELECT * FROM schedule_runs WHERE project_alias=? "
|
|
506
|
+
"AND (barrier != 'released' OR (target_kind='new_topic' AND error_code='publishing_unknown')) "
|
|
507
|
+
"ORDER BY run_id", (alias,),
|
|
508
|
+
).fetchall()
|
|
509
|
+
return tuple(_run(row) for row in rows)
|
|
510
|
+
|
|
511
|
+
def pending_runs(self, *, project_alias: str | None = None, limit: int | None = None) -> tuple[Run, ...]:
|
|
512
|
+
with self.owner._lock:
|
|
513
|
+
where = "barrier != 'released'" + (" AND project_alias=?" if project_alias else "")
|
|
514
|
+
parameters: tuple[Any, ...] = (project_alias,) if project_alias else ()
|
|
515
|
+
if limit is not None:
|
|
516
|
+
if type(limit) is not int or not 1 <= limit <= 1001:
|
|
517
|
+
raise ScheduleError("列表数量必须在 1 到 1001 之间。")
|
|
518
|
+
parameters += (limit,)
|
|
519
|
+
rows = self._db.execute("SELECT * FROM schedule_runs WHERE " + where + " ORDER BY due_at, run_id" + (" LIMIT ?" if limit is not None else ""), parameters).fetchall()
|
|
520
|
+
return tuple(_run(row) for row in rows)
|
|
521
|
+
|
|
522
|
+
def pending_route(self, *, app_id: str, chat_id: str, topic_id: str | None = None, root_message_id: str | None = None) -> Run | None:
|
|
523
|
+
if not topic_id and not root_message_id:
|
|
524
|
+
return None
|
|
525
|
+
with self.owner._lock:
|
|
526
|
+
row = self._db.execute(
|
|
527
|
+
"SELECT * FROM schedule_runs WHERE app_id=? AND chat_id=? AND target_kind='new_topic' AND barrier != 'released' AND phase IN ('claimed','publishing_topic','binding_ready','starting_turn') AND (topic_id=? OR root_message_id=?) LIMIT 1",
|
|
528
|
+
(app_id, chat_id, topic_id, root_message_id),
|
|
529
|
+
).fetchone()
|
|
530
|
+
return _run(row) if row else None
|
|
531
|
+
|
|
532
|
+
def _insert_run(self, plan: Plan, due_at: float, now: float, *, reason: str | None = None, missed_from: float | None = None, missed_count: int = 0, trigger_source: str = "scheduled") -> Run:
|
|
533
|
+
run_id = str(uuid.uuid4())
|
|
534
|
+
topic_id = None
|
|
535
|
+
if plan.target_binding_id is not None:
|
|
536
|
+
row = self._db.execute(
|
|
537
|
+
"SELECT s.topic_id FROM bindings b JOIN scopes s ON s.scope_key=b.scope_key WHERE b.binding_id=?",
|
|
538
|
+
(plan.target_binding_id,),
|
|
539
|
+
).fetchone()
|
|
540
|
+
topic_id = row["topic_id"] if row else None
|
|
541
|
+
self._db.execute(
|
|
542
|
+
"INSERT INTO schedule_runs(run_id,plan_id,plan_revision,due_at,project_alias,app_id,chat_id,phase,barrier,root_uuid,seed_uuid,error_code,created_at,updated_at,missed_from,missed_count,trigger_source,target_kind,binding_id,topic_id) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
543
|
+
(run_id, plan.id, plan.revision, due_at, plan.project_alias, plan.app_id, plan.chat_id, "released" if reason else "claimed", "released" if reason else "held", str(uuid.uuid4()), str(uuid.uuid4()), reason, now, now, missed_from, missed_count, trigger_source, plan.target_kind, plan.target_binding_id, topic_id),
|
|
544
|
+
)
|
|
545
|
+
return self.get_run(run_id)
|
|
546
|
+
|
|
547
|
+
def claim_manual(self, plan_id: str, *, app_id: str, expected_revision: int, request_id: str, request_payload: object | None = None, now: float | None = None) -> tuple[MutationResult, Claim | None]:
|
|
548
|
+
"""Claim the saved plan once without consuming a scheduled occurrence."""
|
|
549
|
+
now = self._now(now)
|
|
550
|
+
payload = request_payload if request_payload is not None else dict(
|
|
551
|
+
plan_id=plan_id, app_id=app_id, expected_revision=expected_revision,
|
|
552
|
+
)
|
|
553
|
+
with self.owner._transaction():
|
|
554
|
+
if result := self._request(request_id, "run_now", payload, now):
|
|
555
|
+
return result, None
|
|
556
|
+
plan = self.get(plan_id)
|
|
557
|
+
if plan.app_id != app_id:
|
|
558
|
+
raise ScheduleNotFound("定时计划不存在或已删除。")
|
|
559
|
+
if type(expected_revision) is not int or plan.revision != expected_revision:
|
|
560
|
+
raise ScheduleRevisionConflict("计划已经修改,请刷新后重试。")
|
|
561
|
+
self._project(plan.project_alias)
|
|
562
|
+
self.require_binding_target(plan)
|
|
563
|
+
pending = self.pending_for_plan(plan.id)
|
|
564
|
+
if pending is not None:
|
|
565
|
+
if pending.barrier == "unknown":
|
|
566
|
+
raise ScheduleBlockedUnknown("上次执行状态待确认,请先刷新核查后再运行。")
|
|
567
|
+
raise ScheduleRunInProgress("该计划已有一次执行尚未结束,请结束后再运行。")
|
|
568
|
+
run = self._insert_run(plan, now, now, trigger_source="manual")
|
|
569
|
+
result = MutationResult(plan.id, plan.revision, run_id=run.id)
|
|
570
|
+
self._remember(request_id, "run_now", payload, result, now)
|
|
571
|
+
self._prune(plan.id)
|
|
572
|
+
return result, Claim(run, plan)
|
|
573
|
+
|
|
574
|
+
def claim_due(self, plan_id: str, *, app_id: str, now: float | None = None, recover: bool = False) -> Claim | None:
|
|
575
|
+
now = self._now(now)
|
|
576
|
+
with self.owner._transaction():
|
|
577
|
+
plan = self.get(plan_id, include_deleted=True)
|
|
578
|
+
due = plan.next_due_at
|
|
579
|
+
if plan.deleted or not plan.enabled or plan.app_id != app_id or due is None or due > now:
|
|
580
|
+
return None
|
|
581
|
+
assert plan.schedule is not None
|
|
582
|
+
self.owner.require_project_not_deleting(plan.project_alias)
|
|
583
|
+
high = plan.processed_through
|
|
584
|
+
if high is not None and due <= high:
|
|
585
|
+
self._db.execute("UPDATE schedule_plans SET next_due_at=? WHERE plan_id=?", (plan.schedule.next_after(max(now, high)), plan.id))
|
|
586
|
+
return None
|
|
587
|
+
last, count = plan.schedule.through(due, now)
|
|
588
|
+
if count == 0:
|
|
589
|
+
self._db.execute("UPDATE schedule_plans SET next_due_at=NULL WHERE plan_id=?", (plan.id,))
|
|
590
|
+
return None
|
|
591
|
+
reason: str | None = None
|
|
592
|
+
if recover or now - last > 60:
|
|
593
|
+
reason = "missed"
|
|
594
|
+
else:
|
|
595
|
+
# Persist one compact range for older points, claim only the
|
|
596
|
+
# latest eligible occurrence within the one-minute grace.
|
|
597
|
+
if count > 1:
|
|
598
|
+
previous, older_count = plan.schedule.through(due, last - 0.000001)
|
|
599
|
+
self._insert_run(plan, previous, now, reason="missed", missed_from=due, missed_count=older_count)
|
|
600
|
+
project = self.owner.get_project(plan.project_alias, include_deleted=True)
|
|
601
|
+
pending = self._db.execute("SELECT barrier FROM schedule_runs WHERE plan_id=? AND barrier != 'released' LIMIT 1", (plan.id,)).fetchone()
|
|
602
|
+
if not project.enabled or project.deleted:
|
|
603
|
+
reason = "project_disabled"
|
|
604
|
+
elif target_error := self.binding_target_error(plan):
|
|
605
|
+
reason = target_error
|
|
606
|
+
elif pending:
|
|
607
|
+
reason = "blocked_unknown" if pending["barrier"] == "unknown" else "skipped_busy"
|
|
608
|
+
run = self._insert_run(plan, last, now, reason=reason, missed_from=due if reason == "missed" else None, missed_count=count if reason == "missed" else 0)
|
|
609
|
+
self._db.execute(
|
|
610
|
+
"UPDATE schedule_plans SET next_due_at=?, processed_through=? WHERE plan_id=?",
|
|
611
|
+
(plan.schedule.next_after(max(now, last)), last, plan.id),
|
|
612
|
+
)
|
|
613
|
+
self._prune(plan.id)
|
|
614
|
+
return Claim(run, plan) if reason is None else None
|
|
615
|
+
|
|
616
|
+
def _set_run(self, run_id: str, **changes: Any) -> Run:
|
|
617
|
+
allowed = {"phase", "barrier", "root_message_id", "topic_id", "origin_message_id", "binding_id", "initial_turn_id", "error_code", "delivery_state", "binding_removed", "disposition"}
|
|
618
|
+
if set(changes) - allowed:
|
|
619
|
+
raise ScheduleError("不支持的执行阶段字段。")
|
|
620
|
+
current = self.get_run(run_id)
|
|
621
|
+
if "phase" in changes:
|
|
622
|
+
phase = changes["phase"]
|
|
623
|
+
if phase not in PHASES:
|
|
624
|
+
raise ScheduleError("无效交接阶段。")
|
|
625
|
+
if PHASES[phase] < PHASES[current.phase]:
|
|
626
|
+
changes.pop("phase")
|
|
627
|
+
if "barrier" in changes and changes["barrier"] not in {"held", "unknown", "released"}:
|
|
628
|
+
raise ScheduleError("无效调度 barrier。")
|
|
629
|
+
if current.barrier == "released":
|
|
630
|
+
changes.pop("barrier", None)
|
|
631
|
+
for field in ("root_message_id", "topic_id", "origin_message_id", "binding_id", "initial_turn_id"):
|
|
632
|
+
if field in changes:
|
|
633
|
+
value, previous = changes[field], getattr(current, field)
|
|
634
|
+
if not isinstance(value, str) or not value or (previous is not None and previous != value):
|
|
635
|
+
raise ScheduleConflict(f"{field} 是不可替换的 exact identity。")
|
|
636
|
+
if "binding_removed" in changes and (type(changes["binding_removed"]) is not bool or (current.binding_removed and not changes["binding_removed"])):
|
|
637
|
+
raise ScheduleConflict("会话移除事实不可撤销。")
|
|
638
|
+
if "disposition" in changes and (
|
|
639
|
+
current.target_kind != "binding" or changes["disposition"] not in {"started", "steered"}
|
|
640
|
+
or (current.disposition is not None and changes["disposition"] != current.disposition)
|
|
641
|
+
):
|
|
642
|
+
raise ScheduleConflict("输入接收回执不可替换。")
|
|
643
|
+
if changes.get("barrier") == "released" and not (
|
|
644
|
+
current.target_kind == "binding" and (current.disposition or changes.get("disposition"))
|
|
645
|
+
):
|
|
646
|
+
changes["phase"] = "released"
|
|
647
|
+
if changes:
|
|
648
|
+
changes["updated_at"] = self._now(None)
|
|
649
|
+
self._db.execute("UPDATE schedule_runs SET " + ",".join(f"{key}=?" for key in changes) + " WHERE run_id=?", (*changes.values(), run_id))
|
|
650
|
+
return self.get_run(run_id)
|
|
651
|
+
|
|
652
|
+
def set_run(self, run_id: str, **changes: Any) -> Run:
|
|
653
|
+
try:
|
|
654
|
+
with self.owner._transaction():
|
|
655
|
+
run = self._set_run(run_id, **changes)
|
|
656
|
+
if "delivery_state" in changes:
|
|
657
|
+
self._prune(run.plan_id)
|
|
658
|
+
return run
|
|
659
|
+
except sqlite3.IntegrityError as error:
|
|
660
|
+
raise ScheduleConflict("交接引用已属于其他定时执行或阶段不合法。") from error
|
|
661
|
+
|
|
662
|
+
def begin_publication(self, run_id: str) -> Run:
|
|
663
|
+
"""Reserve the only publisher before its first external side effect."""
|
|
664
|
+
with self.owner._transaction():
|
|
665
|
+
run = self.get_run(run_id)
|
|
666
|
+
if (
|
|
667
|
+
run.phase != "claimed" or run.barrier != "held"
|
|
668
|
+
or any((run.root_message_id, run.origin_message_id, run.initial_turn_id))
|
|
669
|
+
or (run.target_kind == "new_topic" and any((run.topic_id, run.binding_id)))
|
|
670
|
+
):
|
|
671
|
+
raise ScheduleConflict("该次定时执行已经进入发布交接。")
|
|
672
|
+
return self._set_run(run_id, phase="publishing_topic")
|
|
673
|
+
|
|
674
|
+
def begin_binding_submission(self, run_id: str, binding_id: str) -> Run:
|
|
675
|
+
"""Validate the frozen target immediately before ordinary input admission."""
|
|
676
|
+
with self.owner._transaction():
|
|
677
|
+
run = self.get_run(run_id)
|
|
678
|
+
if (run.target_kind != "binding" or run.binding_id != binding_id
|
|
679
|
+
or run.barrier != "held" or run.phase != "publishing_topic"
|
|
680
|
+
or not run.origin_message_id or run.initial_turn_id is not None):
|
|
681
|
+
raise ScheduleConflict("原会话输入交接不匹配。")
|
|
682
|
+
self._project(run.project_alias)
|
|
683
|
+
reason = self._binding_target_error(binding_id, run.project_alias, run.app_id, run.chat_id)
|
|
684
|
+
if reason is not None:
|
|
685
|
+
error = ScheduleConflict("定时任务的原会话已切换、归档或删除,本次未提交。")
|
|
686
|
+
error.code = reason
|
|
687
|
+
raise error
|
|
688
|
+
scope = self.owner.get_scope(self.owner.get(binding_id).scope_key)
|
|
689
|
+
if scope.topic_id != run.topic_id:
|
|
690
|
+
raise ScheduleConflict("定时任务的原会话位置不匹配。")
|
|
691
|
+
return self._set_run(run_id, phase="starting_turn")
|
|
692
|
+
|
|
693
|
+
def finish_binding_submission(self, run_id: str, turn_id: str, disposition: str) -> Run:
|
|
694
|
+
"""Persist an exact accepted input receipt; future occurrences are independent."""
|
|
695
|
+
with self.owner._transaction():
|
|
696
|
+
run = self.get_run(run_id)
|
|
697
|
+
if run.target_kind != "binding" or run.phase not in {"starting_turn", "handed_off", "released"}:
|
|
698
|
+
raise ScheduleConflict("原会话输入接收回执不匹配。")
|
|
699
|
+
run = self._set_run(run_id, initial_turn_id=turn_id, disposition=disposition,
|
|
700
|
+
phase="handed_off", barrier="released")
|
|
701
|
+
self._prune(run.plan_id)
|
|
702
|
+
return run
|
|
703
|
+
|
|
704
|
+
def abandon_pending_deliveries(self) -> None:
|
|
705
|
+
"""Startup only: a previous process's final receipt cannot be resumed."""
|
|
706
|
+
with self.owner._transaction():
|
|
707
|
+
rows = self._db.execute(
|
|
708
|
+
"SELECT DISTINCT plan_id FROM schedule_runs "
|
|
709
|
+
"WHERE target_kind='new_topic' AND initial_turn_id IS NOT NULL AND delivery_state IS NULL"
|
|
710
|
+
).fetchall()
|
|
711
|
+
self._db.execute(
|
|
712
|
+
"UPDATE schedule_runs SET delivery_state='unknown', updated_at=? "
|
|
713
|
+
"WHERE target_kind='new_topic' AND initial_turn_id IS NOT NULL AND delivery_state IS NULL",
|
|
714
|
+
(self._now(None),),
|
|
715
|
+
)
|
|
716
|
+
for row in rows:
|
|
717
|
+
self._prune(row["plan_id"])
|
|
718
|
+
|
|
719
|
+
def release(self, run_id: str, *, error_code: str | None = None) -> Run:
|
|
720
|
+
"""Caller must possess no-start, exact terminal, or lifecycle proof."""
|
|
721
|
+
with self.owner._transaction():
|
|
722
|
+
changes: dict[str, Any] = {"barrier": "released"}
|
|
723
|
+
if error_code is not None:
|
|
724
|
+
changes["error_code"] = error_code
|
|
725
|
+
run = self._set_run(run_id, **changes)
|
|
726
|
+
self._prune(run.plan_id)
|
|
727
|
+
return run
|
|
728
|
+
|
|
729
|
+
def _release_binding(self, binding_id: str, *, binding_removed: bool) -> None:
|
|
730
|
+
rows = self._db.execute("SELECT run_id,plan_id FROM schedule_runs WHERE binding_id=? AND (target_kind='new_topic' OR ?)", (binding_id, binding_removed)).fetchall()
|
|
731
|
+
for row in rows:
|
|
732
|
+
self._set_run(row["run_id"], barrier="released", binding_removed=binding_removed)
|
|
733
|
+
self._prune(row["plan_id"])
|
|
734
|
+
if binding_removed:
|
|
735
|
+
self._tombstone("target_kind='binding' AND target_binding_id=?", (binding_id,), self._now(None))
|
|
736
|
+
|
|
737
|
+
def _resume_binding(self, binding_id: str) -> None:
|
|
738
|
+
"""Within activation's transaction, discard due points from the inactive interval."""
|
|
739
|
+
now = self._now(None)
|
|
740
|
+
rows = self._db.execute(
|
|
741
|
+
"SELECT * FROM schedule_plans WHERE target_kind='binding' AND target_binding_id=? "
|
|
742
|
+
"AND deleted=0 AND enabled=1 AND next_due_at <= ?", (binding_id, now),
|
|
743
|
+
).fetchall()
|
|
744
|
+
for row in rows:
|
|
745
|
+
plan = _plan(row)
|
|
746
|
+
assert plan.schedule is not None and plan.next_due_at is not None
|
|
747
|
+
last, count = plan.schedule.through(plan.next_due_at, now)
|
|
748
|
+
high = max(last, plan.processed_through) if plan.processed_through is not None else last
|
|
749
|
+
if count and (plan.processed_through is None or last > plan.processed_through):
|
|
750
|
+
self._insert_run(plan, last, now, reason="missed", missed_from=plan.next_due_at, missed_count=count)
|
|
751
|
+
self._db.execute(
|
|
752
|
+
"UPDATE schedule_plans SET processed_through=?, next_due_at=? WHERE plan_id=?",
|
|
753
|
+
(high, plan.schedule.next_after(max(now, high)), plan.id),
|
|
754
|
+
)
|
|
755
|
+
self._prune(plan.id)
|
|
756
|
+
|
|
757
|
+
def release_binding(self, binding_id: str, *, binding_removed: bool = False) -> None:
|
|
758
|
+
with self.owner._transaction():
|
|
759
|
+
self._release_binding(binding_id, binding_removed=binding_removed)
|
|
760
|
+
|
|
761
|
+
def _prune(self, plan_id: str) -> None:
|
|
762
|
+
self._db.execute(
|
|
763
|
+
"DELETE FROM schedule_runs WHERE plan_id=? AND barrier='released' "
|
|
764
|
+
"AND (target_kind='binding' OR COALESCE(error_code, '') != 'publishing_unknown') "
|
|
765
|
+
"AND (target_kind='binding' OR initial_turn_id IS NULL OR delivery_state IS NOT NULL) "
|
|
766
|
+
"AND run_id NOT IN (SELECT run_id FROM schedule_runs WHERE plan_id=? "
|
|
767
|
+
"AND barrier='released' AND (target_kind='binding' OR COALESCE(error_code, '') != 'publishing_unknown') "
|
|
768
|
+
"AND (target_kind='binding' OR initial_turn_id IS NULL OR delivery_state IS NOT NULL) "
|
|
769
|
+
"ORDER BY due_at DESC, run_id DESC LIMIT 100)",
|
|
770
|
+
(plan_id, plan_id),
|
|
771
|
+
)
|