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
netizen_cli/main.py
ADDED
|
@@ -0,0 +1,903 @@
|
|
|
1
|
+
"""Single-process Feishu Channel + one long-lived AsyncCodex service."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
# The launcher makes this one descriptor inheritable only for its final exec.
|
|
8
|
+
# Restore CLOEXEC before importing SDK modules so import-time subprocesses could
|
|
9
|
+
# never inherit the service-lifetime lock.
|
|
10
|
+
_EARLY_LIFETIME_DESCRIPTOR = os.environ.get("NETIZEN_LIFETIME_LOCK_FD", "")
|
|
11
|
+
if _EARLY_LIFETIME_DESCRIPTOR.isdecimal():
|
|
12
|
+
try:
|
|
13
|
+
os.set_inheritable(int(_EARLY_LIFETIME_DESCRIPTOR), False)
|
|
14
|
+
except OSError:
|
|
15
|
+
# _adopt_lifetime_lock() reports the authoritative validation error.
|
|
16
|
+
pass
|
|
17
|
+
del _EARLY_LIFETIME_DESCRIPTOR
|
|
18
|
+
|
|
19
|
+
import time
|
|
20
|
+
|
|
21
|
+
_IMPORT_STARTED_AT = time.monotonic()
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
import concurrent.futures
|
|
25
|
+
import contextlib
|
|
26
|
+
import logging
|
|
27
|
+
import signal
|
|
28
|
+
import stat
|
|
29
|
+
import sys
|
|
30
|
+
import uuid
|
|
31
|
+
from collections.abc import Awaitable, Callable
|
|
32
|
+
from logging.handlers import RotatingFileHandler
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
from lark_channel import (
|
|
37
|
+
ChatQueueConfig,
|
|
38
|
+
ChannelConfig,
|
|
39
|
+
Events,
|
|
40
|
+
FeishuChannel,
|
|
41
|
+
InboundConfig,
|
|
42
|
+
LogLevel,
|
|
43
|
+
OutboundConfig,
|
|
44
|
+
PolicyConfig,
|
|
45
|
+
SafetyConfig,
|
|
46
|
+
SecurityConfig,
|
|
47
|
+
TextBatchConfig,
|
|
48
|
+
)
|
|
49
|
+
import lark_oapi as lark
|
|
50
|
+
from openai_codex import AsyncCodex, CodexConfig
|
|
51
|
+
|
|
52
|
+
from .admin.web import AdminWebRunner
|
|
53
|
+
from .admin.auth import CredentialFileError, load_credential_snapshot
|
|
54
|
+
from .bindings import BindingStore, validate_channel_database
|
|
55
|
+
from .builtin_skills import builtin_skill_root
|
|
56
|
+
from .cli_data import (
|
|
57
|
+
InstanceDataError,
|
|
58
|
+
StartupRejected,
|
|
59
|
+
acquire_lifetime_lock,
|
|
60
|
+
prepare_instance,
|
|
61
|
+
)
|
|
62
|
+
from .channel_app import ChannelApplication
|
|
63
|
+
from .codex_runtime import CodexRuntime
|
|
64
|
+
from .instance import resolve_instance_root
|
|
65
|
+
from .management import (
|
|
66
|
+
InstanceManagementService,
|
|
67
|
+
ManagementRuntimePort,
|
|
68
|
+
ScopeCoordinator,
|
|
69
|
+
)
|
|
70
|
+
from .message_history import FeishuMessageHistoryReader
|
|
71
|
+
from .projects import ProjectError, ProjectRegistry
|
|
72
|
+
from .sdk_gap_adapter import (
|
|
73
|
+
AppServerGoalControl,
|
|
74
|
+
AppServerSideBoundaryControl,
|
|
75
|
+
AppServerSkillCatalog,
|
|
76
|
+
AppServerSkillRoots,
|
|
77
|
+
AppServerThreadDeleteControl,
|
|
78
|
+
AppServerThreadSubscriptionControl,
|
|
79
|
+
SdkGapCapabilityUnavailable,
|
|
80
|
+
)
|
|
81
|
+
from .settings import Settings
|
|
82
|
+
from .schedules.mcp import ScheduleMcpRunner
|
|
83
|
+
from .schedules.scheduler import Scheduler
|
|
84
|
+
from .terminal_cleanup import PinnedExperimentalTerminalCleanup
|
|
85
|
+
from .turn_plan_observer import (
|
|
86
|
+
PinnedTurnActivityObserver,
|
|
87
|
+
TurnActivityObservationUnavailable,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
_IMPORT_SECONDS = time.monotonic() - _IMPORT_STARTED_AT
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
logger = logging.getLogger(__name__)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
_CODEX_SERVICE_CONFIG_OVERRIDES = ("allow_login_shell=false",)
|
|
97
|
+
_SHUTDOWN_BUDGET_SECONDS = 60.0
|
|
98
|
+
_READY_MARKER_CONTENT = b"netizen service ready\n"
|
|
99
|
+
_LOG_MAX_BYTES = 5 * 1024 * 1024
|
|
100
|
+
_LOG_BACKUP_COUNT = 2
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _log_startup_timing(stage: str, started_at: float) -> float:
|
|
104
|
+
now = time.monotonic()
|
|
105
|
+
logger.info("netizen startup: %s completed in %.3fs", stage, now - started_at)
|
|
106
|
+
return now
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _configure_platform_trust() -> None:
|
|
110
|
+
"""Use the native macOS trust store for application-owned TLS."""
|
|
111
|
+
|
|
112
|
+
if sys.platform != "darwin":
|
|
113
|
+
return
|
|
114
|
+
import truststore
|
|
115
|
+
|
|
116
|
+
# Netizen is the application entry point, so application-wide injection is
|
|
117
|
+
# intentional. It lets the Channel WebSocket honor Keychain-managed roots
|
|
118
|
+
# without generating a CA bundle or inventing another environment policy.
|
|
119
|
+
truststore.inject_into_ssl()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def build_channel(settings: Settings, store: BindingStore) -> FeishuChannel:
|
|
123
|
+
return FeishuChannel(
|
|
124
|
+
config=ChannelConfig(resolve_sender_names=True),
|
|
125
|
+
app_id=settings.app_id,
|
|
126
|
+
app_secret=settings.app_secret,
|
|
127
|
+
log_level=LogLevel.WARNING,
|
|
128
|
+
policy=PolicyConfig(
|
|
129
|
+
dm_policy="open",
|
|
130
|
+
group_policy="open",
|
|
131
|
+
require_mention=True,
|
|
132
|
+
respond_to_mention_all=False,
|
|
133
|
+
),
|
|
134
|
+
safety=SafetyConfig(
|
|
135
|
+
text_batch=TextBatchConfig(delay_ms=0, long_delay_ms=0),
|
|
136
|
+
# Scope/Thread serialization belongs to CodexRuntime. The SDK's
|
|
137
|
+
# queue is keyed only by chat_id and would incorrectly serialize
|
|
138
|
+
# independent Feishu topics in the same group.
|
|
139
|
+
chat_queue=ChatQueueConfig(enabled=False, merge_while_busy=False),
|
|
140
|
+
),
|
|
141
|
+
# ADR 0011's exact-version first-level quote adapter consumes the
|
|
142
|
+
# public InboundMessage.raw relation fields. Keep that contract
|
|
143
|
+
# explicit instead of relying on the SDK's current default.
|
|
144
|
+
inbound=InboundConfig(include_raw=True),
|
|
145
|
+
outbound=OutboundConfig(
|
|
146
|
+
reply_mode="static",
|
|
147
|
+
text_chunk_limit=3_500,
|
|
148
|
+
),
|
|
149
|
+
security=SecurityConfig(mode=settings.security_mode),
|
|
150
|
+
dedup_store=store,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class ServiceCore:
|
|
155
|
+
"""Objects that must live on FeishuChannel's background event loop."""
|
|
156
|
+
|
|
157
|
+
def __init__(
|
|
158
|
+
self,
|
|
159
|
+
*,
|
|
160
|
+
settings: Settings,
|
|
161
|
+
channel: FeishuChannel,
|
|
162
|
+
store: BindingStore,
|
|
163
|
+
projects: ProjectRegistry,
|
|
164
|
+
instance_root: Path,
|
|
165
|
+
) -> None:
|
|
166
|
+
self._settings = settings
|
|
167
|
+
self._channel = channel
|
|
168
|
+
self._store = store
|
|
169
|
+
self._projects = projects
|
|
170
|
+
self._instance_root = instance_root.resolve()
|
|
171
|
+
self._codex: AsyncCodex | None = None
|
|
172
|
+
self._runtime: CodexRuntime | None = None
|
|
173
|
+
self._management: InstanceManagementService | None = None
|
|
174
|
+
self._message_history_client: Any | None = None
|
|
175
|
+
self._admin: AdminWebRunner | None = None
|
|
176
|
+
self._schedule_mcp: ScheduleMcpRunner | None = None
|
|
177
|
+
self._scheduler: Scheduler | None = None
|
|
178
|
+
self.application: ChannelApplication | None = None
|
|
179
|
+
self._started = False
|
|
180
|
+
self._closed = False
|
|
181
|
+
|
|
182
|
+
async def start(self) -> None:
|
|
183
|
+
stage_started_at = time.monotonic()
|
|
184
|
+
try:
|
|
185
|
+
if self._settings.admin_web.enabled:
|
|
186
|
+
credential_path = self._settings.admin_web.credential_path
|
|
187
|
+
if credential_path is None:
|
|
188
|
+
raise RuntimeError(
|
|
189
|
+
"Admin Web is enabled without a credential path"
|
|
190
|
+
)
|
|
191
|
+
# Credential validation and the closed listener come first so
|
|
192
|
+
# a bad secret or occupied port cannot start the Feishu side.
|
|
193
|
+
self._admin = AdminWebRunner(
|
|
194
|
+
host=self._settings.admin_web.host,
|
|
195
|
+
port=self._settings.admin_web.port,
|
|
196
|
+
credential_path=credential_path,
|
|
197
|
+
instance_root=self._instance_root,
|
|
198
|
+
config_path=self._settings.config_path,
|
|
199
|
+
config_snapshot=self._settings.config_snapshot,
|
|
200
|
+
access_host=self._settings.admin_web.access_host,
|
|
201
|
+
)
|
|
202
|
+
await self._admin.bind()
|
|
203
|
+
stage_started_at = _log_startup_timing("Admin listener", stage_started_at)
|
|
204
|
+
|
|
205
|
+
# Bind the native MCP transport before App Server initializes its
|
|
206
|
+
# catalog. Calls remain closed until the shared application is ready.
|
|
207
|
+
self._schedule_mcp = ScheduleMcpRunner()
|
|
208
|
+
await self._schedule_mcp.bind()
|
|
209
|
+
stage_started_at = _log_startup_timing("Schedule MCP listener", stage_started_at)
|
|
210
|
+
|
|
211
|
+
expired_sides = self._store.expire_live_side_topics()
|
|
212
|
+
if expired_sides:
|
|
213
|
+
logger.info(
|
|
214
|
+
"expired Side Topics from a previous process",
|
|
215
|
+
extra={"count": len(expired_sides)},
|
|
216
|
+
)
|
|
217
|
+
self._codex = AsyncCodex(
|
|
218
|
+
CodexConfig(
|
|
219
|
+
config_overrides=(
|
|
220
|
+
*_CODEX_SERVICE_CONFIG_OVERRIDES,
|
|
221
|
+
*self._schedule_mcp.config_overrides,
|
|
222
|
+
),
|
|
223
|
+
env={
|
|
224
|
+
**os.environ,
|
|
225
|
+
**self._schedule_mcp.app_server_env,
|
|
226
|
+
"NETIZEN_ROOT": str(self._instance_root),
|
|
227
|
+
},
|
|
228
|
+
)
|
|
229
|
+
)
|
|
230
|
+
await self._codex.__aenter__()
|
|
231
|
+
# Release-local Skills must be active before catalogs, recovery or
|
|
232
|
+
# any native Thread can be reached. Failure closes this startup.
|
|
233
|
+
await AppServerSkillRoots(self._codex).set_roots((builtin_skill_root(),))
|
|
234
|
+
stage_started_at = _log_startup_timing("Codex connection", stage_started_at)
|
|
235
|
+
terminal_cleanup = PinnedExperimentalTerminalCleanup(self._codex)
|
|
236
|
+
thread_subscription_control = AppServerThreadSubscriptionControl(
|
|
237
|
+
self._codex
|
|
238
|
+
)
|
|
239
|
+
skill_catalog = None
|
|
240
|
+
goal_control = None
|
|
241
|
+
side_boundary_control = None
|
|
242
|
+
thread_delete_control = None
|
|
243
|
+
turn_plan_observer = None
|
|
244
|
+
try:
|
|
245
|
+
skill_catalog = AppServerSkillCatalog(self._codex)
|
|
246
|
+
except SdkGapCapabilityUnavailable as error:
|
|
247
|
+
logger.warning("native Skills unavailable: %s", error)
|
|
248
|
+
try:
|
|
249
|
+
goal_control = AppServerGoalControl(self._codex)
|
|
250
|
+
except SdkGapCapabilityUnavailable as error:
|
|
251
|
+
logger.warning("native Goal unavailable: %s", error)
|
|
252
|
+
if callable(getattr(self._codex, "thread_fork", None)):
|
|
253
|
+
try:
|
|
254
|
+
side_boundary_control = AppServerSideBoundaryControl(self._codex)
|
|
255
|
+
except SdkGapCapabilityUnavailable as error:
|
|
256
|
+
logger.warning("native Side unavailable: %s", error)
|
|
257
|
+
else:
|
|
258
|
+
logger.warning("native Side unavailable: AsyncCodex.thread_fork missing")
|
|
259
|
+
try:
|
|
260
|
+
thread_delete_control = AppServerThreadDeleteControl(self._codex)
|
|
261
|
+
except SdkGapCapabilityUnavailable as error:
|
|
262
|
+
logger.warning("native Thread Delete unavailable: %s", error)
|
|
263
|
+
try:
|
|
264
|
+
turn_plan_observer = PinnedTurnActivityObserver(self._codex)
|
|
265
|
+
except TurnActivityObservationUnavailable as error:
|
|
266
|
+
logger.warning("native Turn activity observation unavailable: %s", error)
|
|
267
|
+
self._runtime = CodexRuntime(
|
|
268
|
+
codex=self._codex,
|
|
269
|
+
bindings=self._store,
|
|
270
|
+
terminal_cleanup=terminal_cleanup,
|
|
271
|
+
skill_catalog=skill_catalog,
|
|
272
|
+
goal_control=goal_control,
|
|
273
|
+
side_boundary_control=side_boundary_control,
|
|
274
|
+
thread_subscription_control=thread_subscription_control,
|
|
275
|
+
background_terminal_inspector=terminal_cleanup,
|
|
276
|
+
thread_delete_control=thread_delete_control,
|
|
277
|
+
turn_plan_observer=turn_plan_observer,
|
|
278
|
+
)
|
|
279
|
+
scope_coordinator = ScopeCoordinator()
|
|
280
|
+
self._management = InstanceManagementService(
|
|
281
|
+
bindings=self._store,
|
|
282
|
+
projects=self._projects,
|
|
283
|
+
runtime=ManagementRuntimePort(self._runtime),
|
|
284
|
+
scope_coordinator=scope_coordinator,
|
|
285
|
+
chat_labels=self._channel,
|
|
286
|
+
root=self._instance_root,
|
|
287
|
+
)
|
|
288
|
+
schedules = self._management.enable_schedules(
|
|
289
|
+
app_id=self._settings.app_id, chat_info=self._channel,
|
|
290
|
+
)
|
|
291
|
+
self._management.enable_defaults(
|
|
292
|
+
app_id=self._settings.app_id, chat_info=self._channel,
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
async def manage_schedule(request: dict[str, Any], thread_id: str | None) -> dict[str, Any]:
|
|
296
|
+
return await schedules.manage(request, native_thread_id=thread_id)
|
|
297
|
+
|
|
298
|
+
self._schedule_mcp.attach(manage_schedule)
|
|
299
|
+
if self._admin is not None:
|
|
300
|
+
self._admin.attach_management(self._management)
|
|
301
|
+
self._message_history_client = (
|
|
302
|
+
lark.Client.builder()
|
|
303
|
+
.app_id(self._settings.app_id)
|
|
304
|
+
.app_secret(self._settings.app_secret)
|
|
305
|
+
.timeout(10)
|
|
306
|
+
.log_level(lark.LogLevel.WARNING)
|
|
307
|
+
.build()
|
|
308
|
+
)
|
|
309
|
+
self.application = ChannelApplication(
|
|
310
|
+
app_id=self._settings.app_id,
|
|
311
|
+
channel=self._channel,
|
|
312
|
+
runtime=self._runtime,
|
|
313
|
+
bindings=self._store,
|
|
314
|
+
projects=self._projects,
|
|
315
|
+
message_history=FeishuMessageHistoryReader(
|
|
316
|
+
self._message_history_client
|
|
317
|
+
),
|
|
318
|
+
management=self._management,
|
|
319
|
+
admin_urls=self._admin.urls if self._admin is not None else (),
|
|
320
|
+
admin_loopback_only=self._admin.loopback_only if self._admin is not None else None,
|
|
321
|
+
instance_root=self._instance_root,
|
|
322
|
+
)
|
|
323
|
+
self._scheduler = Scheduler(
|
|
324
|
+
bindings=self._store, runtime=self._runtime,
|
|
325
|
+
app_id=self._settings.app_id,
|
|
326
|
+
dispatch=self.application.dispatch_scheduled_run,
|
|
327
|
+
)
|
|
328
|
+
schedules.set_wake_handler(self._scheduler.wake)
|
|
329
|
+
schedules.set_refresh_handler(self._scheduler.refresh)
|
|
330
|
+
schedules.set_run_now_handler(self._scheduler.run_now)
|
|
331
|
+
self._management.set_schedule_creation_drain(self._scheduler.drain_project_creation)
|
|
332
|
+
await self._scheduler.recover()
|
|
333
|
+
if expired_sides:
|
|
334
|
+
await self.application.refresh_expired_side_cards(expired_sides)
|
|
335
|
+
self._started = True
|
|
336
|
+
_log_startup_timing("runtime recovery", stage_started_at)
|
|
337
|
+
except BaseException:
|
|
338
|
+
await self._close_partial_start()
|
|
339
|
+
raise
|
|
340
|
+
|
|
341
|
+
def open_admission(self) -> None:
|
|
342
|
+
if not self._started or self.application is None:
|
|
343
|
+
raise RuntimeError("service core is not ready for admission")
|
|
344
|
+
if self._admin is not None:
|
|
345
|
+
self._admin.open_admission()
|
|
346
|
+
if self._schedule_mcp is not None:
|
|
347
|
+
self._schedule_mcp.open_admission()
|
|
348
|
+
if self._scheduler is not None:
|
|
349
|
+
self._scheduler.start()
|
|
350
|
+
|
|
351
|
+
async def _close_partial_start(self) -> None:
|
|
352
|
+
deadline = asyncio.get_running_loop().time() + _SHUTDOWN_BUDGET_SECONDS
|
|
353
|
+
if self._scheduler is not None:
|
|
354
|
+
self._scheduler.close_admission()
|
|
355
|
+
await _cleanup_with_budget(
|
|
356
|
+
"partial Scheduler close", self._scheduler.close,
|
|
357
|
+
deadline=deadline, cap=5,
|
|
358
|
+
)
|
|
359
|
+
await _cleanup_with_budget(
|
|
360
|
+
"partial scheduled dispatch drain", lambda: self._scheduler.drain(deadline),
|
|
361
|
+
deadline=deadline, cap=10,
|
|
362
|
+
)
|
|
363
|
+
if self._schedule_mcp is not None:
|
|
364
|
+
self._schedule_mcp.close_admission()
|
|
365
|
+
await _cleanup_with_budget(
|
|
366
|
+
"partial schedule MCP close", self._schedule_mcp.close,
|
|
367
|
+
deadline=deadline, cap=5,
|
|
368
|
+
)
|
|
369
|
+
if self._admin is not None:
|
|
370
|
+
await _cleanup_with_budget(
|
|
371
|
+
"partial Admin listener close",
|
|
372
|
+
self._admin.close_listener,
|
|
373
|
+
deadline=deadline,
|
|
374
|
+
cap=5,
|
|
375
|
+
)
|
|
376
|
+
await _cleanup_with_budget(
|
|
377
|
+
"partial Admin handler drain",
|
|
378
|
+
lambda: self._admin.drain(deadline),
|
|
379
|
+
deadline=deadline,
|
|
380
|
+
cap=10,
|
|
381
|
+
)
|
|
382
|
+
self._admin.close_auth()
|
|
383
|
+
if self._management is not None:
|
|
384
|
+
await _cleanup_with_budget(
|
|
385
|
+
"partial management I/O close",
|
|
386
|
+
lambda: self._management.close(deadline=deadline),
|
|
387
|
+
deadline=deadline,
|
|
388
|
+
cap=10,
|
|
389
|
+
)
|
|
390
|
+
if self._runtime is not None:
|
|
391
|
+
self._runtime.close_admission()
|
|
392
|
+
await _cleanup_with_budget(
|
|
393
|
+
"partial Runtime task cleanup",
|
|
394
|
+
self._runtime.cancel_tasks,
|
|
395
|
+
deadline=deadline,
|
|
396
|
+
cap=5,
|
|
397
|
+
)
|
|
398
|
+
if self._codex is not None:
|
|
399
|
+
await _cleanup_with_budget(
|
|
400
|
+
"partial Codex transport close",
|
|
401
|
+
self._codex.close,
|
|
402
|
+
deadline=deadline,
|
|
403
|
+
cap=10,
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
async def close(self) -> None:
|
|
407
|
+
if self._closed:
|
|
408
|
+
return
|
|
409
|
+
self._closed = True
|
|
410
|
+
if self._management is not None:
|
|
411
|
+
self._management.set_service_ready(False)
|
|
412
|
+
deadline = asyncio.get_running_loop().time() + _SHUTDOWN_BUDGET_SECONDS
|
|
413
|
+
management_closed = self._management is None
|
|
414
|
+
try:
|
|
415
|
+
if self._scheduler is not None:
|
|
416
|
+
self._scheduler.close_admission()
|
|
417
|
+
if self._schedule_mcp is not None:
|
|
418
|
+
self._schedule_mcp.close_admission()
|
|
419
|
+
if self._admin is not None:
|
|
420
|
+
self._admin.close_admission()
|
|
421
|
+
try:
|
|
422
|
+
self._channel.update_policy(
|
|
423
|
+
dm_policy="disabled",
|
|
424
|
+
group_policy="disabled",
|
|
425
|
+
)
|
|
426
|
+
except Exception:
|
|
427
|
+
logger.exception("failed to disable Feishu admission")
|
|
428
|
+
if self._runtime is not None:
|
|
429
|
+
self._runtime.close_admission()
|
|
430
|
+
if self._scheduler is not None:
|
|
431
|
+
await _cleanup_with_budget(
|
|
432
|
+
"Scheduler timer close", self._scheduler.close,
|
|
433
|
+
deadline=deadline, cap=3,
|
|
434
|
+
)
|
|
435
|
+
if self._admin is not None:
|
|
436
|
+
await _cleanup_with_budget(
|
|
437
|
+
"Admin listener close",
|
|
438
|
+
self._admin.close_listener,
|
|
439
|
+
deadline=deadline,
|
|
440
|
+
cap=5,
|
|
441
|
+
)
|
|
442
|
+
safety = getattr(self._channel, "safety", None)
|
|
443
|
+
if safety is not None:
|
|
444
|
+
await _cleanup_with_budget(
|
|
445
|
+
"Feishu handler drain",
|
|
446
|
+
safety.dispose,
|
|
447
|
+
deadline=deadline,
|
|
448
|
+
cap=10,
|
|
449
|
+
)
|
|
450
|
+
if self._admin is not None:
|
|
451
|
+
await _cleanup_with_budget(
|
|
452
|
+
"Admin handler drain",
|
|
453
|
+
lambda: self._admin.drain(deadline),
|
|
454
|
+
deadline=deadline,
|
|
455
|
+
cap=_SHUTDOWN_BUDGET_SECONDS,
|
|
456
|
+
)
|
|
457
|
+
if self._schedule_mcp is not None:
|
|
458
|
+
await _cleanup_with_budget(
|
|
459
|
+
"schedule MCP handler drain", lambda: self._schedule_mcp.drain(deadline),
|
|
460
|
+
deadline=deadline, cap=10,
|
|
461
|
+
)
|
|
462
|
+
if self._scheduler is not None:
|
|
463
|
+
await _cleanup_with_budget(
|
|
464
|
+
"scheduled dispatch drain", lambda: self._scheduler.drain(deadline),
|
|
465
|
+
deadline=deadline, cap=10,
|
|
466
|
+
)
|
|
467
|
+
if self._management is not None:
|
|
468
|
+
management_closed = await _cleanup_with_budget(
|
|
469
|
+
"management I/O drain",
|
|
470
|
+
lambda: self._management.close(deadline=deadline),
|
|
471
|
+
deadline=deadline,
|
|
472
|
+
cap=15,
|
|
473
|
+
)
|
|
474
|
+
if self._runtime is not None:
|
|
475
|
+
interrupted = await _cleanup_with_budget(
|
|
476
|
+
"native Turn interrupt",
|
|
477
|
+
self._runtime.interrupt_all,
|
|
478
|
+
deadline=deadline,
|
|
479
|
+
cap=15,
|
|
480
|
+
)
|
|
481
|
+
if interrupted:
|
|
482
|
+
remaining = max(
|
|
483
|
+
0.0,
|
|
484
|
+
min(15.0, deadline - asyncio.get_running_loop().time()),
|
|
485
|
+
)
|
|
486
|
+
idle = remaining > 0 and await self._runtime.wait_idle(
|
|
487
|
+
timeout=remaining
|
|
488
|
+
)
|
|
489
|
+
if not idle:
|
|
490
|
+
logger.warning("native Turns did not drain before SDK close")
|
|
491
|
+
else:
|
|
492
|
+
logger.warning(
|
|
493
|
+
"native Turn cleanup did not complete; skipping completion drain"
|
|
494
|
+
)
|
|
495
|
+
finally:
|
|
496
|
+
try:
|
|
497
|
+
try:
|
|
498
|
+
if self.application is not None:
|
|
499
|
+
await _cleanup_with_budget(
|
|
500
|
+
"Feishu presentation cleanup",
|
|
501
|
+
self.application.close,
|
|
502
|
+
deadline=deadline,
|
|
503
|
+
cap=4,
|
|
504
|
+
)
|
|
505
|
+
finally:
|
|
506
|
+
# A cancelled or timed-out drain leaves submitted I/O running.
|
|
507
|
+
# Give it one final bounded join within the same shutdown budget.
|
|
508
|
+
if not management_closed and self._management is not None:
|
|
509
|
+
await _cleanup_with_budget(
|
|
510
|
+
"management I/O final cleanup",
|
|
511
|
+
lambda: self._management.close(deadline=deadline),
|
|
512
|
+
deadline=deadline,
|
|
513
|
+
cap=4,
|
|
514
|
+
)
|
|
515
|
+
finally:
|
|
516
|
+
try:
|
|
517
|
+
if self._codex is not None:
|
|
518
|
+
await _cleanup_with_budget(
|
|
519
|
+
"Codex transport close",
|
|
520
|
+
self._codex.close,
|
|
521
|
+
deadline=deadline,
|
|
522
|
+
cap=10,
|
|
523
|
+
)
|
|
524
|
+
finally:
|
|
525
|
+
if self._runtime is not None:
|
|
526
|
+
await _cleanup_with_budget(
|
|
527
|
+
"Runtime task cleanup",
|
|
528
|
+
self._runtime.cancel_tasks,
|
|
529
|
+
deadline=deadline,
|
|
530
|
+
cap=5,
|
|
531
|
+
)
|
|
532
|
+
if self._admin is not None:
|
|
533
|
+
self._admin.close_auth()
|
|
534
|
+
if self._schedule_mcp is not None:
|
|
535
|
+
await _cleanup_with_budget(
|
|
536
|
+
"schedule MCP transport close", self._schedule_mcp.close,
|
|
537
|
+
deadline=deadline, cap=5,
|
|
538
|
+
)
|
|
539
|
+
await _cleanup_with_budget(
|
|
540
|
+
"Binding Store close",
|
|
541
|
+
self._store.aclose,
|
|
542
|
+
deadline=deadline,
|
|
543
|
+
cap=5,
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
async def run(
|
|
548
|
+
settings: Settings,
|
|
549
|
+
*,
|
|
550
|
+
instance_root: Path,
|
|
551
|
+
ready_file: Path | None = None,
|
|
552
|
+
) -> None:
|
|
553
|
+
stage_started_at = time.monotonic()
|
|
554
|
+
# Initialization belongs to explicit setup. A lost database must never turn
|
|
555
|
+
# into a fresh empty instance merely because Runtime was restarted.
|
|
556
|
+
validate_channel_database(settings.data_dir / "channel.sqlite3")
|
|
557
|
+
store = BindingStore(settings.data_dir / "channel.sqlite3")
|
|
558
|
+
stage_started_at = _log_startup_timing("database", stage_started_at)
|
|
559
|
+
try:
|
|
560
|
+
try:
|
|
561
|
+
projects = ProjectRegistry(
|
|
562
|
+
store=store,
|
|
563
|
+
project_root=settings.project_root,
|
|
564
|
+
projects=settings.projects,
|
|
565
|
+
)
|
|
566
|
+
except ProjectError as error:
|
|
567
|
+
raise StartupRejected(str(error)) from error
|
|
568
|
+
channel = build_channel(settings, store)
|
|
569
|
+
except BaseException:
|
|
570
|
+
store.close()
|
|
571
|
+
raise
|
|
572
|
+
core = ServiceCore(
|
|
573
|
+
settings=settings,
|
|
574
|
+
channel=channel,
|
|
575
|
+
store=store,
|
|
576
|
+
projects=projects,
|
|
577
|
+
instance_root=instance_root,
|
|
578
|
+
)
|
|
579
|
+
_log_startup_timing("Projects and Channel construction", stage_started_at)
|
|
580
|
+
stop_event = asyncio.Event()
|
|
581
|
+
loop = asyncio.get_running_loop()
|
|
582
|
+
for signal_name in (signal.SIGINT, signal.SIGTERM):
|
|
583
|
+
try:
|
|
584
|
+
loop.add_signal_handler(signal_name, stop_event.set)
|
|
585
|
+
except NotImplementedError: # pragma: no cover - Windows
|
|
586
|
+
pass
|
|
587
|
+
|
|
588
|
+
core_started = False
|
|
589
|
+
try:
|
|
590
|
+
await _await_channel_future(channel.schedule(core.start()))
|
|
591
|
+
core_started = True
|
|
592
|
+
assert core.application is not None
|
|
593
|
+
stage_started_at = time.monotonic()
|
|
594
|
+
await _start_channel_input(channel, core.application, ready_file=ready_file)
|
|
595
|
+
_log_startup_timing("Feishu connection", stage_started_at)
|
|
596
|
+
await _await_channel_future(channel.schedule(_open_core_admission(core, ready_file=ready_file)))
|
|
597
|
+
logger.info(
|
|
598
|
+
"netizen service ready",
|
|
599
|
+
extra={"projects": len(projects.list())},
|
|
600
|
+
)
|
|
601
|
+
await stop_event.wait()
|
|
602
|
+
finally:
|
|
603
|
+
try:
|
|
604
|
+
if core_started:
|
|
605
|
+
await _await_channel_future(channel.schedule(core.close()))
|
|
606
|
+
finally:
|
|
607
|
+
try:
|
|
608
|
+
await asyncio.to_thread(channel.stop)
|
|
609
|
+
if not core._closed:
|
|
610
|
+
store.close()
|
|
611
|
+
finally:
|
|
612
|
+
if ready_file is not None:
|
|
613
|
+
_clear_ready_marker(ready_file)
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
async def _start_channel_input(
|
|
617
|
+
channel: FeishuChannel,
|
|
618
|
+
application: ChannelApplication,
|
|
619
|
+
*,
|
|
620
|
+
ready_file: Path | None,
|
|
621
|
+
) -> None:
|
|
622
|
+
_register_channel_handlers(channel, application)
|
|
623
|
+
await channel.start_background()
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
async def _open_core_admission(core: ServiceCore, *, ready_file: Path | None = None) -> None:
|
|
627
|
+
core.open_admission()
|
|
628
|
+
if ready_file is not None:
|
|
629
|
+
_publish_ready_marker(ready_file)
|
|
630
|
+
assert core._management is not None
|
|
631
|
+
core._management.set_service_ready(True)
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
async def _await_channel_future(
|
|
635
|
+
future: concurrent.futures.Future[Any],
|
|
636
|
+
) -> Any:
|
|
637
|
+
return await asyncio.wrap_future(future)
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
async def _cleanup_step(
|
|
641
|
+
label: str,
|
|
642
|
+
operation: Awaitable[object],
|
|
643
|
+
*,
|
|
644
|
+
timeout: float,
|
|
645
|
+
) -> bool:
|
|
646
|
+
try:
|
|
647
|
+
await asyncio.wait_for(operation, timeout=timeout)
|
|
648
|
+
except TimeoutError:
|
|
649
|
+
logger.warning("%s timed out after %.1fs", label, timeout)
|
|
650
|
+
return False
|
|
651
|
+
except Exception:
|
|
652
|
+
logger.exception("%s failed", label)
|
|
653
|
+
return False
|
|
654
|
+
return True
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
async def _cleanup_with_budget(
|
|
658
|
+
label: str,
|
|
659
|
+
operation: Callable[[], Awaitable[object]],
|
|
660
|
+
*,
|
|
661
|
+
deadline: float,
|
|
662
|
+
cap: float,
|
|
663
|
+
) -> bool:
|
|
664
|
+
remaining = min(cap, deadline - asyncio.get_running_loop().time())
|
|
665
|
+
if remaining <= 0:
|
|
666
|
+
logger.warning("%s skipped because the shutdown budget was exhausted", label)
|
|
667
|
+
return False
|
|
668
|
+
return await _cleanup_step(label, operation(), timeout=remaining)
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def _log_channel_error(error: object) -> None:
|
|
672
|
+
logger.error("Feishu Channel error: %s", type(error).__name__)
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def _register_channel_handlers(
|
|
676
|
+
channel: FeishuChannel,
|
|
677
|
+
application: ChannelApplication,
|
|
678
|
+
) -> None:
|
|
679
|
+
channel.on(Events.MESSAGE, application.handle_message)
|
|
680
|
+
channel.on(Events.CARD_ACTION, application.handle_card_action)
|
|
681
|
+
channel.on(Events.ERROR, _log_channel_error)
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def _scrub_channel_environment() -> None:
|
|
685
|
+
# Hygiene only: with the accepted same-user/full-access Pilot boundary,
|
|
686
|
+
# Codex can still read the protected secret file if explicitly instructed.
|
|
687
|
+
# NETIZEN_ROOT stays as the non-secret, exact instance context for tools.
|
|
688
|
+
os.environ.pop("FEISHU_APP_SECRET", None)
|
|
689
|
+
os.environ.pop("FEISHU_APP_SECRET_FILE", None)
|
|
690
|
+
os.environ.pop("NETIZEN_LARK_APP_CONFIG", None)
|
|
691
|
+
os.environ.pop("NETIZEN_ADMIN_SECRET", None)
|
|
692
|
+
os.environ.pop("NETIZEN_ADMIN_SECRET_FILE", None)
|
|
693
|
+
os.environ.pop("NETIZEN_CONFIG_PATH", None)
|
|
694
|
+
os.environ.pop("NETIZEN_LOG_FILE", None)
|
|
695
|
+
os.environ.pop("NETIZEN_MANAGED_LAUNCH_AGENT", None)
|
|
696
|
+
os.environ.pop("NETIZEN_READY_FILE", None)
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def _managed_absolute_path(raw: str, *, label: str) -> Path:
|
|
700
|
+
path = Path(raw)
|
|
701
|
+
if not path.is_absolute() or path == Path(path.anchor):
|
|
702
|
+
raise StartupRejected(f"{label} must be an absolute non-root path: {path}")
|
|
703
|
+
return path
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def _validate_instance_path(path: Path, expected: Path, *, label: str) -> None:
|
|
707
|
+
if not path.is_absolute() or path.resolve() != expected:
|
|
708
|
+
raise StartupRejected(f"{label} must belong to NETIZEN_ROOT: expected {expected}, got {path}")
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def _validate_instance_environment(
|
|
712
|
+
root: Path, config_path: Path, environment: dict[str, str],
|
|
713
|
+
) -> None:
|
|
714
|
+
"""Reject mixed instance paths before opening logs, credentials or state."""
|
|
715
|
+
_validate_instance_path(config_path, root / "config.yaml", label="NETIZEN_CONFIG_PATH")
|
|
716
|
+
expected_paths = {
|
|
717
|
+
"NETIZEN_LARK_APP_CONFIG": root / "lark-app" / "config.json",
|
|
718
|
+
"NETIZEN_ADMIN_SECRET_FILE": root / "credentials" / "admin-web-secret",
|
|
719
|
+
"NETIZEN_LOG_FILE": root / "state" / "netizen.log",
|
|
720
|
+
"NETIZEN_READY_FILE": root / "state" / "service.ready",
|
|
721
|
+
"NETIZEN_LIFETIME_LOCK_FILE": root / "state" / "service.lifetime.lock",
|
|
722
|
+
}
|
|
723
|
+
for name, expected in expected_paths.items():
|
|
724
|
+
if name in environment:
|
|
725
|
+
_validate_instance_path(Path(environment[name]), expected, label=name)
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def _adopt_lifetime_lock() -> int | None:
|
|
729
|
+
raw_descriptor = os.environ.pop("NETIZEN_LIFETIME_LOCK_FD", None)
|
|
730
|
+
raw_path = os.environ.pop("NETIZEN_LIFETIME_LOCK_FILE", None)
|
|
731
|
+
if raw_descriptor is None and raw_path is None:
|
|
732
|
+
return None
|
|
733
|
+
if raw_descriptor is None or raw_path is None:
|
|
734
|
+
raise StartupRejected("managed service lifetime lock environment is incomplete")
|
|
735
|
+
if not raw_descriptor.isdecimal():
|
|
736
|
+
raise StartupRejected("NETIZEN_LIFETIME_LOCK_FD is not a file descriptor")
|
|
737
|
+
descriptor = int(raw_descriptor)
|
|
738
|
+
lock_path = _managed_absolute_path(
|
|
739
|
+
raw_path,
|
|
740
|
+
label="NETIZEN_LIFETIME_LOCK_FILE",
|
|
741
|
+
)
|
|
742
|
+
if lock_path.is_symlink():
|
|
743
|
+
raise StartupRejected(f"service lifetime lock must not be a symlink: {lock_path}")
|
|
744
|
+
try:
|
|
745
|
+
descriptor_metadata = os.fstat(descriptor)
|
|
746
|
+
path_metadata = lock_path.stat()
|
|
747
|
+
except OSError as error:
|
|
748
|
+
raise StartupRejected(f"could not adopt service lifetime lock: {error}") from error
|
|
749
|
+
if (
|
|
750
|
+
not stat.S_ISREG(descriptor_metadata.st_mode)
|
|
751
|
+
or descriptor_metadata.st_uid != os.geteuid()
|
|
752
|
+
or (descriptor_metadata.st_dev, descriptor_metadata.st_ino)
|
|
753
|
+
!= (path_metadata.st_dev, path_metadata.st_ino)
|
|
754
|
+
):
|
|
755
|
+
raise StartupRejected("service lifetime lock FD does not match its managed path")
|
|
756
|
+
os.set_inheritable(descriptor, False)
|
|
757
|
+
return descriptor
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def _publish_ready_marker(path: Path) -> None:
|
|
761
|
+
temporary = path.parent / f".{path.name}.{os.getpid()}.{uuid.uuid4().hex}"
|
|
762
|
+
descriptor: int | None = None
|
|
763
|
+
try:
|
|
764
|
+
descriptor = os.open(
|
|
765
|
+
temporary,
|
|
766
|
+
os.O_WRONLY | os.O_CREAT | os.O_EXCL,
|
|
767
|
+
0o600,
|
|
768
|
+
)
|
|
769
|
+
view = memoryview(_READY_MARKER_CONTENT)
|
|
770
|
+
while view:
|
|
771
|
+
written = os.write(descriptor, view)
|
|
772
|
+
view = view[written:]
|
|
773
|
+
os.fsync(descriptor)
|
|
774
|
+
os.close(descriptor)
|
|
775
|
+
descriptor = None
|
|
776
|
+
os.replace(temporary, path)
|
|
777
|
+
os.chmod(path, 0o600)
|
|
778
|
+
except OSError as error:
|
|
779
|
+
raise RuntimeError(f"could not publish service ready marker {path}: {error}") from error
|
|
780
|
+
finally:
|
|
781
|
+
if descriptor is not None:
|
|
782
|
+
with contextlib.suppress(OSError):
|
|
783
|
+
os.close(descriptor)
|
|
784
|
+
with contextlib.suppress(OSError):
|
|
785
|
+
temporary.unlink()
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def _clear_ready_marker(path: Path) -> None:
|
|
789
|
+
if not path.exists() and not path.is_symlink():
|
|
790
|
+
return
|
|
791
|
+
if path.is_dir() and not path.is_symlink():
|
|
792
|
+
logger.error("service ready marker became a directory: %s", path)
|
|
793
|
+
return
|
|
794
|
+
try:
|
|
795
|
+
path.unlink()
|
|
796
|
+
except OSError:
|
|
797
|
+
logger.exception("failed to clear service ready marker")
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def _configure_logging() -> None:
|
|
801
|
+
raw_log_file = os.environ.get("NETIZEN_LOG_FILE", "")
|
|
802
|
+
if not raw_log_file.strip():
|
|
803
|
+
logging.basicConfig(
|
|
804
|
+
level=logging.INFO,
|
|
805
|
+
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
|
806
|
+
force=True,
|
|
807
|
+
)
|
|
808
|
+
return
|
|
809
|
+
log_file = _managed_absolute_path(raw_log_file, label="NETIZEN_LOG_FILE")
|
|
810
|
+
if log_file.is_symlink() or (log_file.exists() and not log_file.is_file()):
|
|
811
|
+
raise RuntimeError(f"managed service log is not a regular file: {log_file}")
|
|
812
|
+
handler = RotatingFileHandler(
|
|
813
|
+
log_file,
|
|
814
|
+
maxBytes=_LOG_MAX_BYTES,
|
|
815
|
+
backupCount=_LOG_BACKUP_COUNT,
|
|
816
|
+
encoding="utf-8",
|
|
817
|
+
)
|
|
818
|
+
os.chmod(log_file, 0o600)
|
|
819
|
+
logging.basicConfig(
|
|
820
|
+
level=logging.INFO,
|
|
821
|
+
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
|
822
|
+
handlers=[handler],
|
|
823
|
+
force=True,
|
|
824
|
+
)
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def main() -> None:
|
|
828
|
+
try:
|
|
829
|
+
_run_service()
|
|
830
|
+
except StartupRejected as error:
|
|
831
|
+
if os.environ.get("NETIZEN_CLI_SERVICE") != "1":
|
|
832
|
+
raise
|
|
833
|
+
# systemd Restart=on-failure and launchd SuccessfulExit=false must not
|
|
834
|
+
# loop over unchanged invalid data. No readiness marker is published;
|
|
835
|
+
# `netizen start` still reports failure when readiness is not reached.
|
|
836
|
+
logger.error("Netizen startup rejected: %s; repair the instance and start it again", error)
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def _run_service() -> None:
|
|
840
|
+
startup_environment = dict(os.environ)
|
|
841
|
+
lifetime_descriptor = _adopt_lifetime_lock()
|
|
842
|
+
try:
|
|
843
|
+
stage_started_at = time.monotonic()
|
|
844
|
+
if lifetime_descriptor is not None and "NETIZEN_ROOT" not in startup_environment:
|
|
845
|
+
raise StartupRejected("managed service environment is missing NETIZEN_ROOT")
|
|
846
|
+
try:
|
|
847
|
+
instance_root = resolve_instance_root(environ=startup_environment)
|
|
848
|
+
except ValueError as error:
|
|
849
|
+
raise StartupRejected(str(error)) from error
|
|
850
|
+
config_path = Path(startup_environment.get(
|
|
851
|
+
"NETIZEN_CONFIG_PATH",
|
|
852
|
+
str(instance_root / "config.yaml"),
|
|
853
|
+
))
|
|
854
|
+
_validate_instance_environment(instance_root, config_path, startup_environment)
|
|
855
|
+
# The entry point owns the instance context for both managed and manual
|
|
856
|
+
# starts. Export it before creating any Codex/tool subprocesses.
|
|
857
|
+
os.environ["NETIZEN_ROOT"] = str(instance_root)
|
|
858
|
+
os.environ["NETIZEN_LARK_APP_CONFIG"] = str(instance_root / "lark-app" / "config.json")
|
|
859
|
+
os.environ["NETIZEN_ADMIN_SECRET_FILE"] = str(instance_root / "credentials" / "admin-web-secret")
|
|
860
|
+
_configure_platform_trust()
|
|
861
|
+
raw_ready_file = startup_environment.get("NETIZEN_READY_FILE", "")
|
|
862
|
+
if lifetime_descriptor is not None and not raw_ready_file:
|
|
863
|
+
raise StartupRejected("managed service environment is missing NETIZEN_READY_FILE")
|
|
864
|
+
ready_file = (
|
|
865
|
+
_managed_absolute_path(raw_ready_file, label="NETIZEN_READY_FILE")
|
|
866
|
+
if lifetime_descriptor is not None
|
|
867
|
+
else None
|
|
868
|
+
)
|
|
869
|
+
try:
|
|
870
|
+
settings = Settings.from_file(config_path)
|
|
871
|
+
except (ValueError, FileNotFoundError, PermissionError) as error:
|
|
872
|
+
raise StartupRejected(str(error)) from error
|
|
873
|
+
_validate_instance_path(settings.data_dir, instance_root / "state", label="instance.dataDir")
|
|
874
|
+
if settings.admin_web.credential_path is not None:
|
|
875
|
+
_validate_instance_path(
|
|
876
|
+
settings.admin_web.credential_path,
|
|
877
|
+
instance_root / "credentials" / "admin-web-secret",
|
|
878
|
+
label="Admin credential path",
|
|
879
|
+
)
|
|
880
|
+
if settings.admin_web.enabled:
|
|
881
|
+
try:
|
|
882
|
+
load_credential_snapshot(settings.admin_web.credential_path)
|
|
883
|
+
except CredentialFileError as error:
|
|
884
|
+
raise StartupRejected(str(error)) from error
|
|
885
|
+
if lifetime_descriptor is None:
|
|
886
|
+
try:
|
|
887
|
+
lifetime_descriptor = acquire_lifetime_lock(instance_root)
|
|
888
|
+
except (InstanceDataError, FileNotFoundError, PermissionError) as error:
|
|
889
|
+
raise StartupRejected(str(error)) from error
|
|
890
|
+
prepare_instance(instance_root, lifetime_descriptor=lifetime_descriptor)
|
|
891
|
+
_configure_logging()
|
|
892
|
+
logger.info("netizen startup: module imports completed in %.3fs", _IMPORT_SECONDS)
|
|
893
|
+
_scrub_channel_environment()
|
|
894
|
+
_log_startup_timing("configuration", stage_started_at)
|
|
895
|
+
asyncio.run(run(settings, instance_root=instance_root, ready_file=ready_file))
|
|
896
|
+
finally:
|
|
897
|
+
if lifetime_descriptor is not None:
|
|
898
|
+
with contextlib.suppress(OSError):
|
|
899
|
+
os.close(lifetime_descriptor)
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
if __name__ == "__main__":
|
|
903
|
+
main()
|