codebuff-agent 2.1.74__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.
- codebuff_agent-2.1.74.data/data/etc/jupyter/jupyter_server_config.d/codebuff_agent.json +7 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/package.json +157 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/104.2b8f20f9d1ac2c9db01b.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/150.39fa98117cc8ca590465.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/166.386ff9b2c37ac865fdee.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/216.25cf81131bf7274dabf4.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/353.581bd1cb00746399b62e.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/367.575f9621b63c9a641aa9.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/421.11dac223a2c2da1b09f5.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/432.8513b4c6cc1d1232d165.js +2 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/432.8513b4c6cc1d1232d165.js.LICENSE.txt +19 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/439.ba5b5ef70ec6de214b8c.js +2 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/439.ba5b5ef70ec6de214b8c.js.LICENSE.txt +8 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/481.cc2dec9fa869d66f1407.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/487.0796e931d6e651482c14.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/49.d326f554b6f9ccde6580.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/541.26897ea32845659e36be.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/579.e569d3edd8391fd8fbb3.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/58.eb7cc679f7535cc8a2f1.js +2 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/58.eb7cc679f7535cc8a2f1.js.LICENSE.txt +8 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/666.d9bfdb23f98489d0a188.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/677.d266c39dbed33bc388b3.js +2 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/677.d266c39dbed33bc388b3.js.LICENSE.txt +8 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/68.26b6f59c8454adb17f20.js +204 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/694.4ac443814260057e68f7.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/791.61affb8d3480d3d60e0d.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/848.4d2db09cfbb09374b299.js +2 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/848.4d2db09cfbb09374b299.js.LICENSE.txt +9 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/868.5cfe7e1a348ec5af1b8c.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/97.ea559a5b8a9a09f17c6b.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/remoteEntry.5292fc9c6df7e7920cc8.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/style.js +4 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/third-party-licenses.json +724 -0
- codebuff_agent-2.1.74.dist-info/METADATA +142 -0
- codebuff_agent-2.1.74.dist-info/RECORD +217 -0
- codebuff_agent-2.1.74.dist-info/WHEEL +4 -0
- codebuff_agent-2.1.74.dist-info/licenses/LICENSE +21 -0
- codebuff_core/__init__.py +153 -0
- codebuff_core/codebuff_config_defaults.json +64 -0
- codebuff_core/core/__init__.py +35 -0
- codebuff_core/core/code_validator.py +1238 -0
- codebuff_core/core/error_classifier.py +579 -0
- codebuff_core/core/exceptions.py +49 -0
- codebuff_core/data/tiktoken_cache/9b5ad71b2ce5302211f9c61530b329a4922fc6a4 +100256 -0
- codebuff_core/directory_policy.py +32 -0
- codebuff_core/factory.py +155 -0
- codebuff_core/interfaces.py +197 -0
- codebuff_core/knowledge/__init__.py +31 -0
- codebuff_core/knowledge/chunking.py +375 -0
- codebuff_core/knowledge/loader.py +339 -0
- codebuff_core/knowledge/watchdog_service.py +354 -0
- codebuff_core/legacy_paths.py +69 -0
- codebuff_core/llm/__init__.py +13 -0
- codebuff_core/llm/service.py +407 -0
- codebuff_core/managers/__init__.py +22 -0
- codebuff_core/managers/config_manager.py +690 -0
- codebuff_core/managers/remote_config.py +673 -0
- codebuff_core/managers/remote_skills.py +539 -0
- codebuff_core/managers/session_manager.py +255 -0
- codebuff_core/middleware/__init__.py +3 -0
- codebuff_core/middleware/error_recovery.py +394 -0
- codebuff_core/middleware/logging_utils.py +33 -0
- codebuff_core/models/__init__.py +111 -0
- codebuff_core/models/agent.py +306 -0
- codebuff_core/models/chat.py +57 -0
- codebuff_core/models/common.py +82 -0
- codebuff_core/models/rag.py +300 -0
- codebuff_core/platform_rules.py +127 -0
- codebuff_core/prompts/__init__.py +44 -0
- codebuff_core/prompts/auto_agent_prompts.py +1278 -0
- codebuff_core/services/__init__.py +30 -0
- codebuff_core/services/agent_service.py +345 -0
- codebuff_core/services/analytics_aggregate.py +837 -0
- codebuff_core/services/chat_service.py +898 -0
- codebuff_core/services/context_condenser.py +306 -0
- codebuff_core/services/pii_guardrail.py +393 -0
- codebuff_core/services/rag_chat_agent.py +2993 -0
- codebuff_core/services/rag_collections.py +2740 -0
- codebuff_core/services/rag_manager.py +890 -0
- codebuff_core/services/rag_service.py +145 -0
- codebuff_core/services/retriever.py +260 -0
- codebuff_core/services/vllm_embedding_service.py +290 -0
- codebuff_core/tests/__init__.py +1 -0
- codebuff_core/tests/conftest.py +81 -0
- codebuff_core/tests/test_error_recovery.py +647 -0
- codebuff_core/tests/test_factory.py +135 -0
- codebuff_core/tests/test_services.py +150 -0
- jupyter_ext/__init__.py +266 -0
- jupyter_ext/_version.py +4 -0
- jupyter_ext/agent/__init__.py +49 -0
- jupyter_ext/agent/agent_factory.py +2270 -0
- jupyter_ext/agent/ast_tools.py +451 -0
- jupyter_ext/agent/autofix_middleware.py +481 -0
- jupyter_ext/agent/background_procs.py +372 -0
- jupyter_ext/agent/code_history_middleware.py +211 -0
- jupyter_ext/agent/codex_autofix.py +160 -0
- jupyter_ext/agent/codex_bridge.py +3339 -0
- jupyter_ext/agent/codex_hooks.py +320 -0
- jupyter_ext/agent/codex_slash.py +685 -0
- jupyter_ext/agent/codex_threads.py +114 -0
- jupyter_ext/agent/content_injection.py +377 -0
- jupyter_ext/agent/continuation_middleware.py +548 -0
- jupyter_ext/agent/custom_skills.py +335 -0
- jupyter_ext/agent/file_diffs.py +99 -0
- jupyter_ext/agent/git_history.py +188 -0
- jupyter_ext/agent/guardrail_middleware.py +421 -0
- jupyter_ext/agent/json_repair_utils.py +54 -0
- jupyter_ext/agent/langchain_tools.py +2673 -0
- jupyter_ext/agent/mermaid_fixer.py +289 -0
- jupyter_ext/agent/mermaid_middleware.py +135 -0
- jupyter_ext/agent/middleware.py +359 -0
- jupyter_ext/agent/middleware_prompts.py +53 -0
- jupyter_ext/agent/observability.py +334 -0
- jupyter_ext/agent/patch_tool_calls.py +57 -0
- jupyter_ext/agent/prompts.py +112 -0
- jupyter_ext/agent/skill_middleware.py +257 -0
- jupyter_ext/agent/skills/airflow_dag_development.md +36 -0
- jupyter_ext/agent/skills/anomaly_detection.md +12 -0
- jupyter_ext/agent/skills/athena_sql.md +156 -0
- jupyter_ext/agent/skills/bdp_table_search.md +33 -0
- jupyter_ext/agent/skills/bigquery.md +65 -0
- jupyter_ext/agent/skills/bigquery_oracle.md +231 -0
- jupyter_ext/agent/skills/bigquery_tuning.md +307 -0
- jupyter_ext/agent/skills/classification_evaluation.md +11 -0
- jupyter_ext/agent/skills/clustering_analysis.md +12 -0
- jupyter_ext/agent/skills/comprehensive_eda.md +11 -0
- jupyter_ext/agent/skills/data_analysis.md +236 -0
- jupyter_ext/agent/skills/data_cleaning_pipeline.md +12 -0
- jupyter_ext/agent/skills/data_loading.md +158 -0
- jupyter_ext/agent/skills/data_quality_report.md +13 -0
- jupyter_ext/agent/skills/dimensionality_reduction.md +12 -0
- jupyter_ext/agent/skills/emr_script_development.md +17 -0
- jupyter_ext/agent/skills/experiment_tracking.md +13 -0
- jupyter_ext/agent/skills/feature_importance_analysis.md +10 -0
- jupyter_ext/agent/skills/hyperparameter_tuning.md +12 -0
- jupyter_ext/agent/skills/hypothesis_testing.md +12 -0
- jupyter_ext/agent/skills/imbalanced_data_handling.md +12 -0
- jupyter_ext/agent/skills/inference.md +392 -0
- jupyter_ext/agent/skills/lambda_function_development.md +19 -0
- jupyter_ext/agent/skills/model_comparison.md +11 -0
- jupyter_ext/agent/skills/model_explainability.md +12 -0
- jupyter_ext/agent/skills/model_training.md +318 -0
- jupyter_ext/agent/skills/pyspark.md +352 -0
- jupyter_ext/agent/skills/python_code_quality.md +35 -0
- jupyter_ext/agent/skills/regression_evaluation.md +12 -0
- jupyter_ext/agent/skills/spring_java_development.md +35 -0
- jupyter_ext/agent/skills/text_preprocessing.md +12 -0
- jupyter_ext/agent/skills/time_series_analysis.md +13 -0
- jupyter_ext/agent/skills/unit_testing.md +203 -0
- jupyter_ext/agent/todo_prompts.py +57 -0
- jupyter_ext/agent/tool_report.py +45 -0
- jupyter_ext/analytics_ingest.py +599 -0
- jupyter_ext/etc/jupyter/jupyter_server_config.d/codebuff_agent.json +7 -0
- jupyter_ext/handlers/__init__.py +8 -0
- jupyter_ext/handlers/analytics_handler.py +170 -0
- jupyter_ext/handlers/benchmark_handler.py +794 -0
- jupyter_ext/handlers/eda_benchmark_handler.py +1328 -0
- jupyter_ext/handlers/rag_admin_handler.py +689 -0
- jupyter_ext/handlers/websocket.py +497 -0
- jupyter_ext/http_handlers.py +8744 -0
- jupyter_ext/idle_monitor.py +346 -0
- jupyter_ext/labextension/package.json +157 -0
- jupyter_ext/labextension/static/104.2b8f20f9d1ac2c9db01b.js +1 -0
- jupyter_ext/labextension/static/150.39fa98117cc8ca590465.js +1 -0
- jupyter_ext/labextension/static/166.386ff9b2c37ac865fdee.js +1 -0
- jupyter_ext/labextension/static/216.25cf81131bf7274dabf4.js +1 -0
- jupyter_ext/labextension/static/353.581bd1cb00746399b62e.js +1 -0
- jupyter_ext/labextension/static/367.575f9621b63c9a641aa9.js +1 -0
- jupyter_ext/labextension/static/421.11dac223a2c2da1b09f5.js +1 -0
- jupyter_ext/labextension/static/432.8513b4c6cc1d1232d165.js +2 -0
- jupyter_ext/labextension/static/432.8513b4c6cc1d1232d165.js.LICENSE.txt +19 -0
- jupyter_ext/labextension/static/439.ba5b5ef70ec6de214b8c.js +2 -0
- jupyter_ext/labextension/static/439.ba5b5ef70ec6de214b8c.js.LICENSE.txt +8 -0
- jupyter_ext/labextension/static/481.cc2dec9fa869d66f1407.js +1 -0
- jupyter_ext/labextension/static/487.0796e931d6e651482c14.js +1 -0
- jupyter_ext/labextension/static/49.d326f554b6f9ccde6580.js +1 -0
- jupyter_ext/labextension/static/541.26897ea32845659e36be.js +1 -0
- jupyter_ext/labextension/static/579.e569d3edd8391fd8fbb3.js +1 -0
- jupyter_ext/labextension/static/58.eb7cc679f7535cc8a2f1.js +2 -0
- jupyter_ext/labextension/static/58.eb7cc679f7535cc8a2f1.js.LICENSE.txt +8 -0
- jupyter_ext/labextension/static/666.d9bfdb23f98489d0a188.js +1 -0
- jupyter_ext/labextension/static/677.d266c39dbed33bc388b3.js +2 -0
- jupyter_ext/labextension/static/677.d266c39dbed33bc388b3.js.LICENSE.txt +8 -0
- jupyter_ext/labextension/static/68.26b6f59c8454adb17f20.js +204 -0
- jupyter_ext/labextension/static/694.4ac443814260057e68f7.js +1 -0
- jupyter_ext/labextension/static/791.61affb8d3480d3d60e0d.js +1 -0
- jupyter_ext/labextension/static/848.4d2db09cfbb09374b299.js +2 -0
- jupyter_ext/labextension/static/848.4d2db09cfbb09374b299.js.LICENSE.txt +9 -0
- jupyter_ext/labextension/static/868.5cfe7e1a348ec5af1b8c.js +1 -0
- jupyter_ext/labextension/static/97.ea559a5b8a9a09f17c6b.js +1 -0
- jupyter_ext/labextension/static/remoteEntry.5292fc9c6df7e7920cc8.js +1 -0
- jupyter_ext/labextension/static/style.js +4 -0
- jupyter_ext/labextension/static/third-party-licenses.json +724 -0
- jupyter_ext/log_paths.py +113 -0
- jupyter_ext/resource_usage.py +519 -0
- jupyter_ext/secrets_bootstrap.py +116 -0
- jupyter_ext/team_access.py +105 -0
- jupyter_ext/tests/__init__.py +1 -0
- jupyter_ext/tests/test_context.py +234 -0
- jupyter_ext/tests/test_factory.py +563 -0
- jupyter_ext/tests/test_file_finder.py +139 -0
- jupyter_ext/tests/test_handlers.py +58 -0
- jupyter_ext/tests/test_mcp_selftest.py +204 -0
- jupyter_ext/tests/test_mermaid_fixer.py +272 -0
- jupyter_ext/tests/test_resource_tiers.py +298 -0
- jupyter_ext/tools/__init__.py +15 -0
- jupyter_ext/tools/base.py +90 -0
- jupyter_ext/tools/context.py +268 -0
- jupyter_ext/tools/dir_complete.py +86 -0
- jupyter_ext/tools/file_finder.py +303 -0
- jupyter_ext/tools/file_tools.py +244 -0
- jupyter_ext/tools/jupyter_tools.py +296 -0
- jupyter_ext/tools/registry.py +159 -0
- jupyter_ext/tools/search_tools.py +230 -0
- jupyter_ext/tools/security_tools.py +657 -0
- jupyter_ext/tools/shell_tools.py +109 -0
- jupyter_ext/tools/user_tools.py +132 -0
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
"""centri 커스텀 스킬 동기화 (이슈 #134).
|
|
2
|
+
|
|
3
|
+
스킬의 정본은 centri RDS(`codebuff.custom_skill`)이고, centri 포털에서 편집한다. pod 는
|
|
4
|
+
agent server 의 `GET /api/v1/codebuff/skills` 로 받아 동기화 디렉터리
|
|
5
|
+
`<codebuff 홈>/skills/<scope>/<name>/SKILL.md` 로 내려쓴다. 에이전트는 그 파일을 로컬
|
|
6
|
+
스킬과 같은 규칙으로 읽는다(`jupyter_ext/agent/custom_skills.py`). 계약은
|
|
7
|
+
`docs/스킬RDS/계약.md` 가 정본이다.
|
|
8
|
+
|
|
9
|
+
- `CODEBUFF_SKILLS_URL` 이 없으면 완전한 no-op 이다. 공유 키가 있는 pod 는 시크릿
|
|
10
|
+
부트스트랩이 클러스터 주소로 채운다.
|
|
11
|
+
- 기동 때 한 번, 그 뒤 300초마다 받는다. 기동은 기다리지 않는다 — 스킬은 설정과 달리
|
|
12
|
+
기동 시점에 한 번만 읽는 값이 없고, 다음 에이전트 생성부터 쓰이면 된다.
|
|
13
|
+
- 200 을 받으면 받은 목록이 곧 디렉터리의 상태다. 빠진 스킬은 지운다 — 단 `SYNC_SCOPES`
|
|
14
|
+
네 디렉터리 바로 아래의 스킬 디렉터리만 지운다. 사람이 만든 스킬 디렉터리와 섞지 않는다.
|
|
15
|
+
- 받기 실패(연결·타임아웃·4xx/5xx·비JSON·`schema_version` 불일치)는 파일을 그대로 둔다.
|
|
16
|
+
마지막으로 받은 스킬이 계속 쓰인다. 쓰는 도중의 디스크 오류는 그 회차를 멈춘다 — 쓰기를
|
|
17
|
+
지우기보다 먼저 하므로 옛 스킬이 남는 쪽이고, 다음 회차가 다시 맞춘다.
|
|
18
|
+
- 항목 단위로 검증한다. 남의 `scope_key`·규격 밖 이름·빈 설명·상한 넘는 본문은 그 항목만
|
|
19
|
+
버린다.
|
|
20
|
+
- 어떤 경우에도 예외를 밖으로 내지 않는다.
|
|
21
|
+
|
|
22
|
+
로컬 스킬 보고 (이슈 #182): 같은 스레드가 주기마다 pod 가 찾은 로컬 스킬 목록을
|
|
23
|
+
`POST <CODEBUFF_SKILLS_URL>/local` 로 보낸다. 지난 성공 보고와 같으면 보내지 않는다.
|
|
24
|
+
본문의 `host` 는 `socket.gethostname()`(k8s 에서는 pod 이름)이다 (이슈 #204).
|
|
25
|
+
centri 수신부가 아직 없을 수 있어(404) 실패는 debug 로그와 `status()["report"]` 로만
|
|
26
|
+
남기고, 다음 주기에 다시 보낸다.
|
|
27
|
+
|
|
28
|
+
도구 정의 보고 (이슈 #248): 같은 본문 최상위 `tools[]` 에 에이전트가 모델에 넘기는 도구
|
|
29
|
+
정의(엔진별 이름·설명·입력 스키마·승인 여부)를 싣는다. centri(lks21c/centri#1846)는 규격
|
|
30
|
+
밖 항목을 그 항목만 버리므로, 여기서 먼저 계약에 맞춘다(`clean_tools`). 목록을 만들지
|
|
31
|
+
못하면 `tools` 키를 뺀다 — 빈 배열은 "도구 0개" 로 읽힌다.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import json
|
|
37
|
+
import logging
|
|
38
|
+
import os
|
|
39
|
+
import re
|
|
40
|
+
import shutil
|
|
41
|
+
import socket
|
|
42
|
+
import threading
|
|
43
|
+
from datetime import datetime, timedelta, timezone
|
|
44
|
+
from pathlib import Path
|
|
45
|
+
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
|
|
46
|
+
|
|
47
|
+
from codebuff_core.legacy_paths import resolve_home
|
|
48
|
+
|
|
49
|
+
logger = logging.getLogger(__name__)
|
|
50
|
+
|
|
51
|
+
SCHEMA_VERSION = 1
|
|
52
|
+
REFRESH_INTERVAL_SEC = 300.0
|
|
53
|
+
# 본문이 수십 KB 라 설정(3초)보다 길게 준다. 기동을 기다리게 하지 않으므로 길어도 무해하다
|
|
54
|
+
TIMEOUT_SEC = 10.0
|
|
55
|
+
URL_ENV = "CODEBUFF_SKILLS_URL"
|
|
56
|
+
# 설정 동기화와 같은 공유 키·사용자 토큰 (remote_config 와 같은 값)
|
|
57
|
+
TOKEN_ENV = "CODEX_SERVER_API_KEY"
|
|
58
|
+
USER_TOKEN_ENV = "KEYCLOAK_ID_TOKEN"
|
|
59
|
+
USER_TOKEN_HEADER = "X-Codebuff-User-Token"
|
|
60
|
+
#: 로컬 스킬 보고 경로 — `CODEBUFF_SKILLS_URL` 뒤에 붙인다 (이슈 #182)
|
|
61
|
+
REPORT_SUFFIX = "/local"
|
|
62
|
+
|
|
63
|
+
SKILL_FILE = "SKILL.md"
|
|
64
|
+
#: 디렉터리 이름이 되는 값이라 경로 문자를 막는다. 로컬 파서와 DDL 주석이 이 값을 따른다
|
|
65
|
+
NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
|
|
66
|
+
# YAML 이 읽지 못하는 문자 — 설명에 남으면 로컬 파서가 그 파일을 통째로 버린다
|
|
67
|
+
_YAML_UNPRINTABLE = re.compile(
|
|
68
|
+
"[^\x09\x0a\x0d\x20-\x7e\x85\xa0-\ud7ff\ue000-\ufffd\U00010000-\U0010ffff]"
|
|
69
|
+
)
|
|
70
|
+
#: codex `read_file` 의 상한과 같다. 넘는 본문은 받지 않는다
|
|
71
|
+
BODY_MAX_CHARS = 64_000
|
|
72
|
+
DESCRIPTION_MAX_CHARS = 1_024
|
|
73
|
+
#: 동기화 디렉터리 안의 하위 디렉터리 — 순서가 우선순위다(구체적인 쪽이 앞)
|
|
74
|
+
SYNC_SCOPES = ("user", "project", "team", "global")
|
|
75
|
+
#: 응답 항목에서 pod 가 읽는 필드. DDL 이 모두 담아야 한다
|
|
76
|
+
ENTRY_FIELDS = ("name", "scope", "scope_key", "description", "body", "enabled")
|
|
77
|
+
|
|
78
|
+
# 도구 정의 보고의 centri 계약 (이슈 #248, centri `codebuff_builtin_tools.py`)
|
|
79
|
+
TOOL_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$")
|
|
80
|
+
TOOL_ENGINES = ("codex", "langchain")
|
|
81
|
+
TOOL_VERSION_RE = re.compile(r"^[0-9A-Za-z][0-9A-Za-z.+_-]{0,63}$")
|
|
82
|
+
#: 넘으면 centri 가 스키마를 비운다 — 보내기 전에 비워 본문을 줄인다
|
|
83
|
+
TOOL_SCHEMA_MAX_CHARS = 16_000
|
|
84
|
+
TOOL_MAX_ITEMS = 100
|
|
85
|
+
|
|
86
|
+
_KST = timezone(timedelta(hours=9))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def clean_tools(entries: Sequence[Any]) -> List[Dict[str, Any]]:
|
|
90
|
+
"""도구 정의 목록을 centri 계약에 맞춘다. 맞출 수 없는 항목은 버린다.
|
|
91
|
+
|
|
92
|
+
이름·엔진·버전이 규격 밖이거나 같은 엔진에 같은 이름이 또 있으면 버린다. 설명은 1024자에서
|
|
93
|
+
자르고, 직렬화한 스키마가 16000자를 넘으면 비운다. 승인은 `True` 일 때만 참이다.
|
|
94
|
+
"""
|
|
95
|
+
out: List[Dict[str, Any]] = []
|
|
96
|
+
seen = set()
|
|
97
|
+
for e in entries:
|
|
98
|
+
if len(out) >= TOOL_MAX_ITEMS:
|
|
99
|
+
break
|
|
100
|
+
if not isinstance(e, dict):
|
|
101
|
+
continue
|
|
102
|
+
name, engine = e.get("name"), e.get("engine")
|
|
103
|
+
if not isinstance(name, str) or not TOOL_NAME_RE.match(name):
|
|
104
|
+
continue
|
|
105
|
+
if engine not in TOOL_ENGINES or (engine, name) in seen:
|
|
106
|
+
continue
|
|
107
|
+
version = e.get("codebuff_version")
|
|
108
|
+
if not isinstance(version, str) or not TOOL_VERSION_RE.match(version):
|
|
109
|
+
continue
|
|
110
|
+
schema = e.get("input_schema")
|
|
111
|
+
if isinstance(schema, dict):
|
|
112
|
+
text = json.dumps(schema, ensure_ascii=False)
|
|
113
|
+
elif isinstance(schema, str):
|
|
114
|
+
text = schema
|
|
115
|
+
else:
|
|
116
|
+
schema, text = "", ""
|
|
117
|
+
if len(text) > TOOL_SCHEMA_MAX_CHARS:
|
|
118
|
+
schema = ""
|
|
119
|
+
description = e.get("description")
|
|
120
|
+
seen.add((engine, name))
|
|
121
|
+
out.append(
|
|
122
|
+
{
|
|
123
|
+
"name": name,
|
|
124
|
+
"engine": engine,
|
|
125
|
+
"codebuff_version": version,
|
|
126
|
+
"description": (description if isinstance(description, str) else "")[
|
|
127
|
+
:DESCRIPTION_MAX_CHARS
|
|
128
|
+
],
|
|
129
|
+
"input_schema": schema,
|
|
130
|
+
"approval": e.get("approval") is True,
|
|
131
|
+
}
|
|
132
|
+
)
|
|
133
|
+
return out
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _render(name: str, description: str, body: str) -> str:
|
|
137
|
+
"""SKILL.md 한 벌. 설명은 YAML 큰따옴표 문자열로 쓴다.
|
|
138
|
+
|
|
139
|
+
JSON 문자열은 YAML 큰따옴표 문자열로도 읽히고 한 줄이라, 설명 속 `---` 가 frontmatter
|
|
140
|
+
경계(`---` 만 있는 줄)로 읽힐 일이 없다.
|
|
141
|
+
"""
|
|
142
|
+
quoted = json.dumps(description, ensure_ascii=False)
|
|
143
|
+
# 이름도 따옴표로 감싼다 — 맨 이름 `no`·`0x1f` 는 YAML 이 불리언·숫자로 읽는다
|
|
144
|
+
return f"---\nname: {json.dumps(name)}\ndescription: {quoted}\n---\n\n{body}\n"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _clean_description(text: str) -> str:
|
|
148
|
+
return " ".join(_YAML_UNPRINTABLE.sub("", text).split())[:DESCRIPTION_MAX_CHARS]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class RemoteSkillsClient:
|
|
152
|
+
"""centri 에서 스킬 목록을 받아 동기화 디렉터리에 내려쓴다."""
|
|
153
|
+
|
|
154
|
+
def __init__(
|
|
155
|
+
self,
|
|
156
|
+
url: str = "",
|
|
157
|
+
token: str = "",
|
|
158
|
+
transport: Any = None,
|
|
159
|
+
refresh_interval: float = REFRESH_INTERVAL_SEC,
|
|
160
|
+
timeout: float = TIMEOUT_SEC,
|
|
161
|
+
team: str = "",
|
|
162
|
+
project: str = "",
|
|
163
|
+
user_ids: Sequence[str] = (),
|
|
164
|
+
user_token: str = "",
|
|
165
|
+
root: Optional[Path] = None,
|
|
166
|
+
local_source: Optional[Callable[[], List[dict]]] = None,
|
|
167
|
+
tools_source: Optional[Callable[[], List[dict]]] = None,
|
|
168
|
+
) -> None:
|
|
169
|
+
self._url = (url or "").strip()
|
|
170
|
+
self._token = token or ""
|
|
171
|
+
self._team = team or ""
|
|
172
|
+
self._project = project or ""
|
|
173
|
+
# 사번이 어느 변수로 오는지 확정하지 못했다(이슈 #101). 첫 값을 보내고, 받을 때는
|
|
174
|
+
# 후보 중 하나와 맞으면 이 사용자의 것으로 본다
|
|
175
|
+
self._user_ids = tuple(u for u in user_ids if u)
|
|
176
|
+
self._user_token = user_token or ""
|
|
177
|
+
self._transport = transport
|
|
178
|
+
self._interval = refresh_interval
|
|
179
|
+
self._timeout = timeout
|
|
180
|
+
self._root = root
|
|
181
|
+
self._lock = threading.Lock()
|
|
182
|
+
# 디스크 쓰기는 한 번에 하나 — 임시 파일 이름이 pid 라 겹치면 서로의 것을 옮긴다
|
|
183
|
+
self._apply_lock = threading.Lock()
|
|
184
|
+
self._stop = threading.Event()
|
|
185
|
+
self._thread: Optional[threading.Thread] = None
|
|
186
|
+
self._count = 0
|
|
187
|
+
self._dropped = 0
|
|
188
|
+
self._updated_at: Optional[str] = None
|
|
189
|
+
self._last_ok_at: Optional[str] = None
|
|
190
|
+
self._last_error: Optional[str] = None
|
|
191
|
+
self._attempted = False
|
|
192
|
+
# 로컬 스킬 보고 — 목록을 만드는 함수는 jupyter_ext 가 준다(워크스페이스를 안다)
|
|
193
|
+
self._local_source = local_source
|
|
194
|
+
# 도구 정의 보고 (이슈 #248) — 도구를 만드는 쪽(jupyter_ext)이 목록을 준다
|
|
195
|
+
self._tools_source = tools_source
|
|
196
|
+
self._report_sent: Optional[str] = None
|
|
197
|
+
self._report_count = 0
|
|
198
|
+
self._report_ok_at: Optional[str] = None
|
|
199
|
+
self._report_error: Optional[str] = None
|
|
200
|
+
self._report_attempted = False
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def enabled(self) -> bool:
|
|
204
|
+
return bool(self._url)
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def started(self) -> bool:
|
|
208
|
+
return self._thread is not None
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def root(self) -> Path:
|
|
212
|
+
return self._root if self._root is not None else resolve_home("skills")
|
|
213
|
+
|
|
214
|
+
def start(self) -> None:
|
|
215
|
+
if not self.enabled or self._thread is not None:
|
|
216
|
+
return
|
|
217
|
+
self._thread = threading.Thread(
|
|
218
|
+
target=self._run, name="codebuff-remote-skills", daemon=True
|
|
219
|
+
)
|
|
220
|
+
self._thread.start()
|
|
221
|
+
|
|
222
|
+
@property
|
|
223
|
+
def report_url(self) -> str:
|
|
224
|
+
return self._url.rstrip("/") + REPORT_SUFFIX
|
|
225
|
+
|
|
226
|
+
def _run(self) -> None:
|
|
227
|
+
while not self._stop.is_set():
|
|
228
|
+
self.refresh_now()
|
|
229
|
+
self.report_now()
|
|
230
|
+
self._stop.wait(self._interval)
|
|
231
|
+
|
|
232
|
+
def stop(self) -> None:
|
|
233
|
+
self._stop.set()
|
|
234
|
+
|
|
235
|
+
def refresh_now(self) -> bool:
|
|
236
|
+
"""한 번 받아 디렉터리에 반영한다. 성공 여부를 돌려준다. 예외를 내지 않는다."""
|
|
237
|
+
if not self.enabled:
|
|
238
|
+
return False
|
|
239
|
+
try:
|
|
240
|
+
entries, updated_at = self._fetch()
|
|
241
|
+
wanted, dropped = self._accept(entries)
|
|
242
|
+
with self._apply_lock:
|
|
243
|
+
self._apply(wanted)
|
|
244
|
+
with self._lock:
|
|
245
|
+
self._count = len(wanted)
|
|
246
|
+
self._dropped = dropped
|
|
247
|
+
self._updated_at = updated_at
|
|
248
|
+
self._last_ok_at = datetime.now(_KST).isoformat(timespec="seconds")
|
|
249
|
+
self._last_error = None
|
|
250
|
+
self._attempted = True
|
|
251
|
+
return True
|
|
252
|
+
except Exception as e: # noqa: BLE001 — 어떤 실패도 마지막 파일을 유지한다
|
|
253
|
+
with self._lock:
|
|
254
|
+
self._last_error = f"{type(e).__name__}: {e}"[:300]
|
|
255
|
+
self._attempted = True
|
|
256
|
+
logger.warning("custom skills 받기 실패 — 마지막 파일 유지: %s", e)
|
|
257
|
+
return False
|
|
258
|
+
|
|
259
|
+
def report_now(self) -> bool:
|
|
260
|
+
"""로컬 스킬 목록을 centri 에 보낸다. 바뀌지 않았으면 보내지 않는다. 예외를 내지 않는다.
|
|
261
|
+
|
|
262
|
+
수신부 미배포(404)가 정상 상태일 수 있어 실패는 WARNING 이 아니라 debug 로 남긴다.
|
|
263
|
+
"""
|
|
264
|
+
if not self.enabled or self._local_source is None:
|
|
265
|
+
return False
|
|
266
|
+
try:
|
|
267
|
+
skills = list(self._local_source())
|
|
268
|
+
payload: Dict[str, Any] = {
|
|
269
|
+
"schema_version": SCHEMA_VERSION,
|
|
270
|
+
"team": self._team,
|
|
271
|
+
"project": self._project,
|
|
272
|
+
"user": self._user_ids[0] if self._user_ids else "",
|
|
273
|
+
# 같은 사람·팀·프로젝트의 기계 둘을 centri 가 가를 수 있게 (#204)
|
|
274
|
+
"host": socket.gethostname(),
|
|
275
|
+
"skills": skills,
|
|
276
|
+
}
|
|
277
|
+
tools = self._report_tools()
|
|
278
|
+
if tools is not None:
|
|
279
|
+
payload["tools"] = tools
|
|
280
|
+
digest = json.dumps(payload, sort_keys=True, ensure_ascii=False)
|
|
281
|
+
with self._lock:
|
|
282
|
+
if digest == self._report_sent:
|
|
283
|
+
return True
|
|
284
|
+
payload["reported_at"] = datetime.now(_KST).isoformat(timespec="seconds")
|
|
285
|
+
self._post_report(payload)
|
|
286
|
+
with self._lock:
|
|
287
|
+
self._report_sent = digest
|
|
288
|
+
self._report_count = len(skills)
|
|
289
|
+
self._report_ok_at = payload["reported_at"]
|
|
290
|
+
self._report_error = None
|
|
291
|
+
self._report_attempted = True
|
|
292
|
+
return True
|
|
293
|
+
except Exception as e: # noqa: BLE001 — 보고 실패가 pod 를 흔들면 안 된다
|
|
294
|
+
with self._lock:
|
|
295
|
+
self._report_error = f"{type(e).__name__}: {e}"[:300]
|
|
296
|
+
self._report_attempted = True
|
|
297
|
+
logger.debug("로컬 스킬 보고 실패 — 다음 주기에 다시 보낸다: %s", e)
|
|
298
|
+
return False
|
|
299
|
+
|
|
300
|
+
def _report_tools(self) -> Optional[List[Dict[str, Any]]]:
|
|
301
|
+
"""보고할 도구 정의. 소스가 없거나 실패하면 None — 스킬 보고는 계속 간다."""
|
|
302
|
+
if self._tools_source is None:
|
|
303
|
+
return None
|
|
304
|
+
try:
|
|
305
|
+
return clean_tools(list(self._tools_source()))
|
|
306
|
+
except Exception as e: # noqa: BLE001 — 도구 목록 실패가 스킬 보고를 막지 않는다
|
|
307
|
+
logger.debug("도구 정의 목록 실패 — 이번 보고에서 뺀다: %s", e)
|
|
308
|
+
return None
|
|
309
|
+
|
|
310
|
+
def _post_report(self, payload: Dict[str, Any]) -> None:
|
|
311
|
+
import httpx
|
|
312
|
+
|
|
313
|
+
with httpx.Client(
|
|
314
|
+
transport=self._transport, timeout=httpx.Timeout(self._timeout)
|
|
315
|
+
) as client:
|
|
316
|
+
resp = client.post(self.report_url, headers=self._headers(), json=payload)
|
|
317
|
+
resp.raise_for_status()
|
|
318
|
+
|
|
319
|
+
def _headers(self) -> Dict[str, str]:
|
|
320
|
+
headers = {"Authorization": f"Bearer {self._token}"} if self._token else {}
|
|
321
|
+
if self._user_token:
|
|
322
|
+
headers[USER_TOKEN_HEADER] = self._user_token
|
|
323
|
+
return headers
|
|
324
|
+
|
|
325
|
+
def _fetch(self) -> Tuple[list, Optional[str]]:
|
|
326
|
+
import httpx
|
|
327
|
+
|
|
328
|
+
params = {
|
|
329
|
+
"team": self._team,
|
|
330
|
+
"project": self._project,
|
|
331
|
+
"user": self._user_ids[0] if self._user_ids else "",
|
|
332
|
+
}
|
|
333
|
+
with httpx.Client(
|
|
334
|
+
transport=self._transport, timeout=httpx.Timeout(self._timeout)
|
|
335
|
+
) as client:
|
|
336
|
+
resp = client.get(self._url, headers=self._headers(), params=params)
|
|
337
|
+
resp.raise_for_status()
|
|
338
|
+
body = resp.json()
|
|
339
|
+
if not isinstance(body, dict):
|
|
340
|
+
raise ValueError("응답이 JSON 객체가 아님")
|
|
341
|
+
version = body.get("schema_version")
|
|
342
|
+
if version != SCHEMA_VERSION:
|
|
343
|
+
raise ValueError(f"schema_version 불일치: {version!r}")
|
|
344
|
+
skills = body.get("skills")
|
|
345
|
+
# 빠진 `skills` 를 빈 목록으로 받으면 받은 스킬이 전부 지워진다
|
|
346
|
+
if not isinstance(skills, list):
|
|
347
|
+
raise ValueError("skills 가 JSON 배열이 아님")
|
|
348
|
+
updated_at = body.get("updated_at")
|
|
349
|
+
return skills, updated_at if isinstance(updated_at, str) else None
|
|
350
|
+
|
|
351
|
+
def _owns(self, scope: str, key: str) -> bool:
|
|
352
|
+
if scope == "global":
|
|
353
|
+
return key == ""
|
|
354
|
+
if scope == "team":
|
|
355
|
+
return bool(self._team) and key == self._team
|
|
356
|
+
if scope == "project":
|
|
357
|
+
return bool(self._project) and key == self._project
|
|
358
|
+
return key in self._user_ids
|
|
359
|
+
|
|
360
|
+
def _accept(self, entries: list) -> Tuple[Dict[Tuple[str, str], str], int]:
|
|
361
|
+
"""받을 스킬 `{(scope, name): 파일 내용}` 과 버린 항목 수."""
|
|
362
|
+
wanted: Dict[Tuple[str, str], str] = {}
|
|
363
|
+
dropped = 0
|
|
364
|
+
for e in entries:
|
|
365
|
+
reason = self._reject_reason(e)
|
|
366
|
+
if reason is None and (e["scope"], e["name"]) in wanted:
|
|
367
|
+
reason = "같은 scope 에 같은 이름이 또 있음"
|
|
368
|
+
if reason is not None:
|
|
369
|
+
dropped += 1
|
|
370
|
+
label = e.get("name") if isinstance(e, dict) else type(e).__name__
|
|
371
|
+
logger.warning("custom skill 항목을 버림 (%s): %r", reason, label)
|
|
372
|
+
continue
|
|
373
|
+
wanted[(e["scope"], e["name"])] = _render(
|
|
374
|
+
e["name"], _clean_description(e["description"]), e["body"].strip()
|
|
375
|
+
)
|
|
376
|
+
return wanted, dropped
|
|
377
|
+
|
|
378
|
+
def _reject_reason(self, e: Any) -> Optional[str]:
|
|
379
|
+
if not isinstance(e, dict):
|
|
380
|
+
return "객체가 아님"
|
|
381
|
+
# TINYINT 가 0·"0" 으로 직렬화돼도 꺼진 것으로 본다
|
|
382
|
+
if e.get("enabled", True) in (False, 0, "0", "false", None):
|
|
383
|
+
return "꺼짐"
|
|
384
|
+
name, scope = e.get("name"), e.get("scope")
|
|
385
|
+
# fullmatch — `$` 는 끝 개행 앞에서도 맞아 `sql\n` 이 디렉터리 이름이 된다
|
|
386
|
+
if not isinstance(name, str) or not NAME_RE.fullmatch(name):
|
|
387
|
+
return "이름이 규격 밖"
|
|
388
|
+
if scope not in SYNC_SCOPES:
|
|
389
|
+
return "scope 가 규격 밖"
|
|
390
|
+
if not self._owns(scope, e.get("scope_key") or ""):
|
|
391
|
+
return "이 pod 의 scope_key 가 아님"
|
|
392
|
+
desc, body = e.get("description"), e.get("body")
|
|
393
|
+
if not isinstance(desc, str) or not _clean_description(desc):
|
|
394
|
+
return "설명이 없음"
|
|
395
|
+
if not isinstance(body, str):
|
|
396
|
+
return "본문이 문자열이 아님"
|
|
397
|
+
if len(body) > BODY_MAX_CHARS:
|
|
398
|
+
return f"본문이 {BODY_MAX_CHARS}자를 넘음"
|
|
399
|
+
return None
|
|
400
|
+
|
|
401
|
+
def _apply(self, wanted: Dict[Tuple[str, str], str]) -> None:
|
|
402
|
+
"""쓰기를 먼저 하고 지우기를 나중에 한다 — 중간에 실패하면 옛 스킬이 남는 쪽이다."""
|
|
403
|
+
root = self.root
|
|
404
|
+
for (scope, name), text in wanted.items():
|
|
405
|
+
skill_dir = root / scope / name
|
|
406
|
+
# 심볼릭 링크는 따라가지 않는다 — 링크만 떼고 가리키는 곳은 남긴다.
|
|
407
|
+
# 같은 이름의 일반 파일도 떼야 디렉터리를 만들 수 있다
|
|
408
|
+
if skill_dir.is_symlink() or skill_dir.is_file():
|
|
409
|
+
skill_dir.unlink()
|
|
410
|
+
path = skill_dir / SKILL_FILE
|
|
411
|
+
if path.is_symlink():
|
|
412
|
+
path.unlink()
|
|
413
|
+
try:
|
|
414
|
+
if path.read_text(encoding="utf-8") == text:
|
|
415
|
+
continue
|
|
416
|
+
except (OSError, UnicodeDecodeError):
|
|
417
|
+
pass
|
|
418
|
+
skill_dir.mkdir(parents=True, exist_ok=True)
|
|
419
|
+
tmp = path.with_name(f".{SKILL_FILE}.{os.getpid()}.tmp")
|
|
420
|
+
try:
|
|
421
|
+
tmp.write_text(text, encoding="utf-8")
|
|
422
|
+
os.replace(tmp, path)
|
|
423
|
+
finally:
|
|
424
|
+
if tmp.exists():
|
|
425
|
+
tmp.unlink()
|
|
426
|
+
for scope in SYNC_SCOPES:
|
|
427
|
+
scope_dir = root / scope
|
|
428
|
+
scope_dir.mkdir(parents=True, exist_ok=True)
|
|
429
|
+
for child in scope_dir.iterdir():
|
|
430
|
+
if (scope, child.name) in wanted:
|
|
431
|
+
continue
|
|
432
|
+
if child.is_symlink() or not child.is_dir():
|
|
433
|
+
child.unlink()
|
|
434
|
+
else:
|
|
435
|
+
shutil.rmtree(child)
|
|
436
|
+
|
|
437
|
+
def status(self) -> Dict[str, Any]:
|
|
438
|
+
with self._lock:
|
|
439
|
+
if not self.enabled:
|
|
440
|
+
state = "disabled"
|
|
441
|
+
elif not self._attempted:
|
|
442
|
+
state = "pending"
|
|
443
|
+
elif self._last_error is not None:
|
|
444
|
+
state = "error"
|
|
445
|
+
else:
|
|
446
|
+
state = "ok"
|
|
447
|
+
return {
|
|
448
|
+
"state": state,
|
|
449
|
+
"schemaVersion": SCHEMA_VERSION,
|
|
450
|
+
"skills": self._count,
|
|
451
|
+
"dropped": self._dropped,
|
|
452
|
+
"updatedAt": self._updated_at,
|
|
453
|
+
"lastOkAt": self._last_ok_at,
|
|
454
|
+
"lastError": self._last_error,
|
|
455
|
+
"report": self._report_status(),
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
def _report_status(self) -> Dict[str, Any]:
|
|
459
|
+
"""로컬 스킬 보고 상태. `_lock` 을 잡은 채로 부른다."""
|
|
460
|
+
if not self.enabled or self._local_source is None:
|
|
461
|
+
state = "disabled"
|
|
462
|
+
elif not self._report_attempted:
|
|
463
|
+
state = "pending"
|
|
464
|
+
elif self._report_error is not None:
|
|
465
|
+
state = "error"
|
|
466
|
+
else:
|
|
467
|
+
state = "ok"
|
|
468
|
+
return {
|
|
469
|
+
"state": state,
|
|
470
|
+
"skills": self._report_count,
|
|
471
|
+
"lastOkAt": self._report_ok_at,
|
|
472
|
+
"lastError": self._report_error,
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
_client: Optional[RemoteSkillsClient] = None
|
|
477
|
+
_client_lock = threading.Lock()
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def get_client() -> RemoteSkillsClient:
|
|
481
|
+
"""현재 클라이언트. 기동 전이면 꺼진 클라이언트다 — identity 는 기동이 넘긴다."""
|
|
482
|
+
global _client
|
|
483
|
+
with _client_lock:
|
|
484
|
+
if _client is None:
|
|
485
|
+
_client = RemoteSkillsClient()
|
|
486
|
+
return _client
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _client_from_env(
|
|
490
|
+
team: str,
|
|
491
|
+
project: str,
|
|
492
|
+
user_ids: Sequence[str],
|
|
493
|
+
local_source: Optional[Callable[[], List[dict]]] = None,
|
|
494
|
+
tools_source: Optional[Callable[[], List[dict]]] = None,
|
|
495
|
+
) -> RemoteSkillsClient:
|
|
496
|
+
return RemoteSkillsClient(
|
|
497
|
+
url=os.environ.get(URL_ENV, ""),
|
|
498
|
+
token=os.environ.get(TOKEN_ENV, ""),
|
|
499
|
+
team=team,
|
|
500
|
+
project=project,
|
|
501
|
+
user_ids=user_ids,
|
|
502
|
+
user_token=os.environ.get(USER_TOKEN_ENV, ""),
|
|
503
|
+
local_source=local_source,
|
|
504
|
+
tools_source=tools_source,
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def start_from_env(
|
|
509
|
+
team: str,
|
|
510
|
+
project: str,
|
|
511
|
+
user_ids: Sequence[str],
|
|
512
|
+
local_source: Optional[Callable[[], List[dict]]] = None,
|
|
513
|
+
tools_source: Optional[Callable[[], List[dict]]] = None,
|
|
514
|
+
) -> RemoteSkillsClient:
|
|
515
|
+
"""서버 기동 시 시크릿 주입 뒤에 부른다. 첫 받기를 기다리지 않는다.
|
|
516
|
+
|
|
517
|
+
팀·프로젝트·사용자는 인자로만 받는다 — 팀은 JWT 클레임 폴백이 있어 env 를 직접
|
|
518
|
+
읽으면 빠진다(`jupyter_ext.team_access.resolve_team`). `local_source` 는 centri 에
|
|
519
|
+
보고할 로컬 스킬 목록을, `tools_source` 는 도구 정의 목록을 돌려주는 함수다(이슈 #182·#248).
|
|
520
|
+
"""
|
|
521
|
+
global _client
|
|
522
|
+
with _client_lock:
|
|
523
|
+
if _client is None or not _client.started:
|
|
524
|
+
_client = _client_from_env(
|
|
525
|
+
team=team,
|
|
526
|
+
project=project,
|
|
527
|
+
user_ids=user_ids,
|
|
528
|
+
local_source=local_source,
|
|
529
|
+
tools_source=tools_source,
|
|
530
|
+
)
|
|
531
|
+
_client.start()
|
|
532
|
+
return _client
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def set_client(client: Optional[RemoteSkillsClient]) -> None:
|
|
536
|
+
"""테스트용 — 클라이언트를 갈아 끼운다. None 이면 다음 조회 때 꺼진 클라이언트를 만든다."""
|
|
537
|
+
global _client
|
|
538
|
+
with _client_lock:
|
|
539
|
+
_client = client
|