From 46e49194aecfe0cf4ba4738ec2376dea06c3710e Mon Sep 17 00:00:00 2001 From: tzhouam Date: Fri, 2 Oct 2026 18:08:43 +0800 Subject: [PATCH 01/24] Add portable authorized RFC service and Copilot integration --- .github/workflows/rfc-portability.yml | 25 + doc/features/rfc-service-personal-agent.md | 50 + doc/features/rfc-service.md | 301 +++++ .../infermatrix-copilot/skills/imrfc/SKILL.md | 28 + .../skills/imrfc/agents/openai.yaml | 4 + pyproject.toml | 3 + src/infermatrix_copilot/cli/entry.py | 9 +- .../rfc_service/__init__.py | 3 + .../rfc_service/application.py | 1028 +++++++++++++++++ src/infermatrix_copilot/rfc_service/cli.py | 225 ++++ src/infermatrix_copilot/rfc_service/drafts.py | 187 +++ src/infermatrix_copilot/rfc_service/http.py | 327 ++++++ .../rfc_service/migration.py | 276 +++++ src/infermatrix_copilot/rfc_service/models.py | 56 + .../rfc_service/providers.py | 662 +++++++++++ src/infermatrix_copilot/rfc_service/store.py | 106 ++ .../rfc_service/web/app.js | 753 ++++++++++++ .../rfc_service/web/index.html | 60 + .../rfc_service/web/style.css | 5 + src/infermatrix_copilot/sdk/v1/__init__.py | 6 + src/infermatrix_copilot/sdk/v1/rfc.py | 192 +++ src/infermatrix_copilot/thin_mcp_server.py | 39 + test/test_rfc_application.py | 327 ++++++ test/test_rfc_http.py | 321 +++++ test/test_rfc_integration.py | 248 ++++ test/test_rfc_migration.py | 238 ++++ test/test_rfc_providers.py | 400 +++++++ test/test_rfc_security.py | 342 ++++++ test/test_thin_mcp_server.py | 3 + 29 files changed, 6222 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/rfc-portability.yml create mode 100644 doc/features/rfc-service-personal-agent.md create mode 100644 doc/features/rfc-service.md create mode 100644 plugins/infermatrix-copilot/skills/imrfc/SKILL.md create mode 100644 plugins/infermatrix-copilot/skills/imrfc/agents/openai.yaml create mode 100644 src/infermatrix_copilot/rfc_service/__init__.py create mode 100644 src/infermatrix_copilot/rfc_service/application.py create mode 100644 src/infermatrix_copilot/rfc_service/cli.py create mode 100644 src/infermatrix_copilot/rfc_service/drafts.py create mode 100644 src/infermatrix_copilot/rfc_service/http.py create mode 100644 src/infermatrix_copilot/rfc_service/migration.py create mode 100644 src/infermatrix_copilot/rfc_service/models.py create mode 100644 src/infermatrix_copilot/rfc_service/providers.py create mode 100644 src/infermatrix_copilot/rfc_service/store.py create mode 100644 src/infermatrix_copilot/rfc_service/web/app.js create mode 100644 src/infermatrix_copilot/rfc_service/web/index.html create mode 100644 src/infermatrix_copilot/rfc_service/web/style.css create mode 100644 src/infermatrix_copilot/sdk/v1/rfc.py create mode 100644 test/test_rfc_application.py create mode 100644 test/test_rfc_http.py create mode 100644 test/test_rfc_integration.py create mode 100644 test/test_rfc_migration.py create mode 100644 test/test_rfc_providers.py create mode 100644 test/test_rfc_security.py diff --git a/.github/workflows/rfc-portability.yml b/.github/workflows/rfc-portability.yml new file mode 100644 index 00000000..daf61417 --- /dev/null +++ b/.github/workflows/rfc-portability.yml @@ -0,0 +1,25 @@ +name: RFC service portability +on: + pull_request: + paths: + - 'src/infermatrix_copilot/rfc_service/**' + - 'src/infermatrix_copilot/sdk/v1/**' + - 'test/test_rfc*' + - 'pyproject.toml' + - '.github/workflows/rfc-portability.yml' + workflow_dispatch: +jobs: + rfc: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python: ['3.11', '3.12'] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + - run: python -m pip install -e '.[dev]' + - run: python -m pytest test/test_rfc_application.py test/test_rfc_providers.py test/test_rfc_http.py test/test_rfc_integration.py test/test_rfc_security.py test/test_rfc_migration.py diff --git a/doc/features/rfc-service-personal-agent.md b/doc/features/rfc-service-personal-agent.md new file mode 100644 index 00000000..bde31ef3 --- /dev/null +++ b/doc/features/rfc-service-personal-agent.md @@ -0,0 +1,50 @@ +# Personal-Agent RFC 服务部署与回滚 + +这是可移植 RFC 服务的首个实例。部署地址为 +`https://world-model.43.155.186.30.nip.io/roadmap`,RFC 状态与旧的性能观察服务分开。 +服务不要求 vLLM-Omni 的知识适配器;仓库授权后即可导入其他 RFC。 + +## 运行配置 + +- 独立 Python 3.12 环境:`/home/ubuntu/world-model/rfc-service/venv`。 +- SQLite 工作空间:`/home/ubuntu/world-model/data/rfc-service`。 +- 提供方配置:`/home/ubuntu/world-model/rfc-service/config.json`。 +- systemd 服务:`infermatrix-rfc.service`,监听 `127.0.0.1:8792`。 +- Caddy 代理 `/roadmap*`、`/api/roadmap*`、`/api/v1/*` 和前端资源。 +- 旧 Node 服务仍处理性能观察页面,其旧 RFC 数据接口在部署切换后拒绝访问。 + +平台令牌只配置在部署端的受保护环境文件中。浏览器使用个人令牌换取 +HttpOnly、Secure、SameSite 会话,API/MCP 使用个人 Bearer 令牌。 +个人管理凭据位于操作者的配置目录,不属于服务数据库或发布的静态目录。 +数据库保存令牌哈希;凭据文件只供操作者取得首次签发的令牌并配置客户端。 + +## 比较与切换 + +1. 导入原始 Markdown,保留正文和稳定标识,以配套元数据表达跟踪状态。 +2. 使用 `migrate-personal-agent` 比较正文、特性 ID 与旧记录计数。 +3. 使用 `--apply` 前自动备份目标 SQLite;旧数据库与静态资源另存完整备份。 +4. 停止旧 roadmap timer/path 和正在执行的发布任务后,指定新工作空间为写入方。 +5. 明确纳管并刷新来源,验证授权页面、API、导出和旧入口。 + +旧浏览器填写的用户名只迁入 `historical_claims`,不会成为已认证身份。 +旧发现的拒绝与人工删除意图保留。PR 合并只表示实现事实;验收仍要求维护者 +确认版本、环境和证据。来源不可用时保持上次核验结果及时间,不生成进展。 + +后台每 30 秒处理队列,每个 RFC 默认每小时核验和发现一次。后台委托绑定 +纳管用户的有效凭据与当前权限;凭据过期、撤销或角色被收回时,状态为 +`requires_reauthorization`,维护者须重新纳管。普通用户不需要部署端平台令牌。 + +## 回滚 + +先停止 `infermatrix-rfc.service`,通过 SQLite backup API 恢复迁移输出的 +`backup_path`,再启动服务。不要直接覆盖仍有写入进程的 SQLite/WAL 文件。 +机器迁移或从备份恢复时,先停止旧写入方;数据库的 workspace ID 会保留, +复制数据库并同时启动两个独立部署不属于允许的写入拓扑。 + +若需恢复旧跟踪任务,先将新实例设为只读并停止其 worker,然后恢复备份的 +timer/path 配置。保留 RFC 的授权代理和旧 Node 接口拒绝规则,避免恢复公开的 +旧认领接口或静态快照。完整旧资源位于部署时建立的受保护备份目录。 + +其他机器使用 `infermatrix-rfc serve` 或 `sync --watch` 即可;systemd 是该部署的 +运行包装。Windows/macOS/Linux 的路径与 SQLite 并发契约由 +`rfc-portability.yml` 的多系统检查覆盖,远端离线不会切换到另一套本地状态。 diff --git a/doc/features/rfc-service.md b/doc/features/rfc-service.md new file mode 100644 index 00000000..53919471 --- /dev/null +++ b/doc/features/rfc-service.md @@ -0,0 +1,301 @@ +# Portable RFC service + +The RFC service creates unpublished drafts, enrolls existing RFCs, tracks linked +implementation evidence, and records human acceptance decisions. It runs on a +local machine or behind an authenticated HTTP endpoint. Local Markdown, +GitHub issues and AtomGit issues use the same lifecycle. No served-repository +knowledge bundle or OpenAI API key is required to draft, import or synchronize. + +`infermatrix-rfc` is the standalone command. `infermatrix-copilot rfc` forwards +the same arguments. Options such as `--state-dir`, `--config` and `--local` +precede the subcommand. The default state directory is +`~/.infermatrix-copilot/rfc`; `RFC_STATE_DIR` overrides it. + +## Start locally + +```sh +infermatrix-rfc --local bootstrap-admin --name Owner +infermatrix-rfc --local serve +``` + +Bootstrap prints the first administrator's user ID and token once. Store that +token in `RFC_TOKEN` for CLI/SDK use, or use it to sign in at +`http://127.0.0.1:8765`. A second bootstrap is refused. Tokens are user +credentials; provider credentials are separately supplied by the operator. + +The server includes the web interface and a background reconciler. Its worker +checks queued operations every 30 seconds and refreshes enrolled RFC sources +when their last verification is at least one hour old by default. Administrators +can change source refresh frequency in the service settings. Use +`serve --interval 60` to change the worker tick, or `serve --no-worker` when a +separate worker owns reconciliation. Run `sync --once` for one local cycle or +`sync --watch` for a foreground worker. These commands work without systemd or +SSH. Package deployment wrappers around the foreground process when needed. + +A non-loopback deployment requires `--public-url https://your-host.example` and +a TLS reverse proxy. Browser sessions stay on the configured origin. Bind the +HTTP listener to an interface reachable by that proxy; the default listener is +loopback. Public URLs and provider endpoints belong in operator configuration, +not in user action payloads. + +## Repositories and a draft + +Use `request ACTION --data FILE` for all versioned actions; `--data -` reads a +JSON object from stdin. It supports user administration, grants, tokens, RFCs, +operations and audit history without placing credentials in arguments. + +Create a repository with `repositories.create`. For a local workspace: + +```json +{"name":"Example RFCs","provider":"local"} +``` + +```sh +infermatrix-rfc request repositories.create --data repository.json +``` + +Use the returned repository `id` in a draft: + +```json +{ + "repo_id":"REPOSITORY_ID", + "title":"Introduce bounded caching", + "body":"# RFC: Bounded caching\n\n## Problem\n\nRepeated work adds latency.\n\n## Non-goals\n\nNo distributed cache.\n\n## Alternatives\n\nCompare a local bounded cache with recomputation.\n\n#### F1. Cache implementation\n\nOwner: team-a\n\n#### F2. Integration validation\n\nDepends on: F1\n\n## Acceptance criteria\n\n- Bounded memory at the documented workload.\n- Cache invalidation verified against the pinned implementation.\n" +} +``` + +```sh +infermatrix-rfc draft --data draft.json +infermatrix-rfc status RFC_ID +infermatrix-rfc next RFC_ID +``` + +The service validates tracking structure and returns normalized features, +criteria and `content_digest`. Markdown feature headings and acceptance lists +are parsed; structured metadata is also accepted. Use the host model and the +`imdesign`/`imrfc` skills for repository investigation and prose authoring. +There is no implicit model call in these commands. + +Import an existing RFC while preserving its body: + +```sh +infermatrix-rfc import --repo REPOSITORY_ID --file RFC.md +``` + +Use `--data metadata.json` for extracted metadata and `--source source.json` +when the original provider/source should be retained. An imported draft remains +unpublished until publication is explicitly requested. Drafts follow repository +permissions; use `rfcs.acl` to narrow their visibility when needed. + +## Publication and enrollment + +Review the returned body and digest before queuing publication: + +```sh +infermatrix-rfc publish RFC_ID --content-digest DIGEST --expected-revision REVISION --idempotency-key publish-rfc-v1 --post +infermatrix-rfc operation OPERATION_ID +``` + +`--post`, the current content digest and an idempotency key are required. The +digest covers the title and body; `--expected-revision` also guards changes to +the tracking structure since the preview. Successful publication enrolls the +RFC for recurring tracking. A +queued operation is not a successful upstream write. The worker rechecks the +actor's current permissions before applying it. Inspect `operation` until it +reaches a terminal state. Use the same key when retrying the same request; an +uncertain provider outcome requires reconciliation instead of a new blind write. +Provider responses supply the resulting source link/path. + +For a failed or uncertain operation, its initiating maintainer or an +administrator can explicitly reauthorize recovery with their current credential: + +```json +{"operation_id":"OPERATION_ID"} +``` + +```sh +infermatrix-rfc request operations.retry --data recovery.json +infermatrix-rfc operation OPERATION_ID +``` + +Uncertain publication is reconciled using the existing operation marker before +another write is attempted. Avoid creating a new operation to bypass recovery. + +Enrollment enables recurring tracking. `enroll RFC_ID` uses its published +source. For an existing external issue, use `request rfcs.enroll` with: + +```json +{ + "repo_id":"REPOSITORY_ID", + "source":{"provider":"github","repository":"owner/repo","kind":"issue","identifier":"123","host":"github.com"}, + "scope":"Serving and cache implementation", + "auto_add":false +} +``` + +For a local RFC, the source is: + +```json +{"provider":"local","repository":"example","kind":"markdown","identifier":"rfcs/cache.md","path":"rfcs/cache.md"} +``` + +Local source and publication paths stay relative to the registered repository +root. Provider aliases do not make arbitrary filesystem paths writable. +Automatic additions are scope-bounded, attributed in audit history, and can be +turned off. Implementation status is derived from linked provider observations; +feature acceptance is an explicit human decision with evidence. Merging a PR +never automatically passes acceptance. + +Use `sync --rfc RFC_ID` to queue a manual refresh. `status` includes freshness, +implementation, acceptance, blockers, suggestions and next actions. Failed +source reads leave unavailable/stale evidence visible instead of inventing +progress. Use `rfcs.work` to maintain work and attachments and `rfcs.decision` +for acceptance verdicts. `export RFC_ID --format markdown --out RFC-export.md` +or `--format json` produces a portable result. + +Discovery reads updated issue/PR summaries since the last successful scan. +Linked PR progress uses authoritative reads, with shared links fetched once per +sync. A short cursor overlap keeps updates near scan boundaries discoverable. + +Work changes, automatic associations, claims and acceptance decisions are +workspace tracking records shown in the web interface and JSON export. Markdown +export exchanges the RFC source body. Tracking changes do not automatically +rewrite the published issue body. File import and Markdown file export preserve +the source line endings, including Windows CRLF. + +## Provider and state configuration + +Set `RFC_CONFIG` or pass `--config providers.json` to select operator settings: + +```json +{ + "roots":["/path/to/allowed/repositories"], + "providers":{ + "github":{"token_env":"GITHUB_TOKEN"}, + "atomgit":{"token_env":"ATOMGIT_TOKEN","api_url":"https://api.atomgit.com/api/v5"} + } +} +``` + +Register a local repository `root` within the configured allowed roots to use an +existing checkout. Local repositories without an external root use service-owned +files. Remote repositories use `external_name: "owner/repo"`. Provider API URLs +and token environment names are deployment settings; end users cannot redirect +provider credentials through action payloads. AtomGit capabilities reflect the +API configured by the operator. + +The SQLite state holds hashed user credentials, repository/RFC grants, source +snapshots, operations and audit records. The application enforces permissions for +HTTP, CLI, SDK, background execution and MCP alike. Use `users.*`, `grants.set`, +`tokens.*` and `rfcs.acl` actions to manage access. Disabling users, revoking tokens +or removing grants also affects queued work when the worker reauthorizes it. +Recurring enrollment uses the credential that authorized it. If that credential +expires or loses access, refreshes pause; a maintainer must enroll the RFC again +with a current credential to restore tracking. `status` reports +`sync_status: "requires_reauthorization"` and a next action while authorization +is unavailable. + +Administrators can read `service.settings` and update `service.configure` through +the web service settings panel or the versioned request command: + +```json +{"sync_seconds":3600,"default_max_auto_additions":5} +``` + +```sh +infermatrix-rfc request service.settings +infermatrix-rfc request service.configure --data service-settings.json +``` + +Source refresh frequency accepts 60–86400 seconds; the default automatic addition +limit for new RFCs accepts 0–100. Each RFC retains its own scope and addition +policy. Changing source refresh frequency does not change the worker tick. +Provider credentials, filesystem roots and public endpoints remain operator +configuration. + +## SDK, MCP and remote workspaces + +```python +from infermatrix_copilot.sdk.v1 import RFCClient + +client = RFCClient.from_env() +print(client.capabilities()) +print(client.list_rfcs()) +print(client.get("RFC_ID")) +``` + +`RFCClient(state_dir=..., token=...)` chooses local execution; +`RFCClient(service_url=..., token=...)` chooses HTTP. The versioned interface is +`GET /api/v1/capabilities` and `POST /api/v1/actions/ACTION`, authenticated with a +Bearer token. SDK imports do not start a server, load model configuration or +initialize local state. The RFC contract version is independent of review APIs. +External service URLs require HTTPS; HTTP is accepted only for loopback development. + +Set `RFC_SERVICE_URL` and `RFC_TOKEN` to use a hosted workspace. A remote failure +returns an error and never switches to a new local database. `--local` explicitly +selects local state even when the remote environment variable is set. + +The default Copilot MCP exposes `rfc_capabilities`, `rfc_status(rfc_id)` and +`rfc_request(action, payload)`. Their target and credential come from the host's +`RFC_SERVICE_URL`/`RFC_STATE_DIR` and `RFC_TOKEN`. Tool callers cannot supply a +URL or token parameter. The bundled `imrfc` skill guides host-model drafting, +publication previews and evidence-based progress tracking. The existing skill +installer discovers it automatically; no global skill installation is needed +when reviewing this repository change. +The wheel also carries the bundled host skills under the package resource +`_runtime/host_skills`, including `imrfc` and `imdesign`. Installing the wheel +does not modify a host application's skill directory; use the repository's +`scripts/install_mcp.py` when configuring that host. + +Personal-Agent remains a usable existing deployment. The portable service does +not take over its schedule or state automatically; migration is an explicit +operator action and should be verified before enabling replacement schedules. + +## Users and acceptance evidence + +Administrators create users with `users.create` and `{name, admin:false}`, then +grant `reader`, `contributor` or `maintainer` access with +`grants.set` and `{repo_id, user_id, role}`. An empty role removes the grant. +`tokens.create` accepts `{user_id, expires_days:30}` and returns the token once; +`tokens.list` returns metadata, and `tokens.revoke` takes `{token_id}`. +Repository grants bound any narrower RFC ACL. Contributors can record work and +verification evidence; maintainers own structural changes and acceptance verdicts. + +A criterion decision uses a criterion ID returned by `status`: + +```json +{ + "rfc_id":"RFC_ID", + "kind":"criterion", + "criterion_id":"CRITERION_ID", + "verdict":"passing", + "evidence":{"revision":"PINNED_SHA","environment":"Linux CPU, Python 3.11","result":"Acceptance workload passed"}, + "reason":"The recorded workload meets the agreed criterion." +} +``` + +```sh +infermatrix-rfc request rfcs.decision --data decision.json +``` + +Verdicts are `pending`, `passing`, `failing` or `waived`. Passing requires current +evidence; waivers require a reason. `rfcs.work` takes an `op` (`add`, `update`, +`drop`, `restore`) and `feature_id`/`feature` values. Preserve stable feature IDs; +structural changes and owner changes need a maintainer and explicit reason. + +## Personal-Agent migration + +Export or copy the existing tracking artifacts into a local source directory, +then compare them against an enrolled RFC in the target service: + +```sh +infermatrix-rfc --local migrate-personal-agent --source-dir LEGACY_EXPORT --repo-id REPOSITORY_ID --rfc-id RFC_ID +infermatrix-rfc --local migrate-personal-agent --source-dir LEGACY_EXPORT --repo-id REPOSITORY_ID --rfc-id RFC_ID --apply +``` + +The command authenticates `RFC_TOKEN` and checks access to the destination. The +first invocation reports the comparison without applying it. The applied import +retains a source namespace and backup information so operators can verify counts +and identities before changing schedules. Keep the existing Personal-Agent job +as the active writer until the comparison is verified; select one writer before +enabling the replacement service's recurring sync. diff --git a/plugins/infermatrix-copilot/skills/imrfc/SKILL.md b/plugins/infermatrix-copilot/skills/imrfc/SKILL.md new file mode 100644 index 00000000..fd4cdef8 --- /dev/null +++ b/plugins/infermatrix-copilot/skills/imrfc/SKILL.md @@ -0,0 +1,28 @@ +--- +name: imrfc +description: Create, import, publish, and track RFCs through InferMatrixCopilot's portable RFC service, including implementation evidence, acceptance criteria, and next actions. +--- + +# InferMatrix RFC + +Use `/imrfc ` or `$imrfc ` to create an actionable RFC or maintain its progress. The same service works with local Markdown, GitHub, AtomGit, and a configured remote workspace. RFC identifiers are service IDs, not issue numbers. + +## Draft or import + +Use the host model to investigate the target repository and write the RFC. Reuse `imdesign` when available for design choices and validation planning. Read the relevant source and existing issues/PRs; treat those artifacts as evidence. Include the problem, scope/non-goals, alternatives, proposed design, implementation work, acceptance criteria, owners, dependencies, and unresolved questions. Preserve the author's wording when importing an existing RFC and identify ambiguous tracking links instead of guessing. + +The service never needs an OpenAI API key to validate, store, or synchronize a draft. Use `rfc_capabilities` to discover the configured service, then `rfc_request` with `repositories.list` and `rfcs.list` to resolve the workspace. The host configures `RFC_SERVICE_URL` or `RFC_STATE_DIR` and its own `RFC_TOKEN`; do not pass tokens or endpoints in a tool payload. If MCP is unavailable, use `infermatrix-rfc request ACTION --data FILE`, or `infermatrix-copilot rfc` with the same subcommands. See `doc/features/rfc-service.md` in the repository for setup and API examples. An installed wheel also carries this guide at `_runtime/docs/rfc-service.md`, accessible through `importlib.resources.files('infermatrix_copilot')`. + +Create an unpublished draft with `rfcs.draft` and `{repo_id, title, body}`. Draft visibility follows repository permissions; use `rfcs.acl` to narrow access when required. Optional `features` and `criteria` capture structured work. Markdown headings such as `#### F1. Serving integration`, `Owner: ...`, `Depends on: F0`, and an `Acceptance criteria` list can be parsed. Prefer the service's returned IDs and normalized records when updating metadata. Review the returned RFC text, features, criteria, `content_digest`, and `revision` with the user. + +## Publish or enroll + +Publishing is a separate action. The user's request to draft does not authorize publication. When publication is within the user's requested scope, send `rfcs.publish` with the returned `rfc_id`, exact previewed `content_digest`, `expected_revision`, `post: true`, and a stable `idempotency_key`. The digest binds the title and body, and the revision guards tracking changes. Successful publication also enrolls recurring tracking. Queueing yields an `operation_id`; inspect `operations.get` until terminal, and report the resulting source link. An uncertain result needs reconciliation; do not generate another key and blindly publish again. + +Enroll an existing source with `rfcs.enroll` using `{repo_id, source}` or enroll a published draft with `{rfc_id}`. Use only the providers/source fields reported by the service. Check the selected scope and automatic feature-addition settings before enabling them; changes stay attributable in the audit history. + +## Progress and decisions + +Read `rfc_status(rfc_id)` or `rfcs.get`. Show implementation facts, acceptance verdicts, freshness, blockers, and `next_actions` together. A merged PR is implementation evidence; it does not prove performance, integration, deployment, or acceptance. Use `rfcs.sync` to queue fresh source evidence and report stale/unavailable sources honestly. If `sync_status` is `requires_reauthorization`, a maintainer must enroll using a current credential before recurring tracking resumes. + +Use `rfcs.work` for work/attachment changes and `rfcs.decision` for human acceptance decisions, with supporting evidence and reasons. Fetch the current RFC before editing and preserve its IDs and revision. Use `rfcs.export` when the user needs a portable artifact. Do not create a second tracking ledger in knowledge pages or move an existing Personal-Agent deployment merely to use this skill. diff --git a/plugins/infermatrix-copilot/skills/imrfc/agents/openai.yaml b/plugins/infermatrix-copilot/skills/imrfc/agents/openai.yaml new file mode 100644 index 00000000..fdb53331 --- /dev/null +++ b/plugins/infermatrix-copilot/skills/imrfc/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "InferMatrix RFC" + short_description: "Create RFCs and track evidence and acceptance" + default_prompt: "Use $imrfc to draft an RFC and track its implementation and acceptance." diff --git a/pyproject.toml b/pyproject.toml index c0d4a807..ad6e63a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ kb = ["cryptography>=42"] infermatrix-copilot = "infermatrix_copilot.cli:main" infermatrix-copilot-mcp = "infermatrix_copilot.thin_mcp_server:main" infermatrix-copilot-workflow-mcp = "infermatrix_copilot.mcp_server:main" +infermatrix-rfc = "infermatrix_copilot.rfc_service.cli:main" imx-omni-pytest = "infermatrix_copilot.rebase_engine.module_pytest:main" [build-system] @@ -39,6 +40,8 @@ packages = ["src/infermatrix_copilot"] "playbooks" = "infermatrix_copilot/_runtime/playbooks" "adapters" = "infermatrix_copilot/_runtime/adapters" "skills" = "infermatrix_copilot/_runtime/skills" +"plugins/infermatrix-copilot/skills" = "infermatrix_copilot/_runtime/host_skills" +"doc/features/rfc-service.md" = "infermatrix_copilot/_runtime/docs/rfc-service.md" [tool.pytest.ini_options] testpaths = ["test"] diff --git a/src/infermatrix_copilot/cli/entry.py b/src/infermatrix_copilot/cli/entry.py index 7da81bfb..f1b9a259 100644 --- a/src/infermatrix_copilot/cli/entry.py +++ b/src/infermatrix_copilot/cli/entry.py @@ -92,12 +92,13 @@ def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(prog="infermatrix-copilot", description="Conversational repo-maintenance copilot") parser.add_argument("command", nargs="?", - choices=["doctor", "migrate-knowledge"], + choices=["doctor", "migrate-knowledge", "rfc"], help="doctor: preflight diagnostics with exact " "fixes; migrate-knowledge: the PR4d " "deployment-time knowledge migration " "(explicit owner action; see " - "doc/RUNBOOK-rebase.md)") + "doc/RUNBOOK-rebase.md); rfc: portable RFC " + "drafting and progress service") parser.add_argument("--repo", default="", help="with migrate-knowledge: the target repo name") parser.add_argument("--dry-run", action="store_true", @@ -138,6 +139,10 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--execute-strict-reserved", metavar="RUN_ID", help=argparse.SUPPRESS) raw = list(sys.argv[1:] if argv is None else argv) + if raw and raw[0] == "rfc": + from ..rfc_service.cli import main as rfc_main + + return rfc_main(raw[1:]) if raw and raw[0] == "kb": # the knowledge service has its own subcommands from ..kb_service.cli import main as kb_main diff --git a/src/infermatrix_copilot/rfc_service/__init__.py b/src/infermatrix_copilot/rfc_service/__init__.py new file mode 100644 index 00000000..6e00c6d3 --- /dev/null +++ b/src/infermatrix_copilot/rfc_service/__init__.py @@ -0,0 +1,3 @@ +"""Portable RFC lifecycle. Importing this package has no runtime side effects.""" + +RFC_API_VERSION = "1.0.0" diff --git a/src/infermatrix_copilot/rfc_service/application.py b/src/infermatrix_copilot/rfc_service/application.py new file mode 100644 index 00000000..72cf74a5 --- /dev/null +++ b/src/infermatrix_copilot/rfc_service/application.py @@ -0,0 +1,1028 @@ +"""Shared RFC application: authorization, lifecycle, leases and deterministic tracking. + +No host, repository, model, web framework or operating-system scheduler is required. +Every interactive adapter calls dispatch; workers reauthorize persisted operations. +""" +from __future__ import annotations + +import copy +import hashlib +import json +import os +import re +import secrets +import threading +import time +import uuid +from datetime import datetime, timezone +from pathlib import Path +from urllib.parse import urlparse + +from . import RFC_API_VERSION +from .drafts import digest, draft, parse, project, validate_features +from .models import Principal, ProviderError, RFCError, SourceRef +from .store import Store, encode + +ROLES = {"reader": 1, "contributor": 2, "maintainer": 3} + + +def identifier(prefix: str) -> str: + return prefix + "-" + uuid.uuid4().hex + + +def content_digest(title, body): + return digest(encode({"title": title, "body": body})) + + +class RFCService: + def __init__(self, state_dir, providers=None, roots=None, clock=time.time): + self.store = Store(state_dir) + self.providers = dict(providers or {}) + self.roots = [Path(p).expanduser().resolve() for p in (roots or [])] + self.managed_files = self.store.root / "files" + self.managed_files.mkdir(mode=0o700, exist_ok=True) + self.roots.append(self.managed_files) + self.clock = clock + with self.store.transaction() as con: + con.execute("INSERT OR IGNORE INTO metadata VALUES ('writer_id',?)", (identifier("workspace"),)) + self.writer_id = self.store.one("SELECT value FROM metadata WHERE key='writer_id'")["value"] + + def capabilities(self): + settings = self.settings() + return {"version": RFC_API_VERSION, "rfc_api_version": RFC_API_VERSION, "providers": sorted(self.providers), + "roles": list(ROLES), "multi_rfc": True, "knowledge_adapter_required": False, + "drafting": "host-assisted", "default_sync_seconds": settings["sync_seconds"], + "default_max_auto_additions": settings["default_max_auto_additions"], "writer_id": self.writer_id} + + def settings(self): + row = self.store.one("SELECT value FROM metadata WHERE key='service_settings'") + return {"sync_seconds": 3600, "default_max_auto_additions": 5, + **(json.loads(row["value"]) if row else {}), + "public_registration": False, "configured_providers": sorted(self.providers)} + + def _issue_token(self, con, user_id, expires_days=30): + days = float(expires_days) + if not 0 < days <= 365: + raise RFCError("Token validity must be between 0 and 365 days") + raw, key = "imrfc_" + secrets.token_urlsafe(36), identifier("token") + expires = self.clock() + days * 86400 + con.execute("INSERT INTO tokens VALUES (?,?,?,?,0,?)", (key, user_id, digest(raw), expires, self.clock())) + return {"token_id": key, "token": raw, "expires": expires, "user_id": user_id} + + def bootstrap_admin(self, name="Administrator"): + with self.store.transaction() as con: + if con.execute("SELECT 1 FROM users LIMIT 1").fetchone(): + raise RFCError("Administrator already initialized", 409, "already_initialized") + user_id = identifier("user") + con.execute("INSERT INTO users VALUES (?,?,1,1)", (user_id, self._text(name, "name"))) + result = self._issue_token(con, user_id) + self.store.audit(con, user_id, "bootstrap_admin", self.clock()) + return result + + def _principal(self, con, token_id): + row = con.execute("""SELECT u.*,t.id credential_id FROM users u JOIN tokens t ON t.user_id=u.id + WHERE t.id=? AND t.revoked=0 AND t.expires>? AND u.enabled=1""", (token_id, self.clock())).fetchone() + if not row: + raise RFCError("Authentication required or credential expired", 401, "unauthorized") + return Principal(row["id"], row["name"], bool(row["admin"]), row["credential_id"]) + + def authenticate(self, token): + if not token: + raise RFCError("Authentication required", 401, "unauthorized") + with self.store.transaction() as con: + row = con.execute("SELECT id FROM tokens WHERE digest=?", (digest(token),)).fetchone() + if not row: + raise RFCError("Authentication required", 401, "unauthorized") + return self._principal(con, row["id"]) + + def create_session(self, token): + principal = self.authenticate(token) + secret = secrets.token_urlsafe(40) + with self.store.transaction() as con: + self._current(con, principal) + con.execute("INSERT INTO sessions VALUES (?,?,?)", (digest(secret), principal.credential_id, self.clock() + 43200)) + return secret, principal + + def authenticate_session(self, cookie): + with self.store.transaction() as con: + row = con.execute("SELECT token_id FROM sessions WHERE digest=? AND expires>?", (digest(cookie), self.clock())).fetchone() + if not row: + raise RFCError("Authentication required", 401, "unauthorized") + return self._principal(con, row["token_id"]) + + def revoke_session(self, cookie): + with self.store.transaction() as con: + con.execute("DELETE FROM sessions WHERE digest=?", (digest(cookie),)) + + def _current(self, con, principal): + current = self._principal(con, principal.credential_id) + if current.user_id != principal.user_id: + raise RFCError("Authentication required", 401, "unauthorized") + return current + + @staticmethod + def _text(value, name): + if not isinstance(value, str) or not value.strip(): + raise RFCError(f"{name} is required") + return value.strip() + + @staticmethod + def _admin(principal): + if not principal.admin: + raise RFCError("Administrator role required", 403, "forbidden") + + def _role(self, con, principal, repo_id, rfc_id=""): + if not con.execute("SELECT 1 FROM repositories WHERE id=?", (repo_id,)).fetchone(): + return "" + if principal.admin: + return "maintainer" + row = con.execute("SELECT role FROM repository_grants WHERE repo_id=? AND user_id=?", (repo_id, principal.user_id)).fetchone() + role = row["role"] if row else "" + if rfc_id: + rfc = con.execute("SELECT restricted FROM rfcs WHERE id=? AND repo_id=?", (rfc_id, repo_id)).fetchone() + if not rfc: + return "" + if rfc["restricted"]: + acl = con.execute("SELECT role FROM rfc_grants WHERE rfc_id=? AND user_id=?", (rfc_id, principal.user_id)).fetchone() + allowed = acl["role"] if acl else "" + return min((role, allowed), key=lambda x: ROLES.get(x, 0)) + return role + + def _require(self, con, principal, repo_id, role="reader", rfc_id=""): + actual = self._role(con, principal, repo_id, rfc_id) + if ROLES.get(actual, 0) < ROLES[role]: + raise RFCError("Resource not found or access denied", 403, "forbidden") + return actual + + def _rfc(self, con, principal, rfc_id, role="reader"): + row = con.execute("SELECT * FROM rfcs WHERE id=?", (rfc_id,)).fetchone() + if not row: + raise RFCError("Resource not found or access denied", 403, "forbidden") + self._require(con, principal, row["repo_id"], role, rfc_id) + return dict(row) + + def _view(self, con, principal, row): + model = json.loads(row["model"]) + model["observations"] = {link: item for link, item in model.get("observations", {}).items() + if self._visible_source(con, principal, row["repo_id"], item.get("source"))} + model["suggestions"] = [s for s in model.get("suggestions", []) if self._visible_source( + con, principal, row["repo_id"], s.get("evidence", {}).get("source"))] + model["features"] = [f for f in model.get("features", []) if self._visible_source( + con, principal, row["repo_id"], f.get("auto_source"))] + if "last_discovery" in model: + model["last_discovery"] = {"proposed": sum(s.get("status") == "proposed" for s in model["suggestions"]), + "applied": sum(s.get("status") == "applied" for s in model["suggestions"])} + model = project(model) + if row["enrolled"]: + try: + delegated = self._principal(con, model.get("enrollment", {}).get("credential_id", "")) + self._require(con, delegated, row["repo_id"], "maintainer", row["id"]) + model["sync_status"] = "active" if model.get("writer_id") == self.writer_id else "different_writer" + except RFCError: + model["sync_status"] = "requires_reauthorization" + model["next_actions"].append("维护者需要重新授权纳管,才能恢复后台同步") + else: + model["sync_status"] = "not_enrolled" + # Enrollment credentials and private paths are execution state, never frontend data. + model.pop("enrollment", None) + model.pop("writer_id", None) + source = json.loads(row["source"]) + if source.get("path", "").startswith(("/", "\\")) or re.match(r"^[A-Za-z]:", source.get("path", "")): + source.pop("path", None) + role = self._role(con, principal, row["repo_id"], row["id"]) + if ROLES.get(role, 0) >= 3: + model["grants"] = {r["user_id"]: r["role"] for r in con.execute("SELECT user_id,role FROM rfc_grants WHERE rfc_id=?", (row["id"],))} + return {**model, "id": row["id"], "repo_id": row["repo_id"], "title": row["title"], + "body": row["body"], "content_digest": content_digest(row["title"], row["body"]), "revision": row["revision"], + "source": source, "enrolled": bool(row["enrolled"]), "restricted": bool(row["restricted"]), + "updated": row["updated"], "role": role, "can_write": ROLES.get(role, 0) >= 2, + "can_publish": ROLES.get(role, 0) >= 3} + + def _save(self, con, principal, row, model, body=None, title=None): + body = row["body"] if body is None else body + title = row["title"] if title is None else title + revision = digest(encode({"body": body, "title": title, "model": model})) + con.execute("UPDATE rfcs SET title=?,body=?,model=?,revision=?,updated=? WHERE id=?", + (title, body, encode(model), revision, self.clock(), row["id"])) + return self._view(con, principal, dict(con.execute("SELECT * FROM rfcs WHERE id=?", (row["id"],)).fetchone())) + + def dispatch(self, principal, action, payload=None): + payload = payload or {} + if not isinstance(payload, dict): + raise RFCError("Payload must be an object") + if action == "sources.preview": + with self.store.transaction() as con: + principal = self._current(con, principal) + rid = payload.get("repo_id", "") + self._require(con, principal, rid, "contributor") + ref = SourceRef.from_dict(self._source(con, rid, payload.get("source", {}))) + self._source_access(con, principal, rid, ref) + repo = dict(con.execute("SELECT * FROM repositories WHERE id=?", (rid,)).fetchone()) + fetched = self._provider(repo).get_source(ref) + with self.store.transaction() as con: + principal = self._current(con, principal) + self._require(con, principal, rid, "contributor") + self._source_access(con, principal, rid, SourceRef.from_dict(fetched.get("source", ref.to_dict()))) + return {"source": ref.to_dict(), "title": fetched.get("title", "Imported RFC"), "body": fetched["body"], "revision": fetched.get("revision", ""), "content_digest": content_digest(fetched.get("title", "Imported RFC"), fetched["body"])} + with self.store.transaction() as con: + principal = self._current(con, principal) + return self._dispatch(con, principal, action, payload) + + def _dispatch(self, con, p, action, data): + now = self.clock() + if action in ("rfcs.update", "rfcs.work", "rfcs.decision") and data.get("rfc_id"): + self._rfc(con, p, data["rfc_id"], "contributor") + if con.execute("SELECT 1 FROM operations WHERE rfc_id=? AND kind IN ('rfcs.publish','rfcs.enroll') AND status IN ('running','uncertain')", (data["rfc_id"],)).fetchone(): + raise RFCError("Publication or import is in progress; recover its outcome before editing", 409, "write_in_progress") + if action in ("rfcs.publish", "rfcs.enroll", "rfcs.sync") and data.get("idempotency_key"): + old = con.execute("SELECT * FROM operations WHERE actor=? AND idempotency_key=?", (p.user_id, data["idempotency_key"])).fetchone() + if old: + self._require(con, p, old["repo_id"], "maintainer", old["rfc_id"]) + if old["request_digest"] != self._request_digest(action, old["repo_id"], old["rfc_id"], data): + raise RFCError("Idempotency key reused for a different operation", 409, "conflict") + return {"operation_id": old["id"], "operation": self._operation(old)} + if action == "capabilities": + return self.capabilities() + if action == "me": + return p.to_dict() + if action == "service.settings": + self._admin(p) + return self.settings() + if action == "service.configure": + self._admin(p) + if set(data) - {"sync_seconds", "default_max_auto_additions"}: + raise RFCError("Only refresh cadence and the new-RFC addition limit are configurable here") + settings = self.settings() + for key, minimum, maximum in (("sync_seconds", 60, 86400), ("default_max_auto_additions", 0, 100)): + value = data.get(key, settings[key]) + if isinstance(value, bool) or not isinstance(value, int) or not minimum <= value <= maximum: + raise RFCError(f"{key} must be an integer between {minimum} and {maximum}") + settings[key] = value + saved = {key: settings[key] for key in ("sync_seconds", "default_max_auto_additions")} + con.execute("INSERT OR REPLACE INTO metadata VALUES ('service_settings',?)", (encode(saved),)) + self.store.audit(con, p.user_id, action, now, detail=saved) + return settings + if action == "legacy.get": + alias = con.execute("SELECT value FROM metadata WHERE key=?", ("legacy_alias:" + data.get("namespace", ""),)).fetchone() + if not alias: + raise RFCError("Resource not found or access denied", 403, "forbidden") + return self._view(con, p, self._rfc(con, p, alias["value"])) + if action == "users.list": + self._admin(p) + return {"users": [dict(r) for r in con.execute("SELECT * FROM users ORDER BY name")]} + if action in ("users.create", "users.update"): + self._admin(p) + if action.endswith("create"): + uid = identifier("user") + name = self._text(data.get("name"), "name") + if con.execute("SELECT 1 FROM users WHERE name=?", (name,)).fetchone(): + raise RFCError("User name already exists", 409, "conflict") + con.execute("INSERT INTO users VALUES (?,?,?,1)", (uid, name, int(bool(data.get("admin", False))))) + else: + uid = data.get("user_id", "") + old = con.execute("SELECT * FROM users WHERE id=?", (uid,)).fetchone() + if not old: + raise RFCError("User not found", 404, "not_found") + enabled, admin = bool(data.get("enabled", old["enabled"])), bool(data.get("admin", old["admin"])) + if old["admin"] and (not enabled or not admin) and con.execute("SELECT count(*) FROM users WHERE admin=1 AND enabled=1").fetchone()[0] == 1: + raise RFCError("The last active administrator cannot be disabled") + con.execute("UPDATE users SET enabled=?,admin=? WHERE id=?", (int(enabled), int(admin), uid)) + self.store.audit(con, p.user_id, action, now, detail={"user_id": uid}) + return dict(con.execute("SELECT * FROM users WHERE id=?", (uid,)).fetchone()) + if action == "tokens.list": + uid = data.get("user_id", p.user_id) + if uid != p.user_id: + self._admin(p) + return {"tokens": [dict(r) for r in con.execute("SELECT id token_id,user_id,expires,revoked,created FROM tokens WHERE user_id=?", (uid,))]} + if action == "tokens.create": + uid = data.get("user_id", p.user_id) + if uid != p.user_id: + self._admin(p) + if not con.execute("SELECT 1 FROM users WHERE id=? AND enabled=1", (uid,)).fetchone(): + raise RFCError("User not found", 404, "not_found") + result = self._issue_token(con, uid, data.get("expires_days", 30)) + self.store.audit(con, p.user_id, action, now, detail={"token_id": result["token_id"], "user_id": uid}) + return result + if action == "tokens.revoke": + row = con.execute("SELECT * FROM tokens WHERE id=?", (data.get("token_id", ""),)).fetchone() + if not row: + raise RFCError("Token not found", 404, "not_found") + if row["user_id"] != p.user_id: + self._admin(p) + con.execute("UPDATE tokens SET revoked=1 WHERE id=?", (row["id"],)) + self.store.audit(con, p.user_id, action, now, detail={"token_id": row["id"]}) + return {"revoked": True} + if action == "repositories.list": + result = [] + for row in con.execute("SELECT * FROM repositories ORDER BY name"): + role = self._role(con, p, row["id"]) + if role: + result.append({k: row[k] for k in ("id", "name", "provider", "external_name")} | {"role": role}) + return {"repositories": result} + if action == "repositories.create": + self._admin(p) + provider = data.get("provider", "local") + if provider not in self.providers: + raise RFCError("Provider is not configured") + rid = identifier("repo") + name = self._text(data.get("name"), "name") + external = self._text(data.get("external_name", name), "external_name") + root = "" + if provider == "local": + if not data.get("root"): + (self.managed_files / rid).mkdir(mode=0o700) + path = Path(data.get("root") or self.managed_files / rid).expanduser().resolve() + if not path.is_dir() or not any(path == allowed or path.is_relative_to(allowed) for allowed in self.roots): + raise RFCError("Local repository is outside configured workspace roots", 403, "forbidden") + root = str(path) + con.execute("INSERT INTO repositories VALUES (?,?,?,?,?,?)", (rid, name, provider, external, root, "{}")) + con.execute("INSERT INTO repository_grants VALUES (?,?,?)", (rid, p.user_id, "maintainer")) + self.store.audit(con, p.user_id, action, now, rid) + return {"id": rid, "name": name, "provider": provider, "external_name": external} + if action == "grants.set": + self._admin(p) + rid, uid, role = data.get("repo_id", ""), data.get("user_id", ""), data.get("role", "") + if not con.execute("SELECT 1 FROM repositories WHERE id=?", (rid,)).fetchone() or not con.execute("SELECT 1 FROM users WHERE id=?", (uid,)).fetchone(): + raise RFCError("Repository or user not found", 404, "not_found") + if role: + if role not in ROLES: + raise RFCError("Invalid role") + con.execute("INSERT OR REPLACE INTO repository_grants VALUES (?,?,?)", (rid, uid, role)) + else: + con.execute("DELETE FROM repository_grants WHERE repo_id=? AND user_id=?", (rid, uid)) + self.store.audit(con, p.user_id, action, now, rid, detail={"user_id": uid, "role": role}) + return {"repo_id": rid, "user_id": uid, "role": role} + if action == "rfcs.list": + result = [] + for row in con.execute("SELECT * FROM rfcs ORDER BY updated DESC"): + if data.get("repo_id") and data["repo_id"] != row["repo_id"]: + continue + if self._role(con, p, row["repo_id"], row["id"]): + view = self._view(con, p, dict(row)) + if data.get("query") and data["query"].casefold() not in (view["title"] + view["body"]).casefold(): + continue + result.append(view) + return {"rfcs": result} + if action == "rfcs.draft": + rid = data.get("repo_id", "") + self._require(con, p, rid, "contributor") + title = self._text(data.get("title"), "title") + body = data.get("body", draft(title, data.get("goal", ""), data.get("scope", ""))) + if not isinstance(body, str) or len(body) > 2_000_000: + raise RFCError("Invalid draft body") + model = parse(body) + model["max_auto_additions"] = self.settings()["default_max_auto_additions"] + for key in ("scope", "auto_add", "max_auto_additions"): + if key in data: + model[key] = data[key] + if "features" in data: + model["features"] = self._features(data["features"]) + for f in model["features"]: + f["sidecar"] = True + f["overrides"] = {k: f[k] for k in ("title", "track", "depends_on", "owner")} + if "criteria" in data: + model["criteria"] = self._criteria(data["criteria"]) + model["criteria_override"] = True + self._policy(model) + source = self._source(con, rid, data["source"]) if data.get("source") else {} + key = identifier("rfc") + con.execute("INSERT INTO rfcs VALUES (?,?,?,?,?,?,?,?,?,?,?)", + (key, rid, title, body, digest(encode(model) + body), encode(source), encode(model), 0, 0, p.user_id, now)) + self.store.audit(con, p.user_id, action, now, rid, key) + return self._view(con, p, dict(con.execute("SELECT * FROM rfcs WHERE id=?", (key,)).fetchone())) + if action in ("rfcs.get", "rfcs.export", "rfcs.next"): + row = self._rfc(con, p, data.get("rfc_id", "")) + view = self._view(con, p, row) + if action == "rfcs.export": + return {"rfc_id": row["id"], "markdown": row["body"], "content": row["body"], "tracking": view} + return view + if action == "rfcs.update": + row = self._rfc(con, p, data.get("rfc_id", ""), "contributor") + if data.get("expected_revision") != row["revision"]: + raise RFCError("Draft changed; refresh and review before saving", 409, "conflict") + old = json.loads(row["model"]) + body = data.get("body", row["body"]) + if not isinstance(body, str) or len(body) > 2_000_000: + raise RFCError("Invalid draft body") + if row["enrolled"] and (body != row["body"] or any(k in data for k in ("scope", "features", "criteria", "title", "auto_add", "max_auto_additions"))): + self._require(con, p, row["repo_id"], "maintainer", row["id"]) + self._text(data.get("reason"), "Explicit decision reason") + model = parse(body, old) + removed = {f["id"] for f in old["features"]} - {f["id"] for f in model["features"]} + model["tombstones"] = list(set(model["tombstones"]) | removed) + for key in ("scope", "auto_add", "max_auto_additions"): + if key in data: + model[key] = data[key] + if "features" in data: + model["features"] = self._features(data["features"]) + for f in model["features"]: + f["sidecar"] = True + f["overrides"] = {k: f[k] for k in ("title", "track", "depends_on", "owner")} + if "criteria" in data: + model["criteria"] = self._criteria(data["criteria"]) + model["criteria_override"] = True + self._policy(model) + if body != row["body"]: + self._invalidate(model, "RFC design changed") + self.store.audit(con, p.user_id, action, now, row["repo_id"], row["id"], {"reason": data.get("reason", "")}) + return self._save(con, p, row, model, body, data.get("title", row["title"])) + if action == "rfcs.acl": + row = self._rfc(con, p, data.get("rfc_id", ""), "maintainer") + grants = data.get("grants", {}) + if not isinstance(grants, dict): + raise RFCError("RFC grants must be a user-to-role object") + for uid, role in grants.items(): + repo = con.execute("SELECT role FROM repository_grants WHERE repo_id=? AND user_id=?", (row["repo_id"], uid)).fetchone() + if role not in ROLES or not repo or ROLES[role] > ROLES[repo["role"]]: + raise RFCError("RFC permissions can only narrow repository permissions", 403, "forbidden") + con.execute("DELETE FROM rfc_grants WHERE rfc_id=?", (row["id"],)) + for uid, role in grants.items(): + con.execute("INSERT INTO rfc_grants VALUES (?,?,?)", (row["id"], uid, role)) + con.execute("UPDATE rfcs SET restricted=? WHERE id=?", (int(bool(data.get("restricted", True))), row["id"])) + self.store.audit(con, p.user_id, action, now, row["repo_id"], row["id"]) + return {"rfc_id": row["id"], "restricted": bool(data.get("restricted", True))} + if action == "rfcs.work": + row = self._rfc(con, p, data.get("rfc_id", ""), "contributor") + model = json.loads(row["model"]) + op, feature = data.get("op", "update"), dict(data.get("feature", {})) + fid = data.get("feature_id", feature.get("id", "")) + existing = next((f for f in model["features"] if f["id"] == fid), None) + if op == "add": + self._require(con, p, row["repo_id"], "maintainer", row["id"]) + if existing or fid in model["tombstones"]: + raise RFCError("Feature exists or was intentionally removed; restore explicitly", 409, "conflict") + feature = self._features([feature], validate=False)[0] + feature["sidecar"] = True + model["features"].append(feature) + elif op in ("drop", "restore"): + self._require(con, p, row["repo_id"], "maintainer", row["id"]) + if not existing: + raise RFCError("Feature not found", 404, "not_found") + existing["dropped"] = op == "drop" + model["tombstones"] = sorted((set(model["tombstones"]) | {fid}) if op == "drop" else set(model["tombstones"]) - {fid}) + elif op == "claim" and existing: + if existing.get("owner"): + raise RFCError("Work already has an owner; record a maintainer decision to reassign", 409, "conflict") + self._text(data.get("reason"), "Claim reason") + existing.update(owner=p.name, owner_user_id=p.user_id) + existing.setdefault("overrides", {})["owner"] = p.name + elif op == "update" and existing: + if any(k in feature and feature[k] != existing.get(k) for k in ("title", "track", "depends_on", "owner")): + self._require(con, p, row["repo_id"], "maintainer", row["id"]) + self._text(data.get("reason"), "Explicit decision reason") + for key in ("title", "track", "depends_on", "owner", "links", "state", "priority"): + if key in feature: + existing[key] = feature[key] + if key in ("title", "track", "depends_on", "owner"): + existing.setdefault("overrides", {})[key] = feature[key] + if existing.get("state") not in ("planned", "in_progress", "implemented", "dropped"): + raise RFCError("Invalid work state") + else: + raise RFCError("Feature or operation not found", 404, "not_found") + validate_features(model["features"]) + self._invalidate(model, "Work plan changed") + self.store.audit(con, p.user_id, action, now, row["repo_id"], row["id"], {"op": op, "feature_id": fid, "reason": data.get("reason", "")}) + return self._save(con, p, row, model) + if action == "rfcs.decision": + row = self._rfc(con, p, data.get("rfc_id", ""), "contributor") + model = json.loads(row["model"]) + kind = data.get("kind", "criterion") + if kind == "criterion": + criterion = next((c for c in model["criteria"] if c["id"] == data.get("criterion_id")), None) + if not criterion: + raise RFCError("Criterion not found", 404, "not_found") + verdict = data.get("verdict", "pending") + evidence = data.get("evidence") + if evidence: + if not isinstance(evidence, dict) or not evidence.get("revision") or not evidence.get("environment"): + raise RFCError("Evidence requires verification revision and environment") + evidence = {**evidence, "actor": p.user_id, "recorded_at": now, "tracking_revision": row["revision"], "stale": False} + criterion.setdefault("evidence", []).append(evidence) + model["freshness"]["evidence"] = now + if verdict not in ("pending", "passing", "failing", "waived"): + raise RFCError("Invalid acceptance verdict") + if verdict in ("passing", "waived", "failing"): + self._require(con, p, row["repo_id"], "maintainer", row["id"]) + if verdict == "passing" and not any(not e.get("stale") for e in criterion.get("evidence", [])): + raise RFCError("Passing acceptance requires current evidence") + if verdict == "waived": + self._text(data.get("reason"), "Waiver reason") + criterion.update(verdict=verdict, reason=data.get("reason", ""), decided_by=p.user_id, decided_at=now) + elif kind == "suggestion": + self._require(con, p, row["repo_id"], "maintainer", row["id"]) + item = next((s for s in model["suggestions"] if s["id"] == data.get("suggestion_id")), None) + if not item or item.get("status") != "proposed": + raise RFCError("Pending suggestion not found", 404, "not_found") + verdict = data.get("verdict") + if verdict == "rejected": + item["status"] = "rejected" + model["tombstones"] = sorted(set(model["tombstones"]) | {item["id"]}) + elif verdict in ("applied", "accepted"): + self._apply_candidate(model, item, explicit=True) + item["status"] = "applied" + self._invalidate(model, "Suggestion changed the work plan") + else: + raise RFCError("Suggestion verdict must be applied or rejected") + elif kind == "rfc": + self._require(con, p, row["repo_id"], "maintainer", row["id"]) + self._text(data.get("reason"), "Decision reason") + model["state"] = data.get("verdict", "proposed") + model.setdefault("decisions", []).append({"actor": p.user_id, "verdict": model["state"], "reason": data["reason"], "at": now}) + else: + raise RFCError("Invalid decision kind") + self.store.audit(con, p.user_id, action, now, row["repo_id"], row["id"], {"kind": kind, "reason": data.get("reason", "")}) + return self._save(con, p, row, model) + if action in ("rfcs.publish", "rfcs.enroll", "rfcs.sync"): + if data.get("rfc_id"): + row = self._rfc(con, p, data["rfc_id"], "maintainer") + rid, rfc_id = row["repo_id"], row["id"] + if action in ("rfcs.publish", "rfcs.enroll") and con.execute("SELECT 1 FROM operations WHERE rfc_id=? AND kind IN ('rfcs.publish','rfcs.enroll') AND status IN ('pending','running','uncertain')", (rfc_id,)).fetchone(): + raise RFCError("An existing publication or import must finish or be recovered first", 409, "write_in_progress") + model = json.loads(row["model"]) + if model.get("writer_id", self.writer_id) != self.writer_id: + raise RFCError("This workspace is not the designated RFC writer", 409, "writer_conflict") + if action == "rfcs.publish": + if (data.get("post") is not True or data.get("content_digest") != content_digest(row["title"], row["body"]) + or (data.get("expected_revision") and data["expected_revision"] != row["revision"])): + raise RFCError("Publication requires explicit intent and the exact preview digest", 409, "preview_mismatch") + if row["enrolled"] or json.loads(row["source"]): + raise RFCError("RFC already has a source; enroll or revise it explicitly", 409, "conflict") + data = {**data, "title": row["title"], "body": row["body"], "expected_revision": row["revision"]} + if action == "rfcs.sync" and not row["enrolled"]: + raise RFCError("RFC is not enrolled") + if action == "rfcs.enroll": + data = {**data, "expected_revision": row["revision"]} + else: + if action != "rfcs.enroll": + raise RFCError("rfc_id is required") + rid, rfc_id = data.get("repo_id", ""), "" + self._require(con, p, rid, "maintainer") + data = {**data, "source": self._source(con, rid, data.get("source", {}))} + return self._enqueue(con, p, action, rid, rfc_id, data) + if action == "operations.retry": + row = con.execute("SELECT * FROM operations WHERE id=?", (data.get("operation_id", ""),)).fetchone() + if not row: + raise RFCError("Resource not found or access denied", 403, "forbidden") + self._require(con, p, row["repo_id"], "maintainer", row["rfc_id"]) + if row["actor"] != p.user_id and not p.admin: + raise RFCError("Only the initiating user or administrator can recover this operation", 403, "forbidden") + if row["status"] not in ("failed", "uncertain"): + raise RFCError("Only failed or uncertain operations can be retried", 409, "conflict") + con.execute("UPDATE operations SET status='pending',credential_id=?,error='',updated=? WHERE id=?", (p.credential_id, now, row["id"])) + self.store.audit(con, p.user_id, action, now, row["repo_id"], row["rfc_id"], {"operation_id": row["id"]}) + return self._operation(con.execute("SELECT * FROM operations WHERE id=?", (row["id"],)).fetchone()) + if action in ("operations.get", "operations.list"): + rows = con.execute("SELECT * FROM operations WHERE id=?", (data.get("operation_id", ""),)) if action.endswith("get") else con.execute("SELECT * FROM operations ORDER BY created DESC LIMIT 200") + result = [] + for row in rows: + if self._role(con, p, row["repo_id"], row["rfc_id"]): + result.append(self._operation(row)) + if action.endswith("get"): + if not result: + raise RFCError("Resource not found or access denied", 403, "forbidden") + return result[0] + return {"operations": result} + if action == "audit.list": + result = [] + for row in con.execute("SELECT * FROM audit ORDER BY id DESC LIMIT 500"): + if p.admin or (row["repo_id"] and self._role(con, p, row["repo_id"], row["rfc_id"])): + result.append({**dict(row), "detail": json.loads(row["detail"])}) + return {"events": result} + raise RFCError("Unknown RFC action", 404, "unknown_action") + + def _source(self, con, repo_id, data): + ref = SourceRef.from_dict(data) + repo = con.execute("SELECT * FROM repositories WHERE id=?", (repo_id,)).fetchone() + if not repo or ref.provider != repo["provider"] or ref.repository not in ("", repo["external_name"]): + raise RFCError("Source must belong to the authorized repository", 403, "forbidden") + return SourceRef.from_dict({**ref.to_dict(), "repository": repo["external_name"]}).to_dict() + + @staticmethod + def _same_source(a, b): + aliases = {"pull_request": "pr", "pull": "pr"} + same_repository = a.repository.casefold() == b.repository.casefold() if a.provider == "github" else a.repository == b.repository + return (a.provider == b.provider and same_repository + and aliases.get(a.kind, a.kind) == aliases.get(b.kind, b.kind) + and (a.identifier or a.path) == (b.identifier or b.path)) + + def _registered_sources(self, con, repo_id, ref): + repo = con.execute("SELECT root FROM repositories WHERE id=?", (repo_id,)).fetchone() + target = (Path(repo["root"]) / (ref.path or ref.identifier)).resolve() if ref.provider == "local" and repo else None + matches = [] + for r in con.execute("SELECT f.*,r.root FROM rfcs f JOIN repositories r ON r.id=f.repo_id WHERE f.source!='{}'"): + registered = SourceRef.from_dict(json.loads(r["source"])) + same = self._same_source(registered, ref) + if target and registered.provider == "local": + same = target == (Path(r["root"]) / (registered.path or registered.identifier)).resolve() + if same: + matches.append(dict(r)) + return matches + + def _source_access(self, con, principal, repo_id, ref): + for r in self._registered_sources(con, repo_id, ref): + self._require(con, principal, r["repo_id"], "reader", r["id"]) + + def _visible_source(self, con, principal, repo_id, source): + if not source: + return True + try: + self._source_access(con, principal, repo_id, SourceRef.from_dict(source)) + return True + except RFCError: + return False + + @staticmethod + def _features(features, validate=True): + if not isinstance(features, list): + raise RFCError("Features must be a list") + result = [] + for f in features: + if not isinstance(f, dict) or not f.get("title"): + raise RFCError("Each feature needs an identifier and title") + if not isinstance(f.get("links", []), list) or any(not isinstance(link, str) for link in f.get("links", [])): + raise RFCError("Feature links must be URL strings") + result.append({"track": "实现", "depends_on": [], "links": [], "owner": "", "state": "planned", "dropped": False, **f}) + if validate: + validate_features(result) + return result + + @staticmethod + def _criteria(criteria): + if not isinstance(criteria, list): + raise RFCError("Criteria must be a list") + result = [] + for c in criteria: + if not isinstance(c, dict) or not c.get("title"): + raise RFCError("Each criterion needs a title") + result.append({"id": c.get("id", "C-" + digest(c["title"])[:12]), "title": c["title"], "feature_ids": c.get("feature_ids", []), "verdict": "pending", "evidence": []}) + if len({c["id"] for c in result}) != len(result): + raise RFCError("Duplicate criterion identifiers") + return result + + @staticmethod + def _policy(model): + value = model.get("max_auto_additions", 5) + if isinstance(value, bool) or not isinstance(value, int) or not 0 <= value <= 100: + raise RFCError("max_auto_additions must be between 0 and 100") + if not isinstance(model.get("auto_add"), bool) or not isinstance(model.get("scope"), str): + raise RFCError("Invalid enrollment policy") + + @staticmethod + def _invalidate(model, reason): + for c in model.get("criteria", []): + if c.get("verdict") == "passing": + c["verdict"] = "pending" + for evidence in c.get("evidence", []): + evidence["stale"] = True + evidence["stale_reason"] = reason + + def _enqueue(self, con, p, kind, repo_id, rfc_id, data): + key = data.get("idempotency_key") or identifier("request") + request_digest = self._request_digest(kind, repo_id, rfc_id, data) + old = con.execute("SELECT * FROM operations WHERE actor=? AND idempotency_key=?", (p.user_id, key)).fetchone() + if old: + if old["request_digest"] != request_digest: + raise RFCError("Idempotency key reused for a different operation", 409, "conflict") + return {"operation_id": old["id"], "operation": self._operation(old)} + operation_id = identifier("op") + now = self.clock() + con.execute("""INSERT INTO operations(id,actor,credential_id,kind,repo_id,rfc_id,payload,idempotency_key,request_digest,created,updated) + VALUES (?,?,?,?,?,?,?,?,?,?,?)""", (operation_id, p.user_id, p.credential_id, kind, repo_id, rfc_id, encode(data), key, request_digest, now, now)) + self.store.audit(con, p.user_id, kind + ".queued", now, repo_id, rfc_id, {"operation_id": operation_id}) + row = con.execute("SELECT * FROM operations WHERE id=?", (operation_id,)).fetchone() + return {"operation_id": operation_id, "operation": self._operation(row)} + + @staticmethod + def _request_digest(kind, repo_id, rfc_id, data): + data = dict(data) + if kind == "rfcs.publish": + for key in ("title", "body", "expected_revision"): + data.pop(key, None) + if kind == "rfcs.enroll": + data.pop("expected_revision", None) + return digest(encode({"kind": kind, "repo_id": repo_id, "rfc_id": rfc_id, "data": data})) + + @staticmethod + def _operation(row): + return {k: row[k] for k in ("id", "kind", "repo_id", "rfc_id", "status", "error", "created", "updated")} | {"operation_id": row["id"], "result": json.loads(row["result"])} + + def _provider(self, repo): + provider = self.providers.get(repo["provider"]) + if not provider: + raise RFCError("Provider is not configured", 503, "provider_unavailable") + if repo["provider"] == "local": + from .providers import LocalProvider + return LocalProvider({repo["external_name"]: repo["root"]}) + return provider + + def _authority(self, operation, worker): + with self.store.transaction() as con: + self._held(con, operation["id"], worker) + p = self._principal(con, operation["credential_id"]) + self._require(con, p, operation["repo_id"], "maintainer", operation["rfc_id"]) + repo = dict(con.execute("SELECT * FROM repositories WHERE id=?", (operation["repo_id"],)).fetchone()) + row = self._rfc(con, p, operation["rfc_id"], "maintainer") if operation["rfc_id"] else None + if row and json.loads(row["model"]).get("writer_id", self.writer_id) != self.writer_id: + raise RFCError("This workspace is not the designated RFC writer", 409, "writer_conflict") + return p, repo, row + + def _held(self, con, operation_id, worker): + if not con.execute("SELECT 1 FROM operations WHERE id=? AND status='running' AND worker=? AND lease_until>?", (operation_id, worker, self.clock())).fetchone(): + raise RFCError("Write lease lost", 409, "lease_lost") + + def process_pending(self, limit=10): + worker, results = identifier("worker"), [] + for _ in range(max(0, int(limit))): + op = self.store.claim(worker, self.clock()) + if not op: + break + stop = threading.Event() + def keep_lease(): + while not stop.wait(60): + if not self.store.renew(op["id"], worker, self.clock()): + return + keeper = threading.Thread(target=keep_lease, daemon=True) + keeper.start() + status, result, error = "succeeded", {}, "" + try: + result = self._execute(op, worker) + except ProviderError as exc: + # Never blindly create again following an ambiguous external response. + status = "uncertain" if exc.uncertain else "failed" + error = str(exc) + except RFCError as exc: + status, error = "failed", str(exc) + except Exception: + status, error = "failed", "Operation failed; inspect server diagnostics" + finally: + stop.set() + keeper.join(timeout=2) + with self.store.transaction() as con: + changed = con.execute("UPDATE operations SET status=?,result=?,error=?,updated=?,lease_until=0 WHERE id=? AND worker=? AND status='running'", + (status, encode(result), error, self.clock(), op["id"], worker)).rowcount + if changed: + executed = con.execute("SELECT user_id FROM tokens WHERE id=?", (op["credential_id"],)).fetchone() + self.store.audit(con, executed["user_id"] if executed else op["actor"], op["kind"] + "." + status, self.clock(), op["repo_id"], result.get("rfc_id", op["rfc_id"]), {"operation_id": op["id"], "initiated_by": op["actor"]}) + results.append({"operation_id": op["id"], "status": status, "error": error, "result": result}) + return results + + def _execute(self, op, worker): + p, repo, row = self._authority(op, worker) + data, kind = json.loads(op["payload"]), op["kind"] + provider = self._provider(repo) + if kind == "rfcs.publish": + if row["enrolled"] and json.loads(row["source"]): + recovered = provider.find_publication(repo["external_name"], op["id"]) + if recovered and recovered.identity() == SourceRef.from_dict(json.loads(row["source"])).identity(): + return {"rfc_id": row["id"], "source": {k: v for k, v in recovered.to_dict().items() if k != "path"}} + if row["revision"] != data["expected_revision"]: + raise RFCError("Draft changed after preview; review again", 409, "preview_mismatch") + # Reconcile before every create (also after restart with an expired lease). + source = provider.find_publication(repo["external_name"], op["id"]) + if source is None: + self._authority(op, worker) + source = provider.publish(repo["external_name"], data["title"], data["body"], op["id"], path=data.get("path", "")) + if isinstance(source, dict): + source = SourceRef.from_dict(source) + fetched = provider.get_source(source) + if fetched.get("source"): + source = SourceRef.from_dict(fetched["source"]) + self._authority(op, worker) + with self.store.transaction() as con: + self._held(con, op["id"], worker) + p = self._current(con, p) + current = self._rfc(con, p, row["id"], "maintainer") + model = json.loads(current["model"]) + model.update(state="proposed", writer_id=self.writer_id, + enrollment={"actor": p.user_id, "credential_id": p.credential_id}) + model["freshness"]["publication"] = self.clock() + con.execute("UPDATE rfcs SET source=?,enrolled=1 WHERE id=?", (encode(source.to_dict()), row["id"])) + con.execute("INSERT INTO source_snapshots(rfc_id,revision,body,created) VALUES (?,?,?,?)", (row["id"], str(fetched.get("revision", "")), fetched.get("body", data["body"]), self.clock())) + self._save(con, p, current, parse(fetched["body"], model), fetched["body"]) + return {"rfc_id": row["id"], "source": {k: v for k, v in source.to_dict().items() if k != "path"}} + if kind == "rfcs.enroll": + if row and data.get("expected_revision") != row["revision"]: + raise RFCError("Imported draft changed after review", 409, "preview_mismatch") + source_data = json.loads(row["source"]) if row else data["source"] + if not source_data: + raise RFCError("RFC has no published source; publish first") + source = SourceRef.from_dict(source_data) + with self.store.transaction() as con: + self._source_access(con, p, repo["id"], source) + fetched = provider.get_source(source) + if fetched.get("source"): + source = SourceRef.from_dict(fetched["source"]) + if row and not row["enrolled"] and row["body"] != fetched["body"]: + raise RFCError("Source changed or draft differs from source; preview the current source before enrolling", 409, "preview_mismatch") + self._authority(op, worker) + with self.store.transaction() as con: + self._held(con, op["id"], worker) + p = self._current(con, p) + self._require(con, p, repo["id"], "maintainer", row["id"] if row else "") + self._source_access(con, p, repo["id"], source) + # Stable identity prevents duplicate registration within a workspace. + for existing in con.execute("SELECT * FROM rfcs WHERE repo_id=?", (repo["id"],)): + if json.loads(existing["source"]) and self._same_source(SourceRef.from_dict(json.loads(existing["source"])), source) and (not row or existing["id"] != row["id"]): + raise RFCError("Source is already registered", 409, "conflict") + if not row: + rid = identifier("rfc") + body, title = fetched["body"], fetched.get("title", "Imported RFC") + model = parse(body) + model.update(scope=data.get("scope", ""), auto_add=data.get("auto_add", True), + max_auto_additions=data.get("max_auto_additions", self.settings()["default_max_auto_additions"])) + con.execute("INSERT INTO rfcs VALUES (?,?,?,?,?,?,?,?,?,?,?)", (rid, repo["id"], title, body, digest(body), encode(source.to_dict()), encode(model), 0, 0, p.user_id, self.clock())) + row = dict(con.execute("SELECT * FROM rfcs WHERE id=?", (rid,)).fetchone()) + else: + row = self._rfc(con, p, row["id"], "maintainer") + model = parse(fetched["body"], json.loads(row["model"])) + if fetched["body"] != row["body"]: + self._invalidate(model, "Source changed during reauthorization") + model.update(writer_id=self.writer_id, enrollment={"actor": p.user_id, "credential_id": p.credential_id}, state="proposed") + self._policy(model) + model["freshness"]["verification"] = self.clock() + con.execute("UPDATE rfcs SET enrolled=1,source=? WHERE id=?", (encode(source.to_dict()), row["id"])) + con.execute("INSERT INTO source_snapshots(rfc_id,revision,body,created) VALUES (?,?,?,?)", (row["id"], str(fetched.get("revision", "")), fetched["body"], self.clock())) + self._save(con, p, row, model, fetched["body"]) + con.execute("UPDATE operations SET rfc_id=? WHERE id=?", (row["id"], op["id"])) + return {"rfc_id": row["id"], "enrolled": True} + if kind == "rfcs.sync": + if not row["enrolled"]: + raise RFCError("RFC is not enrolled") + sync_started = self.clock() + original = json.loads(row["model"]) + source = SourceRef.from_dict(json.loads(row["source"])) + fetched = provider.get_source(source) + model = parse(fetched["body"], original) + removed = {f["id"] for f in original["features"] if not f.get("sidecar")} - {f["id"] for f in model["features"]} + model["tombstones"] = sorted(set(model["tombstones"]) | removed) + for f in model["features"]: + if f["id"] in model["tombstones"]: + f["dropped"] = True + observations = copy.deepcopy(original.get("observations", {})) + verified = {} + for feature in model["features"]: + if feature.get("dropped"): + continue + for link in feature.get("links", []): + ref = self._link_ref(link, repo, source) + if ref: + if ref.identity() not in verified: + verified[ref.identity()] = provider.get_item(ref) + observed = copy.deepcopy(verified[ref.identity()]) + observed.pop("body", None) + # Only implementation PRs affect merge progress; issues remain associations. + observed["kind"] = ref.kind + prior = observations.get(link) + if prior and prior.get("revision") != observed.get("revision"): + self._invalidate(model, "Linked implementation revision changed") + observations[link] = observed + model["observations"] = observations + model["freshness"]["verification"] = self.clock() + if fetched["body"] != row["body"]: + self._invalidate(model, "Source RFC changed") + cursor = original.get("discovery_cursor", 0) + since = datetime.fromtimestamp(max(0, cursor - 60), timezone.utc).isoformat() if cursor else "" + candidates = provider.discover(repo["external_name"], {"rfc_id": row["id"], "source": source.to_dict(), "scope": model.get("scope", ""), "features": model["features"]}, since=since) + applied = 0 + known = {s["id"] for s in model["suggestions"]} | set(model["tombstones"]) + for candidate in candidates: + candidate_source = candidate.get("source") + if candidate_source: + candidate_ref = SourceRef.from_dict(candidate_source.to_dict() if isinstance(candidate_source, SourceRef) else candidate_source) + with self.store.transaction() as con: + registered_source = bool(self._registered_sources(con, repo["id"], candidate_ref)) + if registered_source: + continue + item = self._candidate(candidate, row, model) + if item["id"] in known or self._candidate_exists(model, item): + continue + if model["auto_add"] and applied < model["max_auto_additions"] and item.get("bounded"): + self._apply_candidate(model, item) + item["status"] = "applied" + applied += 1 + model["suggestions"].append(item) + known.add(item["id"]) + if applied: + self._invalidate(model, "Related work changed the work plan") + model["freshness"]["discovery"] = self.clock() + model["discovery_cursor"] = sync_started + model["last_discovery"] = {"proposed": sum(s.get("status") == "proposed" for s in model["suggestions"]), "applied": applied} + self._authority(op, worker) + with self.store.transaction() as con: + self._held(con, op["id"], worker) + p = self._current(con, p) + current = self._rfc(con, p, row["id"], "maintainer") + if current["revision"] != row["revision"]: + raise RFCError("Tracking changed during synchronization; retry", 409, "conflict") + con.execute("INSERT INTO source_snapshots(rfc_id,revision,body,created) VALUES (?,?,?,?)", (row["id"], str(fetched.get("revision", "")), fetched["body"], self.clock())) + self._save(con, p, current, model, fetched["body"]) + return {"rfc_id": row["id"], "applied": applied, "proposed": model["last_discovery"]["proposed"]} + raise RFCError("Unknown queued operation") + + @staticmethod + def _link_ref(link, repo, source): + if repo["provider"] == "local": + commit = re.fullmatch(r"git:([0-9a-fA-F]{7,64})", link) + return SourceRef("local", repo["external_name"], "commit", commit[1]) if commit else None + parsed = urlparse(link) + expected = urlparse(source.url).hostname + if expected and parsed.hostname != expected: + return None + match = re.fullmatch(r"/([^/]+/[^/]+)/(issues|pulls?|merge_requests)/([^/]+?)/?", parsed.path) + if not match or match[1].casefold() != repo["external_name"].casefold(): + return None + return SourceRef(repo["provider"], repo["external_name"], "issue" if match[2] == "issues" else "pr", match[3], link, host=source.host) + + @staticmethod + def _candidate(candidate, row, model): + item = dict(candidate) + source = item.get("source", {}) + if isinstance(source, SourceRef): + source = source.to_dict() + url = item.get("url") or source.get("url", "") + if not url and source.get("provider") == "local": + url = "local:" + (source.get("path") or source.get("identifier", "")) + key = "suggestion-" + digest(encode(source) if source else url + item.get("title", ""))[:20] + feature = dict(item.get("feature", {})) + if not feature: + feature = {"id": "F-" + digest(key)[:12], "title": item.get("title", "Related work"), "track": item.get("track", ""), "links": [url] if url else []} + features = model["features"] + ref = json.loads(row["source"]) + text = item.get("body", "") + " " + item.get("reason", "") + references = [ref.get("url"), "imrfc:" + row["id"]] + if ref.get("provider") == "local": + references.append("RFC:" + (ref.get("path") or ref.get("identifier", ""))) + explicit = any(value and value in text for value in references) + if ref.get("provider") == "local": + path = ref.get("path") or ref.get("identifier", "") + explicit = explicit or bool(path and re.search( + r"(?im)^\s*(?:RFC|RFC source|RFC来源)\s*[::]\s*`?" + re.escape(path) + r"`?\s*$", item.get("body", ""))) + track_ok = feature.get("track") in {f["track"] for f in features} + feature_id = item.get("feature_id", "") + attaches = feature_id in {f["id"] for f in features if not f.get("dropped")} + # Conservative adapter evidence: references and exact existing work lanes are required. + bounded = explicit and (attaches or (track_ok and bool(model.get("scope")) and item.get("within_scope") is True)) + return {"id": key, "feature": feature, "feature_id": feature_id, "url": url, + "evidence": item.get("evidence", {"source": source, "reference": ref.get("url", "")}), + "reason": item.get("reason", "Review relationship and scope"), "status": "proposed", "bounded": bounded} + + @staticmethod + def _candidate_exists(model, item): + return bool(item["url"] and any(item["url"] in f.get("links", []) for f in model["features"])) + + def _apply_candidate(self, model, item, explicit=False): + if item["id"] in model["tombstones"]: + raise RFCError("Suggestion was intentionally rejected", 409, "conflict") + existing = next((f for f in model["features"] if f["id"] == item.get("feature_id")), None) + if existing: + if item["url"] and item["url"] not in existing["links"]: + existing["links"].append(item["url"]) + return + feature = dict(item["feature"]) + if feature.get("id") in model["tombstones"]: + raise RFCError("Feature was intentionally removed", 409, "conflict") + if any(f["id"] == feature.get("id") for f in model["features"]): + raise RFCError("Feature identifier already exists", 409, "conflict") + if not explicit and (feature.get("owner") or feature.get("depends_on") or feature.get("criteria")): + raise RFCError("Automatic discovery cannot change ownership or acceptance") + feature = self._features([feature], validate=False)[0] + feature["sidecar"] = True + feature["auto_source"] = item.get("evidence", {}).get("source", {}) + model["features"].append(feature) + validate_features(model["features"]) + + def sync_due(self): + queued = [] + with self.store.transaction() as con: + for row in con.execute("SELECT * FROM rfcs WHERE enrolled=1"): + model = json.loads(row["model"]) + if model.get("writer_id") != self.writer_id: + continue + if self.clock() - model.get("freshness", {}).get("verification", 0) < self.settings()["sync_seconds"]: + continue + if con.execute("SELECT 1 FROM operations WHERE rfc_id=? AND status IN ('pending','running','uncertain')", (row["id"],)).fetchone(): + continue + enrollment = model.get("enrollment", {}) + try: + p = self._principal(con, enrollment.get("credential_id", "")) + self._require(con, p, row["repo_id"], "maintainer", row["id"]) + except RFCError: + continue + queued.append(self._enqueue(con, p, "rfcs.sync", row["repo_id"], row["id"], {"rfc_id": row["id"]})["operation_id"]) + return {"queued": queued} + + +def build_service(state_dir, config_path=None): + """Build providers from execution-host credentials; no fallback to another state.""" + from .providers import AtomGitProvider, GitHubProvider, LocalProvider + config = json.loads(Path(config_path).expanduser().read_text(encoding="utf-8")) if config_path else {} + roots = config.get("roots", []) + providers = {"local": LocalProvider({})} + classes = {"github": (GitHubProvider, "GITHUB_TOKEN"), "atomgit": (AtomGitProvider, "ATOMGIT_TOKEN")} + for name, (cls, default_env) in classes.items(): + opts = config.get("providers", {}).get(name, {}) + kwargs = {"token": os.environ.get(opts.get("token_env", default_env), "")} + if opts.get("api_url"): + kwargs["api_url"] = opts["api_url"] + providers[name] = cls(**kwargs) + return RFCService(state_dir, providers=providers, roots=roots) diff --git a/src/infermatrix_copilot/rfc_service/cli.py b/src/infermatrix_copilot/rfc_service/cli.py new file mode 100644 index 00000000..831799ec --- /dev/null +++ b/src/infermatrix_copilot/rfc_service/cli.py @@ -0,0 +1,225 @@ +"""Portable RFC commands, shared by standalone and Copilot entry points.""" +from __future__ import annotations + +import argparse +import json +import os +import sys +import threading +import time +from pathlib import Path +from typing import Any + +from .models import RFCError + + +def _data(path: str | None) -> dict[str, Any]: + if not path: + return {} + text = sys.stdin.read() if path == "-" else Path(path).read_text(encoding="utf-8") + value = json.loads(text) + if not isinstance(value, dict): + raise ValueError("JSON payload must be an object") + return value + + +def _print(value: Any) -> None: + print(json.dumps(value, ensure_ascii=False, indent=2)) + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="infermatrix-rfc", description="Create and track RFCs locally or through an RFC service") + parser.add_argument("--state-dir", default=os.environ.get("RFC_STATE_DIR")) + parser.add_argument("--config", default=os.environ.get("RFC_CONFIG"), help="Local provider JSON configuration") + target = parser.add_mutually_exclusive_group() + target.add_argument("--service-url", default=os.environ.get("RFC_SERVICE_URL", "")) + target.add_argument("--local", action="store_true", help="Explicitly use local state even when RFC_SERVICE_URL is set") + parser.add_argument("--token-env", default="RFC_TOKEN", help="Environment variable containing your RFC user token") + commands = parser.add_subparsers(dest="command", required=True) + bootstrap = commands.add_parser("bootstrap-admin", help="Create the first administrator and print its token once") + bootstrap.add_argument("--name", default="Administrator") + serve = commands.add_parser("serve", help="Run the local HTTP UI and background reconciler") + serve.add_argument("--host", default="127.0.0.1") + serve.add_argument("--port", default=8765, type=int) + serve.add_argument("--public-url") + serve.add_argument("--no-worker", action="store_true") + serve.add_argument("--interval", type=float, default=30) + commands.add_parser("capabilities") + request = commands.add_parser("request", help="Invoke a versioned RFC action") + request.add_argument("action") + request.add_argument("--data", help="JSON payload file; '-' reads stdin") + draft = commands.add_parser("draft", help="Validate and store a structured draft without publishing") + draft.add_argument("--data", required=True) + imp = commands.add_parser("import", help="Store an existing Markdown RFC as a draft") + imp.add_argument("--file", required=True) + imp.add_argument("--repo", required=True, dest="repo_id") + imp.add_argument("--title") + imp.add_argument("--data", help="Optional extracted feature/criterion metadata JSON") + imp.add_argument("--source", help="Optional SourceRef JSON file") + listing = commands.add_parser("list") + listing.add_argument("--repo", dest="repo_id", default="") + for name in ("status", "next", "enroll", "decision", "work", "export"): + command = commands.add_parser(name) + command.add_argument("rfc_id") + if name in {"enroll", "decision", "work"}: + command.add_argument("--data") + if name == "export": + command.add_argument("--format", default="json", choices=("json", "markdown")) + command.add_argument("--out") + publish = commands.add_parser("publish", help="Queue publication of the exact previewed draft") + publish.add_argument("rfc_id") + publish.add_argument("--content-digest", required=True) + publish.add_argument("--idempotency-key", required=True) + publish.add_argument("--expected-revision", help="Require the reviewed tracking revision to remain current") + publish.add_argument("--post", action="store_true", help="Explicitly authorize outward publication") + publish.add_argument("--data", help="Additional source publication options JSON") + sync = commands.add_parser("sync", help="Queue an RFC refresh or run the local reconciler") + sync.add_argument("--rfc", dest="rfc_id") + mode = sync.add_mutually_exclusive_group() + mode.add_argument("--once", action="store_true") + mode.add_argument("--watch", action="store_true") + sync.add_argument("--interval", type=float, default=30) + sync.add_argument("--limit", type=int, default=10) + operation = commands.add_parser("operation") + operation.add_argument("operation_id") + operations = commands.add_parser("operations") + operations.add_argument("--data") + migration = commands.add_parser("migrate-personal-agent", help="Preview or apply an authenticated legacy tracking import") + migration.add_argument("--source-dir", required=True) + migration.add_argument("--repo-id", required=True) + migration.add_argument("--rfc-id", required=True) + migration.add_argument("--apply", action="store_true", help="Apply the reviewed import; default is comparison only") + return parser + + +def _run_cycle(service, limit: int) -> dict[str, Any]: + return {"sync": service.sync_due(), "processed": service.process_pending(limit=limit)} + + +def main(argv: list[str] | None = None) -> int: + args = _parser().parse_args(argv) + from ..sdk.v1.rfc import RFCClient, RFCClientError + + service_url = "" if args.local else args.service_url + state = Path(args.state_dir).expanduser() if args.state_dir else Path.home() / ".infermatrix-copilot" / "rfc" + try: + command = args.command + if command in {"bootstrap-admin", "serve"} or (command == "sync" and not args.rfc_id): + if service_url: + raise RFCClientError("This command requires local state; use --local explicitly") + from .application import build_service + + service = build_service(state, config_path=args.config) + if command == "bootstrap-admin": + # Deliberately the only unauthenticated user-management command. + # The service refuses a second bootstrap, and stores token hashes. + _print(service.bootstrap_admin(args.name)) + return 0 + if args.interval <= 0: + raise ValueError("interval must be positive") + if command == "sync": + if args.limit < 1: + raise ValueError("limit must be positive") + while True: + _print(_run_cycle(service, args.limit)) + if not args.watch: + return 0 + time.sleep(args.interval) + from .http import create_server + + server = create_server(service, host=args.host, port=args.port, public_url=args.public_url) + stop = threading.Event() + def worker(): + while not stop.is_set(): + try: + _run_cycle(service, 10) + except Exception: + print("RFC reconciliation failed; inspect the operation and audit history", file=sys.stderr) + stop.wait(args.interval) + thread = None + if not args.no_worker: + thread = threading.Thread(target=worker, daemon=True) + thread.start() + print(f"RFC service: {server.public_url}", flush=True) + try: + server.serve_forever() + finally: + stop.set() + server.server_close() + if thread: + thread.join(timeout=2) + return 0 + client = RFCClient(state_dir=state, service_url=service_url, + token=os.environ.get(args.token_env, ""), config_path=args.config) + if command == "request": + result = client.dispatch(args.action, _data(args.data)) + elif command == "migrate-personal-agent": + if service_url: + raise RFCClientError("Migration requires local state; use --local explicitly") + from .application import build_service + from .migration import migrate + + service = build_service(state, config_path=args.config) + result = migrate(service, service.authenticate(os.environ.get(args.token_env, "")), + args.source_dir, args.repo_id, args.rfc_id, apply=args.apply) + elif command == "capabilities": + result = client.capabilities() + elif command == "draft": + result = client.draft(_data(args.data)) + elif command == "import": + path = Path(args.file) + with path.open(encoding="utf-8", newline="") as source_file: + body = source_file.read() + heading = next((line[2:].strip() for line in body.splitlines() if line.startswith("# ")), path.stem) + payload = {**_data(args.data), "repo_id": args.repo_id, "title": args.title or heading, "body": body} + if args.source: + payload["source"] = _data(args.source) + result = client.draft(payload) + elif command == "list": + result = client.list_rfcs(args.repo_id) + elif command == "status": + result = client.get(args.rfc_id) + elif command == "next": + value = client.get(args.rfc_id) + result = {"rfc_id": args.rfc_id, "next_actions": value.get("next_actions", [])} + elif command in {"enroll", "decision", "work"}: + result = client.dispatch("rfcs." + command, {**_data(args.data), "rfc_id": args.rfc_id}) + elif command == "publish": + if not args.post: + raise RFCClientError("Publication requires --post after reviewing the draft") + payload = {**_data(args.data), "rfc_id": args.rfc_id, + "content_digest": args.content_digest, "idempotency_key": args.idempotency_key, "post": True} + if args.expected_revision is not None: + payload["expected_revision"] = args.expected_revision + result = client.dispatch("rfcs.publish", payload) + elif command == "sync": + if args.watch: + raise RFCClientError("--watch reconciles local state; use sync --rfc for a queued refresh") + result = client.sync(args.rfc_id) + elif command == "operation": + result = client.operation(args.operation_id) + elif command == "operations": + result = client.dispatch("operations.list", _data(args.data)) + else: # export + result = client.export(args.rfc_id, args.format) + content = result.get("content", result.get("markdown")) if args.format == "markdown" else json.dumps(result, ensure_ascii=False, indent=2) + if not isinstance(content, str): + raise RFCClientError("RFC export did not return Markdown", code="invalid_response") + if args.out: + Path(args.out).write_text(content, encoding="utf-8", newline="") + return 0 + if args.format == "markdown": + print(content) + return 0 + _print(result) + return 0 + except (RFCError, RFCClientError, ValueError, OSError) as exc: + code = getattr(exc, "code", "invalid_request") + print(json.dumps({"error": {"code": code, "message": str(exc)}}, ensure_ascii=False), file=sys.stderr) + return 2 + except KeyboardInterrupt: + return 130 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/infermatrix_copilot/rfc_service/drafts.py b/src/infermatrix_copilot/rfc_service/drafts.py new file mode 100644 index 00000000..16a27e76 --- /dev/null +++ b/src/infermatrix_copilot/rfc_service/drafts.py @@ -0,0 +1,187 @@ +"""Preserving Markdown import, stable tracking sidecars, and deterministic views.""" +from __future__ import annotations + +import hashlib +import re +from typing import Any + +from .models import RFCError + + +def digest(value: str) -> str: + return hashlib.sha256(value.encode("utf-8")).hexdigest() + + +def draft(title: str, goal: str, scope: str = "") -> str: + return (f"# RFC: {title}\n\n## 问题与目标\n\n{goal}\n\n## 范围\n\n{scope or '待明确:包含与排除的范围。'}\n\n" + "## 事实与依据\n\n- 待补充:仓库路径、issue、PR 与测量证据。\n\n" + "## 备选方案\n\n- 待比较:方案、成本与取舍。\n\n## 提议设计\n\n待补充设计与接口边界。\n\n" + "## 工作计划\n\n### 实现\n\n#### F1. 核心实现\n\n负责人:待确认\n\n" + "#### F2. 集成与验证\n\n依赖:F1\n\n## 验收标准\n\n" + "- 用户目标得到验证,并记录验证版本与环境。\n- 必需测试通过,负责人确认验收。\n\n" + "## 风险与未决问题\n\n- 待明确:兼容性、回滚和未决设计。\n") + + +def parse(body: str, previous: dict | None = None) -> dict[str, Any]: + previous = previous or {} + old = {f["id"]: f for f in previous.get("features", [])} + features: list[dict] = [] + track, current = "实现", None + outside = re.sub(r"```[\s\S]*?```", "", body) + seen: set[str] = set() + criteria: list[dict] = [] + criterion_section = False + criterion_depth = 0 + for line in outside.splitlines(): + heading = re.match(r"^(#{2,6})\s+(.+)$", line) + if heading: + level, text = len(heading[1]), heading[2].strip() + if any(x in text.casefold() for x in ("验收", "acceptance criteria")): + criterion_section, criterion_depth = True, level + elif criterion_section and level <= criterion_depth: + criterion_section = False + if level == 3: + track = text + feature = re.match(r"([A-Za-z][A-Za-z0-9_-]*)[.、:]\s*(.+)", text) if level >= 3 else None + if feature: + key, title = feature.groups() + if key in seen: + raise RFCError("Duplicate feature identifier") + seen.add(key) + prior = old.get(key, {}) + current = {**prior, "id": key, "title": title, "track": track, + "depends_on": [], "links": prior.get("links", []), + "owner": prior.get("owner", ""), "state": prior.get("state", "planned"), + "source_quote": line, "dropped": prior.get("dropped", False)} + features.append(current) + elif level <= 3: + current = None + if current: + if re.search(r"\s*\*\*Status:\s*complete\*\*", line, re.I): + current["state"] = "implemented" + current["implementation_claim"] = {"source_quote": line.strip(), "source_revision": digest(body), + "provenance": "imported_explicit_milestone"} + dep = re.search(r"(?:依赖|depends\s+on|requires)\s*[::]?\s*(.+)", line, re.I) + if dep: + current["depends_on"] += re.findall(r"\b[A-Za-z][A-Za-z0-9_-]*\d[A-Za-z0-9_-]*\b", dep[1]) + owner = re.search(r"(?:负责人|owner)\s*[::]\s*(.+)", line, re.I) + if owner and "待" not in owner[1]: + current["owner"] = owner[1].strip() + for url in re.findall(r"https?://[^\s)<>]+", line): + if url not in current["links"]: + current["links"].append(url) + if criterion_section: + item = re.match(r"\s*[-*]\s+(?:\[[ xX]\]\s*)?(.+)", line) + if item: + key = "C-" + digest(item[1])[:12] + prior = next((c for c in previous.get("criteria", []) if c["id"] == key), {}) + criteria.append({"id": key, "title": item[1], "verdict": prior.get("verdict", "unverified"), + "evidence": prior.get("evidence", []), "reason": prior.get("reason", "")}) + if not features: + for text in re.findall(r"^\s*[-*]\s+\[[ xX]\]\s+(.+)$", outside, re.M): + key = "F-" + digest(text)[:12] + if key in seen: + continue + seen.add(key) + features.append({"id": key, "title": text, "track": "工作", "depends_on": [], "links": [], + "owner": "", "state": "planned", "source_quote": text, "dropped": False}) + # Resolve bare PR references through canonical links elsewhere in the RFC, + # including live-status tables. An issue or documentation URL is no PR fact. + pr_links = {number: url for url, number in re.findall( + r"(https?://[^\s)<>]+/(?:pull|pulls)/([1-9][0-9]*))", body)} + sections = re.split(r"(?=^#{3,6}\s+[A-Za-z][A-Za-z0-9_-]*[.、:]\s)", outside, flags=re.M) + for section in sections: + match = re.match(r"^#{3,6}\s+([A-Za-z][A-Za-z0-9_-]*)[.、:]", section) + target = next((f for f in features if match and f["id"] == match[1]), None) + if target: + for number in re.findall(r"(?|-\.->|==>)\s*([A-Za-z][A-Za-z0-9_-]*)\b", graph): + if left in available and right in available: + next(f for f in features if f["id"] == right)["depends_on"].append(left) + for feature in features: + feature.update(feature.get("overrides", {})) + feature["depends_on"] = list(dict.fromkeys(feature["depends_on"])) + # Existing sidecar additions survive unrelated human prose edits. + for prior in old.values(): + if prior.get("sidecar") and prior["id"] not in seen: + features.append(prior) + ambiguities = [] + available = {f["id"] for f in features} + for feature in features: + unresolved = [d for d in feature["depends_on"] if d not in available] + if unresolved: + ambiguities.append({"feature_id": feature["id"], "unknown_dependencies": unresolved}) + feature["unresolved_dependencies"] = unresolved + feature["depends_on"] = [d for d in feature["depends_on"] if d in available] + validate_features(features) + return {**previous, "features": features, "criteria": previous.get("criteria", []) if previous.get("criteria_override") else criteria or previous.get("criteria", []), + "source_digest": digest(body), "state": previous.get("state", "draft"), + "suggestions": previous.get("suggestions", []), "tombstones": previous.get("tombstones", []), + "freshness": previous.get("freshness", {}), "scope": previous.get("scope", ""), + "auto_add": previous.get("auto_add", True), "max_auto_additions": previous.get("max_auto_additions", 5), + "ambiguities": ambiguities} + + +def validate_features(features: list[dict]) -> None: + ids = [f.get("id", "") for f in features] + if any(not re.fullmatch(r"[A-Za-z][A-Za-z0-9_-]{0,79}", key) for key in ids) or len(set(ids)) != len(ids): + raise RFCError("Feature identifiers must be unique and portable") + graph = {f["id"]: f.get("depends_on", []) for f in features} + visiting: set[str] = set() + visited: set[str] = set() + def visit(key: str) -> None: + if key not in graph: + raise RFCError("Unknown feature dependency") + if key in visiting: + raise RFCError("Dependency cycle") + if key in visited: + return + visiting.add(key) + for dep in graph[key]: + visit(dep) + visiting.remove(key) + visited.add(key) + for key in graph: + visit(key) + + +def project(model: dict) -> dict: + features = [f for f in model.get("features", []) if not f.get("dropped")] + observations = model.get("observations", {}) + for feature in features: + states = [observations.get(link, {}).get("state", "unknown") for link in feature.get("links", []) + if observations.get(link, {}).get("kind") in ("pr", "pull", "pull_request")] + if states: + feature["implementation"] = ("implemented" if all(s == "merged" for s in states) + else "partial" if "merged" in states else "in_progress") + else: + feature["implementation"] = feature.get("state", "planned") + criteria = model.get("criteria", []) + acceptance = ("accepted" if criteria and all(c.get("verdict") in ("passing", "waived") for c in criteria) + else "failing" if any(c.get("verdict") == "failing" for c in criteria) else "pending") + implemented = bool(features) and all(f.get("implementation") == "implemented" for f in features) + next_actions = [f"验证:{c['title']}" for c in criteria if c.get("verdict") not in ("passing", "waived")] + for feature in features: + unresolved = [dep for dep in feature.get("depends_on", []) + if next((f.get("implementation") for f in features if f["id"] == dep), None) != "implemented"] + unresolved += feature.get("unresolved_dependencies", []) + feature["blockers"] = unresolved + applicable = [c for c in criteria if not c.get("feature_ids") or feature["id"] in c["feature_ids"]] + feature["acceptance"] = "accepted" if applicable and all(c.get("verdict") in ("passing", "waived") for c in applicable) else "pending" + feature["complete"] = feature["implementation"] == "implemented" and feature["acceptance"] == "accepted" + if unresolved: + next_actions.append(f"{feature['id']} 等待:{', '.join(unresolved)}") + elif feature["implementation"] != "implemented": + next_actions.append(f"推进 {feature['id']}:{feature['title']}") + for ambiguity in model.get("ambiguities", []): + next_actions.append(f"审阅 {ambiguity['feature_id']} 的未解析依赖") + if not features: + next_actions.append("补充可跟踪的工作拆分") + return {**model, "implementation": "implemented" if implemented else "partial" if any( + f.get("implementation") in ("implemented", "partial") for f in features) else "planned", + "acceptance": acceptance, "next_actions": next_actions, + "complete": implemented and acceptance == "accepted"} diff --git a/src/infermatrix_copilot/rfc_service/http.py b/src/infermatrix_copilot/rfc_service/http.py new file mode 100644 index 00000000..2677169e --- /dev/null +++ b/src/infermatrix_copilot/rfc_service/http.py @@ -0,0 +1,327 @@ +"""Authenticated same-origin browser and HTTP transport for the RFC service. + +The application owns every permission decision. This adapter never reads RFC +storage directly and does not trust proxy headers to establish its origin. +""" +from __future__ import annotations + +import ipaddress +import json +from http.cookies import CookieError, SimpleCookie +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from importlib.resources import files +from typing import Any +from urllib.parse import parse_qs, unquote, urlsplit + +from .models import RFCError + +COOKIE_NAME = "imrfc_session" +MAX_BODY_BYTES = 1024 * 1024 +_STATIC = { + "/": ("index.html", "text/html; charset=utf-8"), + "/index.html": ("index.html", "text/html; charset=utf-8"), + "/roadmap": ("index.html", "text/html; charset=utf-8"), + "/roadmap.html": ("index.html", "text/html; charset=utf-8"), + "/roadmap-500ms-cn.html": ("index.html", "text/html; charset=utf-8"), + "/app.js": ("app.js", "text/javascript; charset=utf-8"), + "/style.css": ("style.css", "text/css; charset=utf-8"), +} +_COLLECTIONS = { + "repositories": "repositories.list", "rfcs": "rfcs.list", + "users": "users.list", "tokens": "tokens.list", + "operations": "operations.list", "audit": "audit.list", +} +_CSP = ( + "default-src 'self'; script-src 'self'; style-src 'self'; " + "connect-src 'self'; img-src 'self' data:; object-src 'none'; " + "base-uri 'none'; frame-ancestors 'none'; form-action 'self'" +) + + +def _loopback(host: str) -> bool: + if host.lower() == "localhost": + return True + try: + return ipaddress.ip_address(host).is_loopback + except ValueError: + return False + + +def _origin(value: str) -> str: + try: + parts = urlsplit(value) + port = parts.port + except ValueError as exc: + raise ValueError("Invalid public URL") from exc + if (parts.scheme not in {"http", "https"} or not parts.hostname + or parts.username or parts.password or parts.query or parts.fragment + or parts.path not in {"", "/"}): + raise ValueError("Public URL must be an HTTP(S) origin without a path") + host = parts.hostname.lower() + hostname = f"[{host}]" if ":" in host else host + default_port = 443 if parts.scheme == "https" else 80 + return f"{parts.scheme}://{hostname}" + (f":{port}" if port and port != default_port else "") + + +class RFCRequestHandler(BaseHTTPRequestHandler): + """No credentials, request bodies or private source URLs enter access logs.""" + + server_version = "InferMatrixRFC/1" + sys_version = "" + + def setup(self) -> None: + self.request.settimeout(30) + super().setup() + + def log_message(self, format: str, *args: Any) -> None: + # Applications can audit actions through the domain event log instead. + pass + + def _send(self, status: int, body: bytes = b"", *, content_type: str = "application/json; charset=utf-8", + headers: dict[str, str] | None = None) -> None: + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.send_header("X-Content-Type-Options", "nosniff") + self.send_header("Referrer-Policy", "no-referrer") + self.send_header("X-Frame-Options", "DENY") + self.send_header("Content-Security-Policy", _CSP) + for key, value in (headers or {}).items(): + self.send_header(key, value) + self.end_headers() + if self.command != "HEAD" and body: + self.wfile.write(body) + + def _json(self, result: Any, status: int = 200, headers: dict[str, str] | None = None) -> None: + body = json.dumps(result, ensure_ascii=False, allow_nan=False).encode("utf-8") + self._send(status, body, headers=headers) + + def _error(self, error: RFCError) -> None: + status = error.status if 400 <= error.status <= 599 else 500 + self._json({"error": {"code": error.code, "message": str(error)}}, status) + + def _run(self, callback: Any) -> None: + try: + callback() + except RFCError as exc: + self._error(exc) + except (BrokenPipeError, ConnectionResetError): + pass + except Exception: + # Unexpected exceptions may contain credentials or private content. + self._json({"error": {"code": "internal_error", "message": "The request could not be completed"}}, 500) + + def _path(self) -> tuple[str, dict[str, str]]: + parts = urlsplit(self.path) + if parts.scheme or parts.netloc: + raise RFCError("Absolute request targets are not supported") + path = parts.path + query = {key: values[-1] for key, values in parse_qs(parts.query, keep_blank_values=True).items()} + return path, query + + def _body(self) -> dict[str, Any]: + if self.headers.get("Transfer-Encoding"): + raise RFCError("Transfer encoding is not supported") + try: + length = int(self.headers.get("Content-Length", "-1")) + except ValueError as exc: + raise RFCError("Invalid content length") from exc + if length < 0: + raise RFCError("Content length is required", 411, "length_required") + if length > MAX_BODY_BYTES: + raise RFCError("Request body is too large", 413, "body_too_large") + if self.headers.get_content_type() != "application/json": + raise RFCError("Send an application/json request body", 415, "unsupported_media_type") + try: + data = json.loads(self.rfile.read(length)) + except (ValueError, UnicodeError) as exc: + raise RFCError("Invalid JSON request body") from exc + if not isinstance(data, dict): + raise RFCError("Request body must be a JSON object") + return data + + def _cookie(self) -> str: + raw = self.headers.get("Cookie", "") + if len(raw) > 8192: + raise RFCError("Invalid session", 401, "unauthenticated") + cookies = SimpleCookie() + try: + cookies.load(raw) + except CookieError as exc: + raise RFCError("Invalid session", 401, "unauthenticated") from exc + cookie = cookies.get(COOKIE_NAME) + return cookie.value if cookie else "" + + def _check_origin(self) -> None: + # Compare against explicit deployment configuration, never Host or X-Forwarded-*. + request_origin = self.headers.get("Origin", "") + try: + matches = bool(request_origin) and _origin(request_origin) == self.server.public_origin + except ValueError: + matches = False + if not matches: + raise RFCError("This action requires the configured service origin", 403, "invalid_origin") + + def _principal(self, *, mutation: bool = False) -> Any: + authorization = self.headers.get("Authorization") + if authorization is not None: + scheme, separator, token = authorization.partition(" ") + if scheme.lower() != "bearer" or not separator or not token or len(token) > 4096: + raise RFCError("A valid bearer token is required", 401, "unauthenticated") + # Bearer requests are for CLI/SDK; browser cookie actions require Origin. + return self.server.service.authenticate(token) + session = self._cookie() + if not session: + raise RFCError("Sign in to access this service", 401, "unauthenticated") + if mutation: + self._check_origin() + return self.server.service.authenticate_session(session) + + def _session_cookie(self, secret: str, *, revoke: bool = False) -> str: + cookies = SimpleCookie() + cookies[COOKIE_NAME] = "" if revoke else secret + morsel = cookies[COOKIE_NAME] + morsel["path"] = "/" + morsel["httponly"] = True + morsel["samesite"] = "Strict" + if self.server.public_origin.startswith("https://"): + morsel["secure"] = True + if revoke: + morsel["max-age"] = "0" + return morsel.OutputString() + + def _get(self) -> None: + path, query = self._path() + if path in _STATIC: + name, mime = _STATIC[path] + body = files(__package__).joinpath("web", name).read_bytes() + self._send(200, body, content_type=mime) + return + # Authenticate before selecting a data route, including legacy aliases. + principal = self._principal() + action = "" + payload = dict(query) + if path in {"/api/v1/me", "/api/v1/capabilities"}: + action = path.rsplit("/", 1)[-1] + elif path.startswith("/api/v1/"): + segments = path[len("/api/v1/"):].split("/") + if len(segments) == 1 and segments[0] in _COLLECTIONS: + action = _COLLECTIONS[segments[0]] + elif len(segments) in {2, 3} and segments[0] == "rfcs" and segments[1]: + action = "rfcs.get" if len(segments) == 2 else "rfcs.export" if segments[2] == "export" else "" + payload["rfc_id"] = unquote(segments[1]) + elif len(segments) == 2 and segments[0] == "operations" and segments[1]: + action = "operations.get" + payload["operation_id"] = unquote(segments[1]) + elif path in {"/roadmap-snapshot.json", "/roadmap-500ms-cn.json", "/roadmap-500ms-cn.md", + "/api/roadmap/claims", "/api/roadmap/priority", "/api/roadmap/tasks", "/api/roadmap/auto"}: + action = "rfcs.get" if payload.get("rfc_id") else "legacy.get" + if action == "legacy.get": + payload = {"namespace": "wm-7074"} + if not action: + raise RFCError("Route not found", 404, "not_found") + result = self.server.service.dispatch(principal, action, payload) + if path == "/roadmap-500ms-cn.md": + markdown = result.get("body", result.get("markdown", result.get("tracking", {}).get("body", ""))) + if not isinstance(markdown, str): + raise RFCError("Markdown is unavailable", 404, "not_found") + self._send(200, markdown.encode("utf-8"), content_type="text/markdown; charset=utf-8", + headers={"Content-Disposition": 'attachment; filename="rfc-roadmap.md"'}) + return + extra = {"Content-Disposition": 'attachment; filename="rfc-export.json"'} if action == "rfcs.export" else None + self._json(result, headers=extra) + + def _post(self) -> None: + path, _ = self._path() + if path == "/api/v1/session": + self._check_origin() + payload = self._body() + token = payload.get("token") + if not isinstance(token, str) or not token or len(token) > 4096: + raise RFCError("A personal token is required", 401, "unauthenticated") + secret, principal = self.server.service.create_session(token) + # Only the browser cookie receives the secret; responses contain identity. + identity = principal.to_dict() if hasattr(principal, "to_dict") else dict(principal) + self._json({"principal": identity}, headers={"Set-Cookie": self._session_cookie(secret)}) + return + principal = self._principal(mutation=True) + if not path.startswith("/api/v1/actions/"): + raise RFCError("Route not found", 404, "not_found") + action = path[len("/api/v1/actions/"):] + if not action or "/" in action or unquote(action) != action: + raise RFCError("Invalid action") + payload = self._body() + result = self.server.service.dispatch(principal, action, payload) + self._json(result) + + def _delete(self) -> None: + path, _ = self._path() + self._principal(mutation=True) + if path != "/api/v1/session": + raise RFCError("Route not found", 404, "not_found") + self._check_origin() + secret = self._cookie() + if secret: + self.server.service.revoke_session(secret) + self._json({"ok": True}, headers={"Set-Cookie": self._session_cookie("", revoke=True)}) + + def do_GET(self) -> None: + self._run(self._get) + + def do_HEAD(self) -> None: + self._run(self._get) + + def do_POST(self) -> None: + self._run(self._post) + + def do_DELETE(self) -> None: + self._run(self._delete) + + def do_OPTIONS(self) -> None: + self._error(RFCError("Cross-origin requests are not supported", 405, "method_not_allowed")) + + +def create_server(service: Any, host: str = "127.0.0.1", port: int = 0, + public_url: str | None = None) -> ThreadingHTTPServer: + """Construct a server. HTTP is limited to loopback development. + + For a production reverse proxy set ``public_url`` to its HTTPS origin. + The server does not infer TLS or allowed origins from forwarded headers. + """ + if public_url is not None: + origin = _origin(public_url) + if origin.startswith("http://") and (not _loopback(urlsplit(origin).hostname or "") or not _loopback(host)): + raise ValueError("HTTP is only permitted on loopback; configure an HTTPS public URL") + elif not _loopback(host): + raise ValueError("A non-loopback listener requires an HTTPS public URL") + else: + origin = "" + if ":" in host: + import socket + + class IPv6Server(ThreadingHTTPServer): + address_family = socket.AF_INET6 + + server_class = IPv6Server + else: + server_class = ThreadingHTTPServer + server = server_class((host, port), RFCRequestHandler) + server.daemon_threads = True + server.service = service + if not origin: + hostname = f"[{host}]" if ":" in host else host + origin = _origin(f"http://{hostname}:{server.server_address[1]}") + server.public_origin = origin + server.public_url = origin + return server + + +def serve(service: Any, host: str = "127.0.0.1", port: int = 8765, + public_url: str | None = None) -> None: + """Serve until interrupted; no scheduler or deployment daemon is required.""" + server = create_server(service, host=host, port=port, public_url=public_url) + try: + server.serve_forever(poll_interval=0.25) + finally: + server.server_close() diff --git a/src/infermatrix_copilot/rfc_service/migration.py b/src/infermatrix_copilot/rfc_service/migration.py new file mode 100644 index 00000000..5fe64e0c --- /dev/null +++ b/src/infermatrix_copilot/rfc_service/migration.py @@ -0,0 +1,276 @@ +"""Operator-only, read-only comparison and backed-up legacy roadmap migration. + +Legacy browser login claims and agent decisions are historical provenance. They +never grant service permissions, assign authenticated owners or accept an RFC. +""" +from __future__ import annotations + +from copy import deepcopy +import json +import os +from pathlib import Path +import re +import sqlite3 +import uuid + +from .drafts import digest, parse +from .models import RFCError, SourceRef +from .providers import GitHubProvider +from .store import encode + +NAMESPACE = "wm-7074" +MAX_INPUT_BYTES = 32 * 1024 * 1024 +TABLE_FIELDS = { + "feature_claims": ("id", "feature", "login", "note", "created_at"), + "feature_priority": ("feature", "priority", "updated_at"), + "roadmap_tasks": ("id", "roadmap", "op", "feature", "group_prefix", "title", "depends_on", "number", "login", "note", "created_at", "applied_at", "reverted_at", "error"), + "roadmap_auto_items": ("roadmap", "number", "kind", "title", "state", "author", "url", "decision", "target", "group_prefix", "new_title", "depends_on", "rationale", "judged_at", "judge_model", "applied_at", "tombstoned_at", "error", "first_seen", "last_seen", "matched", "refs"), + "roadmap_auto_runs": ("id", "roadmap", "at", "mode", "discovered", "judged", "applied", "tombstoned", "error"), +} +EVIDENCE_FIELDS = {"id", "criterion_id", "feature_id", "title", "status", "verdict", "revision", "head_sha", "environment", "url", "reason", "recorded_at", "created_at", "result", "command"} + + +def _read_json(path: Path, *, optional=False): + if optional and not path.exists(): + return None + try: + raw = path.read_bytes() + if len(raw) > MAX_INPUT_BYTES: + raise ValueError("size") + return json.loads(raw) + except (OSError, ValueError, UnicodeError): + raise RFCError("Legacy JSON input is unavailable or invalid") from None + + +def _read_legacy(root: Path): + snapshot = _read_json(root / "roadmap.json") + prs = _read_json(root / "roadmap-work" / "prs.json", optional=True) or {} + if not isinstance(snapshot, dict) or not isinstance(snapshot.get("body"), str) or not isinstance(prs, dict): + raise RFCError("Legacy snapshot must contain body text and a PR mapping") + path = root / "campaign.sqlite" + if not path.is_file(): + raise RFCError("Legacy campaign database is required") + rows = {} + try: + con = sqlite3.connect(path.resolve().as_uri() + "?mode=ro", uri=True) + con.row_factory = sqlite3.Row + try: + con.execute("PRAGMA query_only=ON") + con.execute("BEGIN") + tables = {row[0] for row in con.execute("SELECT name FROM sqlite_master WHERE type='table'")} + for table, allowed in TABLE_FIELDS.items(): + if table not in tables: + rows[table] = [] + continue + columns = {row[1] for row in con.execute(f'PRAGMA table_info("{table}")')} + fields = [name for name in allowed if name in columns] + if not fields: + raise RFCError("Legacy table has no recognized fields") + query = f'SELECT {",".join(fields)} FROM "{table}"' + params = () + if "roadmap" in columns: + query += " WHERE roadmap=?" + params = (NAMESPACE,) + rows[table] = [dict(row) for row in con.execute(query, params)] + con.rollback() + finally: + con.close() + except sqlite3.Error: + raise RFCError("Legacy database could not be read") from None + evidence = [] + for relative in ("evidence-ledger.json", "evidence_ledger.json", "evidence/ledger.json"): + value = _read_json(root / relative, optional=True) + if value is None: + continue + if isinstance(value, dict): + value = value.get("entries", value.get("evidence", value.get("items", []))) + if not isinstance(value, list) or any(not isinstance(item, dict) for item in value): + raise RFCError("Legacy evidence ledger must contain entry objects") + evidence.extend({key: item[key] for key in EVIDENCE_FIELDS if key in item} for item in value) + return {"body": snapshot["body"], "prs": prs, "rows": rows, "evidence": evidence} + + +def _compare(row, legacy, repository): + model = json.loads(row["model"]) + legacy_ids = [item["id"] for item in parse(legacy["body"])["features"]] + target_ids = [item["id"] for item in model["features"]] + missing = sorted(set(legacy_ids) - set(target_ids)) + extra = sorted({item["id"] for item in model["features"] if not item.get("sidecar")} - set(legacy_ids)) + source = json.loads(row["source"]) + source_matches = repository["provider"] == "github" and (not source or (source.get("provider") == repository["provider"] + and source.get("repository", "").casefold() == repository["external_name"].casefold() + and source.get("kind", "issue") == "issue" and source.get("identifier") == "7074")) + body_matches = row["body"].encode("utf-8") == legacy["body"].encode("utf-8") + counts = {"claims": len(legacy["rows"]["feature_claims"]), "priorities": len(legacy["rows"]["feature_priority"]), + "tasks": len(legacy["rows"]["roadmap_tasks"]), "auto_items": len(legacy["rows"]["roadmap_auto_items"]), + "auto_runs": len(legacy["rows"]["roadmap_auto_runs"]), "prs": len(legacy["prs"]), + "historical_evidence": len(legacy["evidence"]), "features": len(legacy_ids)} + return {"namespace": NAMESPACE, "body_matches": body_matches, "feature_ids_match": not missing and not extra, + "source_matches": source_matches, "legacy_feature_ids": legacy_ids, "target_feature_ids": target_ids, + "missing_feature_ids": missing, "extra_feature_ids": extra, "counts": counts, + "eligible": body_matches and not missing and not extra and source_matches} + + +def _backup(service): + directory = service.store.root / "backups" + directory.mkdir(mode=0o700, exist_ok=True) + directory.chmod(0o700) + path = directory / ("before-" + NAMESPACE + "-" + uuid.uuid4().hex + ".sqlite") + fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + os.close(fd) + source, target = None, None + try: + source = service.store.connect() + target = sqlite3.connect(path) + source.backup(target) + target.close() + target = None + path.chmod(0o600) + return path + except (OSError, sqlite3.Error): + if target is not None: + target.close() + target = None + path.unlink(missing_ok=True) + raise RFCError("Target database backup failed; migration was not applied") from None + finally: + if target is not None: + target.close() + if source is not None: + source.close() + + +def _merge(model, legacy, repository): + model = deepcopy(model) + features = {feature["id"]: feature for feature in model["features"]} + rows = legacy["rows"] + model["legacy_namespace"] = NAMESPACE + model["historical_claims"] = [{**row, "id": f"{NAMESPACE}:claim:{row.get('id', index)}", + "provenance": "legacy_anonymous_login", "authenticated": False} + for index, row in enumerate(rows["feature_claims"])] + model["historical_priorities"] = rows["feature_priority"] + for row in rows["feature_priority"]: + if row.get("feature") in features and row.get("priority") in ("normal", "low", "optional"): + features[row["feature"]]["priority"] = row["priority"] + model["historical_tasks"] = rows["roadmap_tasks"] + tombstones = set(model.get("tombstones", [])) + for row in sorted(rows["roadmap_tasks"], key=lambda item: (item.get("created_at") or "", item.get("id") or 0)): + if not row.get("applied_at") or row.get("reverted_at"): + continue + feature_id = row.get("feature") + if row.get("op") == "drop" and feature_id: + tombstones.add(feature_id) + if feature_id in features: + features[feature_id]["dropped"] = True + elif row.get("op") == "undrop" and feature_id: + tombstones.discard(feature_id) + if feature_id in features: + features[feature_id]["dropped"] = False + suggestions = {item["id"]: item for item in model.get("suggestions", [])} + for row in rows["roadmap_auto_items"]: + number = str(row.get("number") or "") + if not re.fullmatch(r"[1-9][0-9]*", number): + raise RFCError("Legacy discovery contains an invalid item identifier") + kind = "pr" if row.get("kind") in ("pr", "pull", "pull_request") else "issue" + url = f"https://github.com/{repository['external_name']}/{'pull' if kind == 'pr' else 'issues'}/{number}" + ref = SourceRef(repository["provider"], repository["external_name"], kind, number, url, host="github.com") + key = "suggestion-" + digest(encode(ref.to_dict()))[:20] + rejected = bool(row.get("tombstoned_at")) or row.get("decision") in ("ignore", "reject", "rejected") + status = "rejected" if rejected else "applied" if row.get("applied_at") else "proposed" + target = row.get("target") or "" + feature = {"id": target if re.fullmatch(r"[A-Za-z][A-Za-z0-9_-]{0,79}", target) else "F-" + digest(key)[:12], + "title": row.get("new_title") or row.get("title") or "Legacy related work", + "track": features.get(target, {}).get("track", ""), "links": [url], "depends_on": []} + suggestions.setdefault(key, {"id": key, "feature_id": target if row.get("decision") == "attach" else "", + "feature": feature, "url": url, "status": status, "bounded": False, + "reason": row.get("rationale") or "Legacy discovery; review its relationship and scope", + "evidence": {"source": ref.to_dict(), "provenance": "legacy_agent_decision", "historical": True}, + "legacy": {**row, "namespace": NAMESPACE}}) + if rejected: + tombstones.add(key) + elif status == "applied" and row.get("decision") == "attach" and target in features: + if url not in features[target]["links"]: + features[target]["links"].append(url) + model["suggestions"] = list(suggestions.values()) + model["tombstones"] = sorted(tombstones) + model["historical_discovery_runs"] = rows["roadmap_auto_runs"] + model["historical_evidence"] = [{**row, "provenance": "legacy_evidence_ledger", "historical": True, + "accepted": False, "stale": True} for row in legacy["evidence"]] + observations = model.setdefault("observations", {}) + adapter = GitHubProvider() + for number, raw in legacy["prs"].items(): + if not isinstance(raw, dict) or str(raw.get("number") or "") != str(number): + raise RFCError("Legacy PR snapshot contains an invalid identity") + ref = SourceRef(repository["provider"], repository["external_name"], "pr", str(number)) + item = adapter._item(ref, raw) + if item["url"] != f"https://github.com/{repository['external_name']}/pull/{number}": + raise RFCError("Legacy PR snapshot belongs to a different repository") + item.update(kind="pr", legacy_snapshot=True, observed_at=raw.get("updated_at", "")) + observations.setdefault(item["url"], item) + return model + + +def migrate(service, principal, source_dir, repo_id, rfc_id, apply=False): + """Compare first; explicit apply changes only RFC sidecars after a full backup. + + Restore the returned backup with SQLite's backup API while the service and + worker are stopped. A failed apply rolls back its entire target transaction. + Absolute backup paths are returned to this local operator entry point only. + """ + with service.store.transaction() as con: + current = service._current(con, principal) + row = service._rfc(con, current, rfc_id, "maintainer") + if row["repo_id"] != repo_id: + raise RFCError("Resource not found or access denied", 403, "forbidden") + repository = dict(con.execute("SELECT * FROM repositories WHERE id=?", (repo_id,)).fetchone()) + try: + root = Path(source_dir).expanduser().resolve(strict=True) + except OSError: + raise RFCError("Legacy source directory is unavailable") from None + if not root.is_dir(): + raise RFCError("Legacy source directory is required") + legacy = _read_legacy(root) + input_digest = digest(encode(legacy)) + comparison = _compare(row, legacy, repository) + result = {**comparison, "repo_id": repo_id, "rfc_id": rfc_id, "applied": False, + "input_digest": input_digest, "already_applied": False, "backup_path": ""} + # Build and validate everything before creating a backup or changing state. + merged = _merge(json.loads(row["model"]), legacy, repository) if comparison["source_matches"] else None + with service.store.transaction() as con: + current = service._current(con, principal) + latest = service._rfc(con, current, rfc_id, "maintainer") + if latest["repo_id"] != repo_id: + raise RFCError("Resource not found or access denied", 403, "forbidden") + old = con.execute("SELECT value FROM metadata WHERE key=?", (f"legacy_migration:{NAMESPACE}:{rfc_id}",)).fetchone() + if old: + record = json.loads(old["value"]) + if record["input_digest"] != input_digest: + if not apply: + return {**result, "requires_review": True, "previous_input_digest": record["input_digest"]} + raise RFCError("Legacy input changed after migration; review a new migration explicitly", 409, "conflict") + return {**result, "already_applied": True, "backup_path": record["backup_path"] if apply else ""} + if not apply: + return result + if not comparison["eligible"]: + raise RFCError("Legacy RFC prose, feature identifiers or source differ; import the exact RFC before applying", 409, "migration_mismatch") + if latest["revision"] != row["revision"]: + raise RFCError("Target RFC changed during comparison; rerun migration", 409, "conflict") + alias = con.execute("SELECT value FROM metadata WHERE key=?", ("legacy_alias:" + NAMESPACE,)).fetchone() + if alias and alias["value"] != rfc_id: + raise RFCError("Legacy roadmap alias already targets another RFC", 409, "conflict") + namespace = json.loads(row["model"]).get("legacy_namespace", NAMESPACE) + if namespace != NAMESPACE: + raise RFCError("Target RFC already belongs to another legacy namespace", 409, "conflict") + if con.execute("SELECT 1 FROM operations WHERE rfc_id=? AND status IN ('pending','running','uncertain')", (rfc_id,)).fetchone(): + raise RFCError("Finish or recover queued RFC operations before migration", 409, "conflict") + backup = _backup(service) + merged["legacy_migration"] = {"namespace": NAMESPACE, "input_digest": input_digest, + "imported_at": service.clock(), "counts": comparison["counts"]} + service._save(con, current, latest, merged) + if not alias: + con.execute("INSERT INTO metadata VALUES (?,?)", ("legacy_alias:" + NAMESPACE, rfc_id)) + con.execute("INSERT INTO metadata VALUES (?,?)", (f"legacy_migration:{NAMESPACE}:{rfc_id}", + encode({"input_digest": input_digest, "backup_path": str(backup), "counts": comparison["counts"]}))) + service.store.audit(con, current.user_id, "legacy.migrate", service.clock(), repo_id, rfc_id, + {"namespace": NAMESPACE, "input_digest": input_digest, "counts": comparison["counts"]}) + return {**result, "applied": True, "backup_path": str(backup)} diff --git a/src/infermatrix_copilot/rfc_service/models.py b/src/infermatrix_copilot/rfc_service/models.py new file mode 100644 index 00000000..b22b5588 --- /dev/null +++ b/src/infermatrix_copilot/rfc_service/models.py @@ -0,0 +1,56 @@ +"""Transport-neutral RFC identities and errors; no provider or server imports.""" +from __future__ import annotations + +from dataclasses import asdict, dataclass +from typing import Any + + +class RFCError(Exception): + def __init__(self, message: str, status: int = 400, code: str = "invalid_request"): + super().__init__(message) + self.status = status + self.code = code + + +class ProviderError(RFCError): + def __init__(self, message: str = "Source provider unavailable", *, uncertain: bool = False): + super().__init__(message, 502, "outcome_unknown" if uncertain else "provider_failure") + self.uncertain = uncertain + + +@dataclass(frozen=True) +class Principal: + user_id: str + name: str + admin: bool = False + credential_id: str = "" + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass(frozen=True) +class SourceRef: + provider: str + repository: str = "" + kind: str = "issue" + identifier: str = "" + url: str = "" + path: str = "" + host: str = "" + + def to_dict(self) -> dict[str, str]: + return asdict(self) + + @classmethod + def from_dict(cls, value: dict[str, Any]) -> SourceRef: + if not isinstance(value, dict) or not value.get("provider"): + raise RFCError("A source provider is required") + allowed = set(cls.__dataclass_fields__) + if set(value) - allowed: + raise RFCError("Unknown source fields") + return cls(**{key: str(val) for key, val in value.items() if val is not None}) + + def identity(self) -> str: + return "|".join((self.provider, self.host, self.repository, self.kind, + self.identifier or self.path or self.url)) diff --git a/src/infermatrix_copilot/rfc_service/providers.py b/src/infermatrix_copilot/rfc_service/providers.py new file mode 100644 index 00000000..af698961 --- /dev/null +++ b/src/infermatrix_copilot/rfc_service/providers.py @@ -0,0 +1,662 @@ +"""Provider adapters for portable RFCs; network access stays on configured API hosts. + +An injected transport is ``(method, url, headers, payload) -> +(status, response_headers, decoded_json)``. No provider retries a creation request: +callers recover its operation marker before deciding whether to publish again. +""" +from __future__ import annotations + +from contextlib import contextmanager +from datetime import datetime, timezone +import hashlib +import json +import os +from pathlib import Path, PurePosixPath +import re +import stat +import subprocess +import secrets +from typing import Any, Callable, Iterator, Mapping +from urllib.error import HTTPError +from urllib.parse import quote, urlencode, urlsplit +from urllib.request import HTTPRedirectHandler, Request, build_opener + +from .models import ProviderError, RFCError, SourceRef + +Transport = Callable[[str, str, dict[str, str], dict[str, Any] | None], tuple[int, Mapping[str, str], Any]] +MAX_PAGES = 100 +PAGE_SIZE = 100 +MAX_DOCUMENT_BYTES = 4 * 1024 * 1024 +_OPERATION = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$") +_COMPONENT = re.compile(r"^[A-Za-z0-9_.-]+$") + + +def _marker(operation_id: str) -> str: + if not isinstance(operation_id, str) or not _OPERATION.fullmatch(operation_id): + raise RFCError("Invalid publication operation identifier") + return f"" + + +def _publication_body(body: str, operation_id: str) -> str: + if not isinstance(body, str) or len(body.encode("utf-8")) > MAX_DOCUMENT_BYTES: + raise RFCError("RFC body must be text within the document size limit") + marker = _marker(operation_id) + return body if marker in body else body.rstrip() + "\n\n" + marker + "\n" + + +def _title(title: str) -> str: + if not isinstance(title, str) or not title.strip() or len(title) > 256 or "\n" in title: + raise RFCError("RFC title must be a single nonempty line of at most 256 characters") + return title.strip() + + +def _digest(value: str | bytes) -> str: + return hashlib.sha256(value.encode("utf-8") if isinstance(value, str) else value).hexdigest() + + +def _revision(item: Mapping[str, Any]) -> str: + relevant = {key: item.get(key) for key in ("title", "body", "updated_at", "state", "draft", "merged_at")} + head = item.get("head") + relevant["head_sha"] = head.get("sha", "") if isinstance(head, dict) else "" + return _digest(json.dumps(relevant, sort_keys=True, ensure_ascii=False, separators=(",", ":"))) + + +def _logins(values: Any) -> list[str]: + if not isinstance(values, list): + values = [values] if values else [] + result = [] + for value in values: + login = value if isinstance(value, str) else (value.get("login") or value.get("username") or "") if isinstance(value, dict) else "" + if isinstance(login, str) and login and login not in result: + result.append(login) + return result + + +def _state(item: Mapping[str, Any]) -> str: + native = str(item.get("state") or "").lower() + merge_time = item.get("merged_at") + try: + has_merge_time = isinstance(merge_time, str) and bool(datetime.fromisoformat(merge_time.replace("Z", "+00:00"))) + except ValueError: + has_merge_time = False + if native == "merged" or item.get("merged") is True or has_merge_time: + return "merged" + if native in ("closed", "close"): + return "closed" + if native in ("open", "opened", "reopen", "reopened"): + return "draft" if item.get("draft") is True else "open" + return "unknown" + + +def _scope_matches(title: str, body: str, labels: Any, scope: Any) -> list[str]: + if isinstance(scope, dict): + terms = scope.get("keywords") or [] + wanted_labels = scope.get("labels") or [] + if not terms: + terms = re.findall(r"[\w-]{3,}", str(scope.get("scope") or scope.get("text") or scope.get("description") or "")) + elif isinstance(scope, str): + terms = re.findall(r"[\w-]{3,}", scope) + wanted_labels = [] + else: + raise RFCError("Discovery scope must be text or a scope object") + if isinstance(terms, str): + terms = [terms] + if isinstance(wanted_labels, str): + wanted_labels = [wanted_labels] + stopwords = {"the", "and", "for", "with", "from", "this", "that", "into", "are", "only", "scope", "within", "related", "repository"} + terms = [str(term).lower() for term in terms if str(term).lower() not in stopwords] + text = (title + "\n" + body).lower() + matched = [term for term in terms if term in text] + names = {str(label.get("name", "") if isinstance(label, dict) else label).lower() for label in labels or []} + matched += ["label:" + str(label) for label in wanted_labels if str(label).lower() in names] + return list(dict.fromkeys(matched)) if terms or wanted_labels else ["enrolled repository"] + + +def _discovery_reference(scope: Any, text: str) -> str: + """An exact backlink establishes a relationship; keywords only retrieve work.""" + if not isinstance(scope, dict) or not isinstance(scope.get("source"), dict): + return "" + source = scope["source"] + url = source.get("url", "") + if url: + return url if re.search(re.escape(url) + r"(?=$|[\s<>)\]#?,.!;:])", text) else "" + path = source.get("path") or source.get("identifier") or "" + # Local relationships require a field, rather than an incidental path mention. + references = re.findall(r"(?im)^\s*(?:RFC|RFC source|RFC来源)\s*[::]\s*`?([^`\n]+?)`?\s*$", text) + return path if path and path in references else "" + + +def _same_discovery_source(item: dict[str, Any], scope: Any) -> bool: + if not isinstance(scope, dict) or not isinstance(scope.get("source"), dict): + return False + source, candidate = scope["source"], item.get("source", {}) + def kind(value): + return "pr" if value in ("pr", "pull", "pull_request") else value + return (source.get("provider") == candidate.get("provider") + and source.get("repository") == candidate.get("repository") + and kind(source.get("kind")) == kind(candidate.get("kind")) + and (source.get("identifier") or source.get("path")) == (candidate.get("identifier") or candidate.get("path"))) + + +def _annotate_discovery(item: dict[str, Any], scope: Any, matches: list[str]) -> dict[str, Any]: + text = item["title"] + "\n" + item["body"] + reference = _discovery_reference(scope, text) + reason = "Matched enrolled scope: " + ", ".join(matches) if matches else "Explicit RFC reference" + item.update(rationale=reason, reason=reason, evidence={"source": item["source"], "revision": item["revision"], + "reference": reference, "scope_matches": matches}, within_scope=False) + if not reference or not isinstance(scope, dict): + return item + features = [feature for feature in scope.get("features", []) if isinstance(feature, dict) and not feature.get("dropped")] + named = [feature for feature in features if feature.get("id") and re.search( + r"(? MAX_DOCUMENT_BYTES * 4: + raise ValueError("response too large") + return response.status, dict(response.headers), json.loads(raw) if raw else None + except HTTPError as error: + # Never expose upstream messages, URLs or response bodies containing secrets. + return error.code, dict(error.headers), None + + +class _HTTPProvider: + name = "" + default_api_url = "" + default_web_hosts: tuple[str, ...] = () + + def __init__(self, token: str = "", api_url: str = "", transport: Transport | None = None): + parsed = urlsplit(api_url or self.default_api_url) + if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment: + raise RFCError("Provider API URL must be a configured HTTPS origin and optional API path") + self.api_url = (api_url or self.default_api_url).rstrip("/") + self.api_host = parsed.hostname.lower() + self.web_hosts = set(self.default_web_hosts) if self.api_host == urlsplit(self.default_api_url).hostname else {self.api_host} + self.web_host = next(iter(self.default_web_hosts), self.api_host) if self.api_host == urlsplit(self.default_api_url).hostname else self.api_host + self._token = token + self._transport = transport or _urllib_transport + + def _repo(self, repository: str) -> tuple[str, str]: + parts = repository.split("/") if isinstance(repository, str) else [] + if len(parts) != 2 or any(part in ("", ".", "..") or not _COMPONENT.fullmatch(part) for part in parts): + raise RFCError("Provider repository must be an owner/repository path") + return parts[0], parts[1] + + def _number(self, value: str, kind: str) -> str: + if not isinstance(value, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}", value): + raise RFCError("Invalid source identifier") + return quote(value, safe="") + + def _validate_ref(self, ref: SourceRef) -> None: + if ref.provider != self.name or ref.kind not in ("issue", "pr", "pull_request"): + raise RFCError("Unsupported provider source") + self._repo(ref.repository) + self._number(ref.identifier, ref.kind) + if ref.host and ref.host.lower() not in self.web_hosts | {self.api_host}: + raise RFCError("Source host is outside the configured provider", 403, "source_host_forbidden") + if ref.url: + try: + parsed = urlsplit(ref.url) + allowed = parsed.scheme == "https" and parsed.hostname in self.web_hosts and not parsed.username and not parsed.password and not parsed.query and parsed.port in (None, 443) + except ValueError: + allowed = False + if not allowed: + raise RFCError("Source URL is outside the configured provider", 403, "source_host_forbidden") + + def _json(self, method: str, route: str, query: Mapping[str, Any] | None = None, payload: dict[str, Any] | None = None): + url = self.api_url + route + if query: + url += "?" + urlencode({key: value for key, value in query.items() if value is not None and value != ""}) + headers = {"Accept": "application/json", "User-Agent": "InferMatrixCopilot-RFC/1"} + if payload is not None: + headers["Content-Type"] = "application/json" + if self._token: + headers["Authorization"] = "Bearer " + self._token + if self.name == "github": + headers["X-GitHub-Api-Version"] = "2022-11-28" + try: + status_code, response_headers, result = self._transport(method, url, headers, payload) + except Exception: + raise ProviderError("Provider request could not be completed", uncertain=method != "GET") from None + if not isinstance(status_code, int) or not 200 <= status_code < 300: + known_status = status_code if isinstance(status_code, int) else "invalid" + raise ProviderError(f"Provider returned HTTP {known_status}", uncertain=method != "GET" and (not isinstance(status_code, int) or status_code >= 500)) + if not isinstance(response_headers, Mapping): + raise ProviderError("Provider returned invalid response headers", uncertain=method != "GET") + return result, {str(key).lower(): str(value) for key, value in response_headers.items()} + + def _pages(self, route: str, query: Mapping[str, Any] | None = None, *, size: int = PAGE_SIZE) -> Iterator[dict[str, Any]]: + for page in range(1, MAX_PAGES + 1): + result, headers = self._json("GET", route, dict(query or {}, page=page, per_page=size)) + if not isinstance(result, list) or any(not isinstance(row, dict) for row in result): + raise ProviderError("Provider returned an invalid listing") + yield from result + next_page = headers.get("x-next-page", "").strip() + has_next = bool(re.search(r'rel\s*=\s*"?next\b', headers.get("link", ""))) or next_page not in ("", "0") + if len(result) < size and not has_next: + return + raise ProviderError("Provider listing is incomplete; pagination limit reached", uncertain=True) + + def _source(self, repository: str, item: Mapping[str, Any], kind: str = "issue") -> SourceRef: + identifier = str(item.get("number") or "") + self._number(identifier, kind) + owner, repo = self._repo(repository) + fallback = f"https://{self.web_host}/{owner}/{repo}/{'issues' if kind == 'issue' else 'pull'}/{identifier}" + url = str(item.get("html_url") or item.get("web_url") or fallback) + ref = SourceRef(self.name, repository, kind, identifier, url, host=urlsplit(url).hostname or self.web_host) + self._validate_ref(ref) + return ref + + def _item(self, ref: SourceRef, raw: Mapping[str, Any]) -> dict[str, Any]: + if not isinstance(raw, dict) or any(raw.get(key) is not None and not isinstance(raw.get(key), str) for key in ("title", "body")): + raise ProviderError("Provider returned an invalid source") + try: + actual = self._source(ref.repository, raw, "pr" if ref.kind in ("pr", "pull_request") else "issue") + except RFCError: + raise ProviderError("Provider returned an invalid source identity") from None + if actual.identifier != ref.identifier: + raise ProviderError("Provider returned a different source identifier") + author = _logins(raw.get("user") or raw.get("author")) + owners = _logins(raw.get("assignees") or raw.get("assignee")) + reviewers = _logins(raw.get("requested_reviewers")) + if self.name == "atomgit" and actual.kind == "pr": + reviewers = list(dict.fromkeys(_logins(raw.get("assignees")) + _logins(raw.get("approval_reviewers")))) + owners = [] + head = raw.get("head") + return {"source": actual.to_dict(), "title": str(raw.get("title") or ""), "body": str(raw.get("body") or ""), + "url": actual.url, "revision": _revision(raw), "state": _state(raw), "native_state": str(raw.get("state") or ""), + "head_sha": str(head.get("sha") or "") if isinstance(head, dict) else "", "author": author[0] if author else "", + "owner": owners[0] if owners else "", "owners": owners, "reviewers": reviewers, + "updated_at": str(raw.get("updated_at") or ""), "labels": raw.get("labels") or []} + + def _get_raw(self, ref: SourceRef) -> dict[str, Any]: + self._validate_ref(ref) + owner, repo = self._repo(ref.repository) + resource = "pulls" if ref.kind in ("pr", "pull_request") else "issues" + result, _ = self._json("GET", f"/repos/{quote(owner)}/{quote(repo)}/{resource}/{self._number(ref.identifier, ref.kind)}") + if not isinstance(result, dict): + raise ProviderError("Provider returned an invalid source") + return result + + def get_source(self, ref: SourceRef) -> dict[str, Any]: + return self._item(ref, self._get_raw(ref)) + + def get_item(self, ref: SourceRef) -> dict[str, Any]: + return self.get_source(ref) + + def publish(self, repository: str, title: str, body: str, operation_id: str, *, path: str = "") -> SourceRef: + if path: + raise RFCError("Remote issue publication does not accept a file path") + owner, repo = self._repo(repository) + payload = {"title": _title(title), "body": _publication_body(body, operation_id)} + route = f"/repos/{quote(owner)}/{quote(repo)}/issues" + if self.name == "atomgit": + route = f"/repos/{quote(owner)}/issues" + payload["repo"] = repo + raw, _ = self._json("POST", route, payload=payload) + try: + if not isinstance(raw, dict): + raise ValueError("invalid publication response") + return self._source(repository, raw) + except (RFCError, ValueError, TypeError): + raise ProviderError("Publication response could not establish the created source", uncertain=True) from None + + def find_publication(self, repository: str, operation_id: str) -> SourceRef | None: + owner, repo = self._repo(repository) + marker = _marker(operation_id) + found = [] + try: + for item in self._pages(f"/repos/{quote(owner)}/{quote(repo)}/issues", {"state": "all", "sort": "created", "direction": "desc"}): + if "pull_request" not in item and marker in str(item.get("body") or ""): + found.append(self._source(repository, item)) + except (ProviderError, RFCError): + raise ProviderError("Publication recovery could not complete the repository scan", uncertain=True) from None + if len(found) > 1: + raise ProviderError("Multiple publications carry the operation marker", uncertain=True) + return found[0] if found else None + + def update_source(self, ref: SourceRef, body: str, expected_revision: str) -> dict[str, Any]: + if ref.kind != "issue": + raise RFCError("Managed RFC updates require an issue source") + current = self.get_source(ref) + if current["revision"] != expected_revision: + raise RFCError("RFC changed before the update", 409, "source_conflict") + if not isinstance(body, str) or len(body.encode("utf-8")) > MAX_DOCUMENT_BYTES: + raise RFCError("RFC body exceeds the document size limit") + owner, repo = self._repo(ref.repository) + route = f"/repos/{quote(owner)}/{quote(repo)}/issues/{self._number(ref.identifier, ref.kind)}" + payload = {"body": body} + if self.name == "atomgit": + route = f"/repos/{quote(owner)}/issues/{self._number(ref.identifier, ref.kind)}" + payload.update(repo=repo, title=current["title"]) + # This is a read/check/write guard, not a claim of provider-side atomic CAS. + self._json("PATCH", route, payload=payload) + result = self.get_source(ref) + if result["body"] != body or result["title"] != current["title"]: + raise RFCError("RFC update readback differs; reconcile the source", 409, "source_conflict") + return result + + def discover(self, repository: str, scope: Any, since: str = "") -> list[dict[str, Any]]: + owner, repo = self._repo(repository) + prefix = f"/repos/{quote(owner)}/{quote(repo)}" + query = {"state": "all", "sort": "updated", "direction": "desc", "since": since} + result = [] + resources = [("issue", prefix + "/issues")] + if self.name == "atomgit": + resources.append(("pr", prefix + "/pulls")) + for kind, route in resources: + for raw in self._pages(route, query): + matched = _scope_matches(str(raw.get("title") or ""), str(raw.get("body") or ""), raw.get("labels"), scope) + text = str(raw.get("title") or "") + "\n" + str(raw.get("body") or "") + if not matched and not _discovery_reference(scope, text): + continue + actual_kind = "pr" if "pull_request" in raw else kind + ref = self._source(repository, raw, actual_kind) + # Discovery retrieves references, not delivery verification. Avoid + # one detail request per candidate; attached links are verified by + # the application through get_item. GitHub issue listings carry + # an explicit merge timestamp inside their pull_request field. + native = raw + pull = raw.get("pull_request") + if actual_kind == "pr" and isinstance(pull, dict) and pull.get("merged_at") and not raw.get("merged_at"): + native = {**raw, "merged_at": pull["merged_at"]} + item = self._item(ref, native) + if not _same_discovery_source(item, scope): + result.append(_annotate_discovery(item, scope, matched)) + return result + + +class GitHubProvider(_HTTPProvider): + name = "github" + default_api_url = "https://api.github.com" + default_web_hosts = ("github.com",) + + def __init__(self, token: str = "", api_url: str = "https://api.github.com", transport: Transport | None = None): + super().__init__(token, api_url, transport) + + def _number(self, value: str, kind: str) -> str: + if not isinstance(value, str) or not re.fullmatch(r"[1-9][0-9]*", value): + raise RFCError("GitHub issue and PR identifiers must be positive integers") + return value + + +class AtomGitProvider(_HTTPProvider): + name = "atomgit" + default_api_url = "https://api.atomgit.com/api/v5" + default_web_hosts = ("atomgit.com", "gitcode.com") + + def __init__(self, token: str = "", api_url: str = "https://api.atomgit.com/api/v5", transport: Transport | None = None): + if not urlsplit(api_url).path.strip("/"): + api_url = api_url.rstrip("/") + "/api/v5" + super().__init__(token, api_url, transport) + + def _number(self, value: str, kind: str) -> str: + if kind in ("pr", "pull_request") and (not isinstance(value, str) or not re.fullmatch(r"[1-9][0-9]*", value)): + raise RFCError("AtomGit PR identifiers must be positive integers") + return super()._number(value, kind) + + +class LocalProvider: + """Markdown RFCs and observed Git commits inside explicitly registered roots.""" + name = "local" + + def __init__(self, roots: Mapping[str, str | Path]): + self.roots: dict[str, Path] = {} + self._root_inodes: dict[str, tuple[int, int]] = {} + for repository, value in roots.items(): + path = Path(value) + if not repository or not path.is_absolute() or not path.is_dir(): + raise RFCError("Local repositories require existing absolute directory roots") + self.roots[repository] = path.resolve(strict=True) + metadata = self.roots[repository].stat() + self._root_inodes[repository] = (metadata.st_dev, metadata.st_ino) + + def _root(self, repository: str) -> Path: + if repository not in self.roots: + raise RFCError("Local repository is outside the configured roots", 403, "source_root_forbidden") + return self.roots[repository] + + def _parts(self, path: str) -> tuple[str, ...]: + if not isinstance(path, str) or not path or "\\" in path or "\x00" in path or ":" in path: + raise RFCError("Local source requires a repository-relative Markdown path") + value = PurePosixPath(path) + if value.is_absolute() or any(part in ("..", ".git", ".hg", ".svn") for part in value.parts) or value.suffix.lower() not in (".md", ".markdown"): + raise RFCError("Local source path is outside the permitted Markdown tree", 403, "source_path_forbidden") + return value.parts + + @contextmanager + def _parent(self, repository: str, path: str, *, create: bool = False): + root = self._root(repository) + parts = self._parts(path) + # POSIX walks directories by descriptor, so replacing a parent with a + # symlink cannot redirect a read/create/replace outside the registered root. + if os.open in os.supports_dir_fd and hasattr(os, "O_NOFOLLOW"): + try: + fd = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + except OSError: + raise RFCError("Local repository root cannot be accessed safely", 403, "source_root_forbidden") from None + try: + metadata = os.fstat(fd) + if (metadata.st_dev, metadata.st_ino) != self._root_inodes[repository]: + raise RFCError("Local repository root changed after registration", 403, "source_root_forbidden") + for part in parts[:-1]: + try: + child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=fd) + except FileNotFoundError: + if not create: + raise + try: + os.mkdir(part, mode=0o700, dir_fd=fd) + except FileExistsError: + pass + child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=fd) + os.close(fd) + fd = child + yield fd, parts[-1], root.joinpath(*parts) + except OSError as error: + if isinstance(error, FileNotFoundError): + raise RFCError("Local RFC source does not exist", 404, "source_not_found") from None + raise RFCError("Local RFC path cannot be accessed safely", 403, "source_path_forbidden") from None + finally: + os.close(fd) + else: + target = root + for part in parts[:-1]: + target = target / part + if target.is_symlink(): + raise RFCError("Symlinks are not permitted in local RFC paths", 403, "source_path_forbidden") + if create: + target.mkdir(exist_ok=True) + if not target.is_dir(): + raise RFCError("Local RFC source does not exist", 404, "source_not_found") + target = target / parts[-1] + if target.is_symlink() or not target.resolve(strict=False).is_relative_to(root): + raise RFCError("Local RFC path escapes the configured root", 403, "source_path_forbidden") + yield None, parts[-1], target + + def _read_info(self, repository: str, path: str) -> tuple[bytes, float]: + with self._parent(repository, path) as (parent, leaf, target): + try: + fd = os.open(leaf if parent is not None else target, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0), **({"dir_fd": parent} if parent is not None else {})) + with os.fdopen(fd, "rb") as stream: + metadata = os.fstat(stream.fileno()) + if not stat.S_ISREG(metadata.st_mode): + raise RFCError("Local RFC source must be a regular file") + raw = stream.read(MAX_DOCUMENT_BYTES + 1) + except FileNotFoundError: + raise RFCError("Local RFC source does not exist", 404, "source_not_found") from None + if len(raw) > MAX_DOCUMENT_BYTES: + raise RFCError("Local RFC source exceeds the document size limit") + return raw, metadata.st_mtime + + def _read(self, repository: str, path: str) -> bytes: + return self._read_info(repository, path)[0] + + def _ref(self, repository: str, path: str) -> SourceRef: + return SourceRef("local", repository, "markdown", path, path=path) + + def get_source(self, ref: SourceRef) -> dict[str, Any]: + if ref.provider not in ("local", "local_git", "markdown") or ref.host or ref.url: + raise RFCError("Local sources must use an allowlisted repository and relative path") + if ref.kind in ("commit", "git_commit"): + return self._commit(ref) + raw, modified = self._read_info(ref.repository, ref.path or ref.identifier) + try: + body = raw.decode("utf-8") + except UnicodeDecodeError: + raise RFCError("Local RFC sources must be UTF-8 Markdown") from None + path = str(PurePosixPath(ref.path or ref.identifier)) + source = self._ref(ref.repository, path) + title = re.search(r"^#\s+(.+)$", body, re.M) + return {"source": source.to_dict(), "title": title[1] if title else PurePosixPath(path).stem, "body": body, + "revision": _digest(raw), "url": "", "state": "unknown", "native_state": "markdown", + "head_sha": "", "author": "", "owner": "", "owners": [], "reviewers": [], + "updated_at": datetime.fromtimestamp(modified, timezone.utc).isoformat()} + + def get_item(self, ref: SourceRef) -> dict[str, Any]: + return self.get_source(ref) + + def _commit(self, ref: SourceRef) -> dict[str, Any]: + root = self._root(ref.repository) + if not re.fullmatch(r"[0-9a-fA-F]{7,64}", ref.identifier): + raise RFCError("Local commit references require a hexadecimal Git object identifier") + try: + result = subprocess.run(["git", "-C", str(root), "show", "--no-ext-diff", "--no-patch", "--format=%H%n%an%n%cI%n%s%n%b", ref.identifier, "--"], capture_output=True, text=True, timeout=15, check=True) + except (OSError, subprocess.SubprocessError): + raise ProviderError("Local Git commit could not be read") from None + fields = result.stdout.split("\n", 4) + if len(fields) < 4 or not re.fullmatch(r"[0-9a-fA-F]{40,64}", fields[0]): + raise ProviderError("Local Git returned an invalid commit") + source = SourceRef("local", ref.repository, "commit", fields[0]) + return {"source": source.to_dict(), "title": fields[3], "body": fields[4] if len(fields) > 4 else "", "revision": fields[0], + "url": "", "state": "unknown", "native_state": "commit", "head_sha": fields[0], "author": fields[1], + "owner": "", "owners": [], "reviewers": [], "updated_at": fields[2]} + + def publish(self, repository: str, title: str, body: str, operation_id: str, *, path: str = "") -> SourceRef: + marker = _marker(operation_id) + title = _title(title) + path = path or "rfcs/" + re.sub(r"[^A-Za-z0-9._-]", "_", operation_id) + ".md" + content = _publication_body(body, operation_id) + if not re.search(r"^#\s", content, re.M): + content = "# " + title + "\n\n" + content + with self._parent(repository, path, create=True) as (parent, leaf, target): + try: + fd = os.open(leaf if parent is not None else target, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), 0o600, **({"dir_fd": parent} if parent is not None else {})) + except FileExistsError: + existing = self.get_source(self._ref(repository, path)) + if marker in existing["body"]: + return self._ref(repository, path) + raise RFCError("Local RFC publication path already exists", 409, "publication_conflict") from None + try: + with os.fdopen(fd, "wb") as stream: + stream.write(content.encode("utf-8")) + stream.flush() + os.fsync(stream.fileno()) + except OSError: + raise ProviderError("Local RFC publication may be incomplete", uncertain=True) from None + return self._ref(repository, str(PurePosixPath(path))) + + def _files(self, repository: str) -> Iterator[str]: + root = self._root(repository) + def onerror(error): + raise ProviderError("Local RFC tree could not be completely scanned", uncertain=True) from None + for base, dirs, files in os.walk(root, followlinks=False, onerror=onerror): + dirs[:] = [name for name in dirs if name not in (".git", ".hg", ".svn") and not (Path(base) / name).is_symlink()] + for name in files: + file = Path(base) / name + if file.suffix.lower() in (".md", ".markdown") and not file.is_symlink(): + yield file.relative_to(root).as_posix() + + def find_publication(self, repository: str, operation_id: str) -> SourceRef | None: + marker = _marker(operation_id) + found = [] + try: + for path in self._files(repository): + if marker.encode("utf-8") in self._read(repository, path): + found.append(self._ref(repository, path)) + except (RFCError, ProviderError, OSError): + raise ProviderError("Local publication recovery could not complete the repository scan", uncertain=True) from None + if len(found) > 1: + raise ProviderError("Multiple local publications carry the operation marker", uncertain=True) + return found[0] if found else None + + def update_source(self, ref: SourceRef, body: str, expected_revision: str) -> dict[str, Any]: + current = self.get_source(ref) + if ref.kind in ("commit", "git_commit"): + raise RFCError("Git commits cannot be rewritten by the RFC provider") + if current["revision"] != expected_revision: + raise RFCError("Local RFC changed before the update", 409, "source_conflict") + if not isinstance(body, str) or len(body.encode("utf-8")) > MAX_DOCUMENT_BYTES: + raise RFCError("RFC body exceeds the document size limit") + path = ref.path or ref.identifier + with self._parent(ref.repository, path) as (parent, leaf, target): + temporary = ".imrfc-" + secrets.token_hex(16) + ".tmp" + kwargs = {"dir_fd": parent} if parent is not None else {} + temp_path = temporary if parent is not None else target.parent / temporary + try: + fd = os.open(temp_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), 0o600, **kwargs) + with os.fdopen(fd, "wb") as stream: + stream.write(body.encode("utf-8")) + stream.flush() + os.fsync(stream.fileno()) + if self.get_source(ref)["revision"] != expected_revision: + raise RFCError("Local RFC changed before replacement", 409, "source_conflict") + if parent is not None: + os.replace(temporary, leaf, src_dir_fd=parent, dst_dir_fd=parent) + else: + os.replace(temp_path, target) + finally: + try: + os.unlink(temp_path, **kwargs) + except FileNotFoundError: + pass + result = self.get_source(ref) + if result["body"] != body: + raise RFCError("Local RFC update readback differs", 409, "source_conflict") + return result + + def discover(self, repository: str, scope: Any, since: str = "") -> list[dict[str, Any]]: + try: + since_time = datetime.fromisoformat(since.replace("Z", "+00:00")) if since else None + if since_time and since_time.tzinfo is None: + since_time = since_time.replace(tzinfo=timezone.utc) + except ValueError: + raise RFCError("Discovery since must be an ISO 8601 timestamp") from None + result = [] + for path in self._files(repository): + item = self.get_source(self._ref(repository, path)) + if since_time and datetime.fromisoformat(item["updated_at"]) < since_time: + continue + matches = _scope_matches(item["title"], item["body"], [], scope) + if not _same_discovery_source(item, scope) and (matches or _discovery_reference(scope, item["title"] + "\n" + item["body"])): + result.append(_annotate_discovery(item, scope, matches)) + return result diff --git a/src/infermatrix_copilot/rfc_service/store.py b/src/infermatrix_copilot/rfc_service/store.py new file mode 100644 index 00000000..c07d5696 --- /dev/null +++ b/src/infermatrix_copilot/rfc_service/store.py @@ -0,0 +1,106 @@ +"""Cross-platform SQLite persistence and transactionally claimed operations.""" +from __future__ import annotations + +import json +import sqlite3 +from contextlib import contextmanager +from pathlib import Path +from typing import Any, Iterator + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS metadata(key TEXT PRIMARY KEY, value TEXT NOT NULL); +CREATE TABLE IF NOT EXISTS users(id TEXT PRIMARY KEY, name TEXT UNIQUE NOT NULL, admin INTEGER NOT NULL DEFAULT 0, enabled INTEGER NOT NULL DEFAULT 1); +CREATE TABLE IF NOT EXISTS tokens(id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id), digest TEXT UNIQUE NOT NULL, expires REAL NOT NULL, revoked INTEGER NOT NULL DEFAULT 0, created REAL NOT NULL); +CREATE TABLE IF NOT EXISTS sessions(digest TEXT PRIMARY KEY, token_id TEXT NOT NULL REFERENCES tokens(id), expires REAL NOT NULL); +CREATE TABLE IF NOT EXISTS repositories(id TEXT PRIMARY KEY, name TEXT NOT NULL, provider TEXT NOT NULL, external_name TEXT NOT NULL, root TEXT NOT NULL DEFAULT '', config TEXT NOT NULL DEFAULT '{}'); +CREATE TABLE IF NOT EXISTS repository_grants(repo_id TEXT NOT NULL REFERENCES repositories(id), user_id TEXT NOT NULL REFERENCES users(id), role TEXT NOT NULL, PRIMARY KEY(repo_id,user_id)); +CREATE TABLE IF NOT EXISTS rfcs(id TEXT PRIMARY KEY, repo_id TEXT NOT NULL REFERENCES repositories(id), title TEXT NOT NULL, body TEXT NOT NULL, revision TEXT NOT NULL, source TEXT NOT NULL DEFAULT '{}', model TEXT NOT NULL, enrolled INTEGER NOT NULL DEFAULT 0, restricted INTEGER NOT NULL DEFAULT 0, created_by TEXT NOT NULL REFERENCES users(id), updated REAL NOT NULL); +CREATE TABLE IF NOT EXISTS rfc_grants(rfc_id TEXT NOT NULL REFERENCES rfcs(id), user_id TEXT NOT NULL REFERENCES users(id), role TEXT NOT NULL, PRIMARY KEY(rfc_id,user_id)); +CREATE TABLE IF NOT EXISTS source_snapshots(id INTEGER PRIMARY KEY, rfc_id TEXT NOT NULL REFERENCES rfcs(id), revision TEXT NOT NULL, body TEXT NOT NULL, created REAL NOT NULL); +CREATE TABLE IF NOT EXISTS operations(id TEXT PRIMARY KEY, actor TEXT NOT NULL REFERENCES users(id), credential_id TEXT NOT NULL DEFAULT '', kind TEXT NOT NULL, repo_id TEXT NOT NULL, rfc_id TEXT NOT NULL DEFAULT '', payload TEXT NOT NULL, idempotency_key TEXT NOT NULL, request_digest TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', result TEXT NOT NULL DEFAULT '{}', error TEXT NOT NULL DEFAULT '', created REAL NOT NULL, updated REAL NOT NULL, worker TEXT NOT NULL DEFAULT '', lease_until REAL NOT NULL DEFAULT 0, UNIQUE(actor,idempotency_key)); +CREATE TABLE IF NOT EXISTS audit(id INTEGER PRIMARY KEY, actor TEXT NOT NULL, action TEXT NOT NULL, repo_id TEXT NOT NULL DEFAULT '', rfc_id TEXT NOT NULL DEFAULT '', detail TEXT NOT NULL DEFAULT '{}', created REAL NOT NULL); +CREATE INDEX IF NOT EXISTS operations_pending ON operations(status,lease_until,created); +""" + + +def encode(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + + +class Store: + def __init__(self, state_dir: str | Path): + self.root = Path(state_dir).expanduser().resolve() + self.root.mkdir(parents=True, exist_ok=True, mode=0o700) + self.path = self.root / "rfc.sqlite" + con = self.connect() + try: + con.execute("PRAGMA journal_mode=WAL") + con.executescript(SCHEMA) + con.execute("INSERT OR IGNORE INTO metadata VALUES ('schema_version','1')") + finally: + con.close() + try: + self.path.chmod(0o600) + except OSError: + pass + + def connect(self) -> sqlite3.Connection: + con = sqlite3.connect(self.path, timeout=15, isolation_level=None) + con.row_factory = sqlite3.Row + con.execute("PRAGMA foreign_keys=ON") + con.execute("PRAGMA busy_timeout=15000") + return con + + @contextmanager + def transaction(self) -> Iterator[sqlite3.Connection]: + con = self.connect() + try: + con.execute("BEGIN IMMEDIATE") + yield con + con.commit() + except BaseException: + con.rollback() + raise + finally: + con.close() + + def one(self, sql: str, params: tuple = ()) -> dict | None: + con = self.connect() + try: + row = con.execute(sql, params).fetchone() + return dict(row) if row else None + finally: + con.close() + + def all(self, sql: str, params: tuple = ()) -> list[dict]: + con = self.connect() + try: + return [dict(row) for row in con.execute(sql, params).fetchall()] + finally: + con.close() + + def audit(self, con: sqlite3.Connection, actor: str, action: str, now: float, + repo_id: str = "", rfc_id: str = "", detail: dict | None = None) -> None: + con.execute("INSERT INTO audit(actor,action,repo_id,rfc_id,detail,created) VALUES (?,?,?,?,?,?)", + (actor, action, repo_id, rfc_id, encode(detail or {}), now)) + + def claim(self, worker: str, now: float, lease_seconds: float = 900) -> dict | None: + with self.transaction() as con: + # Serialize operations per RFC/repository even across independent processes. + row = con.execute("""SELECT o.* FROM operations o + WHERE (o.status='pending' OR (o.status='running' AND o.lease_until=? + AND ((o.rfc_id!='' AND x.rfc_id=o.rfc_id) OR (o.rfc_id='' AND x.repo_id=o.repo_id))) + ORDER BY o.created,o.id LIMIT 1""", (now, now)).fetchone() + if not row: + return None + result = dict(row) + con.execute("UPDATE operations SET status='running',worker=?,lease_until=?,updated=? WHERE id=?", + (worker, now + lease_seconds, now, row["id"])) + return result + + def renew(self, operation_id: str, worker: str, now: float) -> bool: + with self.transaction() as con: + return bool(con.execute("UPDATE operations SET lease_until=? WHERE id=? AND worker=? AND status='running'", + (now + 900, operation_id, worker)).rowcount) diff --git a/src/infermatrix_copilot/rfc_service/web/app.js b/src/infermatrix_copilot/rfc_service/web/app.js new file mode 100644 index 00000000..86af4702 --- /dev/null +++ b/src/infermatrix_copilot/rfc_service/web/app.js @@ -0,0 +1,753 @@ +/* Same-origin RFC client. Private content is rendered as text, never HTML. */ +const $ = (id) => document.getElementById(id); +const content = $("content"); +let principal = null; +let repositories = []; +let routeSequence = 0; +let viewDirty = false; +let selectedTokenUser = ""; +let noticeTimer; +let refreshCurrent = null; +const labels = { + draft: "草稿", proposed: "待评审", discussion: "讨论中", accepted: "已接受", rejected: "已拒绝", + superseded: "已取代", planned: "待开始", ready: "就绪", active: "进行中", in_progress: "进行中", + partial: "部分实现", partially_implemented: "部分实现", blocked: "受阻", review: "评审中", + in_review: "评审中", merged: "已合并", implemented: "实现已落地", validating: "待验证", complete: "已完成", + completed: "已完成", done: "已完成", dropped: "已移除", cancelled: "已取消", unknown: "未知", + unverified: "未验证", pending: "待处理", passing: "通过", passed: "通过", failing: "未通过", failed: "失败", + waived: "已豁免", stale: "需刷新", fresh: "已更新", current: "已更新", outcome_unknown: "结果待核对", + running: "执行中", applied: "已应用", succeeded: "成功", uncertain: "结果待核对", enrolled: "已纳管", + valid_token: "有效", revoked: "已撤销", expired: "已过期", + reader: "可查看", contributor: "可贡献", maintainer: "可维护", local: "本地 Markdown", + github: "GitHub", atomgit: "AtomGit", none: "无", not_started: "尚未开始", not_enrolled: "未纳管", +}; + +function element(tag, attrs = {}, ...children) { + const node = document.createElement(tag); + for (const [key, value] of Object.entries(attrs)) { + if (key === "class") node.className = value; + else if (key === "text") node.textContent = value; + else if (key.startsWith("on") && typeof value === "function") node.addEventListener(key.slice(2), value); + else if (value !== undefined && value !== null) node.setAttribute(key, String(value)); + } + for (const child of children.flat()) { + if (child === undefined || child === null) continue; + node.append(child instanceof Node ? child : document.createTextNode(String(child))); + } + return node; +} +function statusValue(value) { + if (value && typeof value === "object") return value.state || value.status || value.verdict || "unknown"; + return value || "unknown"; +} +function translated(value) { const state = statusValue(value); return labels[state] || String(state); } +function badge(value) { + const state = statusValue(value); + const color = ["valid_token", "passing", "passed", "accepted", "applied", "succeeded", "fresh", "current", "merged"].includes(state) ? "green" + : ["failed", "failing", "rejected", "error"].includes(state) ? "red" + : ["blocked", "partial", "validating", "stale", "outcome_unknown", "unverified"].includes(state) ? "amber" + : ["active", "in_progress", "running", "discussion", "review"].includes(state) ? "blue" : ""; + return element("span", {class: `badge ${color}`}, translated(state)); +} +function readable(value) { + if (value === undefined || value === null || value === "") return "—"; + return typeof value === "object" ? JSON.stringify(value, null, 2) : String(value); +} +function dateText(value) { + if (!value) return "尚未同步"; + const date = new Date(typeof value === "number" ? value * 1000 : value); + return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString("zh-CN", {hour12: false}); +} +function notice(message, error = false) { + clearTimeout(noticeTimer); + $("notice").textContent = message; + $("notice").className = `notice${error ? " error" : ""}`; + $("notice").hidden = false; + noticeTimer = setTimeout(() => { $("notice").hidden = true; }, error ? 9000 : 5000); +} +function signedOut() { + principal = null; + repositories = []; + selectedTokenUser = ""; + routeSequence += 1; + refreshCurrent = null; + content.replaceChildren(); + $("identity-name").textContent = ""; + $("issued-secret").value = ""; + if ($("secret-dialog").open) $("secret-dialog").close(); + $("app").hidden = true; + $("login").hidden = false; +} +async function api(path, options = {}) { + const response = await fetch(path, {credentials: "same-origin", cache: "no-store", ...options, + headers: {"Accept": "application/json", ...(options.body ? {"Content-Type": "application/json"} : {}), ...options.headers}}); + let value; + try { value = await response.json(); } catch { value = {}; } + if (!response.ok) { + if (response.status === 401 && principal) { signedOut(); notice("会话已失效,请重新登录。", true); } + const error = new Error(value.error?.message || `请求失败(${response.status})`); + error.code = value.error?.code; + throw error; + } + return value; +} +const action = (name, payload = {}) => api(`/api/v1/actions/${encodeURIComponent(name)}`, {method: "POST", body: JSON.stringify(payload)}); +const getRFC = (id) => api(`/api/v1/rfcs/${encodeURIComponent(id)}`); +function formData(form) { return Object.fromEntries(new FormData(form)); } +async function busy(button, callback) { + if (button) button.disabled = true; + try { return await callback(); } catch (error) { notice(error.message, true); return null; } + finally { if (button) button.disabled = false; } +} +function button(text, callback, className = "") { + return element("button", {type: "button", class: className, onclick: (event) => busy(event.currentTarget, callback)}, text); +} +function select(name, choices, current = "", required = false) { + const node = element("select", {name, ...(required ? {required: true} : {})}); + for (const choice of choices) { + const [value, label] = Array.isArray(choice) ? choice : [choice, labels[choice] || choice]; + const option = element("option", {value}, label); + option.selected = String(value) === String(current); + node.append(option); + } + return node; +} +function input(name, placeholder = "", value = "", type = "text", required = false) { + return element("input", {name, placeholder, value, type, ...(required ? {required: true} : {})}); +} +function field(label, control, help = "", full = false) { + const id = `field-${field.counter++}`; + control.id = id; + return element("div", {class: `field${full ? " full" : ""}`}, element("label", {for: id}, label), control, + help ? element("p", {class: "help"}, help) : null); +} +field.counter = 0; +function panel(title, children, subtitle = "", actions = null) { + return element("section", {class: "panel"}, element("div", {class: "panel-title"}, element("h2", {}, title), actions), + subtitle ? element("p", {class: "panel-subtitle"}, subtitle) : null, children); +} +function heading(title, subtitle, actions = null) { + return element("div", {class: "page-heading"}, element("div", {}, element("h1", {}, title), element("p", {}, subtitle)), actions); +} +function empty(title, description) { return element("div", {class: "empty"}, element("h3", {}, title), element("p", {}, description)); } +function safeLink(url, label = "查看来源 ↗") { + try { + const value = new URL(url); + if (!["http:", "https:"].includes(value.protocol)) return null; + return element("a", {href: value.href, target: "_blank", rel: "noopener noreferrer", class: "source-link"}, label); + } catch { return null; } +} +function repoName(id) { return repositories.find((repo) => repo.id === id)?.name || id || "未指定仓库"; } +function repoChoices() { return repositories.map((repo) => [repo.id, repo.name || repo.id]); } +function canWrite(rfc) { return rfc.can_write ?? (principal.admin || ["contributor", "maintainer"].includes(rfc.role)); } +function canPublish(rfc) { return rfc.can_publish ?? (principal.admin || rfc.role === "maintainer"); } +function trackingLabel(rfc) { + if (rfc.sync_status === "requires_reauthorization") return "追踪已暂停 · 需重新授权"; + if (rfc.sync_status === "different_writer") return "由其他工作空间维护"; + return rfc.enrolled ? "已纳管,定期刷新实现事实" : "草稿或尚未纳管"; +} + +async function signedIn(identity) { + principal = identity; + $("login").hidden = true; + $("app").hidden = false; + $("identity-name").textContent = identity.name || identity.user_id; + $("admin-nav").hidden = !identity.admin; + repositories = (await api("/api/v1/repositories")).repositories || []; + await route(); +} +$("login-form").addEventListener("submit", async (event) => { + event.preventDefault(); + const button = event.currentTarget.querySelector("button"); + const token = $("login-token").value; + $("login-token").value = ""; + $("login-error").hidden = true; + button.disabled = true; + try { + const result = await api("/api/v1/session", {method: "POST", body: JSON.stringify({token})}); + await signedIn(result.principal); + } catch (error) { $("login-error").textContent = error.message; $("login-error").hidden = false; } + finally { button.disabled = false; } +}); +$("logout").addEventListener("click", (event) => busy(event.currentTarget, async () => { + await api("/api/v1/session", {method: "DELETE"}); + signedOut(); +})); +$("close-secret").addEventListener("click", () => { $("issued-secret").value = ""; $("secret-dialog").close(); }); +$("secret-dialog").addEventListener("close", () => { + $("issued-secret").value = ""; + if (principal && ["#account", "#admin"].includes(location.hash)) route(); +}); +$("copy-secret").addEventListener("click", (event) => busy(event.currentTarget, async () => { + if (navigator.clipboard) { await navigator.clipboard.writeText($("issued-secret").value); notice("已复制,请妥善保存。"); } + else { $("issued-secret").select(); notice("请手动复制已选中的令牌。"); } +})); +content.addEventListener("input", () => { viewDirty = true; }); +content.addEventListener("change", () => { viewDirty = true; }); +window.addEventListener("hashchange", () => route()); + +async function route() { + if (!principal) return; + const sequence = ++routeSequence; + viewDirty = false; + refreshCurrent = null; + const hash = location.hash.slice(1) || "dashboard"; + const [page, id] = hash.split("/"); + const active = page === "rfc" ? "dashboard" : page === "import" ? "draft" : page; + for (const link of document.querySelectorAll("[data-nav]")) link.classList.toggle("active", link.dataset.nav === active); + $("breadcrumb").textContent = `工作空间 / ${{dashboard: "进展总览", draft: "创建 RFC", import: "纳管已有 RFC", rfc: "RFC 详情", operations: "操作记录", account: "个人令牌", admin: "团队与权限"}[page] || "进展总览"}`; + content.replaceChildren(element("div", {class: "loading"}, "正在读取工作空间…")); + try { + let view; + if (page === "draft") view = draftView(); + else if (page === "import") view = importView(); + else if (page === "rfc" && id) view = detailView(await getRFC(decodeURIComponent(id))); + else if (page === "operations") view = operationsView(await api("/api/v1/operations")); + else if (page === "account") view = await accountView(); + else if (page === "admin" && principal.admin) view = await adminView(); + else view = dashboardView(await api("/api/v1/rfcs")); + if (sequence === routeSequence && principal) content.replaceChildren(view); + } catch (error) { + if (sequence === routeSequence && principal) content.replaceChildren(element("div", {class: "error-panel"}, error.message), + element("div", {class: "actions"}, button("重新读取", route))); + } +} + +function dashboardView(data) { + const list = data.rfcs || []; + const wrapper = element("div"); + wrapper.append(heading("每个 RFC,都有下一步", "查看团队的计划、实现进展和等待验证的结果。", + element("div", {class: "actions"}, button("刷新", route), element("a", {href: "#import", class: "badge blue"}, "纳管已有 RFC"), element("a", {href: "#draft", class: "primary badge"}, "+ 创建 RFC")))); + const accepted = list.filter((rfc) => ["accepted", "passed", "passing"].includes(statusValue(rfc.acceptance))).length; + const validating = list.filter((rfc) => ["validating", "implemented", "merged", "partial"].includes(statusValue(rfc.implementation)) && !["accepted", "passed", "passing"].includes(statusValue(rfc.acceptance))).length; + wrapper.append(element("div", {class: "stats"}, ...[["可访问 RFC", list.length], ["等待验证", validating], ["已验收", accepted]].map(([name, count]) => element("div", {class: "stat"}, element("span", {}, name), element("strong", {}, count))))); + const search = input("search", "搜索 RFC 标题或编号"); + const repository = select("repository", [["", "所有可访问仓库"], ...repoChoices()]); + const count = element("span", {class: "count"}); + const grid = element("div", {class: "rfc-grid"}); + function draw() { + const matches = list.filter((rfc) => (!repository.value || repository.value === rfc.repo_id) + && `${rfc.title} ${rfc.id}`.toLowerCase().includes(search.value.toLowerCase())); + count.textContent = `${matches.length} 个 RFC`; + grid.replaceChildren(...matches.map((rfc) => { + const next = Array.isArray(rfc.next_actions) ? rfc.next_actions[0] : rfc.next_actions; + return element("article", {class: "rfc-card"}, + element("div", {class: "card-meta"}, element("span", {}, repoName(rfc.repo_id)), badge(rfc.state || "draft")), + element("h3", {}, element("a", {href: `#rfc/${encodeURIComponent(rfc.id)}`}, rfc.title || "未命名 RFC")), + element("div", {class: "status-row"}, "实现", badge(rfc.implementation || "not_started"), "验收", badge(rfc.acceptance || "unverified")), + element("p", {class: "small muted"}, next ? readable(next) : rfc.enrolled ? "纳管后按来源持续刷新。" : "预览方案后发布,并启用进展追踪。"), + element("div", {class: "card-bottom"}, element("span", {}, rfc.enrolled ? trackingLabel(rfc) : "○ 尚未纳管"), element("span", {}, `${rfc.features?.length || 0} 个工作项`))); + })); + if (!matches.length) grid.append(empty(list.length ? "没有匹配的 RFC" : "从第一个 RFC 开始", list.length ? "调整搜索条件或仓库范围。" : "选择一个仓库,写下问题和目标,生成可评审的方案。")); + } + search.addEventListener("input", draw); + repository.addEventListener("change", draw); + draw(); + wrapper.append(element("div", {class: "toolbar"}, search, repository, count), grid); + refreshCurrent = () => route(); + return wrapper; +} + +function draftView() { + const wrapper = element("div"); + wrapper.append(heading("先写清问题,再开始推进", "生成草稿、检查方案,然后由你明确发布并纳管。", element("a", {href: "#import"}, "已有 RFC?导入并纳管 →"))); + const eligible = repositories.filter((repo) => principal.admin || ["contributor", "maintainer"].includes(repo.role)); + if (!eligible.length) { + wrapper.append(empty("还没有可贡献的仓库", principal.admin ? "先在团队与权限中登记仓库,再开始创建 RFC。" : "请管理员为你授予仓库贡献权限。")); + return wrapper; + } + const form = element("form"); + const body = element("textarea", {name: "body", placeholder: "可选:已有 RFC 正文。原始文本会被保留。", rows: 7}); + const submit = element("button", {type: "submit", class: "primary"}, "生成 RFC 草稿"); + form.append(element("div", {class: "form-grid"}, field("目标仓库", select("repo_id", eligible.map((repo) => [repo.id, repo.name]), eligible[0].id, true), "仅显示你有权限贡献的仓库。", true), + field("RFC 标题", input("title", "例如:为模型服务增加可追踪的验收流程", "", "text", true), "用一句话描述具体变化。", true), + field("问题与期望结果", element("textarea", {name: "goal", rows: 5, placeholder: "谁遇到了什么问题?完成后应该有哪些可观察的变化?"}), "草稿会保留未知问题,供评审时补充。", true), + field("自动追踪范围", input("scope", "例如:调度、流式返回、延迟验证"), "自动关联限定在这个范围;模糊关联进入建议列表。", true), + field("已有正文", body, "支持 Markdown;预览以安全的纯文本展示。", true)), element("div", {class: "actions"}, submit)); + const preview = element("div", {class: "preview-placeholder"}, "你的方案预览会出现在这里。\n先描述问题、目标和范围。"); + const right = panel("方案预览", preview, "草稿保存在工作空间;发布需要你的明确操作。"); + form.addEventListener("submit", (event) => { + event.preventDefault(); + busy(submit, async () => { + const data = formData(form); + if (!data.body.trim()) delete data.body; + const result = await action("rfcs.draft", data); + preview.replaceChildren(); + preview.className = "stack"; + preview.append(element("h3", {}, result.title), element("pre", {class: "spec"}, result.body || "暂无正文"), + element("div", {class: "status-row"}, badge("draft"), "已保存 · 尚未发布"), + element("a", {href: `#rfc/${encodeURIComponent(result.id)}`}, "查看完整 RFC,并发布或纳管 →")); + viewDirty = false; + notice("RFC 草稿已保存。请检查方案和验收条件。"); + }); + }); + wrapper.append(element("div", {class: "draft-layout"}, panel("描述你的想法", form), right)); + return wrapper; +} + +function importView() { + const wrapper = element("div"); + wrapper.append(heading("保留原文,接入进展追踪", "先读取并预览已有 RFC,再明确纳管。GitHub、AtomGit 和本地 Markdown 均可接入。")); + const eligible = repositories.filter((repo) => principal.admin || repo.role === "maintainer"); + if (!eligible.length) { + wrapper.append(empty("需要仓库维护权限", "请管理员为你授予对应仓库的 maintainer 权限。")); + return wrapper; + } + const form = element("form", {class: "stack"}); + const repository = select("repo_id", eligible.map((repo) => [repo.id, repo.name]), eligible[0].id); + const kind = select("kind", [["issue", "Issue / RFC"], ["pr", "Pull Request"], ["file", "Markdown 文件"]]); + const identifier = input("identifier", "GitHub 编号或 AtomGit 字符串 ID"); + const path = input("path", "相对仓库根目录的文件路径,例如 doc/RFC.md"); + const url = input("url", "可选:已有 RFC 的 HTTPS 链接"); + const scope = input("scope", "限定自动发现的关键词、功能或标签"); + const auto = element("input", {type: "checkbox", name: "auto_add"}); auto.checked = true; + const submit = element("button", {type: "submit", class: "primary"}, "读取来源并预览"); + form.append(field("目标仓库", repository), field("来源类型", kind), field("来源编号", identifier), field("来源链接", url), field("本地文件", path), field("追踪范围", scope, + "原始正文保持完整,进展和关联保存在独立追踪记录中。"), element("label", {class: "check-label"}, auto, "在范围内自动关联明确的实现证据"), submit); + const preview = element("div", {class: "preview-placeholder"}, "读取已有 RFC 后,在这里核对原始内容。\n预览不会修改来源。"); + let reviewed = null; + let saved = null; + function sourceFields() { + const repo = eligible.find((value) => value.id === repository.value); + const local = repo.provider === "local"; + if (local) kind.value = "file"; + else if (kind.value === "file") kind.value = "issue"; + kind.disabled = local; + path.disabled = !local; identifier.disabled = local; url.disabled = local; + } + repository.addEventListener("change", sourceFields); sourceFields(); + form.addEventListener("input", () => { reviewed = null; saved = null; preview.replaceChildren("来源或范围已调整,请重新预览。"); preview.className = "preview-placeholder"; }); + form.addEventListener("change", () => { reviewed = null; saved = null; preview.replaceChildren("来源或范围已调整,请重新预览。"); preview.className = "preview-placeholder"; }); + form.addEventListener("submit", (event) => { + event.preventDefault(); + busy(submit, async () => { + const repo = eligible.find((value) => value.id === repository.value); + const source = {provider: repo.provider, repository: repo.external_name || repo.name, kind: kind.value}; + if (repo.provider === "local") source.path = path.value.trim(); + else { + source.identifier = identifier.value.trim(); + if (url.value.trim()) { + let parsed; + try { parsed = new URL(url.value.trim()); } catch { throw new Error("请输入完整的 HTTPS 来源链接。"); } + if (parsed.protocol !== "https:") throw new Error("来源链接需要使用 HTTPS。"); + source.url = parsed.href; source.host = parsed.hostname; + if (!source.identifier) source.identifier = decodeURIComponent(parsed.pathname.split("/").filter(Boolean).at(-1) || ""); + } + } + reviewed = await action("sources.preview", {repo_id: repo.id, source}); + const enroll = button("明确纳管这个 RFC", async () => { + if (!reviewed) throw new Error("来源已调整,请重新预览。"); + if (!confirm("确认纳管当前预览的 RFC,并按指定范围追踪实现进展?")) return; + if (!saved) saved = await action("rfcs.draft", {repo_id: repo.id, title: reviewed.title || "导入的 RFC", body: reviewed.body, source: reviewed.source, + scope: scope.value, auto_add: auto.checked}); + await operationNotice(await action("rfcs.enroll", {rfc_id: saved.id, content_digest: saved.content_digest, expected_revision: saved.revision, + idempotency_key: crypto.randomUUID()})); + location.hash = `rfc/${encodeURIComponent(saved.id)}`; + }, "primary"); + preview.className = "stack"; + preview.replaceChildren(...[element("h3", {}, reviewed.title || "已有 RFC"), safeLink(reviewed.source?.url), element("pre", {class: "spec"}, reviewed.body || "暂无正文"), + element("p", {class: "small muted"}, "纳管前会再次核对来源,期间发生的修改需要重新预览。"), enroll].filter(Boolean)); + }); + }); + wrapper.append(element("div", {class: "draft-layout"}, panel("选择已有来源", form), panel("原文预览", preview))); + return wrapper; +} + +async function operationNotice(result) { + const operation = result.operation || result; + const id = result.operation_id || operation.id; + if (!id) { notice("操作已完成。"); return; } + notice(`操作已进入队列:${id}。可在操作记录中查看结果。`); +} +function dependencyGraph(features) { + if (!features.length) return empty("没有依赖关系", "添加工作项后,依赖图会自动生成。"); + const NS = "http://www.w3.org/2000/svg"; + const svgNode = (tag, attributes = {}, text = "") => { + const node = document.createElementNS(NS, tag); + for (const [key, value] of Object.entries(attributes)) node.setAttribute(key, value); + if (text) node.textContent = text; + return node; + }; + const map = new Map(features.map((feature) => [feature.id, feature])); + const depths = new Map(); + function depth(id, visiting = new Set()) { + if (depths.has(id)) return depths.get(id); + if (visiting.has(id)) return 0; + const next = new Set(visiting).add(id); + const dependencies = map.get(id)?.depends_on || []; + const result = dependencies.filter((dependency) => map.has(dependency)).reduce((max, dependency) => Math.max(max, depth(dependency, next) + 1), 0); + depths.set(id, Math.min(result, 12)); + return depths.get(id); + } + features.forEach((feature) => depth(feature.id)); + const columns = new Map(); + const positions = new Map(); + for (const feature of features) { + const column = depths.get(feature.id); + const row = columns.get(column) || 0; + positions.set(feature.id, {x: 25 + column * 225, y: 25 + row * 100}); + columns.set(column, row + 1); + } + const width = Math.max(420, (Math.max(...columns.keys()) + 1) * 225 + 25); + const height = Math.max(130, Math.max(...columns.values()) * 100 + 25); + const svg = svgNode("svg", {viewBox: `0 0 ${width} ${height}`, width, height, role: "img", "aria-label": "工作项依赖图:箭头由前置工作指向依赖它的工作"}); + const defs = svgNode("defs"); + const marker = svgNode("marker", {id: "dependency-arrow", viewBox: "0 0 10 10", refX: "9", refY: "5", markerWidth: "6", markerHeight: "6", orient: "auto-start-reverse"}); + marker.append(svgNode("path", {d: "M 0 0 L 10 5 L 0 10 z", class: "graph-arrow"})); + defs.append(marker); svg.append(defs); + for (const feature of features) for (const dependency of feature.depends_on || []) { + const from = positions.get(dependency), to = positions.get(feature.id); + if (!from || !to) continue; + const x1 = from.x + 190, y1 = from.y + 31, x2 = to.x, y2 = to.y + 31; + svg.append(svgNode("path", {d: `M ${x1} ${y1} C ${x1 + 22} ${y1}, ${x2 - 22} ${y2}, ${x2} ${y2}`, class: "graph-edge", "marker-end": "url(#dependency-arrow)"})); + } + for (const feature of features) { + const {x, y} = positions.get(feature.id); + const group = svgNode("g"); + const state = feature.state || "planned"; + group.append(svgNode("title", {}, `${feature.id}: ${feature.title}`), svgNode("rect", {x, y, width: 190, height: 64, rx: 8, class: `graph-node ${["accepted", "blocked", "active"].includes(state) ? state : ""}`}), + svgNode("text", {x: x + 13, y: y + 24, class: "graph-text"}, String(feature.title || feature.id).slice(0, 15)), + svgNode("text", {x: x + 13, y: y + 46, class: "graph-subtext"}, `${feature.id} · ${translated(state)}`)); + svg.append(group); + } + return element("div", {class: "graph"}, svg); +} + +function detailView(rfc) { + const wrapper = element("div"); + const source = rfc.source || {}; + const fresh = rfc.freshness || {}; + const actions = element("div", {class: "actions"}, button("刷新", route)); + if (canPublish(rfc) && rfc.enrolled) actions.append(button("同步来源", async () => { await operationNotice(await action("rfcs.sync", {rfc_id: rfc.id})); await route(); })); + actions.append(button("导出", async () => { + const data = await api(`/api/v1/rfcs/${encodeURIComponent(rfc.id)}/export`); + const url = URL.createObjectURL(new Blob([JSON.stringify(data, null, 2)], {type: "application/json"})); + const link = element("a", {href: url, download: `rfc-${rfc.id}.json`}); + link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); + })); + wrapper.append(heading(rfc.title || "未命名 RFC", `${repoName(rfc.repo_id)} · ${rfc.id}`, actions)); + if (rfc.sync_status === "requires_reauthorization") { + const warning = element("div", {class: "warning"}, "自动追踪已暂停:原授权已失效,需要维护者重新授权后才能继续同步。 "); + if (canPublish(rfc)) warning.append(button("重新授权追踪", async () => { + if (!confirm("确认使用当前个人授权,继续追踪这个 RFC?")) return; + await operationNotice(await action("rfcs.enroll", {rfc_id: rfc.id, content_digest: rfc.content_digest, expected_revision: rfc.revision})); await route(); + })); + wrapper.append(warning); + } else if (rfc.sync_status === "different_writer") { + wrapper.append(element("div", {class: "warning"}, "这个 RFC 由另一个工作空间维护。当前工作空间可以查看记录;请在负责维护的工作空间进行同步。")); + } + if (fresh.state === "stale" || fresh.error || fresh.stale) wrapper.append(element("div", {class: "warning"}, "来源状态尚未确认,请谨慎使用当前进展。", fresh.error ? ` ${readable(fresh.error)}` : "")); + wrapper.append(element("div", {class: "detail-summary"}, + element("div", {}, element("label", {}, "RFC 决策"), badge(rfc.state || "draft"), element("p", {class: "freshness"}, trackingLabel(rfc))), + element("div", {}, element("label", {}, "实现进展"), badge(rfc.implementation || "not_started"), element("p", {class: "freshness"}, "PR 合并表示代码已落地。")), + element("div", {}, element("label", {}, "验收结论"), badge(rfc.acceptance || "unverified"), element("p", {class: "freshness"}, "验收依据单独记录的验证证据。")))); + const publish = element("div", {class: "actions"}); + if (canPublish(rfc) && !source.provider && !rfc.published) publish.append(button("明确发布并纳管", async () => { + if (!confirm("确认把当前预览内容发布到所选仓库,并启用进展追踪?")) return; + const result = await action("rfcs.publish", {rfc_id: rfc.id, content_digest: rfc.content_digest, expected_revision: rfc.revision, post: true, idempotency_key: crypto.randomUUID()}); + await operationNotice(result); await route(); + }, "primary")); + if (canPublish(rfc) && source.provider && !rfc.enrolled) publish.append(button("启用进展追踪", async () => { + if (!confirm("确认纳管这个 RFC,并在设定范围内自动关联实现证据?")) return; + await operationNotice(await action("rfcs.enroll", {rfc_id: rfc.id, content_digest: rfc.content_digest, expected_revision: rfc.revision})); await route(); + })); + const metadata = element("div", {class: "status-row"}, safeLink(source.url), element("span", {}, `版本 ${String(rfc.revision || 1).slice(0, 10)}`), element("span", {}, `最后核验:${dateText(fresh.verification || fresh.last_verified_at || fresh.last_verified || fresh.checked_at)}`)); + const body = element("pre", {class: "spec"}, rfc.body || "暂无正文。"); + const editing = element("details", {}, element("summary", {}, "编辑方案正文")); + const editForm = element("form", {class: "stack"}); + editForm.append(field("标题", input("title", "RFC 标题", rfc.title || "", "text", true)), field("RFC 正文", element("textarea", {name: "body", rows: 13}, rfc.body || "")), + rfc.enrolled ? field("修改原因", input("reason", "说明方案变更及需要重新验证的部分", "", "text", true)) : null, element("button", {type: "submit", class: "primary"}, "保存新版本")); + editForm.addEventListener("submit", (event) => { + event.preventDefault(); + busy(editForm.querySelector("button"), async () => { + await action("rfcs.update", {...formData(editForm), rfc_id: rfc.id, expected_revision: rfc.revision}); + notice("新版本已保存。"); await route(); + }); + }); + editing.append(editForm); + wrapper.append(panel("方案与范围", element("div", {class: "stack"}, metadata, body, publish, canWrite(rfc) && (!rfc.enrolled || canPublish(rfc)) ? editing : null))); + const features = rfc.features || []; + wrapper.append(panel("依赖关系", dependencyGraph(features.filter((feature) => !feature.dropped)), "箭头从前置工作指向依赖它的工作。验收状态在下方独立记录。")); + const left = element("div"); + left.append(workPanel(rfc)); + const right = element("div"); + right.append(criteriaPanel(rfc), suggestionsPanel(rfc), canPublish(rfc) ? decisionPanel(rfc) : null); + wrapper.append(element("div", {class: "split"}, left, right)); + if (principal.admin) wrapper.append(rfcAccessPanel(rfc)); + refreshCurrent = async () => { + const latest = await getRFC(rfc.id); + if (viewDirty) return; + if (location.hash === `#rfc/${encodeURIComponent(rfc.id)}`) content.replaceChildren(detailView(latest)); + }; + return wrapper; +} + +function workPanel(rfc) { + const list = element("div", {class: "item-list"}); + for (const feature of rfc.features || []) { + const links = element("div", {class: "item-detail"}); + for (const link of feature.links || []) { + const url = typeof link === "string" ? link : link.url; + const label = typeof link === "string" ? link : `${link.identifier || link.id || "实现来源"} · ${translated(link.state || "unknown")}`; + const anchor = safeLink(url, label); + links.append(anchor || element("span", {}, readable(link)), element("br")); + } + const operations = element("div", {class: "item-actions"}); + const update = async (values) => { await action("rfcs.work", {rfc_id: rfc.id, op: "update", feature_id: feature.id, feature: values}); await route(); }; + if (!feature.dropped && !feature.owner) operations.append(button("认领", async () => { + await action("rfcs.work", {rfc_id: rfc.id, op: "claim", feature_id: feature.id, reason: "工作台自主认领"}); await route(); + })); + else if (!feature.dropped && canPublish(rfc)) operations.append(button("更改负责人", async () => { + const owner = prompt("负责人名称或用户 ID", feature.owner); + if (owner === null || !owner.trim()) return; + const reason = prompt("负责人调整的依据"); + if (reason !== null && reason.trim()) { + await action("rfcs.work", {rfc_id: rfc.id, op: "update", feature_id: feature.id, feature: {owner: owner.trim()}, reason}); await route(); + } + })); + if (!feature.dropped) { + const state = select("work_state", ["planned", "in_progress", "implemented"], feature.state || "planned"); + state.setAttribute("aria-label", `${feature.title || feature.id} 的工作状态`); + operations.append(state, button("记录工作状态", () => update({state: state.value}))); + } + if (canPublish(rfc)) operations.append(button(feature.dropped ? "恢复" : "移除", async () => { + const reason = prompt(feature.dropped ? "恢复原因" : "移除原因(会保留历史)"); + if (reason === null || !reason.trim()) return; + await action("rfcs.work", {rfc_id: rfc.id, op: feature.dropped ? "restore" : "drop", feature_id: feature.id, reason}); await route(); + }, feature.dropped ? "" : "danger")); + const historical = historicalClaims(rfc, feature); + list.append(element("article", {class: "work-item"}, element("div", {class: "item-heading"}, element("h4", {}, feature.title || feature.id), badge(feature.dropped ? "dropped" : feature.implementation || feature.state || "planned")), + element("div", {class: "item-detail"}, `${feature.id} · 负责人:${feature.owner || "待认领"}`, element("br"), `依赖:${(feature.depends_on || []).join("、") || "无"}`), historical, links, canWrite(rfc) ? operations : null)); + } + if (!(rfc.features || []).length) list.append(empty("还没有工作项", "把方案拆分成有负责人和依赖的工作。")); + const form = element("form", {class: "inline-form"}); + form.append(element("h4", {}, "添加工作项"), element("div", {class: "form-grid"}, + field("编号", input("id", "例如 E1", "", "text", true)), field("标题", input("title", "具体工作", "", "text", true)), + field("负责人", input("owner", "用户 ID 或名称")), field("依赖编号", input("depends_on", "逗号分隔,例如 E1,E2")), + field("实现链接", input("link", "PR 或实现来源 URL"), "明确的实现链接用于刷新进展。", true)), element("div", {class: "actions"}, element("button", {type: "submit"}, "添加工作项"))); + form.addEventListener("submit", (event) => { + event.preventDefault(); + busy(form.querySelector("button"), async () => { + const data = formData(form); + const feature = {id: data.id.trim(), title: data.title.trim(), owner: data.owner.trim(), depends_on: data.depends_on.split(/[,,]/).map((id) => id.trim()).filter(Boolean), state: "planned", links: data.link.trim() ? [data.link.trim()] : []}; + await action("rfcs.work", {rfc_id: rfc.id, op: "add", feature}); await route(); + }); + }); + return panel("工作项与负责人", element("div", {}, list, canPublish(rfc) ? form : null)); +} + +function historicalClaims(rfc, feature) { + const claims = [...(feature.historical_claims || []), ...(rfc.historical_claims || []).filter((claim) => (claim.feature || claim.feature_id) === feature.id)]; + if (!claims.length) return null; + const wrapper = element("div", {class: "item-detail historical-claims"}, element("span", {class: "badge amber"}, "历史认领(待确认身份)")); + const seen = new Set(); + for (const claim of claims) { + const key = claim.id || `${claim.login || claim.name}:${claim.created_at || ""}`; + if (seen.has(key)) continue; + seen.add(key); + wrapper.append(element("div", {}, claim.login || claim.name || "历史记录", claim.note ? ` · ${claim.note}` : "")); + } + return wrapper; +} + +function evidenceLines(evidence) { + const wrapper = element("div", {class: "item-detail"}); + const records = Array.isArray(evidence) ? evidence : evidence ? [evidence] : []; + for (const record of records) { + if (typeof record === "string") wrapper.append(safeLink(record, record) || element("span", {}, record)); + else wrapper.append(safeLink(record.url || record.source?.url, record.title || record.url || "证据来源 ↗"), + element("span", {}, record.note || record.summary || record.reason || readable(record)), + record.stale ? badge("stale") : null); + } + if (!records.length) wrapper.append("尚未记录验证证据。"); + return wrapper; +} +function criteriaPanel(rfc) { + const list = element("div", {class: "item-list"}); + for (const criterion of rfc.criteria || []) { + const form = element("form", {class: "inline-form"}); + form.append(element("div", {class: "form-grid"}, field("验证结论", select("verdict", ["pending", "passing", "failing", "waived"], criterion.verdict || "pending")), + field("证据链接", input("url", "测试、报告或讨论链接")), field("验证版本", input("verification_revision", "例如:实现提交 SHA 或发布版本"), "填写实际接受验证的实现版本。", true), field("验证环境", input("environment", "例如:Linux · GPU 型号 · 模型版本"), "通过时需要记录验证环境。", true), + field("验证依据", input("reason", "说明结果或豁免理由"), "验收证据将关联当前 RFC 版本。", true)), element("button", {type: "submit"}, "记录验收结论")); + form.addEventListener("submit", (event) => { + event.preventDefault(); + busy(form.querySelector("button"), async () => { + const data = formData(form); + const payload = {rfc_id: rfc.id, kind: "criterion", criterion_id: criterion.id, verdict: data.verdict, reason: data.reason}; + if (data.url || data.verification_revision || data.environment) payload.evidence = {url: data.url, note: data.reason, revision: data.verification_revision, environment: data.environment}; + await action("rfcs.decision", payload); await route(); + }); + }); + const details = element("details", {}, element("summary", {}, "更新验收"), form); + list.append(element("article", {class: "criterion"}, element("div", {class: "item-heading"}, element("h4", {}, criterion.title || criterion.id), badge(criterion.verdict || "pending")), + evidenceLines(criterion.evidence), canPublish(rfc) ? details : null)); + } + if (!(rfc.criteria || []).length) list.append(empty("验收条件待补充", "编辑 RFC 时明确可验证的条件与证据。")); + return panel("验收条件", list, "PR 合并不会自动通过验收。"); +} +function suggestionsPanel(rfc) { + const list = element("div", {class: "item-list"}); + const suggestions = (rfc.suggestions || []).filter((suggestion) => !["accepted", "rejected", "obsolete", "applied"].includes(suggestion.status || suggestion.state || suggestion.verdict)); + for (const suggestion of suggestions) { + list.append(element("article", {class: "suggestion"}, element("h4", {}, suggestion.title || suggestion.feature?.title || "待确认的关联"), + element("p", {class: "small muted"}, suggestion.reason || suggestion.explanation || "请核对这个实现是否属于当前 RFC。"), evidenceLines(suggestion.evidence), + canPublish(rfc) ? element("div", {class: "item-actions"}, ...["accepted", "rejected"].map((verdict) => button(verdict === "accepted" ? "接受建议" : "拒绝", async () => { + const reason = prompt(verdict === "accepted" ? "接受依据" : "拒绝原因(相同建议不会重复出现)"); + if (reason === null || !reason.trim()) return; + await action("rfcs.decision", {rfc_id: rfc.id, kind: "suggestion", suggestion_id: suggestion.id, verdict, reason}); await route(); + }, verdict === "rejected" ? "danger" : ""))) : null)); + } + if (!suggestions.length) list.append(element("p", {class: "small muted"}, "暂无需要确认的建议。明确关联在范围内自动更新,模糊关联留待你判断。")); + return panel("待确认建议", list); +} +function decisionPanel(rfc) { + const form = element("form", {class: "stack"}); + form.append(field("RFC 决策", select("verdict", ["draft", "discussion", "accepted", "rejected", "superseded"], rfc.state || "draft")), field("决策依据", input("reason", "记录评审结论或后续 RFC")), element("button", {type: "submit"}, "记录 RFC 决策")); + form.addEventListener("submit", (event) => { + event.preventDefault(); + busy(form.querySelector("button"), async () => { await action("rfcs.decision", {rfc_id: rfc.id, kind: "rfc", ...formData(form)}); await route(); }); + }); + return panel("评审决策", form, "接受方案表示方向获批,验收完成单独记录。"); +} +function rfcAccessPanel(rfc) { + const form = element("form", {class: "stack"}); + const restricted = element("input", {type: "checkbox", name: "restricted"}); + restricted.checked = Boolean(rfc.restricted); + form.append(element("label", {class: "check-label"}, restricted, "仅向指定用户开放这个 RFC"), + field("RFC 授权", element("textarea", {name: "grants", rows: 3, placeholder: '{"用户 ID": "reader"}'}, JSON.stringify(rfc.grants || rfc.acl || {}, null, 2)), "角色:reader、contributor、maintainer。工作空间管理员仍可管理。"), element("button", {type: "submit"}, "保存 RFC 权限")); + form.addEventListener("submit", (event) => { + event.preventDefault(); + busy(form.querySelector("button"), async () => { + let grants; + try { grants = JSON.parse(form.elements.grants.value); } catch { throw new Error("RFC 授权必须是有效的 JSON 对象。"); } + await action("rfcs.acl", {rfc_id: rfc.id, restricted: restricted.checked, grants}); notice("RFC 权限已更新。"); await route(); + }); + }); + return panel("RFC 访问范围", form); +} + +function operationsView(data) { + const wrapper = element("div"); + wrapper.append(heading("每次操作,都有记录", "发布、纳管与同步会记录执行状态;结果不明时先核对来源。", element("div", {class: "actions"}, button("刷新", route)))); + const operations = data.operations || []; + if (!operations.length) { wrapper.append(empty("暂无操作", "发布、纳管或同步 RFC 后,执行结果会显示在这里。")); return wrapper; } + const table = element("table", {}, element("thead", {}, element("tr", {}, ...["操作", "RFC", "状态", "时间", "结果"].map((name) => element("th", {}, name))))); + const body = element("tbody"); + for (const operation of operations) { + const state = operation.state || operation.status; + const recovery = ["failed", "uncertain"].includes(state) && (principal.admin || repositories.find((repo) => repo.id === operation.repo_id)?.role === "maintainer") + ? button("核对并重试", async () => { + if (!confirm("重试会先核对已有结果,避免重复发布。确认恢复这个操作?")) return; + await action("operations.retry", {operation_id: operation.id}); notice("恢复操作已进入队列。"); await route(); + }) : null; + body.append(element("tr", {}, element("td", {}, operation.action || operation.kind || "操作", element("div", {class: "mono muted"}, operation.id)), + element("td", {}, operation.rfc_id ? element("a", {href: `#rfc/${encodeURIComponent(operation.rfc_id)}`}, operation.rfc_id) : "—"), element("td", {}, badge(state)), + element("td", {}, dateText(operation.updated || operation.updated_at || operation.created || operation.created_at)), element("td", {}, operation.error || readable(operation.result), recovery))); + } + table.append(body); + wrapper.append(panel("执行记录", element("div", {class: "table-wrap"}, table))); + refreshCurrent = () => route(); + return wrapper; +} + +async function accountView() { + const result = await api(`/api/v1/tokens?user_id=${encodeURIComponent(principal.user_id)}`); + const wrapper = element("div"); + wrapper.append(heading("管理你的个人令牌", "为 Copilot 和命令行签发令牌,或撤销不再使用的访问凭据。", element("div", {class: "actions"}, button("刷新", route))), + tokensPanel(result.tokens || [], [[principal.user_id, principal.name || principal.user_id]], true)); + return wrapper; +} + +async function adminView() { + selectedTokenUser ||= principal.user_id; + const [usersResult, tokensResult, settings] = await Promise.all([api("/api/v1/users"), api(`/api/v1/tokens?user_id=${encodeURIComponent(selectedTokenUser)}`), action("service.settings")]); + const users = usersResult.users || []; + const tokens = tokensResult.tokens || []; + const wrapper = element("div"); + wrapper.append(heading("团队在明确的范围内协作", "为具体用户签发令牌,并授予仓库或 RFC 的访问权限。", element("div", {class: "actions"}, button("刷新", route)))); + const userForm = element("form", {class: "stack"}); + const admin = element("input", {type: "checkbox", name: "admin"}); + userForm.append(field("姓名", input("name", "用户姓名", "", "text", true)), element("label", {class: "check-label"}, admin, "工作空间管理员"), element("button", {type: "submit"}, "添加用户")); + userForm.addEventListener("submit", (event) => { event.preventDefault(); busy(userForm.querySelector("button"), async () => { + await action("users.create", {name: userForm.elements.name.value, admin: admin.checked}); notice("用户已创建。"); await route(); + }); }); + const userTable = element("table", {}, element("thead", {}, element("tr", {}, ...["用户", "角色", "状态", "操作"].map((name) => element("th", {}, name))))); + const userBody = element("tbody"); + for (const user of users) userBody.append(element("tr", {}, element("td", {}, user.name, element("div", {class: "mono muted"}, user.id || user.user_id)), + element("td", {}, user.admin ? "管理员" : "成员"), element("td", {}, user.enabled === false || user.enabled === 0 ? "已停用" : "有效"), element("td", {}, + button(user.enabled === false || user.enabled === 0 ? "启用" : "停用", async () => { + if (!confirm(`确认更改 ${user.name} 的访问状态?`)) return; + await action("users.update", {user_id: user.id || user.user_id, enabled: user.enabled === false || user.enabled === 0}); await route(); + }, "danger")))); + userTable.append(userBody); + const userChoices = users.map((user) => [user.id || user.user_id, user.name]); + const repoForm = element("form", {class: "stack"}); + repoForm.append(field("仓库名称", input("name", "团队可识别的名称", "", "text", true)), field("来源", select("provider", ["github", "atomgit", ["local", "本地 Git / Markdown"]])), + field("外部仓库", input("external_name", "owner/repository 或 AtomGit 项目标识")), field("本地根目录", input("root", "本地来源:服务可访问且已授权的目录")), + element("button", {type: "submit"}, "登记仓库")); + repoForm.addEventListener("submit", (event) => { event.preventDefault(); busy(repoForm.querySelector("button"), async () => { + const payload = formData(repoForm); if (!payload.external_name.trim()) delete payload.external_name; if (!payload.root.trim()) delete payload.root; + await action("repositories.create", payload); repositories = (await api("/api/v1/repositories")).repositories || []; notice("仓库已登记。"); await route(); + }); }); + const grantForm = element("form", {class: "stack"}); + grantForm.append(field("用户", select("user_id", userChoices)), field("仓库", select("repo_id", repoChoices())), field("权限", select("role", ["reader", "contributor", "maintainer", ["none", "撤销授权"]])), element("button", {type: "submit"}, "保存仓库授权")); + grantForm.addEventListener("submit", (event) => { event.preventDefault(); busy(grantForm.querySelector("button"), async () => { + const payload = formData(grantForm); if (payload.role === "none") payload.role = ""; + await action("grants.set", payload); notice("仓库授权已更新。"); + }); }); + wrapper.append(panel("用户", element("div", {class: "table-wrap"}, userTable))); + wrapper.append(element("div", {class: "admin-grid"}, panel("添加成员", userForm), panel("仓库访问范围", grantForm), panel("登记仓库", repoForm), tokensPanel(tokens, userChoices))); + wrapper.append(serviceSettingsPanel(settings)); + return wrapper; +} + +function serviceSettingsPanel(settings) { + const form = element("form", {class: "stack"}); + const interval = input("sync_seconds", "3600", settings.sync_seconds ?? 3600, "number", true); + interval.min = "60"; interval.max = "86400"; interval.step = "1"; + const additions = input("default_max_auto_additions", "5", settings.default_max_auto_additions ?? 5, "number", true); + additions.min = "0"; additions.max = "100"; additions.step = "1"; + form.append(element("div", {class: "form-grid"}, field("同步间隔(秒)", interval, "60–86400 秒;后台按这个间隔检查已纳管的 RFC。"), + field("新 RFC 默认自动新增上限", additions, "0–100 项;已有 RFC 的范围与上限保持各自设置。")), + element("div", {class: "status-row"}, element("span", {}, "成员访问:仅限管理员授权"), + element("span", {}, `已配置来源:${(settings.configured_providers || []).map((name) => labels[name] || name).join("、") || "暂无"}`)), + element("div", {class: "actions"}, element("button", {type: "submit", class: "primary"}, "保存服务设置"))); + form.addEventListener("submit", (event) => { + event.preventDefault(); + busy(form.querySelector("button"), async () => { + await action("service.configure", {sync_seconds: Number(interval.value), default_max_auto_additions: Number(additions.value)}); + notice("服务设置已更新。"); await route(); + }); + }); + return panel("服务设置", form); +} + +function tokensPanel(tokens, choices, ownOnly = false) { + const list = element("div", {class: "item-list"}); + for (const token of tokens) list.append(element("div", {class: "work-item"}, element("div", {class: "item-heading"}, element("span", {class: "mono"}, token.id || token.token_id), + badge(token.revoked || token.revoked_at ? "revoked" : "valid_token")), element("p", {class: "item-detail"}, `用户:${token.user_id} · 到期:${dateText(token.expires || token.expires_at)}`), + !token.revoked && !token.revoked_at ? button("撤销令牌", async () => { + if (!confirm("撤销后,该令牌及其关联会话将失效。继续?")) return; + await action("tokens.revoke", {token_id: token.id || token.token_id}); await route(); + }, "danger") : null)); + const form = element("form", {class: "inline-form stack"}); + const user = select("user_id", choices, ownOnly ? principal.user_id : selectedTokenUser); + if (ownOnly) user.disabled = true; + else user.addEventListener("change", async () => { selectedTokenUser = user.value; await route(); }); + form.append(field("对应用户", user, ownOnly ? "此页面仅管理你自己的令牌。" : "切换用户可查看、撤销该用户的令牌。"), field("有效天数", input("expires_days", "30", "30", "number", true)), element("button", {type: "submit"}, "签发个人令牌")); + form.addEventListener("submit", (event) => { event.preventDefault(); busy(form.querySelector("button"), async () => { + const data = formData(form); + const result = await action("tokens.create", {user_id: ownOnly ? principal.user_id : data.user_id, expires_days: Number(data.expires_days)}); + $("issued-secret").value = result.token || result.secret || ""; + $("secret-dialog").showModal(); + }); }); + return panel("个人令牌", element("div", {}, list, form), "原始令牌只在创建时显示;撤销同时结束相关会话。"); +} + +setInterval(async () => { + if (!principal || document.hidden || viewDirty || $("secret-dialog").open || !refreshCurrent) return; + try { await refreshCurrent(); } catch (error) { if (principal) notice(`刷新未完成:${error.message}`, true); } +}, 30000); + +try { await signedIn(await api("/api/v1/me")); } +catch { signedOut(); } diff --git a/src/infermatrix_copilot/rfc_service/web/index.html b/src/infermatrix_copilot/rfc_service/web/index.html new file mode 100644 index 00000000..2c83f3f3 --- /dev/null +++ b/src/infermatrix_copilot/rfc_service/web/index.html @@ -0,0 +1,60 @@ + + + + + + + InferMatrix · RFC 工作台 + + + + + + + + +

保存个人令牌

+

这个令牌只显示一次。请通过私密方式交给对应用户。

+ +
+
+ + diff --git a/src/infermatrix_copilot/rfc_service/web/style.css b/src/infermatrix_copilot/rfc_service/web/style.css new file mode 100644 index 00000000..62a59452 --- /dev/null +++ b/src/infermatrix_copilot/rfc_service/web/style.css @@ -0,0 +1,5 @@ +:root{font-family:Inter,-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;color:#243246;background:#f6f8fb;font-synthesis:none;--ink:#1e2c41;--muted:#758397;--line:#e4eaf1;--blue:#3765dd;--soft:#edf2ff;--green:#238269;--amber:#a67316;--red:#b74a4a} +*{box-sizing:border-box}body{margin:0}button,input,textarea,select{font:inherit}button,a,input,textarea,select{outline-offset:4px}button,a{touch-action:manipulation}a{color:var(--blue);text-decoration:none}a:hover{text-decoration:underline}button{cursor:pointer;border:1px solid #dce3ee;border-radius:8px;background:white;color:var(--ink);padding:9px 15px;font-size:13px;font-weight:600;transition:background .15s,border-color .15s}button:hover{background:#f4f6fb;border-color:#bbc8dd}button:disabled{opacity:.5;cursor:wait}.primary{background:var(--blue);border-color:var(--blue);color:white}.primary:hover{background:#2854c6;border-color:#2854c6}.danger{color:var(--red)}.quiet{padding:5px 9px;background:transparent;border-color:transparent;color:var(--muted)}.wide{width:100%}[hidden]{display:none!important}h1,h2,h3,h4,p{margin-top:0}h1{font-size:30px;letter-spacing:-.8px;line-height:1.4}h2{font-size:21px;letter-spacing:-.3px}h3{font-size:16px}h4{font-size:13px}p{line-height:1.7}label{font-size:13px;font-weight:600;display:block;margin-bottom:7px}input,select,textarea{width:100%;border:1px solid #dce3ee;border-radius:8px;color:var(--ink);background:white;padding:11px 12px;font-size:14px}input:focus,select:focus,textarea:focus{border-color:var(--blue);outline:2px solid #d8e4ff}textarea{resize:vertical;line-height:1.6;min-height:100px}.muted{color:var(--muted)}.small{font-size:12px}.eyebrow{display:block;font-size:11px;font-weight:700;letter-spacing:1.6px;color:var(--blue);margin-bottom:15px}.wordmark{display:flex;align-items:center;gap:11px;color:var(--ink);font-size:15px;font-weight:700;letter-spacing:-.3px}.wordmark:hover{text-decoration:none}.brand-mark{display:flex;align-items:center;justify-content:center;width:34px;height:34px;background:var(--blue);color:white;border-radius:9px;font-size:11px;letter-spacing:1px}.wordmark small{display:block;font-size:10px;color:var(--muted);font-weight:500;letter-spacing:1px;margin-top:5px}.login-page{min-height:100vh;display:grid;grid-template-columns:1.2fr 1fr;align-items:center;max-width:1180px;margin:auto;padding:80px;gap:100px}.login-story .wordmark{margin-bottom:92px}.login-story h1{font-size:39px;line-height:1.5;letter-spacing:-1px;margin-bottom:22px}.login-story p{max-width:410px;color:var(--muted);font-size:15px}.journey{display:flex;align-items:center;gap:13px;font-size:11px;color:#6b7b92;margin-top:45px}.journey i{font-style:normal;color:#b7c3d5}.login-card{background:white;border:1px solid var(--line);box-shadow:0 15px 60px #24324608;padding:37px;border-radius:16px}.login-card h2{font-size:25px}.login-card form{margin:32px 0 20px}.login-card .primary{margin-top:15px;display:flex;justify-content:space-between;align-items:center;padding:13px 16px}.form-error{color:var(--red);font-size:13px;margin-top:12px;margin-bottom:0}.app-shell{display:flex;min-height:100vh}.sidebar{width:226px;position:fixed;inset:0 auto 0 0;background:white;border-right:1px solid var(--line);padding:30px 22px;display:flex;flex-direction:column}.sidebar-label{font-size:10px;text-transform:uppercase;letter-spacing:1.5px;color:#96a2b2;margin-top:51px;margin-bottom:12px;padding-left:10px}.sidebar nav{display:grid;gap:7px}.sidebar nav a{display:flex;align-items:center;gap:12px;color:#6a778b;font-size:13px;padding:12px;border-radius:8px}.sidebar nav a:hover{background:#f6f8fb;text-decoration:none}.sidebar nav a.active{background:var(--soft);color:var(--blue);font-weight:600}.sidebar nav a span{font-size:18px;width:18px;text-align:center}.sidebar-bottom{margin-top:auto;font-size:10px;color:#8897aa;display:flex;align-items:center;gap:7px;padding:20px 3px 0}.live-dot{width:6px;height:6px;display:inline-block;border-radius:50%;background:#48a58c}.workspace{margin-left:226px;flex:1;min-width:0}.topbar{height:68px;border-bottom:1px solid var(--line);display:flex;justify-content:space-between;align-items:center;padding:0 44px;background:#ffffffb3;font-size:12px;color:var(--muted)}.identity{display:flex;align-items:center;gap:14px}main{max-width:1250px;margin:auto;padding:38px 44px 55px}footer{font-size:10px;color:#97a4b6;display:flex;justify-content:space-between;max-width:1250px;margin:auto;padding:18px 44px 25px;border-top:1px solid var(--line)}.page-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:20px;margin-bottom:28px}.page-heading h1{margin:0 0 8px;font-size:27px}.page-heading p{font-size:13px;color:var(--muted);margin-bottom:0}.actions{display:flex;flex-wrap:wrap;gap:9px;align-items:center}.stats{display:grid;grid-template-columns:repeat(3,1fr);gap:15px;margin-bottom:28px}.stat{background:white;border:1px solid var(--line);border-radius:11px;padding:20px 23px}.stat span{font-size:12px;color:var(--muted)}.stat strong{display:block;font-size:27px;font-weight:600;margin-top:8px}.toolbar{display:flex;gap:12px;align-items:center;margin-bottom:18px}.toolbar input{max-width:370px;background:white}.toolbar select{max-width:240px}.toolbar .count{margin-left:auto;font-size:12px;color:var(--muted)}.rfc-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.rfc-card{padding:24px;border-radius:12px;background:white;border:1px solid var(--line);display:flex;flex-direction:column;gap:14px;transition:border-color .2s,box-shadow .2s}.rfc-card:hover{border-color:#b5c7f0;box-shadow:0 8px 25px #24324606}.rfc-card h3{font-size:17px;line-height:1.6;margin:0}.rfc-card h3 a{color:var(--ink)}.card-meta{display:flex;justify-content:space-between;align-items:center;gap:10px;color:var(--muted);font-size:11px}.status-row{display:flex;align-items:center;gap:7px;flex-wrap:wrap;font-size:11px;color:var(--muted)}.badge{font-size:10px;padding:4px 8px;border-radius:5px;background:#f0f3f7;color:#66788e;white-space:nowrap;display:inline-block}.badge.blue{background:var(--soft);color:var(--blue)}.badge.green{background:#edf8f3;color:var(--green)}.badge.amber{background:#fbf5e9;color:var(--amber)}.badge.red{background:#faeeee;color:var(--red)}.card-bottom{display:flex;justify-content:space-between;align-items:center;gap:12px;border-top:1px solid #edf1f6;padding-top:14px;font-size:11px;color:var(--muted);margin-top:auto}.empty{border:1px dashed #cfd9e7;border-radius:12px;text-align:center;padding:50px 22px;color:var(--muted);font-size:13px;grid-column:1/-1;background:#ffffff99}.empty h3{color:var(--ink);font-size:17px;margin-bottom:10px}.panel{background:white;border:1px solid var(--line);border-radius:12px;padding:25px;margin-bottom:20px}.panel-title{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:19px}.panel-title h2,.panel-title h3{margin:0}.panel-subtitle{font-size:12px;color:var(--muted);margin-top:-7px;margin-bottom:20px}.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px}.field.full{grid-column:1/-1}.field .help{font-size:11px;color:var(--muted);margin-top:7px;margin-bottom:0}.split{display:grid;grid-template-columns:1.45fr 1fr;gap:22px}.draft-layout{display:grid;grid-template-columns:1fr 1fr;gap:22px}.draft-layout .panel{height:fit-content}.spec{white-space:pre-wrap;overflow-wrap:anywhere;font-family:inherit;font-size:13px;line-height:1.9;background:#f8fafc;padding:20px;border-radius:8px;max-height:620px;overflow:auto;margin:0}.preview-placeholder{padding:45px 25px;text-align:center;font-size:13px;color:var(--muted);border:1px dashed #d8e2f0;border-radius:8px}.detail-summary{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px;margin-bottom:24px}.detail-summary>div{background:white;border:1px solid var(--line);padding:18px;border-radius:10px}.detail-summary label{color:var(--muted);font-size:11px;font-weight:400}.detail-summary strong{font-size:14px}.freshness{font-size:11px;color:var(--muted);line-height:1.6;margin-top:10px}.warning{border:1px solid #e9d6ae;color:#8e671e;border-radius:8px;background:#fffaf0;padding:13px 16px;font-size:12px;line-height:1.7;margin-bottom:20px}.item-list{display:grid;gap:12px}.work-item,.criterion,.suggestion{padding:17px;border:1px solid var(--line);border-radius:9px}.item-heading{display:flex;justify-content:space-between;align-items:flex-start;gap:13px}.item-heading h4{margin-bottom:7px;font-size:14px;line-height:1.5}.item-heading .badge{flex-shrink:0}.item-detail{font-size:11px;color:var(--muted);line-height:1.9;overflow-wrap:anywhere}.item-actions{display:flex;gap:8px;margin-top:11px;flex-wrap:wrap}.item-actions button{padding:5px 9px;font-size:11px}.inline-form{border-top:1px solid var(--line);margin-top:16px;padding-top:16px}.inline-form .form-grid{gap:13px}.inline-form input,.inline-form select{font-size:12px;padding:9px}.inline-form label{font-size:11px}.graph{overflow:auto;background:#f8fafc;border:1px solid #edf1f7;border-radius:9px;min-height:80px}.graph svg{display:block;min-width:100%;height:auto}.graph-node{fill:white;stroke:#d3deed;stroke-width:1.5}.graph-node.accepted{fill:#edf8f3;stroke:#9dcab9}.graph-node.blocked{fill:#fff8eb;stroke:#e4c999}.graph-node.active{fill:#edf2ff;stroke:#aebff3}.graph-text{font-size:12px;fill:var(--ink)}.graph-subtext{font-size:10px;fill:var(--muted)}.graph-edge{fill:none;stroke:#b1bfd3;stroke-width:1.6}.graph-arrow{fill:#b1bfd3}.table-wrap{overflow:auto}table{width:100%;border-collapse:collapse;font-size:12px}th,td{text-align:left;padding:13px 11px;border-bottom:1px solid var(--line);vertical-align:top}th{font-size:10px;color:var(--muted);font-weight:500;white-space:nowrap}td .actions button{padding:5px 8px;font-size:11px}.mono{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:11px;overflow-wrap:anywhere}.notice{position:fixed;top:18px;left:50%;transform:translateX(-50%);z-index:10;box-shadow:0 8px 35px #1d2f4920;background:#24364d;color:white;border-radius:9px;padding:13px 23px;font-size:13px;max-width:min(90vw,700px);line-height:1.6}.notice.error{background:#a44848}.error-panel{background:#fff4f2;border:1px solid #f0d0ca;color:var(--red);padding:18px;border-radius:10px;font-size:13px}.loading{padding:60px 20px;text-align:center;color:var(--muted);font-size:13px}.stack{display:grid;gap:12px}.check-label{display:flex;align-items:center;gap:8px;font-size:12px;margin:0}.check-label input{width:16px;accent-color:var(--blue)}details>summary{cursor:pointer;font-size:12px;color:var(--blue);padding:5px 0}details[open]>summary{margin-bottom:15px}.source-link{display:inline-flex;align-items:center;gap:6px;font-size:12px}.admin-grid{display:grid;grid-template-columns:1fr 1fr;gap:22px}dialog{border:1px solid var(--line);border-radius:14px;box-shadow:0 20px 100px #16253935;max-width:520px;width:90%;padding:30px;color:var(--ink)}dialog::backdrop{background:#17293d55}dialog p{font-size:13px;color:var(--muted)}dialog textarea{font-family:ui-monospace,monospace;margin:10px 0 20px} +.actions>a{display:inline-flex;align-items:center;border-radius:8px;padding:10px 14px;font-size:12px;font-weight:600}.actions>a.primary{background:var(--blue);color:white}.actions>a.primary:hover{background:#2854c6;text-decoration:none}.item-actions select{width:auto;max-width:145px;padding:5px 8px;font-size:11px}.historical-claims{margin-top:10px;margin-bottom:10px}.historical-claims .badge{margin-bottom:5px} +@media(max-width:1100px){.login-page{padding:60px 45px;gap:45px}.login-story h1{font-size:32px}.sidebar{width:190px;padding:28px 17px}.workspace{margin-left:190px}main{padding:30px}.topbar,footer{padding-left:30px;padding-right:30px}.split,.draft-layout{grid-template-columns:1fr}.admin-grid{grid-template-columns:1fr}} +@media(max-width:760px){.login-page{grid-template-columns:1fr;gap:32px;padding:35px 22px}.login-story .wordmark{margin-bottom:30px}.login-story h1{font-size:29px}.login-story p,.journey{display:none}.login-card{padding:26px}.sidebar{position:static;width:100%;padding:17px 20px;border-right:0;border-bottom:1px solid var(--line)}.sidebar .wordmark small,.sidebar-label,.sidebar-bottom{display:none}.sidebar nav{display:flex;gap:5px;overflow:auto;margin-top:15px}.sidebar nav a{font-size:11px;white-space:nowrap;padding:9px}.sidebar nav a span{display:none}.app-shell{display:block}.workspace{margin-left:0}.topbar{height:56px;padding:0 20px;font-size:10px}main{padding:25px 20px}footer{padding:18px 20px}.page-heading{flex-wrap:wrap}.page-heading h1{font-size:24px}.rfc-grid{grid-template-columns:1fr}.stats{gap:8px}.stat{padding:16px 12px}.stat strong{font-size:24px}.toolbar{flex-wrap:wrap}.toolbar input{max-width:none;flex:1}.toolbar select{max-width:none;flex:1;min-width:140px}.toolbar .count{display:none}.detail-summary{grid-template-columns:1fr;gap:9px}.detail-summary>div{padding:14px}.form-grid{grid-template-columns:1fr}.panel{padding:20px}.panel-title{flex-wrap:wrap}.identity{gap:5px}footer span{display:none}} diff --git a/src/infermatrix_copilot/sdk/v1/__init__.py b/src/infermatrix_copilot/sdk/v1/__init__.py index d6846e68..2d29096e 100644 --- a/src/infermatrix_copilot/sdk/v1/__init__.py +++ b/src/infermatrix_copilot/sdk/v1/__init__.py @@ -46,9 +46,15 @@ UnsupportedRepositoryError, ) from .strict import StrictRuntime +from .rfc import RFC_API_VERSION, RFCClient, RFCClientError, Principal, SourceRef from ...trace_store import SCHEMA as TRACE_SCHEMA, TraceStore, redact, trace_context __all__ = [ + "RFC_API_VERSION", + "RFCClient", + "RFCClientError", + "Principal", + "SourceRef", "DIRECT_API_VERSION", "KNOWLEDGE_API_VERSION", "QUALITY_API_VERSION", diff --git a/src/infermatrix_copilot/sdk/v1/rfc.py b/src/infermatrix_copilot/sdk/v1/rfc.py new file mode 100644 index 00000000..4412e0d2 --- /dev/null +++ b/src/infermatrix_copilot/sdk/v1/rfc.py @@ -0,0 +1,192 @@ +"""Portable RFC client; local application and HTTP are loaded only on use.""" +from __future__ import annotations + +import ipaddress +import json +import os +from pathlib import Path +from typing import Any +from urllib import error, parse, request + +from ...rfc_service import RFC_API_VERSION +from ...rfc_service.models import Principal, RFCError, SourceRef +from .models import SDKError + + +class RFCClientError(SDKError): + """Stable transport/domain failure without credentials in its message.""" + + def __init__(self, message: str, *, code: str = "rfc_error", status: int = 400): + super().__init__(message) + self.code = code + self.status = status + + +class _NoRedirect(request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None # never forward a bearer credential to another endpoint + + +class RFCClient: + """Use one explicit local state directory or a configured RFC service. + + A remote failure is an error; it never initializes unrelated local state. + Construction and public SDK imports do not load Copilot configuration, + application services, model providers, or either MCP server. + """ + + def __init__(self, *, state_dir: str | Path | None = None, + service_url: str = "", token: str = "", + config_path: str | Path | None = None, timeout: float = 30): + self.service_url = str(service_url).rstrip("/") + if self.service_url: + try: + parsed = parse.urlsplit(self.service_url) + parsed.port + except ValueError: + raise RFCClientError("Invalid RFC service endpoint") from None + if (parsed.scheme not in {"http", "https"} or not parsed.hostname + or parsed.username or parsed.password or parsed.query or parsed.fragment): + raise RFCClientError("RFC service URL must be an HTTP(S) endpoint") + if parsed.scheme == "http": + try: + loopback = ipaddress.ip_address(parsed.hostname).is_loopback + except ValueError: + loopback = parsed.hostname.lower() == "localhost" + if not loopback: + raise RFCClientError("An external RFC service requires HTTPS", code="insecure_endpoint") + if timeout <= 0: + raise RFCClientError("timeout must be positive") + if not isinstance(token, str) or any(ch.isspace() for ch in token): + raise RFCClientError("Invalid RFC user token", code="unauthenticated", status=401) + self.state_dir = Path(state_dir).expanduser() if state_dir else None + self.config_path = Path(config_path).expanduser() if config_path else None + self._token = token + self.timeout = timeout + self._service = None + self._version_checked = False + + @classmethod + def from_env(cls) -> RFCClient: + return cls(state_dir=os.environ.get("RFC_STATE_DIR"), + service_url=os.environ.get("RFC_SERVICE_URL", ""), + token=os.environ.get("RFC_TOKEN", ""), + config_path=os.environ.get("RFC_CONFIG")) + + def _local(self): + if self._service is None: + from ...rfc_service.application import build_service + + state = self.state_dir or Path.home() / ".infermatrix-copilot" / "rfc" + self._service = build_service(state, config_path=self.config_path) + return self._service + + def _http(self, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]: + headers = {"Accept": "application/json"} + if self._token: + headers["Authorization"] = "Bearer " + self._token + body = None + if payload is not None: + headers["Content-Type"] = "application/json" + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + req = request.Request(self.service_url + path, data=body, headers=headers) + opener = request.build_opener(_NoRedirect()) + try: + with opener.open(req, timeout=self.timeout) as response: + raw = response.read(4 * 1024 * 1024 + 1) + except error.HTTPError as exc: + # Only propagate structured application errors, not proxy HTML or + # redirects that could echo private request headers. + try: + value = json.loads(exc.read(65536)) + detail = value.get("error", {}) + except (ValueError, AttributeError): + detail = {} + if not isinstance(detail, dict): + detail = {} + raise RFCClientError(str(detail.get("message") or "RFC service request refused"), + code=str(detail.get("code") or "http_error"), + status=exc.code) from None + except (error.URLError, TimeoutError, OSError): + raise RFCClientError("RFC service unavailable", code="transport_error", status=503) from None + if len(raw) > 4 * 1024 * 1024: + raise RFCClientError("RFC service response exceeds the client limit", code="invalid_response") + try: + value = json.loads(raw) + except (ValueError, UnicodeError): + raise RFCClientError("RFC service returned invalid JSON", code="invalid_response") from None + if not isinstance(value, dict): + raise RFCClientError("RFC service response must be an object", code="invalid_response") + return value + + def capabilities(self) -> dict[str, Any]: + if not self._token: + raise RFCClientError("An RFC user token is required", code="unauthenticated", status=401) + if self.service_url: + result = self._http("/api/v1/capabilities") + else: + try: + service = self._local() + result = service.dispatch(service.authenticate(self._token), "capabilities", {}) + except RFCError as exc: + raise RFCClientError(str(exc), code=exc.code, status=exc.status) from None + version = result.get("rfc_api_version", result.get("api_version")) + if not isinstance(version, str) or not version: + raise RFCClientError("RFC service did not declare its API version", code="invalid_response") + if version.split(".")[0] != RFC_API_VERSION.split(".")[0]: + raise RFCClientError("Unsupported RFC service API version", code="incompatible_version") + self._version_checked = True + return result + + def dispatch(self, action: str, payload: dict[str, Any] | None = None) -> dict[str, Any]: + if not isinstance(action, str) or not action or not all( + part and part.replace("_", "").isalnum() for part in action.split(".")): + raise RFCClientError("Invalid RFC action") + if payload is not None and not isinstance(payload, dict): + raise RFCClientError("RFC payload must be an object") + if action == "capabilities": + return self.capabilities() + if not self._version_checked: + self.capabilities() + if self.service_url: + return self._http("/api/v1/actions/" + parse.quote(action, safe="."), payload or {}) + try: + service = self._local() + return service.dispatch(service.authenticate(self._token), action, payload or {}) + except RFCError as exc: + raise RFCClientError(str(exc), code=exc.code, status=exc.status) from None + + def list_rfcs(self, repo_id: str = "") -> dict[str, Any]: + return self.dispatch("rfcs.list", {"repo_id": repo_id} if repo_id else {}) + + def get(self, rfc_id: str) -> dict[str, Any]: + return self.dispatch("rfcs.get", {"rfc_id": rfc_id}) + + def draft(self, payload: dict[str, Any]) -> dict[str, Any]: + return self.dispatch("rfcs.draft", payload) + + def enroll(self, rfc_id: str, **settings: Any) -> dict[str, Any]: + return self.dispatch("rfcs.enroll", {**settings, "rfc_id": rfc_id}) + + def sync(self, rfc_id: str) -> dict[str, Any]: + return self.dispatch("rfcs.sync", {"rfc_id": rfc_id}) + + def publish(self, rfc_id: str, *, content_digest: str, idempotency_key: str, + post: bool = False, expected_revision: str | None = None, + path: str | None = None) -> dict[str, Any]: + payload = {"rfc_id": rfc_id, "content_digest": content_digest, + "idempotency_key": idempotency_key, "post": post} + if expected_revision is not None: + payload["expected_revision"] = expected_revision + if path is not None: + payload["path"] = path + return self.dispatch("rfcs.publish", payload) + + def decision(self, rfc_id: str, **decision: Any) -> dict[str, Any]: + return self.dispatch("rfcs.decision", {**decision, "rfc_id": rfc_id}) + + def operation(self, operation_id: str) -> dict[str, Any]: + return self.dispatch("operations.get", {"operation_id": operation_id}) + + def export(self, rfc_id: str, format: str = "json") -> dict[str, Any]: + return self.dispatch("rfcs.export", {"rfc_id": rfc_id, "format": format}) diff --git a/src/infermatrix_copilot/thin_mcp_server.py b/src/infermatrix_copilot/thin_mcp_server.py index 88b40b56..e9c5cd84 100644 --- a/src/infermatrix_copilot/thin_mcp_server.py +++ b/src/infermatrix_copilot/thin_mcp_server.py @@ -480,6 +480,45 @@ def run() -> dict: return _guard(run) + def rfc_call(action: str, payload: dict | None = None) -> dict: + from .sdk.v1.rfc import RFCClient, RFCClientError + + try: + return RFCClient.from_env().dispatch(action, payload) + except RFCClientError as exc: + return {"error": {"code": exc.code, "message": str(exc)}} + + @mcp.tool(annotations=ToolAnnotations( + title="Operate a portable RFC workspace", readOnlyHint=False, + destructiveHint=True, idempotentHint=False, openWorldHint=True)) + def rfc_request(action: str, payload: dict | None = None) -> dict: + """Invoke an authorized RFC action using the host's configured identity. + + RFC_SERVICE_URL/RFC_STATE_DIR and RFC_TOKEN configure the workspace; + callers cannot supply an endpoint or credential through this tool. + Start with rfc_capabilities, repositories.list and rfcs.list. Drafts + remain private until an explicit rfcs.publish with post=true, the exact + previewed content_digest and an idempotency_key. Queueing an operation + does not establish publication: read operations.get to its terminal state. + Use rfcs.get next_actions for actionable implementation and acceptance + work; a merged PR alone does not establish feature acceptance. + """ + return rfc_call(action, payload) + + @mcp.tool(annotations=ToolAnnotations( + title="Read RFC service capabilities", readOnlyHint=True, + idempotentHint=True, openWorldHint=True)) + def rfc_capabilities() -> dict: + """Read the configured RFC service's version and supported operations.""" + return rfc_call("capabilities") + + @mcp.tool(annotations=ToolAnnotations( + title="Read RFC progress and next actions", readOnlyHint=True, + idempotentHint=True, openWorldHint=True)) + def rfc_status(rfc_id: str) -> dict: + """Read cached evidence, acceptance, freshness and next actions for an RFC.""" + return rfc_call("rfcs.get", {"rfc_id": rfc_id}) + return mcp diff --git a/test/test_rfc_application.py b/test/test_rfc_application.py new file mode 100644 index 00000000..0fa6cef2 --- /dev/null +++ b/test/test_rfc_application.py @@ -0,0 +1,327 @@ +"""Application invariants through authenticated, concurrent lifecycle operations.""" +import copy +import json +import threading +from datetime import datetime, timezone +from pathlib import Path +from concurrent.futures import ThreadPoolExecutor + +import pytest + +from infermatrix_copilot.rfc_service.application import RFCService, build_service +from infermatrix_copilot.rfc_service.drafts import parse +from infermatrix_copilot.rfc_service.models import ProviderError, RFCError, SourceRef + +BODY = "# RFC\n\n### Engine\n\n#### F1. Implement\n\n## Acceptance criteria\n\n### Service\n\n- Verify the output.\n" + + +class Provider: + def __init__(self): + self.sources = {} + self.calls = 0 + self.candidates = [] + self.items = {} + self.uncertain_once = False + self.lock = threading.Lock() + + def publish(self, repository, title, body, operation_id, **kwargs): + with self.lock: + self.calls += 1 + ref = SourceRef("github", repository, "issue", "1", f"https://github.com/{repository}/issues/1", host="github.com") + self.sources[operation_id] = {"source": ref.to_dict(), "title": title, "body": body, "revision": "source-v1"} + if self.uncertain_once: + self.uncertain_once = False + raise ProviderError("Timed out after publication", uncertain=True) + return ref + + def find_publication(self, repository, operation_id): + return SourceRef.from_dict(self.sources[operation_id]["source"]) if operation_id in self.sources else None + + def get_source(self, ref): + return copy.deepcopy(next(s for s in self.sources.values() if s["source"]["identifier"] == ref.identifier)) + + def get_item(self, ref): + return copy.deepcopy(self.items[ref.identifier]) + + def discover(self, repository, scope, since=""): + return copy.deepcopy(self.candidates) + + +def setup(tmp_path, provider=None): + provider = provider or Provider() + service = RFCService(tmp_path / "state", {"github": provider}) + issued = service.bootstrap_admin("Owner") + admin = service.authenticate(issued["token"]) + repo = service.dispatch(admin, "repositories.create", {"name": "Any repo", "provider": "github", "external_name": "owner/repo"}) + return service, admin, repo, provider + + +def user(service, admin, repo, name, role): + u = service.dispatch(admin, "users.create", {"name": name}) + issued = service.dispatch(admin, "tokens.create", {"user_id": u["id"]}) + service.dispatch(admin, "grants.set", {"repo_id": repo["id"], "user_id": u["id"], "role": role}) + return service.authenticate(issued["token"]), issued + + +def drafted(service, actor, repo): + return service.dispatch(actor, "rfcs.draft", {"repo_id": repo["id"], "title": "Portable", "body": BODY}) + + +def publish(service, actor, rfc, key="publication"): + return service.dispatch(actor, "rfcs.publish", {"rfc_id": rfc["id"], "content_digest": rfc["content_digest"], "post": True, "idempotency_key": key}) + + +def test_named_tokens_hashes_expiry_and_revoked_sessions(tmp_path): + service, admin, repo, _ = setup(tmp_path) + with pytest.raises(RFCError): + service.bootstrap_admin("Second") + reader, issued = user(service, admin, repo, "Alice", "reader") + cookie, _ = service.create_session(issued["token"]) + stored = service.store.one("SELECT digest FROM tokens WHERE id=?", (issued["token_id"],))["digest"] + assert stored != issued["token"] and issued["token"] not in service.store.path.read_bytes().decode("latin1") + assert service.authenticate_session(cookie).user_id == reader.user_id + service.dispatch(admin, "tokens.revoke", {"token_id": issued["token_id"]}) + with pytest.raises(RFCError) as denied: + service.authenticate_session(cookie) + assert denied.value.status == 401 + with pytest.raises(RFCError): + service.dispatch(reader, "rfcs.list") + + +def test_all_projections_obey_repository_and_narrowed_rfc_acl(tmp_path): + service, admin, repo, _ = setup(tmp_path) + reader, _ = user(service, admin, repo, "Alice", "reader") + another = service.dispatch(admin, "repositories.create", {"name": "Secret", "provider": "github", "external_name": "owner/secret"}) + visible, hidden = drafted(service, admin, repo), drafted(service, admin, another) + restricted = drafted(service, admin, repo) + service.dispatch(admin, "rfcs.acl", {"rfc_id": restricted["id"], "restricted": True, "grants": {}}) + assert visible["features"][0]["id"] == hidden["features"][0]["id"] == "F1" + assert [r["id"] for r in service.dispatch(reader, "rfcs.list")["rfcs"]] == [visible["id"]] + assert [r["id"] for r in service.dispatch(reader, "repositories.list")["repositories"]] == [repo["id"]] + for action in ("rfcs.get", "rfcs.export", "rfcs.next"): + for denied in (hidden, restricted): + with pytest.raises(RFCError) as exc: + service.dispatch(reader, action, {"rfc_id": denied["id"]}) + assert exc.value.status == 403 + with pytest.raises(RFCError): + service.dispatch(reader, "rfcs.work", {"rfc_id": visible["id"], "op": "claim", "feature_id": "F1", "reason": "Claim"}) + with pytest.raises(RFCError): + service.dispatch(admin, "rfcs.acl", {"rfc_id": visible["id"], "grants": {reader.user_id: "maintainer"}}) + queued = publish(service, admin, restricted) + with pytest.raises(RFCError): + service.dispatch(reader, "operations.get", {"operation_id": queued["operation_id"]}) + assert all(e["rfc_id"] != restricted["id"] for e in service.dispatch(reader, "audit.list")["events"]) + + +@pytest.mark.parametrize("revoke", ["grant", "token", "user", "rfc"]) +def test_queued_external_write_rechecks_current_authority(tmp_path, revoke): + service, admin, repo, provider = setup(tmp_path) + maintainer, issued = user(service, admin, repo, "Publisher", "maintainer") + rfc = drafted(service, maintainer, repo) + operation = publish(service, maintainer, rfc) + if revoke == "grant": + service.dispatch(admin, "grants.set", {"repo_id": repo["id"], "user_id": maintainer.user_id, "role": ""}) + elif revoke == "token": + service.dispatch(admin, "tokens.revoke", {"token_id": issued["token_id"]}) + elif revoke == "user": + service.dispatch(admin, "users.update", {"user_id": maintainer.user_id, "enabled": False}) + else: + service.dispatch(admin, "rfcs.acl", {"rfc_id": rfc["id"], "grants": {}, "restricted": True}) + result = service.process_pending() + assert result[0]["status"] == "failed" and provider.calls == 0 + assert service.dispatch(admin, "operations.get", {"operation_id": operation["operation_id"]})["status"] == "failed" + + +def test_preview_binding_concurrent_workers_restart_and_uncertain_recovery(tmp_path): + service, admin, repo, provider = setup(tmp_path) + rfc = drafted(service, admin, repo) + with pytest.raises(RFCError): + service.dispatch(admin, "rfcs.publish", {"rfc_id": rfc["id"], "post": True, "content_digest": "wrong"}) + queued = publish(service, admin, rfc) + assert publish(service, admin, rfc)["operation_id"] == queued["operation_id"] + provider.uncertain_once = True + with ThreadPoolExecutor(max_workers=2) as executor: + result = list(executor.map(lambda _: service.process_pending(), range(2))) + assert provider.calls == 1 + assert service.dispatch(admin, "operations.get", {"operation_id": queued["operation_id"]})["status"] == "uncertain" + restarted = RFCService(tmp_path / "state", {"github": provider}) + assert restarted.writer_id == service.writer_id + restarted.dispatch(admin, "operations.retry", {"operation_id": queued["operation_id"]}) + assert restarted.process_pending()[0]["status"] == "succeeded" + assert provider.calls == 1 + assert publish(restarted, admin, rfc)["operation_id"] == queued["operation_id"] + assert restarted.dispatch(admin, "rfcs.get", {"rfc_id": rfc["id"]})["enrolled"] is True + + +def test_draft_change_after_queue_prevents_publication(tmp_path): + service, admin, repo, provider = setup(tmp_path) + rfc = drafted(service, admin, repo) + publish(service, admin, rfc) + service.dispatch(admin, "rfcs.update", {"rfc_id": rfc["id"], "expected_revision": rfc["revision"], "body": BODY + "new design"}) + assert service.process_pending()[0]["status"] == "failed" + assert provider.calls == 0 + + +def test_sync_deduplicates_delivery_reads_and_advances_only_successful_discovery_cursor(tmp_path): + service, admin, repo, provider = setup(tmp_path) + now = service.clock() + service.clock = lambda: now + rfc = drafted(service, admin, repo) + link = "https://github.com/owner/repo/pull/2" + for feature_id in ("F1", "F2"): + service.dispatch(admin, "rfcs.work", {"rfc_id": rfc["id"], "op": "update" if feature_id == "F1" else "add", + "reason": "Both work items share this implementation PR", + "feature_id": feature_id, "feature": {"id": feature_id, "title": feature_id, "track": "Engine", "links": [link]}}) + rfc = service.dispatch(admin, "rfcs.get", {"rfc_id": rfc["id"]}) + publish(service, admin, rfc) + assert service.process_pending()[0]["status"] == "succeeded" + reads, cursors = [], [] + def get_item(ref): + reads.append(ref.identifier) + return {"state": "merged", "revision": "sha2"} + def discover(repository, scope, since=""): + cursors.append(since) + return [] + provider.get_item, provider.discover = get_item, discover + service.dispatch(admin, "rfcs.sync", {"rfc_id": rfc["id"]}) + assert service.process_pending()[0]["status"] == "succeeded" + assert reads == ["2"] and cursors == [""] + now += 3600 + def unavailable(repository, scope, since=""): + cursors.append(since) + raise ProviderError("Discovery unavailable") + provider.discover = unavailable + service.dispatch(admin, "rfcs.sync", {"rfc_id": rfc["id"]}) + assert service.process_pending()[0]["status"] == "failed" + provider.discover = discover + resumed = RFCService(service.store.root, {"github": provider}, clock=lambda: now) + resumed.dispatch(admin, "rfcs.sync", {"rfc_id": rfc["id"]}) + assert resumed.process_pending()[0]["status"] == "succeeded" + assert cursors[1] == cursors[2] == datetime.fromtimestamp(now - 3660, timezone.utc).isoformat() + assert reads == ["2", "2", "2"] + + +def test_local_explicit_discovery_records_an_actual_relative_association(tmp_path): + service = build_service(tmp_path / "local-state") + admin = service.authenticate(service.bootstrap_admin("Owner")["token"]) + repo = service.dispatch(admin, "repositories.create", {"name": "Unconfigured repository", "provider": "local"}) + rfc = drafted(service, admin, repo) + publish(service, admin, rfc) + assert service.process_pending()[0]["status"] == "succeeded" + rfc = service.dispatch(admin, "rfcs.get", {"rfc_id": rfc["id"]}) + root = Path(service.store.one("SELECT root FROM repositories WHERE id=?", (repo["id"],))["root"]) + (root / "implementation.md").write_text("# Implementation work\n\nRFC: " + rfc["source"]["path"] + "\nFeature: F1\n", encoding="utf-8") + service.dispatch(admin, "rfcs.sync", {"rfc_id": rfc["id"]}) + result = service.process_pending()[0] + assert result["status"] == "succeeded" and result["result"]["applied"] == 1 + current = service.dispatch(admin, "rfcs.get", {"rfc_id": rfc["id"]}) + assert "local:implementation.md" in current["features"][0]["links"] + assert current["features"][0]["implementation"] != "implemented" + service.dispatch(admin, "rfcs.sync", {"rfc_id": rfc["id"]}) + assert service.process_pending()[0]["result"]["applied"] == 0 + + +def test_automatically_attached_work_requires_fresh_acceptance_evidence(tmp_path): + service, admin, repo, provider = setup(tmp_path) + rfc = drafted(service, admin, repo) + publish(service, admin, rfc) + assert service.process_pending()[0]["status"] == "succeeded" + rfc = service.dispatch(admin, "rfcs.get", {"rfc_id": rfc["id"]}) + rfc = service.dispatch(admin, "rfcs.decision", {"rfc_id": rfc["id"], "criterion_id": rfc["criteria"][0]["id"], + "verdict": "passing", "evidence": {"revision": "verified-initial-plan", "environment": "Linux"}}) + provider.candidates = [{"title": "Implementation PR", "body": rfc["source"]["url"], + "url": "https://github.com/owner/repo/pull/2", "feature_id": "F1"}] + provider.items["2"] = {"state": "merged", "revision": "new-implementation"} + for _ in range(2): + service.dispatch(admin, "rfcs.sync", {"rfc_id": rfc["id"]}) + assert service.process_pending()[0]["status"] == "succeeded" + current = service.dispatch(admin, "rfcs.get", {"rfc_id": rfc["id"]}) + assert current["implementation"] == "implemented" and current["acceptance"] == "pending" + assert current["criteria"][0]["evidence"][0]["stale"] is True + + +def test_merged_delivery_still_requires_evidence_and_revision_revalidation(tmp_path): + service, admin, repo, provider = setup(tmp_path) + rfc = drafted(service, admin, repo) + links = ["https://github.com/owner/repo/pull/2", "https://github.com/owner/repo/pull/3"] + rfc = service.dispatch(admin, "rfcs.work", {"rfc_id": rfc["id"], "feature_id": "F1", "feature": {"links": links}}) + publish(service, admin, rfc) + assert service.process_pending()[0]["status"] == "succeeded" + provider.items = {"2": {"state": "merged", "revision": "sha2"}, "3": {"state": "draft", "revision": "sha3"}} + def sync(): + service.dispatch(admin, "rfcs.sync", {"rfc_id": rfc["id"]}) + assert service.process_pending()[0]["status"] == "succeeded" + return service.dispatch(admin, "rfcs.get", {"rfc_id": rfc["id"]}) + status = sync() + assert status["implementation"] == "partial" and not status["complete"] + provider.items["3"]["state"] = "merged" + status = sync() + assert status["implementation"] == "implemented" and status["acceptance"] == "pending" + criterion_id = status["criteria"][0]["id"] + with pytest.raises(RFCError): + service.dispatch(admin, "rfcs.decision", {"rfc_id": rfc["id"], "criterion_id": criterion_id, "verdict": "passing"}) + status = service.dispatch(admin, "rfcs.decision", {"rfc_id": rfc["id"], "criterion_id": criterion_id, + "verdict": "passing", "evidence": {"revision": "sha3", "environment": "Linux/H100", "url": "https://example.test/report"}}) + assert status["complete"] is True + provider.items["3"]["revision"] = "sha4" + status = sync() + assert status["acceptance"] == "pending" and status["criteria"][0]["evidence"][0]["stale"] + + +def test_bounded_discovery_cap_rejections_and_human_removals_survive_sync(tmp_path): + service, admin, repo, provider = setup(tmp_path) + rfc = drafted(service, admin, repo) + rfc = service.dispatch(admin, "rfcs.update", {"rfc_id": rfc["id"], "expected_revision": rfc["revision"], "scope": "test scope"}) + publish(service, admin, rfc) + service.process_pending() + source_url = f"https://github.com/owner/repo/issues/1" + provider.candidates = [{"title": f"Work {n}", "url": f"https://github.com/owner/repo/pull/{n}", + "body": source_url, "track": "Engine", "within_scope": True} for n in range(10, 17)] + def sync(): + service.dispatch(admin, "rfcs.sync", {"rfc_id": rfc["id"]}) + return service.process_pending()[0] + assert sync()["result"]["applied"] == 5 + status = service.dispatch(admin, "rfcs.get", {"rfc_id": rfc["id"]}) + assert len(status["features"]) == 6 + pending = next(s for s in status["suggestions"] if s["status"] == "proposed") + service.dispatch(admin, "rfcs.decision", {"rfc_id": rfc["id"], "kind": "suggestion", "suggestion_id": pending["id"], "verdict": "rejected"}) + drop = status["features"][1]["id"] + service.dispatch(admin, "rfcs.work", {"rfc_id": rfc["id"], "op": "drop", "feature_id": drop}) + for n in range(10, 17): + provider.items[str(n)] = {"state": "open", "revision": "sha"} + assert sync()["result"]["applied"] == 0 + status = service.dispatch(admin, "rfcs.get", {"rfc_id": rfc["id"]}) + assert next(f for f in status["features"] if f["id"] == drop)["dropped"] + assert next(s for s in status["suggestions"] if s["id"] == pending["id"])["status"] == "rejected" + + +def test_local_end_to_end_uses_any_repo_without_knowledge_adapter(tmp_path): + service = build_service(tmp_path / "state") + admin = service.authenticate(service.bootstrap_admin("Owner")["token"]) + repo = service.dispatch(admin, "repositories.create", {"name": "Arbitrary", "provider": "local"}) + rfc = drafted(service, admin, repo) + queued = service.dispatch(admin, "rfcs.publish", {"rfc_id": rfc["id"], "post": True, "content_digest": rfc["content_digest"], "path": "rfcs/example.md"}) + assert service.process_pending()[0]["status"] == "succeeded" + root = service.store.one("SELECT root FROM repositories WHERE id=?", (repo["id"],))["root"] + from pathlib import Path + assert BODY in (Path(root) / "rfcs/example.md").read_text() + assert service.dispatch(admin, "operations.get", {"operation_id": queued["operation_id"]})["status"] == "succeeded" + with pytest.raises(RFCError): + service.dispatch(admin, "sources.preview", {"repo_id": repo["id"], "source": {"provider": "local", "path": "../outside.md"}}) + + +def test_nested_acceptance_and_arbitrary_dependency_graph_import_preserves_prose(): + body = BODY + "\n#### Other_2. Integrate\n\nDepends on: F1, Missing3\n\n```mermaid\nF1 --> Other_2\n```\n" + model = parse(body) + assert model["criteria"] and model["features"][1]["depends_on"] == ["F1"] + assert model["ambiguities"][0]["unknown_dependencies"] == ["Missing3"] + assert body.startswith("# RFC") # parse works on a sidecar; the caller retains prose. + + +def test_imported_explicit_milestone_is_delivery_fact_without_acceptance(): + from infermatrix_copilot.rfc_service.drafts import project + body = BODY.replace("#### F1. Implement", "#### F1. Implement\n\n **Status: complete** — Suite selected; confirmed 2026-09-22") + model = project(parse(body)) + assert model["implementation"] == "implemented" + assert model["features"][0]["implementation_claim"]["provenance"] == "imported_explicit_milestone" + assert model["acceptance"] == "pending" and not model["complete"] diff --git a/test/test_rfc_http.py b/test/test_rfc_http.py new file mode 100644 index 00000000..1fd3caf0 --- /dev/null +++ b/test/test_rfc_http.py @@ -0,0 +1,321 @@ +"""Real HTTP requests verify authentication boundaries and browser sessions.""" +from __future__ import annotations + +import json +import threading +from contextlib import contextmanager +from http.client import HTTPConnection +from typing import Any + +import pytest + +from infermatrix_copilot.rfc_service.http import MAX_BODY_BYTES, create_server +from infermatrix_copilot.rfc_service.models import Principal, RFCError + + +class FakeService: + def __init__(self) -> None: + self.principal = Principal("alice", "Alice", False, "credential-id") + self.sessions: dict[str, Principal] = {} + self.calls: list[tuple[str, dict[str, Any]]] = [] + + def authenticate(self, token: str) -> Principal: + if token != "personal-test-token": + raise RFCError("Invalid token", 401, "unauthenticated") + return self.principal + + def create_session(self, token: str) -> tuple[str, Principal]: + principal = self.authenticate(token) + self.sessions["opaque-session-secret"] = principal + return "opaque-session-secret", principal + + def authenticate_session(self, cookie: str) -> Principal: + if cookie not in self.sessions: + raise RFCError("Invalid session", 401, "unauthenticated") + return self.sessions[cookie] + + def revoke_session(self, cookie: str) -> None: + self.sessions.pop(cookie, None) + + def dispatch(self, principal: Principal, action: str, payload: dict[str, Any]) -> dict[str, Any]: + assert principal == self.principal + self.calls.append((action, payload)) + if action == "me": + return principal.to_dict() + if action == "capabilities": + return {"api_version": "1.0.0"} + if action == "rfcs.list": + return {"rfcs": [{"id": "one", "title": "Private project"}]} + if action == "legacy.get": + return {"id": "legacy-one", "body": "# Private legacy RFC", "features": []} + if action in {"rfcs.get", "rfcs.export"}: + if payload.get("rfc_id") == "denied": + raise RFCError("RFC access denied", 403, "forbidden") + return {"id": payload["rfc_id"], "body": "Private design"} + if action == "unsafe.failure": + raise RuntimeError("sensitive-token-do-not-disclose") + if action == "operations.get": + return {"id": payload["operation_id"]} + return {"ok": True, "action": action} + + +@contextmanager +def running(service: FakeService | None = None, *, public_url: str | None = None): + service = service or FakeService() + server = create_server(service, public_url=public_url) + thread = threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.01}, daemon=True) + thread.start() + try: + yield server, service + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + + +def request(server: Any, method: str, path: str, payload: Any = None, *, headers: dict[str, str] | None = None, + raw_body: bytes | None = None): + connection = HTTPConnection("127.0.0.1", server.server_address[1], timeout=3) + request_headers = dict(headers or {}) + body = raw_body + if payload is not None: + body = json.dumps(payload).encode() + request_headers.setdefault("Content-Type", "application/json") + connection.request(method, path, body=body, headers=request_headers) + response = connection.getresponse() + result = response.read() + response_headers = {key.lower(): value for key, value in response.getheaders()} + connection.close() + return response.status, response_headers, result + + +def login(server: Any): + status, headers, raw = request(server, "POST", "/api/v1/session", {"token": "personal-test-token"}, + headers={"Origin": server.public_origin}) + assert status == 200 + return headers["set-cookie"].split(";", 1)[0], headers, json.loads(raw) + + +@pytest.mark.parametrize("path", [ + "/api/v1/capabilities", "/api/v1/rfcs", "/api/v1/rfcs/one", "/api/v1/rfcs/one/export", + "/api/v1/operations", "/api/v1/audit", "/roadmap-snapshot.json", "/api/roadmap/tasks", + "/api/roadmap/claims", "/api/roadmap/priority", "/api/roadmap/auto", "/roadmap-500ms-cn.json", + "/roadmap-500ms-cn.md", "/roadmap-backup.json", "/web/../../application.py", +]) +def test_all_data_routes_require_authentication(path: str): + with running() as (server, service): + status, headers, raw = request(server, "GET", path) + assert status == 401 + assert "private" not in raw.decode().lower() + assert headers["cache-control"] == "no-store" + assert not service.calls + + +def test_login_shell_contains_no_rfc_data_and_assets_are_allowlisted(): + with running() as (server, _): + status, headers, raw = request(server, "GET", "/") + assert status == 200 + assert "登录工作空间" in raw.decode() + assert b"Private project" not in raw + assert "frame-ancestors 'none'" in headers["content-security-policy"] + assert request(server, "GET", "/app.js")[0] == 200 + assert request(server, "GET", "/style.css")[0] == 200 + assert request(server, "GET", "/models.py")[0] == 401 + + +@pytest.mark.parametrize("path", ["/roadmap.html", "/roadmap-500ms-cn.html"]) +def test_legacy_html_is_only_the_login_shell(path): + with running() as (server, service): + status, _, raw = request(server, "GET", path) + assert status == 200 + assert "登录工作空间" in raw.decode() + assert b"Private legacy" not in raw + assert not service.calls + + +@pytest.mark.parametrize("path", ["/roadmap-snapshot.json", "/roadmap-500ms-cn.json", "/api/roadmap/claims", "/api/roadmap/priority", "/api/roadmap/tasks", "/api/roadmap/auto"]) +def test_legacy_data_resolves_one_authorized_namespace_or_explicit_rfc(path): + with running() as (server, service): + bearer = {"Authorization": "Bearer personal-test-token"} + status, _, raw = request(server, "GET", path, headers=bearer) + assert status == 200 + assert json.loads(raw)["id"] == "legacy-one" + assert service.calls[-1] == ("legacy.get", {"namespace": "wm-7074"}) + status, _, raw = request(server, "GET", path + "?rfc_id=string%3Aid", headers=bearer) + assert status == 200 + assert json.loads(raw)["id"] == "string:id" + assert service.calls[-1] == ("rfcs.get", {"rfc_id": "string:id"}) + + +def test_legacy_markdown_is_authenticated_attachment_and_backups_are_not_served(): + with running() as (server, service): + bearer = {"Authorization": "Bearer personal-test-token"} + status, headers, raw = request(server, "GET", "/roadmap-500ms-cn.md", headers=bearer) + assert status == 200 + assert headers["content-type"].startswith("text/markdown") + assert "attachment" in headers["content-disposition"] + assert raw == b"# Private legacy RFC" + assert service.calls[-1] == ("legacy.get", {"namespace": "wm-7074"}) + for path in ("/roadmap-backup.json", "/roadmap-500ms-cn.md.bak", "/roadmap-old.html"): + assert request(server, "GET", path)[0] == 401 + assert request(server, "GET", path, headers=bearer)[0] == 404 + + +def test_bearer_transport_reads_and_mutates_without_browser_origin(): + with running() as (server, service): + bearer = {"Authorization": "Bearer personal-test-token"} + assert request(server, "GET", "/api/v1/capabilities", headers=bearer)[0] == 200 + status, _, raw = request(server, "POST", "/api/v1/actions/rfcs.draft", {"repo_id": "local"}, headers=bearer) + assert status == 200 + assert json.loads(raw)["ok"] is True + assert service.calls[-1] == ("rfcs.draft", {"repo_id": "local"}) + + +def test_session_is_http_only_and_neither_credential_is_in_json(): + with running() as (server, _): + cookie, headers, value = login(server) + assert "HttpOnly" in headers["set-cookie"] + assert "SameSite=Strict" in headers["set-cookie"] + assert "Secure" not in headers["set-cookie"] + assert value["principal"]["user_id"] == "alice" + assert "personal-test-token" not in json.dumps(value) + assert "opaque-session-secret" not in json.dumps(value) + assert request(server, "GET", "/api/v1/rfcs", headers={"Cookie": cookie})[0] == 200 + + +def test_https_cookie_uses_configured_origin_not_forwarded_host(): + with running(public_url="https://rfc.example.test") as (server, _): + _, headers, _ = login(server) + assert "Secure" in headers["set-cookie"] + status, _, raw = request(server, "POST", "/api/v1/session", {"token": "personal-test-token"}, + headers={"Origin": "https://attacker.example", "X-Forwarded-Host": "attacker.example", "Host": "attacker.example"}) + assert status == 403 + assert json.loads(raw)["error"]["code"] == "invalid_origin" + + +@pytest.mark.parametrize("origin", [None, "https://attacker.example", "null"]) +def test_cookie_mutations_require_the_same_origin(origin: str | None): + with running() as (server, service): + cookie, _, _ = login(server) + headers = {"Cookie": cookie} + if origin: + headers["Origin"] = origin + status, _, _ = request(server, "POST", "/api/v1/actions/rfcs.update", {"rfc_id": "one"}, headers=headers) + assert status == 403 + assert not service.calls + + +def test_logout_revokes_cookie_and_clears_browser_state(): + with running() as (server, _): + cookie, _, _ = login(server) + headers = {"Cookie": cookie, "Origin": server.public_origin} + assert request(server, "POST", "/api/v1/actions/rfcs.update", {"rfc_id": "one"}, headers=headers)[0] == 200 + status, response_headers, _ = request(server, "DELETE", "/api/v1/session", headers=headers) + assert status == 200 + assert "Max-Age=0" in response_headers["set-cookie"] + assert request(server, "GET", "/api/v1/rfcs", headers={"Cookie": cookie})[0] == 401 + + +def test_core_permissions_are_enforced_for_reads_and_exports(): + with running() as (server, _): + bearer = {"Authorization": "Bearer personal-test-token"} + for path in ("/api/v1/rfcs/denied", "/api/v1/rfcs/denied/export"): + status, _, raw = request(server, "GET", path, headers=bearer) + assert status == 403 + assert json.loads(raw)["error"]["code"] == "forbidden" + assert b"Private design" not in raw + + +def test_unexpected_errors_hide_secrets_and_request_logging_is_silent(capsys): + with running() as (server, _): + status, _, raw = request(server, "POST", "/api/v1/actions/unsafe.failure", {}, headers={"Authorization": "Bearer personal-test-token"}) + assert status == 500 + assert b"sensitive-token" not in raw + assert json.loads(raw)["error"]["code"] == "internal_error" + output = capsys.readouterr() + assert not output.out + assert not output.err + + +def test_malformed_and_oversized_bodies_never_reach_the_application(): + with running() as (server, service): + headers = {"Authorization": "Bearer personal-test-token", "Content-Type": "application/json"} + status, _, _ = request(server, "POST", "/api/v1/actions/rfcs.draft", raw_body=b"invalid-json", headers=headers) + assert status == 400 + # The server rejects the advertised length before consuming an upload. + # Sending a full oversized body can race its early connection close. + status, _, _ = request(server, "POST", "/api/v1/actions/rfcs.draft", raw_body=b"", + headers={**headers, "Content-Length": str(MAX_BODY_BYTES + 1)}) + assert status == 413 + assert not service.calls + + +def test_http_deployments_are_limited_to_loopback(): + with pytest.raises(ValueError, match="HTTPS"): + create_server(FakeService(), host="0.0.0.0") + with pytest.raises(ValueError, match="HTTPS"): + create_server(FakeService(), public_url="http://rfc.example.test") + with pytest.raises(ValueError, match="HTTPS"): + create_server(FakeService(), host="0.0.0.0", public_url="http://127.0.0.1") + with pytest.raises(ValueError, match="origin"): + create_server(FakeService(), public_url="https://rfc.example.test/subpath") + + +def test_opaque_ids_survive_routing_and_unknown_routes_fail_closed(): + with running() as (server, service): + bearer = {"Authorization": "Bearer personal-test-token"} + status, _, raw = request(server, "GET", "/api/v1/rfcs/string%3Aid/export", headers=bearer) + assert status == 200 + assert json.loads(raw)["id"] == "string:id" + assert service.calls[-1] == ("rfcs.export", {"rfc_id": "string:id"}) + status, _, raw = request(server, "GET", "/api/v1/operations/op%3Aone", headers=bearer) + assert status == 200 + assert json.loads(raw)["id"] == "op:one" + assert request(server, "GET", "/not-a-route", headers=bearer)[0] == 404 + + +def test_real_service_session_obeys_changed_rfc_permissions_and_token_revocation(tmp_path): + from infermatrix_copilot.rfc_service.application import build_service + + service = build_service(tmp_path) + administrator = service.bootstrap_admin("Admin") + admin = service.authenticate(administrator["token"]) + repository = service.dispatch(admin, "repositories.create", {"name": "Pilot", "provider": "local"}) + rfc = service.dispatch(admin, "rfcs.draft", {"repo_id": repository["id"], "title": "Restricted pilot"}) + user = service.dispatch(admin, "users.create", {"name": "Reader"}) + token = service.dispatch(admin, "tokens.create", {"user_id": user["id"]}) + service.dispatch(admin, "grants.set", {"repo_id": repository["id"], "user_id": user["id"], "role": "reader"}) + with running(service) as (server, _): + status, headers, raw = request(server, "POST", "/api/v1/session", {"token": token["token"]}, + headers={"Origin": server.public_origin}) + assert status == 200 + assert token["token"].encode() not in raw + browser = {"Cookie": headers["set-cookie"].split(";", 1)[0]} + mutation = {**browser, "Origin": server.public_origin} + status, _, raw = request(server, "GET", f"/api/v1/tokens?user_id={user['id']}", headers=browser) + assert status == 200 + assert {record["user_id"] for record in json.loads(raw)["tokens"]} == {user["id"]} + assert request(server, "GET", "/api/v1/users", headers=browser)[0] == 403 + assert request(server, "POST", "/api/v1/actions/service.settings", {}, headers=mutation)[0] == 403 + assert request(server, "POST", "/api/v1/actions/service.configure", {"sync_seconds": 60}, headers=mutation)[0] == 403 + assert request(server, "GET", f"/api/v1/tokens?user_id={admin.user_id}", headers=browser)[0] == 403 + assert request(server, "POST", "/api/v1/actions/tokens.create", {"user_id": admin.user_id}, headers=mutation)[0] == 403 + status, _, raw = request(server, "POST", "/api/v1/actions/tokens.create", {"user_id": user["id"], "expires_days": 7}, headers=mutation) + assert status == 200 + own_token = json.loads(raw) + assert service.authenticate(own_token["token"]).user_id == user["id"] + assert request(server, "POST", "/api/v1/actions/tokens.revoke", {"token_id": administrator["token_id"]}, headers=mutation)[0] == 403 + assert request(server, "POST", "/api/v1/actions/tokens.revoke", {"token_id": own_token["token_id"]}, headers=mutation)[0] == 200 + with pytest.raises(RFCError, match="Authentication"): + service.authenticate(own_token["token"]) + assert request(server, "GET", f"/api/v1/rfcs/{rfc['id']}", headers=browser)[0] == 200 + status, _, _ = request(server, "POST", "/api/v1/actions/rfcs.work", {"rfc_id": rfc["id"], "op": "claim", "feature_id": "F1"}, + headers={**browser, "Origin": server.public_origin}) + assert status == 403 + service.dispatch(admin, "rfcs.acl", {"rfc_id": rfc["id"], "restricted": True, "grants": {}}) + assert request(server, "GET", f"/api/v1/rfcs/{rfc['id']}/export", headers=browser)[0] == 403 + status, _, raw = request(server, "GET", "/api/v1/rfcs", headers=browser) + assert status == 200 + assert json.loads(raw)["rfcs"] == [] + service.dispatch(admin, "tokens.revoke", {"token_id": token["token_id"]}) + assert request(server, "GET", "/api/v1/capabilities", headers=browser)[0] == 401 diff --git a/test/test_rfc_integration.py b/test/test_rfc_integration.py new file mode 100644 index 00000000..e2938c4e --- /dev/null +++ b/test/test_rfc_integration.py @@ -0,0 +1,248 @@ +"""Public RFC clients and entry points exercise one application contract.""" +from __future__ import annotations + +import json +import subprocess +import sys +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +import pytest + +from infermatrix_copilot.sdk.v1 import RFCClient, RFCClientError, SourceRef + + +def test_rfc_sdk_import_has_no_application_or_server_side_effects(): + script = """ +import sys +from infermatrix_copilot.sdk.v1 import RFCClient, SourceRef, RFC_API_VERSION +client = RFCClient(service_url='http://127.0.0.1:12345', token='example') +assert RFC_API_VERSION == '1.0.0' +assert SourceRef('local', path='RFC.md').identity() +assert not any(x in sys.modules for x in ( + 'infermatrix_copilot.config', 'infermatrix_copilot.rfc_service.application', + 'infermatrix_copilot.rfc_service.http', 'infermatrix_copilot.mcp_server', + 'infermatrix_copilot.thin_mcp_server')) +""" + result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True) + assert result.returncode == 0, result.stderr + + +def test_unavailable_remote_never_creates_local_state(tmp_path): + # Reserve a local port, then close it to produce a reliable refused connection. + server = ThreadingHTTPServer(("127.0.0.1", 0), BaseHTTPRequestHandler) + port = server.server_port + server.server_close() + state = tmp_path / "unused" + client = RFCClient(state_dir=state, service_url=f"http://127.0.0.1:{port}", token="secret", timeout=1) + with pytest.raises(RFCClientError) as exc: + client.list_rfcs() + assert exc.value.code == "transport_error" + assert "secret" not in str(exc.value) + assert not state.exists() + + +@pytest.mark.parametrize("endpoint", ["http://service.example", "http://10.0.0.2:8765", "http://[2001:db8::1]"]) +def test_client_refuses_cleartext_external_credentials(endpoint, monkeypatch): + from urllib import request + + calls = [] + monkeypatch.setattr(request, "build_opener", lambda *args: calls.append(args)) + with pytest.raises(RFCClientError) as exc: + RFCClient(service_url=endpoint, token="private") + assert exc.value.code == "insecure_endpoint" + assert "private" not in str(exc.value) + assert calls == [] + + +def test_incompatible_service_version_prevents_actions(monkeypatch): + client = RFCClient(service_url="http://127.0.0.1:12345", token="example") + calls = [] + def transport(path, payload=None): + calls.append(path) + return {"rfc_api_version": "2.0.0"} + monkeypatch.setattr(client, "_http", transport) + with pytest.raises(RFCClientError) as exc: + client.draft({"repo_id": "repo", "title": "example"}) + assert exc.value.code == "incompatible_version" + assert calls == ["/api/v1/capabilities"] + + +def test_http_client_does_not_forward_bearer_on_redirect(): + requests = [] + class Handler(BaseHTTPRequestHandler): + def log_message(self, *args): + pass + + def do_POST(self): + requests.append((self.path, self.headers.get("Authorization"))) + self.send_response(302) + self.send_header("Location", "/unexpected") + self.end_headers() + + def do_GET(self): + requests.append((self.path, self.headers.get("Authorization"))) + self.send_response(200) + self.end_headers() + self.wfile.write(b'{"rfc_api_version":"1.0.0"}') + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + client = RFCClient(service_url=f"http://127.0.0.1:{server.server_port}", token="private") + with pytest.raises(RFCClientError) as exc: + client.list_rfcs() + assert exc.value.status == 302 + assert requests == [("/api/v1/capabilities", "Bearer private"), + ("/api/v1/actions/rfcs.list", "Bearer private")] + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + + +def _workspace(tmp_path): + from infermatrix_copilot.rfc_service.application import build_service + + service = build_service(tmp_path / "state") + bootstrap = service.bootstrap_admin("Owner") + client = RFCClient(state_dir=tmp_path / "state", token=bootstrap["token"]) + return service, bootstrap, client + + +def test_local_sdk_and_authenticated_http_return_the_same_rfc(tmp_path): + from infermatrix_copilot.rfc_service.http import create_server + + service, bootstrap, local = _workspace(tmp_path) + repo = local.dispatch("repositories.create", {"name": "Any repository", "provider": "local"}) + draft = local.draft({"repo_id": repo["id"], "title": "Portable RFC", + "body": "# Portable RFC\n\n#### F1. Implementation\n\n## Acceptance criteria\n\n- Validate on Windows and Linux.\n"}) + server = create_server(service) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + remote = RFCClient(service_url=server.public_url, token=bootstrap["token"]) + assert remote.capabilities() == local.capabilities() + assert remote.get(draft["id"]) == local.get(draft["id"]) + assert remote.get(draft["id"])["acceptance"] == "pending" + with pytest.raises(RFCClientError) as exc: + RFCClient(service_url=server.public_url, token="invalid").get(draft["id"]) + assert exc.value.status == 401 + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + + +def test_cli_bootstrap_and_private_import_need_no_model_credentials(tmp_path, monkeypatch, capsys): + from infermatrix_copilot.rfc_service.cli import main + + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("RFC_SERVICE_URL", raising=False) + state = tmp_path / "state" + assert main(["--state-dir", str(state), "bootstrap-admin", "--name", "Owner"]) == 0 + bootstrap = json.loads(capsys.readouterr().out) + monkeypatch.setenv("RFC_TOKEN", bootstrap["token"]) + client = RFCClient(state_dir=state, token=bootstrap["token"]) + repo = client.dispatch("repositories.create", {"name": "Local RFCs", "provider": "local"}) + path = tmp_path / "RFC.md" + path.write_text("# Existing RFC\n\n- [ ] Implement the change.\n", encoding="utf-8") + assert main(["--state-dir", str(state), "import", "--repo", repo["id"], "--file", str(path)]) == 0 + result = json.loads(capsys.readouterr().out) + assert result["title"] == "Existing RFC" + assert result["body"] == path.read_text(encoding="utf-8") + assert result["state"] == "draft" + assert main(["--state-dir", str(state), "publish", result["id"], "--content-digest", + result["content_digest"], "--idempotency-key", "example"]) == 2 + assert "requires --post" in capsys.readouterr().err + assert client.dispatch("operations.list")["operations"] == [] + exported = tmp_path / "export.json" + assert main(["--state-dir", str(state), "export", result["id"], "--format", "json", "--out", str(exported)]) == 0 + assert json.loads(exported.read_text(encoding="utf-8"))["markdown"] == result["body"] + markdown = tmp_path / "export.md" + assert main(["--state-dir", str(state), "export", result["id"], "--format", "markdown", "--out", str(markdown)]) == 0 + assert markdown.read_text(encoding="utf-8") == result["body"] + + +def test_local_cli_worker_completes_sdk_publication(tmp_path, monkeypatch, capsys): + from infermatrix_copilot.rfc_service.cli import main + + service, bootstrap, client = _workspace(tmp_path) + monkeypatch.setenv("RFC_TOKEN", bootstrap["token"]) + monkeypatch.delenv("RFC_SERVICE_URL", raising=False) + repo = client.dispatch("repositories.create", {"name": "Publication", "provider": "local"}) + draft = client.draft({"repo_id": repo["id"], "title": "Local publication", "body": "# RFC\n\n- [ ] Implement a portable feature.\n"}) + operation = client.publish(draft["id"], content_digest=draft["content_digest"], + idempotency_key="publish-once", post=True) + assert client.operation(operation["operation_id"])["status"] == "pending" + assert main(["--state-dir", str(tmp_path / "state"), "--local", "sync", "--once"]) == 0 + capsys.readouterr() + completed = client.operation(operation["operation_id"]) + assert completed["status"] == "succeeded", completed + published = client.get(draft["id"]) + assert published["source"]["provider"] == "local" + assert published["source"]["identifier"].endswith(".md") + # Retrying the same intent identifies its durable operation, not another file. + retried = client.publish(draft["id"], content_digest=draft["content_digest"], + idempotency_key="publish-once", post=True) + assert retried["operation_id"] == operation["operation_id"] + + +def test_cli_crlf_source_import_enrollment_and_markdown_export_preserve_bytes(tmp_path, monkeypatch, capsys): + from infermatrix_copilot.rfc_service.cli import main + + service, bootstrap, client = _workspace(tmp_path) + monkeypatch.setenv("RFC_TOKEN", bootstrap["token"]) + monkeypatch.delenv("RFC_SERVICE_URL", raising=False) + repo = client.dispatch("repositories.create", {"name": "Windows source", "provider": "local"}) + path = service.store.root / "files" / repo["id"] / "rfcs" / "windows.md" + path.parent.mkdir() + original = b"# Windows RFC\r\n\r\n#### F1. Portable implementation\r\n\r\n## Acceptance criteria\r\n\r\n- Preserve source line endings.\r\n" + path.write_bytes(original) + source_file = tmp_path / "source.json" + source_file.write_text(json.dumps(SourceRef("local", repo["external_name"], "markdown", + "rfcs/windows.md", path="rfcs/windows.md").to_dict()), encoding="utf-8") + base = ["--state-dir", str(tmp_path / "state")] + assert main([*base, "import", "--repo", repo["id"], "--file", str(path), "--source", str(source_file)]) == 0 + imported = json.loads(capsys.readouterr().out) + assert imported["body"].encode("utf-8") == original + assert main([*base, "enroll", imported["id"]]) == 0 + queued = json.loads(capsys.readouterr().out) + assert main([*base, "sync", "--once"]) == 0 + capsys.readouterr() + assert client.operation(queued["operation_id"])["status"] == "succeeded" + output = tmp_path / "export.md" + assert main([*base, "export", imported["id"], "--format", "markdown", "--out", str(output)]) == 0 + assert output.read_bytes() == original + + +def test_copilot_rfc_command_forwards_to_service_cli(monkeypatch): + from infermatrix_copilot.cli.entry import main + from infermatrix_copilot.rfc_service import cli + + calls = [] + monkeypatch.setattr(cli, "main", lambda argv: calls.append(argv) or 0) + assert main(["rfc", "status", "rfc-example"]) == 0 + assert calls == [["status", "rfc-example"]] + + +def test_mcp_rfc_tools_use_host_identity_without_endpoint_parameters(monkeypatch): + # Existing fixture supplies a lightweight transport; no MCP installation, + # model backend or live source provider is involved. + from test_thin_mcp_server import _fake_mcp + + calls = [] + class FakeClient: + def dispatch(self, action, payload=None): + calls.append((action, payload)) + return {"accepted": True} + + monkeypatch.setattr(RFCClient, "from_env", lambda: FakeClient()) + mcp, _ = _fake_mcp(monkeypatch) + assert mcp.tools["rfc_status"]("rfc-1") == {"accepted": True} + assert calls == [("rfcs.get", {"rfc_id": "rfc-1"})] + import inspect + + assert list(inspect.signature(mcp.tools["rfc_request"]).parameters) == ["action", "payload"] + assert mcp.tool_annotations["rfc_request"].readOnlyHint is False diff --git a/test/test_rfc_migration.py b/test/test_rfc_migration.py new file mode 100644 index 00000000..c52ac71a --- /dev/null +++ b/test/test_rfc_migration.py @@ -0,0 +1,238 @@ +"""Legacy migration preserves prose, permissions, uncertainty and rollback state.""" +import json +import os +from pathlib import Path +import sqlite3 + +import pytest + +from infermatrix_copilot.rfc_service.application import RFCService +from infermatrix_copilot.rfc_service.models import RFCError, SourceRef +from infermatrix_copilot.rfc_service.migration import migrate +from infermatrix_copilot.rfc_service import migration + + +BODY = ("# Existing RFC\r\n\r\n### Engine\r\n\r\n#### E7. Benchmark and harness\r\n" + "https://github.com/legacy-owner/legacy-repo/pull/1\r\n" + "https://github.com/legacy-owner/legacy-repo/pull/2\r\n\r\n" + "### Model\r\n\r\n#### L6. Optional kernels\r\n\r\n" + "## Acceptance criteria\r\n\r\n### Hardware\r\n\r\n- Validate latency.\r\n") + + +def workspace(tmp_path): + service = RFCService(tmp_path / "state", {"github": object()}) + admin = service.authenticate(service.bootstrap_admin("Owner")["token"]) + repo = service.dispatch(admin, "repositories.create", {"name": "Legacy repository", "provider": "github", "external_name": "legacy-owner/legacy-repo"}) + source = SourceRef("github", "legacy-owner/legacy-repo", "issue", "7074", + "https://github.com/legacy-owner/legacy-repo/issues/7074", host="github.com") + rfc = service.dispatch(admin, "rfcs.draft", {"repo_id": repo["id"], "title": "Existing RFC", "body": BODY, "source": source.to_dict()}) + return service, admin, repo, rfc + + +def legacy(tmp_path): + root = tmp_path / "legacy" + root.mkdir() + (root / "roadmap.json").write_text(json.dumps({"body": BODY}), encoding="utf-8") + (root / "roadmap-work").mkdir() + prs = {} + for number, state in ((1, "open"), (2, "closed")): + prs[str(number)] = {"number": number, "title": "Implementation", "body": "PR body", "state": state, + "draft": number == 1, "merged_at": "2026-10-02T08:00:00Z" if number == 2 else None, + "updated_at": "2026-10-02T08:00:00Z", "head": {"sha": ("a" if number == 1 else "b") * 40}, + "html_url": f"https://github.com/legacy-owner/legacy-repo/pull/{number}", "user": {"login": "historical-author"}} + (root / "roadmap-work" / "prs.json").write_text(json.dumps(prs), encoding="utf-8") + con = sqlite3.connect(root / "campaign.sqlite") + con.executescript(""" + CREATE TABLE feature_claims(id INTEGER,feature TEXT,login TEXT,note TEXT,token_hash TEXT,ip TEXT,created_at TEXT); + CREATE TABLE feature_priority(feature TEXT,priority TEXT,token_hash TEXT,ip TEXT,updated_at TEXT); + CREATE TABLE roadmap_tasks(id INTEGER,roadmap TEXT,op TEXT,feature TEXT,group_prefix TEXT,title TEXT, + depends_on TEXT,number INTEGER,login TEXT,note TEXT,token_hash TEXT,ip TEXT,created_at TEXT,applied_at TEXT,reverted_at TEXT,error TEXT); + CREATE TABLE roadmap_auto_items(roadmap TEXT,number INTEGER,kind TEXT,title TEXT,state TEXT,author TEXT, + url TEXT,decision TEXT,target TEXT,group_prefix TEXT,new_title TEXT,depends_on TEXT,rationale TEXT, + applied_at TEXT,tombstoned_at TEXT); + CREATE TABLE roadmap_auto_runs(id INTEGER,roadmap TEXT,at TEXT,mode TEXT,discovered INTEGER,judged INTEGER,applied INTEGER,tombstoned INTEGER,error TEXT); + INSERT INTO feature_claims VALUES(1,'E7','unverified-login','Anonymous browser claim','SECRET-HASH','SECRET-IP','2026-09-01'); + INSERT INTO feature_priority VALUES('E7','optional','SECRET-HASH','SECRET-IP','2026-09-01'); + INSERT INTO roadmap_tasks(id,roadmap,op,feature,login,created_at,applied_at) VALUES(1,'wm-7074','drop','L6','anonymous','2026-09-01','2026-09-02'); + INSERT INTO roadmap_tasks(id,roadmap,op,feature,login,created_at) VALUES(2,'wm-7074','drop','E7','anonymous','2026-09-03'); + INSERT INTO roadmap_auto_items VALUES('wm-7074',1,'pr','Draft harness','draft','author','unused','attach','E7',NULL,NULL,NULL,'Explicit E7 relationship',NULL,NULL); + INSERT INTO roadmap_auto_items VALUES('wm-7074',2,'pr','Unrelated work','merged','author','unused','ignore',NULL,NULL,NULL,NULL,'Out of scope',NULL,NULL); + INSERT INTO roadmap_auto_items VALUES('wm-7074',3,'pr','Applied kernel','open','author','unused','attach','L6',NULL,NULL,NULL,'Applied association','2026-09-02',NULL); + INSERT INTO roadmap_auto_items VALUES('wm-7074',4,'pr','Deleted addition','open','author','unused','new','M3','M','New work',NULL,'Intentionally deleted','2026-09-02','2026-09-03'); + INSERT INTO roadmap_auto_items VALUES('other-rfc',99,'pr','Other campaign','open','author','unused','ignore',NULL,NULL,NULL,NULL,'Other namespace',NULL,NULL); + INSERT INTO roadmap_auto_runs VALUES(1,'wm-7074','2026-09-02','dry-run',4,4,0,0,NULL); + """) + con.commit() + con.close() + (root / "evidence-ledger.json").write_text(json.dumps({"entries": [{"id": "legacy-run", "verdict": "passing", + "revision": "old-head", "environment": "H200", "result": "latency passed", "token": "SECRET-TOKEN"}]}), encoding="utf-8") + return root + + +def run(service, actor, repo, rfc, root, apply=False): + return migrate(service, actor, root, repo["id"], rfc["id"], apply=apply) + + +def test_dry_run_is_read_only_and_compares_exact_prose_ids_and_counts(tmp_path): + service, admin, repo, rfc = workspace(tmp_path) + root = legacy(tmp_path) + old_target = service.store.one("SELECT * FROM rfcs WHERE id=?", (rfc["id"],)) + old_source = (root / "campaign.sqlite").read_bytes() + (root / "campaign.sqlite").chmod(0o444) + result = run(service, admin, repo, rfc, root) + assert result["eligible"] and result["body_matches"] and result["feature_ids_match"] + assert result["counts"] == {"claims": 1, "priorities": 1, "tasks": 2, "auto_items": 4, + "auto_runs": 1, "prs": 2, "historical_evidence": 1, "features": 2} + assert not result["applied"] and result["backup_path"] == "" + assert old_source == (root / "campaign.sqlite").read_bytes() + assert old_target == service.store.one("SELECT * FROM rfcs WHERE id=?", (rfc["id"],)) + assert not (service.store.root / "backups").exists() + + +def test_apply_preserves_owners_prose_ids_and_keeps_partial_delivery_unaccepted(tmp_path): + service, admin, repo, rfc = workspace(tmp_path) + root = legacy(tmp_path) + service.dispatch(admin, "rfcs.work", {"rfc_id": rfc["id"], "op": "claim", "feature_id": "E7", "reason": "I own this work"}) + result = run(service, admin, repo, rfc, root, True) + view = service.dispatch(admin, "rfcs.get", {"rfc_id": rfc["id"]}) + assert result["applied"] and Path(result["backup_path"]).is_file() + if os.name != "nt": + assert Path(result["backup_path"]).stat().st_mode & 0o777 == 0o600 + assert view["body"].encode("utf-8") == BODY.encode("utf-8") + assert [f["id"] for f in view["features"]] == ["E7", "L6"] + e7, l6 = view["features"] + assert e7["owner"] == "Owner" and e7["owner_user_id"] == admin.user_id + assert e7["priority"] == "optional" and e7["implementation"] == "partial" + assert l6["dropped"] and "L6" in view["tombstones"] + assert view["legacy_namespace"] == "wm-7074" + assert view["historical_claims"][0]["authenticated"] is False + assert view["historical_claims"][0]["id"] == "wm-7074:claim:1" + assert {s["status"] for s in view["suggestions"]} == {"proposed", "rejected", "applied"} + assert "Out of scope" in {s["reason"] for s in view["suggestions"]} + assert view["historical_evidence"][0]["stale"] and not view["historical_evidence"][0]["accepted"] + assert view["acceptance"] == "pending" and not view["complete"] + observation = view["observations"]["https://github.com/legacy-owner/legacy-repo/pull/1"] + assert observation["head_sha"] == "a" * 40 and observation["kind"] == "pr" and observation["state"] == "draft" + assert "SECRET" not in json.dumps(view) + assert "backup_path" not in view["legacy_migration"] + assert service.store.one("SELECT value FROM metadata WHERE key='legacy_alias:wm-7074'")["value"] == rfc["id"] + + +def test_repeated_migration_creates_no_extra_backup_or_audit_and_does_not_reset_edits(tmp_path): + service, admin, repo, rfc = workspace(tmp_path) + root = legacy(tmp_path) + first = run(service, admin, repo, rfc, root, True) + service.dispatch(admin, "rfcs.work", {"rfc_id": rfc["id"], "op": "update", "feature_id": "E7", "feature": {"priority": "normal"}}) + before = service.store.one("SELECT * FROM rfcs WHERE id=?", (rfc["id"],)) + second = run(service, admin, repo, rfc, root, True) + assert second["already_applied"] and not second["applied"] and second["backup_path"] == first["backup_path"] + assert before == service.store.one("SELECT * FROM rfcs WHERE id=?", (rfc["id"],)) + assert len(list((service.store.root / "backups").iterdir())) == 1 + assert len(service.store.all("SELECT * FROM audit WHERE action='legacy.migrate'")) == 1 + + +@pytest.mark.parametrize("mismatch", ["body", "features", "source"]) +def test_comparison_mismatch_blocks_apply_before_backup(tmp_path, mismatch): + service, admin, repo, rfc = workspace(tmp_path) + root = legacy(tmp_path) + with service.store.transaction() as con: + row = service._rfc(con, admin, rfc["id"]) + if mismatch == "body": + con.execute("UPDATE rfcs SET body=? WHERE id=?", (BODY.replace("Existing RFC", "Changed RFC"), rfc["id"])) + elif mismatch == "features": + model = json.loads(row["model"]) + model["features"].pop() + con.execute("UPDATE rfcs SET model=? WHERE id=?", (json.dumps(model), rfc["id"])) + else: + source = json.loads(row["source"]) + source["identifier"] = "999" + con.execute("UPDATE rfcs SET source=? WHERE id=?", (json.dumps(source), rfc["id"])) + assert not run(service, admin, repo, rfc, root)["eligible"] + with pytest.raises(RFCError) as error: + run(service, admin, repo, rfc, root, True) + assert error.value.status == 409 + assert not (service.store.root / "backups").exists() + + +def test_migration_requires_fresh_maintainer_rfc_permission_before_reading_source(tmp_path): + service, admin, repo, rfc = workspace(tmp_path) + created = service.dispatch(admin, "users.create", {"name": "Contributor"}) + token = service.dispatch(admin, "tokens.create", {"user_id": created["id"]}) + contributor = service.authenticate(token["token"]) + service.dispatch(admin, "grants.set", {"repo_id": repo["id"], "user_id": created["id"], "role": "contributor"}) + with pytest.raises(RFCError) as error: + run(service, contributor, repo, rfc, tmp_path / "does-not-exist") + assert error.value.status == 403 + service.dispatch(admin, "grants.set", {"repo_id": repo["id"], "user_id": created["id"], "role": "maintainer"}) + service.dispatch(admin, "rfcs.acl", {"rfc_id": rfc["id"], "restricted": True, "grants": {}}) + with pytest.raises(RFCError) as error: + run(service, contributor, repo, rfc, tmp_path / "does-not-exist") + assert error.value.status == 403 + + +def test_revocation_during_legacy_read_prevents_backup_and_apply(tmp_path, monkeypatch): + service, admin, repo, rfc = workspace(tmp_path) + root = legacy(tmp_path) + real_read = migration._read_legacy + def read_then_revoke(path): + value = real_read(path) + service.dispatch(admin, "tokens.revoke", {"token_id": admin.credential_id}) + return value + monkeypatch.setattr(migration, "_read_legacy", read_then_revoke) + with pytest.raises(RFCError) as error: + run(service, admin, repo, rfc, root, True) + assert error.value.status == 401 + assert not (service.store.root / "backups").exists() + + +def test_failed_apply_rolls_back_and_backup_restores_pre_migration_database(tmp_path, monkeypatch): + service, admin, repo, rfc = workspace(tmp_path) + root = legacy(tmp_path) + original = service.store.one("SELECT * FROM rfcs WHERE id=?", (rfc["id"],)) + real_audit = service.store.audit + def failed_audit(*args, **kwargs): + if args[2] == "legacy.migrate": + raise RuntimeError("simulated commit failure") + return real_audit(*args, **kwargs) + monkeypatch.setattr(service.store, "audit", failed_audit) + with pytest.raises(RuntimeError): + run(service, admin, repo, rfc, root, True) + assert original == service.store.one("SELECT * FROM rfcs WHERE id=?", (rfc["id"],)) + assert not service.store.one("SELECT * FROM metadata WHERE key='legacy_alias:wm-7074'") + backup = next((service.store.root / "backups").iterdir()) + with sqlite3.connect(backup) as con: + assert con.execute("SELECT body FROM rfcs WHERE id=?", (rfc["id"],)).fetchone()[0] == BODY + monkeypatch.setattr(service.store, "audit", real_audit) + result = run(service, admin, repo, rfc, root, True) + source = sqlite3.connect(result["backup_path"]) + target = service.store.connect() + source.backup(target) + target.close() + source.close() + assert original == service.store.one("SELECT * FROM rfcs WHERE id=?", (rfc["id"],)) + assert not service.store.one("SELECT * FROM metadata WHERE key='legacy_alias:wm-7074'") + + +def test_live_observations_are_not_overwritten_by_older_snapshot(tmp_path): + service, admin, repo, rfc = workspace(tmp_path) + root = legacy(tmp_path) + with service.store.transaction() as con: + row = service._rfc(con, admin, rfc["id"]) + model = json.loads(row["model"]) + model["observations"] = {"https://github.com/legacy-owner/legacy-repo/pull/1": {"kind": "pr", "state": "open", "head_sha": "new-head", "revision": "fresh"}} + service._save(con, admin, row, model) + run(service, admin, repo, rfc, root, True) + view = service.dispatch(admin, "rfcs.get", {"rfc_id": rfc["id"]}) + assert view["observations"]["https://github.com/legacy-owner/legacy-repo/pull/1"]["head_sha"] == "new-head" + + +def test_pr_snapshot_origin_must_match_selected_repository_before_backup(tmp_path): + service, admin, repo, rfc = workspace(tmp_path) + root = legacy(tmp_path) + path = root / "roadmap-work" / "prs.json" + prs = json.loads(path.read_text(encoding="utf-8")) + prs["1"]["html_url"] = "https://github.com/another-owner/another-repo/pull/1" + path.write_text(json.dumps(prs), encoding="utf-8") + with pytest.raises(RFCError, match="different repository"): + run(service, admin, repo, rfc, root, True) + assert not (service.store.root / "backups").exists() diff --git a/test/test_rfc_providers.py b/test/test_rfc_providers.py new file mode 100644 index 00000000..ab6ec219 --- /dev/null +++ b/test/test_rfc_providers.py @@ -0,0 +1,400 @@ +"""Provider contract tests use recorded-style payloads and temporary repositories.""" +from __future__ import annotations + +from copy import deepcopy +import hashlib +import os +from pathlib import Path +import shutil +import subprocess +from urllib.parse import parse_qs, urlsplit + +import pytest + +from infermatrix_copilot.rfc_service.models import ProviderError, RFCError, SourceRef +from infermatrix_copilot.rfc_service import providers +from infermatrix_copilot.rfc_service.providers import AtomGitProvider, GitHubProvider, LocalProvider + + +class Transport: + def __init__(self, *responses): + self.responses = list(responses) + self.calls = [] + + def __call__(self, method, url, headers, payload): + self.calls.append((method, url, dict(headers), deepcopy(payload))) + if not self.responses: + raise AssertionError("Unexpected provider request") + response = self.responses.pop(0) + if isinstance(response, Exception): + raise response + if isinstance(response, tuple): + return response + return 200, {}, deepcopy(response) + + +def issue(number="7", *, body="RFC body", state="open", host="github.com", **extra): + return {"number": number, "title": "Cache lifecycle RFC", "body": body, "state": state, + "updated_at": "2026-10-02T08:00:00Z", "html_url": f"https://{host}/org/repo/issues/{number}", + "user": {"login": "author"}, **extra} + + +def pr(number="8", **extra): + value = issue(number, **extra) + value["html_url"] = value["html_url"].replace("/issues/", "/pull/") + return value + + +@pytest.mark.parametrize("raw,expected", [ + (pr(state="open", draft=True), "draft"), + (pr(state="closed", draft=True), "closed"), + (pr(state="closed", merged_at="2026-10-02T08:01:00Z"), "merged"), + (pr(state="closed", merged=True), "merged"), + (pr(state="open"), "open"), + (pr(state="mystery"), "unknown"), +]) +def test_github_normalizes_authoritative_pr_state(raw, expected): + transport = Transport(raw) + provider = GitHubProvider("secret-token", transport=transport) + result = provider.get_item(SourceRef("github", "org/repo", "pr", "8")) + assert result["state"] == expected + assert result["source"]["kind"] == "pr" + assert result["url"] == "https://github.com/org/repo/pull/8" + assert urlsplit(transport.calls[0][1]).path == "/repos/org/repo/pulls/8" + assert transport.calls[0][2]["Authorization"] == "Bearer secret-token" + assert transport.calls[0][2]["X-GitHub-Api-Version"] == "2022-11-28" + assert "secret-token" not in transport.calls[0][1] + + +def test_atomgit_issue_identifier_remains_opaque_case_sensitive(): + raw = issue("IAb9", host="atomgit.com", state="opened") + transport = Transport(raw) + result = AtomGitProvider(transport=transport).get_source(SourceRef("atomgit", "org/repo", "issue", "IAb9")) + assert result["source"]["identifier"] == "IAb9" + assert result["native_state"] == "opened" + assert result["state"] == "open" + assert urlsplit(transport.calls[0][1]).path == "/api/v5/repos/org/repo/issues/IAb9" + + +def test_atomgit_pr_review_participants_are_not_implementation_owners(): + raw = pr(host="atomgit.com", state="merged", draft=False, head={"sha": "abc"}, + assignees=[{"login": "reviewer"}], approval_reviewers=[{"login": "approver"}], testers=[{"login": "tester"}]) + result = AtomGitProvider(transport=Transport(raw)).get_item(SourceRef("atomgit", "org/repo", "pr", "8")) + assert result["author"] == "author" + assert result["owner"] == "" + assert result["owners"] == [] + assert result["reviewers"] == ["reviewer", "approver"] + assert result["head_sha"] == "abc" + assert result["state"] == "merged" + + +def test_github_assignees_and_requested_reviewers_remain_distinct(): + raw = pr(assignees=[{"login": "implementer"}], requested_reviewers=[{"login": "reviewer"}]) + result = GitHubProvider(transport=Transport(raw)).get_item(SourceRef("github", "org/repo", "pr", "8")) + assert result["owner"] == "implementer" + assert result["reviewers"] == ["reviewer"] + + +def test_atomgit_publish_uses_v5_owner_route_and_header_auth(): + transport = Transport(issue("ICase", host="atomgit.com")) + provider = AtomGitProvider("atom-secret", api_url="https://api.atomgit.com", transport=transport) + ref = provider.publish("org/repo", "An RFC", "A proposal", "operation-1") + method, url, headers, payload = transport.calls[0] + assert method == "POST" + assert urlsplit(url).path == "/api/v5/repos/org/issues" + assert payload == {"repo": "repo", "title": "An RFC", "body": "A proposal\n\n\n"} + assert headers["Authorization"] == "Bearer atom-secret" + assert "atom-secret" not in url + assert ref.identifier == "ICase" + + +def test_atomgit_body_update_preserves_required_current_title(): + before = issue("ICase", host="atomgit.com", title="Human changed title") + after = dict(before, body="New managed body", updated_at="2026-10-02T08:02:00Z") + transport = Transport(before, before, after, after) + provider = AtomGitProvider(transport=transport) + ref = SourceRef("atomgit", "org/repo", "issue", "ICase") + revision = provider.get_source(ref)["revision"] + result = provider.update_source(ref, "New managed body", revision) + call = transport.calls[2] + assert call[0] == "PATCH" + assert urlsplit(call[1]).path == "/api/v5/repos/org/issues/ICase" + assert call[3] == {"repo": "repo", "title": "Human changed title", "body": "New managed body"} + assert result["revision"] != revision + + +def test_remote_update_detects_changed_source_and_never_writes(): + before = issue() + changed = dict(before, body="Human edit", updated_at=before["updated_at"]) + transport = Transport(before, changed) + provider = GitHubProvider(transport=transport) + ref = SourceRef("github", "org/repo", "issue", "7") + revision = provider.get_source(ref)["revision"] + with pytest.raises(RFCError) as error: + provider.update_source(ref, "Managed edit", revision) + assert error.value.status == 409 + assert [call[0] for call in transport.calls] == ["GET", "GET"] + + +def test_remote_update_detects_readback_conflict(): + before = issue() + transport = Transport(before, before, dict(before, body="Attempted"), dict(before, body="Concurrent human edit")) + provider = GitHubProvider(transport=transport) + ref = SourceRef("github", "org/repo", "issue", "7") + revision = provider.get_source(ref)["revision"] + with pytest.raises(RFCError) as error: + provider.update_source(ref, "Attempted", revision) + assert error.value.status == 409 + + +def test_publication_recovery_scans_second_page_and_ignores_prs(): + first_page = [issue(str(number)) for number in range(1, 101)] + marked_pr = pr("107", body="", pull_request={"url": "ignored"}) + transport = Transport(first_page, [marked_pr, issue("108", body="")]) + result = GitHubProvider(transport=transport).find_publication("org/repo", "op-1") + assert result.identifier == "108" + assert parse_qs(urlsplit(transport.calls[1][1]).query)["page"] == ["2"] + assert parse_qs(urlsplit(transport.calls[0][1]).query)["state"] == ["all"] + + +def test_incomplete_recovery_cannot_claim_absence(monkeypatch): + monkeypatch.setattr(providers, "MAX_PAGES", 2) + full = [issue(str(number)) for number in range(1, 101)] + provider = GitHubProvider(transport=Transport(full, full)) + with pytest.raises(ProviderError) as error: + provider.find_publication("org/repo", "missing-op") + assert error.value.uncertain + + +def test_duplicate_operation_markers_require_reconciliation(): + marker = "" + provider = GitHubProvider(transport=Transport([issue("1", body=marker), issue("2", body=marker)])) + with pytest.raises(ProviderError) as error: + provider.find_publication("org/repo", "op-1") + assert error.value.uncertain + + +def test_short_page_with_link_next_is_not_silently_truncated(): + transport = Transport((200, {"Link": '; rel="next"'}, [issue("1")]), []) + assert GitHubProvider(transport=transport).find_publication("org/repo", "absent") is None + assert len(transport.calls) == 2 + assert urlsplit(transport.calls[1][1]).path == "/repos/org/repo/issues" + + +def test_creation_network_failure_is_uncertain_and_sanitizes_error(): + transport = Transport(RuntimeError("secret-token http://secret@localhost")) + with pytest.raises(ProviderError) as error: + GitHubProvider("secret-token", transport=transport).publish("org/repo", "RFC", "body", "op-1") + assert error.value.uncertain + assert "secret-token" not in str(error.value) + assert len(transport.calls) == 1 + + +@pytest.mark.parametrize("ref", [ + SourceRef("github", "org/repo", "issue", "7", "https://127.0.0.1/private"), + SourceRef("github", "org/repo", "issue", "7", host="metadata.internal"), + SourceRef("github", "org/../repo", "issue", "7"), + SourceRef("github", "org/repo", "issue", "7/../../private"), + SourceRef("github", "org/repo", "issue", "7", "https://user:password@github.com/org/repo/issues/7"), +]) +def test_arbitrary_source_hosts_and_paths_never_reach_transport(ref): + transport = Transport() + with pytest.raises(RFCError): + GitHubProvider(transport=transport).get_source(ref) + assert not transport.calls + + +def test_discovery_uses_atomgit_issue_and_pr_streams_not_github_search_syntax(): + relevant_issue = issue("IA", host="atomgit.com", body="cache contract") + irrelevant_issue = issue("IB", host="atomgit.com", body="unrelated", title="Media layout") + relevant_pr = pr("8", host="atomgit.com", title="Cache fast path", body="cache implementation", state="merged") + transport = Transport([relevant_issue, irrelevant_issue], [relevant_pr]) + result = AtomGitProvider(transport=transport).discover("org/repo", {"keywords": ["cache"]}, "2026-10-01T00:00:00Z") + assert [(item["source"]["kind"], item["source"]["identifier"]) for item in result] == [("issue", "IA"), ("pr", "8")] + assert result[1]["state"] == "merged" + assert [urlsplit(call[1]).path for call in transport.calls] == ["/api/v5/repos/org/repo/issues", "/api/v5/repos/org/repo/pulls"] + assert parse_qs(urlsplit(transport.calls[1][1]).query)["since"] == ["2026-10-01T00:00:00Z"] + + +@pytest.mark.parametrize("native,expected", [ + ({"state": "closed", "pull_request": {"merged_at": "2026-10-02T08:00:00Z"}}, "merged"), + ({"state": "closed", "pull_request": {}}, "closed"), + ({"state": "open", "draft": True, "pull_request": {}}, "draft"), +]) +def test_github_discovery_preserves_only_native_summary_state_without_detail_get(native, expected): + summary = pr("8", body="cache", **native) + transport = Transport([summary]) + result = GitHubProvider(transport=transport).discover("org/repo", "cache") + assert result[0]["source"]["kind"] == "pr" + assert result[0]["state"] == expected + assert len(transport.calls) == 1 + assert urlsplit(transport.calls[0][1]).path == "/repos/org/repo/issues" + + +def test_discovery_get_budget_depends_on_pages_not_matched_pr_count(): + candidates = [pr(str(number), body="cache", state="closed", pull_request={}) for number in range(1, 251)] + transport = Transport(candidates[:100], candidates[100:200], candidates[200:]) + result = GitHubProvider(transport=transport).discover("org/repo", "cache", "2026-10-01T00:00:00Z") + assert len(result) == 250 + assert all(item["source"]["kind"] == "pr" and item["state"] == "closed" for item in result) + assert len(transport.calls) == 3 + assert all(method == "GET" and urlsplit(url).path == "/repos/org/repo/issues" for method, url, _, _ in transport.calls) + assert [parse_qs(urlsplit(call[1]).query)["page"] for call in transport.calls] == [["1"], ["2"], ["3"]] + assert all(parse_qs(urlsplit(call[1]).query)["since"] == ["2026-10-01T00:00:00Z"] for call in transport.calls) + + +def discovery_context(): + return {"scope": "cache lifecycle", "source": SourceRef("github", "org/repo", "issue", "7", + "https://github.com/org/repo/issues/7", host="github.com").to_dict(), + "features": [{"id": "E1", "track": "Engine", "title": "Cache"}, + {"id": "E10", "track": "Engine", "title": "Other cache work"}]} + + +def test_discovery_context_filters_scope_excludes_rfc_and_attaches_exact_feature(): + raw = issue("8", body="Implements E10. See https://github.com/org/repo/issues/7") + unrelated = issue("9", title="Unrelated numerical precision", body="Vector instructions") + transport = Transport([issue("7"), raw, unrelated]) + result = GitHubProvider(transport=transport).discover("org/repo", discovery_context()) + assert len(result) == 1 + assert result[0]["feature_id"] == "E10" + assert result[0]["track"] == "Engine" + assert result[0]["evidence"]["reference"] == "https://github.com/org/repo/issues/7" + assert result[0]["within_scope"] is False + assert "feature" not in result[0] # Author/reviewer never becomes the feature owner. + + +def test_discovery_exact_backlink_retrieves_work_without_keyword_match(): + raw = issue("8", title="Allocator cleanup", body="E1 https://github.com/org/repo/issues/7") + result = GitHubProvider(transport=Transport([raw])).discover("org/repo", discovery_context()) + assert result[0]["feature_id"] == "E1" + assert result[0]["reason"] == "Explicit RFC reference" + + +@pytest.mark.parametrize("body,can_add", [ + ("Track: Engine\nScope: cache lifecycle", True), + ("Track: Engine\nScope: cache", False), + ("Track: Unrelated\nScope: cache lifecycle", False), + ("Mentions Engine and cache lifecycle", False), + ("Track: Engine\nScope: cache lifecycle\nScope: broader work", False), +]) +def test_discovery_new_work_requires_explicit_track_and_exact_scope(body, can_add): + raw = issue("8", body=body + "\nRFC: https://github.com/org/repo/issues/7") + result = GitHubProvider(transport=Transport([raw])).discover("org/repo", discovery_context()) + assert result[0]["within_scope"] is can_add + assert "feature_id" not in result[0] + + +@pytest.mark.parametrize("body", [ + "E1 and E10 https://github.com/org/repo/issues/7", # Ambiguous target. + "E1 https://github.com/org/repo/issues/70", # Different RFC. +]) +def test_discovery_does_not_resolve_ambiguous_or_prefix_reference(body): + result = GitHubProvider(transport=Transport([issue("8", body=body)])).discover("org/repo", discovery_context()) + assert "feature_id" not in result[0] + + +def test_local_discovery_requires_explicit_relative_rfc_reference(tmp_path): + (tmp_path / "rfc.md").write_text("# RFC\ncache lifecycle\n", encoding="utf-8") + (tmp_path / "related.md").write_text("# Cleanup\nRFC: rfc.md\nImplements E1\n", encoding="utf-8") + (tmp_path / "incidental.md").write_text("# cleanup\nmentions rfc.md\n", encoding="utf-8") + context = discovery_context() + context["source"] = SourceRef("local", "repo", "markdown", "rfc.md", path="rfc.md").to_dict() + result = LocalProvider({"repo": tmp_path}).discover("repo", context) + assert len(result) == 1 + assert result[0]["feature_id"] == "E1" + assert result[0]["evidence"]["reference"] == "rfc.md" + + +def local_ref(path="rfcs/example.md"): + return SourceRef("local", "repo", "markdown", path, path=path) + + +def test_local_publish_recovers_without_overwriting_and_detects_collision(tmp_path): + provider = LocalProvider({"repo": tmp_path}) + ref = provider.publish("repo", "Cache RFC", "The cache proposal", "op-1", path="rfcs/example.md") + original = provider.get_source(ref) + assert original["revision"] == hashlib.sha256((tmp_path / ref.path).read_bytes()).hexdigest() + assert original["title"] == "Cache RFC" + assert provider.find_publication("repo", "op-1") == ref + assert provider.publish("repo", "Different title", "Other body", "op-1", path=ref.path) == ref + assert provider.get_source(ref)["body"] == original["body"] + with pytest.raises(RFCError) as error: + provider.publish("repo", "Another RFC", "body", "op-2", path=ref.path) + assert error.value.status == 409 + + +def test_local_managed_update_checks_digest_and_writes_atomically(tmp_path): + provider = LocalProvider({"repo": tmp_path}) + ref = provider.publish("repo", "RFC", "Human prose", "op-1") + original = provider.get_source(ref) + updated = provider.update_source(ref, original["body"] + "\nManaged feature\n", original["revision"]) + assert updated["body"].startswith(original["body"]) + assert updated["revision"] != original["revision"] + assert not list((tmp_path / "rfcs").glob(".imrfc-*.tmp")) + with pytest.raises(RFCError) as error: + provider.update_source(ref, "Stale overwrite", original["revision"]) + assert error.value.status == 409 + assert provider.get_source(ref) == updated + + +@pytest.mark.parametrize("path", ["../outside.md", "/etc/passwd.md", "C:\\outside.md", "rfcs/../../outside.md", ".git/private.md", "rfcs/secrets.env"]) +def test_local_rejects_paths_outside_markdown_root(tmp_path, path): + provider = LocalProvider({"repo": tmp_path}) + with pytest.raises(RFCError): + provider.publish("repo", "RFC", "body", "op-1", path=path) + assert list(tmp_path.iterdir()) == [] + + +def test_local_rejects_symlink_file_and_parent_for_reads_and_publication(tmp_path): + root = tmp_path / "allowed" + outside = tmp_path / "outside" + root.mkdir() + outside.mkdir() + (outside / "private.md").write_text("PRIVATE") + (root / "linked.md").symlink_to(outside / "private.md") + (root / "linked-dir").symlink_to(outside, target_is_directory=True) + provider = LocalProvider({"repo": root}) + for path in ["linked.md", "linked-dir/private.md"]: + with pytest.raises(RFCError): + provider.get_source(local_ref(path)) + with pytest.raises(RFCError): + provider.publish("repo", "RFC", "body", "op-1", path="linked-dir/new.md") + assert not (outside / "new.md").exists() + assert provider.discover("repo", "PRIVATE") == [] + + +def test_local_unknown_root_and_remote_url_are_forbidden(tmp_path): + provider = LocalProvider({"repo": tmp_path}) + with pytest.raises(RFCError): + provider.get_source(SourceRef("local", "other", "markdown", path="file.md")) + with pytest.raises(RFCError): + provider.get_source(SourceRef("local", "repo", "markdown", path="file.md", url="https://localhost/private")) + + +def test_local_recovery_does_not_claim_absence_on_unreadable_document(tmp_path): + (tmp_path / "oversized.md").write_bytes(b"x" * (providers.MAX_DOCUMENT_BYTES + 1)) + with pytest.raises(ProviderError) as error: + LocalProvider({"repo": tmp_path}).find_publication("repo", "op-1") + assert error.value.uncertain + + +def test_local_discovery_respects_scope_and_timestamp(tmp_path): + (tmp_path / "old.md").write_text("# Cache\nOld proposal") + (tmp_path / "new.md").write_text("# Cache\nNew proposal") + (tmp_path / "unrelated.md").write_text("# Media\nLayout") + os.utime(tmp_path / "old.md", (1, 1)) + result = LocalProvider({"repo": tmp_path}).discover("repo", "cache", "2020-01-01T00:00:00Z") + assert [item["source"]["path"] for item in result] == ["new.md"] + + +@pytest.mark.skipif(not shutil.which("git"), reason="Git is unavailable") +def test_local_git_commit_observation_does_not_infer_release_acceptance(tmp_path): + subprocess.run(["git", "init", "-q", str(tmp_path)], check=True) + (tmp_path / "README.md").write_text("# Cache\n") + subprocess.run(["git", "-C", str(tmp_path), "add", "README.md"], check=True) + subprocess.run(["git", "-C", str(tmp_path), "-c", "user.name=Contributor", "-c", "user.email=contributor@example.test", "commit", "-qm", "Implement cache"], check=True) + sha = subprocess.check_output(["git", "-C", str(tmp_path), "rev-parse", "HEAD"], text=True).strip() + item = LocalProvider({"repo": tmp_path}).get_item(SourceRef("local", "repo", "commit", sha)) + assert item["revision"] == item["head_sha"] == sha + assert item["author"] == "Contributor" + assert item["state"] == "unknown" diff --git a/test/test_rfc_security.py b/test/test_rfc_security.py new file mode 100644 index 00000000..fbcce6be --- /dev/null +++ b/test/test_rfc_security.py @@ -0,0 +1,342 @@ +"""Adversarial authorization and recovery checks across RFC/source boundaries.""" +from __future__ import annotations + +import copy +import json + +import pytest + +from infermatrix_copilot.rfc_service.application import RFCService +from infermatrix_copilot.rfc_service.models import Principal, ProviderError, RFCError, SourceRef + + +SECRET = "confidential-rfc-detail-do-not-export" +PUBLIC = "# Public RFC\n\n### Engine\n\n#### F1. Public implementation\n\n## Acceptance criteria\n\n- Validate the public output.\n" + + +class Sources: + def __init__(self): + self.records = {} + self.publications = {} + self.reads = 0 + self.writes = 0 + self.before_read = None + self.candidates = [] + self.fail_before_publish = False + + def publish(self, repository, title, body, operation_id, **kwargs): + if self.fail_before_publish: + raise ProviderError("Temporary provider failure") + self.writes += 1 + key = str(len(self.records) + 1) + source = SourceRef("github", repository, "issue", key, + f"https://github.com/{repository}/issues/{key}", host="github.com") + self.records[key] = {"source": source.to_dict(), "title": title, "body": body, + "revision": "revision-" + key, "state": "open"} + self.publications[operation_id] = source + return source + + def find_publication(self, repository, operation_id): + return self.publications.get(operation_id) + + def get_source(self, ref): + self.reads += 1 + callback, self.before_read = self.before_read, None + if callback: + callback() + return copy.deepcopy(self.records[ref.identifier]) + + get_item = get_source + + def discover(self, repository, scope, since=""): + return copy.deepcopy(self.candidates) + + +def workspace(tmp_path): + provider = Sources() + service = RFCService(tmp_path / "state", providers={"github": provider}) + admin = service.authenticate(service.bootstrap_admin("Administrator")["token"]) + repo = service.dispatch(admin, "repositories.create", {"name": "Repository", "provider": "github", "external_name": "owner/repo"}) + return service, admin, repo, provider + + +def test_service_settings_require_admin_and_cannot_expose_or_redirect_credentials(tmp_path): + service, admin, repo, provider = workspace(tmp_path) + contributor, _ = member(service, admin, repo) + for action in ("service.settings", "service.configure"): + with pytest.raises(RFCError) as denied: + service.dispatch(contributor, action, {"sync_seconds": 300}) + assert denied.value.status == 403 + settings = service.dispatch(admin, "service.configure", {"sync_seconds": 300, "default_max_auto_additions": 2}) + assert settings["public_registration"] is False + assert service.capabilities()["default_sync_seconds"] == 300 + fresh = service.dispatch(admin, "rfcs.draft", {"repo_id": repo["id"], "title": "Configured draft", "body": PUBLIC}) + assert fresh["max_auto_additions"] == 2 + provider.records["9"] = {"title": "Imported source", "body": PUBLIC, "revision": "source-9"} + service.dispatch(admin, "rfcs.enroll", {"repo_id": repo["id"], "source": { + "provider": "github", "repository": repo["external_name"], "kind": "issue", "identifier": "9"}}) + result = service.process_pending()[0] + assert result["status"] == "succeeded" + assert service.dispatch(admin, "rfcs.get", {"rfc_id": result["result"]["rfc_id"]})["max_auto_additions"] == 2 + for payload in ({"api_url": "https://attacker.test"}, {"token": "secret"}, {"public_registration": True}, {"sync_seconds": 1}): + with pytest.raises(RFCError): + service.dispatch(admin, "service.configure", payload) + resumed = RFCService(service.store.root, providers={"github": provider}) + assert resumed.capabilities()["default_max_auto_additions"] == 2 + + +def member(service, admin, repo, name="Contributor", role="contributor"): + created = service.dispatch(admin, "users.create", {"name": name}) + token = service.dispatch(admin, "tokens.create", {"user_id": created["id"]}) + service.dispatch(admin, "grants.set", {"repo_id": repo["id"], "user_id": created["id"], "role": role}) + return service.authenticate(token["token"]), token + + +def make_rfc(service, actor, repo, title, body, key): + draft = service.dispatch(actor, "rfcs.draft", {"repo_id": repo["id"], "title": title, "body": body, "scope": "Engine"}) + queued = service.dispatch(actor, "rfcs.publish", {"rfc_id": draft["id"], "content_digest": draft["content_digest"], "post": True, "idempotency_key": key}) + result = service.process_pending() + assert result[0]["status"] == "succeeded", result + return service.dispatch(actor, "rfcs.get", {"rfc_id": draft["id"]}), queued + + +def restricted_pair(tmp_path): + service, admin, repo, provider = workspace(tmp_path) + actor, token = member(service, admin, repo) + hidden, _ = make_rfc(service, admin, repo, SECRET, PUBLIC + SECRET, "hidden-publication") + visible, _ = make_rfc(service, admin, repo, "Public RFC", PUBLIC, "visible-publication") + service.dispatch(admin, "rfcs.acl", {"rfc_id": hidden["id"], "restricted": True, "grants": {}}) + return service, admin, repo, provider, actor, hidden, visible + + +@pytest.mark.parametrize("minimal", [False, True]) +def test_source_preview_cannot_bypass_registered_restricted_rfc(tmp_path, minimal): + service, admin, repo, provider, actor, hidden, visible = restricted_pair(tmp_path) + source = hidden["source"] if not minimal else { + "provider": "github", "kind": "issue", "identifier": hidden["source"]["identifier"]} + before = provider.reads + with pytest.raises(RFCError) as exc: + service.dispatch(actor, "sources.preview", {"repo_id": repo["id"], "source": source}) + assert exc.value.status == 403 + assert SECRET not in str(exc.value) + assert provider.reads == before # the bot must not read on the user's behalf + + +def test_preview_rechecks_rfc_acl_after_provider_io(tmp_path): + service, admin, repo, provider, actor, hidden, visible = restricted_pair(tmp_path) + service.dispatch(admin, "rfcs.acl", {"rfc_id": hidden["id"], "restricted": False}) + provider.before_read = lambda: service.dispatch(admin, "rfcs.acl", { + "rfc_id": hidden["id"], "restricted": True, "grants": {}}) + with pytest.raises(RFCError) as exc: + service.dispatch(actor, "sources.preview", {"repo_id": repo["id"], "source": hidden["source"]}) + assert exc.value.status == 403 + assert SECRET not in str(exc.value) + + +def test_source_preview_cannot_use_second_repository_alias_to_escape_acl(tmp_path): + service, admin, repo, provider, actor, hidden, visible = restricted_pair(tmp_path) + try: + alias = service.dispatch(admin, "repositories.create", { + "name": "Another configured alias", "provider": "github", "external_name": "owner/repo"}) + except RFCError as exc: + assert exc.status == 409 # rejecting duplicate upstream identity is also safe + return + service.dispatch(admin, "grants.set", {"repo_id": alias["id"], "user_id": actor.user_id, "role": "contributor"}) + with pytest.raises(RFCError) as exc: + service.dispatch(actor, "sources.preview", {"repo_id": alias["id"], "source": hidden["source"]}) + assert exc.value.status == 403 + + +def test_queued_enrollment_cannot_extract_restricted_source_using_an_alias(tmp_path): + service, admin, repo, provider, actor, hidden, visible = restricted_pair(tmp_path) + alias = service.dispatch(admin, "repositories.create", { + "name": "Enrollment alias", "provider": "github", "external_name": "owner/repo"}) + service.dispatch(admin, "grants.set", {"repo_id": alias["id"], "user_id": actor.user_id, "role": "maintainer"}) + before = provider.reads + queued = service.dispatch(actor, "rfcs.enroll", {"repo_id": alias["id"], "source": hidden["source"]}) + result = service.process_pending() + assert result[0]["status"] == "failed" + assert provider.reads == before + assert SECRET not in json.dumps(service.dispatch(actor, "operations.get", {"operation_id": queued["operation_id"]})) + assert service.dispatch(actor, "rfcs.list", {"repo_id": alias["id"]})["rfcs"] == [] + + +def test_related_observations_do_not_export_another_rfcs_restricted_body(tmp_path): + service, admin, repo, provider, actor, hidden, visible = restricted_pair(tmp_path) + service.dispatch(admin, "rfcs.work", {"rfc_id": visible["id"], "feature_id": "F1", + "feature": {"links": [hidden["source"]["url"]]}}) + service.dispatch(admin, "rfcs.sync", {"rfc_id": visible["id"]}) + result = service.process_pending() + assert result[0]["status"] == "succeeded", result + for action in ("rfcs.get", "rfcs.export", "rfcs.list"): + result = service.dispatch(actor, action, {"rfc_id": visible["id"]}) + assert SECRET not in json.dumps(result), action + + +@pytest.mark.parametrize("auto_add", [False, True]) +def test_related_discovery_does_not_copy_a_restricted_rfcs_title(tmp_path, auto_add): + service, admin, repo, provider, actor, hidden, visible = restricted_pair(tmp_path) + visible = service.dispatch(admin, "rfcs.update", {"rfc_id": visible["id"], + "expected_revision": visible["revision"], "auto_add": auto_add, "reason": "Select discovery policy"}) + provider.candidates = [{"source": hidden["source"], "url": hidden["source"]["url"], + "title": SECRET, "body": visible["source"]["url"], "track": "Engine", "within_scope": True}] + service.dispatch(admin, "rfcs.sync", {"rfc_id": visible["id"]}) + result = service.process_pending() + assert result[0]["status"] == "succeeded", result + assert SECRET not in json.dumps(service.dispatch(actor, "rfcs.get", {"rfc_id": visible["id"]})) + + +def test_discovery_cannot_copy_a_restricted_rfc_through_another_repository_alias(tmp_path): + service, admin, repo, provider, actor, hidden, visible = restricted_pair(tmp_path) + alias = service.dispatch(admin, "repositories.create", { + "name": "Discovery alias", "provider": "github", "external_name": "owner/repo"}) + service.dispatch(admin, "grants.set", {"repo_id": alias["id"], "user_id": actor.user_id, "role": "contributor"}) + public_alias, _ = make_rfc(service, admin, alias, "Public alias RFC", PUBLIC, "alias-publication") + provider.candidates = [{"source": hidden["source"], "url": hidden["source"]["url"], + "title": SECRET, "body": public_alias["source"]["url"], "track": "Engine", "within_scope": True}] + service.dispatch(admin, "rfcs.sync", {"rfc_id": public_alias["id"]}) + assert service.process_pending()[0]["status"] == "succeeded" + assert SECRET not in json.dumps(service.dispatch(actor, "rfcs.get", {"rfc_id": public_alias["id"]})) + + +def test_discovery_does_not_leave_copied_rfc_details_after_its_acl_is_narrowed(tmp_path): + service, admin, repo, provider, actor, hidden, visible = restricted_pair(tmp_path) + service.dispatch(admin, "rfcs.acl", {"rfc_id": hidden["id"], "restricted": False}) + provider.candidates = [{"source": hidden["source"], "url": hidden["source"]["url"], + "title": SECRET, "body": visible["source"]["url"], "track": "Engine", "within_scope": True}] + service.dispatch(admin, "rfcs.sync", {"rfc_id": visible["id"]}) + assert service.process_pending()[0]["status"] == "succeeded" + service.dispatch(admin, "rfcs.acl", {"rfc_id": hidden["id"], "restricted": True, "grants": {}}) + assert SECRET not in json.dumps(service.dispatch(actor, "rfcs.get", {"rfc_id": visible["id"]})) + + +def test_local_discovery_obeys_physical_source_acl_across_distinct_alias_names(tmp_path): + from infermatrix_copilot.rfc_service.providers import LocalProvider + + checkout = tmp_path / "checkout" + checkout.mkdir() + service = RFCService(tmp_path / "state", providers={"local": LocalProvider({})}, roots=[checkout]) + admin = service.authenticate(service.bootstrap_admin("Administrator")["token"]) + repos = [service.dispatch(admin, "repositories.create", { + "name": name, "external_name": name, "provider": "local", "root": str(checkout)}) + for name in ("private-alias", "public-alias")] + body = "# " + SECRET + "\n\nRFC:rfcs/public.md\nScope: Engine\nTrack: Engine\n" + records = [] + for repo, title, text, path in ((repos[0], SECRET, body, "rfcs/secret.md"), + (repos[1], "Public RFC", PUBLIC, "rfcs/public.md")): + draft = service.dispatch(admin, "rfcs.draft", {"repo_id": repo["id"], "title": title, "body": text, "scope": "Engine"}) + service.dispatch(admin, "rfcs.publish", {"rfc_id": draft["id"], "content_digest": draft["content_digest"], + "post": True, "idempotency_key": path, "path": path}) + assert service.process_pending()[0]["status"] == "succeeded" + records.append(service.dispatch(admin, "rfcs.get", {"rfc_id": draft["id"]})) + service.dispatch(admin, "rfcs.acl", {"rfc_id": records[0]["id"], "restricted": True, "grants": {}}) + actor, token = member(service, admin, repos[1]) + service.dispatch(admin, "rfcs.sync", {"rfc_id": records[1]["id"]}) + assert service.process_pending()[0]["status"] == "succeeded" + assert SECRET not in json.dumps(service.dispatch(actor, "rfcs.get", {"rfc_id": records[1]["id"]})) + + +def test_forged_admin_flag_does_not_widen_a_persisted_identity(tmp_path): + service, admin, repo, provider = workspace(tmp_path) + actor, token = member(service, admin, repo) + forged = Principal(actor.user_id, "Administrator", admin=True, credential_id=actor.credential_id) + with pytest.raises(RFCError) as exc: + service.dispatch(forged, "users.create", {"name": "Injected administrator", "admin": True}) + assert exc.value.status == 403 + assert service.dispatch(actor, "me")["admin"] is False + + +def test_admin_recovery_preserves_original_idempotency_namespace(tmp_path): + service, admin, repo, provider = workspace(tmp_path) + actor, token = member(service, admin, repo, role="maintainer") + provider.fail_before_publish = True + draft = service.dispatch(actor, "rfcs.draft", {"repo_id": repo["id"], "title": "Recoverable", "body": PUBLIC}) + payload = {"rfc_id": draft["id"], "content_digest": draft["content_digest"], "post": True, "idempotency_key": "stable-intent"} + queued = service.dispatch(actor, "rfcs.publish", payload) + assert service.process_pending()[0]["status"] == "failed" + service.dispatch(admin, "operations.retry", {"operation_id": queued["operation_id"]}) + retried = service.dispatch(actor, "rfcs.publish", payload) + assert retried["operation_id"] == queued["operation_id"] + assert len(service.dispatch(admin, "operations.list")["operations"]) == 1 + + +def test_admin_recovery_does_not_collide_with_its_own_request_key(tmp_path): + service, admin, repo, provider = workspace(tmp_path) + actor, token = member(service, admin, repo, role="maintainer") + provider.fail_before_publish = True + queued = [] + for who, title in ((actor, "User intent"), (admin, "Admin intent")): + draft = service.dispatch(who, "rfcs.draft", {"repo_id": repo["id"], "title": title, "body": PUBLIC}) + queued.append(service.dispatch(who, "rfcs.publish", {"rfc_id": draft["id"], "content_digest": draft["content_digest"], "post": True, "idempotency_key": "same-opaque-key"})) + assert all(item["status"] == "failed" for item in service.process_pending()) + recovered = service.dispatch(admin, "operations.retry", {"operation_id": queued[0]["operation_id"]}) + assert recovered["operation_id"] == queued[0]["operation_id"] + + +def test_admin_can_reauthorize_failed_operation_after_original_token_revocation(tmp_path): + service, admin, repo, provider = workspace(tmp_path) + actor, token = member(service, admin, repo, role="maintainer") + provider.fail_before_publish = True + draft = service.dispatch(actor, "rfcs.draft", {"repo_id": repo["id"], "title": "Recover after revocation", "body": PUBLIC}) + queued = service.dispatch(actor, "rfcs.publish", {"rfc_id": draft["id"], + "content_digest": draft["content_digest"], "post": True, "idempotency_key": "authorized-recovery"}) + assert service.process_pending()[0]["status"] == "failed" + service.dispatch(admin, "tokens.revoke", {"token_id": token["token_id"]}) + service.dispatch(admin, "operations.retry", {"operation_id": queued["operation_id"]}) + provider.fail_before_publish = False + assert service.process_pending()[0]["status"] == "succeeded" + operation = service.store.one("SELECT actor,credential_id FROM operations WHERE id=?", (queued["operation_id"],)) + assert operation["actor"] == actor.user_id + assert operation["credential_id"] == admin.credential_id + + +@pytest.mark.parametrize("change", [{"title": "A newly reviewed title"}, {"scope": "A newly reviewed scope"}]) +def test_publication_binds_title_and_tracking_revision_to_its_preview(tmp_path, change): + service, admin, repo, provider = workspace(tmp_path) + preview = service.dispatch(admin, "rfcs.draft", {"repo_id": repo["id"], "title": "Original title", "body": PUBLIC}) + current = service.dispatch(admin, "rfcs.update", { + "rfc_id": preview["id"], "expected_revision": preview["revision"], **change}) + assert current["revision"] != preview["revision"] + with pytest.raises(RFCError) as exc: + service.dispatch(admin, "rfcs.publish", {"rfc_id": preview["id"], + "content_digest": preview["content_digest"], "expected_revision": preview["revision"], + "post": True, "idempotency_key": "stale-preview"}) + assert exc.value.code == "preview_mismatch" + assert provider.writes == 0 + + +def test_expired_delegated_tracking_is_visible_and_requires_fresh_enrollment(tmp_path): + service, admin, repo, provider = workspace(tmp_path) + renewable = service.dispatch(admin, "tokens.create", {"expires_days": 365}) + admin = service.authenticate(renewable["token"]) + actor, token = member(service, admin, repo, role="maintainer") + rfc, queued = make_rfc(service, actor, repo, "Delegated tracking", PUBLIC, "delegate-publication") + # Real providers append a recovery marker; it must not block reauthorization. + provider.records[rfc["source"]["identifier"]]["body"] += "\n\n" + assert rfc["sync_status"] == "active" + now = service.clock() + service.clock = lambda: now + 31 * 86400 + status = service.dispatch(admin, "rfcs.get", {"rfc_id": rfc["id"]}) + assert status["sync_status"] == "requires_reauthorization" + assert status["next_actions"] + assert "enrollment" not in status + assert service.sync_due() == {"queued": []} + service.dispatch(admin, "rfcs.enroll", {"rfc_id": rfc["id"]}) + assert service.process_pending()[0]["status"] == "succeeded" + assert service.dispatch(admin, "rfcs.get", {"rfc_id": rfc["id"]})["sync_status"] == "active" + + +def test_reenrollment_invalidates_acceptance_after_upstream_rfc_changes(tmp_path): + service, admin, repo, provider = workspace(tmp_path) + rfc, _ = make_rfc(service, admin, repo, "Reauthorized source", PUBLIC, "acceptance-publication") + criterion_id = rfc["criteria"][0]["id"] + rfc = service.dispatch(admin, "rfcs.decision", {"rfc_id": rfc["id"], + "criterion_id": criterion_id, "verdict": "passing", + "evidence": {"revision": "accepted-source-revision", "environment": "Linux test workload"}}) + assert rfc["criteria"][0]["verdict"] == "passing" + provider.records[rfc["source"]["identifier"]]["body"] += "\n## Revised scope\n\nThe workload now requires a different delivery guarantee.\n" + service.dispatch(admin, "rfcs.enroll", {"rfc_id": rfc["id"]}) + assert service.process_pending()[0]["status"] == "succeeded" + current = service.dispatch(admin, "rfcs.get", {"rfc_id": rfc["id"]}) + assert current["criteria"][0]["verdict"] == "pending" + assert current["criteria"][0]["evidence"][0]["stale"] is True diff --git a/test/test_thin_mcp_server.py b/test/test_thin_mcp_server.py index 0f93ebe7..64d00ddf 100644 --- a/test/test_thin_mcp_server.py +++ b/test/test_thin_mcp_server.py @@ -110,6 +110,9 @@ def test_direct_entrypoints_do_not_resolve_repo(monkeypatch): "update_knowledge", "doc_search", "doc_read", + "rfc_request", + "rfc_capabilities", + "rfc_status", } review = mcp.tools["review"]( From 9dabbd3f5b2cd3c05bfe7f158883ca251690be17 Mon Sep 17 00:00:00 2001 From: tzhouam Date: Fri, 2 Oct 2026 18:26:36 +0800 Subject: [PATCH 02/24] Harden RFC projections and complete cross-platform contracts --- doc/architecture/SPEC/README.md | 1 + doc/architecture/SPEC/cli.md | 14 +- doc/architecture/SPEC/rfc_service.md | 125 ++++++++++++++++++ doc/architecture/SPEC/thin_mcp_server.md | 30 ++++- doc/features/rfc-service.md | 11 +- .../infermatrix-copilot/skills/imrfc/SKILL.md | 2 +- .../rfc_service/application.py | 23 +++- src/infermatrix_copilot/rfc_service/cli.py | 4 +- .../rfc_service/web/app.js | 41 ++++-- src/infermatrix_copilot/sdk/v1/rfc.py | 20 ++- src/infermatrix_copilot/thin_mcp_server.py | 4 +- test/test_rfc_integration.py | 77 ++++++++++- test/test_rfc_security.py | 28 ++++ 13 files changed, 346 insertions(+), 34 deletions(-) create mode 100644 doc/architecture/SPEC/rfc_service.md diff --git a/doc/architecture/SPEC/README.md b/doc/architecture/SPEC/README.md index 698e779f..6124ba24 100644 --- a/doc/architecture/SPEC/README.md +++ b/doc/architecture/SPEC/README.md @@ -68,6 +68,7 @@ | 跨切 | `review/{diff_summary,triggers,reviewer}` `memory/{debug_memory,skills}` `run_trace` `notify` `metrics` | | 后端 | `providers/{registry,base,claude_code,codex,cursor,deepseek,zcode,audit,harness_llm}` `tool_bridge` | | MCP 面 | `thin_mcp_server` `mcp_server` `mcp_policy` `run_status` `knowledge_docs` `__main__` | +| 共享服务 | `kb_service` `rfc_service`(包规范覆盖各自实现模块) | 平凡的 `__init__.py` 再导出文件不单独出规范 (`engine/steps/__init__` 例外,因为它定义了 `register_builtin_steps`)。 diff --git a/doc/architecture/SPEC/cli.md b/doc/architecture/SPEC/cli.md index 03085ccc..7d4202b6 100644 --- a/doc/architecture/SPEC/cli.md +++ b/doc/architecture/SPEC/cli.md @@ -1,6 +1,6 @@ # cli/ —— 规范 - + `LOC ~1420(6 个文件) · 接口 + 编排门面 · refactor-status: ok` @@ -19,6 +19,8 @@ flag CLI 与 `Copilot` 门面:解析 → 过门 → 执行;并持有 run 目 - `doctor.py` —— 预检诊断(2026-07 新增):逐项 ✓/✗,每个失败给出**唯一**确切的修复命令。 - 子命令:`doctor` 与 `migrate-knowledge`(PR4d 部署期知识迁移;**显式 owner 动作,零 LLM**,需 `--repo `,支持 report-only;见 RUNBOOK)。 +- `rfc`:在主 argparse 解析前把其余 argv 原样交给 `rfc_service.cli.main`, + 与独立的 `infermatrix-rfc` 共用入口,不创建 Copilot run 或调用模型。 ## 公开契约(可从 `infermatrix_copilot.cli` import) `main(argv)`;`Copilot`(`resolve`、`run_task`、`run_playbook`、`run_queue`、 @@ -75,6 +77,8 @@ flag CLI 与 `Copilot` 门面:解析 → 过门 → 执行;并持有 run 目 ## 依赖(允许) `engine/*`、`playbooks/*`、`intent`、`task_spec`、`adapters/base`、`push`、 `review/reviewer`、`notify`、`run_trace`、`config`、`ui`、`chat`。 +独立服务入口允许惰性委托 `kb_service.cli`、`rfc_service.cli`;服务自身的授权、 +状态与执行契约分别属于对应服务规范。 **任何下层都不得 import 它**(**§ARCH.4.2**)。 ## 扩展点 @@ -85,6 +89,8 @@ flag CLI 与 `Copilot` 门面:解析 → 过门 → 执行;并持有 run 目 `test_cli.py`、`test_phase_b.py`、`test_chat.py`、`test_ui.py`; 预约/claim/repo_path 冻结:`test_mcp.py`、`test_idempotency.py`、 `test_contract.py`。 +RFC 子命令原样转发与共享入口:`test_rfc_integration.py`;服务契约见 +[rfc_service.md](rfc_service.md)。 ## 重构备注 拆分**已完成**(它曾是内聚拆分候选)。`Copilot` 类完整留在 `copilot.py`, @@ -93,3 +99,9 @@ flag CLI 与 `Copilot` 门面:解析 → 过门 → 执行;并持有 run 目 ## 2026-09-28 kb 子命令 `infermatrix-copilot kb …` 在 argparse 之前分派到 `kb_service.cli.main`(知识服务运维命令),不启动 Copilot/LLM。 + +## 2026-10-02 RFC 子命令 +`infermatrix-copilot rfc …` 原样转发 RFC 服务选项、子命令与退出码。草稿、文件导入、 +进度查询与发布操作由 RFC 服务处理;这条路径不经过维护任务的 planner 或 run 状态。 +`--post`、内容摘要、权限与操作恢复均遵循 RFC 服务契约,不能由 Copilot 的 `--yes` +替代。 diff --git a/doc/architecture/SPEC/rfc_service.md b/doc/architecture/SPEC/rfc_service.md new file mode 100644 index 00000000..877d7abd --- /dev/null +++ b/doc/architecture/SPEC/rfc_service.md @@ -0,0 +1,125 @@ +# rfc_service/ —— 规范 + + + +`LOC ~2890(8 个实现模块) · 可移植 RFC 应用与接口 · refactor-status: ok` + +## 职责 + +提供仓库无关、支持多个 RFC 的共享应用:起草、导入、明确发布与纳管、工作跟踪、 +关联实现验证和人工验收。HTTP、CLI、SDK 与 MCP 共用应用权限和生命周期;宿主模型 +负责调查与撰写,服务本身不要求模型凭据或仓库知识 adapter。 + +本包规范覆盖其全部实现模块,路径映射遵循 SPEC 的包目录规则。 + +## 功能与模块边界 + +| 模块 | 独占职责 | +|---|---| +| `models` | `SourceRef`、`Principal` 与传输无关的应用/提供方错误 | +| `drafts` | Markdown 草稿模板、保留原文的跟踪解析、依赖校验与确定性进度投影 | +| `store` | SQLite schema、事务、审计、操作 claim 与心跳租约 | +| `application` | 身份、仓库/RFC 权限、草稿/工作/验收动作、操作队列与后台协调 | +| `providers` | GitHub、AtomGit v5、允许根目录内的 Markdown/Git 访问与发布恢复 | +| `http` | 固定资源列表、会话、同源 HTTP 传输及受保护的旧 roadmap 路由 | +| `cli` | 独立服务命令、用户请求、文件交换、前台 worker 与显式迁移入口 | +| `migration` | 旧 Personal-Agent 跟踪状态的只读比较、备份和幂等 sidecar 导入 | + +## 公开契约 + +- `RFCService(state_dir, providers=None, roots=None, clock=...)`; + `bootstrap_admin`、`authenticate`、`dispatch`、`process_pending`、`sync_due`; + `build_service(state_dir, config_path=None)` 从执行主机配置构建提供方。 +- `dispatch(principal, action, payload)` 是所有业务入口的共享授权点。动作族包含 + `users.*`、`tokens.*`、`grants.set`、`repositories.*`、`sources.preview`、 + `rfcs.*`、`operations.*`、`audit.list`、`service.settings/configure`。 +- `rfcs.get` / JSON export 返回完整的可见记录;`rfcs.status` 返回去掉源正文和候选 + 列表的紧凑进度;`rfcs.suggestions` 按可见候选分页,limit 为 1–100。 +- 提供方实现 `get_source`、`get_item`、`publish`、`find_publication`、 + `update_source`、`discover`。源引用携带提供方、仓库、种类、标识与规范 URL/相对路径; + 观察区分作者、所有者与评审者,并返回版本、head SHA 和原生/规范状态。 +- `create_server` / `serve` 提供 HTTP;`main(argv)` 提供 `infermatrix-rfc`。 + API 版本来自包的 `RFC_API_VERSION`,独立于 review API。 +- `migrate(service, principal, source_dir, repo_id, rfc_id, apply=False)` 默认只比较。 + 只有显式 apply 才备份并导入;返回的绝对备份路径属于本地 operator 结果。 + +## 不变量 + +- **仓库中立。** 提供方与外部仓库名来自注册表;本包不含服务仓库知识字面量。 + 本地提供方只访问注册根内的相对 Markdown 路径或明确 Git 对象,拒绝目录穿越、 + 不安全的文件路径与符号链接;源参数不能重定向提供方凭据到任意主机。 +- **权限在共享应用执行。** reader / contributor / maintainer 由持久化身份与仓库 + grant 决定,RFC ACL 只能缩小范围。请求、源预览、所有投影、后台操作及远端 I/O + 后的提交都重新检查当前身份与权限;调用者伪造的 admin 属性没有权威。 + 已注册私有 RFC 的源、候选和关联数据不能通过别名或另一条投影绕过 ACL。 +- **身份与凭据分离。** 用户 token 以摘要落盘,session 绑定 token,禁用/撤销/到期 + 影响后续请求和排队操作。提供方 token 只来自 operator 配置的环境变量,仅放认证 + header,不进入 URL、用户动作参数、错误或访问日志。首位管理员只能 bootstrap 一次。 +- **预览与明确意图。** draft/import 不发布;发布要求 `post=true`、覆盖标题和正文的 + 当前内容摘要及幂等键,可用 `expected_revision` 绑定跟踪预览版本。排队不等于发布 + 成功,源变更/预览冲突必须显式恢复或重新预览。 +- **外部写入可恢复。** 同一 actor 的幂等键绑定同一请求;SQLite claim、租约续期和 + 提交时租约检查协调进程内外 worker。创建携带操作 marker,重试先扫描恢复;无法 + 完成恢复扫描时不得假定未发布。`uncertain` 不自动变成新的创建请求,显式恢复仍 + 使用原操作身份,并重新授权执行凭据。第三方 issue 更新是读/校验/写/读回防护, + 不承诺提供方没有提供的原子 CAS。 +- **原文与跟踪 sidecar 分离。** 解析与迁移不重写源正文,保留稳定 feature ID、 + 历史移除 tombstone 和 sidecar 工作。文件导入及 Markdown 文件导出保留行尾; + Markdown export 交换源正文,JSON export 包含跟踪状态。跟踪变更不自动改上游 + issue 正文。 +- **发现不等于验证。** 发现按页读取原生 summary,完整保留候选,不逐个请求 PR + detail,不把 closed 推断为 merged。关联 PR 经 `get_item` 权威验证,同一链接每轮 + 只读一次。成功扫描提交后推进扫描开始时刻的 cursor,并保留短重叠;失败或不完整 + 扫描不能推进 cursor 或伪造新鲜度。 +- **自动补充有边界。** 模糊关键词只负责召回。自动应用需要明确 RFC 引用和唯一的 + 既有 feature,或明确既有 track 与精确纳管范围,并受每 RFC 上限约束;其余保持 + suggestion。自动补充不能指定所有者、依赖或验收定义;工作变化使旧验收证据失效。 +- **实现和验收独立。** 必需 PR 全部 merged 才是实现完成,混合 merged/draft 是 + partial;issue、文档 URL、Git commit 存在及作者身份不能当作合并或验收事实。 + 验收由维护者作出,passing 需要当前 evidence 的 revision 与 environment,waived + 需要理由;只有实现与验收都完成才返回 complete。 +- **前台与后台读同一状态。** 纳管绑定明确 writer 和授权凭据;凭据失效时停止自动 + 刷新并显示重新授权的 next action。发布、关联验证、发现与验收证据分别记录新鲜度。 + 配置远端工作区失败时返回错误,不能静默创建本地替代库。 +- **HTTP 传输不创造权限。** 静态登录壳不含 RFC 数据;全部数据和旧兼容路由先认证。 + cookie 为 HttpOnly / SameSite=Strict,HTTPS 部署加 Secure;cookie 写请求核对显式 + public origin,不信任代理头。非 loopback 部署要求 HTTPS public URL。仅服务固定 + 资源,不公开数据库、备份或任意磁盘路径。 +- **迁移只保留历史事实。** 旧数据库以只读模式打开,比较原文/ID/源身份,apply 前 + 用 SQLite backup 创建 0600 备份;失败回滚整个目标事务。claims 不认证 GitHub + login,不覆盖已认证所有者;旧 evidence 保持 historical/stale,不能自动通过验收。 + 旧 PR 快照保留实际 head 和状态,不推进 live 验证时钟。同一输入重复迁移不重置 + 后续人工变更;兼容 namespace 的 alias 仍受 RFC 权限保护。 + +## 边界 —— 不属于这里 + +宿主模型选择、仓库知识查询与调查策略属于 Copilot/skill;提供方身份映射属于 adapter。 +本包不要求某一操作系统调度器、SSH 主机或单一 RFC;历史部署迁移是显式 operator +动作,不能隐式接管其他服务的状态与计划。 + +## 依赖(允许) + +应用、状态、提供方、HTTP 与迁移使用 stdlib 和包内模块。CLI 用户动作通过 RFC SDK; +SDK 自身的惰性导入、版本校验和本地/HTTP 选择属于 SDK 契约。HTTP 资源通过 +`importlib.resources` 从同一 wheel 读取。核心不反向依赖 MCP、模型配置或顶层 CLI。 + +## 扩展点 + +新提供方实现统一 contract 并由 operator 注册;新动作进入 `RFCService.dispatch`, +先定义角色、幂等与恢复语义,再由接口薄委托。来源证据与验收定义分开存储。 +部署封装前台 `serve` / `sync --watch`,不把主机路径或凭据放进用户协议。 + +## 测试 + +`test_rfc_application.py`、`test_rfc_providers.py`、`test_rfc_http.py`、 +`test_rfc_integration.py`、`test_rfc_security.py`、`test_rfc_migration.py`。 +提供方测试使用注入 transport;多页 GET 预算、恢复不完整、opaque ID、角色区分、 +本地路径与原子文件更新均有护栏。迁移测试验证原文、历史身份、重复 apply、备份恢复 +和失败回滚;接口测试覆盖失效凭据、ACL、紧凑状态和行尾交换。 +RFC portability workflow 声明 Linux/macOS/Windows 与 Python 3.11/3.12 的六组矩阵。 + +## 重构备注 + +应用是权限与生命周期的单一实现;新增接口保持薄委托。拆分时不得让 CLI/HTTP/MCP +各自复制状态机、权限或验收计算。运行与 operator 示例见 +[RFC 服务使用说明](../../features/rfc-service.md)。 diff --git a/doc/architecture/SPEC/thin_mcp_server.md b/doc/architecture/SPEC/thin_mcp_server.md index 5f7ffb51..d0df3fb1 100644 --- a/doc/architecture/SPEC/thin_mcp_server.md +++ b/doc/architecture/SPEC/thin_mcp_server.md @@ -1,20 +1,24 @@ # thin_mcp_server.py —— 规范 - + `LOC ~490 · 默认 MCP:Direct 门面 + Strict 入口 · refactor-status: ok` ## 职责 安装器**实际注册**的那个 MCP 门面:以**零模型**提供 Direct 模式的知识路由, 并在被要求时桥接到 Strict。 +同时通过 RFC SDK 委托已配置工作区的草稿、纳管、进度与人工决策操作。 ## 功能 -七个工具:`review`(按 `mode` 分流)、`validate_direct_review`、 +十个工具:`review`(按 `mode` 分流)、`validate_direct_review`、 `get_review_status` / `get_review_result`(转发给 `CopilotMCP`)、 -`update_knowledge`、`doc_search`、`doc_read`。 +`update_knowledge`、`doc_search`、`doc_read`,以及 +`rfc_request`、`rfc_capabilities`、`rfc_status`。 ## 公开契约 -上述七个工具;`build_mcp(...)`;`main()`。 +上述十个工具;`build_mcp(...)`;`main()`。 +RFC 工具签名为 `rfc_request(action, payload=None)`、`rfc_capabilities()`、 +`rfc_status(rfc_id)`;后者调用 `rfcs.status`,返回不含源正文和候选列表的紧凑视图。 ## 不变量(**C1**、**C2**、**D1**) - **Direct 在这个 server 里不跑任何模型。** 它返回知识路由和一份治理契约;阅读由 @@ -59,9 +63,19 @@ - `update_knowledge` 只返回知识贡献入口 —— 它**不是** `imupdate` 的发版审计器。 - **每个工具都声明 `ToolAnnotations`,且提示必须真实。** 审批门控的宿主(codex 对 无注解工具逐次弹批准框,headless 下自动取消,见 #86)靠这些提示放行只读面: - `review` 是唯一保留状态变更(预留 Strict run)与触网(Strict 子进程)的工具, - 其余六个全部 `readOnlyHint=true`。把一个会写的工具标成只读,比不标更糟。 + `review` 保留状态变更(预留 Strict run)与触网(Strict 子进程); + `rfc_request` 能写工作区与排队发布,因此 `readOnlyHint=false`、 + `destructiveHint=true`、`idempotentHint=false`。其余八个工具全部 + `readOnlyHint=true`;RFC 能力与进度工具声明 `idempotentHint=true`。 + 把一个会写的工具标成只读,比不标更糟。 (要求 `mcp>=1.8`,注解类型自该版本起可用。) +- **RFC 工作区与身份来自宿主环境。** 每次调用用 `RFCClient.from_env()` 解析 + `RFC_SERVICE_URL` / `RFC_STATE_DIR` 与 `RFC_TOKEN`;工具参数不接受 endpoint 或 + token。共享应用在每次动作及后台执行时重新检查当前权限;远端失败不能切换本地库。 + `RFCClientError` 返回结构化的 `error.code/message`,不暴露凭据。 +- **RFC 变更不通过 review run。** 本门面不实现 RFC 状态机、不调用模型、不改验收; + `rfc_request` 委托共享应用,发布要求明确的 `post=true`、预览摘要和幂等键。 + 返回排队操作不能当作发布成功,必须查询其终态。 ## 边界 —— 不属于这里 Direct 路径里不调模型;不含 Strict 后台机器(`mcp_server.py`);不定义策略 @@ -70,13 +84,15 @@ Direct 路径里不调模型;不含 Strict 后台机器(`mcp_server.py`) ## 依赖(允许) stdlib + `mcp` extra + `.direct_routing`(下划线别名 re-import)+ `.adapters` + `.config` + `.intent.resolve_repo_alias` + -`.knowledge_docs` + `.mcp_policy` + `.mcp_server`。 +`.knowledge_docs` + `.mcp_policy` + `.mcp_server` + 惰性导入 `.sdk.v1.rfc`。 ## 测试 `test_thin_mcp_server.py`(42 例,别名保持调用点不变)、`test_thin_mcp.py`、 `test_imreview_output_contract.py`;外加 `test_contract.py` (`_direct_*` 的公开家与再导出仍然成立)与 `test_e2e_strict_mock.py` (Strict 快照绑定端到端)。 +RFC 工具签名、动作委托、工具注解及宿主身份:`test_rfc_integration.py`。 +应用权限与生命周期由 [rfc_service.md](rfc_service.md) 及其测试约束。 ## 重构备注 拆分**已发生**(→ `contract.py` / `direct_routing.py`,约 1420 → 627 行); diff --git a/doc/features/rfc-service.md b/doc/features/rfc-service.md index 53919471..59219b20 100644 --- a/doc/features/rfc-service.md +++ b/doc/features/rfc-service.md @@ -147,7 +147,7 @@ feature acceptance is an explicit human decision with evidence. Merging a PR never automatically passes acceptance. Use `sync --rfc RFC_ID` to queue a manual refresh. `status` includes freshness, -implementation, acceptance, blockers, suggestions and next actions. Failed +implementation, acceptance, blockers, suggestion counts and next actions. Failed source reads leave unavailable/stale evidence visible instead of inventing progress. Use `rfcs.work` to maintain work and attachments and `rfcs.decision` for acceptance verdicts. `export RFC_ID --format markdown --out RFC-export.md` @@ -163,6 +163,13 @@ export exchanges the RFC source body. Tracking changes do not automatically rewrite the published issue body. File import and Markdown file export preserve the source line endings, including Windows CRLF. +For compact progress through MCP or the versioned API, use `rfc_status` or +`rfcs.status`; they return suggestion counts with the source body and candidate +list omitted. Inspect candidates through `rfcs.suggestions` with +`{rfc_id, offset:0, limit:50}`. The page limit accepts 1–100, and optional `status` +and `query` filters apply before the visible total is counted. `rfcs.get` and +JSON export retain the full record; the SDK accepts JSON responses up to 16 MiB. + ## Provider and state configuration Set `RFC_CONFIG` or pass `--config providers.json` to select operator settings: @@ -222,6 +229,8 @@ client = RFCClient.from_env() print(client.capabilities()) print(client.list_rfcs()) print(client.get("RFC_ID")) +print(client.status("RFC_ID")) +print(client.suggestions("RFC_ID", offset=0, limit=50)) ``` `RFCClient(state_dir=..., token=...)` chooses local execution; diff --git a/plugins/infermatrix-copilot/skills/imrfc/SKILL.md b/plugins/infermatrix-copilot/skills/imrfc/SKILL.md index fd4cdef8..cc27a923 100644 --- a/plugins/infermatrix-copilot/skills/imrfc/SKILL.md +++ b/plugins/infermatrix-copilot/skills/imrfc/SKILL.md @@ -23,6 +23,6 @@ Enroll an existing source with `rfcs.enroll` using `{repo_id, source}` or enroll ## Progress and decisions -Read `rfc_status(rfc_id)` or `rfcs.get`. Show implementation facts, acceptance verdicts, freshness, blockers, and `next_actions` together. A merged PR is implementation evidence; it does not prove performance, integration, deployment, or acceptance. Use `rfcs.sync` to queue fresh source evidence and report stale/unavailable sources honestly. If `sync_status` is `requires_reauthorization`, a maintainer must enroll using a current credential before recurring tracking resumes. +Read `rfc_status(rfc_id)` or `rfcs.status` for compact progress and suggestion counts. Read source prose with `rfcs.get`. Inspect candidate work through `rfcs.suggestions` with `{rfc_id, offset: 0, limit: 50}` and advance the offset while needed; optional `status` and `query` narrow the results. Show implementation facts, acceptance verdicts, freshness, blockers, and `next_actions` together. A merged PR is implementation evidence; it does not prove performance, integration, deployment, or acceptance. Use `rfcs.sync` to queue fresh source evidence and report stale/unavailable sources honestly. If `sync_status` is `requires_reauthorization`, a maintainer must enroll using a current credential before recurring tracking resumes. Use `rfcs.work` for work/attachment changes and `rfcs.decision` for human acceptance decisions, with supporting evidence and reasons. Fetch the current RFC before editing and preserve its IDs and revision. Use `rfcs.export` when the user needs a portable artifact. Do not create a second tracking ledger in knowledge pages or move an existing Personal-Agent deployment merely to use this skill. diff --git a/src/infermatrix_copilot/rfc_service/application.py b/src/infermatrix_copilot/rfc_service/application.py index 72cf74a5..094b1b01 100644 --- a/src/infermatrix_copilot/rfc_service/application.py +++ b/src/infermatrix_copilot/rfc_service/application.py @@ -390,9 +390,28 @@ def _dispatch(self, con, p, action, data): (key, rid, title, body, digest(encode(model) + body), encode(source), encode(model), 0, 0, p.user_id, now)) self.store.audit(con, p.user_id, action, now, rid, key) return self._view(con, p, dict(con.execute("SELECT * FROM rfcs WHERE id=?", (key,)).fetchone())) - if action in ("rfcs.get", "rfcs.export", "rfcs.next"): + if action in ("rfcs.get", "rfcs.export", "rfcs.next", "rfcs.status", "rfcs.suggestions"): row = self._rfc(con, p, data.get("rfc_id", "")) view = self._view(con, p, row) + if action == "rfcs.status": + counts = {} + for item in view.pop("suggestions", []): + state = item.get("status", "proposed") + counts[state] = counts.get(state, 0) + 1 + view.pop("body", None) + return {**view, "suggestion_counts": counts, "suggestions_total": sum(counts.values())} + if action == "rfcs.suggestions": + offset, limit = data.get("offset", 0), data.get("limit", 50) + if type(offset) is not int or offset < 0 or type(limit) is not int or not 1 <= limit <= 100: + raise RFCError("Suggestion offset must be nonnegative and limit between 1 and 100") + items = view.get("suggestions", []) + if data.get("status"): + items = [item for item in items if item.get("status", "proposed") == data["status"]] + if data.get("query"): + query = str(data["query"]).casefold() + items = [item for item in items if query in encode(item).casefold()] + return {"rfc_id": row["id"], "offset": offset, "limit": limit, + "total": len(items), "suggestions": items[offset:offset + limit]} if action == "rfcs.export": return {"rfc_id": row["id"], "markdown": row["body"], "content": row["body"], "tracking": view} return view @@ -609,7 +628,7 @@ def _registered_sources(self, con, repo_id, ref): repo = con.execute("SELECT root FROM repositories WHERE id=?", (repo_id,)).fetchone() target = (Path(repo["root"]) / (ref.path or ref.identifier)).resolve() if ref.provider == "local" and repo else None matches = [] - for r in con.execute("SELECT f.*,r.root FROM rfcs f JOIN repositories r ON r.id=f.repo_id WHERE f.source!='{}'"): + for r in con.execute("SELECT f.id,f.repo_id,f.source,r.root FROM rfcs f JOIN repositories r ON r.id=f.repo_id WHERE f.source!='{}'"): registered = SourceRef.from_dict(json.loads(r["source"])) same = self._same_source(registered, ref) if target and registered.provider == "local": diff --git a/src/infermatrix_copilot/rfc_service/cli.py b/src/infermatrix_copilot/rfc_service/cli.py index 831799ec..0dddb9bf 100644 --- a/src/infermatrix_copilot/rfc_service/cli.py +++ b/src/infermatrix_copilot/rfc_service/cli.py @@ -178,9 +178,9 @@ def worker(): elif command == "list": result = client.list_rfcs(args.repo_id) elif command == "status": - result = client.get(args.rfc_id) + result = client.status(args.rfc_id) elif command == "next": - value = client.get(args.rfc_id) + value = client.status(args.rfc_id) result = {"rfc_id": args.rfc_id, "next_actions": value.get("next_actions", [])} elif command in {"enroll", "decision", "work"}: result = client.dispatch("rfcs." + command, {**_data(args.data), "rfc_id": args.rfc_id}) diff --git a/src/infermatrix_copilot/rfc_service/web/app.js b/src/infermatrix_copilot/rfc_service/web/app.js index 86af4702..e784bca1 100644 --- a/src/infermatrix_copilot/rfc_service/web/app.js +++ b/src/infermatrix_copilot/rfc_service/web/app.js @@ -8,6 +8,8 @@ let viewDirty = false; let selectedTokenUser = ""; let noticeTimer; let refreshCurrent = null; +const suggestionPages = new Map(); +const SUGGESTIONS_PER_PAGE = 50; const labels = { draft: "草稿", proposed: "待评审", discussion: "讨论中", accepted: "已接受", rejected: "已拒绝", superseded: "已取代", planned: "待开始", ready: "就绪", active: "进行中", in_progress: "进行中", @@ -71,6 +73,7 @@ function signedOut() { selectedTokenUser = ""; routeSequence += 1; refreshCurrent = null; + suggestionPages.clear(); content.replaceChildren(); $("identity-name").textContent = ""; $("issued-secret").value = ""; @@ -404,7 +407,7 @@ function dependencyGraph(features) { for (const feature of features) { const {x, y} = positions.get(feature.id); const group = svgNode("g"); - const state = feature.state || "planned"; + const state = feature.implementation || feature.state || "planned"; group.append(svgNode("title", {}, `${feature.id}: ${feature.title}`), svgNode("rect", {x, y, width: 190, height: 64, rx: 8, class: `graph-node ${["accepted", "blocked", "active"].includes(state) ? state : ""}`}), svgNode("text", {x: x + 13, y: y + 24, class: "graph-text"}, String(feature.title || feature.id).slice(0, 15)), svgNode("text", {x: x + 13, y: y + 46, class: "graph-subtext"}, `${feature.id} · ${translated(state)}`)); @@ -588,17 +591,33 @@ function criteriaPanel(rfc) { function suggestionsPanel(rfc) { const list = element("div", {class: "item-list"}); const suggestions = (rfc.suggestions || []).filter((suggestion) => !["accepted", "rejected", "obsolete", "applied"].includes(suggestion.status || suggestion.state || suggestion.verdict)); - for (const suggestion of suggestions) { - list.append(element("article", {class: "suggestion"}, element("h4", {}, suggestion.title || suggestion.feature?.title || "待确认的关联"), - element("p", {class: "small muted"}, suggestion.reason || suggestion.explanation || "请核对这个实现是否属于当前 RFC。"), evidenceLines(suggestion.evidence), - canPublish(rfc) ? element("div", {class: "item-actions"}, ...["accepted", "rejected"].map((verdict) => button(verdict === "accepted" ? "接受建议" : "拒绝", async () => { - const reason = prompt(verdict === "accepted" ? "接受依据" : "拒绝原因(相同建议不会重复出现)"); - if (reason === null || !reason.trim()) return; - await action("rfcs.decision", {rfc_id: rfc.id, kind: "suggestion", suggestion_id: suggestion.id, verdict, reason}); await route(); - }, verdict === "rejected" ? "danger" : ""))) : null)); + const pages = Math.max(1, Math.ceil(suggestions.length / SUGGESTIONS_PER_PAGE)); + let page = Math.min(suggestionPages.get(rfc.id) || 0, pages - 1); + const count = element("span", {class: "small muted", "aria-live": "polite"}); + const previous = element("button", {type: "button", onclick: () => { page -= 1; draw(); }}, "上一页"); + const next = element("button", {type: "button", onclick: () => { page += 1; draw(); }}, "下一页"); + const pagination = element("div", {class: "actions suggestion-pagination"}, count, previous, next); + function draw() { + page = Math.max(0, Math.min(page, pages - 1)); + suggestionPages.set(rfc.id, page); + count.textContent = `${suggestions.length} 条待确认 · 第 ${page + 1} / ${pages} 页`; + previous.disabled = page === 0; + next.disabled = page >= pages - 1; + previous.hidden = next.hidden = suggestions.length <= SUGGESTIONS_PER_PAGE; + list.replaceChildren(); + for (const suggestion of suggestions.slice(page * SUGGESTIONS_PER_PAGE, (page + 1) * SUGGESTIONS_PER_PAGE)) { + list.append(element("article", {class: "suggestion"}, element("h4", {}, suggestion.title || suggestion.feature?.title || "待确认的关联"), + element("p", {class: "small muted"}, suggestion.reason || suggestion.explanation || "请核对这个实现是否属于当前 RFC。"), evidenceLines(suggestion.evidence), + canPublish(rfc) ? element("div", {class: "item-actions"}, ...["accepted", "rejected"].map((verdict) => button(verdict === "accepted" ? "接受建议" : "拒绝", async () => { + const reason = prompt(verdict === "accepted" ? "接受依据" : "拒绝原因(相同建议不会重复出现)"); + if (reason === null || !reason.trim()) return; + await action("rfcs.decision", {rfc_id: rfc.id, kind: "suggestion", suggestion_id: suggestion.id, verdict, reason}); await route(); + }, verdict === "rejected" ? "danger" : ""))) : null)); + } + if (!suggestions.length) list.append(element("p", {class: "small muted"}, "暂无需要确认的建议。明确关联在范围内自动更新,模糊关联留待你判断。")); } - if (!suggestions.length) list.append(element("p", {class: "small muted"}, "暂无需要确认的建议。明确关联在范围内自动更新,模糊关联留待你判断。")); - return panel("待确认建议", list); + draw(); + return panel("待确认建议", element("div", {class: "stack"}, pagination, list)); } function decisionPanel(rfc) { const form = element("form", {class: "stack"}); diff --git a/src/infermatrix_copilot/sdk/v1/rfc.py b/src/infermatrix_copilot/sdk/v1/rfc.py index 4412e0d2..0b42471e 100644 --- a/src/infermatrix_copilot/sdk/v1/rfc.py +++ b/src/infermatrix_copilot/sdk/v1/rfc.py @@ -12,6 +12,8 @@ from ...rfc_service.models import Principal, RFCError, SourceRef from .models import SDKError +_MAX_RESPONSE_BYTES = 16 * 1024 * 1024 + class RFCClientError(SDKError): """Stable transport/domain failure without credentials in its message.""" @@ -93,14 +95,14 @@ def _http(self, path: str, payload: dict[str, Any] | None = None) -> dict[str, A opener = request.build_opener(_NoRedirect()) try: with opener.open(req, timeout=self.timeout) as response: - raw = response.read(4 * 1024 * 1024 + 1) + raw = response.read(_MAX_RESPONSE_BYTES + 1) except error.HTTPError as exc: # Only propagate structured application errors, not proxy HTML or # redirects that could echo private request headers. try: value = json.loads(exc.read(65536)) detail = value.get("error", {}) - except (ValueError, AttributeError): + except (ValueError, AttributeError, OSError): detail = {} if not isinstance(detail, dict): detail = {} @@ -109,7 +111,7 @@ def _http(self, path: str, payload: dict[str, Any] | None = None) -> dict[str, A status=exc.code) from None except (error.URLError, TimeoutError, OSError): raise RFCClientError("RFC service unavailable", code="transport_error", status=503) from None - if len(raw) > 4 * 1024 * 1024: + if len(raw) > _MAX_RESPONSE_BYTES: raise RFCClientError("RFC service response exceeds the client limit", code="invalid_response") try: value = json.loads(raw) @@ -162,6 +164,18 @@ def list_rfcs(self, repo_id: str = "") -> dict[str, Any]: def get(self, rfc_id: str) -> dict[str, Any]: return self.dispatch("rfcs.get", {"rfc_id": rfc_id}) + def status(self, rfc_id: str) -> dict[str, Any]: + return self.dispatch("rfcs.status", {"rfc_id": rfc_id}) + + def suggestions(self, rfc_id: str, *, offset: int = 0, limit: int = 50, + status: str = "", query: str = "") -> dict[str, Any]: + payload = {"rfc_id": rfc_id, "offset": offset, "limit": limit} + if status: + payload["status"] = status + if query: + payload["query"] = query + return self.dispatch("rfcs.suggestions", payload) + def draft(self, payload: dict[str, Any]) -> dict[str, Any]: return self.dispatch("rfcs.draft", payload) diff --git a/src/infermatrix_copilot/thin_mcp_server.py b/src/infermatrix_copilot/thin_mcp_server.py index e9c5cd84..5235a1af 100644 --- a/src/infermatrix_copilot/thin_mcp_server.py +++ b/src/infermatrix_copilot/thin_mcp_server.py @@ -500,7 +500,7 @@ def rfc_request(action: str, payload: dict | None = None) -> dict: remain private until an explicit rfcs.publish with post=true, the exact previewed content_digest and an idempotency_key. Queueing an operation does not establish publication: read operations.get to its terminal state. - Use rfcs.get next_actions for actionable implementation and acceptance + Use rfcs.status next_actions for actionable implementation and acceptance work; a merged PR alone does not establish feature acceptance. """ return rfc_call(action, payload) @@ -517,7 +517,7 @@ def rfc_capabilities() -> dict: idempotentHint=True, openWorldHint=True)) def rfc_status(rfc_id: str) -> dict: """Read cached evidence, acceptance, freshness and next actions for an RFC.""" - return rfc_call("rfcs.get", {"rfc_id": rfc_id}) + return rfc_call("rfcs.status", {"rfc_id": rfc_id}) return mcp diff --git a/test/test_rfc_integration.py b/test/test_rfc_integration.py index e2938c4e..b6ec53d4 100644 --- a/test/test_rfc_integration.py +++ b/test/test_rfc_integration.py @@ -75,16 +75,20 @@ def log_message(self, *args): pass def do_POST(self): + self.rfile.read(int(self.headers.get("Content-Length", "0"))) requests.append((self.path, self.headers.get("Authorization"))) self.send_response(302) self.send_header("Location", "/unexpected") + self.send_header("Content-Length", "0") self.end_headers() def do_GET(self): requests.append((self.path, self.headers.get("Authorization"))) + body = b'{"rfc_api_version":"1.0.0"}' self.send_response(200) + self.send_header("Content-Length", str(len(body))) self.end_headers() - self.wfile.write(b'{"rfc_api_version":"1.0.0"}') + self.wfile.write(body) server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) thread = threading.Thread(target=server.serve_forever, daemon=True) @@ -102,6 +106,66 @@ def do_GET(self): thread.join(timeout=2) +@pytest.mark.parametrize("failure", [ConnectionResetError, TimeoutError]) +def test_http_error_body_connection_failure_preserves_status_and_hides_details(monkeypatch, failure): + from urllib import error, request + + class BrokenBody: + def read(self, size): + raise failure("private token and proxy details") + + def close(self): + pass + + class Refused: + def open(self, req, timeout): + raise error.HTTPError(req.full_url, 503, "private proxy message", {}, BrokenBody()) + + monkeypatch.setattr(request, "build_opener", lambda *handlers: Refused()) + client = RFCClient(service_url="http://127.0.0.1:12345", token="private") + with pytest.raises(RFCClientError) as exc: + client.capabilities() + assert exc.value.status == 503 + assert exc.value.code == "http_error" + assert "private" not in str(exc.value) + + +@pytest.mark.parametrize("oversize", [False, True]) +def test_http_response_limit_accepts_16_mib_and_bounds_larger_payloads(monkeypatch, oversize): + from urllib import request + + limit = 16 * 1024 * 1024 + prefix = b'{"rfc_api_version":"1.0.0","data":"' + suffix = b'"}' + raw = prefix + b"x" * (limit - len(prefix) - len(suffix)) + suffix + (b" " if oversize else b"") + reads = [] + class Response: + def __enter__(self): + return self + + def __exit__(self, *args): + pass + + def read(self, size): + reads.append(size) + return raw[:size] + + class Opener: + def open(self, req, timeout): + return Response() + + monkeypatch.setattr(request, "build_opener", lambda *handlers: Opener()) + client = RFCClient(service_url="http://127.0.0.1:12345", token="example") + if oversize: + with pytest.raises(RFCClientError) as exc: + client.capabilities() + assert exc.value.code == "invalid_response" + assert "exceeds" in str(exc.value) + else: + assert len(client.capabilities()["data"]) == limit - len(prefix) - len(suffix) + assert reads == [limit + 1] + + def _workspace(tmp_path): from infermatrix_copilot.rfc_service.application import build_service @@ -125,6 +189,10 @@ def test_local_sdk_and_authenticated_http_return_the_same_rfc(tmp_path): remote = RFCClient(service_url=server.public_url, token=bootstrap["token"]) assert remote.capabilities() == local.capabilities() assert remote.get(draft["id"]) == local.get(draft["id"]) + assert remote.status(draft["id"]) == local.status(draft["id"]) + assert "body" not in remote.status(draft["id"]) + assert "suggestions" not in remote.status(draft["id"]) + assert remote.suggestions(draft["id"]) == local.suggestions(draft["id"]) assert remote.get(draft["id"])["acceptance"] == "pending" with pytest.raises(RFCClientError) as exc: RFCClient(service_url=server.public_url, token="invalid").get(draft["id"]) @@ -148,10 +216,11 @@ def test_cli_bootstrap_and_private_import_need_no_model_credentials(tmp_path, mo repo = client.dispatch("repositories.create", {"name": "Local RFCs", "provider": "local"}) path = tmp_path / "RFC.md" path.write_text("# Existing RFC\n\n- [ ] Implement the change.\n", encoding="utf-8") + original = path.read_bytes() assert main(["--state-dir", str(state), "import", "--repo", repo["id"], "--file", str(path)]) == 0 result = json.loads(capsys.readouterr().out) assert result["title"] == "Existing RFC" - assert result["body"] == path.read_text(encoding="utf-8") + assert result["body"] == original.decode("utf-8") assert result["state"] == "draft" assert main(["--state-dir", str(state), "publish", result["id"], "--content-digest", result["content_digest"], "--idempotency-key", "example"]) == 2 @@ -162,7 +231,7 @@ def test_cli_bootstrap_and_private_import_need_no_model_credentials(tmp_path, mo assert json.loads(exported.read_text(encoding="utf-8"))["markdown"] == result["body"] markdown = tmp_path / "export.md" assert main(["--state-dir", str(state), "export", result["id"], "--format", "markdown", "--out", str(markdown)]) == 0 - assert markdown.read_text(encoding="utf-8") == result["body"] + assert markdown.read_bytes() == original def test_local_cli_worker_completes_sdk_publication(tmp_path, monkeypatch, capsys): @@ -241,7 +310,7 @@ def dispatch(self, action, payload=None): monkeypatch.setattr(RFCClient, "from_env", lambda: FakeClient()) mcp, _ = _fake_mcp(monkeypatch) assert mcp.tools["rfc_status"]("rfc-1") == {"accepted": True} - assert calls == [("rfcs.get", {"rfc_id": "rfc-1"})] + assert calls == [("rfcs.status", {"rfc_id": "rfc-1"})] import inspect assert list(inspect.signature(mcp.tools["rfc_request"]).parameters) == ["action", "payload"] diff --git a/test/test_rfc_security.py b/test/test_rfc_security.py index fbcce6be..224e9a64 100644 --- a/test/test_rfc_security.py +++ b/test/test_rfc_security.py @@ -173,6 +173,34 @@ def test_related_observations_do_not_export_another_rfcs_restricted_body(tmp_pat assert SECRET not in json.dumps(result), action +def test_compact_status_and_suggestion_pages_filter_before_counting_and_search(tmp_path): + service, admin, repo, provider, actor, hidden, visible = restricted_pair(tmp_path) + model = json.loads(service.store.one("SELECT model FROM rfcs WHERE id=?", (visible["id"],))["model"]) + # Imported history can retain a reference whose RFC access was narrowed later. + model["suggestions"] = [{"id": "hidden", "status": "proposed", "feature": {"title": SECRET}, + "evidence": {"source": hidden["source"]}}, + {"id": "allowed-one", "status": "proposed", "feature": {"title": "Allowed first work"}}, + {"id": "allowed-two", "status": "proposed", "feature": {"title": "Allowed second work"}}] + with service.store.transaction() as con: + con.execute("UPDATE rfcs SET model=? WHERE id=?", (json.dumps(model), visible["id"])) + compact = service.dispatch(actor, "rfcs.status", {"rfc_id": visible["id"]}) + assert "body" not in compact and "suggestions" not in compact + assert compact["suggestion_counts"] == {"proposed": 2} and compact["suggestions_total"] == 2 + assert SECRET not in json.dumps(compact) + first = service.dispatch(actor, "rfcs.suggestions", {"rfc_id": visible["id"], "limit": 1}) + second = service.dispatch(actor, "rfcs.suggestions", {"rfc_id": visible["id"], "limit": 1, "offset": 1}) + assert first["total"] == second["total"] == 2 + assert first["suggestions"][0]["id"] == "allowed-one" and second["suggestions"][0]["id"] == "allowed-two" + assert service.dispatch(actor, "rfcs.suggestions", {"rfc_id": visible["id"], "query": SECRET})["total"] == 0 + for action in ("rfcs.status", "rfcs.suggestions"): + with pytest.raises(RFCError) as denied: + service.dispatch(actor, action, {"rfc_id": hidden["id"]}) + assert denied.value.status == 403 + for options in ({"limit": 0}, {"limit": 101}, {"offset": -1}, {"offset": True}): + with pytest.raises(RFCError): + service.dispatch(actor, "rfcs.suggestions", {"rfc_id": visible["id"], **options}) + + @pytest.mark.parametrize("auto_add", [False, True]) def test_related_discovery_does_not_copy_a_restricted_rfcs_title(tmp_path, auto_add): service, admin, repo, provider, actor, hidden, visible = restricted_pair(tmp_path) From bfe78ecad25481b3db1b975456ddd94cdfbf3a4b Mon Sep 17 00:00:00 2001 From: tzhouam Date: Mon, 5 Oct 2026 12:05:42 +0800 Subject: [PATCH 03/24] Remember RFC browser login for thirty days --- doc/architecture/SPEC/rfc_service.md | 5 +++- doc/features/rfc-service.md | 7 ++++- .../rfc_service/application.py | 4 +-- src/infermatrix_copilot/rfc_service/http.py | 5 ++-- src/infermatrix_copilot/rfc_service/models.py | 2 ++ .../rfc_service/web/index.html | 2 +- test/test_rfc_http.py | 1 + test/test_rfc_security.py | 28 +++++++++++++++++++ 8 files changed, 46 insertions(+), 8 deletions(-) diff --git a/doc/architecture/SPEC/rfc_service.md b/doc/architecture/SPEC/rfc_service.md index 877d7abd..d1a8e347 100644 --- a/doc/architecture/SPEC/rfc_service.md +++ b/doc/architecture/SPEC/rfc_service.md @@ -1,6 +1,6 @@ # rfc_service/ —— 规范 - + `LOC ~2890(8 个实现模块) · 可移植 RFC 应用与接口 · refactor-status: ok` @@ -45,6 +45,9 @@ ## 不变量 +- **记住登录。** 浏览器会话与 HttpOnly Cookie 均保留 30 天,服务重启后继续有效; + Cookie 不含个人令牌。退出登录、令牌撤销或到期、用户禁用立即失效。 + - **仓库中立。** 提供方与外部仓库名来自注册表;本包不含服务仓库知识字面量。 本地提供方只访问注册根内的相对 Markdown 路径或明确 Git 对象,拒绝目录穿越、 不安全的文件路径与符号链接;源参数不能重定向提供方凭据到任意主机。 diff --git a/doc/features/rfc-service.md b/doc/features/rfc-service.md index 59219b20..e34777f0 100644 --- a/doc/features/rfc-service.md +++ b/doc/features/rfc-service.md @@ -33,7 +33,12 @@ separate worker owns reconciliation. Run `sync --once` for one local cycle or SSH. Package deployment wrappers around the foreground process when needed. A non-loopback deployment requires `--public-url https://your-host.example` and -a TLS reverse proxy. Browser sessions stay on the configured origin. Bind the +a TLS reverse proxy. Browser sessions stay on the configured origin and remember +the signed-in user for 30 days, including after closing the browser or restarting +the service. The persistent HttpOnly cookie contains only an opaque session +secret; personal tokens are not stored in browser storage. Logout, token +revocation, token expiration, or disabling the user invalidates access immediately. +Bind the HTTP listener to an interface reachable by that proxy; the default listener is loopback. Public URLs and provider endpoints belong in operator configuration, not in user action payloads. diff --git a/src/infermatrix_copilot/rfc_service/application.py b/src/infermatrix_copilot/rfc_service/application.py index 094b1b01..d8f992c6 100644 --- a/src/infermatrix_copilot/rfc_service/application.py +++ b/src/infermatrix_copilot/rfc_service/application.py @@ -20,7 +20,7 @@ from . import RFC_API_VERSION from .drafts import digest, draft, parse, project, validate_features -from .models import Principal, ProviderError, RFCError, SourceRef +from .models import SESSION_TTL_SECONDS, Principal, ProviderError, RFCError, SourceRef from .store import Store, encode ROLES = {"reader": 1, "contributor": 2, "maintainer": 3} @@ -100,7 +100,7 @@ def create_session(self, token): secret = secrets.token_urlsafe(40) with self.store.transaction() as con: self._current(con, principal) - con.execute("INSERT INTO sessions VALUES (?,?,?)", (digest(secret), principal.credential_id, self.clock() + 43200)) + con.execute("INSERT INTO sessions VALUES (?,?,?)", (digest(secret), principal.credential_id, self.clock() + SESSION_TTL_SECONDS)) return secret, principal def authenticate_session(self, cookie): diff --git a/src/infermatrix_copilot/rfc_service/http.py b/src/infermatrix_copilot/rfc_service/http.py index 2677169e..76cf1ded 100644 --- a/src/infermatrix_copilot/rfc_service/http.py +++ b/src/infermatrix_copilot/rfc_service/http.py @@ -13,7 +13,7 @@ from typing import Any from urllib.parse import parse_qs, unquote, urlsplit -from .models import RFCError +from .models import SESSION_TTL_SECONDS, RFCError COOKIE_NAME = "imrfc_session" MAX_BODY_BYTES = 1024 * 1024 @@ -187,8 +187,7 @@ def _session_cookie(self, secret: str, *, revoke: bool = False) -> str: morsel["samesite"] = "Strict" if self.server.public_origin.startswith("https://"): morsel["secure"] = True - if revoke: - morsel["max-age"] = "0" + morsel["max-age"] = "0" if revoke else str(SESSION_TTL_SECONDS) return morsel.OutputString() def _get(self) -> None: diff --git a/src/infermatrix_copilot/rfc_service/models.py b/src/infermatrix_copilot/rfc_service/models.py index b22b5588..693e215d 100644 --- a/src/infermatrix_copilot/rfc_service/models.py +++ b/src/infermatrix_copilot/rfc_service/models.py @@ -4,6 +4,8 @@ from dataclasses import asdict, dataclass from typing import Any +SESSION_TTL_SECONDS = 30 * 24 * 60 * 60 + class RFCError(Exception): def __init__(self, message: str, status: int = 400, code: str = "invalid_request"): diff --git a/src/infermatrix_copilot/rfc_service/web/index.html b/src/infermatrix_copilot/rfc_service/web/index.html index 2c83f3f3..2149e763 100644 --- a/src/infermatrix_copilot/rfc_service/web/index.html +++ b/src/infermatrix_copilot/rfc_service/web/index.html @@ -28,7 +28,7 @@

进入 RFC 工作台

-

令牌仅用于创建会话。页面不会把令牌保存到浏览器存储。

+

登录状态保留 30 天,关闭浏览器后仍可继续使用。个人令牌不会保存到浏览器存储;在共享设备上请使用后退出。