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.
Files changed (112) hide show
  1. netizen_cli/__init__.py +3 -0
  2. netizen_cli/__main__.py +4 -0
  3. netizen_cli/admin/__init__.py +1 -0
  4. netizen_cli/admin/auth.py +928 -0
  5. netizen_cli/admin/errors.py +9 -0
  6. netizen_cli/admin/port_config.py +115 -0
  7. netizen_cli/admin/presentation.py +257 -0
  8. netizen_cli/admin/queries.py +337 -0
  9. netizen_cli/admin/static/admin.css +260 -0
  10. netizen_cli/admin/static/admin.js +2898 -0
  11. netizen_cli/admin/static/index.html +327 -0
  12. netizen_cli/admin/transport.py +935 -0
  13. netizen_cli/admin/web.py +2717 -0
  14. netizen_cli/bindings.py +3215 -0
  15. netizen_cli/builtin_skills.py +93 -0
  16. netizen_cli/cards/__init__.py +105 -0
  17. netizen_cli/cards/callbacks.py +565 -0
  18. netizen_cli/cards/controls.py +2273 -0
  19. netizen_cli/cards/defaults.py +213 -0
  20. netizen_cli/cards/model_info.py +80 -0
  21. netizen_cli/cards/questions.py +220 -0
  22. netizen_cli/cards/reply.py +2247 -0
  23. netizen_cli/cards/scheduled.py +836 -0
  24. netizen_cli/channel/__init__.py +1 -0
  25. netizen_cli/channel/completion_mentions.py +60 -0
  26. netizen_cli/channel/input_preparation.py +644 -0
  27. netizen_cli/channel/messages.py +57 -0
  28. netizen_cli/channel/ports.py +52 -0
  29. netizen_cli/channel/question_inputs.py +51 -0
  30. netizen_cli/channel/reactions.py +293 -0
  31. netizen_cli/channel/reply_presenter.py +1505 -0
  32. netizen_cli/channel/topics.py +70 -0
  33. netizen_cli/channel_app.py +6593 -0
  34. netizen_cli/cli.py +287 -0
  35. netizen_cli/cli_data.py +536 -0
  36. netizen_cli/cli_packages.py +526 -0
  37. netizen_cli/cli_services.py +651 -0
  38. netizen_cli/cli_setup.py +242 -0
  39. netizen_cli/cli_update.py +303 -0
  40. netizen_cli/cli_update_restore.py +53 -0
  41. netizen_cli/cli_update_worker.py +333 -0
  42. netizen_cli/codex_runtime.py +7125 -0
  43. netizen_cli/completion_mention.py +16 -0
  44. netizen_cli/database_migrations.py +218 -0
  45. netizen_cli/defaults/__init__.py +5 -0
  46. netizen_cli/defaults/models.py +39 -0
  47. netizen_cli/defaults/service.py +232 -0
  48. netizen_cli/defaults/store.py +260 -0
  49. netizen_cli/deployment/__init__.py +1 -0
  50. netizen_cli/deployment/restart_worker.py +134 -0
  51. netizen_cli/deployment/update_executor.py +258 -0
  52. netizen_cli/deployment/update_protocol.py +281 -0
  53. netizen_cli/domain.py +416 -0
  54. netizen_cli/error_messages.py +124 -0
  55. netizen_cli/experience.py +531 -0
  56. netizen_cli/feishu_app_onboarding.py +187 -0
  57. netizen_cli/feishu_app_permissions.py +123 -0
  58. netizen_cli/git_status.py +63 -0
  59. netizen_cli/image_inputs.py +579 -0
  60. netizen_cli/instance.py +84 -0
  61. netizen_cli/lark_app.py +125 -0
  62. netizen_cli/main.py +903 -0
  63. netizen_cli/management/__init__.py +83 -0
  64. netizen_cli/management/blocking_io.py +352 -0
  65. netizen_cli/management/chat_labels.py +266 -0
  66. netizen_cli/management/coordination.py +32 -0
  67. netizen_cli/management/service.py +2187 -0
  68. netizen_cli/management/updates.py +214 -0
  69. netizen_cli/markdown_images.py +78 -0
  70. netizen_cli/message_content.py +786 -0
  71. netizen_cli/message_history.py +643 -0
  72. netizen_cli/message_preparation.py +60 -0
  73. netizen_cli/message_projection.py +923 -0
  74. netizen_cli/migrations/__init__.py +1 -0
  75. netizen_cli/migrations/schema.py +103 -0
  76. netizen_cli/migrations/v14.py +438 -0
  77. netizen_cli/model_settings.py +269 -0
  78. netizen_cli/package_resources.py +22 -0
  79. netizen_cli/projects.py +327 -0
  80. netizen_cli/prompt_projection.py +327 -0
  81. netizen_cli/quoted_context.py +312 -0
  82. netizen_cli/resources/config.example.yaml +35 -0
  83. netizen_cli/resources/skills/netizen-lark/SKILL.md +64 -0
  84. netizen_cli/resources/skills/netizen-user-guide/SKILL.md +37 -0
  85. netizen_cli/resources/skills/netizen-user-guide/references/user-guide.md +842 -0
  86. netizen_cli/result_images.py +123 -0
  87. netizen_cli/runtime/__init__.py +1 -0
  88. netizen_cli/runtime/contracts.py +792 -0
  89. netizen_cli/runtime/name_writes.py +67 -0
  90. netizen_cli/runtime/thread_naming.py +451 -0
  91. netizen_cli/schedules/__init__.py +1 -0
  92. netizen_cli/schedules/mcp.py +535 -0
  93. netizen_cli/schedules/models.py +394 -0
  94. netizen_cli/schedules/scheduler.py +374 -0
  95. netizen_cli/schedules/service.py +766 -0
  96. netizen_cli/schedules/store.py +771 -0
  97. netizen_cli/sdk_gap_adapter.py +1151 -0
  98. netizen_cli/service_launcher.py +583 -0
  99. netizen_cli/session_settings.py +126 -0
  100. netizen_cli/settings.py +216 -0
  101. netizen_cli/skill_references.py +40 -0
  102. netizen_cli/terminal_cleanup.py +155 -0
  103. netizen_cli/turn_activity.py +688 -0
  104. netizen_cli/turn_files.py +812 -0
  105. netizen_cli/turn_patch_children.py +254 -0
  106. netizen_cli/turn_plan_observer.py +315 -0
  107. netizen_cli/user_questions.py +106 -0
  108. netizen_cli-0.10.0.dist-info/METADATA +18 -0
  109. netizen_cli-0.10.0.dist-info/RECORD +112 -0
  110. netizen_cli-0.10.0.dist-info/WHEEL +5 -0
  111. netizen_cli-0.10.0.dist-info/entry_points.txt +2 -0
  112. netizen_cli-0.10.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,374 @@
