Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 15 additions & 5 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

The organization-wide Git and pull-request policy lives in
[InKCre Git and GitHub Governance](https://github.com/InKCre/.github/blob/main/GOVERNANCE.md).
This guide adds the `core-py` setup, validation, migration, and delivery details required by that
This guide adds the `impl-py` setup, validation, migration, and delivery details required by that
policy; it does not weaken the organization baseline.

## Security Reports
Expand Down Expand Up @@ -30,7 +30,7 @@ Git hook 需要在每个本地 checkout 安装;提交前会执行与 CI 相同

`check` is the same hermetic repository gate used by CI. It verifies the lock and
requirements export, migration configuration and append-only baseline, Ruff formatting,
repository lint, and static types. Use narrower commands while iterating:
repository lint, static types, and the repository test suite. Use narrower commands while iterating:

```bash
pdm run format:check
Expand Down Expand Up @@ -59,6 +59,13 @@ Core fragments live in `.changes/`; CLI fragments live in `cli/.changes/`; Exten
`fixed`, and `security`. Pure release-tooling or contributor-documentation changes do not invent
project news.

Use the primary project or implementation area as the `type(scope): summary` scope: `core`, `cli`,
or an Extension ID such as `mail`. Use `extensions` for one coherent change spanning several
Extensions and `release` for release tooling. The title is a navigation aid, not a version plan; list
every affected release project in the pull-request body and add a fragment under each affected
project. Examples: `fix(core): preserve extension state on restart`, `feat(mail): collect newsletter
issues`, and `ref(extensions): share distribution validation`.

After feature fragments merge, the checked-main controller updates the independent `release/next`
pull request. Only that pull request runs `pdm run release:prepare`, consumes fragments, applies the
maturity-aware SemVer bump, and renders changelogs. It publishes nothing. Merging the prepared pull
Expand Down Expand Up @@ -103,11 +110,14 @@ python scripts/container.py ready
`web` honors `$PORT`; `migrate` only applies checked-in revisions; `ready` performs a
read-only connectivity and Alembic-head check. The Core image owns the supported dependency
baseline and excludes first-party Extension source. The Extension Host acquires only exact
published wheels and never resolves or mutates host dependencies while enabling them.
published wheels. Applying a selected Python Distribution runs pip dependency resolution in
the shared Host environment and can install or upgrade dependencies before a managed restart.
The [native distribution contract](docs/40-deployment/first-party-extension-distribution.md)
owns admission, environment application, and restart behavior.

## Shared Docs And Skill Discovery

`core-py` is a Spoke repo and consumes shared Hub truth from `docs/_shared/`.
`impl-py` is a Spoke repo and consumes shared Hub truth from `docs/_shared/`.

The canonical shared-doc editing skill lives in:

Expand All @@ -124,6 +134,6 @@ Use the repo-root wrapper only to discover the canonical skill. Do not fork the
1. Capture the local pressure in the active Spoke task packet.
2. Edit shared docs in `InKCre/docs`.
3. Push the shared Hub commit first.
4. Bump `core-py/docs/_shared` to that pushed commit.
4. Bump `impl-py/docs/_shared` to that pushed commit.
5. Keep Spoke-local runtime and implementation docs outside `docs/_shared`.
6. Do not mix shared-doc edits, ref bumps, and local implementation changes in one commit.
1 change: 1 addition & 0 deletions cli/.changes/selection-v2.removed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
用 `extension select --distribution python|module_federation --version VERSION` 和 `extension unselect` 替换旧的 `install/uninstall` 命令,修复对当前 Core 返回 405 的请求。选择和取消选择均可用 `--peer` 指定目标;取消选择保留共享配置和信息。新增 `extension status` 读取接入 Peer 的状态,配置 schema 同样从该 Peer 读取,不再访问共享身份上已移除的字段。该版本要求 Core 的 Extension management v2 接口,不兼容旧管理接口。
13 changes: 12 additions & 1 deletion cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,16 @@ inkcre-cli config schemas --json
`connection` 只管理本机连接,`config` 管理远端 deployment config;Agent definition 由 `agent` 管理,
模型目录由 `ai` 发现。Extension 配置和运行目标使用 `extension`,不混入 connection。

Extension management v2 uses `extension select COORDINATE --distribution python --version VERSION`
to select an exact version for the connected Peer, then `extension enable COORDINATE` to enable it.
Both commands accept `--peer PEER_ID` to target another Peer. A Web Peer's Distribution is
`module_federation`; it cannot run as a Python package on Core. Disable the Extension before
`extension unselect COORDINATE` to remove that Peer's selection. Shared configuration and collected
information remain. `extension status [COORDINATE]` reads the connected Peer's selection and runtime
state; `extension get` reads shared identity and configuration. Configuration `--schema` also comes
from the connected Peer, rather than the shared Extension row. These commands replace
`install/uninstall` and require the current Core v2 API, with no automatic fallback.

## Agent Query

Agent Query Sink 用 AI 组合已有 info-base 检索、Resolver 读取和图导航,并通过异步 Job 交付答案。先从
Expand Down Expand Up @@ -82,4 +92,5 @@ inkcre-cli job wait 1 --for 10s --json
这些是同一次 HTTP 响应的本地文件,不是新下载会话,也不会再次调用 Resolver。

开发使用独立 PDM 项目:`pdm install -p cli`、`pdm run -p cli check`、`pdm build -p cli`。
0.0.0 是未发布的开发版本;首次发行由仓库的 Release PR 准备。
The repository Release PR prepares versions and changelogs; feature PRs record release intent in
`cli/.changes/`.
3 changes: 2 additions & 1 deletion cli/docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ Feature PR 只放 `cli/.changes/` fragment,使用根 Towncrier 类型。单一
经过正常准备得到 0.1.0;不能因为 base 中尚无 CLI 就直接发布。

`cli-publish.yml` 从精确 main SHA 自行检查、构建 wheel/sdist,并通过 PDM 原生 Trusted Publishing 上传。
PyPI pending publisher 配置:owner InKCre,repository core-py,workflow cli-publish.yml,environment production。
Configure the PyPI pending publisher with owner `InKCre`, repository `impl-py`, workflow
`cli-publish.yml`, and environment `production`.
安装者只需 pip,不需 PDM。新增 REST 依赖时先交付 Core provider,再发布 CLI consumer;没有永久版本相等 gate。

恢复使用 main 上的 workflow_dispatch。PDM `--no-build --skip-existing` 保留当前 run 已检查的构建并续传缺失
Expand Down
9 changes: 5 additions & 4 deletions cli/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,12 @@ dev = ["ruff>=0.15,<0.16", "pyrefly>=0.56,<0.57"]
distribution = true

[tool.pdm.scripts]
lint = "ruff check src"
format = "ruff format src"
"format:check" = "ruff format --check src"
lint = "ruff check src tests"
format = "ruff format src tests"
"format:check" = "ruff format --check src tests"
test = "python -m unittest discover -s tests"
typecheck = "pyrefly check"
check = {composite = ["lint", "format:check", "typecheck"]}
check = {composite = ["lint", "format:check", "typecheck", "test"]}

[tool.ruff]
line-length = 100
Expand Down
49 changes: 38 additions & 11 deletions cli/src/inkcre_cli/commands/extension.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,15 @@ def route(coordinate: str) -> str:

@click.group(cls=Group)
def extension():
"""安装、配置和启停 Extension;发布仍由 Extension 的交付流程负责。"""
"""管理 Peer 的 Extension 选择、配置和启停;发布由 Extension 交付流程负责。"""


@extension.command("list")
@paging
@common
@click.pass_obj
def list_extensions(inv: Invocation, limit, cursor):
"""列出 Extension 的安装、启用意图与运行状态。"""
"""列出共享 Extension 身份与配置;Peer 运行状态见 status。"""
inv.show(inv.send("GET", "/extensions", params={"limit": limit, "cursor": cursor}))


Expand All @@ -33,27 +33,50 @@ def list_extensions(inv: Invocation, limit, cursor):
@common
@click.pass_obj
def get(inv: Invocation, coordinate):
"""读取一个已安装 Extension。"""
"""读取共享 Extension 身份与配置;不代表任一 Peer 已启用。"""
inv.show(inv.send("GET", route(coordinate)))


@extension.command()
@click.argument("coordinate", required=False)
@common
@click.pass_obj
def status(inv: Invocation, coordinate):
"""读取接入 Peer 的选择、运行版本、配置 schema 与重启状态。"""
inv.show(
inv.send(
"POST", "/extension-management/v2", body={"action": "status", "extension": coordinate}
)
)


@extension.command()
@click.argument("coordinate")
@click.option("--distribution", required=True, type=click.Choice(["python", "module_federation"]))
@click.option("--version", required=True, help="准确的已发布版本,不解析 latest")
@click.option("--peer", help="选择的目标 Peer;省略时为接入 Peer")
@common
@click.pass_obj
def install(inv: Invocation, coordinate, version):
"""安装准确版本;不隐式启用。"""
inv.show(inv.send("POST", route(coordinate), params={"version": version}))
def select(inv: Invocation, coordinate, distribution, version, peer):
"""为目标 Peer 选择准确 Distribution/版本;不隐式启用。"""
inv.show(
inv.send(
"POST",
route(coordinate) + "/selection",
params={"route_to_peer": peer},
body={"distribution": distribution, "version": version},
)
)


@extension.command()
@click.argument("coordinate")
@click.option("--peer", help="取消选择的目标 Peer;省略时为接入 Peer")
@common
@click.pass_obj
def uninstall(inv: Invocation, coordinate):
"""卸载 Extension。"""
inv.show(inv.send("DELETE", route(coordinate)), no_content=True)
def unselect(inv: Invocation, coordinate, peer):
"""取消已禁用 Peer 的选择;保留共享 Extension 配置和信息。"""
inv.show(inv.send("DELETE", route(coordinate) + "/selection", params={"route_to_peer": peer}))


@extension.command()
Expand Down Expand Up @@ -91,9 +114,13 @@ def get_config(inv: Invocation, coordinate):


def config_schema(inv: Invocation, coordinate: str) -> dict:
contract = inv.send("GET", route(coordinate))["config_schema"]
result = inv.send(
"POST", "/extension-management/v2", body={"action": "status", "extension": coordinate}
)
selected = next((row for row in result["extensions"] if row["name"] == coordinate), None)
contract = selected["config_schema"] if selected else None
if contract is None:
raise CommandError("该 Extension 尚未发布配置 schema;可在启用后重新发现")
raise CommandError("接入 Peer 尚未发布该 Extension 的配置 schema;请在选择并启用后重新发现")
return contract


Expand Down
99 changes: 99 additions & 0 deletions cli/tests/test_extension.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
"""CLI requests and receipts at the Extension management v2 HTTP boundary."""

import json
import unittest

import httpx
from click.testing import CliRunner

from inkcre_cli.command import Invocation
from inkcre_cli.connection import Connection
from inkcre_cli.http import CoreRESTClient
from inkcre_cli.main import cli


class ExtensionSelectionTests(unittest.TestCase):
def invoke(self, arguments, handler):
invocation = Invocation()
client = CoreRESTClient(
Connection(base_url="http://core.test", jwt_secret="test-only-secret-at-least-32-bytes")
)
client.client.close()
client.client = httpx.Client(transport=httpx.MockTransport(handler))
invocation._client = client
try:
return CliRunner().invoke(cli, ["--json", "extension", *arguments], obj=invocation)
finally:
invocation.close()

def test_select_uses_v2_body_and_optional_target_peer(self):
for distribution, peer in (("python", None), ("module_federation", "web-peer")):
with self.subTest(distribution=distribution):
requests = []

def handle(request, requests=requests, distribution=distribution):
requests.append(request)
return httpx.Response(
200, json={"distribution": distribution, "enabled": False}
)

arguments = [
"select",
"inkcre/rss",
"--distribution",
distribution,
"--version",
"1.2.3",
]
if peer:
arguments += ["--peer", peer]
result = self.invoke(arguments, handle)
self.assertEqual(result.exit_code, 0, result.output)
self.assertEqual(len(requests), 1)
request = requests[0]
self.assertEqual(request.method, "POST")
self.assertEqual(request.url.path, "/extensions/inkcre/rss/selection")
self.assertEqual(dict(request.url.params), {"route_to_peer": peer} if peer else {})
self.assertEqual(
json.loads(request.content), {"distribution": distribution, "version": "1.2.3"}
)
self.assertEqual(json.loads(result.output)["enabled"], False)

def test_unselect_preserves_status_receipt(self):
receipt = {"peer_id": "web-peer", "extensions": [{"distribution": None, "enabled": False}]}

def handle(request):
self.assertEqual(request.method, "DELETE")
self.assertEqual(request.url.path, "/extensions/inkcre/rss/selection")
self.assertEqual(dict(request.url.params), {"route_to_peer": "web-peer"})
return httpx.Response(200, json=receipt)

result = self.invoke(["unselect", "inkcre/rss", "--peer", "web-peer"], handle)
self.assertEqual(result.exit_code, 0, result.output)
self.assertEqual(json.loads(result.output), receipt)

def test_config_schema_comes_from_current_peer_not_shared_identity(self):
schema = {"type": "object", "properties": {"token": {"type": "string"}}}
status = {
"peer_id": "core-peer",
"extensions": [{"name": "inkcre/rss", "config_schema": schema}],
}

def handle(request):
self.assertEqual(request.method, "POST")
self.assertEqual(request.url.path, "/extension-management/v2")
self.assertEqual(
json.loads(request.content), {"action": "status", "extension": "inkcre/rss"}
)
return httpx.Response(200, json=status)

result = self.invoke(["config", "replace", "inkcre/rss", "--schema"], handle)
self.assertEqual(result.exit_code, 0, result.output)
self.assertEqual(json.loads(result.output), schema)
result = self.invoke(["status", "inkcre/rss"], handle)
self.assertEqual(result.exit_code, 0, result.output)
self.assertEqual(json.loads(result.output), status)


if __name__ == "__main__":
unittest.main()
Loading