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,394 @@
|
|
|
1
|
+
"""Immutable scheduling facts and the shared wall-clock rule calculator."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import UTC, datetime, timedelta
|
|
9
|
+
from typing import Any
|
|
10
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
11
|
+
|
|
12
|
+
from ..session_settings import SessionSettings
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ScheduleError(ValueError):
|
|
16
|
+
code = "invalid_schedule"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ScheduleNotFound(ScheduleError):
|
|
20
|
+
code = "schedule_not_found"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ScheduleConflict(ScheduleError):
|
|
24
|
+
code = "schedule_conflict"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ScheduleRevisionConflict(ScheduleConflict):
|
|
28
|
+
code = "revision_conflict"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ScheduleRequestConflict(ScheduleConflict):
|
|
32
|
+
code = "request_conflict"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ScheduleRunInProgress(ScheduleConflict):
|
|
36
|
+
code = "run_in_progress"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ScheduleBlockedUnknown(ScheduleConflict):
|
|
40
|
+
code = "blocked_unknown"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class AmbiguousLocalTime(ScheduleError):
|
|
44
|
+
code = "ambiguous_local_time"
|
|
45
|
+
|
|
46
|
+
def __init__(self, choices: tuple[dict[str, str], ...]) -> None:
|
|
47
|
+
super().__init__("所选时间因夏令时回拨会出现两次,请选择对应的 UTC 偏移。")
|
|
48
|
+
self.choices = choices
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def resolve_once_local(
|
|
52
|
+
local_at: str, timezone: str, *, original_at: str | None = None,
|
|
53
|
+
utc_offset: str | None = None,
|
|
54
|
+
) -> str:
|
|
55
|
+
"""Resolve a minute-precision picker value without guessing a DST fold."""
|
|
56
|
+
try:
|
|
57
|
+
if not isinstance(local_at, str) or not re.fullmatch(
|
|
58
|
+
r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::00)?", local_at,
|
|
59
|
+
):
|
|
60
|
+
raise ValueError("invalid local datetime")
|
|
61
|
+
zone = ZoneInfo(timezone)
|
|
62
|
+
local = datetime.fromisoformat(local_at)
|
|
63
|
+
moments = []
|
|
64
|
+
for fold in (0, 1):
|
|
65
|
+
moment = local.replace(tzinfo=zone, fold=fold)
|
|
66
|
+
if datetime.fromtimestamp(moment.timestamp(), zone).replace(tzinfo=None) != local:
|
|
67
|
+
continue
|
|
68
|
+
if not moments or moment.timestamp() != moments[0].timestamp():
|
|
69
|
+
moments.append(moment)
|
|
70
|
+
if not moments:
|
|
71
|
+
raise ScheduleError("该时区在这一天没有所选时间(夏令时跳时),请选择其他时间。")
|
|
72
|
+
choices = tuple({
|
|
73
|
+
"utc_offset": moment.isoformat(timespec="minutes")[16:],
|
|
74
|
+
"utc": moment.astimezone(UTC).isoformat(timespec="minutes"),
|
|
75
|
+
} for moment in moments)
|
|
76
|
+
if utc_offset is not None:
|
|
77
|
+
for moment, choice in zip(moments, choices, strict=True):
|
|
78
|
+
if choice["utc_offset"] == utc_offset:
|
|
79
|
+
return moment.isoformat(timespec="minutes")
|
|
80
|
+
raise ScheduleError("所选 UTC 偏移不适用于该日期和时区,请重新预览。")
|
|
81
|
+
if original_at is not None:
|
|
82
|
+
original = datetime.fromisoformat(original_at)
|
|
83
|
+
if original.tzinfo is not None and original.astimezone(zone).replace(tzinfo=None) == local:
|
|
84
|
+
return original.isoformat(timespec="minutes")
|
|
85
|
+
if len(moments) > 1:
|
|
86
|
+
raise AmbiguousLocalTime(choices)
|
|
87
|
+
return moments[0].isoformat(timespec="minutes")
|
|
88
|
+
except ScheduleError:
|
|
89
|
+
raise
|
|
90
|
+
except (TypeError, ValueError, ZoneInfoNotFoundError, OSError, OverflowError) as error:
|
|
91
|
+
raise ScheduleError("请检查执行日期与时区。") from error
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True, slots=True)
|
|
95
|
+
class ScheduleRule:
|
|
96
|
+
kind: str
|
|
97
|
+
timezone: str
|
|
98
|
+
at: str | None = None
|
|
99
|
+
weekdays: tuple[int, ...] = ()
|
|
100
|
+
every_minutes: int | None = None
|
|
101
|
+
anchor: float | None = None
|
|
102
|
+
end_at: str | None = None
|
|
103
|
+
|
|
104
|
+
def __post_init__(self) -> None:
|
|
105
|
+
try:
|
|
106
|
+
ZoneInfo(self.timezone)
|
|
107
|
+
except (ZoneInfoNotFoundError, ValueError, TypeError, OSError) as error:
|
|
108
|
+
raise ScheduleError("请选择有效的 IANA 时区。") from error
|
|
109
|
+
if not isinstance(self.kind, str) or self.kind not in {"once", "daily", "weekly", "interval"}:
|
|
110
|
+
raise ScheduleError("仅支持一次性、每天、每周和固定间隔。")
|
|
111
|
+
if self.kind == "once" and self.end_at is not None:
|
|
112
|
+
raise ScheduleError("一次性计划不能设置截止时间。")
|
|
113
|
+
if self.end_at is not None:
|
|
114
|
+
try:
|
|
115
|
+
if not isinstance(self.end_at, str):
|
|
116
|
+
raise ValueError("invalid end time")
|
|
117
|
+
end = datetime.fromisoformat(self.end_at)
|
|
118
|
+
if end.tzinfo is None or end.second or end.microsecond:
|
|
119
|
+
raise ValueError("end time must be absolute and minute-precision")
|
|
120
|
+
datetime.fromtimestamp(end.timestamp(), UTC)
|
|
121
|
+
datetime.fromtimestamp(end.timestamp(), ZoneInfo(self.timezone))
|
|
122
|
+
object.__setattr__(self, "end_at", end.isoformat(timespec="minutes"))
|
|
123
|
+
except (TypeError, ValueError, OverflowError, OSError) as error:
|
|
124
|
+
raise ScheduleError("截止时间必须是有效的带偏移 ISO 时间,精度为分钟。") from error
|
|
125
|
+
if not isinstance(self.weekdays, (tuple, list)):
|
|
126
|
+
raise ScheduleError("weekdays 必须是星期列表。")
|
|
127
|
+
object.__setattr__(self, "weekdays", tuple(self.weekdays))
|
|
128
|
+
if self.kind == "interval":
|
|
129
|
+
if (
|
|
130
|
+
type(self.every_minutes) is not int or self.every_minutes < 1
|
|
131
|
+
or type(self.anchor) not in {int, float}
|
|
132
|
+
or self.at is not None or self.weekdays
|
|
133
|
+
):
|
|
134
|
+
raise ScheduleError("固定间隔需要正整数分钟和 UTC anchor。")
|
|
135
|
+
try:
|
|
136
|
+
if not math.isfinite(self.anchor):
|
|
137
|
+
raise ScheduleError("固定间隔需要有限的 UTC anchor。")
|
|
138
|
+
datetime.fromtimestamp(self.anchor, UTC)
|
|
139
|
+
datetime.fromtimestamp(self.anchor + self.every_minutes * 60, UTC)
|
|
140
|
+
except (ValueError, OverflowError, OSError) as error:
|
|
141
|
+
raise ScheduleError("固定间隔超出支持的日期范围。") from error
|
|
142
|
+
return
|
|
143
|
+
if self.every_minutes is not None or self.anchor is not None:
|
|
144
|
+
raise ScheduleError("只有固定间隔可以指定间隔和 anchor。")
|
|
145
|
+
if self.kind == "once":
|
|
146
|
+
if not isinstance(self.at, str):
|
|
147
|
+
raise ScheduleError("一次性时间必须是带偏移的 ISO 时间。")
|
|
148
|
+
try:
|
|
149
|
+
moment = datetime.fromisoformat(self.at)
|
|
150
|
+
except (ValueError, TypeError) as error:
|
|
151
|
+
raise ScheduleError("一次性时间必须是带偏移的 ISO 时间。") from error
|
|
152
|
+
if moment.tzinfo is None or moment.second or moment.microsecond or self.weekdays:
|
|
153
|
+
raise ScheduleError("一次性时间须明确偏移,精度为分钟。")
|
|
154
|
+
try:
|
|
155
|
+
datetime.fromtimestamp(moment.timestamp(), UTC)
|
|
156
|
+
datetime.fromtimestamp(moment.timestamp(), ZoneInfo(self.timezone))
|
|
157
|
+
except (ValueError, OverflowError, OSError) as error:
|
|
158
|
+
raise ScheduleError("一次性时间超出支持的日期范围。") from error
|
|
159
|
+
object.__setattr__(self, "at", moment.isoformat(timespec="minutes"))
|
|
160
|
+
return
|
|
161
|
+
if not isinstance(self.at, str) or not re.fullmatch(r"(?:[01]\d|2[0-3]):[0-5]\d", self.at):
|
|
162
|
+
raise ScheduleError("每天/每周时间格式为 HH:mm。")
|
|
163
|
+
if self.kind == "weekly":
|
|
164
|
+
if not self.weekdays or any(type(day) is not int or not 0 <= day <= 6 for day in self.weekdays):
|
|
165
|
+
raise ScheduleError("每周需要非空星期集合,周一为 0、周日为 6。")
|
|
166
|
+
object.__setattr__(self, "weekdays", tuple(sorted(set(self.weekdays))))
|
|
167
|
+
elif self.weekdays:
|
|
168
|
+
raise ScheduleError("只有每周规则可以指定星期。")
|
|
169
|
+
|
|
170
|
+
@classmethod
|
|
171
|
+
def from_dict(cls, value: dict[str, Any]) -> ScheduleRule:
|
|
172
|
+
if not isinstance(value, dict) or set(value) - {"kind", "timezone", "at", "weekdays", "every_minutes", "anchor", "end_at"}:
|
|
173
|
+
raise ScheduleError("时间规则包含不支持的字段。")
|
|
174
|
+
fields = dict(value)
|
|
175
|
+
if "weekdays" in fields:
|
|
176
|
+
if not isinstance(fields["weekdays"], (list, tuple)):
|
|
177
|
+
raise ScheduleError("weekdays 必须是星期列表。")
|
|
178
|
+
fields["weekdays"] = tuple(fields["weekdays"])
|
|
179
|
+
try:
|
|
180
|
+
return cls(**fields)
|
|
181
|
+
except TypeError as error:
|
|
182
|
+
raise ScheduleError("时间规则缺少类型或时区。") from error
|
|
183
|
+
|
|
184
|
+
def to_dict(self) -> dict[str, Any]:
|
|
185
|
+
result: dict[str, Any] = {"kind": self.kind, "timezone": self.timezone}
|
|
186
|
+
if self.at is not None:
|
|
187
|
+
result["at"] = self.at
|
|
188
|
+
if self.kind == "weekly":
|
|
189
|
+
result["weekdays"] = list(self.weekdays)
|
|
190
|
+
if self.kind == "interval":
|
|
191
|
+
result.update(every_minutes=self.every_minutes, anchor=self.anchor)
|
|
192
|
+
if self.end_at is not None:
|
|
193
|
+
result["end_at"] = self.end_at
|
|
194
|
+
return result
|
|
195
|
+
|
|
196
|
+
def _local_occurrence(self, date: datetime) -> float | None:
|
|
197
|
+
if self.kind == "weekly" and date.weekday() not in self.weekdays:
|
|
198
|
+
return None
|
|
199
|
+
hour, minute = map(int, (self.at or "").split(":"))
|
|
200
|
+
local = date.replace(hour=hour, minute=minute, second=0, microsecond=0, fold=0)
|
|
201
|
+
stamp = local.timestamp()
|
|
202
|
+
roundtrip = datetime.fromtimestamp(stamp, ZoneInfo(self.timezone))
|
|
203
|
+
# A nonexistent wall time normalizes to a different clock reading.
|
|
204
|
+
if roundtrip.replace(tzinfo=None) != local.replace(tzinfo=None):
|
|
205
|
+
return None
|
|
206
|
+
return stamp
|
|
207
|
+
|
|
208
|
+
def next_after(self, after: float) -> float | None:
|
|
209
|
+
"""Return the first occurrence strictly after an absolute UTC boundary."""
|
|
210
|
+
try:
|
|
211
|
+
end = datetime.fromisoformat(self.end_at).timestamp() if self.end_at is not None else None
|
|
212
|
+
if end is not None and after >= end:
|
|
213
|
+
return None
|
|
214
|
+
result = self._next_after(after)
|
|
215
|
+
if result is not None and end is not None and result > end:
|
|
216
|
+
return None
|
|
217
|
+
if result is not None:
|
|
218
|
+
# Both projections are part of the common preview contract.
|
|
219
|
+
datetime.fromtimestamp(result, UTC)
|
|
220
|
+
datetime.fromtimestamp(result, ZoneInfo(self.timezone))
|
|
221
|
+
return result
|
|
222
|
+
except ScheduleError:
|
|
223
|
+
raise
|
|
224
|
+
except (TypeError, ValueError, OverflowError, OSError) as error:
|
|
225
|
+
raise ScheduleError("计划时间超出支持的日期范围。") from error
|
|
226
|
+
|
|
227
|
+
def _next_after(self, after: float) -> float | None:
|
|
228
|
+
if not math.isfinite(after):
|
|
229
|
+
raise ScheduleError("时间边界必须是有限 UTC 时间。")
|
|
230
|
+
if self.kind == "once":
|
|
231
|
+
stamp = datetime.fromisoformat(self.at or "").timestamp()
|
|
232
|
+
return stamp if stamp > after else None
|
|
233
|
+
if self.kind == "interval":
|
|
234
|
+
assert self.anchor is not None and self.every_minutes is not None
|
|
235
|
+
interval = self.every_minutes * 60
|
|
236
|
+
index = max(1, math.floor((after - self.anchor) / interval) + 1)
|
|
237
|
+
return self.anchor + index * interval
|
|
238
|
+
date = datetime.fromtimestamp(after, ZoneInfo(self.timezone)).replace(hour=0, minute=0, second=0, microsecond=0)
|
|
239
|
+
# At most a skipped DST day plus the next selected weekday.
|
|
240
|
+
for _ in range(16):
|
|
241
|
+
stamp = self._local_occurrence(date)
|
|
242
|
+
if stamp is not None and stamp > after:
|
|
243
|
+
return stamp
|
|
244
|
+
date += timedelta(days=1)
|
|
245
|
+
raise ScheduleError("无法计算下一次当地时间。")
|
|
246
|
+
|
|
247
|
+
def preview(self, after: float, count: int = 3) -> tuple[float, ...]:
|
|
248
|
+
if type(count) is not int or not 1 <= count <= 100:
|
|
249
|
+
raise ScheduleError("预览数量必须在 1 到 100 之间。")
|
|
250
|
+
result = []
|
|
251
|
+
for _ in range(count):
|
|
252
|
+
following = self.next_after(after)
|
|
253
|
+
if following is None:
|
|
254
|
+
break
|
|
255
|
+
result.append(following)
|
|
256
|
+
after = following
|
|
257
|
+
return tuple(result)
|
|
258
|
+
|
|
259
|
+
def through(self, first: float, until: float) -> tuple[float, int]:
|
|
260
|
+
"""Last occurrence and count in [first, until], without minute iteration."""
|
|
261
|
+
if self.end_at is not None:
|
|
262
|
+
until = min(until, datetime.fromisoformat(self.end_at).timestamp())
|
|
263
|
+
if until < first:
|
|
264
|
+
return first, 0
|
|
265
|
+
if self.kind == "once":
|
|
266
|
+
return first, 1
|
|
267
|
+
if self.kind == "interval":
|
|
268
|
+
assert self.every_minutes is not None
|
|
269
|
+
count = math.floor((until - first) / (self.every_minutes * 60)) + 1
|
|
270
|
+
return first + (count - 1) * self.every_minutes * 60, count
|
|
271
|
+
last, count = first, 1
|
|
272
|
+
while (following := self.next_after(last)) is not None and following <= until:
|
|
273
|
+
last, count = following, count + 1
|
|
274
|
+
return last, count
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@dataclass(frozen=True, slots=True)
|
|
278
|
+
class Plan:
|
|
279
|
+
id: str
|
|
280
|
+
revision: int
|
|
281
|
+
name: str
|
|
282
|
+
instructions: str
|
|
283
|
+
project_alias: str
|
|
284
|
+
app_id: str
|
|
285
|
+
chat_id: str
|
|
286
|
+
schedule: ScheduleRule | None
|
|
287
|
+
enabled: bool
|
|
288
|
+
next_due_at: float | None
|
|
289
|
+
processed_through: float | None
|
|
290
|
+
created_at: float
|
|
291
|
+
updated_at: float
|
|
292
|
+
source: str
|
|
293
|
+
deleted: bool = False
|
|
294
|
+
session_settings: SessionSettings | None = SessionSettings()
|
|
295
|
+
target_kind: str = "new_topic"
|
|
296
|
+
target_binding_id: str | None = None
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
@dataclass(frozen=True, slots=True)
|
|
300
|
+
class PlanLifecycle:
|
|
301
|
+
ended: bool
|
|
302
|
+
has_future: bool
|
|
303
|
+
has_trigger: bool
|
|
304
|
+
|
|
305
|
+
@property
|
|
306
|
+
def can_toggle(self) -> bool:
|
|
307
|
+
return self.has_trigger
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _trigger_opportunities(
|
|
311
|
+
rule: ScheduleRule | None, *, enabled: bool, next_due_at: float | None,
|
|
312
|
+
processed_through: float | None, now: float,
|
|
313
|
+
) -> tuple[bool, bool]:
|
|
314
|
+
if rule is None:
|
|
315
|
+
return False, False
|
|
316
|
+
boundary = max(now, processed_through) if processed_through is not None else now
|
|
317
|
+
has_future = rule.next_after(boundary) is not None
|
|
318
|
+
# A due point remains claimable through the scheduler's inclusive 60-second
|
|
319
|
+
# grace period. Pausing removes that opportunity, and a clock rollback must
|
|
320
|
+
# never revive a point already covered by the processed high-water mark.
|
|
321
|
+
claimable_due = (
|
|
322
|
+
enabled and next_due_at is not None
|
|
323
|
+
and (processed_through is None or next_due_at > processed_through)
|
|
324
|
+
and (rule.end_at is None or next_due_at <= datetime.fromisoformat(rule.end_at).timestamp())
|
|
325
|
+
and 0 <= now - next_due_at <= 60
|
|
326
|
+
)
|
|
327
|
+
return has_future, has_future or claimable_due
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def has_trigger_opportunity(
|
|
331
|
+
rule: ScheduleRule | None, *, enabled: bool, next_due_at: float | None,
|
|
332
|
+
processed_through: float | None, now: float,
|
|
333
|
+
) -> bool:
|
|
334
|
+
"""Whether the current rule has a future or still-claimable occurrence."""
|
|
335
|
+
return _trigger_opportunities(
|
|
336
|
+
rule, enabled=enabled, next_due_at=next_due_at,
|
|
337
|
+
processed_through=processed_through, now=now,
|
|
338
|
+
)[1]
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def plan_lifecycle(plan: Plan, *, now: float, has_pending: bool) -> PlanLifecycle:
|
|
342
|
+
"""Project lifecycle separately from enablement and native execution state."""
|
|
343
|
+
has_future, has_trigger = _trigger_opportunities(
|
|
344
|
+
plan.schedule, enabled=plan.enabled, next_due_at=plan.next_due_at,
|
|
345
|
+
processed_through=plan.processed_through, now=now,
|
|
346
|
+
)
|
|
347
|
+
return PlanLifecycle(
|
|
348
|
+
ended=not has_trigger and not has_pending,
|
|
349
|
+
has_future=has_future, has_trigger=has_trigger,
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
@dataclass(frozen=True, slots=True)
|
|
354
|
+
class Run:
|
|
355
|
+
id: str
|
|
356
|
+
plan_id: str
|
|
357
|
+
plan_revision: int
|
|
358
|
+
due_at: float
|
|
359
|
+
project_alias: str
|
|
360
|
+
app_id: str
|
|
361
|
+
chat_id: str
|
|
362
|
+
phase: str
|
|
363
|
+
barrier: str
|
|
364
|
+
root_uuid: str
|
|
365
|
+
seed_uuid: str
|
|
366
|
+
root_message_id: str | None
|
|
367
|
+
topic_id: str | None
|
|
368
|
+
origin_message_id: str | None
|
|
369
|
+
binding_id: str | None
|
|
370
|
+
initial_turn_id: str | None
|
|
371
|
+
error_code: str | None
|
|
372
|
+
delivery_state: str | None
|
|
373
|
+
created_at: float
|
|
374
|
+
updated_at: float
|
|
375
|
+
missed_from: float | None = None
|
|
376
|
+
missed_count: int = 0
|
|
377
|
+
binding_removed: bool = False
|
|
378
|
+
trigger_source: str = "scheduled"
|
|
379
|
+
target_kind: str = "new_topic"
|
|
380
|
+
disposition: str | None = None
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
@dataclass(frozen=True, slots=True)
|
|
384
|
+
class Claim:
|
|
385
|
+
run: Run
|
|
386
|
+
plan: Plan
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
@dataclass(frozen=True, slots=True)
|
|
390
|
+
class MutationResult:
|
|
391
|
+
plan_id: str
|
|
392
|
+
revision: int
|
|
393
|
+
replayed: bool = False
|
|
394
|
+
run_id: str | None = None
|