1
+ """Claim due plans and hand them to ordinary Channel/Runtime execution.
2
+
3
+ Only dispatch creation is owned here. Native Turns keep their ordinary single
4
+ consumer; this timer never waits for them or resumes them after a restart.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import contextlib
11
+ import logging
12
+ import time
13
+ from collections.abc import Awaitable, Callable
14
+ from typing import Protocol
15
+
16
+ from ..bindings import BindingStore, ProjectDeleting
17
+ from .models import Claim, MutationResult, Run, ScheduleError, ScheduleNotFound
18
+
19
+
20
+ logger = logging.getLogger(__name__)
21
+ TICK_INTERVAL_SECONDS = 60.0
22
+ DUE_BATCH_SIZE = 100
23
+ RECOVERY_TIMEOUT_SECONDS = 10.0
24
+ READ_TIMEOUT_SECONDS = 3.0
25
+ DISPATCH_TIMEOUT_SECONDS = 30.0
26
+ BINDING_DISPATCH_TIMEOUT_SECONDS = 180.0
27
+ _TERMINAL = {"completed", "interrupted", "failed"}
28
+ _PRE_NATIVE = {"claimed", "publishing_topic", "binding_ready"}
29
+
30
+
31
+ class SchedulerUnavailable(ScheduleError):
32
+ code = "unavailable"
33
+
34
+
35
+ class ScheduledTurnReader(Protocol):
36
+ async def read_scheduled_turn(
37
+ self, binding_id: str, turn_id: str, *, deadline: float | None = None,
38
+ ) -> str: ...
39
+
40
+
41
+ class Scheduler:
42
+ def __init__(
43
+ self,
44
+ bindings: BindingStore,
45
+ runtime: ScheduledTurnReader,
46
+ app_id: str,
47
+ dispatch: Callable[[Claim], Awaitable[None]],
48
+ wall_clock: Callable[[], float] = time.time,
49
+ ) -> None:
50
+ self._bindings = bindings
51
+ self._store = bindings.schedules
52
+ self._runtime = runtime
53
+ self._app_id = app_id
54
+ self._dispatch = dispatch
55
+ self._wall_clock = wall_clock
56
+ self._loop: asyncio.AbstractEventLoop | None = None
57
+ self._timer: asyncio.Task[None] | None = None
58
+ self._wake = asyncio.Event()
59
+ self._admission = False
60
+ self._closed = False
61
+ self._recovered = False
62
+ self._ticking = False
63
+ self._dispatches: dict[str, tuple[str, asyncio.Task[None]]] = {}
64
+ # Only exact Turns observed running after restart/explicit refresh.
65
+ # Fresh ordinary consumers are responsible for their own barriers.
66
+ self._recovered_running: dict[str, str] = {}
67
+
68
+ def _own_loop(self) -> asyncio.AbstractEventLoop:
69
+ loop = asyncio.get_running_loop()
70
+ if self._loop is None:
71
+ self._loop = loop
72
+ elif self._loop is not loop:
73
+ raise RuntimeError("Scheduler must run on its owning event loop")
74
+ return loop
75
+
76
+ async def recover(self) -> None:
77
+ """Reconcile pending metadata once, then skip all offline due points."""
78
+ loop = self._own_loop()
79
+ if self._admission or self._closed or self._recovered:
80
+ raise RuntimeError("Scheduler recovery must precede admission exactly once")
81
+ self._store.abandon_pending_deliveries()
82
+ deadline = loop.time() + RECOVERY_TIMEOUT_SECONDS
83
+ for run in self._store.pending_runs():
84
+ if run.app_id != self._app_id:
85
+ continue
86
+ if run.target_kind == "binding":
87
+ self._store.release(run.id, error_code=(
88
+ "input_unknown" if run.phase in {"starting_turn", "handed_off"}
89
+ else "publishing_unknown" if run.phase == "publishing_topic"
90
+ else "recovery_no_start"
91
+ ))
92
+ elif run.phase in _PRE_NATIVE:
93
+ self._store.release(run.id, error_code=(
94
+ "publishing_unknown" if run.phase == "publishing_topic"
95
+ else "recovery_no_start"
96
+ ))
97
+ elif run.barrier != "unknown":
98
+ await self._observe(run, deadline=deadline)
99
+ # A persisted unknown is not retried by every service restart.
100
+ seen: set[str] = set()
101
+ while True:
102
+ plans = self._store.due_plans(app_id=self._app_id, now=self._wall_clock())
103
+ fresh = [plan for plan in plans if plan.id not in seen]
104
+ if not fresh:
105
+ break
106
+ for plan in fresh:
107
+ seen.add(plan.id)
108
+ try:
109
+ self._store.claim_due(plan.id, app_id=self._app_id, now=self._wall_clock(), recover=True)
110
+ except (ProjectDeleting, ScheduleNotFound):
111
+ continue
112
+ await asyncio.sleep(0)
113
+ self._recovered = True
114
+
115
+ def start(self) -> None:
116
+ self._own_loop()
117
+ if self._closed or self._timer is not None or not self._recovered:
118
+ raise RuntimeError("Recover Scheduler before starting it exactly once")
119
+ self._admission = True
120
+ self._timer = asyncio.create_task(self._run_timer(), name="schedule-timer")
121
+
122
+ def wake(self) -> None:
123
+ self._wake.set()
124
+
125
+ def close_admission(self) -> None:
126
+ self._admission = False
127
+ self.wake()
128
+
129
+ def run_now(
130
+ self, plan_id: str, expected_revision: int, request_id: str,
131
+ request_payload: object,
132
+ ) -> MutationResult:
133
+ """Claim and attach manual work without yielding or moving the timer cursor."""
134
+ self._own_loop()
135
+ if not self._admission:
136
+ raise SchedulerUnavailable("调度服务尚未就绪或正在停止,暂不能立即运行。")
137
+ result, claim = self._store.claim_manual(
138
+ plan_id, app_id=self._app_id, expected_revision=expected_revision,
139
+ request_id=request_id, request_payload=request_payload, now=self._wall_clock(),
140
+ )
141
+ if claim is not None:
142
+ self._dispatch_claim(claim)
143
+ return result
144
+
145
+ def _dispatch_claim(self, claim: Claim) -> None:
146
+ task = asyncio.create_task(self._dispatch_once(claim), name="schedule-dispatch:" + claim.run.id)
147
+ self._dispatches[claim.run.id] = (claim.run.project_alias, task)
148
+ task.add_done_callback(lambda done, run_id=claim.run.id: self._dispatch_done(run_id, done))
149
+
150
+ async def close(self) -> None:
151
+ """Stop the timer; shutdown dispatch draining remains a separate step."""
152
+ self.close_admission()
153
+ self._closed = True
154
+ if self._timer is not None:
155
+ self._timer.cancel()
156
+ with contextlib.suppress(asyncio.CancelledError):
157
+ await self._timer
158
+
159
+ async def _run_timer(self) -> None:
160
+ while self._admission:
161
+ self._wake.clear()
162
+ now = self._wall_clock()
163
+ next_tick = now + TICK_INTERVAL_SECONDS - now % TICK_INTERVAL_SECONDS
164
+ try:
165
+ await self.tick()
166
+ except asyncio.CancelledError:
167
+ raise
168
+ except Exception as error:
169
+ # A Store/controller failure must not keep creating work.
170
+ self.close_admission()
171
+ logger.error("Schedule timer stopped", extra={"error_type": type(error).__name__})
172
+ return
173
+ if self._admission:
174
+ try:
175
+ # Keep the minute boundary chosen before work, so a scan
176
+ # crossing that boundary does not postpone newly due plans.
177
+ delay = min(TICK_INTERVAL_SECONDS, max(0.0, next_tick - self._wall_clock()))
178
+ await asyncio.wait_for(self._wake.wait(), delay)
179
+ except TimeoutError:
180
+ pass
181
+
182
+ async def tick(self) -> int:
183
+ """Claim one bounded batch; return without waiting for dispatch or Turn."""
184
+ loop = self._own_loop()
185
+ if not self._admission or self._ticking:
186
+ return 0
187
+ self._ticking = True
188
+ claimed = 0
189
+ processed = 0
190
+ try:
191
+ deadline = loop.time() + RECOVERY_TIMEOUT_SECONDS
192
+ plans = self._store.due_plans(app_id=self._app_id, now=self._wall_clock(), limit=DUE_BATCH_SIZE)
193
+ for plan in plans:
194
+ if not self._admission:
195
+ break
196
+ pending = self._store.pending_for_plan(plan.id)
197
+ if pending is None:
198
+ self._recovered_running.pop(plan.id, None)
199
+ elif self._recovered_running.get(plan.id) == pending.id:
200
+ await self._observe(pending, deadline=deadline)
201
+ if not self._admission:
202
+ break
203
+ try:
204
+ claim = self._store.claim_due(plan.id, app_id=self._app_id, now=self._wall_clock())
205
+ except (ProjectDeleting, ScheduleNotFound):
206
+ continue
207
+ processed += 1
208
+ if claim is not None:
209
+ self._dispatch_claim(claim)
210
+ claimed += 1
211
+ # Drain full batches without another minute's delay, including
212
+ # skipped occurrences. Do not spin on frozen Project deletions.
213
+ if len(plans) == DUE_BATCH_SIZE and processed:
214
+ self.wake()
215
+ return claimed
216
+ finally:
217
+ self._ticking = False
218
+
219
+ async def refresh(self, plan_id: str) -> str | None:
220
+ """Explicit user recheck; ordinary Runtime observation guards still apply."""
221
+ loop = self._own_loop()
222
+ run = self._store.pending_for_plan(plan_id)
223
+ if run is None or run.app_id != self._app_id:
224
+ self._recovered_running.pop(plan_id, None)
225
+ return None
226
+ if run.id in self._dispatches or run.phase in _PRE_NATIVE:
227
+ return None
228
+ if run.target_kind == "binding":
229
+ self._store.release(run.id, error_code="input_unknown")
230
+ return None
231
+ return await self._observe(run, deadline=loop.time() + READ_TIMEOUT_SECONDS)
232
+
233
+ async def _observe(self, run: Run, *, deadline: float) -> str | None:
234
+ if run.target_kind == "binding":
235
+ return None
236
+ loop = self._own_loop()
237
+ self._recovered_running.pop(run.plan_id, None)
238
+ try:
239
+ current = self._store.get_run(run.id)
240
+ except ScheduleNotFound:
241
+ return None
242
+ if current.barrier == "released":
243
+ return None
244
+ if not current.binding_id or not current.initial_turn_id:
245
+ self._store.set_run(current.id, barrier="unknown", error_code="initial_reference_unknown")
246
+ return None
247
+ if loop.time() >= deadline:
248
+ self._store.set_run(current.id, barrier="unknown", error_code="read_timeout")
249
+ return None
250
+ try:
251
+ async with asyncio.timeout_at(min(deadline, loop.time() + READ_TIMEOUT_SECONDS)):
252
+ status = await self._runtime.read_scheduled_turn(
253
+ current.binding_id, current.initial_turn_id,
254
+ deadline=min(deadline, loop.time() + READ_TIMEOUT_SECONDS),
255
+ )
256
+ except asyncio.CancelledError:
257
+ # Shutdown is not native terminal evidence. No later timer is left
258
+ # to repeat the cancelled observation automatically.
259
+ with contextlib.suppress(ScheduleNotFound):
260
+ self._store.set_run(current.id, barrier="unknown", error_code="read_cancelled")
261
+ raise
262
+ except Exception as error:
263
+ code = getattr(error, "code", None)
264
+ if not isinstance(code, str):
265
+ code = "read_timeout" if isinstance(error, TimeoutError) else "read_unavailable"
266
+ with contextlib.suppress(ScheduleNotFound):
267
+ self._store.set_run(current.id, barrier="unknown", error_code=code)
268
+ return None
269
+ # Re-read after await: terminal/lifecycle proof may already have released
270
+ # this barrier. Store never reopens a released occurrence.
271
+ try:
272
+ current = self._store.get_run(current.id)
273
+ except ScheduleNotFound:
274
+ # Completion/lifecycle may have released and pruned it while this
275
+ # read was outstanding. There is no longer a barrier to change.
276
+ return None
277
+ if current.barrier == "released":
278
+ return status if status in _TERMINAL else None
279
+ if status in _TERMINAL:
280
+ self._store.release(current.id)
281
+ return status
282
+ if status == "inProgress":
283
+ current = self._store.set_run(current.id, barrier="held", error_code=None)
284
+ self._recovered_running[current.plan_id] = current.id
285
+ return status
286
+ self._store.set_run(current.id, barrier="unknown", error_code="status_unavailable")
287
+ return None
288
+
289
+ async def _dispatch_once(self, claim: Claim) -> None:
290
+ try:
291
+ timeout = BINDING_DISPATCH_TIMEOUT_SECONDS if claim.run.target_kind == "binding" else DISPATCH_TIMEOUT_SECONDS
292
+ async with asyncio.timeout(timeout):
293
+ await self._dispatch(claim)
294
+ except asyncio.CancelledError:
295
+ self._settle_dispatch(claim.run.id, "dispatch_cancelled")
296
+ raise
297
+ except Exception as error:
298
+ self._settle_dispatch(claim.run.id, "dispatch_timeout" if isinstance(error, TimeoutError) else "dispatch_failed")
299
+ logger.warning("Scheduled dispatch did not finish", extra={"run_id": claim.run.id, "error_type": type(error).__name__})
300
+ else:
301
+ try:
302
+ run = self._store.get_run(claim.run.id)
303
+ except ScheduleNotFound:
304
+ return
305
+ if run.target_kind == "binding" and run.barrier != "released":
306
+ self._settle_dispatch(run.id, "dispatch_incomplete")
307
+ elif run.barrier == "held" and (
308
+ run.phase != "handed_off" or not run.binding_id or not run.initial_turn_id
309
+ ):
310
+ self._settle_dispatch(run.id, "dispatch_incomplete")
311
+
312
+ def _settle_dispatch(self, run_id: str, reason: str) -> None:
313
+ try:
314
+ run = self._store.get_run(run_id)
315
+ except ScheduleNotFound:
316
+ return
317
+ if run.barrier == "released":
318
+ return
319
+ if run.target_kind == "binding":
320
+ self._store.release(run_id, error_code=(
321
+ "input_unknown" if run.phase in {"starting_turn", "handed_off"}
322
+ else "publishing_unknown" if run.phase == "publishing_topic"
323
+ else reason
324
+ ))
325
+ return
326
+ if run.barrier == "unknown":
327
+ return
328
+ if run.phase in _PRE_NATIVE:
329
+ self._store.release(run_id, error_code="publishing_unknown" if run.phase == "publishing_topic" else reason)
330
+ else:
331
+ self._store.set_run(run_id, barrier="unknown", error_code=reason)
332
+
333
+ def _dispatch_done(self, run_id: str, task: asyncio.Task[None]) -> None:
334
+ self._dispatches.pop(run_id, None)
335
+ if not task.cancelled() and task.exception() is not None:
336
+ self.close_admission()
337
+ logger.error("Scheduled dispatch state could not be saved", extra={"run_id": run_id})
338
+
339
+ async def drain(self, deadline: float) -> bool:
340
+ """Bound shutdown creation work; never cancel a handed-off native Turn."""
341
+ loop = self._own_loop()
342
+ tasks = {task for _, task in self._dispatches.values() if not task.done()}
343
+ if not tasks:
344
+ return True
345
+ try:
346
+ _, pending = await asyncio.wait(tasks, timeout=max(0, deadline - loop.time()))
347
+ except asyncio.CancelledError:
348
+ await self._cancel_dispatches({task for task in tasks if not task.done()})
349
+ raise
350
+ if not pending:
351
+ return True
352
+ await self._cancel_dispatches(pending)
353
+ return False
354
+
355
+ async def _cancel_dispatches(self, pending: set[asyncio.Task[None]]) -> None:
356
+ if not pending:
357
+ return
358
+ for task in pending:
359
+ task.cancel()
360
+ # Save uncertainty before yielding, including when the outer service
361
+ # cleanup budget cancelled drain before its own deadline elapsed.
362
+ for run_id, (_, task) in tuple(self._dispatches.items()):
363
+ if task in pending and not task.done():
364
+ self._settle_dispatch(run_id, "dispatch_cancelled")
365
+ await asyncio.wait(pending, timeout=0.1)
366
+
367
+ async def drain_project_creation(self, alias: str, deadline: float) -> bool:
368
+ """Project deletion has already frozen new claims; only observe tasks."""
369
+ loop = self._own_loop()
370
+ tasks = {task for project, task in self._dispatches.values() if project == alias and not task.done()}
371
+ if not tasks:
372
+ return True
373
+ _, pending = await asyncio.wait(tasks, timeout=max(0, deadline - loop.time()))
374
+ return not pending