From a953676352c1df2651326e03314a232a2854d159 Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Mon, 14 Sep 2026 12:05:33 +0800 Subject: [PATCH 1/8] =?UTF-8?q?docs(cli):=20=E5=86=BB=E7=BB=93=20CLI=20uni?= =?UTF-8?q?t=20=E8=AE=BE=E8=AE=A1=E4=B8=8E=E5=AE=8C=E6=95=B4=20preflight?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../capability-map.md | 63 ++++--- .../collaboration/index.md | 19 +- .../collaboration/parallel-units.md | 2 +- .../collaboration/roster.md | 25 ++- .../collaboration/session-bootstrap.md | 24 ++- .../common-patterns/agent-tools.md | 63 ++++++- .../common-patterns/validation-boundaries.md | 63 +++++++ .../decisions/D571-D580.md | 140 ++++++++++++++ .../decisions/D581-D590.md | 134 ++++++++++++++ .../decisions/D591-D600.md | 113 ++++++++++++ .../decisions/D601-D610.md | 37 ++++ .../decisions/index.md | 13 +- .../design-taste.md | 22 ++- .../documentation-promotion/index.md | 12 +- .../packet.md | 43 +++-- .../units/cli-sink/acceptance.md | 145 +++++++++++++++ .../units/cli-sink/agent-management-rest.md | 77 ++++++++ .../units/cli-sink/command-surface.md | 140 ++++++++++++++ .../units/cli-sink/connection-access.md | 119 ++++++++++++ .../units/cli-sink/content-delivery.md | 90 +++++++++ .../units/cli-sink/content-transport.md | 137 ++++++++++++++ .../units/cli-sink/deployment-config-rest.md | 97 ++++++++++ .../units/cli-sink/entity-resolver-rest.md | 96 ++++++++++ .../units/cli-sink/extension-rest.md | 82 +++++++++ .../units/cli-sink/file-output.md | 62 +++++++ .../units/cli-sink/impact-handshake.md | 52 ++++++ .../units/cli-sink/implementation-plan.md | 144 +++++++++++++++ .../units/cli-sink/input-discovery.md | 104 +++++++++++ .../units/cli-sink/job-control.md | 110 +++++++++++ .../units/cli-sink/job-rest.md | 85 +++++++++ .../units/cli-sink/list-error-contract.md | 116 ++++++++++++ .../units/cli-sink/organization-write-rest.md | 85 +++++++++ .../units/cli-sink/output-presentation.md | 67 +++++++ .../units/cli-sink/packet.md | 50 +++++ .../units/cli-sink/peer-rest.md | 48 +++++ .../units/cli-sink/preflight-call-sites.md | 59 ++++++ .../units/cli-sink/preflight-environment.md | 102 ++++++++++ .../units/cli-sink/preflight-rest.md | 48 +++++ .../units/cli-sink/preflight-runtime.md | 103 +++++++++++ .../units/cli-sink/preflight-toolchain.md | 89 +++++++++ .../units/cli-sink/preflight.md | 44 +++++ .../units/cli-sink/product-design.md | 78 ++++++++ .../cli-sink/project-and-dependencies.md | 174 ++++++++++++++++++ .../units/cli-sink/query-continuation.md | 91 +++++++++ .../cli-sink/release-and-distribution.md | 118 ++++++++++++ .../units/cli-sink/research.md | 139 ++++++++++++++ .../units/cli-sink/retrieval-graph-rest.md | 104 +++++++++++ .../units/cli-sink/source-cron-rest.md | 117 ++++++++++++ .../validation-boundary-correction.md | 70 +++++++ 49 files changed, 3936 insertions(+), 79 deletions(-) create mode 100644 tasks/knowledge-lifecycle-capabilities/common-patterns/validation-boundaries.md create mode 100644 tasks/knowledge-lifecycle-capabilities/decisions/D571-D580.md create mode 100644 tasks/knowledge-lifecycle-capabilities/decisions/D581-D590.md create mode 100644 tasks/knowledge-lifecycle-capabilities/decisions/D591-D600.md create mode 100644 tasks/knowledge-lifecycle-capabilities/decisions/D601-D610.md create mode 100644 tasks/knowledge-lifecycle-capabilities/units/cli-sink/acceptance.md create mode 100644 tasks/knowledge-lifecycle-capabilities/units/cli-sink/agent-management-rest.md create mode 100644 tasks/knowledge-lifecycle-capabilities/units/cli-sink/command-surface.md create mode 100644 tasks/knowledge-lifecycle-capabilities/units/cli-sink/connection-access.md create mode 100644 tasks/knowledge-lifecycle-capabilities/units/cli-sink/content-delivery.md create mode 100644 tasks/knowledge-lifecycle-capabilities/units/cli-sink/content-transport.md create mode 100644 tasks/knowledge-lifecycle-capabilities/units/cli-sink/deployment-config-rest.md create mode 100644 tasks/knowledge-lifecycle-capabilities/units/cli-sink/entity-resolver-rest.md create mode 100644 tasks/knowledge-lifecycle-capabilities/units/cli-sink/extension-rest.md create mode 100644 tasks/knowledge-lifecycle-capabilities/units/cli-sink/file-output.md create mode 100644 tasks/knowledge-lifecycle-capabilities/units/cli-sink/impact-handshake.md create mode 100644 tasks/knowledge-lifecycle-capabilities/units/cli-sink/implementation-plan.md create mode 100644 tasks/knowledge-lifecycle-capabilities/units/cli-sink/input-discovery.md create mode 100644 tasks/knowledge-lifecycle-capabilities/units/cli-sink/job-control.md create mode 100644 tasks/knowledge-lifecycle-capabilities/units/cli-sink/job-rest.md create mode 100644 tasks/knowledge-lifecycle-capabilities/units/cli-sink/list-error-contract.md create mode 100644 tasks/knowledge-lifecycle-capabilities/units/cli-sink/organization-write-rest.md create mode 100644 tasks/knowledge-lifecycle-capabilities/units/cli-sink/output-presentation.md create mode 100644 tasks/knowledge-lifecycle-capabilities/units/cli-sink/packet.md create mode 100644 tasks/knowledge-lifecycle-capabilities/units/cli-sink/peer-rest.md create mode 100644 tasks/knowledge-lifecycle-capabilities/units/cli-sink/preflight-call-sites.md create mode 100644 tasks/knowledge-lifecycle-capabilities/units/cli-sink/preflight-environment.md create mode 100644 tasks/knowledge-lifecycle-capabilities/units/cli-sink/preflight-rest.md create mode 100644 tasks/knowledge-lifecycle-capabilities/units/cli-sink/preflight-runtime.md create mode 100644 tasks/knowledge-lifecycle-capabilities/units/cli-sink/preflight-toolchain.md create mode 100644 tasks/knowledge-lifecycle-capabilities/units/cli-sink/preflight.md create mode 100644 tasks/knowledge-lifecycle-capabilities/units/cli-sink/product-design.md create mode 100644 tasks/knowledge-lifecycle-capabilities/units/cli-sink/project-and-dependencies.md create mode 100644 tasks/knowledge-lifecycle-capabilities/units/cli-sink/query-continuation.md create mode 100644 tasks/knowledge-lifecycle-capabilities/units/cli-sink/release-and-distribution.md create mode 100644 tasks/knowledge-lifecycle-capabilities/units/cli-sink/research.md create mode 100644 tasks/knowledge-lifecycle-capabilities/units/cli-sink/retrieval-graph-rest.md create mode 100644 tasks/knowledge-lifecycle-capabilities/units/cli-sink/source-cron-rest.md create mode 100644 tasks/knowledge-lifecycle-capabilities/units/cli-sink/validation-boundary-correction.md diff --git a/tasks/knowledge-lifecycle-capabilities/capability-map.md b/tasks/knowledge-lifecycle-capabilities/capability-map.md index 436bfd3..3c1db2c 100644 --- a/tasks/knowledge-lifecycle-capabilities/capability-map.md +++ b/tasks/knowledge-lifecycle-capabilities/capability-map.md @@ -1,6 +1,6 @@ # Knowledge Lifecycle Capability Map -本文只解释 program 如何拆分、为何按这个顺序讨论,以及 queued work 在哪里。它不维护 active +本文只解释 program 如何拆分、各能力的价值与压力,以及候选工作在哪里。它不维护 active phase、当前问题或具体单元的设计;这些由 [program packet](packet.md) 与 active unit packet 负责。 @@ -14,25 +14,25 @@ phase、当前问题或具体单元的设计;这些由 [program packet](packet | Trunk | Goal | Known units | | --- | --- | --- | | Collection | 把 source-specific information 可靠地持久化到 info-base | 现有 sources、memo-like、CalDAV、Nextcloud Files、Apple Notes | -| Organization | 打理已经存在的 info-base,以改善 use 效果 | breakdown、merge、linking;允许由真实目标发现其他能力 | +| Organization | 打理已经存在的 info-base,以改善 use 效果,而非结构美观 | Nowledge study 的整组组织能力;允许由真实目标发现其他能力 | | Use / Application | 查询 info-base,并让下游使用被选择的信息 | 特征检索、语义检索、图导航检索;sink vertical;indexing 只是支撑 | ### Vertical implementable units -讨论与实现以一个具体 source、organization operation、retrieval mode 或 sink vertical 为纵切。每个 unit -必须能够独立说明: +讨论与实现以一个有明确目标的产品功能组为纵切,可以是具体 source、retrieval mode、sink,或 Nowledge study +这样覆盖多个 organization behavior 的同一实现单元。每个 unit 必须能够独立说明: ```text -user value / observable failure - → native input or use request +Product goal / value / observable behavior + → input, automatic trigger or explicit request → owner and cross-boundary contracts → graph / projection / delivery behavior - → executable acceptance - → bounded implementation increments + → best-effort acceptance + → 整组功能的实现与约定交付 ``` -这样拆分的依据是可观察价值、单一 owner 和可验收的端到端闭环,而不是文件夹、抽象层或 -先造公共框架的便利。 +这样拆分的依据是产品价值、明确的责任边界和端到端闭环,而不是文件夹、抽象层或先造公共框架的便利。 +Unit 内部行为可分别执行,但不因此拆成独立 delivery slices;验收难度不反过来选择产品设计。 ### Cross-cutting pressures @@ -67,27 +67,29 @@ local relations ────────────┘ 这些语义约束讨论方向,但不预先规定每个 source 的 graph shape。 -## 3. Program Queue +## 3. Unit 与候选能力地图 -| Order | Unit family | State | Why here | -| --- | --- | --- | --- | -| 1 | [Memos extension](units/memos-extension/packet.md) | **Complete;backend MVP implemented** | Sir 的直接产品需求;released client E2E 已证明 memo canonical/graph/read contract;durable owner projections committed | -| 2 | [RSS extension hardening](units/rss-extension-hardening/packet.md) | **Complete;human-accepted 2026-08-03** | 已用 RSS/Atom vertical 建立 source instance → collect job → graph → resolver → state 的可信 collection baseline | -| 3 | [Mail extension](units/mail-extension/packet.md) | **Complete;implementation/J1–J4/promotion complete** | 高价值真实邮箱 corpus 已证明 protocol → graph → materialization → generic InfoBase browser 纵切,且 durable owner delivery 已关闭 | -| 4 | [GitHub extension](units/github-extension/packet.md) | Queued correction | 首轮真实账号验收完成;Hub/Spoke 与 core/Extension owner correction 已关闭,仍需修正 implementation boundary 与 re-acceptance | -| 5 | Remaining collection units | Queued | CalDAV、Nextcloud Files、Apple Notes 各暴露不同 access/identity/storage/runtime 压力,不提前压成一个 source framework | -| 6 | [Semantic retrieval](units/semantic-retrieval/packet.md) | **Complete** | real-provider、local/delegated Peer、rumination 与 shared-truth projection 均已验收关闭 | -| 7 | [Feature retrieval](units/feature-retrieval/packet.md) | **Complete** | Lexical increment 的实现、J1–J7、core/client promotion、真实 fork/cold-start 与 exact-main Pages delivery 均已验收;graph facts 与 hybrid composition 仍由相邻能力承担 | -| 8 | [Graph navigation retrieval](units/graph-navigation-retrieval/packet.md) | **Complete** | bounded neighborhood/path、peer-local topology、Graph View、preview/production acceptance 与 durable closure 已完成 | -| 9 | [MCP sink](units/mcp-sink/packet.md) | **Active;product discussion** | query primitive 已成立后,下一可用性缺口是 downstream delivery;MCP sink MVP 服务 Agent retrieval of InKCre,不预设 generic sink framework | -| 10 | Other organization/application units | Queued | breakdown、merge、linking 等仍各自从真实 use/failure evidence 建立合同;hybrid retrieval 等基础 primitive 完成后再组合 | - -这不是永久开发顺序。active unit 结束时,应根据用户价值、已暴露依赖和不确定性重新选择下一个 -unit;不得仅因为表格编号自动启动。 +| Unit / 候选能力 | 价值与主要压力 | +| --- | --- | +| [Memos extension](units/memos-extension/packet.md) | 原生兼容 backend、memo canonical/graph/read 合同;collector 保留为同一 owner 的后续范围 | +| [RSS extension hardening](units/rss-extension-hardening/packet.md) | source instance → collect job → graph → resolver → state 的 collection 纵切 | +| [Mail extension](units/mail-extension/packet.md) | 真实邮件的 protocol → graph → materialization → InfoBase browser 纵切 | +| [GitHub extension](units/github-extension/packet.md) | 真实账号收集与 Core/Extension 边界;后续修正范围由 unit packet 维护 | +| CalDAV、Nextcloud Files、Apple Notes | 各自暴露不同 access/identity/storage/runtime 压力,不提前压成统一 source framework | +| [Semantic retrieval](units/semantic-retrieval/packet.md) | real-provider 与 local/delegated Peer 上的意义检索 | +| [Feature retrieval](units/feature-retrieval/packet.md) | Lexical 检索;graph facts 与 hybrid composition 保持各自责任 | +| [Graph navigation retrieval](units/graph-navigation-retrieval/packet.md) | bounded neighborhood/path、peer-local topology 与 Graph View | +| [MCP sink](units/mcp-sink/packet.md) | 将已有查询与 Resolver 能力交给下游 Agent/tool client,不预设 generic sink framework | +| [Telegram extension](units/telegram-extension/packet.md) | Telegram 原生交互与信息能力的 Extension 纵切 | +| [Organization Nowledge study](units/organization-nowledge-study/packet.md) | 借鉴思想形成整组功能:rumination 载体迁移、supersession、refinement、evidence stance、synthesis、existing-referent anchoring、duplicate assertion | +| 后续 organization / application 候选 | 先核对已交付能力,再由真实价值与缺口提出范围;不把 breakdown、merge、linking 等宽泛名称直接当成下一 unit | + +此表不是排期,也不复制当前阶段。选择与状态以 [parent packet](packet.md) 和对应 unit packet 为准;下一 unit +由 Sir 的目标与实际依赖确定,不按表格顺序自动启动。 ### Memo-like queue boundary -- active ownership unit 是 `memos-extension`;当前 MVP delivery scope 才是 Memos +- Memo family 的已确定 ownership unit 是 `memos-extension`;已交付的 MVP scope 是 Memos 0.29.1-compatible backend,MoeMemos Android 2.0.4 是 acceptance client。 - Memos collector 是同一 extension 的 future delivery scope;它会重新打开 external identity、 reconciliation、cursor 与 delete observation 等问题,但不另建 canonical ownership unit。 @@ -120,10 +122,11 @@ unit;不得仅因为表格编号自动启动。 4. **Implementation Plan + Preflight**:先形成 design-probing draft,再核实版本、代码地址、依赖、 环境和失败分支;任何新 owner/behavior 都退回 Technical/Acceptance 讨论,不留到 Execute。 Technical/Acceptance 获批且 preflight questions 关闭后才冻结为 execution baseline。 -6. **Impact Handshake + explicit start**:Sir 审查 state diff 后才修改 durable docs 或代码。 -7. **Execute / Verify / Promote**:实现闭环,再把稳定 truth 投影到唯一 durable owner。 +5. **Impact Handshake + implementation authorization**:Sir 审查并授权范围后才修改 durable docs 或代码。 +6. **Execute / Verify / Promote / Deliver**:实现闭环,核对约定的实际交付终点;稳定 truth 按唯一 durable owner + 提升,跨 owner 尚未完成的事项明确保留,不能由某次合并隐含关闭。 -一次尽量只讨论一个会改变设计的问题。supporting evidence/acceptance/plan 可以提前探索下游 +一次聚焦一个关键设计复核面,不机械地每轮制造一个问题。supporting evidence/acceptance/plan 可以提前探索下游 gate,但不能把“已经写成草案”误当作“已经获批”或“可以 Execute”。 ## 6. Evidence Boundary diff --git a/tasks/knowledge-lifecycle-capabilities/collaboration/index.md b/tasks/knowledge-lifecycle-capabilities/collaboration/index.md index a150388..a8477ba 100644 --- a/tasks/knowledge-lifecycle-capabilities/collaboration/index.md +++ b/tasks/knowledge-lifecycle-capabilities/collaboration/index.md @@ -28,21 +28,26 @@ recover current model → preflight / spike / branch simulation → frozen Acceptance + Execution baseline → Impact Handshake - → Sir explicitly says “开始” + → explicit implementation authorization → Execute → Verify / Promote - → Close + → agreed delivery endpoint / Close ``` -- Exploration、research、history inspection、experiments、spikes and task-packet maintenance do not require `开始`。 -- `开始` authorizes the reviewed source/durable state diff and its verification,not unresolved investigation。 +- Exploration、research、history inspection、experiments、spikes and task-packet maintenance are autonomous。 +- 明确的实施授权覆盖已复核方案及必要验证,不要求字面口令“开始”,也不重复索取已经给出的授权。 + 新的实质修复方案仍先复核,不能从另一 unit 的历史授权推导当前权限。 - A preflight finding that changes Product、owner、public contract or Acceptance returns to that gate。 - Commit、push、merge and cross-owner publication keep their own authorization and governance boundaries。 - A Unit is an implementation responsibility boundary,not a release、repository or folder boundary。 +- 同一 unit 的整组产品功能共同进入技术设计、验收与实现;内部行为和实现步骤不另切 delivery slices。 + 按阅读目的拆分设计、证据和决策文件,保留一个简洁的 unit 控制入口,不使用 svc grow/growth。 +- 关闭条件以约定的实际交付终点为准。要求生产发布时,PR 合并、镜像上传或绿色但跳过部署的 workflow 都不足以 + 关闭 unit;应核对正常发布路径中的版本、实际部署及健康结果。生产交付也不等于所有语义判断正确。 Parallel Unit sessions are peers rather than coordinator/worker roles。Each session owns one Unit and may minimally maintain the shared program packet、roster and navigation for its own registration or returned result。Orthogonal sessions do not need -routine communication;actual owner overlap、dependency or shared-baseline change triggers direct reconciliation。 +routine communication;actual owner overlap、dependency or shared-baseline change is reported to Sir for reconciliation。 ## Discussion Loop @@ -52,12 +57,14 @@ The unit of progress is a more coherent current system model,not another answe 2. Reconcile the latest input with authority、scope/cardinality、lifecycle and existing contracts。 3. Investigate missing factual or feasibility evidence autonomously。 4. Derive low-risk consequences and remove dominated options。 -5. Present at most one surviving material Human fork,with evidence、recommendation、alternative and consequences。 +5. 一次呈现一个关键设计复核面,解释证据、案例、因果链、建议及取舍;有真实替代方案才比较,不为提问虚构选项。 6. Write accepted conclusions and still-open pressure back to the unit packet immediately。 7. Do not reopen accepted decisions merely because context was compacted or implementation has not started。 “一次尽可能只问一个问题” is a ceiling on simultaneous Human review,not an instruction to manufacture one question per turn。When one coherent answer follows from accepted constraints,the Agent records it and continues。 +关键的新方案即使只有一个推荐方向,也可能需要 Sir 复核。若自主调查后仍缺少决定方向的信息或思路,应说明 +已知事实和具体缺口再请求帮助;不能把“没有第二个方案”当成跳过确认的依据。 ## Reasoning Instruments diff --git a/tasks/knowledge-lifecycle-capabilities/collaboration/parallel-units.md b/tasks/knowledge-lifecycle-capabilities/collaboration/parallel-units.md index 483a6eb..7ea0d1b 100644 --- a/tasks/knowledge-lifecycle-capabilities/collaboration/parallel-units.md +++ b/tasks/knowledge-lifecycle-capabilities/collaboration/parallel-units.md @@ -32,7 +32,7 @@ One unit session owns: - its declared feature branch、worktree and base commit; - only its reserved decision range/shards; - its Product-through-Verify loop、PR and acceptance evidence; -- changes inside the declared implementation surface after the normal `开始` gate。 +- changes inside the declared implementation surface after reviewed scope and explicit implementation authorization。 A unit session records cross-unit/common-pattern candidates in its own packet。It may promote an accepted task-wide control consequence itself when no active peer owns an intersecting surface;otherwise it pauses and reports the intersection to Sir。 diff --git a/tasks/knowledge-lifecycle-capabilities/collaboration/roster.md b/tasks/knowledge-lifecycle-capabilities/collaboration/roster.md index 0552259..555e85e 100644 --- a/tasks/knowledge-lifecycle-capabilities/collaboration/roster.md +++ b/tasks/knowledge-lifecycle-capabilities/collaboration/roster.md @@ -7,33 +7,32 @@ | --- | --- | --- | --- | --- | --- | --- | | `mcp-sink` | `01a04610-338e-7311-93df-847f9801c5af` | merged through PR #88;the current root worktree retains only local task-control state | protected `main` merge `459a6df` | Closed | D-381–D-420 | No active implementation ownership;MCP runtime、ChatGPT Tool acceptance and production delivery are complete | | `telegram-extension` | `01a04685-aa31-7682-a4a2-824727eacce5` | core-py PR #89 / `.github` PR #28 | merged as core-py `42d8527` and `.github` `f7269b9` | Closed / merged | D-421–D-460 | Telegram、repository-wide Changie→Towncrier cutover and organization guidance are complete;Unit worktrees are retired and Release PR #90 is independently owned by the release lifecycle | -| `organization-nowledge-study` | current session | `feat/organization-nowledge-vertical` / current core-py worktree | protected `main` `2282d59` | Verify / Acceptance | D-461–D-570 | Whole implementation vertical owns `units/organization-nowledge-study/**` and reserved decisions;MCP Resolver-reflection overlap is reconciled against current `main` | +| `organization-nowledge-study` | current session | PR #100 / #101 merged;root worktree on main retains local task-control records | protected `main` `b3ccb00` | Closed / production delivered | D-461–D-570 | Core 0.2.0、production probes and stable admission complete;no active implementation ownership | +| `cli-sink` | 当前 CLI session | `feat/inkcre-cli` / root core-py worktree | `b3ccb00`,已核对 origin/main | 完整 Preflight 已完成,等待 Impact Handshake 后实施 | D-571–D-610 | cli/ 独立 PDM 项目;Core REST/Job/校验边界,Mail 取消清理,client-web worker 配套;ext-reg runtime 和 Core pin。预演发现的 production wheel finalize 缺口纳入 P5。跨仓库 feature 工作位置在实施时建立;当前没有业务源码改动 | ## Shared-worktree coordination -MCP and Telegram are closed;the Organization vertical now owns the active root-worktree implementation and its narrow -task-control updates。Historical task-control and operational state can still intersect: +MCP、Telegram 和 Organization 均已关闭;root worktree 保留 parent 生命周期内的本地 task-control 记录, +现已切到 CLI feature branch,没有本轮待交付的源码。CLI 保留上一单元的本地收尾和未跟踪 skill,不将其视为 +本 unit 的修改。没有其他已登记的 active unit,也不进行 cross-session 通信。Historical task-control and operational state can still intersect: - `mcp-sink` has no remaining implementation ownership。Its Core、Extension Runtime and production changes are authoritative on protected `main` at `459a6df`;the root worktree's remaining dirty state is task control,not unmerged MCP source。 -- `telegram-extension` owns its Unit packet and future `extensions/telegram/**` implementation。Its scope now also owns the - repository-wide Changie→Towncrier cutover: root PDM dependency/lock state, release fragments and changelogs, release-contract - orchestration scripts and their CI/documentation consumers。It still does not own Core Source/Resolver/Extension framework。 -- `organization-nowledge-study` owns its exact BehaviorResolver、Graph Navigation、Agent Tool、Job、local Unit TDD and - acceptance-corpus surfaces。It preserves merged MCP projection and Telegram/release truth rather than reopening either Unit。 -- The former root `pyproject.toml` / `pdm.lock` overlap is resolved by PR #88's merge。Telegram's independent implementation - must use protected `main` `459a6df` or a later integrated main commit as its address-sensitive baseline。 +- `telegram-extension` 和 `organization-nowledge-study` 的源码责任已交付到 main。历史清单说明改动来源,不代表 + 这些关闭的 session 继续占有 `pyproject.toml`、Agent runtime、Resolver、Job 或其它源码。后续 unit 按当前真实压力 + 重新登记范围并遵守 durable owner;不需要向已关闭 session 申请修改许可。 - `docs/openapi.json` is a possible generated-output intersection。Whichever Unit regenerates it must compare against the other Unit's live routes and preserve the complete current application contract;it is committed only with the Unit whose API change requires regeneration。 -- Parent packet state、`collaboration/roster.md` and the decision index are shared peer integration surfaces。Telegram records - decisions only in D-421–D-460 and may apply the narrow navigation/state changes implied by those decisions,while preserving - other peers' rows、ranges and current work。 +- Parent packet、roster 与 decision index 是共享 task-control。各 session 只写自己的保留区段,并作必要的导航/状态 + 更新;已保留的历史区段及其空号不回收给新 unit。 - The local environment、development database and repository-wide verification commands are shared operational state。 Concurrent runs must not be interpreted as isolated evidence。When a reset or reconfiguration may affect another active Unit,pause and obtain Sir's direction before changing that shared state。 - Commits remain path-scoped by Unit despite the shared dirty baseline;neither Unit may use broad staging or cleanup that captures、restores or discards the other's work。 +- 新 worktree 只继承提交,不继承 root 的本地收尾记录。切换前核对 parent packet 与实际工作树;需要转交时只带 + 必要 task-control,不把未跟踪的 `.agents/skills/python-backend-code/` 或本机配置混进交付。 ## Adding a Peer Unit diff --git a/tasks/knowledge-lifecycle-capabilities/collaboration/session-bootstrap.md b/tasks/knowledge-lifecycle-capabilities/collaboration/session-bootstrap.md index 64997f9..aebddd6 100644 --- a/tasks/knowledge-lifecycle-capabilities/collaboration/session-bootstrap.md +++ b/tasks/knowledge-lifecycle-capabilities/collaboration/session-bootstrap.md @@ -21,7 +21,7 @@ Before proposing design, read: 2. tasks/knowledge-lifecycle-capabilities/packet.md; 3. collaboration/index.md and collaboration/parallel-units.md; 4. design-taste.md; -5. architecture-understanding/index.md and only relevant shards; +5. relevant durable owners and architecture-understanding/index.md (the latter is provenance, not current implementation authority); 6. this Unit packet; 7. decision index and decisions referenced by this Unit; 8. relevant code, useful history and correctly owned durable docs. @@ -32,15 +32,31 @@ external evidence and accepted Human authority. Follow this loop: Product → Technical ↔ Acceptance ↔ implementation-plan probe → Preflight → frozen Acceptance/Execution baseline → Impact Handshake -→ wait for Sir to explicitly say “开始” → Execute → Verify/Promote. +→ explicit implementation authorization → Execute → Verify/Promote → agreed delivery endpoint. Exploration, research, experiments, spikes and task-packet maintenance are autonomous. Source or durable-doc mutation -requires the normal approved handshake/start boundary. Commit, push and merge retain separate explicit authority. +requires the reviewed scope and explicit implementation authorization; do not repeatedly ask for approval already given +within that scope or require one literal password such as “开始”. Commit, push and merge retain their applicable authority. +Recover current authorization rather than importing another unit's historical grant. The unit of discussion progress is a more coherent current model, not another question. Investigate first, derive natural -consequences, remove dominated options and present at most one material Human fork. Update the Unit packet as discussion +consequences and remove dominated options. Pause for a key design requiring Sir's review, or genuinely missing Human-owned +information after investigation; one recommended design can still need review, without inventing a second option. +Explain the cause, relevant case, proposed behavior and trade-off before asking. Update the Unit packet as discussion proceeds. Do not reopen accepted decisions after context compaction. +The unit carries its whole approved feature set through Technical/Acceptance and implementation; internal behaviors or +implementation steps are not additional delivery slices. Split evidence, designs and decisions by their reading purpose, +while keeping one concise unit control entry. Do not run svc grow/growth. + +Check for uncommitted parent/unit closure records in the source workspace before creating a new worktree; Git alone will +not transfer them. Preserve unrelated files and local configuration. Consult AGENTS.local.md and svc.local.json for this +machine's database topology. Do not assume the absence of local Docker/PostgreSQL means the declared database is unavailable. + +Define the unit's actual delivery endpoint. When it requires production, follow the normal Release PR and exact-artifact +delivery path to completion. A merged PR, a published image or a green workflow whose delivery step was skipped is not +production completion. Keep parent-wide deferred work separate from this unit's completion criteria. + All active Unit sessions are peers;there is no coordinator。Normally edit your Unit packet、declared implementation surfaces and reserved decision range。Make a task-wide packet/roster/index/architecture edit only when it is the smallest accepted current-state consequence,after checking the latest roster。If another active Unit may be affected,pause the intersecting diff --git a/tasks/knowledge-lifecycle-capabilities/common-patterns/agent-tools.md b/tasks/knowledge-lifecycle-capabilities/common-patterns/agent-tools.md index 0c98b5d..82be922 100644 --- a/tasks/knowledge-lifecycle-capabilities/common-patterns/agent-tools.md +++ b/tasks/knowledge-lifecycle-capabilities/common-patterns/agent-tools.md @@ -1,7 +1,8 @@ # Agent Tool 设计与诊断 -状态:task-level common patterns,来自 Sir 已确认的 D-529~D-538。供本任务各 unit 复用;尚未提升为 Hub -durable truth,也不表示相关工具改造已经实现。具体字段和执行计划仍归各 unit。 +状态:task-level common patterns,来自 Sir 已确认的 D-529~D-538、D-551~D-555、D-575、D-597。供本任务各 unit 复用; +尚未提升为 Hub durable truth。具体字段、落地状态和执行计划仍归各 unit,不由此推定所有调用者已采用这些模式。 +这些模式同样适用于普通 API 与 CLI;Agent 使用轨迹是证据来源,不是它们的适用范围上限。 ## 工具形态由能力特征决定 @@ -26,6 +27,57 @@ get_entities 批量取得完整基础记录,Resolver 解释异构内容;调 这既不是把工具越拆越小,也不是用一个工具包办所有工作。评估整个任务的有效信息、调用成本与结果质量,而非 孤立压低 Tool 数量、单次字数或请求次数。实体引用和后继参数应能对应,避免各工具创建不兼容的别名或包装实体。 +混合实体批次的每项引用应保留完整身份,如 `{type, id}`,避免调用者把类型重新编码为全批参数或依赖数字 ID +猜类型(D-551/D-552)。这不要求为已有 Block/Relation 创建新的包装实体,也不是所有 API 的固定 schema。 + +## 有界等待将控制权交还调用者 + +长期操作的执行生命周期与一次调用的观察周期应分开。让调用者指定本次最多等待多久,并在返回时获得实际 +已观察状态和可继续使用的操作引用;它可以据此继续等待、查询或显式停止,而不必让一次调用阻塞到工作终结。 +价值不只在于避免超时,更在于保留 Agent 在新信息下重新判断与调整行动的机会。 + +等待预算耗尽不意味着业务失败、执行超时或取消,也不应创建另一份工作。终态可以提前结束等待。观察过程的 +真实故障仍应报告,不能把未取得的状态写成“当前状态”;执行端的进度只呈现其实际提供的信息。主动停止是 +独立控制意图,不由等待时长或断开连接暗示。这不要求把所有同步操作都改造成 Job,也不预设通用重试机制。 + +来源:[D-575](../decisions/D571-D580.md)。具体默认值、选项、返回体与 exit code 仍由接口 owner 决定;CLI +尚处设计阶段,不能将该模式写成已完成的运行验证。 + +## 长输出分页是查询的通用模式 + +是否需要分页,取决于结果的体量与可分批读取的性质,不取决于它是否叫 Job、配置或检索。各查询指令都应 +检查长输出如何被逐步读取;Job 历史只是一个具体落点,不能由此形成只有历史表需要分页的白名单。 + +分页将一次信息摄取的预算与完整查询结果分开。返回本页可用信息、明确的结果边界以及继续读取的位置, +由调用者决定是否继续;不静默截断,不自动拉完所有页,也不要求调用者从输出长度猜是否还有内容。 +续页位置服务当前查询,不是预制的 next_request 或下一条业务动作。 + +优先使用查询 owner 已有的分页机制,保留其排序、过滤和一致性语义。不为统一外观强加一种 cursor、全局 +Page 模型或分页会话。小结果可以一次返回,但“小目录”不是永远不会产生长输出的领域承诺。 + +分页也不能偷换查询含义:top-k 或图探索上限决定本次查询得到什么,输出分页决定如何逐步交付这些结果。 +不能用翻页暗中扩大已确认的检索范围,或为受限搜索伪造并不存在的下一页。单个长文本/多模态值则保留完整 +内容的交付与读取路径,不为分页随意切碎语义对象,也不重复执行可能 materialize 的内容方法来取得下一段。 + +来源:[D-597](../decisions/D591-D600.md)。CLI 的具体分页、文件交付和显示预算分别由 unit 合同定义; +通用模式不等于已完成所有查询的分页实现。 + +## 独立批次保留部分成果,并单独表达完成度 + +当批次成员能够独立完成时,一个成员失败不应抹掉已取得的其它结果,也不应阻止其它独立成员完成。 +返回可关联到请求项的成功值和逐项错误;调用者可以使用成果,并只处理失败项。没有真实的原子或依赖约束时, +不把一批调用强制折叠成全有或全无,也不为处理部分失败隐式重跑成功项。 + +可用成果与整体完成度是两个维度。报告批次未全部完成,不代表没有任何结果或效果;保留结果,也不等于 +宣称所有请求都成功。协议层据此提供诚实的整体完成信号,但不复制一套业务状态机。CLI 的具体实现是保留 +stdout 中的逐项结果并以非零退出;其它接口不被要求机械复制 shell 退出码。 + +失败应按该接口拥有的结果变体判断,而不是递归搜索任意数据里的 error/status 字段。读取到失败的 Job、 +得到没有匹配的检索结果,不等于读取或检索本身失败。请求整体未能执行时,也不能伪造逐项业务结果。 +真正要求原子提交或成员间存在依赖的操作,仍遵循其领域合同,不套用独立批次的处理方式。 + +来源:[D-597](../decisions/D591-D600.md);既有逐项关联与成果保留原则在此补齐整体完成语义。 + ## 机制优先教导 类型、enum、bounds 和方法发现能表达的规则由机制承担。未知方法返回短错误、可用方法名,并指向 describe; @@ -61,10 +113,13 @@ schema/description/prompt,而不只核对设计文档。合同来源仍归行 检查无关目录、重复说明、多层包装和不能指导纠正的错误。先处理有证据的负担,不以字符数最小化替代判断。 -请求关联字段可以有用地冗余:index 精确定位请求项,block/method 帮助直接读懂结果。保留独立批次中的成功项, -让失败项可辨认和纠正;不自动重试整批。分页、未找到/搜索受限、缺失对象和截断信息会影响解释,不能当噪音删除。 +请求关联字段可以有用地冗余:index 精确定位请求项,block/method 帮助直接读懂结果。长输出与部分失败按 +上面的通用模式处理。分页、未找到/搜索受限、缺失对象和截断信息会影响解释,不能当噪音删除。 不能为响应简洁而统一裁剪异构 Resolver 的实际结果,或重新包装已有的 Block/Relation 为另一套实体。 +简洁成功回执足以确认该次写入,不为低价值的写后复核扩充完整记录或建议重新读取(D-551/D-552)。先诊断 +为什么发生确认,再决定是否存在真实信息缺口;不能把模型额外索取的内容自动视为工具应提供的能力。 + ## 按真实轨迹诊断与对照 ### 最小充分引导,而非规定动作序列 diff --git a/tasks/knowledge-lifecycle-capabilities/common-patterns/validation-boundaries.md b/tasks/knowledge-lifecycle-capabilities/common-patterns/validation-boundaries.md new file mode 100644 index 0000000..8e3325a --- /dev/null +++ b/tasks/knowledge-lifecycle-capabilities/common-patterns/validation-boundaries.md @@ -0,0 +1,63 @@ +# 校验跟随输入边界,而不是调用层数 + +状态:来自 D-590/D-598/D-602 的设计指南;尚未提升为 durable truth。适用于本任务后续设计与实现;具体修正 +范围归单元授权,D-591 已将现有同类边界问题纳入 cli-sink。这不是禁止一切运行时检查。 + +先确定数据从哪里进入、哪一层建立合同,再决定校验位置。外部提交在写入边界按 owner 的 schema 校验; +已经通过该合同持久化的数据,读取时应信任其作为系统内部事实的地位,不因为经过另一个函数、manager 或 +协议 adapter 就重新校验同一 schema。数据库约束守住持久不变量,不能用重复应用层验证替代。 + +类型化读取、反序列化与格式转换有各自的表达职责,但为了类型提示或统一响应形状而重新构造 Pydantic model, +不能成为重验已知数据的理由。实际执行所需的复杂类型恢复按下文的 Python 指南处理。API 的响应类型描述 +输出合同,也不要求内部逐层重复进行业务校验。 + +使用时才成立的条件与写入合同不同,例如当前执行端是否拥有某项能力。此类判断留在实际使用的 owner; +不要借它把所有数据库读取重新定义成不可信输入。导入或迁移确实需要验证时,在对应边界完成,而不是给 +正常读路径永久叠加检查。 + +发现重复校验时,修正建立合同的位置或删去多余检查。不要先让错误的读路径成为障碍,再把“允许正常读取” +包装成一项需要 Human 决策的放宽或特殊豁免。 + +CLI config 是本次实例:写入校验完整候选,GET 返回持久记录;PATCH 不要求旧值先合法。既有 typed get +目前仍有重复验证,这个实现事实不是应当保留的设计约束;具体类型恢复按 D-602 取舍处理。 + +D-598 将这个判断明确应用于 CLI 消费 Core REST:跨过 HTTP 不自动产生第二个业务校验 owner。已确认 Core +拥有该返回合同时,CLI 只解码和转换,不因需要类型提示或展示字段就再次验证完整响应。静态类型描述与 +运行时校验是不同工具;不必为了禁用一个本不需要的响应 validator,再建无校验对象构造框架。 + +该指南不等于任意第三方 HTTP 都天然可信,也不删除 JSON/MIME 解码、真实请求错误或不能完成转换时的诊断。 +适用性取决于已确认的 authority 与调用边界,不能仅凭数据经过“网络”或“数据库”来机械地增加或删除检查。 + +## 使用 Pydantic 时,避免重复建立校验边界 + +D-602 确认取舍,D-603 明确指南应落在 Pydantic 使用方法上,而不只抽象为通用的库选型原则。 +Pydantic 同时承担输入约束、Python 类型构造和序列化;应按调用点实际需要的职责使用,而不是每经过一个 +manager、adapter 或 persistence layer,就再调用一次 model_validate。 + +| 调用位置 | 应怎样使用 Pydantic | +| --- | --- | +| 外部输入进入 owner | 按该输入合同验证;FastAPI 已提供相同的 typed Form 时,manager 直接使用,不 dump 后再 validate | +| 内部传递已构造模型 | 直接传递实例;不要为了确认其类型再调用 model_validate,after model validator 仍可能重跑 | +| PATCH 持久配置 | 旧值取 dict,浅层合并 patch,再对完整新值 model_validate 一次;不先验证旧值或先 normalize(current) | +| GET 持久记录或消费可信 REST 返回 | 返回/消费已有 JSON 表示,不为了“再确认合法”加载业务 schema、补默认值或构造完整模型 | +| 执行端确需恢复复杂 Python 类型 | 从持久 JSON 调用一次原生 model_validate/TypeAdapter,得到嵌套模型、SecretStr、日期或 union,随后传递该结果 | +| 简单持久表示转换 | text[] → tuple 等直接转换;无需复用带唯一性/排序检查的写入 validator | + +例如配置 PATCH 的次序是: + +```python +candidate = {**stored_value, **patch} +config = Config.model_validate(candidate) +# 持久化时用 config.model_dump(mode="json");本次调用后续直接使用 config。 +``` + +复杂类型恢复允许 Pydantic 附带执行字段约束和 validators,不要求另建无校验模型、递归 decoder 或 +Extension decode hooks。model_construct 不会递归恢复嵌套模型,不能机械替换 model_validate;静态 cast +也不完成转换。保留构造的调用点应解释所需的实际 Python 类型,而不是声称数据库又变成了不可信输入。 + +这项取舍不授权在构造前后叠加“持久值必须再合法”的专门检查,也不要求把原生 ValidationError 再包装成 +自有异常。构造失败保留诊断,不回退为 raw dict 或默认值。validator 若有外部查询或业务副作用,应检查其 +职责位置;不要因为恢复模型也会运行 validator,就把这些行为合理化为读取的必要部分。 + +此指南针对 Python/Pydantic 后端;CLI 的可信响应解码仍不需要 Pydantic 运行时校验。它既不是“一切读取都 +不能调用 model_validate”,也不是“一次 model_validate 永远合理”:先确认该处是否真的需要新的模型实例。 diff --git a/tasks/knowledge-lifecycle-capabilities/decisions/D571-D580.md b/tasks/knowledge-lifecycle-capabilities/decisions/D571-D580.md new file mode 100644 index 0000000..b18d1ac --- /dev/null +++ b/tasks/knowledge-lifecycle-capabilities/decisions/D571-D580.md @@ -0,0 +1,140 @@ +# Decisions D-571–D-580 + +## D-571 — CLI sink:独立 Python 分发,通过 Core REST 操作 InKCre + +2026-09-13,Sir 明确将 command line tool/interface 选为下一 implementable unit。Task 内代号为 `cli-sink`, +完整 Python distribution 名为 `inkcre-cli`,通过 pip 分发,作为相对于 core-py 独立的子项目。 + +CLI 与 MCP 都属于 sink,面向 Agent,也应适合命令行中的普通使用。它是操作 Core 的界面,不包含完整 Core, +不直接访问数据库。本轮只支持调用 Core 的 REST API;这不属于 Peer delegation,也不能据此假定所有 Peer +都拥有 REST 服务。未来的 Rust single-binary InKCre Peer 可以提供自己的 CLI,但它与本项目是不同的产品和 +运行角色,不能混淆安装、命名与职责。 + +设计前恢复本任务的讨论准则,并结合技术博客、论坛讨论和 xiaoland/svc 的实际经验研究 Agent-friendly CLI。 +这次选择授权调查和 task-packet 维护,不授权源码实施或提交。沿用 Product → Technical → Acceptance → +Implementation Plan → Preflight / Impact Handshake → 明确实施授权 → 实现与端到端验收的流程。 + +可执行命令名、项目物理位置、MVP 操作清单、认证、REST 补齐方案、兼容与发布机制尚未确定。MCP 的 read-only +MVP 是该单元的范围选择,不能自动成为 CLI 的限制;CLI 本轮是否包含显式写入与行为触发需产品复核。 + +## D-572 — 以 CLI 为首个普通 REST 消费者,允许重整 Core REST + +2026-09-13,Sir 强调必须建立并持续使用 unit packet,并明确允许 Core REST API 改变:以 CLI 作为首个普通 +REST 消费者设计,不因旧有、尚无实际普通消费者的路由或 DTO 维持兼容层。CLI 的实际使用需要可以传导出 +Core 接口调整;此授权明确了可变范围,不跳过本 unit 尚未完成的设计、验收和实施准备。 + +仓库核验发现部分 HTTP handler 同时属于 Peer inbound:`app/routes/semantic_retrieval.py`、 +`lexical_retrieval.py` 和 `organization.py` 分别注册对应 capability;client-web 的三个 domain Manager 调用 +`PeerManager.delegate` 消费这些能力。因此“普通 REST 无现有消费者”不能扩大为“所有 HTTP handler 无消费者”。 +设计时区分外部 REST 合同与已有 Peer protocol;可以保留或明确迁移实际 Peer 合同,不为外部旧 REST 单独 +增加兼容负担,也不令 CLI 参与 Peer delegation。 + +## D-573 — CLI 产品范围包含配置管理、Peer 状态与唤醒 + +2026-09-13,Sir 接受首轮 Agent-friendly 理解和操作范围:三类检索、基础实体与 Resolver 内容读取、graph +写入,以及触发现有 collection/organization 并查看执行结果。同时明确将下列操作纳入同一 unit: + +- Peer 状态与唤醒; +- Extension 安装、卸载、启用、禁用; +- Source 创建、删除、编辑; +- 收集与整理的 Cron 配置。 + +这些能力共同进入本 unit 的产品、技术、验收与实施流程,不拆为未经讨论的独立交付单元。列表、详情和动态 +参数合同的发现,是正确选择和配置这些操作所需的自然配套;其准确命令形状仍待设计。 + +CLI 继续通过 Core REST 操作,不直接访问数据库或在本地执行 collection/organization worker。当前产品范围 +已确认;“唤醒”支持的运行环境、结果语义和接入方式需要下一步明确。不能把 Peer lease 过期直接解释为休眠, +也不能从新增唤醒操作推定已经获准构建跨平台进程/云资源管理系统。 + +## D-574 — HTTP 唤醒复用普通 Core endpoint + +2026-09-13,Sir 接受本轮唤醒覆盖 HTTP 请求触发冷启动的 Core 服务,并指出只要仍通过普通 REST endpoint +操作,就应与其它 CLI 调用使用同一 HTTP 路径,不做特殊平台集成。 + +核验事实:Core 现有的是 `GET /readyz`(`run.py`),不是专门的 `/wake` route。Render 在入站 HTTP 请求 +到达时启动休眠服务,随后 Core 的 readiness handler 报告实际数据库/runtime 状态。因而 CLI 可以向已知 +Core 地址发送普通 GET 并等待其就绪,无需 Render SDK、云管理凭据、Peer delegation 或新的 wake protocol。 + +“唤醒”是调用者要达成的效果,不要求服务端另加命令;进程尚未启动时,触发启动的是宿主平台。这个区别 +保留实现责任,不增加调用者步骤。目标地址取得、超时与等待呈现归后续 CLI/HTTP 设计,不能扩大成业务写 +请求的自动重试。本轮 Peer 唤醒产品边界已确认,下一步进入命令结构设计。 + +## D-575 — CLI 命令按领域语义收敛,等待可限时,并覆盖 Agent definition 管理 + +2026-09-13,Sir 接受命令分组和动态能力从 Core 发现的原则,修正 `get` / `read` 语义接近的问题:取消独立 +`read` 入口。`get` 批量取得 Block / Relation 记录,可选择不返回 content、返回原始 content 或取得 hydrated +content。Hydration 仍属于 Block / Storage 内容读取;solved content 是 Resolver 的解释结果,经 `resolver` +命令调用其普通 `get_solved_content` 方法,不为 CLI 建立第二个解释内容入口。准确选项、默认值和传输形状 +尚未冻结,不能把这个决定误写成更改 `BlockModel.content` 的持久语义。 + +Sir 明确要求 `wait` 支持等待指定时长而非只能等到终态,使 Agent 能及时取回执行状态,再决定继续等待或 +主动停止。等待预算与 Job 执行期限相互独立;本地等待结束不令远端 Job 失败或取消。命令应保留同一 Job +引用和实际已观察状态,不重发工作。主动停止进入产品需要,具体执行机制与停止保证仍需技术设计:当前 +Core 只有 `JobStatus.ABORTED` 枚举,没有可用的主动取消路径,不能只修改状态冒充执行已经停止。 + +`connection` 与 `config` 保持两个独立入口:分别拥有本机 CLI 连接配置和远端 deployment 配置。Sir 将 Agent +definition 和 Organization behavior config 的管理纳入同一 unit。Agent definition 的发现/创建/读取/编辑/ +删除与行为配置的选择分开;行为配置仍持久化在既有 `configs`,不另造 Organization 配置 authority。 + +本决定确认产品范围和命令的职责边界,不授权实现,也不冻结全部 argv、默认等待时长、exit codes 或 HTTP +合同。限时等待背后的 task-level Agent-friendly 模式记录于 [Agent Tool patterns](../common-patterns/agent-tools.md): +长期操作与调用者观察周期分离,保留可恢复观察和显式控制,不让一次调用独占后续编排。 + +## D-576 — Job 控制能力在 CLI unit 内补齐 + +2026-09-13,Sir 明确同意本 unit 补齐 Job 控制能力,包括前轮已识别的主动停止执行缺口。它是 CLI 的实际 +执行控制需要传导到 Core 的工作,不拆成另一个 unit,也不以当前只有 `aborted` 枚举为由缩减为状态写入。 +沿用 D-575 的限时观察、稳定 Job 引用和等待期限独立于执行期限的边界。 + +本次确认范围并继续设计,不跳过后续 Job 控制技术合同、验收、实现计划和实施授权。当前讨论继续进入动态 +合同发现与结构化输入;具体方案在 [输入与发现草案](../units/cli-sink/input-discovery.md) 中等待复核。 + +## D-577 — 接受操作级 schema 与 JSON 输入,CLI 继续使用 Pydantic + +2026-09-13,Sir 接受 [动态合同发现与结构化输入](../units/cli-sink/input-discovery.md) 的方案:本机 help、 +Core 动态目录、选定操作的 `--schema`,以及 argv 选择目标/类型、JSON 承载其余结构化业务数据。schema +对应实际提交层级,发现不成为合法调用的前置条件;支持文件/stdin `--input` 与较小 literal 的 `--input-json`。 + +Sir 同时明确 CLI 继续使用 Pydantic。上一轮“CLI 负责 shell 参数和 JSON 语法”的表述不能被解释为禁止 CLI +使用类型模型或必须手写输入处理。CLI 自有输入、配置和稳定协议模型可沿用 Pydantic;动态 Source/Resolver +等业务合同仍由 Core 的 owner 持有,不在 CLI 复制 Extension 业务模型,也不从此推导需要自制 JSON Schema +解释器或依赖整个 core-py package。 + +Pydantic 的具体版本、跨项目合同复用/生成方式与 HTTP/CLI 库留在 Technical 选型。当前进入结果呈现与 +可组合性设计,尚未获得本 unit 源码实施或提交授权。 + +## D-578 — 按结果语义提供默认输出,显式选择稳定 JSON + +2026-09-13,Sir 接受 [结果呈现与可组合性](../units/cli-sink/output-presentation.md):默认供 Human/Agent +阅读,字符串保留自然文本,结构化结果保留其结构;`--json` 提供稳定的脚本消费合同。TTY 不决定数据格式, +stdout 放结果,stderr 放过程与诊断。两种格式都保留实际引用、截断/分页/搜索边界和真实 Job 状态。 + +不增加通用成功包装、预制 next action 或每种 Resolver 独立的 CLI renderer。仍需分别设计大内容/多模态 +交付、各命令的准确 DTO 与 exit codes;本次确认不等于这些后续合同已冻结,也不授权源码实施。 + +## D-579 — 完整内容使用本地文件交付,JSON 模式紧凑输出 + +2026-09-13,Sir 接受 [大内容与多模态交付](../units/cli-sink/content-delivery.md):短文本/小结构直接输出, +默认可读模式下的大结果保存完整副本并显示有限预览与路径;binary 无论大小、根部或嵌套位置都以文件交付。 +文件保存本次调用的实际结果,不重新执行 Resolver,不丢失其所在结构和非二进制元数据,不新增 Core result +entity。显式 `--output-dir` 保存完整结果并返回导出回执;否则仅在必要时使用退出后仍可读取的 OS 临时目录。 + +未显式导出时,`--json` 不截断文本,也不因根对象过大临时替换其形状;binary 使用明确的文件引用表示。 +Sir 特别要求 JSON 模式不做 pretty print,使用紧凑序列化。这是输出格式约定,不是内容裁剪策略。 + +HTTP 传输、文件引用/回执 DTO、显示预算与文件命名仍待技术设计和预演。下一步复核 Job 等待与停止合同, +沿用 D-576 已确认的范围;不以此次产品批准代替本 unit 的源码实施授权。 + +## D-580 — Job 停止采用 runtime 集中检查的 best-effort 控制 + +2026-09-13,Sir 接受 [Job 等待与停止](../units/cli-sink/job-control.md) 的方案,明确这是 best-effort。 +`abort_requested` 保存停止意图;执行端 JobManager 批量检查本机 active Job,再通过本机异步执行句柄取消。 +不为每个 Job 建独立数据库监听循环,不让业务 Handler 自行读取停止字段,也不增加通知服务或 Peer 控制协议。 +core-py 与 client-web 的 Job worker 需要配套相同控制语义。 + +pending 可以原子关闭为 aborted;running 在请求受理后仍保留实际运行状态,直到执行端收尾。重复请求不产生 +新工作,已有终态不改写。best-effort 接受检查/取消延迟和已发生的部分效果,不意味着只写终态、不实际停止, +也不承诺回滚、强制杀进程或撤回外部请求。限时 wait 继续观察同一个 Job,等待预算不改变执行 timeout。 + +这一决定关闭该轮通知机制取舍;检查周期、精确 REST/CLI DTO 与各 Handler 取消传播仍需技术设计和预演。 +下一步讨论 CLI 连接与普通 Core REST 的接入合同,未进入源码实施或提交阶段。 diff --git a/tasks/knowledge-lifecycle-capabilities/decisions/D581-D590.md b/tasks/knowledge-lifecycle-capabilities/decisions/D581-D590.md new file mode 100644 index 0000000..1a1b355 --- /dev/null +++ b/tasks/knowledge-lifecycle-capabilities/decisions/D581-D590.md @@ -0,0 +1,134 @@ +# Decisions D-581–D-590 + +## D-581 — CLI 使用 deployment JWT secret 自行签发 token + +2026-09-13,Sir 明确要求 CLI 也使用 JWT secret 自行签发 token,否决上一轮尚未批准的独立 Core REST +长期 token 提案。CLI 本地连接保存 `jwt_secret`,受保护的 HTTP 请求沿用当前 JWT claims/算法/生命周期 +合同。Core 继续使用既有验证机制,不增加 `core.rest_api` 凭据配置、token 表、登录/续签服务或双认证入口。 + +这纠正了将产品角色差异自动推导为凭据隔离的判断。CLI 不包含 Core runtime,不直接访问数据库,不登记为 +Peer,也不参与 Peer delegation;这些运行职责与使用同一 deployment 签名凭据可以同时成立。不能把 +REST-only 实现边界宣称为该凭据没有数据库权限。MCP 自有 PAT 的决定不受影响。 + +本机 `connection` 与远端 `config` 继续分离。细节见 [连接与接入](../units/cli-sink/connection-access.md); +配置位置/优先级、签发期限、JWT 库与完整 HTTP 合同仍需技术设计。本次不授权源码实施、commit 或 push。 + +## D-582 — 接受命名连接,默认配置根目录使用 ~/.inkcre + +2026-09-13,Sir 同意命名连接、本机持久默认与单次选择的方案,并建议默认目录使用 `~/.inkcre`,取代上一轮 +操作系统原生用户配置目录的提案。具体 CLI 文件落点取 `~/.inkcre/cli/connections.json`,以子目录明确当前 +独立 CLI 的所有权;不为未来完整 Peer 预设其它目录或共用配置协议。 + +一个 JSON 文件保存命名连接与可选默认名,各连接保有完整 `base_url`/`jwt_secret`。`list/get/set/delete/use` +管理本机配置,`check` 提供非强制的连通与认证检查。选择文件和选择名称都采用显式 flag、专用环境变量、 +默认值的线性优先级;不跨文件合并,也不引入逐字段覆盖、配置继承或自动切换目标。 + +单次选择不修改默认值,运行中的命令不跟随其它进程的默认切换。具体合同见 +[连接与接入](../units/cli-sink/connection-access.md)。固定 home 相对路径用标准库 `Path.home()` 即可,无需 +为选择平台配置目录增加依赖。下一步进入 REST 与返回形状的具体技术设计,尚未授权源码实施或提交。 + +## D-583 — 基础实体 REST 与 Resolver 动态读取合同 + +2026-09-13,Sir 接受 [基础实体读取与 Resolver REST](../units/cli-sink/entity-resolver-rest.md) 的设计。 +`POST /entities/get` 接收有类型的 ID 数组,按输入顺序返回平铺记录或逐项错误;默认 content=raw,支持 none +与 hydrated。`content` 始终表示持久字段;hydrated 模式省略它并用 hydrated_content 交付实际值。Block 保留 +resolver/storage/timestamps,Relation 端点投影为 from_block_id/to_block_id,不改变数据库或领域模型。 + +Resolver 目录、按 exact ID 或 Block 发现方法、按 Block 调用方法由普通 REST 接出。调用 body 直接是方法参数, +成功结果保留方法自身的值,不添加成功包装。发现、schema 与校验后调用复用 ResolverManager,CLI 不复制 +Extension 模型,不导入 Core/MCP runtime。读取可能按既有 Resolver 合同 materialize,不宣称绝对无副作用。 + +这一确认不包含 bytes 的精确 HTTP 编码、文件引用、错误码全表或其它领域的未定合同;下一步继续同次结果 +的内容传输设计。仍未授权源码实施、commit 或 push。 + +## D-584 — 普通值用 JSON,纯 bytes 直接响应,嵌套 bytes 用 multipart + +2026-09-13,Sir 接受新的原生 HTTP 内容传输路线:普通 JSON 可表达的值使用 JSON,返回值本身是 bytes 时 +直接交付二进制 body,对象或数组含 bytes 时使用 multipart 同次交付结构与所有 binary 成员。MessagePack +的上一轮提案未被选择,保留局部实验作为比较证据,不在 MVP 同时实现两套等价的 binary 内容协议。 + +D-583 的逻辑结果形状不变:不能为了返回裸 binary 丢弃 solved content 的其它字段,也不能去掉单项 get +仍保留的批量数组。HTTP 边界从普通领域结果生成表示;Resolver 不理解 MIME part,CLI 也不复制 Extension +结果 schema。文件副本来自本次返回值,不引入服务端 result 生命周期或第二次内容读取。 + +具体设计见 [内容 HTTP 表示](../units/cli-sink/content-transport.md)。本次确认的是传输路线;multipart +字段与 part 的关联表达、本地文件引用及导出回执仍需收敛。已有局部 HTTP 实验证明 raw bytes 与 multipart +传输可行,不等于完整实现、流式内存特征或部署验收通过。本次不授权源码实施或提交。 + +## D-585 — Multipart 用独立关联表连接 JSON 字段与 binary part + +2026-09-13,Sir 接受 multipart 字段关联方案:首个 application/json part 包含 value 与 parts;value 保留 +逻辑结果结构,bytes 位置暂以 null 占位,parts 将相对于 value 的 JSON Pointer 映射到 binary part 的 +Content-ID。没有关联的 null 仍是普通值,空 bytes 仍有 part。字段路径使用 RFC 6901,不猜字段名、cid +字符串或业务对象的形状。 + +这一封装只属于 multipart 的 HTTP 表示。Resolver 不知道 MIME,普通 JSON 与根 bytes 的响应不受影响, +CLI 解码后继续按 D-579 交付原结果与本地文件,不透传 value/parts 成为所有命令的输出外壳。MIME 编解码 +使用成熟库,HTTP 边界仅拥有字段关联适配。 + +具体合同见 [内容 HTTP 表示](../units/cli-sink/content-transport.md)。下一复核面是 CLI 本地文件引用与 +导出回执。关联语义已确认,但完整编解码链路尚未实现或验收;仍未授权源码实施、commit 或 push。 + +## D-586 — CLI 用 file 引用交付 bytes,导出回执指向入口文件 + +2026-09-13,Sir 接受 [本地文件输出](../units/cli-sink/file-output.md)。CLI 将实际 bytes 保存为文件,在 +原结果位置使用 `{"file":"路径"}`;根 bytes 同样处理,其它字段和批次数组结构不变。stdout 使用绝对路径, +保存的结果 JSON 内使用相对引用,便于整个导出目录移动。普通 JSON 仍为紧凑输出,不因大小改变根形状。 + +显式 output-dir 在指定目录内创建本次子目录,根文本保存为 result.txt,根 bytes 为 result.bin,其它值 +为 result.json。stdout 的 JSON 回执只包含入口文件的 file 路径,不增加目录、计数或成功包装。 + +Sir 同时接受呈现层残余:file 引用不是可逆类型编码,业务原有同形对象与 bytes 投影无法仅凭最终 CLI JSON +反向区分。CLI 不把普通业务对象解释为文件指令,不承诺自动反导入或完整类型往返;HTTP 的精确 bytes +关联合同不受影响。不为此新增全结果 envelope、转义规则或文件索引。仍在设计,未授权源码实施或提交。 + +## D-587 — Job REST 接出持久命令,CLI 以有界 GET 组合实现 wait + +2026-09-13,Sir 接受 [Job REST 与有界观察](../units/cli-sink/job-rest.md),并特别确认“创建成功只表示 +Job 已持久化,不要求接入 Peer 能执行它”。创建资格与执行资格保持分离;类型目录来自 deployment catalog, +实际 can_handle/claim 属于执行端,不变成 HTTP 创建的前置条件。 + +普通 REST 提供 Job type 发现、POST jobs 创建、列表/详情与 POST jobs/{id}/abort。创建返回 HTTP 201 和 +实际 Job 记录;停止返回真实记录,不把请求受理冒充执行结束,也不增加 status/state 的通用编辑入口。 + +wait 由 CLI 在预算内重复 GET 同一 Job 实现,默认观察 30 秒、约 2 秒查询一次,终态提前返回。预算覆盖 +HTTP 与查询间隔;正常预算耗尽返回最后成功观察记录,不冒充此刻最新状态。查询到失败 Job 仍是观察成功, +连接/协议故障则为调用失败。停止观察不发 abort、不重派 Job;不增加服务端等待会话或长连接协议。 + +这个确认不冻结其它管理列表的分页、全部错误码、时长解析库或执行端取消传播细节。仍在设计,未授权源码 +实施、commit 或 push。 + +## D-588 — Source / Cron 管理区分保存配置、派发工作与实际执行 + +2026-09-13,Sir 接受 [Source / Cron REST](../units/cli-sink/source-cron-rest.md)。Source 管理不要求接入 +Peer 能执行该 Source:有本地模型时用其规范化,否则用持久类型目录的 schema 校验;保存不证明外部连接可用。 +Source 修改 nickname、config、storage,不开放 type、state、block 的通用编辑;更换类型创建新 Source。 + +PATCH 在记录顶层替换:省略字段保持不变,允许空值的字段可显式设 null;提交 config 或 job_parameters +就是完整替换该对象,不递归合并。配置更新不覆盖运行时游标或 Cron 调度进度。 + +Source 的 collect/backfill 与 Cron 的 run 创建 Job,仍遵守 D-587 的持久化与执行资格分离。禁用 Cron +只停止周期派发,不禁止显式 run;手动 run 不推进周期调度进度。删除 Source 保留已收集 graph、Cron 和 +Job,不隐式取消或改绑;需要被删除配置的后续读取可能失去访问凭据。删除或禁用 Cron 不撤销已创建 Job。 + +具体接口与实现核验点留在上述领域设计文档;管理列表、其余领域 REST、验收与预演仍待完成。本次确认不 +授权源码实施、commit 或 push。 + +## D-589 — Agent 管理接口确认,AI 模型发现归 ai 命令组 + +Sir 接受 [Agent definition 管理与引用发现](../units/cli-sink/agent-management-rest.md),纠正 CLI 分组: +使用 ai models,而不是 agent models。模型读取仍由 AIManager 拥有;Agent Tool 发现保留在 agent 下。 +本次增加 ai 分组不自动扩大到 AI Provider / Model 写入管理。此前提出的其余接口与表单获确认,尚未实施。 + +Sir 同时指出 REST 评审反复陈述既有业务规则的问题。后续应聚焦本层接口增量,既有业务语义用于内部预演; +只有协议方案会改变它或造成冲突时才展开讨论。该纠偏已写入 task 的 experimental design taste,不作为新业务规则。 + +## D-590 — 配置接口确认,校验应位于建立输入合同的边界 + +Sir 接受 [Deployment config REST](../units/cli-sink/deployment-config-rest.md):现有 registry 的 schema 与 keys +发现已满足当前需求,未持久化不构成缺陷。本轮保留无 schema 表的方案;没有脱离代码 owner 发布、维护 schema +或无实现 Peer 独立管理合同的实际需求,不预建 deployment_config_schema。 + +Sir 进一步纠正讨论依据:信任数据库输入、正常读取不重复 schema 校验是一般边界原则,不应包装成一项例外供 +Human 复核。typed get 的类型化表达也不是双向验证的理由。该指南记录在 common-patterns/validation-boundaries, +配置读路径的既有重复校验纳入本 unit 预演;不授权扩成全仓库清理。当前仍为设计,未实施或提交源码。 diff --git a/tasks/knowledge-lifecycle-capabilities/decisions/D591-D600.md b/tasks/knowledge-lifecycle-capabilities/decisions/D591-D600.md new file mode 100644 index 0000000..de73e3f --- /dev/null +++ b/tasks/knowledge-lifecycle-capabilities/decisions/D591-D600.md @@ -0,0 +1,113 @@ +# Decisions D-591–D-600 + +## D-591 — CLI unit 同时纠正已存在的同类校验边界问题 + +Sir 要求将 D-590 揭示的读库重复校验向同类实现展开,而不是仅修正 deployment config。检查与修正纳入 +cli-sink 单元,沿实际输入、持久化和使用调用链定位,不能靠搜索函数名机械删除。 + +本项扩大的是单元范围;仍按既定阶段完成设计、实现计划和预演,再进入源码实施。具体证据、跨 repo owner +和转换压力见 [校验边界修正](../units/cli-sink/validation-boundary-correction.md)。不扩大成一般安全审计,不绕过 +仍在真实输入边界上的校验,也不因排查而修改环境中的第三方包。 + +## D-592 — Peer 接口确认,self 是内置别名,保留轻量 HTTP 唤醒 + +Sir 接受 [Peer REST](../units/cli-sink/peer-rest.md),并将 self 理解为特殊的内置 alias/nickname。接口按当前 +REST 接入 Peer 解析该别名,不持久化 nickname 或建立通用别名系统。 + +Sir 提出可以考虑撤回尚未规范化的唤醒功能,也明确接受直接请求既有 /readyz 的方案。因此本轮保留该轻量 +CLI 操作:使用已配置 connection,有界等待 HTTP 入口就绪;不增加 wake endpoint、Peer 地址发现或平台控制。 +是否因 HTTP 请求启动实例由部署平台决定,不承诺通用 Peer 唤醒。尚未实施、提交或执行实际唤醒。 + +## D-593 — Extension REST 复用 Host,启停支持指定 Peer + +Sir 接受 [Extension 管理 REST](../units/cli-sink/extension-rest.md)。保留现有安装、卸载、记录读取及完整配置 +替换接口,新增浅层配置 PATCH。配置 body 就是配置本身,CLI 不读取后自行合并,也不内置 Extension schema。 + +enable/disable 增加可选 route_to_peer,对应 CLI --peer;省略时操作接入 Core 自己。委托通过既有 Host.manage +完成,不由 CLI 调用 Peer inbound 或增设通用管理协议。安装、卸载与配置读写仍经接入 Host 操作共享记录, +不承诺向全部 Peer 广播配置变更。返回既有 InstalledExtension;卸载成功 204。仍处于设计阶段。 + +## D-594 — Recall 保留独立模式结果,Graph REST 复用查询合同 + +Sir 接受 [检索与 Graph REST](../units/cli-sink/retrieval-graph-rest.md)。普通 lexical/semantic REST 调用领域 +门面,保留既有 non-delegating Peer inbound;CLI 显式选择一个或多个 mode,分别保留原始命中与模式级错误, +不融合分数、跨模式去重或创建聚合 RecallManager。显式 profile 选择配套只读 embedding-profile list/get。 + +图邻域、路径及 connected components 直接暴露既有 Manager 能力和查询边界;普通 REST 实体字段沿 D-583 +映射,不将展示或 Resolver 内容读取混入图查询。追加 GraphForm 的 HTTP 方法由 PUT 改为 POST /graph, +保留现有最小 SubmitGraphResult,不增加 Graph 实体或幂等机制。尚未实施。 + +## D-595 — 收集与整理受理为 Job,CLI 覆盖实体原地编辑和删除 + +Sir 否决直接执行显式 rumination 的提案:Block 是输入,不决定 Organization 的领域归属;CLI 使用的普通 +REST 不能与 Peer delegation inbound 混同。显式收集、整理均应创建相应 Job,再通过已有 Job 观察与控制接口 +管理执行。同时明确将 Block/Relation 原地编辑、删除纳入本单元,而不是把直接图写入收窄为新增 GraphForm。 + +这纠正了方案从已有同步门面反推外部任务受理模型的问题;不是要求所有普通 REST 操作改成 Job。既有 Peer +入站合同不改成任务提交,D-593/D-594 的管理与检索决策保持。此前拟议 POST /blocks/{id}/ruminate 撤回。 + +[修订方案](../units/cli-sink/organization-write-rest.md) 建议 organization ruminate 使用 POST /jobs,新建 +Organization-owned explicit rumination Job type;既有 automatic Job 的 max_seeds 不能表达指定 Block。 +实体 update/delete 使用已有 InfoBase managers,配套修正 Block PATCH 的 omitted storage 被清空问题。 +范围与责任已由 Sir 明确;新增 exact Job ID、准确 CLI/REST 形状仍是本轮提交复核的技术方案,尚未实施。 + +## D-596 — 确认显式 Rumination Job 与实体修改的准确接口 + +Sir 接受 D-595 后的[修订方案](../units/cli-sink/organization-write-rest.md):organization ruminate 通过 +POST /jobs 创建 core.organization.rumination.explicit.v1,parameters 为指定 block;由 Organization-owned +handler 调用本地执行路径,不增加执行 Peer 选项,不使用既有 Peer inbound。返回 Job 后沿既定观察与停止合同。 + +CLI 根级 update/delete 使用与 get 一致的 block:/relation: 引用;普通 REST 为对应资源的 PATCH/DELETE。 +PATCH 保留省略字段,只允许 storage 显式为 null,并修正旧 route 使用 BlockModel 导致的省略值丢失。 +新增 graph 继续使用 GraphForm,不将原地编辑混入 submit。准确操作已确认,仍不构成源码实施或提交授权。 + +## D-597 — 确认列表与错误合同,提炼通用分页和部分失败模式 + +Sir 接受[管理列表与错误输出](../units/cli-sink/list-error-contract.md),包括 Job 的 limit/cursor、原生错误 +信息、输出流和退出码,以及保留全部逐项结果但在部分失败时退出 1。读取到失败的 Job 仍是成功观察,不把 +对象的业务状态当成查询失败。 + +Sir 同时明确长输出分页是各查询指令的通用模式,不应被限定为 Job 历史特例。是否分页由结果体量与查询 +性质判断;小目录一次返回是当前默认方案,不是某些领域免于考虑长输出的规则。已有检索范围、图查询边界 +及完整内容交付保持各自语义,具体长输出覆盖须在后续技术设计和预演中逐项核对,不能仅验收 Job。 + +两项模式已提炼到 [Agent Tool common patterns](../common-patterns/agent-tools.md):分页分离单次读取预算 +与完整结果;独立批次分离可用成果与整体完成度。适用于 task 内其它接口,具体 cursor/退出码仍归各接口。 +仅更新 task packet 与待提升计划,尚未修改 durable docs 或源码。 + +## D-598 — CLI 位于 core-py 内,独立 PDM project,信任 Core REST 返回 + +Sir 接受[项目与依赖方案](../units/cli-sink/project-and-dependencies.md)的库选型及“复用协议,而不是安装 Core” +边界,明确否决独立仓库提案:CLI 放在 core-py 下,有自己的 PDM project;workspace 仅在有实际需要时采用。 +落点为 cli/,distribution/executable 为 inkcre-cli,Python package 为 inkcre_cli,不新增 Git 仓库或第二份 +unit packet。经当前 PDM 2.28 文档核验,普通嵌套 project 已足够,暂不共享 workspace 环境与 lock。 + +Sir 同时纠正响应消费边界:Core REST 的业务返回是可信 authority,CLI 尽可能只转换,不再次校验。 +Pydantic 保留在 CLI 自有输入,响应按 JSON/MIME 解码和实际展示需要转换;必要静态类型不要求调用响应模型 +validator,也不另建通用无校验构造器。保留完整字段、原错误和实际解码失败,不以信任为由猜测或隐藏问题。 +对应原则补充到[校验边界指南](../common-patterns/validation-boundaries.md)。仍处于设计阶段,未授权源码实施或提交。 + +## D-599 — 统一准备版本,CLI 独立发布到 PyPI + +Sir 接受[CLI 版本与发布方案](../units/cli-sink/release-and-distribution.md)。CLI 作为独立 ReleaseProject 接入 +已有 Towncrier 与 release/next,拥有自己的版本、changelog 和 fragments;不成为 Core 或 Extension 的别名。 +feature PR 只携带所属项目的 release intent,Release PR 准备版本,合入 main 后各产物独立发布。 + +CLI 的 PyPI 路径使用 PDM 原生构建与 Trusted Publishing,在发布 run 内重新构建,不消费 PR CI 产物,也不 +进入 Extension Registry 发布矩阵。初始开发版本 0.0.0 经正常 Release PR 形成首个正式版本 0.1.0;新项目 +加入本身不触发上传。修正现有 release helper 对非 Extension 项目身份及初始 changelog 的假设,不另建框架。 + +首发先保证 Core REST 实际可用,再发布依赖它的 CLI;常态 CLI 发布不永久依赖 Core deployment 完成或版本号 +相等。PyPI publisher 配置与真实发布权限仍为交付前置项,不能由设计确认推定已具备。本轮仍只推进技术设计, +未授权源码实施、commit、push 或发布;下一步收敛 D-597 的各查询长输出覆盖。 + +## D-600 — 清单统一支持可选分页,续读不改变查询语义 + +Sir 接受[查询续读方案](../units/cli-sink/query-continuation.md)。管理列表、能力目录及本机连接清单均支持 +可选 limit/cursor;除 Job 保留默认 20 条外,小清单仍默认完整返回。枚举结果由裸数组调整为所属领域清单 +与 next_cursor,未分页也保持相同形状;不增加 total、分页会话或自动取完所有页。 + +图邻域复用现有分页,每页保持关系端点闭合。Recall、路径、连通分量、显式 ID 批次与 Resolver 返回值保留 +各自查询范围和完整结果语义;过长内容沿既有文件交付读取,不伪造下一页,不为续读同次内容重新调用方法。 +详情、精确 schema、批次结果和 Resolver 值不套清单包装。方案确认仍不构成源码实施授权;随后进入验收设计, +实现计划与 preflight 继续核对剩余技术压力。 diff --git a/tasks/knowledge-lifecycle-capabilities/decisions/D601-D610.md b/tasks/knowledge-lifecycle-capabilities/decisions/D601-D610.md new file mode 100644 index 0000000..c679798 --- /dev/null +++ b/tasks/knowledge-lifecycle-capabilities/decisions/D601-D610.md @@ -0,0 +1,37 @@ +# Decisions D-601–D-610 + +## D-601 — CLI 的四条验收旅程与正式分发闭环获确认 + +Sir 接受 [CLI Acceptance](../units/cli-sink/acceptance.md):独立安装后的 CLI 经真实 REST 验证信息取用、 +来源与调度、整理与执行控制、图修改与脚本组合。使用真实技术材料和既有协议设施,不以 endpoint 数量、 +固定 Agent 调用顺序、LLM 措辞或偶然路径作为验收目标。 + +完整跨 Peer Job 链路由两个 core-py 验证;client-web 的真实 worker/AbortSignal 使用有限脚本作组件边界验证, +不因缺少现成 Source 实现而为测试新增业务 Source,也不把该证据冒充浏览器采集验收。静态检查配合手工/脚本 +黑盒旅程,不新增自动化测试矩阵。 + +交付要求 Core 新接口先在生产可用、CLI 正式发布 PyPI,再从 PyPI 干净安装复跑关键操作。接口和验收设计 +已获确认,接下来列实现计划并完成 preflight;本次同意不等于源码实施、提交、推送或发布授权。 + +## D-602 — 复杂持久类型恢复允许一次原生构造,不自制无校验解码框架 + +2026-09-14,Sir 接受 [Runtime 预演](../units/cli-sink/preflight-runtime.md) 提出的取舍,并要求提炼为 +Python 后端级别的通用指南。 + +普通记录读取、CLI 消费 Core REST、已有 typed 对象传递不重新验证;PATCH 只验证合并后的新值。 +执行入口确实需要从持久 JSON 恢复嵌套模型、SecretStr、日期或 union 等 Python 类型时,允许一次 +Pydantic 原生构造,并接受它附带执行模型约束与 validators。不把这条路径伪称为无校验 decode, +也不叠加专门的持久值合法性检查或错误包装。 + +这是对 D-590/D-591 读取边界修正的实现取舍,不撤回信任既有 authority 的原则。简单表示仍直接转换, +不为消灭库附带的最后一次校验增加递归 decoder、平行模型体系或强制 Extension decode hooks。 +指南已加入 [校验边界模式](../common-patterns/validation-boundaries.md),具体落地仍随 CLI 实施与验证推进。 + +## D-603 — 通用指南明确为 Pydantic 的边界使用方法 + +2026-09-14,Sir 指出此前提炼过于泛化;目标是说明在使用 Pydantic 的同时怎样避免过度校验边界, +而不只是陈述通用的成熟库与复杂度取舍。指南改为按外部 Form、内部 typed 值、PATCH、记录读取、复杂类型 +恢复与简单表示转换逐项说明,不改变 D-602 已确认的行为。 + +Sir 同时要求核验是否可以进入实现,并在可以时整理提交。该请求不是源码实施授权;本轮复核仍发现 +preflight 未完成,具体事实见 unit preflight,不因有提交意向而把它改写为 ready。 diff --git a/tasks/knowledge-lifecycle-capabilities/decisions/index.md b/tasks/knowledge-lifecycle-capabilities/decisions/index.md index 084fba1..4fb5749 100644 --- a/tasks/knowledge-lifecycle-capabilities/decisions/index.md +++ b/tasks/knowledge-lifecycle-capabilities/decisions/index.md @@ -63,6 +63,10 @@ so one stable ID has one predictable address;the shard boundary does not imply | [D-541–D-550](D541-D550.md) | 工具修复验证约束 | | [D-551–D-560](D551-D560.md) | 成功回执与逐项类型引用 | | [D-561–D-570](D561-D570.md) | lineage 执行位置与验收范围纠正 | +| [D-571–D-580](D571-D580.md) | CLI sink REST/命令边界、Job 控制、动态发现、Pydantic 输入与内容交付 | +| [D-581–D-590](D581-D590.md) | CLI 自签 JWT、命名连接、实体/Resolver REST、内容与文件交付、Job / Source / Cron / Agent / Config 管理、AI 与 Tool 发现 | +| [D-591–D-600](D591-D600.md) | CLI 校验边界修正、Peer / Extension、检索与 Graph REST、整理 Job、实体修改、独立项目与 PyPI 发布、查询续读 | +| [D-601–D-610](D601-D610.md) | CLI 验收确认、Pydantic 校验边界使用指南 → 实现准备 | | [Withdrawn frames](withdrawn.md) | Explicitly rejected organizing frames and proposals | ## Register Rules @@ -78,9 +82,10 @@ so one stable ID has one predictable address;the shard boundary does not imply ## Current Edge -- Latest confirmed decision by registered ID: [D-561](D561-D570.md)。MCP sink retains D-381–D-420;Telegram extension retains - D-421–D-460;Organization Nowledge study owns D-461–D-570。 -- MCP and Telegram are closed。The active Unit is - [organization-nowledge-study](../units/organization-nowledge-study/packet.md) in Verify / Acceptance after implementation。 +- Latest confirmed decision by registered ID: [D-603](D601-D610.md)。MCP sink retains D-381–D-420;Telegram extension retains + D-421–D-460;Organization Nowledge study retains D-461–D-570;CLI sink reserves D-571–D-610。 +- MCP、Telegram 和 [organization-nowledge-study](../units/organization-nowledge-study/packet.md) 均已关闭;Organization + 已完成 PR #100 / #101 合并及 Core 0.2.0 生产交付。当前 [CLI sink](../units/cli-sink/packet.md) 已完成完整预演,等待 Impact Handshake 后实施; + Parent task 保留其余候选与 durable-owner reconciliation。 - Parallel placement and integration surfaces are shared peer control in the [roster](../collaboration/roster.md);there is no coordinator role。 diff --git a/tasks/knowledge-lifecycle-capabilities/design-taste.md b/tasks/knowledge-lifecycle-capabilities/design-taste.md index 9f55e98..a7380df 100644 --- a/tasks/knowledge-lifecycle-capabilities/design-taste.md +++ b/tasks/knowledge-lifecycle-capabilities/design-taste.md @@ -13,6 +13,9 @@ The task's Human/Agent roles、Unit gates、write-back discipline and parallel-s Agent Tool 的 task-level 设计原则统一维护于 [Agent Tool common patterns](common-patterns/agent-tools.md)。 涉及工具形态、发现、参数/说明、错误或响应设计时先读该文件;本处不另存一份规则。 +涉及 schema 与读写校验位置时,使用 [校验边界指南](common-patterns/validation-boundaries.md):先定位建立 +输入合同的边界,不因函数分层或 typed 返回而反复验证已经持久化的数据。 + The unit of progress is a more coherent、evidence-backed current system model,not another answered question or a longer decision register。Sir's preference to ask one question at a time is an upper bound on simultaneous human review,not a requirement to manufacture one question after every answer。 @@ -25,14 +28,23 @@ Before turning an unresolved point into a human question: 3. when behavior is recurring、asynchronous、partial or state-dependent,replay at least two executions in a sequence/state model and identify the persisted fact that makes the second execution different; 4. eliminate choices already dominated by confirmed constraints and marginal utility; -5. if one coherent answer remains,record/present the derived result without asking。Only surviving credible forks enter human - review,one at a time。 +5. 自然推论可记录后继续;新的关键设计仍需复核,即使只剩一个推荐方向。不为形成问题而制造另一个选项。 This workflow is deliberately experimental。Topology and sequence models are tools selected when they expose the relevant dependency or time behavior,not compulsory diagram artifacts for every small naming or mechanical decision。 ## Before Escalating a Design Question +讨论某一层接口时,复核对象应是该层新增或改变的合同,而不是重新确认其调用领域的既有规则。既有规则用于 +内部预演与一致性检查;只有接口方案确实会改变业务行为、产生歧义或暴露冲突时,才展开相关规则供 Sir 审查。 +例如 CLI / REST 的评审聚焦命令归属、路径、输入输出和协议语义,不把 Thread 快照等既有生命周期当作新决策。 +这是 task-wide experimental discussion guideline,不免除调查、接口文档或实现时保持业务语义的责任。 + +设计对外操作时,先确认调用者要提交什么、何时算受理,再选择内部执行入口。已有同步方法或 Peer inbound +是可复用实现的证据,不自动决定新接口的生命周期;输入属于哪个领域,也不决定行为归属。D-595 的反例是 +从 rumination 接收 Block、已有立即执行门面,推导出 Block 下的同步 CLI 动作。这里应复用任务受理与控制, +不以更少的包装代码换掉调用者需要的 Job 语义;反过来也不把普通记录编辑机械改成 Job。 + Run every candidate through these filters first: 1. **Authority and lifecycle**:does the proposal make one owner/progress cursor depend on an orthogonal lifecycle merely @@ -46,9 +58,9 @@ Run every candidate through these filters first: 5. **Natural consequence**:derive low-risk names、mechanical validation、ordinary error mapping and dominated choices without asking Sir to select them。Record the result and expose it at the batch boundary。 -A human decision question is justified only when at least two **credible、non-dominated** answers remain after those filters, -and choosing among them materially changes observable product behavior、authority、public contract、irreversible effects or a -high-cost failure/recovery path。Missing evidence should trigger exploration,not a speculative choice。 +需要 Sir 复核的是会实质改变产品行为、authority、公开合同或重要代价的方案,不限于存在两个备选答案的情形。 +有真实取舍时提供推荐并解释理由;仅为已确认模型的低风险推论时记录后继续。缺证据先调查,调查后仍需要 +Sir 的信息或方向才能推进时,明确缺口,而不是提出无依据的选项。 ## Operational and safety reasoning discipline diff --git a/tasks/knowledge-lifecycle-capabilities/documentation-promotion/index.md b/tasks/knowledge-lifecycle-capabilities/documentation-promotion/index.md index 21168c9..884e506 100644 --- a/tasks/knowledge-lifecycle-capabilities/documentation-promotion/index.md +++ b/tasks/knowledge-lifecycle-capabilities/documentation-promotion/index.md @@ -8,7 +8,17 @@ - [Spoke Unit TDD promotion](spoke-unit-tdd.md) - [Architecture understanding provenance](../architecture-understanding/index.md) - [Agent Tool common patterns](../common-patterns/agent-tools.md):已确认的 task-level 模式;后续按验证与 owner 进行 - durable promotion,当前不直接改 shared Hub。 + durable promotion,当前不直接改 shared Hub。D-597 补充各查询的长输出分页,以及独立批次的成果/完成度分离; + 通用原则候选归共享技术设计指南,CLI 的 cursor、退出码与文件表示留在 CLI/Core 各自的接口文档。 +- [校验边界指南](../common-patterns/validation-boundaries.md):D-590/D-598 的 task-level 指南,包含可信 Core REST + 返回的消费边界;待 CLI 实现验证后归入 + 共享技术设计指导,具体配置实现留在 Spoke,不在此复制另一份规则。 + D-602/D-603 的 Pydantic 使用指南属于 Python 后端工程指导,不进入 PRD 或语言无关的 Hub 合同; + 随实现落入 Spoke 的 Python 后端指导,并在 python-backend-code skill 的 validation 指导中引用/应用。 + 内容应具体说明 Form 已校验后的 typed 传递、PATCH 合并后单次校验、GET 不额外重验、复杂模型恢复的允许 + 边界与 model_construct 的局限,不只提升为抽象的“使用成熟库”原则。 + 从“所有读回只转换、零校验”修正为“无类型恢复需求时不重验;需要时接受一次成熟库构造的附带约束”, + 保持写入 owner、CLI 可信响应和使用时能力判断不变。不因此建立新的文档权威或把整份 skill 提升为 Hub truth。 ## Control diff --git a/tasks/knowledge-lifecycle-capabilities/packet.md b/tasks/knowledge-lifecycle-capabilities/packet.md index 8e65e24..33ac95c 100644 --- a/tasks/knowledge-lifecycle-capabilities/packet.md +++ b/tasks/knowledge-lifecycle-capabilities/packet.md @@ -5,7 +5,7 @@ - **Guardrails**: 收集、整理、应用是能力动作而非信息状态;block / relation graph 是 info-base 的持久 authority;Extension contribution 不创建第二套 graph authority,也不因 first-party status 自动 成为 Core;横切机制只由具体单元的真实压力推动;durable docs 与业务代码 - 各自只有在完成对应 Impact Handshake 且 Sir 明确“开始”后才修改,并按 owner 分离操作。 + 各自只有在完成对应 Impact Handshake 且 Sir 明确授权实施后才修改,并按 owner 分离操作。 - **Verification**: 每个 active unit 必须拥有自己的可执行验收合同、阶段 gate、Impact Handshake 与验证结果;D-049 要求结构性验证优先交给 static mechanisms,runtime acceptance black-box-first。Program 完成还要求所有获批 durable truth 回到唯一 owner。 @@ -15,13 +15,14 @@ InKCre 的长期产品事实是不建立 terminal-user、tenant 或 per-user ownership/ACL domain;deployment 是单一 owner context,runtime nodes 称为 peers(D-033/D-109)。 Memos、RSS、Mail、semantic retrieval、feature/lexical retrieval 与 graph-navigation retrieval 均已关闭; - current summaries live in [capability-map.md](capability-map.md),details stay in each unit packet and the + 能力划分见 [capability-map.md](capability-map.md),当前状态见本页下方;details stay in each unit packet and the [decision register](decisions/index.md)。GitHub extension 的 collection-side correction remains queued, but no longer blocks root-usability selection after ownership corrections merged。 -- **Next Step**: closed MCP and Telegram fronts remain integrated;the - [organization Nowledge vertical](units/organization-nowledge-study/packet.md) completed its Product mechanism review and anti- - overlearning audit under D-493 and its whole implementation under D-527。It is now in Verify / Acceptance;its results do not - authorize a generic organization framework。 +- **Next Step**: [CLI sink / inkcre-cli](units/cli-sink/packet.md) 已完成完整 + [preflight](units/cli-sink/preflight.md),Execution baseline 冻结至 D-603。真实调用链、独立工具链与双 + Core/IMAP/浏览器/AI 环境已核验;production Extension publisher 漏 finalize 的修正纳入实施计划。 + 下一步是 [Impact Handshake](units/cli-sink/impact-handshake.md) 后等待 Sir 明确“开始”。 + 目前没有业务源码实施、push 或发布;独立 CLI 的四条旅程仍在实施后验收。 ## Program Boundary @@ -41,7 +42,25 @@ - deployment-scoped single-owner 是长期产品边界;外部 source account 或协议中的 `user` 不自动成为 InKCre core domain user,也不引入 tenant 或 per-user ownership/AC。 -## Active Units +## 当前 Unit 的入场 + +2026-09-13,Sir 选择 `cli-sink`:Python 独立子项目 `inkcre-cli`,通过 pip 分发,作为操作 Core 的命令行界面。 +本轮只支持 Core REST API;它不直接访问数据库,也不参与 Peer delegation。见 [D-571](decisions/D571-D580.md)。 + +本次交接的已发布基线是 Core 0.2.0 / main `b3ccb00`;新 session 仍需检查当时的最新 main。CLI 的设计与完整 +preflight 已整理为独立提交;前一单元的本地收尾仍未提交。新 worktree 不会自动带上未提交记录,应在建分支前 +核对并保留或显式转交所需 packet,不能把旧提交里的 active 状态当作现状。环境入口是 `AGENTS.local.md` 与 +`svc.local.json`,不要复制凭据或以本机没有 Docker/PostgreSQL 推断数据库不可用。 + +当前最新决策为 D-603;Pydantic 取舍与完整预演已闭合,CLI 等待实施握手。 +Organization 保留 D-461–D-570,CLI 保留 D-571–D-610,不复用历史空号。 +CLI 已在 root worktree 从与 origin/main 一致的 `b3ccb00` 切出 `feat/inkcre-cli`,保留原有未提交 task-control。 +已关闭 session 不再持有源码锁,历史授权和 deferred 项也不自动成为新 unit 的实施范围。 + +## Unit 状态与选择 + +[CLI sink](units/cli-sink/packet.md) 为当前 active unit,公开接口、验收设计和完整 preflight 已完成, +等待 Impact Handshake 后实施。研究依据包括本任务 Agent Tool 模式、xiaoland/svc 的 CLI 实践及一手公开材料。 [MCP sink](units/mcp-sink/packet.md) 已通过 PR #88 合并并关闭。 @@ -49,10 +68,12 @@ MCP sink MVP 复用现有 retrieval primitives,让外部 Agent/tool client 检 block/relation/solved-content context;最终用于写作、设计、编码还是 chat,由 caller 拥有。它不授权 generic sink framework。 -[Organization Nowledge vertical](units/organization-nowledge-study/packet.md) 是处于 Verify / Acceptance 的 active -implementation Unit。逐项 Nowledge study 与 D-493 transfer audit 是它已完成的 Product phase;D-495 修正了将其误判 +[Organization Nowledge vertical](units/organization-nowledge-study/packet.md) 已完成并关闭。逐项 Nowledge study 与 +D-493 transfer audit 是它已完成的 Product phase;D-495 修正了将其误判 为 research-only Unit 的错误,D-496 修正了继续拆 delivery slices 的错误。整组实现及多轮真实 preview/provider -验收已执行;PR #100 当前范围的合并准备已完成,不执行合并。递归环检测已修复;D-561 的 lineage 同步读取 +验收已执行;PR #100 已随 `915be5a` 合入 main,Release PR #101 的 `b3ccb00` 已完成 Core 0.2.0 生产发布、 +探针及 stable 接纳,unit 已关闭。Parent task 保持 active,Hub promotion 未被隐含标记为完成。 +递归环检测已修复;D-561 的 lineage 同步读取 线程修正已推送,小图实际读取与并行健康响应通过,临时资源已清理。已知 SQL 性能问题延期,不增加大图验收门槛。 整组 Job 均结束不等于所有 seeds 或图语义正确;保留语义误判、预算耗尽 未观测项与未覆盖输入的残余,不能写成 @@ -60,7 +81,7 @@ implementation Unit。逐项 Nowledge study 与 D-493 transfer audit 是它已 [GitHub extension](units/github-extension/packet.md) 的首轮实现和真实账号 acceptance 已随 PR #80 合并;durable owner 与 core/Extension catalog 错误已由独立 correction 关闭,但 batch graph interface、PyGithub integration、 -Extension-local Unit TDD 与 re-acceptance 尚未落地。该 unit 当前是下一轮 selection 的优先候选,不视为完成。 +Extension-local Unit TDD 与 re-acceptance 尚未落地。它保留为候选,不代表已选为下一 unit,也不视为完成。 [Graph navigation retrieval](units/graph-navigation-retrieval/packet.md) 已完成 core-py PR #78、client-web PR #85、 `@inkcre/ui-web@1.4.0`、preview/production acceptance 与 durable closure。 diff --git a/tasks/knowledge-lifecycle-capabilities/units/cli-sink/acceptance.md b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/acceptance.md new file mode 100644 index 0000000..a1d459e --- /dev/null +++ b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/acceptance.md @@ -0,0 +1,145 @@ +# CLI sink 验收设计 + +状态:2026-09-13 按 [D-601](../../decisions/D601-D610.md) 确认。接口基线已确认至 D-600;本页不表示技术预演、实施或验收已经完成, +也不授权 commit、push、发布或操作真实用户数据。当前阶段只由 [unit packet](packet.md) 维护。 + +## 要证明什么 + +验收对象是独立安装后的 inkcre-cli:调用者能从公开 help/schema 找到能力,经普通 Core REST 完成读取与管理, +并准确理解返回值、部分成果和后台执行。既有 retrieval/organization 的算法质量不是本轮重新优化的目标; +但接口不能丢失它们的有效内容或把错误执行描述成成功。 + +主路径是实际 CLI 进程 → 真实 HTTP → Core → 真实持久化/执行端。准备数据、对照外部内容和核查实际效果的 +控制脚本可以使用已有设施;不能用 Manager 直调用替代某一步正在验收的 CLI 操作。脚本不安装成 CLI runtime, +不进入普通 CI 自动执行,不新增 test-only endpoint、Resolver、Source 或生产条件分支。 + +静态检查负责依赖、类型、schema、生成 OpenAPI、迁移完整性和构建。手工/脚本负责真实交互与状态效果。 +不按 endpoint 数量建立自动化测试矩阵,不重复验证成熟库,不扩展泛化负路径或安全审计。 + +## 环境和材料 + +从 CLI wheel 在独立 Python 环境安装,工作目录移出 Core checkout;不给 CLI 配置数据库连接或安装 Core。 +CLI 只配置 REST 地址与已确认的 JWT secret。离线 help、本机 connection 管理不依赖远端上线;正式运行使用 +实际构建版本和明确的 preview/development runtime,不以 import 成功代替可用性。 + +连接旅程包含命名连接选择、connection check、peer get self/list,以及对已知 HTTP 入口的有界 wake。 +分别记录已经 ready 的正常返回和一次平台实际冷启动;冷启动证据使用已配置的可休眠部署及平台日志, +不从过期 lease 猜测休眠,不为测试关闭用户机器或构建新的唤醒协议。具体可用实例在 preflight 核验。 + +主要读写旅程优先在隔离 preview 上执行;双进程执行、受控 Job 停止和真实 IMAP 服务可用本机声明的开发 +设施。开发数据库遵循 AGENTS.local.md 的 SVC/远程 Docker 入口,不假定本机 Docker 或 Dovecot 可用。 +预演必须确认具体拓扑和网络可达性,不能把 loopback 的测试服务直接写成云端 Core 可访问的地址。 + +材料选择以真实信息为主:已有 SQLite architecture 原文、Python asyncio 文档、Anthropic 的 Agent tools +文章,及既有 NASA 多模态材料。保留实际 URL/采集版本;不为命中某个词而编造全文或直接插入 lexical/embedding +records。图引用从真实写入回执取得,验收用的本地别名不进入产品模型、数据匹配或排序逻辑。 + +Collection 选择已有 Extension 的真实协议路径。优先复用 Mail 的真实 IMAP/Dovecot 验收设施,使用已有邮件 +材料覆盖 ordinary/backfill 与 MIME 内容;这些是明确的验收邮件,不冒充用户真实通信。RSS 的公开 feed 可 +补充不需账号的动态发现与采集路径,但不以 RSS 的重复快照代替 Mail backfill。无需为本 CLI 建新外部协议库。 + +既有 PostgreSQL binary / HTTP Storage 和媒体 Block 可由验收准备步骤建立,随后只通过 CLI 读取。准备所需 +AI Provider、模型与 storage 不属于新增 CLI CRUD;不能因为准备材料方便而扩大产品范围。 + +## Journey 1 — 从线索取得可用信息 + +给当前 Agent 一个真实目标,例如“为后台任务取消接口整理参考依据”,仅通过安装包用法、help、动态 schema +和 CLI 实际输出完成信息取用。不规定工具调用顺序、不植入目标 Block ID、不规定最终摘要措辞或最少调用数。 +当前 session 已参与设计,因此记录为实际使用轨迹,不声称是独立、无先验的盲测,也不另开 session 通信。 + +覆盖 lexical/semantic 独立及组合结果、实体记录、图邻域/路径/连通分量、Resolver 方法发现与调用。允许为 +覆盖某个已批准命令另做小的脚本补充,不强迫 Agent 为一次问题执行所有命令。检查它是否能使用引用继续 +获取证据,是否因为名称、schema、输出遗漏而猜测参数或不得不读源码;发现缺陷时修接口,不只加提示词。 + +同一 storage-backed Block 对照 raw pointer、hydrated bytes、Resolver solved result。使用既有 +get_raw_content 的纯 bytes 返回与 ImageSolvedContent 的嵌套 bytes,验证原生二进制及 multipart 传输后 +文件可读取、内容与原材料一致。超长正文与 graph 输出应可继续读取完整文件;JSON 保持紧凑完整,导出目录 +整体移动后,入口 JSON 生成的相对文件引用仍可使用。不新增读取会话,也不重复调用 Resolver 取得后一段。 + +## Journey 2 — 配置来源并真正收集 + +通过 CLI 安装一个实际发布的 Extension、读取/修改配置、启用到指定 Peer,发现其 Source type/schema, +创建和编辑 Source。使用 ordinary collect 与有明确范围的 backfill 创建 Jobs,观察完成后经 CLI 获取收集 +所得 graph 和 Resolver 内容。检查创建 Source 本身没有偷偷启动 collection。 + +再创建 collection Cron,修改模板、启用,观察一次真实到期派发;disable 后也能显式 cron run,并能观察 +那次独立 Job。使用与运行窗口匹配的短 schedule,只等待一个自然 occurrence,不设置测试专用调度时钟或 +把 run_now 当作 scheduler 的证明。具体时区和窗口在 preflight 固定。 + +使用两个 core-py 进程共享同一数据库:REST 接入 A,目标 Extension 在 B 启用。A 从未加载该 Extension 的 +Source class,仍能根据持久目录保存 Source 和受理 Job;B 执行。不能通过“在 A 中先加载再 disable”假装 +移除了其单调注册的类型。CLI 不查执行端数据库、不使用 Peer inbound;指定 Extension 启用目标的普通 REST +委托路径在这里一并覆盖。 + +结束时通过 CLI 删除本次 Cron、Source,禁用并卸载本次安装的 Extension;验证已经收集的信息仍能以基础 +记录读取。不要求已卸载的实现继续提供 Resolver 能力,也不把暂存类型的进程行为当作永久可用性保证。 +若复用已有 Extension,不卸载别人的安装,改用隔离部署完成完整生命周期。 + +## Journey 3 — 配置整理并保留执行控制 + +经 ai models、agent tools 与 config schemas 找到实际合同,创建/修改一份 Agent definition,通过 deployment +config 选择它。对测试材料发起 organization ruminate,得到 Job 后用限时 wait 观察,再继续同一 Job。 +至少完成一次实际 Agent 调用及其可观察执行结果;图无变化可能是正常结果,不规定 LLM 必须写某种关系。 +如果有写入,核查其确由本次输入/配置产生,不以 Job finished 宣称语义质量已全部通过。 + +为 Organization 配置 Cron 并从其模板派发,确认参数能被实际 owner 消费。Cron 的自然调度已在 Journey 2 +验证,这里无需为每个 behavior 重跑一个调度矩阵。definition/config 的更新与清理均通过 CLI;共享 behavior +config 若预先存在则恢复原值,不把清理写成删除整个 deployment 配置。 + +Job 控制分别观察 pending 停止、running 请求与终态、重复停止,以及观察预算结束后 Job 继续运行。用真实 +可合作取消的工作取得 running 窗口;请求受理不是终止证据,必须看到执行端结束工作后的结果。若工作先 +自然完成,记录实际结果,不能声称该次证明了 running 取消。停止不回滚已产生的 graph,不等待外部副作用 +被撤回;不增加新的回滚、重领或 retry。接入 A/执行 B 的链路必须覆盖一次真实停止。 + +client-web 的 worker 与 AbortSignal 配套修正单独用真实浏览器、真实数据库和公开注册入口做有限脚本验证。 +当前仓库只发现通用 Source handlers,未发现实际 SourceImplementation 注册,不能声称现成 Mail/Twitter +collect 可用于浏览器验收。若仍没有可用业务执行体,可在外部验收脚本通过公开 Job handler 注册接口提供 +最小的可取消异步执行体,只检查实际 worker 收到数据库停止意图并传递 AbortSignal;不修改产品代码,不把 +这个组件边界证据冒充完整浏览器 collection journey,不为验收新增产品 Source。 + +## Journey 4 — 修改信息并进行可靠的脚本组合 + +用 GraphForm 通过 CLI 创建一组互相关联的 Blocks/Relations;记录实际 ID,随后原地编辑 Block 和 Relation、 +重新读取、逐项删除。只提交要改变的字段;尤其验证修改 Block.content 没有把省略的 storage 清空。这里的 +图用于验证 producer/record 合同,不要求 retrieval 返回某条偶然选中的同长最短路径。 + +清单使用小 limit 跨页读取,覆盖持久清单、动态目录与已有图邻域。JSON 自带下一页位置;默认可读也不隐藏 +它。再读取一个含长字段的对象,确认分页没有代替正文完整交付。schema 的精确选择不能因为目录分页而找不到 +实际存在的方法;可信 REST 返回不被 CLI 自己的模型丢字段或再次拒绝。 + +在一个显式 ID 批次中包含实际存在的对象与确定不存在的引用,检查逐项成功/错误同时保留、stdout 可消费, +整体退出码反映部分失败。另用一个可定位的输入错误检查诊断指向用户提交的 JSON 位置。这些是已确认公共 +合同的最小压力,不扩为所有输入或所有 endpoint 的负路径矩阵。CLI 中断 wait 不替调用者发送 abort。 + +同类校验边界修正用写入/读回/实际使用闭环检查嵌套 Mail config、Agent/AI 类型与动态配置。代码评审逐项 +确认验证仍留在输入边界、类型转换未丢失,不为证明“validator 没运行”加入 mock 计数或旁路数据库坏数据。 + +## 静态与交付完成条件 + +Core 使用既有 pdm run check、生成合同和 migration 检查;CLI 使用独立 PDM project 的 lock、lint、typecheck、 +wheel/sdist 构建,client-web 使用其声明的相关检查。仅变更适用的 gate,不复制 helper/schema 自动化测试。 +release 准备在隔离副本中演练,确认 CLI 独立 version/changelog,Core/Extension 未被无关提升。 + +候选版本的 preview 必须正常初始化数据库并可用。CLI 的真实 REST 旅程不是只对 mock 或本机 ASGI client +通过;已有 MCP/Peer inbound 选择实际 consumer 做小范围回归,不重新验收 ChatGPT 全链路或 UI 全部功能。 + +按 D-599,首次交付先使 Core 新 REST 在生产可用,再发布 CLI 0.1.0;PyPI 首发成功后,在另一个干净环境 +通过 pip 安装该正式版本,重复连接、信息读取和一个有真实结果的管理/Job 操作。精确版本、source SHA、 +发布 run 和实际环境进入证据。正式包尚未可安装或 Core deployment 失败时,不关闭本 unit。 + +验收材料放在本 unit 的 acceptance/ 下,脚本按需产生,不预建测试框架;大型运行产物放 ignored 目录。 +保留请求/响应、stdout/stderr、退出码、必要执行日志、实际图引用和清理结果。只清理本次创建并核验过的 +资源;不 reset 共享数据库,不批量删除已有业务图。操作预览/发布仍需相应授权,设计确认不替代它。 + +## 实施前准备的状态 + +2026-09-14 上述准备已完成,证据见 [preflight](preflight.md),下一步等待实施授权。原先重点是:共享 Extension runtime 的真实源码 owner +与发布顺序;两端 Job 取消和子任务清理;嵌套持久表示的无重复校验转换;独立 CLI 依赖与 multipart 实际链路; +真实 Extension 版本、IMAP/双 Peer 环境、AI 配置、HTTP 冷启动与 PyPI publisher 权限。不能把这些留到实施 +时才第一次调查,或在未具备环境时将旅程标记通过。已有单次 bytes 实验只作为局部依据,不替代本页验收。 + +材料依据:[SQLite architecture](https://www.sqlite.org/arch.html)、 +[Python asyncio tasks](https://docs.python.org/3.12/library/asyncio-task.html)、 +[Anthropic Agent tools](https://www.anthropic.com/engineering/writing-tools-for-agents)。前者已有本仓库原文副本, +其它材料于本轮核对公开页面;实际采集版本仍随运行记录。行为边界依据组织 TESTING.md 与本 task decisions, +不能因为其它 unit 留有 automated acceptance 就推定可以增加本轮自动化。 diff --git a/tasks/knowledge-lifecycle-capabilities/units/cli-sink/agent-management-rest.md b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/agent-management-rest.md new file mode 100644 index 0000000..832418f --- /dev/null +++ b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/agent-management-rest.md @@ -0,0 +1,77 @@ +# Agent definition 管理与引用发现 + +状态:按 [D-589](../../decisions/D581-D590.md) 确认,尚未实施。范围来自 D-575;本页不新增 Agent 对话、Thread 管理、AI Provider +写入或本机执行能力。Deployment config 的配置发现与管理随后单独收敛。 + +## 接口与 owner + +| REST | CLI 对应意图 | 返回 | +| --- | --- | --- | +| GET /agents | agent list | 持久 Agent definitions;列表沿 D-597 的共同合同 | +| GET /agents/{id} | agent get | 一条完整 definition | +| POST /agents | agent create | HTTP 201 与创建的 definition | +| PATCH /agents/{id} | agent update | HTTP 200 与更新后的 definition | +| DELETE /agents/{id} | agent delete | HTTP 204;不存在为 404 | +| GET /ai/models | ai models | deployment 的 AIModel 记录,不仅列出本机可执行模型 | +| GET /ai/models/{id} | ai models 的精确选择 | 指定 AIModel 记录 | +| GET /agent-tools | agent tools | 当前接入 Peer 已注册 Tool 的 id 与 description | +| GET /agent-tools/{id} | agent tools 的精确选择 / schema 查询 | id、description、input_schema | + +AgentManager 补 definition 管理与 Tool 发现;AI 模型读取由 AIManager 提供。REST 只解析输入、调用 owner +并映射响应。CLI 的模型发现归 ai 命令组,Tool 发现归 agent 命令组;不因 Agent 引用模型而混合两个领域。 +新增 ai 命令组本轮只承接已批准的模型发现,不据此扩展为 Provider / Model 写入管理。 +不创建新的 CRUD service、数据库表、Tool catalog 或额外 AgentToolRegistry。 + +Tool 列表只读取注册信息;单项 schema 使用现有 registration.bind 的动态 input model factory,不执行 handler。 +局部 Tool 清单不是全 deployment 清单,未列出某 Tool 不证明其它 Peer 没有该实现。具体 CLI 单项选择语法 +沿用命令解析阶段统一决定;已知 ID 可直接用于 definition,不强制先发现。 + +## 表单与编辑 + +创建表单使用独立 Pydantic AgentForm,不直接把 SQLModel table 当写入表单。可写字段沿用现有 definition: + +```json +{ + "name": "我的整理 Agent", + "system_prompt": "……", + "model": 7, + "tools": [], + "tool_choice": null, + "max_model_calls_per_turn": 12 +} +``` + +tools 默认空数组,tool_choice 默认 null;其余字段必填。例中的 7 和 12 只是示例,不是 preset 或新增默认值。 +id、created_at、updated_at 由数据库管理;读取与写入成功响应保留这些实际记录字段,没有成功 envelope。 +精确 Tool IDs 继续使用现有集合语义、规范化与非空/不重复约束。nullable ToolChoice 复用现有 union,不发明 +CLI 自己的 tool-choice DSL。 + +PATCH 使用与 Source / Cron 一致的顶层替换:省略保持,tools 整组替换,tool_choice 可显式 null。完整候选值 +经表单校验后只更新可写字段;不把输入缺省值覆盖到未提交字段。持久化方法拥有短事务,沿用已有数据库时间戳。 + +保存 definition 不是执行资格检查。不调用 can_execute、绑定全部 Tool、探测 Provider 或启动一个试运行 Thread。 +model 的存在性沿用数据库 FK;本机缺少 Tool / dialect、模型暂时禁用,不阻止保存一个给其它执行端使用或 +尚在配置中的 definition。模型能力、Tool choice 和实际输入能否一起执行,仍由现有 Agent / AI 执行边界判断。 +API 不因此宣称任何保存成功的 definition 都一定可运行,也不另造一份跨 Peer 执行认证机制。 + +模型发现返回 id、provider、native_model_id、name、capabilities、enabled 与时间戳。capabilities 保留既有 +chat/embedding、modalities 与 features;不折叠成一枚由当前 Peer 推断的 available 标记,不附带 Provider config。 + +## 实现预演使用的既有约束 + +Thread 快照、行为配置引用与删除后缺失引用的处理沿用现有 Agent / Organization 合同,依据见下节。 +这些不是本轮新增的 REST 决策;预演需确认接口没有暗中引入执行、取消、配置改绑等副作用,不反复要求 Sir +确认已有业务规则。AgentForm 不含 behavior/config_key;行为配置仍通过 config 管理。 + +## 已核验证据与预演落点 + +- app/schemas/agent.py:agents 已持久 name、system_prompt、model、tools、nullable tool_choice 和正的 + max_model_calls_per_turn;model FK 指向 ai_models,tools 为 text array,不引用持久 Tool table。 +- app/business/agent/main.py:AgentManager 已拥有 Tool 注册与 bind;run 加载 definition 后把 prompt 变成 + SystemMessage,并把参数和 bound tools 放入 Thread。当前没有 definition CRUD 或公开 Tool 发现方法。 +- app/schemas/ai/main.py、capability.py、chat.py:AIModel、capabilities 与 ToolChoice 已有 canonical 合同。 +- app/business/organization/_shared.py:行为配置使用时加载 Agent;缺失引用不由配置模块自动修复。 + +实现前继续核对 table validator 与新 form 的复用位置,避免复制规范化逻辑;动态 Tool schema factory 的失败 +不能阻断基础 Tool 列表或 Agent definition 读取。列表与错误原则沿 D-597 的[共同合同](list-error-contract.md), +长输出覆盖、动态 schema 展示与实际映射仍需预演。以上仅是设计与源码核验,不是实现或黑盒验收结果。 diff --git a/tasks/knowledge-lifecycle-capabilities/units/cli-sink/command-surface.md b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/command-surface.md new file mode 100644 index 0000000..131de29 --- /dev/null +++ b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/command-surface.md @@ -0,0 +1,140 @@ +# CLI 命令结构 + +状态:分组及职责边界按 [D-575](../../decisions/D571-D580.md) 获批,[输入与发现](input-discovery.md) 已获 D-577 +确认;不是已实现的命令目录。输出原则、Job 控制与本机接入已获 D-578–D-582 确认,精确 REST DTO 仍在设计。 +示例暂以 distribution 同名的 `inkcre-cli` 为可执行名。 + +## 按意图与操作对象组织 + +高频的信息取用动作直接放在根级;需要围绕一个对象持续管理的操作放在领域组内。区分这些入口的依据是 +调用者要取得什么结果、修改哪个对象,而不是 REST 路径、数据库表或追求所有命令具有相同深度。 + +| 命令入口 | 职责与拟议子命令 | +| --- | --- | +| `recall` | 显式选择词法、语义模式找候选;可以组合模式,不在 CLI 隐藏选择策略 | +| `get` | 按引用批量取得 Block / Relation 记录,允许混合实体;可选不含 content、原始 content 或 hydrated content | +| `update` / `delete` | 原地编辑或删除一个明确的 Block / Relation;沿用 get 的实体引用形式,准确表单已按 D-596 确认 | +| `graph` | `neighborhood`、`path`、`components` 提供图查询;`submit` 提交 GraphForm | +| `resolver` | `list`、`methods`、`invoke` 发现与调用普通 Resolver 读取能力,包括 `get_solved_content`;不为每种 Resolver 增加 CLI 子命令 | +| `source` | `types`、`list`、`get`、`create`、`update`、`delete`、`collect`、`backfill` | +| `organization` | `ruminate` 为指定 Block 创建相应 Job;Block 是输入,行为与 handler 仍属于 Organization;行为配置通过 `config` 管理 | +| `agent` | `list`、`get`、`create`、`update`、`delete` 管理 Agent definition;`tools` 发现 Agent Tools;不增加对话、Thread 或本机 Agent runtime | +| `ai` | `models` 发现与读取 AIModel;模型被 Agent 引用,不改变其 AI 领域归属;本轮不扩展 Provider / Model 写入 | +| `job` | `types`、`create`、`list`、`get`、`wait`,并纳入主动停止(拟名 `abort`);限时等待与停止的准确合同见下文 | +| `cron` | `list`、`get`、`create`、`update`、`delete`、`enable`、`disable`、`run`;配置服务端定期执行,或从现有模板立即派发一次 Job | +| `peer` | `list`、`get`、`wake`;报告已知状态与等待 HTTP 服务就绪 | +| `extension` | `list`、`get`、`install`、`uninstall`、`enable`、`disable` 及其配置读取/更新 | +| `connection` | 本机连接配置;`list/get/set/delete/use/check` 已按 D-582 确认,见 [连接与接入](connection-access.md) | +| `config` | 远端 deployment 配置;list/get/replace/update/delete/schemas 已按 D-590 确认,包含 Organization behavior config | + +`get block:42 relation:8` 中的字符串是 shell 下明确实体种类的输入表示,不创建新的领域 ID,也不要求 Core +REST 或 MCP 改成同样的 JSON 形状。`get` 不解释内容:raw 是 `BlockModel.content`,可能是 Storage pointer; +hydrated 通过 `BlockModel.get_hydrated_content()` 取得实际内容。Relation 没有 Storage,不能为统一模式虚构 +它的 hydration 过程;混合批次下其原始 content 是否省略随选项合同明确。图的邻接结构由 `graph` 查询。 +内容字段与基础读取见 [实体与 Resolver REST](entity-resolver-rest.md);[内容 HTTP 表示](content-transport.md) +与 [文件输出](file-output.md) 已按 D-584–D-586 确认,不能从读取方法名称推断内容总是文本。 + +取消独立 `read` 命令。`get_solved_content`、`get_text` 等均是 Resolver 的普通方法,归入 `resolver invoke`, +CLI 不再增加一份与它们并列的内容解释合同。已知方法可直接调用,不必每次先发现。 + +根级 `get`、`update`、`delete` 的 help 必须明确它们操作的是 info-base 实体;Source 等管理对象通过自己的命令组管理。暂不增加 +与这些命令功能相同的另一套别名。`connection` 与 `config` 分别修改本机连接与远端 deployment 配置; +D-582 已确认轻量命名连接,不引入 profile 继承或凭据服务。 + +## 命令不是另一套业务实现 + +`source collect` / `backfill` 向 Core 请求创建已有类型的 Job;回执与 `job get` / `wait` 操作同一个 Job。 +`organization ruminate` 同样创建 Job,参数为指定 Block;当前缺少对应的显式 Job type,按 D-595 在 Organization +内补齐。CLI 不变成 worker,也不直接调用 Peer inbound 或执行门面。这里统一的是收集/整理的任务受理与控制, +不是让检索、配置保存或原地实体编辑也通过 Job 执行。 + +自动 Organization 从 `job types` 发现准确类型和参数,交给 `job create` 执行一次,或将同一 Job 模板交给 +`cron create` 周期运行。已有自动行为自行选择候选,不能在 CLI 为其强加每次固定的 Block,也不维护另一份 +Organization 名称到 Job type 的映射。`cron run` 沿用 `CronManager.run_now`,创建一次 Job 而不推进定期进度。 + +Agent definition 保存 system prompt、model、tools、tool_choice 与每 turn 的调用预算;Organization behavior +config 选择它要使用的 Agent,不复制这些字段。前者由 `agent` 管理,后者由 `config` 管理;修改 definition +不隐式改绑行为,也不改变已经开始的 Thread 快照。模型和 Tool 引用需要配套发现,技术设计核对实际接口, +不因此自动纳入 AI Provider 写入或完整 Agent 会话管理。 + +Extension 安装记录、Peer 启用意图与实际运行状态仍分别表达;CLI 不拥有 Extension 的发布或运行生命周期。 +Source type、Resolver method、Job type 的动态合同来自 Core 的既有 owner。CLI 包不为每个 Extension 附带 +适配代码,也不要求 Extension 额外注册 CLI commands。 + +`connection` 选中的 HTTP 服务与业务执行目标 Peer 不是同一个参数语义:前者是 CLI 接入点;后者只在该业务 +支持指定 Peer 时成立。不能从连接配置推导所有 Job 都由该 Peer 执行。准确参数随 Technical 核对,不发明一项 +对所有命令生效的全局执行 Peer 设置。 + +## 限时观察与执行控制 + +`job wait` 允许调用者指定本次最多等待多久;推荐终态提前返回,否则在等待预算用尽时返回最近实际观察到的 +Job 状态。比如拟议 `job wait 301 --for 30s`,不代表把 Job 执行期限改为 30 秒。准确选项和默认时长待复核。 +等待额度用尽不是 Job 的 `timed_out`,也不自动取消执行;调用者可使用同一 Job ID 继续等待、查询或主动停止。 +观察失败仍需报告真实故障,不能冒充拿到了最新状态。进度只呈现 handler 实际提供的 state,不伪造通用百分比。 + +Core 当前的 `JobStatus.ABORTED` 只是已有状态枚举;`JobManager.run` / `check` 没有主动取消链路。D-580 已确认 +以 JobManager 集中检查停止请求的 best-effort 方案补齐,见 [Job 控制](job-control.md)。主动停止 +因此是本轮需要补齐的实际执行能力(D-576 已明确确认),而不是修改一列即可完成的 REST 包装。技术设计需覆盖 Job 可能在其它 +Peer 执行的事实、停止请求与执行结束的区别,不在这里承诺杀死进程、远端服务请求或回滚已完成的效果。 + +## 以实际过程检查可组合性 + +以下是拟议语法,不是必须遵循的操作 SOP;给定准确 ID 时可以直接读取,不必先搜索或发现。 + +```sh +inkcre-cli recall 'Agent 工具' --mode lexical +inkcre-cli get block:42 relation:8 +inkcre-cli resolver invoke block:42 --method get_solved_content +inkcre-cli graph neighborhood block:42 +``` + +调用者按信息缺口选择步骤。读取内容不自动展开所有相邻实体;邻域查询也不自动读取整幅子图的 solved content。 + +```sh +inkcre-cli source types +inkcre-cli source create --type --input source.json +inkcre-cli source collect 12 +inkcre-cli job get 301 +inkcre-cli job wait 301 --for 30s +``` + +这里的 ID 是示意。`source.json` 的结构必须能通过 CLI 从当前 Core 取得;不能要求调用者翻源代码。Collect +创建回执提供真实 Job 引用,`wait` 有界地观察那次执行,而不是重发 collection。JSON 文件/stdin 是嵌套输入的建议 +承载形式;`--input` / `--input-json` 已按 D-577 确认。 + +```sh +inkcre-cli job types +inkcre-cli cron create --job-type --input cron.json +inkcre-cli cron run 9 +``` + +最终持久的 Cron 模板保存 Job type、其参数、schedule 和超时;拟议 argv/JSON 分工见 [输入草案](input-discovery.md)。 +既可配置收集,也可配置自动整理。手动立即执行与启用未来 schedule 是不同动作,不互相隐含。Source 无需知道自己是否被 Cron 调用。 + +## 后续复核面 + +结构按 D-575 收敛;[动态输入](input-discovery.md)、[结果呈现](output-presentation.md)、[内容交付](content-delivery.md)、 +[Job 控制](job-control.md) 和 [连接接入](connection-access.md) 已获 D-577–D-582 确认。 +基础读取、内容交付及 [Job REST](job-rest.md) 已按 D-583–D-587 确认,[Source / Cron REST](source-cron-rest.md) +已按 D-588 确认,[Agent definition 管理与引用发现](agent-management-rest.md) 已按 D-589 确认,[deployment config](deployment-config-rest.md) +已按 D-590 确认;继续 Peer / Extension 等领域 REST 和分发。类似 `gh api` 的长尾 HTTP +入口暂未列入本方案;没有具体缺口时不让它代替领域命令, +也不因此将未来增加这个普通工具变成禁止事项。 + +[Peer REST 与 HTTP 唤醒](peer-rest.md) 已按 D-592 确认:记录读取、数据库时间 lease_active,以及以已配置 +connection 为目标的直接 /readyz 唤醒。self 为内置别名;不自动支持按任意 Peer ID 发现 REST 地址。 + +[Extension REST](extension-rest.md) 已按 D-593 确认:保留安装、卸载和配置管理的现有 Host 入口,补齐配置 +PATCH,只为 enable/disable 增加目标 Peer 参数;不代表命令及协议已经实现。 + +[检索与 Graph REST](retrieval-graph-rest.md) 已按 D-594 确认多模式 Recall 的独立结果、图查询路径与追加 Graph 的 +POST method correction。语义 profile 选择配套只读 embedding-profile list/get,不引入新的配置写入范围。 + +[收集/整理 Job 与实体修改](organization-write-rest.md) 已按 D-595/D-596 确认;收集、整理均提交相应 Job, +新增显式 rumination Job type,Block/Relation 的原地编辑与删除使用 update/delete。共同的 +[管理列表与错误输出](list-error-contract.md) 已按 D-597 确认;长输出分页和独立批次的部分失败为通用模式, +不改变上述已确认的任务受理语义。 + +现状依据:`app/business/graph_navigation_retrieval/main.py`、`info_base/resolver/main.py`、`info_base/main.py`, +`app/routes/source.py`、`organization.py`、`extension.py`,以及 `app/business/job.py`、`cron.py`。 +这些依据证明已有行为和责任,不证明本表全部 REST 路由已经存在;缺失的普通 REST 仍按 D-572 在 Technical 中设计。 diff --git a/tasks/knowledge-lifecycle-capabilities/units/cli-sink/connection-access.md b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/connection-access.md new file mode 100644 index 0000000..0a069e9 --- /dev/null +++ b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/connection-access.md @@ -0,0 +1,119 @@ +# 本机连接与 Core REST 接入 + +状态:[D-581/D-582](../../decisions/D581-D590.md) 已确认 JWT 自签、命名连接与选择规则。默认目录按 Sir 建议 +位于 `~/.inkcre`,CLI 的具体文件落点为 `~/.inkcre/cli/connections.json`。D-575 的本机/远端配置分离保持不变。 + +## 本机连接只说明去哪里、用什么凭据 + +CLI 本地连接的最小数据为 `base_url` 与 `jwt_secret`。它们由 CLI 自己的 Pydantic model 承载,不写入远端 +`configs`、`peers` 或 `sinks`。`connection` 管理这份本机数据;`config` 继续操作远端 deployment 配置。 + +```json +{"base_url":"https://inkcre.example","jwt_secret":""} +``` + +这里的地址是 Core REST 入口,不是 PostgREST,也不是 MCP endpoint。CLI 不根据它发现数据库连接后直连, +也不因连接成功就注册为 Peer。配置可以持久化在本机,不强制引入 keychain 或凭据代理。命名连接与本机 +配置机制已按 D-582 获批,不意味着源码已实现。 + +## 命名连接、持久默认与单次选择 + +一个用户配置文件保存少量命名连接和可选默认名。连接名是本机别名,不是远端 deployment ID、Peer ID +或另一个认证主体。每条连接保存完整的 `base_url` 与 `jwt_secret`;不设置父连接或继承关系。 + +这一层的收益是让日常 personal、开发/preview 等目标可以明确选择。Agent 不必先修改全局默认值,再执行 +业务命令;两个进程选择不同连接时也不需要互相切换默认值。单个命令启动后固定使用解析出的连接,不在 +后续 wait 查询或请求中跟随另一个进程对默认连接的修改。 + +```sh +inkcre-cli connection set personal --input personal.json +inkcre-cli connection set preview --input preview.json +inkcre-cli connection use personal +inkcre-cli --connection preview source list +``` + +输入文件仍只含该连接的两个字段,name 已经位于 argv。`set` 创建或完整替换同名配置,不递归合并;沿用 +`--input`、`--input-json` 和本机可用的 `--schema`。其余配套命令如下: + +| 命令 | 效果 | +| --- | --- | +| `connection list` | 离线列出名称、地址与保存的默认选择,不发网络请求 | +| `connection get ` | 读取该名称保存的本地配置,不推断远端是否可用 | +| `connection set ` | 写入完整连接配置,不把连通性检查作为保存前提 | +| `connection use ` | 只修改本机持久默认连接 | +| `connection delete ` | 只删除本机记录,不停服务、不删除远端 Peer 或数据 | +| `connection check` | 对本次实际选中的 Core 发出轻量检查,分别报告地址可达/就绪与认证读取的真实结果 | + +`check` 不是合法业务调用的前置条件,也不触发 collection/organization。只访问公共 `/readyz` 不能证明 +JWT 已通过;应复用一个轻量的受保护读取,准确 route 随 REST 设计落实。检查输出应包含实际使用的 endpoint, +避免仅报告某个别名“成功”。远端休眠时 HTTP 本身可能唤醒它,沿用 D-574,不增加平台专用唤醒逻辑。 + +默认选择的低风险行为可直接推导:首次创建连接时将其作为默认,之后新增不改变已有默认;显式 `use` 才 +切换。删除默认记录时一并清除默认引用,不随机选另一部署。没有选择或名称未知时给出已有名称和缺失信息, +不弹出必须交互的菜单,也不把失败连接自动替换为另一个连接。 + +## 保存位置与本次覆盖 + +默认使用 `~/.inkcre/cli/connections.json`。Sir 选择 `~/.inkcre` 作为根目录;`cli/` 是本单元的具体落点, +明确连接文件由独立 CLI 拥有,不替未来完整 Peer 预设配置合同。不跟随工作目录查找项目配置、向上搜索或 +自动加载 core-py 的 `.env`。实现可直接使用 `Path.home() / ".inkcre" / "cli" / "connections.json"`, +不需要平台目录依赖或自行实现各平台路径规则。JSON 与 Pydantic 保持不变。 + +只保留两条独立而直接的选择规则: + +| 选择对象 | 从高到低的优先级 | +| --- | --- | +| 使用哪份完整连接文件 | `--connections-file` → `INKCRE_CLI_CONNECTIONS_FILE` → `~/.inkcre/cli/connections.json` | +| 使用其中哪条连接 | `--connection` → `INKCRE_CLI_CONNECTION` → 文件中保存的默认名 | + +显式参数优先于环境变量,不因两者同时存在而报错。指定文件是替换本次使用的文件,不与默认文件合并; +命名连接是完整参数组合,当前不增加独立 `--base-url`/`--jwt-secret` 或逐字段环境覆盖。需要临时 endpoint +或凭据时,可以在隔离文件中提供完整组合;这里不添加 env interpolation、凭据引用或 profile registry。 + +除了 `set/use/delete`,业务命令及单次 `--connection` 选择不写回本机配置。显式连接文件也让脚本能够使用 +自己生成的临时配置,不修改日常配置。保存文件的原子替换等实现细节由技术设计处理,不扩大成配置数据库。 + +## 参考与明确的取舍 + +GitHub CLI 分别提供主机/凭据环境参数、可覆盖的配置目录与持久账户切换,见 +[环境变量](https://cli.github.com/manual/gh_help_environment) 和 [auth switch](https://cli.github.com/manual/gh_auth_switch)。 +参考的是持久选择与一次进程上下文分离;不复制其账户层、交互式选择或 Git 目录推断。InKCre 的 Block/Job ID +本身不能指示目标 deployment,因此显式连接名有实际使用价值。 + +本地 `xiaoland/svc` 的 `svc_cli/src/svc_cli/config.py` 已采用 JSON/Pydantic,但其完整项目配置加 sparse +local overlay 解决的是项目/机器配置组合,不直接搬到这个只有连接参数的 CLI。原提案曾考虑操作系统原生 +用户配置目录;D-582 已改为 Human 选择的 home 下 `.inkcre`,因此不再需要为目录定位选择 platformdirs。 + +## 使用既有 JWT 合同 + +CLI 在发出受保护的 Core REST 请求时,使用配置的 JWT secret 签发有期限的 token,通过 +`Authorization: Bearer ` 发送。签发属于 CLI 的普通 HTTP 请求层,不分散到每个命令;限时 wait 的 +后续查询也由同一请求层签发,不要求 Agent 手动续签。无需 token cache、登录会话或 refresh-token 协议。 + +现有合同的 authority 是 `app/database_contract/constants.py`、`app/middleware.py` 与 +`docs/40-deployment/database-contract.md`:HS256,`role=authenticated`、`iss=inkcre-peer`、 +`aud=inkcre-api`,具有合法 `iat`/`exp`,最大生命周期 24 小时。CLI 遵循同一合同,不另造 issuer/audience +或放宽验证。具体签发期限与时钟偏差处理在技术设计中对齐现有客户端实践,不能仅照抄 24 小时作为默认值。 + +Core router 可以继续使用现有 `require_peer_jwt`。该验证读取 JWT claims,不要求在 peers 表登记调用者。 +独立 CLI 使用成熟 JWT 库实现签发,不为调用一个签发 helper 依赖整个 core-py runtime;实际依赖版本在 +技术选型时核验。这里复用的是协议合同,不强求不恰当的跨项目源码依赖。 + +## 认证复用不合并运行角色 + +CLI 不作为 Peer 的含义是:它不访问数据库、不发布 Peer advertisement、不执行 Peer delegation/Job worker, +也不包含 Core runtime。它仍可使用 deployment owner 提供的同一签名凭据。这些职责边界不自动构成一种 +比 Peer 更低的凭据权限;尤其不能把“CLI 不访问 PostgREST”误写成该凭据无法通过 PostgREST 验证。 + +先前从“CLI 是独立的外部界面”推出“必须有独立 REST token”是不成立的推导。2026-09-13 Sir 明确否决该 +提案;不新增 `core.rest_api` 凭据配置、API token 表、签发服务或认证方式切换。已有 MCP PAT 仍由 MCP Sink +拥有,与此次 CLI 决定无关。 + +现有 semantic/lexical/rumination HTTP handler 还有真实 Peer inbound consumer,DTO/路由调整仍按 D-572 +核对;这与是否共用认证方式是两件事。不因 CLI 新增另一套验证依赖或复制 router/领域业务。 + +## 剩余技术落点 + +JWT 库、签发期限、HTTP 错误呈现以及配置读写细节仍需技术设计。 +本轮按 ponytail 保留一个 JSON 文件、Pydantic 与标准库目录定位,不建 profile 继承、合并框架或认证子系统。 +没有修改源码、durable docs 或实际凭据。 diff --git a/tasks/knowledge-lifecycle-capabilities/units/cli-sink/content-delivery.md b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/content-delivery.md new file mode 100644 index 0000000..02f7ca2 --- /dev/null +++ b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/content-delivery.md @@ -0,0 +1,90 @@ +# 大内容与多模态交付 + +状态:产品方案已获 [D-579](../../decisions/D571-D580.md) 确认,沿用 D-578 输出原则。这里只设计调用者如何取得完整结果,不冻结 HTTP +编码、文件引用 DTO、具体显示预算或命名算法。[HTTP 表示](content-transport.md) 的 JSON / 原生 bytes / +multipart 路线与字段关联已按 D-584/D-585 确认。[本地文件输出](file-output.md) 已按 D-586 确认,包含接受 +文件引用不是可逆类型编码的取舍;具体显示预算与完整链路预演仍待完成。 + +## 保持内容语义,改变交付载体 + +CLI 不要求所有 Resolver 最终都返回 text,也不替它们重新解析内容。Core 的实际结果可能是 str、bytes、 +嵌套 dict/list、Pydantic model 或 dataclass。CLI 的文件是这次读取结果的副本,不是新的 Block、Storage +或 Resolver 类型,不写回 info-base,也不制造永久可寻址的 Core result entity。 + +| 调用 | 交付的内容 | +| --- | --- | +| `get … --content raw` | 原始记录内容;若它是 Storage pointer,仍交付 pointer 字符串,不自动下载其指向的文件 | +| `get … --content hydrated` | 通过 Core 的 Block/Storage 取得实际 str/bytes,不添加 Resolver 的解释 | +| `resolver invoke … --method get_solved_content` | 交付 Resolver 返回的完整结构,包括可能存在的元数据与嵌套 bytes | +| `resolver invoke … --method get_text` | 交付该方法返回的文本或 null,不宣称其等于文件的全部原始信息 | + +get 的模式和默认 raw 已按 D-583 确认。文件交付不能把 hydration 偷换成 text extraction,也不能为了决定文件 +名额外调用 Resolver 读取方法。CLI 仍从 Core REST 取得内容,不根据 raw pointer 自行连接 Storage 或外部 Source。 + +## 可读输出有限呈现,完整内容可落为本地文件 + +短文本和小型 JSON 可以直接输出。默认可读模式下,长文本或过大的结构化结果保存到本次调用的本地输出 +目录,stdout 给出明确标为预览的有限内容和完整文件路径。文件保存的是本次取得的结果,不需要 Agent 再 +调用一次 Resolver 才能取回未显示部分,也不会因为终端预览而重新生成摘要、OCR 或转录。 + +二进制结果保存为文件,无论它位于根部还是嵌套在 solved content 中。原有对象/数组结构及非二进制字段保持 +可读,文件引用保留它所属的位置;不能只交付一个孤立的图片文件而丢失同次结果里的宽高等字段。D-586 +使用 file 对象,不把路径伪装成原始 content 字符串或 Storage pointer;接受它与普通同形对象无法仅凭 +最终 JSON 反向区分的呈现层残余,CLI 本身不将业务对象误当文件指令。 + +这种策略有一个明确的本地副作用:读取命令可能写临时文件。命令 help 与返回结果都应说明落点。默认 +只在确实需要文件时创建 OS 临时目录,CLI 退出时不立即删除,便于 Agent 后续使用文件工具。临时目录不是 +永久保存承诺;需要长期保留时由调用者指定输出目录。CLI 不增加缓存索引、TTL 服务、自动清理任务或新的 +`cache` 命令。 + +## 显式导出使用一个目录,容纳异构与批量结果 + +提供 `--output-dir `。显式使用时保存完整结果,不受终端显示预算影响;一次调用使用其中的 +独立子目录,返回实际文件路径。目录可以同时容纳结果 JSON、原始文本和多个二进制文件,调用者无需预先 +判断 Resolver 将返回一个文件还是一个带多个文件的对象。不让同一个路径参数按结果类型隐式变成文件或目录。 + +```sh +inkcre-cli resolver invoke block:42 --method get_text --output-dir ./evidence +inkcre-cli resolver invoke block:84 --method get_solved_content --output-dir ./evidence +inkcre-cli get block:84 --content hydrated --output-dir ./evidence +``` + +文本本身保存为文本,不为了包装成 JSON 而改写正文。对象/批次保存完整 JSON 结构,binary 成员通过文件引用 +连接。文件类型来自已有可靠内容元数据;没有时仍保存 bytes,不另建识别 ladder。文件与成员的关联不能只靠 +人类猜文件名,也不自动解开 ZIP、生成媒体摘要或下载返回结构之外的附件。 + +显式导出时 stdout 返回这次导出的路径与必要回执;`--json` 选择回执的结构化形式。这个回执属于显式文件 +交付操作,不推广成所有 CLI 命令的统一 envelope。确切文件引用与回执 DTO 见已确认的 [文件输出](file-output.md)。 + +## `--json` 不截断正文或改变根形状 + +未指定 `--output-dir` 的 `--json` 调用返回完整结构化结果:字符串不因默认终端显示预算而被截成另一段 +字符串,大对象也不突然整体变成一个文件路径对象。需要保存大量 JSON 时可以直接重定向 stdout,或显式 +使用上述目录导出。选择 JSON 不等于承诺结果体积有限;检索数量、是否请求 content 等仍由命令参数控制。 + +JSON 模式使用紧凑序列化,不做 pretty print;这一点也适用于 `--json` 下的导出回执。不为了缩进或排版 +增加输出体积,也不把紧凑格式误解为裁剪业务内容。 + +二进制在 JSON 结果中一律使用声明过的文件交付表示,不把 bytes 猜成 UTF-8,不默认塞入大段 Base64。 +这一表示不随文件大小改变;小 binary 也遵循相同合同。普通输出与 JSON 都指明文件位置与实际交付情况。 +读取/写文件失败仍需报告实际故障,不能返回不存在的“已保存”文件。 + +“完整”只指本次 Core 方法的返回值。它不证明上游信息完整、Storage 未被外部改写,或 Resolver 内部没有自己的 +能力边界。Domain 提供的截断或 unavailable 等信息应原样保留,CLI 不把文件导出宣传为消除这些边界。 + +## 现有证据与技术压力 + +`ImageSolvedContent`、`AudioSolvedContent`、`PDFSolvedContent` 继承 `ByteContentFacts`,同时含 `content: bytes` +与描述字段;`TextResolver` 可将 storage-backed bytes 解码为 str。这说明 hydrated 与 solved 的区别是实际 +合同,不能靠 MIME 或 CLI 展示偏好合并。见 `app/business/info_base/resolver/{image,audio,pdf,text,inspection}.py`。 + +`app/business/sink/projection.py` 已遍历 model/dataclass/JSON 并发现 bytes,MCP 据此构造 Resource 引用;但其 +URI 与 live re-read 是 MCP 交付方式,不直接带入普通 REST CLI。可复用的遍历/序列化部分需要按 owner 重新 +评估,不能直接 import 整个 MCPSink,或把 MCP Resource 当普通下载 URL。 + +本方案要求“文件副本来自本次已执行结果”,因此技术设计必须核对 binary/复杂返回通过 REST 到 CLI 的实际 +传输,避免仅为保存再执行一次可能 materialize 的 Resolver method。优先利用成熟 HTTP/序列化支持,不预设 +结果表、持久服务端快照或新的 Result Manager。若现有机制不能承载,应在 preflight 前形成明确技术方案。 + +本轮使用 ponytail 核对的是交付责任:普通本地文件可供 Agent 已有工具读取,不引入特制阅读工具;Core +继续拥有内容取得与解释,CLI 拥有本次输出副本和显示。不修改源码或 durable docs。 diff --git a/tasks/knowledge-lifecycle-capabilities/units/cli-sink/content-transport.md b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/content-transport.md new file mode 100644 index 0000000..7a0bb6c --- /dev/null +++ b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/content-transport.md @@ -0,0 +1,137 @@ +# 内容的 HTTP 表示 + +状态:2026-09-13 按 [D-584/D-585](../../decisions/D581-D590.md) 确认 JSON / 原生 bytes / multipart 路线及 +multipart 字段关联。MessagePack 是未选方案。D-583 的实体/Resolver 逻辑结果保持不变;本页不定义本地 +文件引用或导出回执,也不表示完整端到端实现已验证。 + +## 压力来自动态 bytes,不只是响应大小 + +一个 Resolver 可以返回 text、null 或含多个 bytes 成员的对象。仅把 bytes 序列化为 Base64 字符串能够保存 +字节内容,但在没有具体输出 schema 时,通用消费者无法仅凭值区分它与普通字符串。CLI 又不能复制每种 +Extension 的结果模型,或根据 content/data 等字段名猜哪些字符串应当解码为文件。 + +需要分别判断返回值本身是 bytes,还是它是含 bytes 的结构化对象。前者不需要任何对象序列化格式;后者 +才需要关联结构与二进制成员。不能从 nested bytes 直接推出所有内容结果都应该使用 MessagePack。 + +## 原生 HTTP 路线:按结果形状选择表示 + +| 实际返回值 | 原生表示 | 保留的语义 | +| --- | --- | --- | +| JSON 可表达的值(含字符串、null) | application/json | 不替内容字符串猜 JSON、HTML 等格式 | +| 返回值本身是 bytes | application/octet-stream | body 就是原始 bytes,不编码为 Base64 | +| 对象或数组中包含 bytes | multipart/related | JSON 结构与多个 binary part 在同一响应中传输 | + +单独 bytes 通过 FastAPI Response 即可发送;已有 iterator/file 时可以使用 StreamingResponse/FileResponse。 +Content-Type 声明 body 的表示,不是开启流式的开关。JSON、MessagePack、multipart 都可以通过 HTTP 流传输; +是否降低内存还取决于 producer/consumer 是否逐段处理。当前 Storage/Resolver 已先形成完整 bytes,不能 +只换响应类就宣称端到端常量内存,也不为此扩展 Storage/Resolver 的 streaming API。 + +裸 binary 默认使用 application/octet-stream,避免把本来是 bytes 的 JSON/HTML 文件误当普通结构化结果。 +若以后使用真实文件 MIME,应同时明确文件交付标记与解码合同;不能只看 MIME 等于 application/json 就丢失 +其 byte 语义。Content-Disposition 可表达已有 filename,但不是承载任意业务 metadata 的地方。 + +含 bytes 的 solved content 不能简单抽出 `.content` 返回,因为这会丢失同次结果里的宽高、描述等字段。 +例如 ImageSolvedContent 继承 ByteContentFacts,既有 bytes,也有 byte_size/detected_media_type 和 image facts。 +即便 get 只请求一个 Block,返回值仍是已确认的记录数组;若 hydrated_content 是 bytes,也属于结构化情形, +不能为了直接下载把记录或数组边界去掉。 + +multipart/related 是复合对象的一次 HTTP 响应,不是多个请求。一个 JSON part 保存结果结构,另外的 part +保存 bytes;同一个 Resolver 结果可以同时交付 metadata 和全部 binary,不需要缓存服务端结果或再次读取。 +MIME 的 boundary / Content-ID / part Content-Type 已有标准,**JSON 成员位置如何对应 part 仍是应用合同**, +由下文的关联表表达,不能声称协议已自动恢复任意 Python 对象。不能靠猜 content/data 字段或业务字符串等于 cid +来识别 binary。RFC 2387 对根 part 的 type/start 机制不代替这个字段关联合同。 + +实现可复用 aiohttp 的公开 MultipartWriter/MultipartReader(当前环境已有 3.14.3,仍是传递依赖)。进一步的 +[依赖选型与局部实验](project-and-dependencies.md) 已按 D-598 确认 Core 使用 MultipartWriter,CLI 通过 HTTPX 接收并用 +标准库 BytesParser 读取;已核验这条局部路径的 bytes 保真,完整端到端验收仍待实施。采用 aiohttp 时需 +在 Core 显式声明依赖,不把“采用 HTTP 原生格式”描述成“标准库和现有框架已经无成本实现全部功能”。 + +## Multipart 字段关联(D-585) + +multipart/related 的第一个 part 为 application/json,保存两项:value 是原结果结构,其中 bytes +位置暂以 null 占位;parts 是相对于 value 的 JSON Pointer 到 binary part Content-ID 的映射。外层响应使用 +`multipart/related; type="application/json"; boundary=…`。既然 root 固定为第一个 part,不再增加 start 参数。 + +例如 Resolver 原本返回含 content bytes 与 width 的对象,JSON root 为: + +```json +{"value":{"content":null,"width":640},"parts":{"/content":"body-1@inkcre"}} +``` + +另一个 part 声明 `Content-ID: ` 与 `Content-Type: application/octet-stream`,body 就是原始 +bytes。示例 ID 只表示两端如何关联;实际 ID 由 MIME 编码侧生成,不是 Block ID、下载 URL 或持久资源。 +CLI 用映射将 part 关联回字段,不依赖 part 到达顺序、字段名、文件名或字符串恰好像 cid。 + +JSON Pointer 使用 RFC 6901 的现成语法:例如 get 批次第一条记录的 hydrated_content 对应 +`/0/hydrated_content`,对象键中的 `/` 与 `~` 使用标准转义。没有映射的 null 仍是普通 null;空 bytes +仍有自己的 binary part。value 内本来就存在 value/parts 字段、cid 字符串或类似文件标记的普通对象,也不会 +被误当传输控制。这是把关联信息与业务值分开的目的,不是让 Resolver 追加特殊字段。 + +这层两字段封装只用于 multipart 的 HTTP 表示。没有 bytes 时仍直接返回原 JSON;纯 bytes 仍直接响应。 +接收端恢复逻辑结构,CLI 再按已确认规则将 bytes 保存成本地文件,原 metadata 与数组根形状不变。封装本身 +不透传为终端的 result/status 外壳,也不成为 Resolver 返回模型或所有 Core API 的统一 envelope。 + +代价是原始 HTTP 消费者需要理解这份小型关联合同;multipart 标准不会自动完成它。相较于在业务 JSON 中 +保留特殊 sentinel 对象,显式映射不占用业务值的合法形状;相较于另造 HTTP header 承载字段路径,JSON root +可以直接表达 Unicode 路径和结构化映射。MIME 分帧与解析交给库,仅保留 InKCre 必需的字段关联适配。 + +已完成的局部 HTTP 实验只验证 part 传输,未验证完整字段关联。后续预演需通过真实序列化边界核对嵌套 +对象/数组、空 bytes、普通 null 与特殊字符键,不为本合同新增自动化测试或扩展到任意 Python 对象。 + +## 与其它路线比较 + +| 路线 | 优点 | 实际代价或不足 | +| --- | --- | --- | +| 原生 binary + multipart | 文件 bytes 不转码,普通 HTTP/MIME 工具可处理,CLI 可逐 part 落文件 | 需要定义嵌套字段与 part 的关联;HTTP 客户端不一定自动解 multipart | +| JSON + Base64 | 单一 JSON 响应,Pydantic 原生支持 | bytes 通常约增加三分之一体积;动态字段的 byte 类型需 schema 或额外类型信息 | +| MessagePack | 对象内原生区分 str/bytes,整体往返代码较少 | 消费者需要 codec;不能像 JSON 直接查看,也不自动解决 streaming/大文件 | +| JSON + 独立下载链接 | 适合本来就独立可寻址的稳定文件 | 任意 Resolver 本次返回值并非既有下载资源;会引入快照生命周期或重执行,当前不推荐 | + +按 D-584 选择原生 binary + multipart:它与文件交付目标相符,也使普通 REST 消费者不必使用专用对象 +codec。这是可读性、互操作与关联合同成本的取舍,不声称比 MessagePack 更少代码。MVP 不同时维护两套 +等价路径;上一轮可行性证据仍保留,但不是执行计划。 + +## 未选 MessagePack 方案与序列化证据 + +上一轮提案仅为 entities/get 与 Resolver invocation 增加 Accept 协商:JSON 的 bytes 为 URL-safe Base64, +CLI 选择 application/msgpack 保留原生 str/bytes 区别。它避免在业务 JSON 中放特殊标记,但消费者需要 codec。 +当前锁与实际 PDM 环境已有 msgpack 1.2.1,来源是 logtail-python 的传递依赖。这不构成采用理由;D-584 +之后不为内容传输新增 msgpack direct dependency,也不添加该候选所需的 python-mimeparse 协商依赖。 + +模型/dataclass 转换为普通结构,日期等维持现有 JSON 风格的字符串,bytes 保留至编码器。局部实验使用 +`TypeAdapter(Any).dump_python(..., mode="python")`,再用 `msgpack.packb(..., use_bin_type=True, +default=to_jsonable_python)` 与 `unpackb(..., raw=False)`;这是可行性证据,不是已冻结的通用序列化实现。 +实际 Resolver 返回值、别名与既有序列化器仍需在实现预演中核对;不承诺支持任意 Python runtime object。 +Pydantic 继续用于模型投影与稳定 DTO;当前选择不需要可插拔 codec registry,HTTP 失败仍可返回既有 JSON +错误体。CLI 按 HTTP 状态及 Content-Type 处理响应,不因表示不符隐式重发可能 materialize 的 Resolver 调用。 + +## 局部核验与证据 + +2026-09-13,在当前 PDM 环境(FastAPI 0.139.2、Pydantic 2.13.4、msgpack 1.2.1)执行了一次无文件、无网络 +的内存 codec 实验。样本包含嵌套 model/dataclass、0–255 全字节集合、空 bytes、Unicode、datetime、null, +以及形状类似 `{type: "bytes", data: "AA=="}` 的普通 authored JSON。往返后 byte 值与字符串类型正确, +普通对象没有被当作特殊标记。编码样本为 376 bytes,对应紧凑 JSON/Base64 为 500 bytes;这不是吞吐/性能基准。 +没有新增测试文件、业务代码或依赖,也未进行 HTTP / preview 端到端验收。 + +同日补充一次临时 FastAPI + HTTPX ASGITransport 内存 HTTP 实验:Response 直接返回原始 bytes;另一端点用 +aiohttp.MultipartWriter 生成 multipart/related,经 StreamingResponse 返回。使用标准库 email.BytesParser +读取 JSON 与两个 binary part,核对 0–255 字节、混合 CR/LF、空 bytes、Unicode 均保留,且未使用 Base64。 +这个实验验证了原生响应与 multipart byte 传输可行性,没有定义最终 JSON 位置关联,也没有验证逐 part +落盘的内存特征、真实网络代理或部署。全部在内存完成,无新增源码/测试文件、依赖或数据库动作。 + +一手依据: + +- [MessagePack specification](https://github.com/msgpack/msgpack/blob/master/spec.md):原生 String/Binary、Array/Map 类型。 +- [msgpack Python API](https://msgpack-python.readthedocs.io/en/latest/api.html):packb/unpackb 与原生类型选项。 +- [FastAPI JSON with Bytes as Base64](https://fastapi.tiangolo.com/advanced/json-base64-bytes/):Pydantic 支持 bytes 的 JSON Base64 表示。 +- [RFC 9110 Accept](https://www.rfc-editor.org/rfc/rfc9110.html#name-accept):响应表示协商。 +- [python-mimeparse](https://github.com/falconry/python-mimeparse):媒体范围和 quality 匹配能力。 +- [FastAPI responses](https://fastapi.tiangolo.com/advanced/custom-response/):Response、StreamingResponse 和 FileResponse。 +- [RFC 9110 multipart types](https://www.rfc-editor.org/rfc/rfc9110.html#name-multipart-types):同一 HTTP body 中的 multipart。 +- [RFC 2387](https://www.rfc-editor.org/rfc/rfc2387.txt):multipart/related 的 root 与复合对象机制。 +- [RFC 6901](https://www.rfc-editor.org/rfc/rfc6901.html):JSON Pointer 对对象成员/数组元素的标准定位语法。 +- [aiohttp multipart](https://docs.aiohttp.org/en/stable/multipart.html):MultipartWriter/MultipartReader 公开 API。 + +本轮通过 python-backend-code 核对原生 HTTP 边界,并按 ponytail 补齐标准协议与成熟库的比较。上一轮从 +“nested bytes 需要类型区分”过早跳到了 MessagePack,遗漏了裸 binary 与 multipart 应分别评价的适用面。 +传输路线及关联合同现已确认;本地文件引用与导出回执在 [文件输出](file-output.md) 中复核,不把后两者混成 HTTP 协议字段。 diff --git a/tasks/knowledge-lifecycle-capabilities/units/cli-sink/deployment-config-rest.md b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/deployment-config-rest.md new file mode 100644 index 0000000..1d05708 --- /dev/null +++ b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/deployment-config-rest.md @@ -0,0 +1,97 @@ +# Deployment config 的发现与管理 REST + +状态:按 [D-590](../../decisions/D581-D590.md) 确认,尚未实施。承接 D-577 的输入发现与 D-589 的分层讨论纠偏。只列接口增量与直接实现缺口, +既有配置消费行为留给内部预演,不重新作为业务决策。 + +## 路径与呈现 + +| REST | CLI | 合同 | +| --- | --- | --- | +| GET /configs | config list | 已持久配置记录,不混入未配置的默认值或虚拟记录 | +| GET /configs/{key} | config get | key、schema、value、created_at、updated_at | +| PUT /configs/{key} | config replace | 创建或完整替换;body 为 {schema, value} | +| PATCH /configs/{key} | config update | body 为 value 的顶层部分字段,不再包 value;不改 schema | +| DELETE /configs/{key} | config delete | 删除配置记录;204,无记录为 404 | +| GET /config-schemas | config schemas | 本机注册 schema 的 id、keys 与 description,不附全部 JSON Schema | +| GET /config-schemas/{id} | config schemas 的精确选择 / --schema 查询 | 同一描述加 input_schema | + +GET 和 PATCH 返回 200;PUT 创建返回 201,替换返回 200,均返回实际配置记录。PUT 的创建/替换由持久化操作 +结果判断,不靠 CLI 预先 GET 或单独读存在性推断。状态码遵循 +[RFC 9110 §9.3.4](https://www.rfc-editor.org/rfc/rfc9110.html#section-9.3.4)。列表分页随管理列表共同合同收敛。 + +config schemas 是当前 Peer 的代码合同发现;configs 是 deployment 的持久数据。schema 未出现在前者,不影响 +读取后者,不伪造 deployment-wide schema catalog。本轮不增加持久 schema 表或跨 Peer schema 汇聚。 + +## 首次配置的发现 + +现有 register_schema 只登记 schema ID 与模型;各 owner 已另有 CONFIG_KEY 常量。建议在同一注册处增加可选 +keys 元数据,让 CLI 知道哪些业务 key 使用该 schema: + +```python +DeploymentConfigManager.register_schema( + RUMINATION_CONFIG_SCHEMA, + RuminationConfig, + keys=(RUMINATION_CONFIG_KEY,), +) +``` + +keys 默认空 tuple,是 owner 声明的已知用途,不是限制写入 key 的白名单,也不要求一个 schema 只能对应一个 +key。注册元数据与 ConfigContract 放在同一 registry entry,不再增加 key registry 或独立 manager。 +description 取模型已有说明;input_schema 仍由 ConfigContract.json_schema 提供,不维护第二套 schema。 + +所有本轮触及的现有 deployment config owner 在自己的注册处提供现有 key 常量,包括 organization、retrieval、 +Resolver、Source、Cron、extension registry 配置;不在路由或 CLI 集中复制这些对应关系,不从 ID 字符串推导 key。 +这是普通配置发现信息,不让 owner 声明 CLI/Agent 专属能力;也不变成配置实例的自动创建或默认值持久化。 + +列表中的一项示例: + +```json +{ + "id": "core.organization.rumination.config.v1", + "keys": ["core.organization.rumination"], + "description": "Deployment selection of one reusable Agent definition." +} +``` + +CLI replace 继续显式传 --schema-id;输入只包含 value。已知 key 与 schema 时可直接写,不强制先发现。 +不在 CLI 从 keys 反向自动选择 schema,因为同一 key 可能有不同 schema 版本。 + +## 管理读取和修复 + +管理 GET / list 返回数据库实际保存的 schema 与 value,不先按业务模型校验或注入默认值;因此未知 schema、 +旧版或错误配置仍可查看。响应 DTO 描述记录形状,不额外验证其业务 schema。DeploymentConfigManager 的 +typed get 保留类型化消费接口,但不把它现有的重复 schema 校验当作保留条件;读路径只承担必要的表示转换。 +具体调用与 Pydantic 构造方式在实现预演中核对,不以手写通用反序列化器替代一次错误的过度校验设计。 + +PUT 仍按明确 schema 校验、规范化完整 value。PATCH 在现有行锁内将原 value 与提交字段浅合并,再校验完整 +候选结果,而不是要求旧 value 先通过验证。这样一次合法 PATCH 可以修复原有错误;未修复的问题仍由 Pydantic +正常指出,不增加旁路写入或专用 repair endpoint。 + +PATCH 的对象/数组字段完整替换,null 为普通提交值,是否有效由 schema 决定,不表示删除字段。body 使用 +application/json,这是本接口明确的浅合并合同,不冒称 JSON Merge Patch。PATCH 本身不规定唯一 patch 格式, +见 [RFC 5789 §2](https://www.rfc-editor.org/rfc/rfc5789.html#section-2)。 + +PUT/PATCH 仍需当前 Peer 的 schema 实现;未知 schema 时无法声称已验证。GET/DELETE 不需要该实现。 +删除只删除记录,不将其表述为统一的 reset-to-default:缺失配置的含义由既有 owner 决定。 + +CLI 示例: + +```sh +inkcre-cli config replace core.organization.rumination \ + --schema-id core.organization.rumination.config.v1 --input-json '{"agent":42}' +inkcre-cli config update core.organization.rumination --input-json '{"agent":43}' +``` + +第一条由 CLI 组成 PUT body {schema, value};第二条直接用输入作为 PATCH body。输出保持配置记录形状, +不增加配置有效性标签或预制下一步操作。 + +## 实现证据与验证范围 + +app/routes/deployment_config.py 已有 GET/PUT/PATCH;app/business/deployment_config.py 已有 schema registry、 +read/get 区分、upsert 与行锁 PATCH。目前 read 经 _view 调用 _validate_record,会使非法/未知配置的 GET 失败; +PATCH 也先验证旧值。app/configuration.py 的 ConfigContract 是无持久化/registry 的模型工具,不把 keys 放入它。 +源码调用检索显示业务使用 typed get,管理 read 当前由配置 route 调用;落地时再次检查所有调用点。 + +需要验证首次配置发现、完整替换、浅 PATCH、读取与修复非法配置、未知 schema 记录的读取/删除。沿用单元手工/ +脚本黑盒验收,不新增自动化测试。注册元数据、管理 read、typed get 的重复验证与 PATCH 变化是本轮真实 Core 影响,需要纳入实现计划; +不会因写进本页而获得源码实施授权。现有合法请求的 PUT/PATCH body 保留,GET 的实际值语义变更须写入 REST 文档。 diff --git a/tasks/knowledge-lifecycle-capabilities/units/cli-sink/entity-resolver-rest.md b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/entity-resolver-rest.md new file mode 100644 index 0000000..931b3e6 --- /dev/null +++ b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/entity-resolver-rest.md @@ -0,0 +1,96 @@ +# 基础实体读取与 Resolver REST + +状态:2026-09-13 按 [D-583](../../decisions/D581-D590.md) 获 Sir 确认,尚未实现。沿用 D-575 的 get / Resolver +职责分离、D-577 的动态输入合同和 D-579 的内容交付原则。本页先收敛读取操作与逻辑结果,binary 的 HTTP +编码、文件引用和导出回执在下一段设计中补齐,不能把下面的 bytes 当作 JSON 原生值。 + +## 从领域能力接出普通 REST + +`BlockManager.get_many` 与 `RelationManager.get_many` 已有按 ID 批量读取;`BlockModel.get_hydrated_content` +已有 Storage 语义。`ResolverManager.get_method_contracts`、`get_method_contract`、`invoke_method` 已拥有 +公开读取方法发现、Pydantic 参数合同与执行。REST 调用这些 owner,不调用 MCP Tool 或 Organization Agent +Tool,也不新增 CLI 专用业务 Manager。CLI 仅经 HTTP 调用,不导入这些 Core 模块。 + +以下路径相对配置的 Core API 根地址。继续使用既有认证依赖,不注册新的 Peer capability;既有 Peer inbound +消费者不因增加这些普通 REST 入口而迁移。 + +## 批量取得基础记录 + +`POST /entities/get`,请求为: + +```json +{"entities":[{"type":"block","id":42},{"type":"relation","id":8}],"content":"raw"} +``` + +CLI `get block:42 relation:8` 将 shell 引用转换为有类型的 ID,HTTP 不通过整数值猜实体种类。 +`entities` 是显式 ID 数组;空数组得到空结果,不复制内部 Agent Tool 的空输入随机选择行为。 +JSON body 使用 POST,而不是给 GET 添加 body;这不表示创建实体。 + +默认 `content=raw`,使 get 默认取得原始记录且不发起 Storage 内容读取。三个选项如下: + +| 选项 | Block 响应 | Relation 响应 | +| --- | --- | --- | +| `none` | 不含 content / hydrated_content | 不含 content | +| `raw` | `content` 是原始字符串,包括可能的 pointer | `content` 是原始关系字符串 | +| `hydrated` | 不含 content,以 `hydrated_content` 返回本次 str/bytes | 仍返回原始 content;Relation 不存在 hydration | + +这些是响应投影,不是改变 BlockModel。任何出现的 `content` 都保持持久字段的意义;未请求的字段省略, +不返回 null 来暗示内容不存在。`storage` 与 exact `resolver` 在所有 Block 成功结果中保留;不因 hydration +清空 storage、不解析 canonical JSON string,也不额外调用 Resolver 生成 label 或 graph context。 + +响应及 CLI `--json` 的逻辑结果为有序数组,单项请求仍返回数组。每项保留 `type`、`id`,成功项平铺基础 +记录字段,失败项包含 `error`,不新增 success/status、entity 包装或 results envelope。数据库查询可以按 +ID 集合读取,再按输入关联;不承诺数据库自然返回顺序。重复引用保持输入位置,不静默去重输出。 + +Block 成功字段为 `type="block"`、`id`、`resolver`、`storage`、`created_at`、`updated_at`,加选定内容字段。 +Relation 成功字段为 `type="relation"`、`id`、`from_block_id`、`to_block_id`、`updated_at`,加选定 content。 +端点命名沿用已有 MCP 投影的明确身份表达,只在 HTTP DTO 映射 `from_` / `to_`,不重命名数据库或领域模型。 +这里不为 Relation 虚构当前没有的 created_at。 + +缺失引用返回同位置的 `{type, id, error: {code: "not_found", message: ...}}`。单个 Block hydration 失败 +也保留同位置的错误,不丢弃其它成功项;整批共享的认证/数据库故障仍是 HTTP 请求失败,不能伪装成实体缺失。 +本批次不是 graph snapshot,不承诺跨 Storage 与数据库的原子观察。字段级错误细节及退出码沿 D-597 的 +[整体错误合同](list-error-contract.md):保留逐项成果,存在逐项错误时退出 1,不建立新的业务失败状态体系。 + +## Resolver 发现与调用 + +| 普通 REST | 用途与 CLI 映射 | +| --- | --- | +| `GET /resolvers` | 当前进程已注册的 exact Resolver 目录;`resolver list` | +| `GET /resolvers/{resolver_id}/methods` | 指定 exact Resolver 的方法合同;`resolver methods --resolver …` | +| `GET /blocks/{block_id}/resolver/methods` | 从 Block 当前 resolver 取得相同方法合同;`resolver methods block:42` | +| `POST /blocks/{block_id}/resolver/methods/{method}` | 调用选定方法;`resolver invoke block:42 --method …` | + +方法发现返回 `{resolver, methods: [{name, description, input_schema}], next_cursor}`,按 D-600 支持可选分页。 +CLI 的短目录只投影名称与说明; +`resolver invoke … --schema` 从同一合同取选定方法的 input_schema,不执行内容方法。先沿用现有完整方法合同 +读取,不为尚无传输瓶颈的目录另建 schema 缓存、逐方法路由注册或发现会话。Resolver 目录不需要先有 Block; +实际调用必须有 Block。未知 Resolver 与没有方法要区分,不能仅凭 get_method_contracts 返回空 tuple 混为一谈。 + +调用 body 就是方法参数,例如 `{"refresh":true}`,不额外包 arguments 或重复 method。无参数调用由 CLI +发送 `{}`。已知方法可以直接 invoke,不强制预先 discovery。Core 交给现有方法 input_model 校验一次; +CLI 不复制 Extension 模型,也不将 JSON Schema 反向编译成另一份运行时业务校验器。 + +成功结果保留方法自身的值:str 是 str,null 是 null,对象/数组保留结构,不加 `{result, status}`。 +模型与 dataclass 是序列化对象,不把动态字段过滤为固定内容模板。含 bytes 的结果使用下一段待定的 HTTP +内容编码,CLI 再履行已确认的本地文件交付;不返回 MCP URI,也不为取回文件重执行 Resolver 方法。 + +POST 适用于这类实际调用:公开 get_/read_ 方法仍可能按已有合同 lazy materialize。REST 不把方法名当作 +绝对无副作用承诺,也不扩展到任意 Python 属性或未被既有反射合同支持的方法。参数错误保留 Pydantic 的 +字段路径,未知方法/不存在 Block/实际执行故障仍可区分;详细 HTTP 错误映射不在本页另造一套例外层。 + +## 实现前核验与证据 + +- `app/routes/block.py`、`relation.py` 当前只有部分单条/邻接入口,不能冒充已有普通 REST 批量接口。 +- `app/business/info_base/{block,relation}.py` 的 get_many 返回已有记录,缺失项与输入顺序由边界投影补齐。 +- `app/schemas/info_base/block.py` 的 hydration 返回 str/bytes,且保持持久 content 和 storage 不变。 +- `app/business/info_base/resolver/main.py` 是方法 registry/reflection/invoke owner;实现时维持 validation 一次。 +- `app/business/sink/projection.py` 已采用 from_block_id/to_block_id,但 MCP Resource 的 live reread 不能满足本 + CLI 的同次结果交付,不能直接把整个投影模块当普通 HTTP adapter。 +- `pyproject.toml` 当前声明 FastAPI `>=0.139.2,<0.140.0`、Pydantic `>=2.11.7,<3.0.0`。 +- [FastAPI Request Body](https://fastapi.tiangolo.com/tutorial/body/) 不推荐 GET body;POST + Pydantic request + model 足以承载这里的异构 ID 数组。既有 Resolver 使用的动态模型也是 + [Pydantic 原生能力](https://docs.pydantic.dev/latest/examples/dynamic_models/),不需要另建 schema 编译设施。 + +预演须覆盖混合成功/缺失、重复 ID、inline 与 storage-backed 的 raw/hydrated、有 bytes 的 solved object、 +空/null 返回,以及 Extension 方法注册前后。这里只记录核验面,不新增自动化测试或宣称验收已冻结。 diff --git a/tasks/knowledge-lifecycle-capabilities/units/cli-sink/extension-rest.md b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/extension-rest.md new file mode 100644 index 0000000..beb73bc --- /dev/null +++ b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/extension-rest.md @@ -0,0 +1,82 @@ +# Extension 管理 REST + +状态:按 [D-593](../../decisions/D591-D600.md) 确认;尚未增加源码或改变 Extension 生命周期。 + +## 接入点与执行目标 + +本机 connection 选择一个 Core REST 接入点。安装记录与 config 属于 deployment;本提案通过该 Core 的 +ExtensionHost 读写,不为这些操作增加 `--peer`。启用和禁用针对某个 Peer 的运行实例,因此额外支持 +`--peer `,REST 对应可选 query parameter `route_to_peer`。省略时操作接入 Core 自己,不自动挑选其它 Peer。 + +```text +CLI --connection personal extension enable --peer + → 接入 Core POST /extensions/{namespace}/{name}/enable?route_to_peer= + → ExtensionHost.manage(EnableExtensionCommand, route_to_peer) + → 本机 manage_local,或已有 PeerManager.delegate + → 目标 Peer 的非委托 manage_local +``` + +CLI 不查目标 Peer 的 inbound URL,不签发另一套 Peer 请求,也不直接修改 enabled 数组。指定目标无法执行时 +沿用该领域的真实错误,不在别的 Peer 启用。既有 `/extension-management` 仍是 non-delegating Peer inbound; +不把它暴露成 CLI 的通用 management 命令,也不在本轮为了 REST 包装扩展它的 command union。 + +配置写入返回共享记录,调用接入 Host 已有的配置更新路径;不承诺向全部运行 Peer 广播或重启。 +既有 Peer command 的 `patch_config` 分支仍保留,但不据此要求 CLI 的每种配置操作都选择执行 Peer。 +若后续出现必须在另一个运行实例执行配置回调的具体需求,再讨论该操作的目标选择,而非先增加通用代理。 + +## 命令与请求 + +以下用 `E` 代表 `/extensions/{namespace}/{name}`;CLI 的标识仍为 canonical `namespace/name`。 + +| CLI | REST | 输入与结果 | +| --- | --- | --- | +| `extension list` | `GET /extensions` | D-600 的 extensions 清单与 next_cursor;元素仍为已安装记录 | +| `extension get ` | `GET E` | 一个已安装记录 | +| `extension install --version ` | `POST E?version=` | 沿用现有 exact-version 安装操作,200 返回已安装记录 | +| `extension uninstall ` | `DELETE E` | 沿用现有卸载操作,成功 204 | +| `extension config get ` | `GET E` | CLI 取出记录的 config,不新增相同目的的 GET route | +| `extension config replace --input config.json` | `PUT E/config` | body 直接是完整 config object;200 返回更新后的已安装记录 | +| `extension config update --input patch.json` | `PATCH E/config` | body 直接是浅层 patch;200 返回更新后的已安装记录 | +| `extension enable [--peer ]` | `POST E/enable[?route_to_peer=]` | 无业务 body;200 返回已安装记录 | +| `extension disable [--peer ]` | `POST E/disable[?route_to_peer=]` | 无业务 body;200 返回已安装记录 | + +新 REST 增量为 PATCH config 和 enable/disable 的目标参数;保留已有 install 的 method/path/version query, +不为格式整齐改造成另一种发布、升级或通用 CRUD 协议。CLI 不自动选择 latest、安装后自动 enable,或为安装 +创建后台 Job。已有 Host 操作的同步结果仍是本次请求结果。 + +replace 与 update 共用 ExtensionHost.update_config 的写入路径;update 由已有 patch_config 浅层合并。 +未出现字段不变,嵌套 object/array 整体替换,null 是配置值而非删除字段指令;要移除字段使用完整 replace。 +CLI 不先 GET 再自行合并 PATCH。HTTP handler 只负责请求映射与错误表达,不持有这套合并业务。 + +## 记录与动态配置合同 + +继续使用现有 InstalledExtension 投影:name、version、enabled、nickname、config、config_schema。 +enabled 保留 Peer UUID 数组,不改为一个与接入点相关的 bool;不新增推测的 running/online 状态。 +Extension-produced state 按现有 DTO 的 exclude 合同不进入 generic management 输出。 + +配置输入和 `--schema` 从对应安装记录的 config_schema 发现,CLI 不附带各 Extension 的模型。 +config_schema 目前允许 null;该事实必须如实表达,不能以启用 Extension 为 schema 查询的副作用。 +准确的 schema-unavailable 呈现与其它动态发现入口在共通输出收敛时统一,不伪造一份完整的 Extension schema。 + +配置更新仍需在有实际 schema 的输入边界建立合同;与 D-591 一起核验当前 Host、runtime、store 的调用链, +避免外部写入没有校验而内部 get/转换反复校验的倒置。尤其不能机械删除首次加载模型的检查:安装后尚无 +config_schema、允许先保存 config 的路径,没有证明这些数据已经通过 Extension 的具体模型。 +此处是首次建立合同与重复校验的区别,不撤回 D-590 的普通记录读取边界,也不扩张为强制先启用才能配置。 + +## 已核实的实现交点 + +- `app/routes/extension.py` 已有上表除 PATCH 与 route_to_peer 外的 HTTP 操作;GET 返回 InstalledExtension, + PUT config body 为 config 本身。隐藏的 `/extension-management` 直接调用 manage_local。 +- `app/business/extension/main.py` 的 manage 已按 exact Peer 在 local execution 与 delegation 间选择, + command union 当前为 enable、disable、patch_config;patch_config 已浅层合并后调用 update_config。 +- `app/business/extension/state.py` 负责实际持久操作与 InstalledExtension 投影;配置尚未运行时的 update + 当前直接交给 store,而 runtime 启动后才发布 config_schema。这是 D-591 校验调查需要保留的首次输入情形。 +- `docs/40-deployment/native-extension-distribution.md` 与 Extension 局部指南拥有安装/启用/运行的既有合同; + REST 包装不是重新设计或重新审批这些生命周期的理由。 +- 当前 client-web 的 `apps/client-web/src/components/extension/extensionCard/extensionCard.vue` 调用自己的 + WebExtensionHost;`packages/core/src/extension/host.ts` 通过其 state port 管理 deployment 记录并启停本机模块。 + 对当前 packages/apps 源码检索未发现 `/extension-management` 或 `patch_config` Peer command 的消费者。 + 不能把这个预留 capability 写成当前 Web UI 已实际使用的协议,也不能据此断言仓库外不存在消费者。 + +后续 preflight 核对路由 query 参数、Host errors 到 HTTP errors 的转换,以及有/无运行 Extension 的写入路径; +验收使用实际已发布 Extension 的安装、配置、启停和卸载,不为本批增加 helper/schema 自动化测试。 diff --git a/tasks/knowledge-lifecycle-capabilities/units/cli-sink/file-output.md b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/file-output.md new file mode 100644 index 0000000..8bd98c5 --- /dev/null +++ b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/file-output.md @@ -0,0 +1,62 @@ +# CLI 本地文件引用与导出回执 + +状态:2026-09-13 按 [D-586](../../decisions/D581-D590.md) 获确认,尚未实现。沿用 D-578/D-579 的内容交付与紧凑 JSON;D-584/D-585 已确认 +HTTP 传输。本文只讨论本机交付,不改变 Resolver、Storage、HTTP 表示或服务器资源生命周期。 + +## 用 file 表达已保存的本地副本 + +CLI 对已取得的 bytes 写文件,再在对应结果位置输出 `{"file":"路径"}`。它只是 CLI 的文件交付表示, +不添加 type/status/size 等重复字段,也不把路径伪装成原始字符串或 Storage pointer。stdout 的路径为绝对路径。 +根 bytes 使用同一表示;结构化结果保留其它字段与对象/数组边界。例如: + +```json +{"content":{"file":"/tmp/inkcre-example/001.bin"},"width":640} +``` + +示例路径并非当前机器的实际落点。普通字符串、数字、null、对象不因内容像路径、Base64、JSON 或 file 引用 +就被重新解释。只有 HTTP raw bytes 或 multipart 关联所明确标记的 bytes 位置会生成本地副本与引用;不导入 +Extension schema,不根据 content 字段名决定文件化,不重新调用 Resolver。 + +这里保留一个已确认的取舍:file 是输出约定,不是可逆的类型编码。如果业务数据本来恰好是同形的 +`{"file":"…"}`,仅凭最终 CLI JSON 无法证明它原本是对象还是 bytes。CLI 不解析这种对象为文件指令, +不提供自动反导入、自动跟随路径或完整 Python 类型往返。需要准确保留 bytes 类型的程序仍可使用已确认的 +HTTP 关联合同。D-586 接受这个呈现层残余,不为它新增全结果 envelope、保留词转义或文件索引。 +这明确了 content-delivery 中“文件引用与业务字段区分”的实际边界,不宣称任意结果都可反向恢复类型。 + +## 显式导出仍返回一个入口文件 + +`--output-dir DIR` 在指定目录内创建本次调用的子目录,并根据根值保存入口文件: + +| 根值 | 入口文件 | +| --- | --- | +| str | result.txt,保存正文,不增加 JSON 引号 | +| bytes | result.bin,保存原始字节 | +| 其它 JSON 值,包括对象、数组与 null | result.json,保存完整紧凑 JSON;嵌套 bytes 使用 file 引用 | + +JSON 中的 binary 文件可用编号命名,例如 001.bin,不以业务对象的键拼接路径。没有可靠的文件类型信息时 +保留 .bin,不为文件命名增加解析器或内容分类 ladder。编号不成为领域身份,不做按内容去重。 + +stdout 只需给入口文件回执,JSON 形式为: + +```json +{"file":"/work/evidence/inkcre-example/result.json"} +``` + +不再重复目录、文件总数、success、所有 binary 路径或已经保存在入口 JSON 中的 metadata。文件引用位于 +原结果中的位置,调用者打开入口文件即可继续使用。这是显式导出操作的回执,不用于包装普通 JSON 结果。 + +保存到 result.json 的引用相对于该 JSON 文件,stdout 直接呈现的引用则为绝对路径。例如保存文件中的 +`{"content":{"file":"001.bin"},"width":640}` 可以随整个目录移动。只变更本次生成的文件引用,不改写 +业务原有路径字符串;不需要把显示输出再解析一遍来寻找 file 字段。 + +## 与默认输出的连接 + +未给 output-dir 时,仅在 bytes 或可读输出超预算时创建 OS 临时目录;进程结束后仍保留供后续工具读取。 +可读输出超预算时按相同入口文件规则保存完整值,再给有限预览与入口路径。`--json` 的字符串和对象不因 +大小切换为导出回执,也不截断内容;只有实际 bytes 被替换为 file 引用。 + +目录、路径与文件写入使用标准库即可,不创建缓存数据库、TTL、下载服务或 CLI 专用阅读器。写入失败不 +返回成功回执;报告实际错误及已产生的目录,不隐式重执行内容方法,也不引入文件写入回滚服务。 + +实现前在脚本化黑盒核验中覆盖 text、根 bytes、嵌套 bytes、批量 hydrated、空 bytes 与目录整体移动后的 +文件读取。这里只列验证面,尚未创建输出文件、源码或自动化测试。方案按 ponytail 收敛本地文件交付职责。 diff --git a/tasks/knowledge-lifecycle-capabilities/units/cli-sink/impact-handshake.md b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/impact-handshake.md new file mode 100644 index 0000000..ccc3990 --- /dev/null +++ b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/impact-handshake.md @@ -0,0 +1,52 @@ +# CLI Sink — Impact Handshake + +2026-09-14;完整 preflight 后的实施基线。等待 Sir 明确“开始”,不把预演或本页视为源码授权。 +产品/接口 D-571–D-600、验收 D-601、Pydantic 取舍 D-602/D-603 保持已确认状态。 + +## From → To + +当前只有零散 Core 普通 REST,没有独立 CLI,Job 没有外部请求停止的完整执行链; +部分可信持久数据/typed 值反复经过输入 validator。 + +本轮交付同仓库独立的 inkcre-cli:通过普通 REST 完成已确认的检索、实体/Resolver、配置和运行管理; +原生 JSON/bytes/multipart 交付内容;Job 停止在 Core 与浏览器 worker 闭合。 +CLI 不安装 Core、不访问数据库、不加入 Peer delegation。 + +## 改动与影响范围 + +| Owner | 具体变化 | 副作用及不变项 | +| --- | --- | --- | +| core-py app/routes、business、schemas | 普通 REST、必要领域管理接口、动态 schema、内容响应、可信读取校验修正 | 普通 REST 允许 breaking 重整;已存在的 Peer/MCP 消费合同保留,不重写 retrieval 算法。 | +| Core Job / migration / run.py | abort_requested 默认 false、本地执行句柄、集中意图观察、shutdown 清理;显式 rumination Job | 新列是 additive;旧 worker 不消费它,需两端发布。不能把 pending/running/terminal 或 abort/timeout 混为一谈。 | +| extensions/mail | to_thread 清理顺序与原生有限网络 timeout | 取消可能等待实际 I/O/清理;不是强杀线程,也不承诺撤销远端副作用。 | +| ext-reg Python runtime | config/state 的重复校验链收敛,typed 返回值保留 | 不改生命周期、注册或 installed-record 合同;独立 feature、发布后再更新 Core dependency pin。 | +| client-web packages/core | Job DTO 与 AbortController 执行/停止闭合 | 不做新增管理 UI,不把 browser 组件探针宣传为完整 Mail client。 | +| cli/ | 独立 PDM project、lock、src、命令与文档 | Click/Pydantic/HTTPX/PyJWT;复用协议而非 Core 代码,REST 返回只转换。 | +| Core release/automation | CLI 接同一 Towncrier Release PR,独立 main-only PyPI publisher;production Extension build 复用 Toolkit finalize | 不把 CLI 放进 Extension matrix,不再造 release controller;不覆盖已发布 wheel。 | +| Hub / 本地文档 | 只提升真正共享的校验/Job 合同;其余在各 owner 落地 | Hub 修改、ref bump、Spoke-local 变更分开;旧 Organization promotion 不搭车。 | + +其中 finalize 是本次 preflight 实测暴露的必要交付修正:正式 Mail 0.2.0 无 installed record,enable 409; +同一 wheel 经已有 Toolkit finalize 后,双 Core 安装/启用 200,真实 IMAP BODYSTRUCTURE/bytes 可读取。 +修复是接上已有步骤,不新增安装旁路。Mail 本轮已有 adapter fragment,将用下一正式版本完成交付。 + +## 实施顺序 + +1. 核对 main 漂移,在 ext-reg/client-web 使用独立 feature 工作位置,保留现有 dirty checkout。 +2. P1 校验边界、P2 Job/取消、P3 普通 REST 按各自 owner 落地,汇合验证。 +3. P4 独立 CLI 完整命令面;P5 对应文档、检查、发布接合。具体步骤见 implementation-plan.md。 +4. 候选 Preview 与隔离双 Core/IMAP 跑四条真实 CLI 旅程;runtime → Core pin,Core/client-web 同步交付。 +5. Core production 新 REST 可用后才首次发布 CLI;在 checkout 外干净 pip 安装复跑,再关闭 unit。 + +以上是依赖顺序,不授权跨 session 通信。提交、push、PR、合并和发布仍遵循各自明确授权。 + +## 验证与剩余条件 + +预演已通过:Mail 取消候选、独立 sdist/wheel 安装、Release prepare/index 隔离、双 Core/IMAP、浏览器数据库 +通路、真实 embedding 与 tool call、云端冷启动。详见 preflight.md;这些不是 CLI 已完成验收。 + +代码落地后运行 Core/CLI/client-web 各自静态 gate 和生成合同,按 acceptance.md 执行四条手工/脚本旅程; +不新增自动化 helper/schema 矩阵。发布后再次确认真实 Registry 安装和 PyPI 安装,不只看 CI green。 + +无尚未定位的设计或编码 blocker。实际 CLI Preview 要在候选 PR 后创建,PyPI Trusted Publisher 尚需 +Human 配置/确认;它们是交付条件,不掩盖成已就绪权限。主环境 AI catalog 为空,验收隔离环境需显式准备 +已验证的 Provider/模型/Agent 配置。任何新发现若改变本页产品范围或 owner,再返回讨论。 diff --git a/tasks/knowledge-lifecycle-capabilities/units/cli-sink/implementation-plan.md b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/implementation-plan.md new file mode 100644 index 0000000..04f9a57 --- /dev/null +++ b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/implementation-plan.md @@ -0,0 +1,144 @@ +# 实现计划 + +状态:2026-09-14,依据 D-571–D-603 和完整 preflight 收敛。产品、接口与验收已确认; +执行入口见 [Impact Handshake](impact-handshake.md)。本页本身不授权源码实施或发布。 + +## 依赖与改动 owner + +```text +P0 基线与环境核验 + ├─ P1 持久表示与校验边界 → ext-reg runtime 发布 → Core dependency pin + ├─ P2 Job 控制协议 → Core worker + client-web worker + └─ P3 Core 普通 REST / schema / 内容传输 + ↓ + P4 独立 CLI 与命令 + ↓ + P5 文档与发布接合 + ↓ + P6 四条黑盒旅程与交付 +``` + +P1、P2、P3 是可分别推进的代码责任面,不要求等待前一批全部完成才写下一批;它们在真实 REST 验收前 +汇合。P4 的连接、输入输出骨架可以在 P3 的协议确定后推进。这里的并行仅描述依赖,不授权跨 session +通信或另启 Agent;当前由本 session 顺序实施与集成。 + +| Owner | 必要改动 | 不随之扩大的范围 | +| --- | --- | --- | +| core-py | 领域管理缺口、Job 协议、普通 REST、独立 cli/ 项目、发布编排 | CLI 不导入 Core;普通 REST 不变成 Peer protocol | +| ext-reg Python runtime | Extension config/state 的既有重复校验路径 | 不改 Extension 生命周期、注册、发布协议,不编辑安装包副本 | +| client-web packages/core | Job 记录与运行控制、对应参数读取路径 | 不新增管理 UI,也不承诺浏览器完整 collection | +| Hub | 经实现证据确认的共享边界与 Job 合同增量 | CLI 命令、具体 Extension 行为不提升为 Hub capability | + +## P0:准备可执行基线 + +Core 已在 `feat/inkcre-cli` / `b3ccb00`,保留当前 task-control 和前一单元收尾记录。实施前重新核对 main, +不把其他单元的 dirty 文件纳入本次提交。ext-reg 本地 main 有独立提交且落后远端,client-web 本地 main +也落后;后续采用从已核验远端 main 建立的独立 feature 工作位置,不重置已有 checkout。 + +完整证据见 [preflight](preflight.md):运行时源、两端取消链路、嵌套表示转换、独立 PDM、发布接合和实际 +验收拓扑均有具体落点。正式实现先核对 main 是否漂移;不能用 preflight 代替代码/部署后的整体验收。 + +## P1:修正既有校验边界 + +以 [调用链清单](validation-boundary-correction.md) 和 [逐 owner 预演](preflight-call-sites.md) 为交付清单。主要落点是 `app/configuration.py`、 +`app/business/deployment_config.py`,Source/Storage/Sink/AI/Peer 的 config 加载入口、`app/schemas/` 中实际 +读回 codec,以及 first-party Extensions 对同一配置的直接加载。Job 的对应修改并入 P2。 + +先区分输入校验、持久表示转换、使用时能力检查;删除额外的重复业务检查,保留真正需要的转换。 +已有 typed 值尽量直接传递;不以浅层 `model_construct` 替换所有读取,也不新建通用递归模型解码框架。 +按 D-602,复杂类型恢复接受一次原生 Pydantic 构造及附带约束;普通读取和已有 typed 对象传递不重验。 +不能以“读回可信”掩盖 SecretStr、嵌套模型或 discriminated union 的需要。 + +共享部分落在 ext-reg `runtimes/core-py/src/inkcre_extension_runtime_core_py/base.py` 及实际调用链。 +runtime 的发行与 Core 的 dependency pin 分开交付;先验证本地候选,再按 owner 发布并更新 Core lock。 +验收用真实 Mail 嵌套参数、Provider 配置及 Extension config/state 使用证明行为,不统计 validator 调用次数。 + +## P2:Job 停止与显式整理命令 + +在 `app/schemas/job.py` 和增量 migration 加入默认 false 的 `abort_requested`,同步实际受影响的 database +contract、生成 schema/DTO 与 client-web Job 表示;不引入执行 Peer 指针、取消队列或 Peer delegation capability。 + +`app/business/job.py` 集中持有本进程执行句柄并批量观察取消意图。pending 通过条件写关闭;running 先记录意图, +实际执行退出后才关闭;终态不回退。完成、超时和主动取消共用现有条件关闭边界,不能把 timeout 和 abort +都映射为同一原因。client-web `packages/core/src/job/manager.ts` 以 AbortController 实现相同协议。 + +检查 `run.py`/scheduler 的生命周期及 Agent turn、Source blocking adapter 的取消传播。只在实际 owner +补齐必要清理,不把 polling 注入每个 handler,也不承诺已经发出的外部操作可撤回。 +[预演](preflight-runtime.md) 已复现 Mail adapter 在取消 to_thread 后锁提前释放的问题;预计修改 +`extensions/mail/adapter.py` 的调用完成/清理顺序与网络超时,包含 Mail fragment,不只修改 Core JobManager。 + +在 Organization owning Job 模块添加已确认的 explicit rumination handler,复用 +`RuminationBehaviorResolver.ruminate_local`;同步 builtin Job catalog,不把 Extension +类型塞进 builtin。Source ordinary/backfill 与 Cron 仍调用既有 Job 创建入口。 + +本批出口是双 Core 跨 Peer 请求停止和浏览器 worker 的真实闭合证据。旧 worker 不认识新字段,故两端更新 +是完整交付的一部分;migration 可先落地,但单有新列不算取消能力通过。 + +## P3:Core 普通 REST 与必要领域接口 + +按依赖分成三组,复用既有 manager,不把 SQL、安装流程或 Job 业务塞进 routes。 + +1. 先补 `app/business/` 的管理缺口:Source CRUD/目录 schema、Cron 管理、Agent definition、config schema + 发现、Peer 查询与 Extension config PATCH。Source 类型未加载时仍可使用持久 catalog 接收合法输入; + 执行资格与保存成功分离。按设计保留各 owner 的事务,不构造通用表 CRUD 服务。 +2. 接实体与使用:在 `app/routes/info_base.py` 及对应领域 router 提供批量 get、GraphForm POST、Block/Relation + PATCH/DELETE、图查询、词法/语义查询和 Resolver 方法发现/调用。Block PATCH 使用真实 update form 的 + 省略字段语义,不能继续由完整 BlockModel 的默认值清空 storage。Resolver reflection 复用 + `app/business/info_base/resolver/main.py`,不从 MCP Tool 反向调用领域能力。 +3. 接控制面:Job/Cron、Source、Agent/AI/profile、config、Peer、Extension 的普通 REST。Job 创建先持久化并 + 返回受理,best-effort 本地触发不阻塞响应。原有 client-web 消费的 Peer inbound 路径与 local execution seam + 保留;普通 REST 可选择领域 facade,而不是把两个协议的 handler 机械合并。 + +列表按 D-600 统一续读:自然标识 cursor、可选 limit、`next_cursor`,Job history 默认有限页;不为 bounded +path/components 查询虚构分页。错误保留 FastAPI/Pydantic 正常 detail,已确认批量操作保留逐项结果。 + +在 HTTP 边界实现一份 JSON / 原生 bytes / multipart 内容投影,使用 aiohttp MIME writer,不改 Resolver +返回值,也不创建内容缓存或下载会话。更新 `run.py` router 注册与 OpenAPI;动态 schema 仍归各领域 owner, +OpenAPI 不替代运行时 Extension 发现。确认新增路由没有覆盖已有精确或参数化路径。 + +## P4:独立 inkcre-cli + +在 `cli/` 建独立 PDM project、lock、src-layout 与 `inkcre-cli` entry point。按 D-598 使用 Click、Pydantic、 +同步 HTTPX、PyJWT;不依赖根项目环境来证明依赖完整。 + +先实现本机 connection、CLI 输入、单次调用的 CoreRESTClient、JSON/MIME 解码与结果/文件呈现,再接具体 +命令组。共同选项使用普通 Click 组合;命令直接映射 REST,不为每个 endpoint 新增一层 forwarding service。 +动态 `--schema` 来自实际 Core,offline help 不访问 Core。远端返回只转换,不建立响应验证模型。 + +命令覆盖以 [command-surface](command-surface.md) 和已确认 REST 页为清单,不能只完成 recall 后把管理命令 +列为后续。先贯通 get/resolver/recall/graph,再接 Job 与 Source/Cron,最后汇合 Agent/config/AI/profile、 +Extension、Peer;本机 connection 独立于远端 config。 + +Job wait 预算包含网络耗时,退出只结束本地观察;主动停止才发 abort 请求。compact JSON、stdout/stderr、 +部分失败、文件输出和可选续读共用已确认呈现路径;不新增自动翻页、全局响应 envelope 或交互确认框。 + +## P5:文档、检查与发布接合 + +CLI 用法、安装和维护归 `cli/` 文档;Core REST 与 Job 的本地实现归现有 Unit TDD;共享原则经 owner 判定后 +更新 Hub,再独立 bump Spoke ref。与实现一起更新,不等所有代码完成才补,也不在设计阶段提前改 durable truth。 + +依 [release-and-distribution](release-and-distribution.md) 修改 `scripts/release.py` 的显式项目发现、影响范围、 +新项目 bootstrap 与 Release PR paths。CLI 加入同一个 Towncrier/`release/next`,但不进入 Extension matrix。 +新增独立 CLI 检查和 main-only PyPI publisher,workflow 只组装既有 PDM 与仓库脚本,不承载长段业务逻辑。 + +预演发现 production Extension publisher 缺少 Preview 已有的 Toolkit wheel finalize,正式 Mail 0.2.0 +因此不能被 runtime 启用。P5 将两条 build path 接到同一现成 finalization 步骤,并在发布工具环境提供 +既有 Toolkit;不改 runtime 的 installed-record 合同,不覆盖旧包。本轮 Mail adapter 的既定 fragment +自然产生下一版本,正式验收需从 Registry 安装并启用该新版本。详情和候选验证见 +[环境证据](preflight-environment.md)。此修正是 CLI Extension 管理旅程的真实前置,不扩展为 release framework 重写。 + +根 Core gate、CLI 独立 lint/type/lock/build 与 client-web 受影响 package gate 分别运行。核对根格式化/类型检查 +和 Docker context 的边界,避免嵌套项目被误装进 Core。Feature PR 仅带各自 fragments,不提前 prepare 正式版本。 + +## P6:验收与 owner 分离交付 + +按 D-601 的 [四条旅程](acceptance.md) 执行手工或脚本黑盒验收;脚本复用既有语料与环境,不升级为自动化套件。 +先完成 preview/隔离运行时的联合证据,再进入获授权的 PR/发布流程。共享 runtime 若发生修正,应先发布并由 +Core pin;Core migration/REST 与 client-web worker 均交付后,才宣称完整控制能力可用。 + +CLI 正式 PyPI 发布前确认 Core production 已有新 REST;发布后在 checkout 外干净 pip 安装复跑读取与管理 +关键路径。runtime、Core、client-web、CLI 都记录实际 SHA/version,不以某一个 green check 替代整体旅程。 +不把多个项目发布做成原子事务;失败在实际 owner 修复并重跑,不引入跨项目回滚编排。 + +最后清理本轮明确创建的验收资源、记录剩余限制并整理 packet。只有这些交付证据齐备才关闭 unit;本页所列 +提交、PR、发布均等待其各自明确授权,不能从验收方案获批推导现在可执行。 diff --git a/tasks/knowledge-lifecycle-capabilities/units/cli-sink/input-discovery.md b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/input-discovery.md new file mode 100644 index 0000000..5c2f660 --- /dev/null +++ b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/input-discovery.md @@ -0,0 +1,104 @@ +# 动态合同发现与结构化输入 + +状态:按 [D-577](../../decisions/D571-D580.md) 获批;建立在 D-575/D-576 的命令边界上,不表示源码已实现。命令目录由 +[command-surface](command-surface.md) 维护,本页只说明如何找到操作、知道输入并执行。 + +## 两个发现层次,而非调用前置流程 + +根级与子命令 `--help` 在本机可用,说明用途、语法、参数承载方式与可观察效果,不依赖 Core 正在运行。 +Source type、Job type、Resolver method 和配置 schema 的实际合同则从所连接 Core 获取,不能用打包时的一份 +Extension 清单替代当前运行能力。 + +目录负责选择,默认提供 exact ID/name 和简短说明;选定操作的 `--schema` 输出其结构化输入的 JSON Schema。 +不在根 help 或每次目录查询中展开全部嵌套定义。需要方法选择时,`resolver methods` 可以按 Block 或 exact +Resolver ID 取得方法目录;实际 invoke 仍以 Block 为目标。 + +```sh +inkcre-cli source types +inkcre-cli source create --type --schema +inkcre-cli resolver methods block:42 +inkcre-cli resolver invoke block:42 --method get_solved_content --schema +inkcre-cli graph submit --schema +``` + +`--schema` 只查询合同,不执行对应操作,不要求提供尚待构造的业务输入。对已知类型、方法及合法参数,允许 +直接调用,不保存“已经发现过”的会话状态,也不强制先查一次 schema。目录的存在不证明一次执行必然成功; +例如持久化 Source type 与某个 Peer 当前加载的 runtime 是不同事实,不能悄悄互相替代。 + +## argv 选择操作对象,JSON 保留业务数据结构 + +简单 ID、type、method、检索模式和等待长度使用普通位置参数或 options。嵌套配置、GraphForm、Agent definition +以及动态方法参数使用 JSON,不把字段递归展开为一组 `--set a.b.c`,也不增加 YAML 或另一种输入 DSL。 + +已确认的显式承载: + +- `--input path.json` 读取文件;`--input -` 读取 stdin; +- `--input-json '{"refresh":true}'` 直接传入较小的 JSON 值,避免为一项参数先生成文件; +- 没有任何必填业务参数时可以省略输入,例如无参数的 Resolver 方法。 + +两种输入 options 表示同一份数据,不做多文件叠加或递归合并。`--json` 不用于输入,保留给后续设计的结构化 +输出选择。是否写文件、从管道取得数据或使用小型 literal 由调用者决定,CLI 不要求 Agent 遵循固定工作过程。 + +对这批命令,selector 放在 argv,JSON 只填写剩余业务字段;`--schema` 必须准确描述这份 JSON,而不是把 +selector 重复要求在 JSON 内。比如 Source create 的 `--type` 已确定类型,输入仍可有 nickname/storage/config; +Job create 的 `--type` 确定 handler,输入有 parameters/timeout_seconds。普通记录里的 type 字段不因此消失。 +CLI adapter 为 HTTP 组装请求,不把这一 shell 呈现方式强加给领域模型或数据库。 + +```sh +inkcre-cli source create --type --input source.json +inkcre-cli resolver invoke block:42 --method get_solved_content --input-json '{"refresh":true}' +inkcre-cli graph submit --input graph.json +inkcre-cli agent create --input agent.json +``` + +`source.json` 保留 `config` 的嵌套层级,不把 config 字段提升为 Source 根字段。`graph.json` 就是 GraphForm, +不再包一层 `graph`;Resolver input 就是选定方法的 arguments,不再嵌套 `method` 或 `arguments`。 +表单不包含数据库管理的 timestamps/ID;GraphForm 的负数本地 ID 是已定义的 producer 引用例外,不被删除。 + +## schema 对应提交位置,不只给一个类型片段 + +| 操作 | 输入 schema 如何取得具体形状 | +| --- | --- | +| `update block:…` / `update relation:…` | D-596 确认的相应 partial update form;selector 不出现在 JSON,省略字段保持,不能包含数据库管理字段 | +| `source create --type …` | 创建字段与该 Source type 的 config schema,后者保持位于 `config` | +| `source update `、`collect `、`backfill ` | 从 Source 的实际 type 选择该操作的合同,不混用保存配置与单次收集参数 | +| `resolver invoke --method …` | 从 Block 的 Resolver 取得该普通方法的参数合同 | +| `job create --type …` | 该 Job type 的 parameters schema 保持位于 `parameters` | +| `cron create --job-type …` | Cron 配置字段加对应 Job template;参数 schema 保持位于 `job_parameters` | +| `agent create` / `update ` | Agent definition 表单;model 与 Tool IDs 的选择提供配套发现,不枚举在每个响应中 | +| `config update ` | 由现有记录的 schema 取得 owner 的配置输入合同 | + +新建或替换 deployment config 时,schema ID 需要显式选择:建议通过 `config schemas` 发现,并用 `--schema-id` +传入。它与查询合同的 `--schema` 开关不同,不把 schema ID 改称 type。输入是配置 value 本身,不再次包装 +`value`。准确 replace/update 子命令与部分更新语义仍需后续逐组复核;不能假定所有 behavior config 都是 +`{"agent": int}`,媒体解释已经有按 modality 选择 Agent 的真实例外。 + +没有足够 selector 时,可以取得共同表单;其中未特化部分必须明确仍是通用形状,不能把开放的 object 伪装成 +已经完整说明 Extension 参数的合同。schema 查询应保留 defaults、required、enum、字段说明及可解析的 `$ref`。 +如何组合已有 owner 合同属于 Technical 工作,优先复用 Pydantic/已有 schema,不自制 JSON Schema 解析器。 + +## 校验与作用边界 + +CLI 继续使用 Pydantic,表达自身输入和连接配置,不手写一套同类解析/验证机制。Core 用实际 +owner 的模型校验动态业务输入;CLI 不复制 Extension 的 Source/Resolver 配置模型,也不因收到 JSON Schema +就预设需要将它反向编译成 Pydantic model。具体合同复用方式留给 Technical。 + +请求路径、payload 与内部模型的必要映射不改变模型 owner;错误保留调用者填写的字段位置, +例如 `config.protocol.parameters.host`,不把它改写成一个看似顶层的 `host`。不自动猜另一个 type/method, +也不在写入失败后自行修补并重发工作。 + +这里的 `--schema` 指输入合同,不由此推导一套动态结果的二次语义校验或完整协议生成框架。D-598 进一步确认 +CLI 信任 Core REST 返回,响应侧使用解码、必要转换与静态类型描述,不增加 Pydantic/JSON Schema 再校验。 +Pydantic 仍用于 CLI 自有输入。发现不执行 Resolver 内容读取, +因此不会仅为查看方法合同触发下载、AI 或 graph materialization;实际 invoke 的效果仍由该方法定义。 + +## 现有证据与待落地缺口 + +`app/schemas/source/main.py` / `SourceManager.sync_source_types` 已有配置、collect、backfill schemas; +`app/schemas/job.py` 已有 `parameters_schema`;`ResolverManager.get_method_contracts` 和 `invoke_method` +共用方法的 Pydantic input model;`ConfigContract.json_schema` 可投影 owner 的模型;GraphForm 已是 producer form。 +这些不等于完整普通 REST 已存在。需要在 Technical 补齐对外发现及必要的操作表单投影,不新增独立业务 registry, +也不要求 Extension 为 CLI 注册另一份 schema。 + +本轮用 ponytail 检查的是这个技术复用边界。文件/stdin、JSON 和模型 schema 已能承载需求;新增内容限于 CLI +呈现和现有 domain 的 HTTP 接口,不在 Product 阶段选择 CLI 库或设计泛用动态命令生成框架。 diff --git a/tasks/knowledge-lifecycle-capabilities/units/cli-sink/job-control.md b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/job-control.md new file mode 100644 index 0000000..ad2ae71 --- /dev/null +++ b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/job-control.md @@ -0,0 +1,110 @@ +# Job 观察与停止 + +状态:范围已获 D-576 确认,等待/停止语义与集中检查机制已获 [D-580](../../decisions/D571-D580.md) 确认,定位为 best-effort。 +检查周期、精确接口与取消传播仍需技术设计及预演;尚未授权实现。[Job REST 与有界观察](job-rest.md) +已按 D-587 确认具体 HTTP 与 CLI wait 方案,不改变本页已确认的运行语义。 +CLI 仍只调用 Core REST。Job 在哪个 Peer 执行,不改变 CLI 的命令,也不需要调用者寻找 worker。 + +## 一个 Job,分别观察与控制 + +```sh +inkcre-cli job get 301 +inkcre-cli job wait 301 --for 30s +inkcre-cli job abort 301 +inkcre-cli job wait 301 --for 10s --json +``` + +`get` 取得当前持久记录。`wait` 在调用者指定的时间窗口中观察同一 Job,进入终态可以提前返回;预算用尽则 +返回最新已观察记录,即使仍是 pending/running。它不重新创建 Job、不改变执行 timeout,也不在命令结束或 +被中断时自动 abort。没有新状态时可以继续下一次 wait。轮询过程走 stderr,stdout 只返回观察结果。 + +Job `state` 仍由 Handler 提供;有何进度、checkpoint 或错误信息取决于它实际持久化了什么。CLI 不把运行 +时间伪装成完成百分比,也不因没有进度字段引入统一 progress 协议。等待过程与 Job 本身的结果是两件事: +正常观察到 failed/timed_out,或者等待预算用完,不等于 CLI 观察操作失败。网络/协议错误仍报告实际失败。 +等待默认预算与 CLI 基于 GET 的组合已按 D-587 确认;准确时长解析语法与完整 exit code 表仍待统一收敛。 + +## 请求停止与执行已经结束分开 + +| 调用时的 Job | `abort` 的行为 | +| --- | --- | +| pending | 原子关闭为 aborted,使其不能再被 claim;不需要等待执行端 | +| running | 持久化停止请求,由实际执行端取消工作;返回当前记录,不把受理冒充已停止 | +| 任一终态 | 返回已有终态,不重写结果,不产生重试或另一个 Job | + +运行中的 Job 在执行端结束前仍是 running。增加一个 Job-owned `abort_requested: bool` 表达请求,不把 +控制信息混入 Handler-owned `state`,也不增加整套中间状态。执行端处理请求并结束执行后再关闭为 aborted。 +示意字段子集为紧凑 JSON:`{"id":301,"status":"running","abort_requested":true}`;完整响应仍是 Job 记录, +不额外包装 receipt。字段与 runtime 集中检查策略已按 D-580 确认。 + +重复请求不会重复创建控制工作。停止与完成竞争时保留已经写入的终态,不把 finished 回写成 aborted。 +停止请求本身不清空执行记录、不释放 Cron 的非终态占位;因此不会仅因请求受理就允许下一次 Cron Job 重叠。 +若执行端已消失,不能声称它确认了停止;保留请求事实并沿用已有 timeout 收敛,不增加重领或 retry。 + +停止不回滚已写入的 graph/state,也不承诺撤回已经发出的远端请求。合作式取消不是杀进程或跨系统事务。 +具体 Handler 的阻塞 SDK、后台子任务、Agent tool 执行如何结束,须在预演中核验;不能只取消一个等待它的 +协程就宣布全部工作已停止。若 Handler 尚未结束,abort_requested 与 running 可以并存。 + +## 多 Peer 仍通过数据库协同 + +```text +CLI → Core REST → JobManager 写入停止请求 → jobs + ↑ + 实际执行端 JobManager 读取请求 + → 取消本机执行 → 结束后关闭 Job +``` + +REST 所在 Peer 不一定是执行端。执行端可以是另一 core-py,也可以是 client-web;因此不能只维护收到 HTTP +请求那台机器的 task map,也不为停止另建 Peer delegation capability、broker、控制 Job 或执行端路由表。 +复用各端现有 Job worker 检查机制,对本机 active Job 读取停止请求,使用各自已有异步运行模型取消。 +检查间隔与结束确认的具体机制进入技术设计;不在产品层承诺即时或强制终止。 + +### 停止意图与通知方式分开 + +2026-09-13,Sir 曾质疑执行端需要监听/检查字段是否不够优雅;澄清责任和成本后,接受集中检查作为 best-effort。 +`abort_requested` 表达持久化意图,不决定使用轮询还是推送。跨进程停止必须让执行端收到某种信号,不能 +仅修改记录就令正在运行的代码停止;但这不要求每个业务 Handler 自行查询数据库。 + +把观察和取消集中在执行端 JobManager:一次批量检查本机 active Job 的停止请求,再对相应执行句柄 +发出取消。没有本机 active Job 时不发这一查询。不为每个 Job 建一个数据库轮询循环,也不在 collect 的 +每个 item、Organization 的每个 seed 中散布 `if job.abort_requested`。Handler 只遵循通常的异步取消和 +资源清理合同;浏览器 Handler 继续使用已有 AbortSignal,而不理解数据库控制字段。 + +机制比较保留以下真实成本: + +- 批量轮询复用当前两端已有的数据库访问与 worker,代价是额外查询和停止响应延迟;封装不会消除这些成本。 + 当前发现 pending Job 的 30 秒周期不自动成为主动停止的响应合同,具体检查周期仍需设计。 +- PostgreSQL LISTEN/NOTIFY 可替代周期查询来提示变化,但 LISTEN 绑定数据库 session,断开后注册消失; + 初始/重新连接时仍要读取持久状态。见 [PostgreSQL 官方文档](https://www.postgresql.org/docs/current/sql-listen.html)。 + 当前浏览器数据通道是 `@supabase/postgrest-js`(`packages/core/src/base/db-api.ts`),不是这种 PostgreSQL + session;改用通知需要补足浏览器的通知转发路径。`app/settings.py` 也已表达不能假定 LISTEN/NOTIFY 可用。 +- 直接推送到执行 Peer 可以降低通知延迟,但要识别执行端并提供可达控制入口;当前 Job 没有执行 Peer 字段, + 浏览器 worker 也没有对应入站控制服务。它不是无成本替换一个字段,并会改变当前仅通过数据库协同的边界。 + +因此选择集中在 JobManager 的批量检查,不为停止另建通知基础设施。该结论按 ponytail 比较既有机制与新增 +成本,并由 python-backend-code 检查 runtime/Handler 责任分离;已获 D-580 确认。best-effort 不消除实际取消 +执行的责任,也不把持久化请求或终态当作强制终止所有外部效果的证明。 + +## 核验依据与实现前压力 + +2026-09-13 只读检查确认: + +- `app/business/job.py` 已拥有 claim/close 的条件更新、`asyncio.timeout`、pending scanner 与 overdue 收敛; + `run.py` 每 30 秒唤醒 `JobManager.check`。没有主动停止 API、请求字段或取消处理。 +- `app/schemas/job.py` 已有 aborted 终态,但 enum 本身不会停止执行;Job `state` 是 Handler 的结果数据。 +- `client-web/packages/core/src/job/manager.ts` 已有 active set、30 秒 worker 与每次执行的 AbortController, + 当前只用于 timeout;`source/manager.ts` 将 AbortSignal 传给 collect/backfill。 +- `client-web/apps/client-web/src/core.ts` 会启动该 worker。它不是纸面上的未来 consumer,需要在本 unit + 配套同一停止协议;没有因此增加 client-web 管理 UI 的需求。 + +2026-09-13 的验收准备进一步区分了 worker 存在与实际业务执行资格:在 client-web 的 packages、extensions、 +apps 跟踪源码中,仅找到 source/manager.ts 的两个通用 Job handler 注册,没有找到实际 SourceImplementation +的注册调用。因此不能仅凭 worker 已启动,承诺可用既有 Mail/Twitter Source 做浏览器 collection 验收。 +双 core-py 验证完整跨 Peer 链路;浏览器侧使用公开 worker 边界的有限脚本验证,准确区分组件证据和业务旅程, +具体提案见 [Acceptance](acceptance.md)。本次没有启动浏览器或核验远程动态 Extension 的运行清单。 + +后续技术设计/预演应走过 pending claim 与 abort 竞争、running 取消与自然结束、timeout、进程退出与子任务 +清理。保持 DB 条件更新为生命周期边界,不以 application 先读后写替代。需同步 schema/migration、两个 +JobManager 与返回记录;这些是本提案的预计影响,不是已获授权的源码改动。 + +本轮按 python-backend-code 核对真实异步执行和取消边界;按 ponytail 保留现有数据库协同及两端异步机制, +不增加新的调度系统。当前阶段不增加自动化测试;验收仍在整体 Acceptance 阶段按真实黑盒边界设计。 diff --git a/tasks/knowledge-lifecycle-capabilities/units/cli-sink/job-rest.md b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/job-rest.md new file mode 100644 index 0000000..c207d5a --- /dev/null +++ b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/job-rest.md @@ -0,0 +1,85 @@ +# Job REST 与 CLI 有界观察 + +状态:2026-09-13 按 [D-587](../../decisions/D581-D590.md) 获确认,尚未实现。以 D-575/D-580 的等待、停止和执行端集中检查为基础; +本页收敛 HTTP 接入与 CLI 组合,不重新定义 Job 生命周期,也不授权实现。运行语义统一见 [Job 控制](job-control.md)。 + +## 创建、观察与请求停止 + +| 普通 REST | CLI 用途 | 返回 | +| --- | --- | --- | +| GET /job-types | job types,发现 deployment 已知的 exact 类型 | 类型目录 | +| GET /job-types/{type} | job create --type … --schema 所需的动态参数合同 | JobType 的参数 schema 与默认执行期限等字段 | +| POST /jobs | job create | HTTP 201,直接返回持久化后的 Job 记录 | +| GET /jobs | job list | 最近的 Job 记录列表,允许 type/status 过滤 | +| GET /jobs/{id} | job get,以及 wait 的每次观察 | HTTP 200,直接返回 Job 记录 | +| POST /jobs/{id}/abort | job abort | HTTP 200,直接返回处理停止请求后观察到的 Job 记录 | + +列表按 D-597 的 [limit/cursor 合同](list-error-contract.md) 返回,不据此宣称返回全部 Job 历史。 +单条不存在为 404;创建/查询/停止 +没有另一份 success/status receipt。实际 Job 字段保留 type、parameters、state、timeout_seconds、status、 +created_at、started_at、closed_at,并加入 D-580 已确认的 abort_requested;不虚构当前没有的执行 Peer 字段。 + +POST /jobs 的请求复用已有 JobCreateForm: + +```json +{"type":"core.source.collect.v1","parameters":{"source":12,"config":{}},"timeout_seconds":300} +``` + +CLI 中 type 仍由 --type 提供,JSON 输入只包含 parameters 与可选 timeout_seconds;--schema 显示的也是 +这一输入位置,不要求填写数据库字段。parameters 的动态合同由 Job owner 提供,Core 沿用 JobManager 的 +本地 Handler 模型或持久化 JobType schema 校验。CLI 不再构造一份 Extension 参数校验器。 + +创建只确认执行机会已持久化,不等待执行、不在 HTTP handler 内运行工作,也不要求接入 Peer 的 can_handle +通过。当前 JobManager 已支持本机没有 Handler 时按持久 schema 创建 Job;其它 capable Peer 可正常 claim。 +GET job-types 因此读取 deployment catalog,而不是过滤成接入 Peer 此刻可执行的清单。目录也不承诺现在 +有在线执行者,或该参数一定能被某个执行者成功处理。 + +POST abort 不需要业务 body。重复调用或 Job 已经结束时仍返回真实记录;不开放通用 status/state PATCH, +也不使用 DELETE 来混淆停止执行与删除历史。具体 pending/running/terminal 转移沿用 D-580,并由 JobManager +及数据库条件更新完成,HTTP 层不复制生命周期分支。abort_requested 与 running 同时出现是合法结果。 + +D-595/D-596 明确:CLI 显式收集、整理均受理为相应 Job。Source 便捷入口沿 D-588;organization ruminate 直接 +使用 POST /jobs 和新增的 explicit rumination 类型,详见 [任务入口](organization-write-rest.md)。已有 automatic +类型的 max_seeds 参数不是指定 Block 的替代物。上述命令不增加执行 Peer 选择,不调用 Peer inbound。 + +## wait 是 CLI 对 GET 的有界组合 + +job wait 默认最多观察 30 秒,可通过 --for 指定其它时长;进入任何终态时提前返回。每次 wait 先进行 +一次 GET,未结束时在预算内以约 2 秒间隔继续查询,不新增 /wait endpoint、服务端等待会话、SSE 或 WebSocket。 +CLI 的状态查询与执行端 JobManager 集中读取 abort_requested 是不同职责,不能混成一个轮询机制。 + +```sh +inkcre-cli job wait 301 --for 30s --json +inkcre-cli job abort 301 --json +inkcre-cli job wait 301 --for 10s --json +``` + +时长字面值的最终解析语法随 CLI 库选型收敛,示例不要求手写 duration DSL。观察预算按单调时钟计算,覆盖 +本次 HTTP 请求和查询间隔,不在每次 GET 后重新计时;剩余预算约束在途读取的等待时间。 + +预算用完时返回最后成功观察到的记录,不宣称它是此刻数据库的最新状态。若预算内尚未取得任何记录,应 +报告观察超时,不能伪造 pending/running。终止原因只是本次观察预算用尽时,可返回已有记录;网络断开、 +认证失败或 HTTP 错误不是预算正常耗尽,不以旧记录冒充观察成功。stdout 保留单个 Job 记录,过程在 stderr。 + +正常读取到任何 Job 状态,包含 failed/timed_out/aborted,或正常等到预算结束,CLI 观察操作均以 exit 0 +结束。调用者检查 Job.status 判断后台工作结果。连接、协议或请求失败使用非零退出码;具体退出码已按 +D-597 确认。Ctrl+C 或命令结束只结束本次观察,不自动发 abort,不修改 Job 的执行期限。 + +## 现有实现与预演压力 + +2026-09-13 只读核验: + +- app/schemas/job.py 已有 JobCreateForm 与 JobTypeModel 的 parameters_schema/default_timeout_seconds。 +- app/business/job.py 的 create 已与 _prepare/can_handle/_claim 分开;持久类型可在没有本地 Handler 时用于创建。 +- 当前 app/routes 中没有 Job 管理 router;app/routes/source.py 的 collect/backfill 直接创建 Job 并同步调用 + JobManager.check。D-588 已确定将这次附带扫描移到受理响应之后,不能让已提交 Job 因后续扫描失败而被 + 描述为未创建;这是已确认的实现落点,不再作为未决产品选择。 +- Job 返回模型没有 updated_at 或执行 Peer,不为输出一致性添加这些字段。client-web 的实际 Job worker 与 + AbortController 已在 Job 控制文档列为配套落点,不因 CLI 通过 core-py 请求就跳过另一端。 + +后续预演需要覆盖创建已提交但响应中断时不自动重发、pending/running/terminal 的观察与停止竞争、观察预算 +覆盖慢请求,以及观察命令结束后工作继续。执行端的集中检查周期和子任务取消仍在 Job 控制的实现前压力中。 +本提案没有为发现目录、路由表或 DTO 新增自动化测试,也没有开展数据库操作。 + +本轮按 python-backend-code 保持 HTTP 边界薄,按 ponytail 复用 JobCreateForm 与 JobManager,将 wait 留作 +已有 GET 的有界调用组合,不建立第二套任务生命周期或等待服务。 diff --git a/tasks/knowledge-lifecycle-capabilities/units/cli-sink/list-error-contract.md b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/list-error-contract.md new file mode 100644 index 0000000..18bfa9c --- /dev/null +++ b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/list-error-contract.md @@ -0,0 +1,116 @@ +# 管理列表与错误输出 + +状态:按 [D-597](../../decisions/D591-D600.md) 确认,并将长输出分页明确为各查询指令的通用模式。 +这里只补各已确认命令共同需要的结果边界,不改变领域业务, +也不授权源码实施。当前包版本为 FastAPI 0.139.2、Pydantic 2.13.4。 + +## 分页依据输出规模,而非领域白名单 + +长输出分页适用于各查询指令,不能限定为 Job 历史特例。共同依据见 +[通用模式](../../common-patterns/agent-tools.md):让调用者按预算逐步读取,同时保留准确结果边界与续读位置。 +不同查询复用各自 owner 的机制,不因此要求同一种 cursor 或新建通用分页框架。 + +本轮的 Source、Cron、Agent definition、Peer、Extension、deployment config、AI model、embedding profile +列表,以及 Source/Job type、Resolver、Agent Tool、config schema 目录,默认完整返回当前小清单,不设隐藏 +截断。D-600 补齐可选分页,并将枚举结果从裸数组改为“领域清单 + next_cursor”;具体合同统一见 +[查询续读](query-continuation.md)。不靠全局 total/Page 抽象解决,也不改变列表元素的既有 DTO。 + +列表元素保持各页已确认的 DTO,不为分页讨论重做字段投影。按自然 identity(id、key 或 Extension coordinate) +升序输出,不引入任意字段排序 DSL;默认可读呈现可以只显示选择所需列,JSON 保留完整列表结果。 + +## Job 历史的具体落点 + +Job 每次派发都会增加记录,GET /jobs 使用 limit 与 cursor,CLI 为: + +```sh +inkcre-cli job list --limit 20 --json +inkcre-cli job list --limit 20 --cursor 281 --json +``` + +按 id 降序取最近分配的记录;cursor 为上一页最后返回的 Job ID,下一页选取 id < cursor。limit 默认 20, +使用正整数,不额外发明一套 opaque cursor 编解码。type/status 过滤沿 D-587,翻页时由调用者保留同一过滤。 + +HTTP 与 CLI JSON 结果为 `{"jobs":[...],"next_cursor":281}`;没有更多记录时 next_cursor 为 null,空表则 +为 `{"jobs":[],"next_cursor":null}`。数据库取 limit + 1 行判断是否还有下一页,不计算 total,不开启跨请求 +snapshot,也不增加 --all 或自动遍历整个历史。默认可读输出同样显示下一页位置,不能悄悄忽略续页信息。 + +ID 是分页位置,不等于提交时间的严格先后。新分配的 Job 不会挤动较旧页的位置,但并发提交、状态变化或 +删除仍会改变后续查询可见集合;这是一组实时列表查询,不是历史快照。没有因此引入 version 或游标存储。 + +图邻域已有 next_cursor;graph path/components 和 lexical/semantic retrieval 各有自己的查询边界。 +查询范围与结果交付分页不是同一参数,不能用翻页暗中增加 top-k 或扩大图探索。长文本/多模态仍沿已确认的 +完整内容文件交付,不能为取得下一段而重新执行 Resolver。这里不把文件交付冒充新的查询分页协议。 + +## 普通 HTTP 沿用既有错误表达 + +HTTP 错误保留 FastAPI 的 `{"detail":...}` 形状。输入校验沿 Pydantic 的 loc/msg/type 等原生字段,保持结构, +不把整份 ValidationError 压成无法定位的单个字符串,也不为 CLI 建立 Core 全局错误码表或新的错误中间件。 + +准确的异常映射在相应普通 REST 边界完成:输入不合法使用 422,目标不存在使用 404,已有状态冲突使用 +相应 409;保留实际 HTTP 状态和领域原因,不将所有 ValueError/IntegrityError 机械映射为同一状态。 +Unexpected errors 保持真实 5xx 与服务端 traceback,不吞掉异常、伪装成功或扩大为全库错误体系重构。 + +只有本次请求输入的验证失败才转为 422;不能把方法内部或输出处理中的 Pydantic 错误伪装成用户输入错误。 +动态输入由现有 owner 验证,REST 边界只补上真实请求层级。例如 Job parameters 的 source 错误应在 +body.parameters.source,而不是 body.source。这是路径投影,不是第二次验证。 + +## CLI 的输出流与退出码 + +成功结果在 stdout,失败诊断在 stderr。已知的调用错误在 --json 下输出一条紧凑诊断对象;HTTP 错误保留 +http_status 与 detail,本地输入、连接或文件错误没有 http_status。CLI 不为每种领域增加退出码或重试策略。 + +CLI 将结构化校验 loc 投影回调用者实际提交的位置:config replace 的 JSON 是 value 本身,不能提示调用者 +在输入文件中凭空补一层 value;argv selector 的错误则指向对应参数。只沿该命令已知的 HTTP 映射增删路径 +前缀,不自制校验解释器,不解析错误消息来猜字段。其它 Pydantic 字段和服务端原因保留。 + +例如 config replace 的某个字段错误可以在 stderr 表示为: + +```json +{"http_status":422,"detail":[{"loc":["agent"],"msg":"Input should be a valid integer","type":"int_parsing","input":"abc"}]} +``` + +若对端返回的不是 JSON,诊断保留其状态与实际文本,而非改称“连接失败”。响应已取得但文件保存失败也应 +报告实际交付故障,不自动重复 Resolver 调用。未预料的 CLI bug 保留原生异常诊断,不为了漂亮错误体而 +全局捕获并隐藏 traceback;结构化的已知错误合同不等于所有内部日志都必须变成 JSON。 + +| Exit code | 意义 | +| --- | --- | +| 0 | 本次命令正常完成,包括空检索、没有路径、读取到 failed Job 或 wait 正常用尽观察预算 | +| 1 | 请求、执行或内容交付失败;也包括已声明批次中存在逐项错误 | +| 2 | CLI 用法或本机输入校验失败,尚未发出业务请求 | +| 130 | 用户中断 CLI;不因此中断已经受理的远端 Job | + +HTTP 422 属于接入 Core 对请求的拒绝,退出 1;本地 argv/JSON 输入校验失败才退出 2。help/schema 成功查询 +正常退出 0。HTTP 204 没有业务结果,默认不输出成功包装,JSON 模式输出 null。 + +批次保留 D-583/D-594 的逐项结果:某项缺失或某种 recall mode 失败时,成功项和失败项都留在 stdout, +最终退出 1。stderr 只需说明存在逐项错误,不再复制整份结果,也不自动重跑成功项。即使全部项失败,仍 +保留相同批次形状。整批共享的 HTTP/连接故障没有批次结果,stdout 留空,诊断在 stderr。 + +只有 CLI 自己已声明的批次错误变体参与这个判断,不递归搜索任意 Resolver 输出中的 error/status 字段。 +读取到 Job.status=failed 与该次读取失败不同;同样,业务结果中的 not_found/limit_reached 不是默认的 +命令失败。输出结果后仍可非零退出,调用者不能因 exit 1 就假设没有任何成功项或实际效果。 + +## 依据与实施前检查 + +- app/schemas/job.py 的 JobModel.id 为数据库分配的 bigint PK;当前 JobManager 没有面向历史列表的接口。 + 对应带过滤和 cursor 的查询应留在 JobManager,不把 SQL 放入 route。 +- app/business/peer/main.py::get_all 与 app/routes/extension.py::list_extensions 已返回完整清单;其它 + 管理列表仍需在各 owner 接出,不能以现有 HTTP 缺口推定必须建设通用 query/repository 层。 +- app/routes/deployment_config.py 与 extension.py 已使用 HTTPException(detail=...) 和 Pydantic errors; + 其中重复读库校验的修正归 D-591,本页不改变该边界。 +- [FastAPI 错误处理](https://fastapi.tiangolo.com/tutorial/handling-errors/) 提供 detail 与默认异常 handler; + [Pydantic 错误结构](https://docs.pydantic.dev/latest/errors/errors/) 已能表达字段路径与原因。 +- [gh run list](https://cli.github.com/manual/gh_run_list) 默认限制最近 20 次执行,是限制历史查询而非裁剪内容 + 的参考;不复制其 workflow 特有过滤。我们的 cursor 是本地合同选择,不宣称 gh 使用同一 API。 +- [gh 的退出码](https://cli.github.com/manual/gh_help_exit-codes) 将取消定义为 2、认证需求定义为 4;本方案 + 不照搬,而为 Python CLI 保留用法错误 2、中断 130,HTTP 认证状态仍可由诊断读取。库选型需核对实际适配。 + +后续预演检查一次完整 stderr/stdout/exit-code 组合、分页 SQL 与路径投影;优先静态机制,必要时做隔离 +脚本实验或真实黑盒 journey。不为结果包裹、字面值和字段映射新增自动化测试。 + +长输出检查应覆盖管理/能力清单、Job 历史、Recall、图查询、批量实体和 Resolver 结果,分别标明查询分页、 +本次结果的分批呈现或完整文件读取。只核验 Job cursor 不能证明已经满足 D-597 的通用长输出原则。 + +逐项覆盖与清单响应调整已按 D-600 确认,见[查询续读](query-continuation.md)。错误、退出码和 Job 分页 +决定保持;旧裸数组形状仅对这些枚举接口被明确替换。 diff --git a/tasks/knowledge-lifecycle-capabilities/units/cli-sink/organization-write-rest.md b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/organization-write-rest.md new file mode 100644 index 0000000..1ae8704 --- /dev/null +++ b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/organization-write-rest.md @@ -0,0 +1,85 @@ +# 收集、整理的 Job 入口与实体修改 + +状态:[D-595/D-596](../../decisions/D591-D600.md) 已确认收集/整理通过 Job 受理、Block/Relation 原地编辑与删除, +以及下文的具体技术方案;尚未实施。此前直接调用 rumination 门面与排除实体修改的 +提案已撤回,不再作为执行依据。 + +## 显式整理提交 Job + +```text +CLI organization ruminate + → 普通 REST POST /jobs → 持久化 Job 并返回 201 + → capable Peer 的 JobManager claim + → Organization-owned handler → ruminate_local(block) +``` + +保留 `inkcre-cli organization ruminate 42` 这个便捷命令,将其转换为以下请求,不另增动作路由: + +```http +POST /jobs +Content-Type: application/json + +{"type":"core.organization.rumination.explicit.v1","parameters":{"block":42}} +``` + +新增的 exact Job type 已按 D-596 确认;它的 parameters 使用已有 RuminationRequest 的 block 输入,handler 由 +Organization 拥有。现有 `core.organization.rumination.automatic.v1` 只接收 max_seeds 并自行选取候选,不能 +拿它冒充处理指定 Block 的任务。两个类型复用已有 rumination 实现,不新增 dispatcher 或独立结果表。 +执行资格复用 Organization 的本地 Agent 配置可用性检查;claim 后调用 non-delegating local path,不让 Job +再次走 Peer delegation。默认执行期限沿现有 Organization Job 的 1800 秒,可由 JobCreateForm 覆盖。 + +CLI 返回实际 Job 记录,后续直接使用 D-587 的 job get/wait/abort。不增加 --peer 或执行 Peer 参数:REST +接入点负责受理,实际执行者由已有 Job claim 机制选择。手动派发与自动候选选择是不同维度;用户仍可通过 +job create 手动派发 automatic 类型,也不为 explicit 类型新增 Cron 禁用规则。 + +Source collect/backfill 继续使用 D-588 的便捷 REST 入口;它们本来就调用 JobManager.create,不执行 Source +业务。所有本轮 CLI 收集/整理入口都受理为相应 Job,而不是长时间保持业务执行 HTTP 请求。 + +此前拟议的 POST /blocks/{id}/ruminate 不建立。Block 是 Organization 的输入,不决定行为的领域归属。 +实际已有的 POST /organization/ruminate 是 Peer inbound,保持原合同供既有消费者使用;不能把它改成提交 +Job,也不能因为使用 HTTP 就把它当作 CLI 的普通 REST。D-593/D-594 已确认的管理与检索接口不因此改成 Job。 + +## 原地编辑与删除 + +与根级 get 的实体引用形式一致,使用 update/delete;本批每次修改一个明确实体。 + +| CLI | 普通 REST | 输入 | +| --- | --- | --- | +| update block:42 --input patch.json | PATCH /blocks/42 | 可选 content、resolver、storage | +| update relation:8 --input patch.json | PATCH /relations/8 | 可选 from_block_id、to_block_id、content | +| delete block:42 | DELETE /blocks/42 | 无 body | +| delete relation:8 | DELETE /relations/8 | 无 body | + +PATCH 只含提交字段,省略保持;storage:null 明确清空引用,其余字段不能显式设为 null。不接收数据库管理的 +id/timestamps。更新返回 HTTP 200 与 D-583 一致的普通实体记录;Relation endpoint 字段只在 REST 边界映射 +为 Manager 使用的 from_/to_。删除成功 204,不另造成功对象;目标不存在 404。 + +Block content 编辑的是存储表示,Storage-backed Block 仍填写 pointer;不把 hydrated content 当作原地写入 +接口,也不隐式增加 Storage bytes 写入或 Resolver 调用。删除 Block 的关联 Relation、索引记录等沿已有 FK +行为处理,不递归删除相邻 Block 或 Storage bytes。CLI 不另做一次图遍历和客户端级联。 + +新增信息继续使用 D-594 的 graph submit / POST /graph:GraphForm 已能表达一个 Block、多个互联 Block,或只 +在既有 Block 间增加 Relation。无需为同一新增行为再接出重复入口;已有实体修改则使用上述明确的 PATCH/DELETE。 + +## 旧 Block PATCH 的输入丢失 + +app/routes/block.py::edit_block 目前接收 BlockModel,再无条件传递 body.content/resolver/storage。 +BlockModel.storage 缺省为 None,但 BlockManager.edit_block 用 Undefined 区分省略与清空,导致请求未提交 +storage 也会清空原引用。使用记录作为表单还混入了数据库管理字段。 + +本轮替换为 partial update form,只将实际提交的字段交给现有 manager,保留 Undefined 语义;输入边界只 +验证本次表单,不先重验旧记录。内部调用者无需改为依赖 REST DTO,也不为映射增加第二个持久化模块。 + +## 依据与预演落点 + +- app/business/organization/jobs.py 的 rumination handler 使用 AutomaticOrganizationJobParameters; + app/business/organization/rumination.py 已有可复用的 ruminate_local 与自动选取候选路径。 +- app/routes/source.py 的 collect/backfill 已创建 Job;D-588 将响应前的 worker scan 移到受理之后。 +- app/business/info_base/block.py 已有 edit_block/delete;relation.py 已有 update/delete。问题是 REST 表单 + 与 CLI 操作缺口,不是缺少 InfoBase 的持久化能力。 +- app/schemas/info_base/relation.py 的 endpoint FK 使用 ON DELETE CASCADE;索引记录同样级联,Source 的 + block projection 引用为 SET NULL。不存在为了删除 Block 而读取 Resolver/Storage 的需要。 + +后续预演从 CLI 创建 Job 的请求体一路检查到实际 handler,确认没有直接执行或委托执行的旁路;再检查 +PATCH 的实际提交字段、更新返回投影和 DELETE 的数据库效果。验证优先静态机制和脚本黑盒边界,不为字段 +逐个创建实现级自动化测试。 diff --git a/tasks/knowledge-lifecycle-capabilities/units/cli-sink/output-presentation.md b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/output-presentation.md new file mode 100644 index 0000000..b4cad78 --- /dev/null +++ b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/output-presentation.md @@ -0,0 +1,67 @@ +# 结果呈现与可组合性 + +状态:输出原则已获 [D-578](../../decisions/D571-D580.md) 确认;D-579 确认大内容交付与紧凑 JSON; +D-597 已确认错误/退出码合同,并将长输出分页与部分失败处理提炼为通用模式。 +本页先决定默认呈现、机器可用结果,以及它们必须保留的业务信息,不设计所有命令的渲染模板。 + +## 按结果语义呈现,显式选择 JSON + +默认输出供 Human/Agent 阅读的内容,`--json` 返回该命令稳定的结构化结果。不能仅凭 stdout 是否为 TTY +判断消费意图:Agent 常通过 pipe 捕获 stdout,但仍可能更适合阅读普通文本。TTY 只影响颜色等终端表现,不 +切换结果格式、字段语义或业务行为。默认不启交互 pager,不让脚本因为等待按键而停住。 + +| 结果 | 默认呈现重点 | +| --- | --- | +| Recall | 可继续寻址的实体引用、命中依据/已有摘要、所属检索模式;保留模式内顺序,不把异质分数伪装成统一尺度 | +| `get` | 明确实体种类的记录字段及调用者选择的 content;不额外调用 Resolver 生成解释 | +| Graph query | Block/Relation 引用、实际方向、relation content 及查询边界,不展开整幅图的 solved content | +| Resolver method | 单项字符串保留自然文本;对象/数组保留 JSON 结构,不为每种 Resolver 增加 CLI renderer | +| 管理列表与详情 | 列表提供选择所需的 ID 与关键字段;详情保留该对象的实际配置和状态 | +| 写入与执行控制 | 返回真实创建/修改的对象引用、必要结果;区分 Job 创建、运行与结束,不添加无信息量的成功包装 | + +默认可读不意味着每条命令都必须把 JSON 再翻译成散文。JSON 本身合适的结果可以直接呈现 JSON;已经返回 +文本的 Resolver 方法也不需要为了可读而改写文本。多项结果必须保留逐项身份,不能拼成失去归属的一段正文。 + +`--json` 提供正式的脚本消费合同,不是提取终端表格,也不是承诺透传任意 HTTP body。它保留该命令的领域 +结果及可继续操作的引用;批量入口不因只有一个结果而临时改变顶层形状。具体 DTO 在命令级设计,不套一层 +通用 `{success, data, metadata, next_actions}`。按 D-598,CLI 信任 Core REST 的业务返回,只解码与转换, +不调用 Pydantic 响应校验;也不能在转换中静默丢失需要交付的字段。 + +JSON 模式不做 pretty print,使用紧凑序列化。格式紧凑不改变字段、内容完整性或业务语义;默认可读输出 +不由这条机器格式要求反向决定。 + +## 格式选择不改变事实 + +普通输出和 JSON 都必须保留对调用者决策有用的边界:是否截断、下一页位置、没有匹配、缺失引用、搜索上限, +以及真实 Job 状态。默认摘要与完整记录有不同目的,但不能把摘要描述成完整信息,也不能只在 JSON 中告诉 +调用者还有未读内容。不得为了排版整齐把两个状态合成一个“失败”。 + +实际依据:`app/schemas/graph_navigation_retrieval.py` 的 path result 区分 `not_found` 和 `limit_reached`, +neighborhood 有 `next_cursor`;`lexical_retrieval.py` 的 match 有 evidence/excerpt/rank,semantic 的 score +具有自己的 profile/metric 语义。CLI 呈现不能消除这些区别,也不要求 domain 为终端输出重新定义结果。 + +同一实体在多个 recall mode 中命中、get 的 raw/hydrated 默认选择与批次 DTO 已分别由 D-583/D-594 确认, +详见相应命令合同。本页不另建一份形状 authority。 + +## stdout 是结果,stderr 是过程 + +stdout 只放该次命令的结果;连接过程、等待进度和诊断走 stderr,不能污染重定向得到的 JSON 或原文。 +错误仍保留能定位问题的原因;分流不意味着消音或损害可观测性。 + +支持 stdout 重定向与既有 `--input -` 的组合,但不声称任意前一命令结果都能原封不动作为下一命令输入。 +例如 Job 返回记录与下一次命令接收的 Job ID 是不同形状;可脚本消费的稳定字段足以组合,不因此增加内嵌 +jq、模板语言或另一套管道协议。字段名与引用保持一致,不要求 Agent 猜别名之间的对应关系。 + +批次的成功项和失败项仍在结果中保留关联,日志不代替结果。退出码表达 CLI 调用的完成语义,不把所有领域 +结果都折叠为布尔值。限时 wait 返回 running 不是错误;Job 失败与观察 Job 成功是不同事实。准确 exit code +规则及批次部分失败的呈现已按 D-597 确认,见 [管理列表与错误输出](list-error-contract.md)。分页与部分失败的 +共同依据维护于 [common patterns](../../common-patterns/agent-tools.md),不是仅适用于 Job 或某一种 Recall 的特例。 + +## 大内容与多模态交付 + +文本、bytes 与嵌套多模态结果不能只靠 `--json` 解决。已确认的 [内容交付](content-delivery.md) 区分有限呈现、 +完整内容保存与文件寻址;具体 [HTTP 编码](content-transport.md) 与 [文件输出](file-output.md) 已获 +D-584–D-586 确认。默认输出不能悄悄丢内容,也不能把 binary 强行 decode 为文本。 + +技术复用按 ponytail 核对:领域结果与引用已有 owner,Pydantic 留在自有输入边界。CLI 只需要适合终端的呈现与 +必要交付,不建立第二套业务完成状态,也不为默认可读输出引入完整的渲染插件体系。 diff --git a/tasks/knowledge-lifecycle-capabilities/units/cli-sink/packet.md b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/packet.md new file mode 100644 index 0000000..d8a1642 --- /dev/null +++ b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/packet.md @@ -0,0 +1,50 @@ +# CLI Sink / inkcre-cli + +- **阶段**:完整 preflight 已完成,Execution baseline 冻结至 D-603;等待 [Impact Handshake](impact-handshake.md) 后的“开始”。 +- **目标**:让 Agent 和命令行用户在工作上下文中,通过独立 CLI 取用信息并操作 Core。 +- **边界**:同仓库 cli/ 独立 Python/PDM project,pip 包 inkcre-cli。仅 Core 普通 REST;不导入 Core、不直接访问数据库、不作为 Peer,不是未来 Rust single-binary Peer。 +- **位置**:core-py feat/inkcre-cli,base b3ccb00ca2e235bfcc9b9f4f4cc17948c59ef54a;决策 D-571–D-610。 +- **授权**:调查/隔离实验/packet 可继续;本轮已按 Sir 的条件授权整理设计/preflight 提交。尚未实施源码,也未授权 push 或发布。 +- **当前事实**:[完整预演](preflight.md) 已核对真实 owner、调用链和验收拓扑。发现 production Extension publisher 漏 finalize,现有 Toolkit 替代路径已实测通过,纳入 P5。共享 DB 未 reset。 +- **下一步**:按 [实现计划](implementation-plan.md) 执行 P1/P2/P3→独立 CLI→交付验收;需先取得本次实施授权。 +- **关闭条件**:[四条旅程](acceptance.md)、相关 runtime/Core/client-web 交付、CLI 正式 PyPI 安装复跑全部通过。preflight 不替代它们。 + +## 产品与命令 + +[产品边界](product-design.md)、[研究与现状](research.md)、[命令面](command-surface.md)、 +[连接与访问](connection-access.md)、[输入与动态发现](input-discovery.md)。 + +Agent-friendly 的判断是能发现能力、正确构造输入、理解部分结果和后台工作,而不是强制 JSON 或堆叠命令。 +默认人类可读,JSON 模式紧凑;本机 connection 与 deployment config 分开,动态能力由 Core 发现。 +普通 REST 可以按 CLI 首个消费者需要重整,但既有 Peer protocol 消费路径独立保留。 + +## REST 与内容合同 + +[实体与 Resolver](entity-resolver-rest.md)、[检索与 Graph](retrieval-graph-rest.md)、 +[内容传输](content-transport.md)、[内容交付](content-delivery.md)、[文件输出](file-output.md)、 +[输出呈现](output-presentation.md)、[清单与部分失败](list-error-contract.md)、[各查询续读](query-continuation.md)。 + +[Job 控制](job-control.md)、[Job/Source/Cron REST](job-rest.md)、 +[显式整理 Job 与实体修改](organization-write-rest.md)、[Agent/AI 管理](agent-management-rest.md)、 +[deployment config](deployment-config-rest.md)、[Peer](peer-rest.md)、[Extension](extension-rest.md)。 + +get 批量取得实体记录,可选择原始或 hydrated content;solved content 属于 Resolver 方法。 +显式 collection/organization 创建 Job,不以 Peer inbound 同步执行业务。停止是 best-effort,等待不是停止。 + +## 实施依据与证据 + +[独立项目与依赖](project-and-dependencies.md)、[版本与发布](release-and-distribution.md)、 +[校验边界修正](validation-boundary-correction.md)、[完整预演总览](preflight.md)。 + +具体预演分为 [校验/取消调用链](preflight-call-sites.md)、[Mail runtime 实验](preflight-runtime.md)、 +[REST 接合](preflight-rest.md)、[独立构建与发布工具链](preflight-toolchain.md)、 +[真实环境与交付前置](preflight-environment.md)。不为同一事实创建另一份 control authority。 + +D-602/D-603:Pydantic 留在输入与必要的复杂类型恢复;已有 typed 值不重复验证,普通管理 GET 和 CLI +返回只转换。允许一次原生模型恢复的附带约束,不自建无验证 decoder。 + +## 文档 owner + +CLI 用法/安装/发布归 cli/;普通 REST 与 Core 内部实现归本 Spoke。 +共享 Job 合同和经证据确认的通用边界按 Hub-first 流程提升。具体 Extension capability 留在其 Spoke。 +Organization 旧单元的 Hub reconciliation 保持独立,不因本次准备顺带清除或提交。 diff --git a/tasks/knowledge-lifecycle-capabilities/units/cli-sink/peer-rest.md b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/peer-rest.md new file mode 100644 index 0000000..00135fb --- /dev/null +++ b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/peer-rest.md @@ -0,0 +1,48 @@ +# Peer 记录与 HTTP 唤醒接入 + +状态:按 [D-592](../../decisions/D591-D600.md) 确认,尚未实施。基于 D-573/D-574 的产品范围与 D-582 的命名连接。 + +## 读取接口 + +| REST | CLI | 返回 | +| --- | --- | --- | +| GET /peers | peer list | deployment 中的 Peer 记录 | +| GET /peers/self | peer get 未提供 ID | 当前 REST 接入 Peer 的记录 | +| GET /peers/{id} | peer get | 指定 Peer 的记录,缺失为 404 | + +沿用 PeerManager.get/get_all/get_current_peer_ref,Peer ID 仍是 UUID。self 是当前 REST 接入 Peer 的内置别名, +在协议入口解析,不是持久 nickname,不引入 alias 表或任意 nickname 查找。静态路径不得被参数路由遮挡。 +基础记录保留 name、labels、config、config_schema、capabilities、lease_expires_at 与时间戳; +读取不经 capability_snapshot 重验整个广告。列表分页与紧凑显示沿 D-597 的[共同管理合同](list-error-contract.md)。 + +增加响应计算字段 lease_active,由 Core 在同一读取操作中按数据库时间比较 lease_expires_at。它只说明 +租约是否有效,不命名为 online,不将它等同于此刻网络可达。CLI 无需用本机时钟推断,也不新增状态表或历史事件。 + +## 唤醒直接使用命名连接 + +本轮保留轻量 peer wake,使用本次已选的 CLI connection: + +```sh +inkcre-cli --connection personal peer wake --for 60s +``` + +直接请求该 base_url 下既有 GET /readyz,并在指定预算内观察到就绪。无需先 GET /peers,也不新增服务端 +POST wake,不经过 Peer delegation。--for 的默认值、过程输出与超时退出码随有界命令共同合同收敛;例中的 +60s 是显式预算,不预设新默认值。 + +另一台提供 REST 的 Core 可保存为另一条 connection。此方案不接受裸 Peer ID 并自动猜 HTTP 地址,也不从 +任意 capability inbound URL 裁剪 REST base;后者声明的只是某个精确业务协议,不承诺完整 Core REST。 +Peer row 的 config 是 owner-defined JSON,当前也没有统一的 REST endpoint advertisement。为省去录入一个 +地址而增加跨 runtime REST endpoint registry,当前收益不足。 + +Sir 接受上述最小操作,同时指出通用唤醒尚未规范化。当前合同只是请求并有界等待已配置 HTTP 入口就绪, +是否因此启动实例由部署平台决定;不承诺启动任意离线进程,也不将未来唤醒协议列为本 unit 前置工作。 +唤醒依靠已配置的入口,不能直接从 peer list 的任意 ID 一步唤醒。 +不因此撤回未来不同 Peer 对等、协议异构的定位,也不把没有 REST 的 Peer 表达为坏掉的 Peer。 + +## 核验依据 + +app/business/peer/main.py 已有记录读取、自身 ID 与基于数据库时间的 lease 筛选;app/schemas/peer/main.py +保留完整 PeerModel,没有统一 REST 地址字段。CorePeerConfig.http_public_base_url 是 core-py owner 的本机 +HTTP 配置,不是所有 Peer 的协议。run.py 的 /readyz 已返回 200/503 和 readiness payload。 +app/routes 目前没有 Peer 管理 router。以上仅是设计与源码证据,未修改源码或执行实际唤醒。 diff --git a/tasks/knowledge-lifecycle-capabilities/units/cli-sink/preflight-call-sites.md b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/preflight-call-sites.md new file mode 100644 index 0000000..341e206 --- /dev/null +++ b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/preflight-call-sites.md @@ -0,0 +1,59 @@ +# 校验与执行链预演 + +2026-09-14,P1/P2 的实现落点。D-602/D-603 已确认,不重新讨论是否允许 Pydantic; +下表区分输入、持久表示恢复和已有 typed 值的传递。这里只记录预演,源码尚未修改。 + +## P1:校验只留在有意义的边界 + +| 具体 owner | 当前问题与实施方式 | +| --- | --- | +| `app/configuration.py` | ConfigContract 接收已构造的目标模型时直接传递;mapping 输入用原生 Pydantic。PATCH 将 raw current 与提交字段浅合并,再验证一个新候选,不先验证旧值。 | +| `app/business/deployment_config.py` | 管理 GET 返回存储的 value,不调用 schema 校验;typed get 保留一次模型恢复。PATCH 在现有行锁内合并;不增加 config schema 表或重复解码器。 | +| ext-reg `runtimes/core-py/src/inkcre_extension_runtime_core_py/base.py` | bind/on_start 中丢弃结果的重复校验移除。get_config/get_state 的复杂 JSON 恢复保留一次。mutation callback 已得到模型,返回值继续传递;用闭包保留 typed 结果,避免 host 提交后 dump→validate 的往返。现有 store 在锁内调用一次 mutator,失败正常传播。 | +| Core Extension facade、Twitter setup/state 调用方 | 移除对 runtime 已返回模型的再次 model_validate。实际从持久 dict 恢复 state、或恢复当前运行实例的 config,不机械删除。 | +| SinkManager | 更新边界得到的 validated model 保留到 running instance 更新;启动读取持久值时仍构造一次。 | +| Source / `app/business/info_base/storage/main.py` | config/state 首次恢复保留;Mail/RSS/Twitter 收到已经是目标模型的 collect config 后不重验。Source 未加载时的 catalog 输入见 [REST 接合](preflight-rest.md)。 | +| AIManager / dialect | Provider 配置包含 SecretStr,首次恢复必要;dialect 不再对同一模型重验。模型 capabilities 的结果 codec 保留一次 TypeAdapter 恢复 union,移除额外的重复、排序检查;输入边界仍规范化。 | +| Agent / Peer codecs | Agent tools 读回恢复 tuple,不重做输入去重排序;ToolChoice union 仍需要原生类型恢复。Peer capability snapshot 同理;发布新 advertisement 的输入规范化不删除。 | +| Deployment config 使用者 | Source、Cron、Extension registry 等拿到已经恢复的模型后直接使用,不再 model_validate 一遍。 | +| Job `_prepare` | eligibility 与实际执行接合,避免一次执行的配置重复恢复;不能将输入错误变成永久 pending。handler/schema 未在本 Peer 注册与提交无效是不同情况。 | + +这不是搜索 `model_validate` 后批量删除。Registry/AI/Peer 外部响应、LLM tool 输入、Resolver 对 bytes/JSON +的解析、GraphForm→持久模型转换均各有实际边界。尤其不能把浅层 model_construct 当作递归类型恢复。 +正式评审逐调用点核对这些边界,验收看真实读写与使用结果,不添加 validator 调用计数测试。 + +## P2:一个执行端拥有本地取消传播 + +```text +条件 claim → 登记本地 task → handler → Agent / Source adapter + ↑ ↓ + 集中读取 abort_requested 等待实际执行和必要清理退出 + └──────────────→ 条件终态更新 +``` + +Core 在 claim 成功后、下一次 await 前登记句柄。集中观察 active job 的取消意图,初始采用 2 秒批次; +没有 active job 就不查询。保留原有 30 秒 pending discovery,不让每个 handler 自行读数据库。 +每个句柄只发送一次主动取消,记住原因;pending 的直接关闭和 claim 使用条件更新处理竞争。 + +client-web 的 active Set 变为保存 AbortController、执行 Promise、结束原因的本地映射。 +主动取消、timeout、正常完成沿现有条件关闭边界汇合;等 handler Promise 退出后再报告本端关闭。 +旧 worker 不认识 abort_requested,因此 Core 和 client-web 两端发布都是完整交付条件。 + +`JobManager.check` 目前扫描并向 scheduler 安排执行,不会 await 整个 collection。现有 REST 等待 check +的问题是持久化后的扫描错误/延迟会影响受理响应;改为提交后 best-effort 触发,日志保留实际失败。 +这不把 Job handler 改成 FastAPI BackgroundTask,也不让请求 session 活到后台。 + +本机安装的 APScheduler `AsyncIOExecutor.shutdown(wait=True)` 仍直接 cancel pending futures;源码明确 +说明同步方法无法 honour wait。实施时先停止接纳本端新工作、排空/取消本端 active tasks,再关闭 Extension +资源和 scheduler。不能凭 wait=True 推断 Mail 连接清理已完成。 + +Agent turn/tool batch 使用正常 asyncio 取消传播。Mail to_thread 的线程不会随 awaiter 取消而停止, +其私有 task/锁/断连顺序及重复取消、异常保留已通过 [隔离实验](preflight-runtime.md)。本轮只在 Mail adapter +加入这项清理和 IMAPClient 原生有限 connect/read timeout,不创建通用强杀线程机制。 + +已有 expire_overdue 仍可能先于另一个 Peer 的清理结束关闭超时记录,这是 best-effort 的既有边界;不新增 +executor lease、重领或回滚。`aborted` 表示本端执行已退出;`abort_requested` 本身不证明外部副作用已撤回。 + +显式整理 Job 使用 `core.organization.rumination.explicit.v1`,输入是 block。 +handler 接 `RuminationBehaviorResolver.ruminate_local`,不经可再次委托的领域 facade;目录属于 Core Job +owner,不把任何 Extension 类型加入 builtin catalog。缺实体等执行错误沿普通 Job 结果报告。 diff --git a/tasks/knowledge-lifecycle-capabilities/units/cli-sink/preflight-environment.md b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/preflight-environment.md new file mode 100644 index 0000000..281e4fe --- /dev/null +++ b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/preflight-environment.md @@ -0,0 +1,102 @@ +# 验收环境与交付前置 + +2026-09-14。这里区分已执行的环境/协议探针与未来 CLI 四条旅程;没有把 preflight 当作产品验收。 + +## 基线与共享开发环境 + +Core feature base/main 是 `b3ccb00ca2e235bfcc9b9f4f4cc17948c59ef54a`;client-web main 是 +`54882ace9634710ff3bfc96a6b521ebe11efaf75`;ext-reg main 是 `3fc45236afcaeaa500f6cef907812a1ac8c2040b`。 +后两者本机 checkout 有旧提交或未跟踪内容,实施使用 origin/main 派生的独立 feature 工作位置,不 reset。 +Core 当前依赖 released Python Extension runtime 0.1.2;修改 owner 在 ext-reg,不修改 site-packages。 + +共享开发 target 仍是 SVC instance `b0a97f7ca6abfdf7` / Compose project +`inkcre-core-py-b0a97f7ca6abfdf7`,经 SSH `wsl.win-ws.localhost` 使用声明的 Windows Docker。 +最初 execution `8c683d1f-a9f3-4379-99a3-62b9c9fb5ece` 在 SSH key exchange 阶段失败,没有迁移或修改 DB。 +恢复后的 execution `505c6a2e-fa00-4ba1-92f9-8ec11ffa13cb` 外层一度返回 child-exit/exit 3 和旧快照, +但随后 execution 日志完成 ensure;重新 status 为 healthy 1/1。以最终直接证据为准,不推断 SVC 内部根因。 + +当前 Core image 为 `inkcre-core-py-development:b3ccb00ca2e2`,Core/init 对应该镜像,init exit 0。 +`http://127.0.0.1:60965/readyz` 返回 200 ready,current/expected migration 都是 `143c4f4adc85`, +roles/privileges/catalog 正常。source fingerprint 为 +`b51450cb04ef9d9e92f20930812f0cfc620fbc61587e981d0be944e27e5b6a48`。未 reset/stop 共享 DB 或删除其 volume。 + +实际 Chromium 149.0.7827.55 从临时 localhost browser origin 带本机开发 JWT 请求声明的 PostgREST +`/jobs?select=id,status&limit=1`,返回 200 和 JSON array。浏览器与临时 HTTP server 已关闭。 +这是浏览器到真实持久化的通路证据,不是尚未实现的 browser abort worker 验收。 + +## 双 Core 与真实 IMAP + +为不污染共享库,另建临时 Docker network `inkcre-cli-preflight-m9huov`。所有容器带同名 +`inkcre.preflight` label:postgres/init/a/b/imap,另有局部 Registry 探针。Postgres 使用独立 tmpfs。 +只使用临时非凭据常量,未复制用户邮箱凭据。Core A/B 使用同一当前 image 和这个独立数据库。 + +两个 readyz 均 ready,两个不同 Peer 的 lease 均在线;两端都能连接真实 IMAP 并 SELECT INBOX。 +A ID 是 `2d525392-6c57-407a-b375-691bef4ef803`,B 是 `084d77b9-ee20-465a-8f46-820463e82d40`。 +IMAP 使用官方 `dovecot/dovecot:2.4.4`,digest 为 +`sha256:723e3392fe16c6fad8ddc605ea767cc01b4bad9cd9f13eb1dbac15e79c89b2d4`,只在该网络暴露 31143。 + +实际 pinned image 的 auth 默认值与最新 README 的简单密码示例不一致;本探针在临时 Dovecot 的 conf.d +显式设置 static passdb `password`、允许明文和关闭 SSL,沿已有 Mail harness 的隔离协议场景。 +没有改 Mail 产品配置默认值,没有借此新增安全测试。权威以 actual image / doveconf 为准: +[官方 Docker 文档](https://doc.dovecot.org/2.4.4/installation/docker.html)、[官方源码](https://github.com/dovecot/docker)。 + +## 实际发现:production Extension wheel 缺少 finalization + +对 [Mail 0.2.0 Release](https://registry.inkcre.dev/v1/extensions/inkcre/mail/releases/0.2.0) 和 PEP 503 的请求 +均成功。Core A 普通 REST 安装返回 200,但 Core B enable 返回 409: +`Registry did not yield exactly one Extension wheel`。 + +下载的正式 wheel SHA-256 为 `2171fac4b94056401d17dd6df67d22099bdf85a1593635fafe54341dbaaed31e`, +与 Simple 页面一致;ZIP 中有常规 METADATA/entry_points/RECORD,却没有 `dist-info/inkcre-extension.json`。 +runtime 0.1.2 和 ext-reg main 均要求此 installed record。 + +原因在现有 producer 交付链:`scripts/automation/extension_publication.sh` 的 build 直接上传原始 wheel; +`scripts/build_extension_preview.py` 则已调用 `inkcre-ext python wheel finalize`。Toolkit 0.2.1 的该命令 +拥有写入 installed record 与重打包的实现。本机对正式 Mail wheel 调用它成功,生成独立候选;没有覆盖或 +上传正式 0.2.0。这不是 CLI 输入错误,也不应在 CLI/runtime 添加绕过。 + +实施计划 P5 增加一处必要接合:production producer 复用同一 Toolkit finalize,保留各 Extension 的 version +authority 和不可变发布;本 unit 本来就需要为 Mail cancellation 修正提供 fragment,使用下一正式版本交付。 +共用 publisher 的其它项目也获得同一构建步骤,但不重写已发布 artifact、不擅自给全部项目 bump version。 +正式关闭前必须实际安装并启用新发布的 Mail,不能只验证 metadata endpoint 为 200。 + +候选闭合验证已通过:用 Toolkit 0.2.1 finalize 后的 wheel 构建单 Mail preview inventory,在上述独立网络 +用普通静态 HTTP server 承载。Pages 的一条 `_redirects` 被等价静态文件映射承载,目录只读权限显式开放给 +容器进程;Core B 的 pip 显式允许该私有 fixture HTTP host。两者只是临时承载条件,不改产品的 HTTPS 发布。 +Core A 安装返回 200;B enable 返回 200,enabled[] 只包含 B,config schema 和 sources_types catalog +实际生成。随后将仓库 historical-parent.eml APPEND 到 Dovecot,UID SEARCH / UID FETCH +`BODYSTRUCTURE BODY.PEEK[]` 成功。这证明修复路径和真实材料可用,不冒充尚未编写的 CLI collection 旅程。 + +## AI 的真实可用性 + +canonical production 的 ai_providers、ai_models、embedding_profiles、configs 当前为空;不能假设 Preview +自动拥有 AI 配置。共享开发库有 Provider 1 / `core.openai-compatible.v1`,Model 1 `text-embedding-v4`, +Profile 1 dimensions 256。只在内存取其 config,不打印或复制密钥到 packet。 + +用现有 OpenAI SDK、关闭自动 retry、限定 timeout 发出极小请求:text-embedding-v4 实际返回 256 维; +qwen-plus 返回 403,但 Sir 先前指定的 `qwen3.5-omni-flash` 和 `qwen3-omni-flash` 都成功返回 function tool +call。验收准备采用已成功的精确模型,并在隔离环境明确创建 Provider/Model/Profile/Agent/config;不依赖 +production 中不存在的记录,不用这次小请求声称 rumination/semantic retrieval 旅程已通过。 + +## 云端与正式发布 + +Core production [run 34709831934](https://github.com/InKCre/core-py/actions/runs/34709831934) 成功,实际 +`https://inkcre-core-production-b26009ded782.herokuapp.com/readyz` 返回 200 ready。 +PostgREST 唤醒初期一度 503/PGRST002;Heroku 日志显示 03:27:48 UTC 连接 PostgreSQL 17.11,03:27:55 +加载 schema cache,之后上述目录 GET 全部 200。这是实测冷启动,不是持久的 migration/CD 故障,未做修复。 + +最近 Preview application [run 34709745186](https://github.com/InKCre/core-py/actions/runs/34709745186) +成功。CLI 尚无候选 PR,因而没有可声称“CLI preview 已通过”的环境;按原有 PR workflow 创建精确候选 +Preview,再跑已批准旅程。IMAP/双 Core 的受控操作留在上述同网络隔离运行时,绝不把 localhost 填给云端 Core。 + +PyPI Trusted Publisher 尚未验证/配置,这是首次正式发布前的 Human 条件,不是本机编码 blocker。 +2026-09-14 公开 PyPI `inkcre-cli` JSON 查询为 404;这不保留名称,也不证明用户发布权限。 +目标沿 release-and-distribution.md:InKCre/core-py、cli-publish.yml、production。先交付 Core 新 REST, +再 CLI 0.1.0;不承诺当前已有发布权限,不在 preflight 创建收费服务或执行发布。 + +## 清理 + +探针结束后已停止并删除本次带精确 label 的 A/B/Registry/IMAP/init/Postgres 六个临时容器、 +其临时 volume 和独立 network。再次 `svc dev status database` 为 healthy 1/1,仍是原共享 Compose project。 +本机 ignored 探针目录的结果已归入这些预演页,目录已移入系统废纸篓,可恢复;不把派生 wheel/虚拟环境 +或探针升级为测试套件。 diff --git a/tasks/knowledge-lifecycle-capabilities/units/cli-sink/preflight-rest.md b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/preflight-rest.md new file mode 100644 index 0000000..eb9889a --- /dev/null +++ b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/preflight-rest.md @@ -0,0 +1,48 @@ +# 普通 REST 的接合预演 + +2026-09-14,P3。这里是代码/事务落点,不是另一份业务规则定义;公开形状仍以已确认的各 REST 设计页为准。 + +## Manager、事务和路由 + +| 接合面 | 已核验的接口与实施注意点 | +| --- | --- | +| GraphForm | `InfoBaseManager.submit_graph(graph, db_session=None)` 无外部 session 时自己 commit,否则 flush。普通 REST 改为 POST,复用该事务,不在 router 重做 graph 写入。 | +| Block PATCH | `BlockManager.edit_block` 的 storage 使用未提供 sentinel;必须用 exclude_unset 保留 omitted,与显式 null 区分。不得用完整 BlockModel 默认值拼 PATCH。 | +| Relation PATCH | `RelationManager.update` 的 from_/to_/content 也有 omitted 语义;沿现有 owner 写入和 commit。单实体 delete 的 false 映射为未找到,不假装已删除。 | +| Source CRUD | 补现有 owner 的查询、update/delete,不创建通用 CRUD service。只写可编辑字段,不覆盖 cursor/state;已有 Source anchor 才同步 projection,改名不额外创建 anchor。 | +| Job / Cron | 复用 Job 参数的输入规范化,不因接入 Peer 无 handler 而拒绝保存。Cron 验证参数但不创建 Job;run-now 显式创建 Job,不改 last_scheduled_for/last_job。 | +| Agent / AI / profile | Agent definition 管理在 Agent owner;模型发现属于 AI。工具目录可发现已注册 schema,不要求提前 bind 一次完整 Agent 才能列表。 | +| Deployment config | `/config-schemas` 独立于 `/configs/{key}`;raw GET 不依赖加载 schema。PUT 的新建/更新状态在写事务中确定,不用先 GET 再猜。 | +| Peer | `/peers/self` 在 `/{id}` 之前注册;它是别名。wake 只对已知 HTTP 地址做有界请求,不引入新的发现/唤醒协议。 | +| Resolver | reflection 位于 `app/business/info_base/resolver/main.py`,不是 inspection.py(后者是媒体内容检查 helper)。REST 直接用其发现和 invoke_method;输入模型在那里构造,不反向调用 MCP tool 或再验证一次。 | +| 内容响应 | JSON / bytes / multipart 只在 HTTP 边界投影;既有 aiohttp writer 与 stdlib email parser 实验已验证 bytes 保真。不改 Resolver 返回模型,不新增下载会话。 | + +保持同步 SQLModel CRUD 用同步 route、异步 resolver/运行操作用 async route 的本轮取舍,不借机迁移整个 +数据库驱动。列表续读见 query-continuation.md;静态目录、动态目录和持久列表分别核对,而不是只给 Job 分页。 + +## catalog-only 的输入不是 typed defaults + +持久目录实际表名是 `sources_types`(不是 source_types)。接入 Peer 可以仅从该目录得到 config/job schema, +使用已有 JSON Schema validator 校验新提交;不要求 instantiate Source,更不运行 can_handle。 +JSON Schema 的 default 是 annotation,不会像 Pydantic 自动填默认值。保留未提交字段,在真正执行端由 +Source 自己的模型恢复默认值;不能为模拟默认值自建 schema decoder。 + +Source-specific collect config 仍由 Source 边界拥有。Job/Cron 受理时复用源类型目录的参数校验, +不能因为外层 `parameters.config` 是 dict 就漏掉首次输入校验,也不能反过来把 Source 规则写死在 JobManager。 +自定义 Python validator 没有完整反映到 JSON Schema 的限制按当前目录能力接受,不虚构远程代码校验。 + +## 确认不动的现有消费者 + +client-web origin/main 的以下代码使用 Peer delegation,而非将要提供的普通 REST: + +- `packages/core/src/semantic-retrieval/main.ts`、`lexical-retrieval/main.ts`、`organization/main.ts`。 +- `apps/client-web/src/extension-peer-control.ts`。 +- `extensions/twitter/src/setup-api.ts` 与 `extensions/mail/src/resolver.ts` 的 Extension-owned capability。 + +保留精确的 `/semantic-retrieval`、`/lexical-retrieval`、`/organization/ruminate`、`/extension-management` +inbound 和 non-delegating local seam。新增普通 `/retrieval/*` 可以接领域 facade;Graph 查询可直接使用 +本 Peer 的数据库。共用 HTTP 并不使 CLI 成为 Peer,也不授权修改上述 capability 协议。 + +Core output DTO 只表达输出,CLI 只做 JSON/MIME 解码和呈现。FastAPI 的 response serialization 不应再次 +调用输入侧业务 validator;配置 raw value 和 runtime-owned 内容不套一套新校验模型。最终 OpenAPI 生成 +与 router 顺序检查在代码落地后执行,动态 Extension 目录不从静态 OpenAPI 反推。 diff --git a/tasks/knowledge-lifecycle-capabilities/units/cli-sink/preflight-runtime.md b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/preflight-runtime.md new file mode 100644 index 0000000..4e30ef2 --- /dev/null +++ b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/preflight-runtime.md @@ -0,0 +1,103 @@ +# Runtime 预演:持久表示与取消 + +2026-09-13;对应实现计划 P1/P2。本页区分已观察事实、实现方向与需 Sir 复核的取舍;没有修改业务源码。 + +## P1:不能把类型恢复误称为无校验解码 + +已检查 `app/configuration.py`、`app/business/deployment_config.py`、Source config/Job 路径、 +`app/schemas/agent.py`、`app/schemas/ai/capability.py`、OpenAI-compatible Provider config 与 Mail schema。 +它们存在三种不同问题: + +| 边界 | 事实 | 可行修正 | +| --- | --- | --- | +| 管理面直接读记录 | DeploymentConfigManager._view 先按当前 runtime schema 验证 value,再 dump 回 JSON | 直接返回持久 value;不依赖 schema 已加载,也不填入当前默认值 | +| PATCH 和同次调用传值 | prepare_patch 先 normalize(current),PATCH 本身又提前 validate(current);返回 _view 再验证;部分调用方对已得到的 typed config 再 model_validate | 合并原始完整候选后仅验证新值;typed 对象直接传递,返回持久结果不重验 | +| 数据库 JSON → 可执行 Python 对象 | Mail parameters/排除策略/checkpoint 是嵌套模型,Provider api_key 是 SecretStr,Agent tool_choice/AI capability 是 union | 必须恢复这些类型;浅层 model_construct 和静态 cast 不完成此工作 | + +本机 Pydantic 2.13.4 隔离实验进一步确认:同一个已构造对象再传入 model_validate,after model validator +仍会运行;默认 revalidate_instances 并不能用来保证所有自定义检查跳过。model_construct 则保留 nested dict, +不会生成 Params 或 SecretStr。实验只有内存中的合成值,没有数据库、凭据或新测试文件。 + +[Pydantic 官方说明](https://docs.pydantic.dev/latest/concepts/models/#creating-models-without-validation) 明确区分了 +这两条路径:不验证的 construction 也不做嵌套模型转换。该库的普通模型恢复同时执行类型转换与约束,不能 +仅把 model_validate 改名为 decode 就声称校验已经消失。 + +### 已确认的取舍(D-602) + +直接读记录、REST/CLI 响应和已构造 typed 对象传递,严格去掉额外检查;PATCH 只验证合并后的新值。 +对于确实需要从 JSON 恢复复杂 Python 模型的执行入口,允许保留一次 Pydantic 原生构造,并接受它附带执行 +模型约束。不能把它宣传成完全无校验,也不再叠加一次专用的“持久值合法性”检查/错误包装。 + +简单且已有明确 owner 的表示(如数据库 text[] → tuple)直接转换,不需要 Pydantic。通用 Source/Extension +config/state 允许外部类型,不能只覆盖当前几种配置就宣称实现了通用 decoder。这里不推荐为了消除最后一次 +库校验而添加每种 Extension 必须实现的 decode hook、递归 schema walker 或另一套并列模型。 + +这比 P1 原先“所有读回只转换”的字面承诺更窄,2026-09-14 已按 D-602 获 Sir 确认,并提炼为 Python 后端 +通用指南。设计取舍已闭合,不代表各调用链已实施验证。CLI 本身仍只解析 REST JSON/MIME,不受此例外影响。 + +## P2:停止链路已经定位,但不能以终态冒充清理完成 + +```text +JobManager.run → handler.handle → Organization → await Thread.current_turn + → gather(Tool tasks) + → Mail → IMAPAdapter._run → to_thread(IMAPClient) +``` + +Organization `_shared.py` 与 media interpretation 直接 await turn;Thread 的 tool batch 使用 asyncio.gather, +CancelledError 记录后重新抛出,未发现这里有 shield 将取消吞掉。因此普通 async 路径可以沿现有 await 传播, +不需要给每个 Tool 增加数据库检查。Tool 内已经发出的 thread/外部效果仍另行判断,不承诺撤回。 + +Core JobManager 目前只处理 TimeoutError/Exception,未记录本地运行句柄;CancelledError 不会沿这些分支 +关闭。client-web active 只有 Set,AbortController 专用于 timeout;需用执行句柄及原因区分 timeout/abort, +等待 handler 的 Promise 结束后再关闭。两端条件更新继续阻止终态覆盖,不能以意图字段变化释放 Cron 占位。 + +### Mail 的实际资源清理缺口 + +`extensions/mail/adapter.py:IMAPAdapter._run` 在 asyncio.Lock 内 await to_thread(function),`__aexit__` 随后 +通过相同 _run 执行 disconnect。本机 threading.Event 控制的隔离实验得到: + +```text +awaiter_cancelled → cleanup_acquired_lock_before_worker_finished → worker_finished +``` + +这说明取消 awaiter 会先释放外层锁,仍在执行的 IMAPClient 调用可能与断连并发。锁的存在本身并未保护 +这个退出分支。修正应由 IMAP adapter 保持该次线程调用的完成与连接清理顺序,Job runtime 不理解 IMAP。 +开始连接途中取消也需要清理,不能只处理 __aexit__(__aenter__ 未返回时它不会被调用)。 + +当前构造 IMAPClient 没有传 timeout;本机库支持 SocketTimeout(connect, read),可复用其有限网络超时, +不自建 IMAP 请求计时器。参考 [IMAPClient API](https://imapclient.readthedocs.io/en/3.0.1/api.html)。 +网络 inactivity timeout 不是整个命令的硬 deadline,不能将其宣传为强制杀线程。 + +2026-09-14 已完成下述隔离替代验证;业务源码尚未修正。JobManager 集中检查应避免重复对同一 handle 发 +cancel 干扰清理。现有 expire_overdue 仍会按数据库时钟关闭逾期记录, +它是既有遗失执行收敛机制,不是 handler 清理完成的确认,也不据此承诺全局零重叠。 + +Mail adapter 修正属于已确认的真实取消链路影响,实施计划应包含对应 Extension fragment 与真实 IMAP 验收。 +不扩大为所有协议的取消框架;独立 blocking 操作是否有残留效果按实际调用链记录。 + +### 已通过的替代清理实验 + +实验继承实际 IMAPAdapter,局部替换 _run/__aenter__,以 threading.Event 控制同步连接、操作、断连, +保持真实的 async context 生命周期与锁。同步方法是受控 double,不连接 IMAP server,也不访问数据库。 +为 import Core 提供的是指向 loopback 未使用端口的占位 DSN 和非凭据常量,禁用 dotenv;不依赖真实账号。 + +候选 `_run` 在锁内保留 to_thread 的 task,并通过 shield 等待它。收到取消时记住 CancelledError,继续等 +同一 task 完成,不重新执行同步函数;结束后取出真实结果/异常,再向上传播取消。若同步函数同时失败, +异常保留在取消的 cause 中,不丢失。重复取消仍只影响等待方,不取消正在排空的私有 task。 + +`__aenter__` 失败或被取消时先调用同一串行路径清理已取得连接,因为 Python 不会为失败的 __aenter__ 调用 +__aexit__。正常退出继续使用已有 __aexit__。这几处都留在 adapter,不让 JobManager 理解 IMAP。 + +下列六次受控取消和一次正常生命周期全部通过: + +- 连接中取消:connect 完成后 disconnect,连接不泄漏。 +- 操作中取消:operation 完成后才 disconnect,锁始终覆盖同步操作。 +- 断连中取消:disconnect 完成后才传播取消。 +- 操作中重复取消:第二次取消不会提前释放锁或结束清理。 +- 操作被取消且同步 I/O 失败:清理完成,取消仍可观察,原始 OSError cause 保留。 +- asyncio.timeout 触发取消:同步操作和断连完成后向调用者表现为 TimeoutError。 +- 正常执行:connect → operation → disconnect,不改变无取消路径。 + +每个取消场景均检查:同步函数尚未返回时外部 task 未结束、锁仍持有;最终 _client 清空、无并发连接操作、 +取消没有被吞掉。这证明候选清理顺序可行,不证明已经终止任意线程、进程崩溃后的清理或真实 IMAP journey。 +实现仍需使用已检查的 IMAPClient 有限网络超时,并在原定 Mail 黑盒旅程验证真实协议路径。 diff --git a/tasks/knowledge-lifecycle-capabilities/units/cli-sink/preflight-toolchain.md b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/preflight-toolchain.md new file mode 100644 index 0000000..ea0ba5c --- /dev/null +++ b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/preflight-toolchain.md @@ -0,0 +1,89 @@ +# 独立 CLI 构建与安装预演 + +2026-09-14;对应计划 P4/P5 的独立项目与入口机制。结论:工具链与所选依赖可以支持当前方案。 +这不是 inkcre-cli 产品实现或 REST 验收,实验未发布任何包,也未修改根 pyproject、lock 或业务源码。 + +## 隔离范围 + +在 WorkSSD 的忽略目录 `.runtime/cli-preflight.uxX70u/cli` 创建独立 PDM project,发行名明确为 +`inkcre-cli-preflight-probe` / 0.0.0,只提供最小的输入/schema/JSON 探针;不用真实 inkcre-cli 包名冒充首发。 +依赖仅 Click、Pydantic、HTTPX、PyJWT,构建后端 pdm-backend,src layout。 + +使用 PDM 2.28.0 显式选择系统 Python 3.12.10,并设置 PDM_IGNORE_ACTIVE_VENV=1。实测 PDM 在该子项目 +创建自己的 `.venv`,run 使用这个解释器,不复用根 Core 环境。依赖约束和本次解析结果为: + +| 直接依赖约束 | 本次解析版本 | +| --- | --- | +| click >=8.1,<9 | 8.5.0 | +| pydantic >=2.10,<3 | 2.13.5 | +| httpx >=0.28.1,<0.29 | 0.28.1 | +| PyJWT >=2.10,<3 | 2.14.0 | + +Python 声明为 >=3.12,没有继承 Core <3.13 的 runtime 上限。本次只证明 macOS arm64 / Python 3.12 +实际安装,不宣称其他 Python/OS 组合已跑过。正式项目使用自己的 lock;不复制整个实验 lock 作为 Core 依赖。 + +## 已执行的安装链 + +```text +pdm use -p /cli --no-version-file +pdm install -p /cli +pdm lock --check -p /cli +pdm build -p /cli + → sdist → 从 sdist 构建 wheel +system-python3.12 -m venv /consumer +/bin/python -m pip install +/bin/python -m pip check +``` + +安装后的 executable 从 `/Volumes/WorkSSD`(仓库外)以普通 subprocess 调用;没有设置 PYTHONPATH。 +consumer 环境不能导入 app、FastAPI、SQLAlchemy 或 PDM。探针实际导入四个直接依赖、构造 Pydantic 输入、 +创建/关闭 HTTPX Client、用非凭据的本地常量完成 JWT round-trip;没有向 Core 或外部服务发送业务请求。 +pip check 无缺失或冲突。 + +## Click 入口预演的修正 + +第一版手动 make_context/invoke 只捕获 ClickException,导致 --help 的正常 Exit 被当成未捕获异常。 +改用 Click 原生 `command.main(..., standalone_mode=False)`,由库处理 help/Exit,调用方仍可接收并格式化 +ClickException。这样不必自建一套 Exit 分派;它不是改变 CLI 输出合同。 + +重新构建并替换 consumer 中的探针 wheel 后,以下 subprocess 检查通过: + +| 调用 | 可观察结果 | +| --- | --- | +| --help | exit 0,stdout help,stderr 空;无需连接或业务输入 | +| --schema | exit 0,stdout 单个 JSON schema;无需业务输入 | +| --input-json '{"value":"probe"}' --json | exit 0,单行 JSON,确认 Core 不可导入 | +| --json(缺业务输入) | exit 2,stdout 空,stderr 为可解析 JSON 错误 | +| --unknown | exit 2,stdout 空,stderr 为可解析 JSON 错误 | + +这是入口和依赖隔离证据,不是完整 CLI 的 JSON 错误呈现实现;HTTP timeout、文件输出、多命令层级与真实 +REST 仍按实现及已确认 Acceptance 验证。已有 JSON/bytes/multipart 设计实验不由本页替代。 + +本轮只在临时项目运行手工脚本,没有增加自动化测试、永久 demo command 或新的库封装。实验环境与构建 +产物在结果记录后清理,避免在 WorkSSD 累积额外 virtualenv。 + +## Release PR 准备的隔离预演 + +完整 preflight 在 `.runtime/cli-complete-preflight.m9huoV/release-spike/` 复制现有 release.py、Towncrier +配置,创建最小的 Core/Mail/CLI 三项目副本。CLI discovery 仅在探针内接入现有 ReleaseProject;调用真实 +`prepare(("cli",))`,不用另写 version/changelog 实现。Towncrier 25.8.0、PDM 2.28.0。 + +CLI 的 added fragment 使 0.0.0 变为 0.1.0,fragment 被消费,CHANGELOG 更新;Core/Mail 的 pyproject +和 CHANGELOG 均未变化,探针 Git index 的前后 bytes 一致。`extensions_only` 不包含 CLI。 +这是编排可复用与 index 隔离证据,不表示实际仓库的 CLI discovery、allowlist 或 bootstrap 已实现。 + +实施落点已经定位:release.py 显式发现 cli/、给普通项目传正确 key;affected_projects 只纳入实际 +artifact inputs;Release PR path allowlist 从项目目录得到;新项目尚无 base 时不因 version comparison +自动发布 0.0.0。继续一个 release/next controller,不另建 CLI controller。 + +根 Ruff/对应 pre-commit 排除 cli/ 并给 CLI 独立 checks;根 Pyrefly 的显式 source paths 不扩入 CLI。 +Dockerfile 和远程 build context 已使用显式目录,保持不复制 CLI;普通 Docker context 也排除 cli/。 +CLI 的 PDM lock、lint/type/build 独立,不能以根 gate 通过替代。 + +client-web 声明 Node 22.22.3、pnpm 11.11.0。实际 project exec 得到 Node v22.22.3,显式执行 pnpm@11.11.0 +得到相同版本;不误用全局 Node 24 / pnpm 11.20。Playwright 的实际浏览器通路见环境页。 +Core 现有 `pdm run check:foundation` 已通过(lock、基础环境、shell、migration manifest),未运行或新增 +本 unit 的自动化测试矩阵。完整 gates 随实现候选运行。 + +已核对 PDM publish 原生支持 --no-build / --skip-existing;正式发布采用原生 Trusted Publishing。 +没有调用 publish,也没有以“命令可用”替代 PyPI 权限验证。 diff --git a/tasks/knowledge-lifecycle-capabilities/units/cli-sink/preflight.md b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/preflight.md new file mode 100644 index 0000000..a57cbb1 --- /dev/null +++ b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/preflight.md @@ -0,0 +1,44 @@ +# 实现前预演 + +状态:2026-09-14,完整 preflight 已完成。D-571–D-603 的产品、接口和验收基线不变; +具体代码落点、真实验收环境与交付顺序已收敛,可以进入 Impact Handshake。 +这不是实施完成、产品验收通过或发布授权。 + +## 结论与证据入口 + +| 计划 | 已闭合的准备 | 证据 | +| --- | --- | --- | +| P0 | Core feature 基线、ext-reg/client-web origin/main、声明开发环境恢复;不重置 dirty checkout 或共享 DB | [环境](preflight-environment.md) | +| P1 | 逐调用点区分输入、一次原生 Pydantic 类型恢复、typed 传递;不自建递归 decoder | [调用链](preflight-call-sites.md) | +| P2 | 两端 worker 取消、shutdown、条件终态、Agent 传播;Mail 清理替代实验通过 | [调用链](preflight-call-sites.md)、[runtime 实验](preflight-runtime.md) | +| P3 | Manager 事务签名、PATCH omitted、catalog-only 输入、路由顺序和现有 Peer 消费者 | [REST 接合](preflight-rest.md) | +| P4 | 独立 PDM/lock、sdist→wheel→干净环境,Core 不可导入,Click 正常退出/compact JSON | [工具链](preflight-toolchain.md) | +| P5 | 复用 ReleaseProject/Towncrier 的 CLI 独立版本与 Git index 隔离;修正发布构建缺口的路径得到验证 | [工具链](preflight-toolchain.md)、[环境](preflight-environment.md) | +| P6 | 双 Core/同 DB、真实 Dovecot、Mail 安装/启用、浏览器→PostgREST、真实 embedding/tool call、云端冷启动 | [环境](preflight-environment.md) | + +原先的 SSH reset、旧 image 快照已经收敛;它们的真实时序保留在环境页,不再列为当前 blocker。 +Pydantic 方案已由 D-602/D-603 确认,不以逐调用点调查重新打开同一个决策。 + +## 预演带来的必要改动 + +最重要的新发现是正式 Mail 0.2.0 wheel 缺少 installed record,enable 真实返回 409。 +Preview 构建已使用现有 Toolkit finalize,production publisher 却没有。 +隔离实验复用 finalize 后,Core A 安装、B 启用和真实 IMAP 读取均成功。 +[实现计划 P5](implementation-plan.md) 因而补入同一构建步骤的复用;不降低 runtime 合同,不覆盖正式旧 wheel, +不扩大为 release framework 重写。这项 source correction 等待本次 Impact Handshake 后的实施授权。 + +另两项修正是具体实现落点:Resolver reflection 在 resolver/main.py;显式整理 Job 调用 +RuminationBehaviorResolver.ruminate_local。APScheduler shutdown 的 wait=True 不会等待异步清理,P2 必须 +先停止接纳并排空本端执行,再关闭资源,而不是依赖这个参数。 + +## 实施与交付条件分开 + +目前没有尚未定位、会推翻设计的编码 blocker。仍存在正常的交付前置: + +- 候选 PR 创建后才有本 unit 的 Preview;必须对实际候选运行四条 CLI 旅程。 +- ext-reg runtime 修改需独立发布并由 Core pin;Core 与 client-web worker 均交付后才算停止协议完整。 +- 正式 Mail 新版本必须包含 finalize 和本轮 adapter 修正,并实际从 Registry 安装/启用。 +- PyPI Trusted Publisher 仍需 Human 配置/确认。先使 Core 新 REST 可用,再发布 CLI 0.1.0 并干净 pip 复跑。 + +本次不声称上述交付已完成,不发布包,不重置真实用户数据。 +下一步是 [Impact Handshake](impact-handshake.md);Sir 明确“开始”后才进入源码实施。 diff --git a/tasks/knowledge-lifecycle-capabilities/units/cli-sink/product-design.md b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/product-design.md new file mode 100644 index 0000000..c5e8619 --- /dev/null +++ b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/product-design.md @@ -0,0 +1,78 @@ +# CLI 产品设计 + +操作范围已获 [D-573/D-575](../../decisions/D571-D580.md) 确认;本页将范围映射到可观察行为,尚未冻结全部命令参数与 +全部行为细节。当前阶段和下一复核面只由 [packet](packet.md) 维护。 + +已确认的命令分组与待复核的参数示例见 [命令结构](command-surface.md)。 + +## 已确认操作范围 + +| 调用者要完成什么 | 本轮提供的能力 | +| --- | --- | +| 从线索找到并取用信息 | 词法、语义、图导航检索;批量实体记录及可选 raw/hydrated content;solved content 等解释通过动态 Resolver 方法读取 | +| 把明确的信息改动交给 Core | GraphForm 新增 graph,以及已有 Block/Relation 的原地编辑、删除;沿用 info-base 的持久化能力 | +| 运行收集或整理并保持执行控制 | 显式 collection/backfill 和 organization 均提交相应 Job;提供状态查看、限时等待及主动停止 | +| 查看参与运行的节点并使可唤醒节点恢复服务 | Peer 列表/详情、状态、唤醒;支持边界见下文 | +| 管理 Extension | 列表/详情、安装/卸载、启用/禁用;必要的配置与合同发现服务实际安装使用 | +| 配置收集来源 | Source 列表/详情、创建/编辑/删除;发现 Source type 与其配置/collect/backfill schema | +| 安排定期收集或整理 | Cron 列表/详情、创建/编辑/删除、启用/禁用;选择 Job type、参数、超时和 schedule | +| 管理整理所用 Agent 与行为配置 | Agent definition 发现/创建/读取/编辑/删除;通过 deployment config 管理 Organization behavior config,包括 Agent 选择 | + +列表、详情、类型与配置合同不是额外产品主线;它们让调用者能完成已获批的管理操作。也不据此把所有数据库 +表变成 CLI CRUD,或增加用户、租户和权限管理。Agent-friendly 的研究方向见 [research](research.md)。 + +D-591 将已有的同类校验边界问题纳入本 unit 配套修正,范围与证据见 +[校验边界修正](validation-boundary-correction.md)。这不是额外 CLI 功能,不改变本页的产品操作范围。 + +D-595 明确上述任务提交与实体修改边界。Block 是整理的输入,不使整理变成 Block 领域操作;既有同步执行 +方法或 Peer inbound 也不决定 CLI 的受理方式。准确方案见 [Job 入口与实体修改](organization-write-rest.md)。 + +## 从已有模型自然得到的边界 + +CLI 配置的是连接哪个 Core REST 服务。这个服务属于一个 Peer,但 Source、Cron 等持久配置是 deployment +共享的。相反,Extension 启用/禁用影响选定 Peer 的 runtime;返回信息应让调用者看清作用对象,不能把一次 +disable 描述为全 deployment 停止运行。安装记录、启用意图与实际运行也不能合成一个状态。 + +Source 负责收集配置与游标,Cron 保存 schedule 和 Job 模板,Job 表示一次执行。CLI 分别操作这三个对象, +不把 Cron 配置放回 Source,不把编辑 Source 自动变成启动 collection,也不把定时配置写成本机 crontab。 +删除 Source 的配置不意味着删除已经收集的信息;关联引用和延迟读取的影响需在删除合同设计中核对。 + +Cron 沿用 Core 的时区、错过不补跑和并发 occurrence 语义,不由 CLI 另建 scheduler。Job 的创建回执只确认 +执行机会已持久化;实际 pending/running/terminal 状态从 Core 查询。组织 Job finished 不证明 Agent 的语义 +判断正确,也可能没有 graph 写入。输出必须保留这些既有含义。 + +等待者拥有本次观察预算,Job 拥有独立的执行期限。等待结束可正常返回 pending/running 等实际状态,不代表 +Job 失败或取消;调用者使用同一引用继续观察或主动停止。Agent definition 管理不隐含新增 Agent 会话产品; +Organization config 也不复制 definition 中的 prompt/model/tools。配置的保存位置与具体字段保持现有 owner。 + +管理配置和读取动态合同依赖当前 Core 的 capability owner。CLI 不内置每种 Extension、Source 或 Organization +的配置清单;如何按需发现并构造输入在 Technical 阶段明确。 + +## Peer 唤醒,D-574 已确认 + +现有 Peer 状态是能力 advertisement 与 lease,尚无 wake 操作。Lease 过期只表示未在该时间范围内续期; +无法区分 scale-to-zero、关闭、失联等原因。状态查看应报告实际已知事实,不猜测休眠原因,也不主动续写 +其它 Peer 的 lease。 + +本轮覆盖由外部 HTTP 请求触发冷启动的 Core 服务,例如现有 Render Free 部署。使用现有 `GET /readyz`, +宿主平台因请求到达而启动服务,Core 返回自身就绪状态;不存在专门的 `/wake` handler。CLI 复用普通 HTTP +调用并等待实际就绪,无需平台 SDK 或独立 wake protocol。唤醒只使服务可用,不附带执行 collection/organization +或补发错过的 Cron。已可访问时可正常完成,不必制造“已经醒着”的错误。 + +初始配置的 Core 自己也可能休眠。因此访问/等待这个已知地址不能先依赖一次 Peer 列表查询;醒来后才能 +读取 deployment 中的其它 Peer。CLI 仍只使用普通 Core REST,不通过过期 lease 的 Peer delegation 唤醒目标。 +目标按 D-592 使用已配置的 CLI connection;保留直接请求 /readyz 的轻量操作,不按任意 Peer ID 自动发现地址, +不建立独立唤醒服务或调度机制。通用 Peer 唤醒尚未规范化,不由本单元补齐。 + +本轮不承诺启动已关闭的桌面应用、Android 应用或机器,也不接管 Render/Heroku/Docker 的部署控制。 +没有 HTTP 唤醒路径的 Peer 可以被查看,但不能仅凭一条 Peer row 使其启动。产品能力边界已确认,超时和 +等待过程的呈现随命令合同落实。 + +## 验收应增加的场景 + +在原有信息取用与显式操作旅程之外,加入从 CLI 安装/启用 Extension、按真实 schema 创建 Source、配置 +collection Cron、观察产生的 Job,以及配置现有 Organization Cron 的真实链路。另覆盖休眠 Core 的访问/ +唤醒与后续 Peer 状态查看。D-575 还要求覆盖限时返回后继续观察/停止 Job,以及管理 Agent definition 并在行为 +配置中选择它。D-595 增加针对指定 Block 创建整理 Job 的链路,以及原地修改/删除 Block、Relation 后重新读取 +实际效果;不能以 HTTP 受理冒充整理完成。准确数据、环境、清理方式和验收终点在 Acceptance 阶段冻结,不从本建议推导 +新增自动化测试或扩大为全部 hosting platform 验收。 diff --git a/tasks/knowledge-lifecycle-capabilities/units/cli-sink/project-and-dependencies.md b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/project-and-dependencies.md new file mode 100644 index 0000000..66a7d81 --- /dev/null +++ b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/project-and-dependencies.md @@ -0,0 +1,174 @@ +# 独立项目、依赖与协议复用 + +状态:2026-09-13 按 [D-598](../../decisions/D591-D600.md) 确认,并纠正为 core-py 仓库内的独立 PDM project; +CLI 信任 Core REST 的业务返回,只做必要转换,不建立响应再校验层。产品源码实施与发布尚未授权; +2026-09-14 已按关键准备授权,在隔离临时项目完成依赖安装、构建与独立安装实验,没有新增自动化测试。 + +## 独立分发,不导入 Core runtime + +CLI 放在当前仓库的 `cli/`,拥有自己的 `pyproject.toml`、`pdm.lock` 和开发环境;distribution 与 executable +均为 `inkcre-cli`,Python import package 为 `inkcre_cli`。不新建仓库,不创建第二份 unit packet。 +独立安装与依赖边界不要求独立 Git 仓库。 + +```text +core-py/cli/ + pyproject.toml + pdm.lock + src/inkcre_cli/ + commands/ # 按已有命令组组织;不是动态 CLI plugin registry + ... # 本机连接、REST 接入、输入与输出按真实职责组织 + docs/ # 安装、命令用法、CLI 维护与发布 +``` + +Core 的 REST 实施仍归服务端。CLI 是公开协议的消费者,安装后的运行不依赖 core-py checkout、数据库模型或 +Extension runtime;也不通过 sys.path 或本地 path dependency 绕过这一边界。同仓库可以在同一 PR 中修改 +协议提供方与消费者,但 CLI 仍有独立的依赖、构建产物和版本。发布顺序需先使 Core 新接口可用,再发布使用 +它的 CLI。不因两个项目都是 Python 就另建第三个合同包。 + +本轮采用普通嵌套 PDM project,不配置 workspace。PDM 2.28.0 已有 experimental workspace:成员共享根环境 +和 lock,并被视为根项目的隐式 editable dependencies;它主要帮助需要一起解析本地包依赖的 monorepo。 +Core 与 CLI 没有 Python package 依赖,独立解析更直接,也能防止完整 Core 环境掩盖 CLI 缺少安装依赖。 +workspace 本身不必然破坏分发隔离,只是目前没有需要它解决的问题。 + +从仓库根显式选择子项目即可使用 PDM 的现成能力: + +```sh +pdm install -p cli +pdm run -p cli inkcre-cli --help +pdm build -p cli +``` + +以上为产品实施后的命令。当前 PDM 2.28.0 的 -p、独立环境路径与 wheel 安装已在临时探针项目验证; +见 [工具链预演](preflight-toolchain.md)。没有在产品 cli/ 创建代码,也没有复用父环境来证明依赖完整。 + +## 库选型 + +| 职责 | 选择 | 具体收益与边界 | +| --- | --- | --- | +| 命令树、argv、help | Click | 原生 command/group、可组合 option decorators 与调用上下文,适合多组管理命令;不自制命令解析框架 | +| 本机配置与输入 | Pydantic 2 | 在 CLI 自有输入边界使用;不借响应 DTO 再校验 Core 返回,不复制动态 Extension 业务校验 | +| HTTP | HTTPX 的同步 Client | 统一连接配置、超时、响应及资源清理;一次 invocation 复用 Client,Job wait 不反复新建连接 | +| JWT 签发 | PyJWT | 按 D-581 复用现有 JWT wire contract,不导入依赖 settings 的 Core middleware,也不自己实现 JWT | +| JSON、文件、接收端 MIME | 标准库 json / pathlib / tempfile / email.parser | 复用格式与文件能力,只保留 InKCre 自己的呈现和 multipart 字段关联 | +| 开发与构建 | PDM 2.28 + pdm-backend | 独立 src-layout package,产出 wheel / sdist;最终安装者不需要 PDM | + +CLI 直接运行依赖因而是 Click、Pydantic、HTTPX、PyJWT;标准库不形成安装依赖。建议 Python 下限 3.12,不把 +Core 的 `<3.13` runtime 上限复制过来。依赖具体范围与 Python 3.12.10 的干净安装已在预演验证;其它版本和 +OS 未作运行验证。不能把当前工作环境里的传递依赖当作独立 wheel 已声明完整。 + +argparse 可以完成该 CLI,SVC 也实际使用它;没有功能性缺陷需要否定。这里推荐 Click,是因为本 unit 已有 +多级命令组和重复公共选项,Click 的组合与 dispatch 可以直接使用,避免各命令自行管理 Namespace/调用分派。 +Typer 同样可行,Extension Toolkit 已在使用;但本 CLI 的主要业务输入是 JSON 和动态 schema,Python 函数 +注解转 argv 的收益较小。公共选项复用及既定错误呈现会直接使用 Click 的接口,因此建议直接依赖 Click。 +这不是组织级库统一,也不是对 Typer 的兼容性否定。 + +共同选项在实际 leaf command 上可用,保持既有示例 `job list --json`;`--connection` 等连接选项也应保持这种 +可发现性,不只定义在 root callback 后要求调用者换位置。选项通过少量普通 decorators 复用,不生成新的参数 +DSL。没有具体需要时不承诺任意 argv 位置等价。 + +Click 的默认退出和错误打印不等于 D-597:入口使用公开的 main(standalone_mode=False),让库处理 help/Exit, +调用方收口实际错误。2026-09-14 的[独立安装预演](preflight-toolchain.md) 发现手动 context/invoke 容易遗漏 +正常 Exit,已用该原生路径复验。`--schema` 可以在没有业务输入时返回、`--help` 不执行 callback、UsageError +可以交由入口处理;完整产品错误和中断路径仍由实现验收覆盖。业务输入只在执行分支加载;help 不读取连接、 +不连接 Core。公共输出模式不能通过扫描业务 JSON 字符串里的 `--json` 来判断。 + +HTTPX 使用同步 Client 即可承接当前请求响应与有界轮询;本轮不增加 async CLI framework。Recall 的独立模式 +与部分结果按 D-594/D-597 实现,不为选 HTTP 库额外承诺并行执行或改变等待/超时合同。 + +## 复用 REST 合同,不复制领域 owner + +```text +CLI commands ──→ CoreRESTClient ──→ Core REST ──→ 既有领域 owner + └────────→ CLI 结果呈现 / 文件交付 +``` + +`CoreRESTClient` 集中 base URL、JWT 签发、HTTP 生命周期、响应状态与 Content-Type 解码;command 保留对象选择、 +method/path 与 CLI 输入的映射。它不是 Peer outbound,也不为只有 REST 的当前实现增加 TransportBase。 +避免为每个 REST 方法再写一层只转发一次请求的 service。 + +Core Pydantic forms 与其 OpenAPI 是静态 HTTP 合同的 authority;Source/Job/Resolver/config 的 owner schema +通过已设计的发现接口提供动态合同。CLI 的 `--schema` 消费这些表示,不把 Extension schema 编译成 Python +classes,也不复制 Extension 方法清单。准确到 JSON 输入位置的 schema 投影仍归已确认 D-577 的实现工作。 + +Pydantic 保留在 CLI 的连接配置和自身输入边界。Core REST 已拥有业务返回合同,CLI 不再对响应调用 +model_validate / TypeAdapter.validate_python 或增加 JSON Schema 校验。Job wait 等需要读取 id/status 的 +代码可使用普通已解码结构与 TypedDict 等静态描述,不为了类型检查重新构造一套运行时响应校验器。 + +JSON/MIME 解码、multipart 字段关联、日期展示和 bytes 落文件是协议或呈现转换,不是业务合法性复核。 +`--json` 保留完整返回值,不经只声明少数字段的模型重新 dump,避免删字段、注入默认值或规范化 raw content。 +HTTP 错误与实际解码失败仍正常报告;信任返回不是把无法解析的内容当作成功,也不要求自行猜测、修复或重发。 +不为本来不需要的响应模型另造通用无校验构造器。 + +已查阅 datamodel-code-generator 的 OpenAPI → Pydantic 能力;本轮不选择生成整套 SDK 或抽取共享模型包。 +两者并非技术不可行,而是当前没有请求/响应 Python 对象跨进程直接复用的需要,也无法替代动态 schema 与 +multipart 关联。后续若稳定静态 DTO 的维护确实成为成本,再按消费范围采用现成生成器,不先建自有生成框架。 + +## multipart:发送与读取分别选适合的公开接口 + +沿用 [内容 HTTP 表示](content-transport.md) 的已确认合同。Core 侧建议显式声明当前已由传递依赖安装的 +aiohttp,使用公开 `MultipartWriter("related")`;JSON root 固定第一项,binary 使用 Content-ID, +`await writer.as_bytes()` 交给 FastAPI Response。它不引入 aiohttp server,也不改变 Core 的 FastAPI 入口。 + +CLI 用 HTTPX 取得响应,再用标准库 `email.parser.BytesParser` 读取 MIME;不需要随之安装 aiohttp。 +不同库实现同一 wire contract,不要求两端导入同一 Python encoder/decoder。双方仍需实现且仅实现 InKCre 的 +JSON Pointer 与 binary part 关联、结果形状选择及本地文件呈现。Core 要声明直接使用的 aiohttp 依赖,不能依赖 +另一个包恰好安装它。 + +本轮局部证据: + +- Python 3.12 的 `EmailMessage(policy=policy.HTTP)` 配合 `cte="binary"` 再 `as_bytes()` 会把 payload 内 + 单独 CR/LF 规范化为 CRLF,实际 bytes 改变。因此不能把这个标准库序列化路径用作 HTTP raw binary writer。 + 这不否定其 parser 对本合同的可用性。 +- urllib3 2.7.0 的公开 RequestField / multipart encoder 加标准库 parser 能保持 bytes;但它的外层声明是 + form-data,需要另行适配 related。本轮不推荐为此再选一套 encoder,保留为调查证据。 +- aiohttp 3.14.3 `MultipartWriter` → FastAPI 0.139.2 Response → HTTPX 0.28.1 ASGI → BytesParser + 已在内存中往返全部 256 个 byte 值、CR/LF/CRLF、空 bytes、Unicode JSON、普通 null 与 Content-ID。 + 未使用 Base64,也未增加 D-585 已排除的 start 参数。 + +这些只验证候选库的接合和 byte 保真,不是实际 Core/Resolver 的端到端验收,也不证明完整 JSON Pointer +关联、模型序列化或大结果文件交付已完成。当前 producer 已把内容驻留内存,不增加“端到端 streaming”的承诺。 + +## 分发与下一步 + +```sh +python -m pip install inkcre-cli +inkcre-cli --help +``` + +这是拟议的最终使用方式,不表示包已经发布。安装者取得普通 Python wheel,不携带 FastAPI、数据库 driver、 +AI runtime 或 Extension runtime。CLI 的版本独立于 Core,不要求两个版本号相等;本轮验收记录具体兼容的 Core +版本与 CLI 构建,不添加调用前版本握手、版本相等 gate 或历史兼容矩阵。 + +同仓库的 release/version 接合已按 D-599 确认,见[发布方案](release-and-distribution.md);各查询的长输出 +续读覆盖已按 D-600 确认,随后进入整体验收、实现计划与 preflight。 +PyPI publication authority 是后续实际交付前置项;本轮查询 inkcre-cli 返回 404 不等于保留名称或已有发布权限。 +无需新仓库配置。main 发布权限、feature branch → PR → main 和同次 release 构建遵循组织规范;CLI 不属于 +Extension 发布矩阵,复用既有版本准备流程而独立发布,不复制一套 Core release controller。 + +## 依据 + +本地核验:core-py `pyproject.toml` 的 `distribution=false` / `build-backend="none"`、 +`app/middleware.py` 的 JWT 签发、`scripts/generate-openapi.py`; +`../ext-reg/toolkit/pyproject.toml` / `src/inkcre_extension_toolkit/cli.py`; +`/Volumes/WorkSSD/Development/svc/svc_cli/pyproject.toml` / `src/svc_cli/cli.py`; +`../.github/GOVERNANCE.md` 与 `CONTRIBUTING.md`。组织内存在 argparse、Typer 和不同 build backend, +未发现一条要求所有 Python CLI 采用同一解析库的规范。 + +一手资料: + +- [Click 的命令组合](https://click.palletsprojects.com/en/stable/complex/) 与 + [异常/退出接口](https://click.palletsprojects.com/en/stable/exceptions/)。网站当前为 8.5.x,局部实验为已安装 8.4.2; + 只采用二者已有的公开机制,未宣称已验证未安装的新版本。 +- [Typer callback 的参数作用域](https://typer.tiangolo.com/tutorial/subcommands/callback-override/)、 + [HTTPX Client](https://www.python-httpx.org/advanced/clients/)。 +- [aiohttp Multipart](https://docs.aiohttp.org/en/stable/multipart.html)、 + [Python MIME parser](https://docs.python.org/3.12/library/email.parser.html)、 + [urllib3 Multipart](https://urllib3.readthedocs.io/en/stable/reference/urllib3.fields.html)。 +- [datamodel-code-generator](https://github.com/koxudaxi/datamodel-code-generator)、 + [PDM backend](https://backend.pdm-project.org/build_config/)、 + [Python CLI packaging](https://packaging.python.org/en/latest/guides/creating-command-line-tools/)。 +- [PDM workspace](https://pdm-project.org/latest/usage/workspace/) 与 + [虚拟环境](https://pdm-project.org/latest/usage/venv/);本机 PDM 为 2.28.0,root pyproject 未配置 workspace。 + +ponytail 在本轮用于排除没有实际收益的共享包、自制协议实现与生成框架;Python backend 技能用于区分 HTTP +合同 owner、CLI 消费模型与 runtime 依赖。两者不替代本任务的 review / implementation gate。 diff --git a/tasks/knowledge-lifecycle-capabilities/units/cli-sink/query-continuation.md b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/query-continuation.md new file mode 100644 index 0000000..1a75d22 --- /dev/null +++ b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/query-continuation.md @@ -0,0 +1,91 @@ +# 查询的长输出与续读覆盖 + +状态:2026-09-13 按 [D-600](../../decisions/D591-D600.md) 确认。D-597 已确认通用长输出原则,D-586 的文件交付、 +D-594 的检索边界及 D-598 的可信响应消费保持。这里检查整个 CLI 查询范围,不新增查询会话或通用分页框架。 + +## 枚举集合与交付一次结果不同 + +管理清单和图邻域可以继续枚举当前集合;recall 返回本次选出的候选,path/components 返回一次有界探索的 +结果,Resolver 则返回其方法定义的值。接口应提供适合这些结果的续读方式,而不是给每个操作机械增加 cursor。 + +| 查询范围 | 逐步读取方式 | +| --- | --- | +| Source、Cron、Agent、Peer、Extension、deployment config、AI model、embedding profile 列表 | 可选 limit/cursor,保留列表元素的既有 DTO | +| Source/Job type、Resolver、Agent Tool、config schema 目录和 Resolver methods | 同样支持枚举分页;精确对象与 schema 读取不变为目录遍历 | +| 本机 connection list | 同一可选分页语义,在本地清单上执行,不访问 Core | +| Job 历史 | 沿 D-597 的默认 20 条、Job ID 降序与 next_cursor | +| Block neighborhood | 复用现有 Relation ID cursor,每页仍包含所返回 Relations 的两端 Blocks | +| Relation neighborhood | 一个 Relation 与其两端,整体返回;若记录内容过大,沿文件交付读取 | +| lexical/semantic recall | limit 仍是各模式的候选数量;完整返回本次 matches,不伪造候选范围外的下一页 | +| graph path / components | 整体交付本次图与查询边界;长结果保存后分段检查,不切碎 GraphModel 冒充完整图 | +| 显式 ID 批量 get | 完成调用者提交的批次,保留顺序、重复引用与逐项错误;不在读取后静默丢掉后半批 | +| 单对象详情、单个 schema、Resolver invoke | 完整值交付;方法自身若有分页参数则按发现的合同传入,不另套 CLI 分页参数 | + +get 的调用者可以主动把 ID 分成多批,或明确选择 content=none,但 CLI 不偷偷替它修改请求。单个对象包含 +长数组、schema 或正文时,不因它不是 list 指令就忽略长输出;同样沿完整文件交付读取。本地文件不是服务器 +查询的下一页,不能用“文件已保存”宣称已经完整枚举尚有 next_cursor 的集合。 + +## 为所有清单补上可选分页 + +除 Job 沿既定默认 20 条外,保留 D-597 的小清单默认完整返回。调用者可用 --limit N 指定本次条数, +--cursor 指向上一页位置;REST 对应同名 query parameters。省略 limit 就返回 cursor 之后的剩余清单, +不增加另一个隐式上限。所有这些调用仍受默认可读呈现预算和显式文件交付控制。 + +为使 JSON 本身携带续页位置,这些枚举结果由裸数组调整为“领域清单 + next_cursor”。没有指定分页 +时也保持同一形状,不因结果大小或是否传入 limit 改变类型。它不是所有命令的 success/data/meta 包装。 + +```sh +inkcre-cli source list --limit 10 --json +inkcre-cli source list --limit 10 --cursor 42 --json +``` + +示意结果为 {"sources":[...],"next_cursor":42};没有后续记录则 next_cursor=null。其它列表使用自身的 +清单字段,例如 agents、peers、configs、resolvers;Resolver methods 在既有 resolver/methods 对象上加 +next_cursor。详情、schema 精确查询、Resolver 返回值、批量实体结果和 recall 模式数组不跟着改成分页包装。 + +除已有 Job/邻域降序外,沿 D-597 的自然 identity 升序。数字 ID 使用数值,Peer 使用 UUID,配置使用 key, +Extension 使用完整 coordinate,类型/Tool/Resolver 使用 exact ID,方法和本机连接使用 name。cursor 就是 +该查询最后返回的 identity;不增加 Base64 编码、签名或游标存储。返回格式保留实际 identity 的 JSON 类型。 + +有 limit 时读取 limit + 1 个元素判断下一页,不计算 total;分页条件与排序使用同一个 identity。数据库查询 +留在相应 Manager;内存目录或 Host 已提供的完整清单可以排序后切页,不为此改外部 Extension Host 协议。 +不存在全局 QueryManager,不要求调用者理解每个清单的存储方式。精确 schema 查找仍按已知 selector 取得, +不能先分页后在第一页里寻找目标并误报不存在。 + +分页是实时读取,不是跨请求快照。删除上一页的 cursor 对象后,后续比较仍可继续;并发增加、删除或修改 +过滤字段可能改变后续可见集合。保留相同过滤条件、显式决定是否继续由调用者负责;不自动取完全部页面, +不增加分页重放或稳定快照机制。字符串的比较与排序必须由同一实现完成,不能混用数据库和 CLI 的不同排序。 + +## 有界查询和完整内容不增加第二份生命周期 + +目前 lexical limit 最大 20,semantic limit 最大 100,混合 recall 的 limit 对每个模式独立生效。 +这两个值来自当前 schema,不是 CLI 新增限制。它们没有下一页合同;本轮不重开检索算法,也不因打印预算 +重新生成 query embedding,或以逐次扩大 top-k 的方式伪装 continuation。 + +Graph path 的 limit_reached 和 components 的 truncated 表达本次探索边界,不是保存了待续跑的遍历。 +调用者可明确修改下一次查询,但那是新查询。Block neighborhood 则已有真正的 next_cursor,直接沿用; +每一页保持端点闭合,不能只为减少字数把 Relations 与它们的 Blocks 拆到互不完整的页面。 + +默认可读输出超预算时,按 D-586 保存同次结果的完整入口文件并给有限预览;Agent 可用自己的文件工具或 +shell 对该文件逐段读取。--output-dir 可主动选择这种交付。--json 保持完整紧凑 JSON,不自动替换为路径、 +不截断;实际 bytes 仍用 file 引用。不增加 CLI 专用文件阅读器、结果缓存数据库或服务器 TTL。 + +Resolver 结果尤其不能通过重复调用取得“下一段”:get_solved_content 等方法可能 materialize,重复执行 +不是对同次返回的续读。若一个具体 Resolver 方法本身定义了 cursor/limit,这些仍是方法 arguments,按其 +动态 schema 调用,而不是 CLI 猜测所有方法都可分页。 + +## 核验依据与后续验证 + +- app/business/graph_navigation_retrieval/main.py::get_block_neighborhood 已按 Relation ID 降序取页,返回 + next_cursor,并读取两端 Blocks;Relation neighborhood 最多一条边与两个端点。 +- app/schemas/{lexical_retrieval,semantic_retrieval}.py 定义各自 limit;SemanticRetrievalManager.retrieve_local + 每次执行 AIManager.embed,随后选出本次 matches,没有查询结果会话。 +- app/schemas/graph_navigation_retrieval.py 中 path/components 没有 continuation;components 的 proof_graph + 与成员集合必须和同次探索结果一起解释。 +- app/schemas/source/main.py、cron.py、agent.py、ai/main.py 的管理记录使用数字 ID;PeerRef 为 UUID, + deployment config 使用 key。app/routes/extension.py::list_extensions 当前直接返回 Host 的完整记录。 +- 当前 API 并未普遍具备本页的分页能力;不能因 packet 已列举全部命令就宣称实现覆盖完成。 + +实现计划应逐项核对“分页输入、查询 owner、续页输出、长字段文件交付”,而不是只检查 Job。预演选择一个 +持久清单、一个动态目录、已有图邻域和一次较大 Resolver 结果检查完整调用链;运行验收沿手工/脚本黑盒, +不为每种清单的相同包装和映射新增自动化测试。CLI 响应继续只解码与转换,不按 Pydantic 再验证业务输出。 diff --git a/tasks/knowledge-lifecycle-capabilities/units/cli-sink/release-and-distribution.md b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/release-and-distribution.md new file mode 100644 index 0000000..7339025 --- /dev/null +++ b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/release-and-distribution.md @@ -0,0 +1,118 @@ +# CLI 版本与发布接合 + +状态:2026-09-13 按 [D-599](../../decisions/D591-D600.md) 确认。项目位置与协议边界沿 D-598;本页不增加 +实施、commit、push 或 PyPI 发布授权。 + +## 同一版本准备流程,独立的发布产物 + +CLI 作为 scripts/release.py 中的独立 ReleaseProject,key 为 cli;它不是 Core,也不是 Extension。 +复用现有 Towncrier 25.8.0、root towncrier.toml 与单一 release/next PR,不增加 CLI release controller。 + +| 项目 | 版本 / changelog / pending fragments | 发布目标 | +| --- | --- | --- | +| Core | root pyproject.toml / CHANGELOG.md / .changes | 既有 Core 镜像与部署 | +| CLI | cli/pyproject.toml / cli/CHANGELOG.md / cli/.changes | PyPI inkcre-cli wheel / sdist | +| 各 Extension | 各自目录内的同名文件 | 既有 Extension Registry | + +```text +feature PR:源码 + 所属项目的 fragments + → main:现有 controller 更新 release/next + → Release PR:只准备有 pending intent 的项目版本和 changelog + → main:各发布入口独立选择自己的新版本,重新构建并发布 +``` + +只改 CLI 不提升 Core 或 Extension 版本;跨 Core REST 与 CLI 的实际变更各自提供 release intent。同一个 +Release PR 可以包含多个项目,但版本号不必相同,也不以共同发布成败建立多产物事务。 + +CLI fragments 使用已有 added/changed/deprecated/removed/fixed/security 类型与 maturity-aware bump 规则。 +初始开发版本为 0.0.0,由首个 added fragment 经正常 Release PR 产生 0.1.0,不在 feature PR 手动准备 +首个正式 changelog。项目刚加入仓库不等于已准备发布;新的 PyPI 路径不因 base 中尚无 CLI 就上传开发版本。 + +## release.py 的必要调整 + +当前 ReleaseProject 已按 directory 访问 version、fragments 和 changelog,prepare 也逐项目执行 Towncrier, +大部分准备逻辑可以直接复用。实际不通用的部分是: + +- _project_from_pyproject 的非 Extension 分支硬编码 key=core;需要让普通项目明确自己的 key/目录,不能 + 把 cli 当成 Core 的别名。 +- discover_projects 目前只列 root Core 和 Extension directories;加入 cli 这个明确项目即可,不扫描任意 + pyproject 或增加通用 package dependency graph。 +- Release PR 的允许路径目前写成 root + extensions;按已发现项目的 directory/changelog/fragments 推导, + 不另抄一套 cli 路径列表。 +- 新项目的初始 CHANGELOG 文件不能被误认成 feature PR 已经准备一次 release。当前 check 会把新增文件 + 也算作 changelog_changed,需要结合 base 是否已有该项目区分初始文件与已有版本的 changelog 修改; + 不能为了通过检查省去 prepare 所需的文件,或增加迁移名称特判。 +- affected_projects 要识别 CLI 交付源码及影响安装/入口的声明,同时保留各项目的责任。不能把 cli/** 的 + 任意改动都当作必须发版;task、测试、贡献指南与纯工具链调整不虚构 CLI release note。依赖声明与开发配置 + 的区别在实现预演中核对,不为此建立新的 CLI 专属 gate 或两次构建比较框架。 + +--extensions-only 继续只返回真正 Extension;CLI 不进入 extension-wheels、Extension preview inventory、 +Registry metadata/prepare/upload/publish,也不把 CLI sources 加进 Core runtime 的安装依赖。 +release tooling 本身在仓库根运行,不等于 CLI wheel 依赖 root package。 + +本轮不借加入 CLI 重写已有 SemVer 算法或 Extension 发布协议。现有事实不等于已验证本项扩展完成;修改范围 +进入后续 implementation plan,并用现有命令和隔离的准备演练检查,不新增 helper/schema 自动化测试。 + +## CLI 的检查与正式发布 + +PR 检查新增独立的 inkcre-cli checks,选择 cli/ 的 PDM project,检查 lock、lint、静态类型和 wheel/sdist 构建。 +不靠装有完整 Core 的环境证明 CLI 依赖完整;干净环境安装、实际命令与真实 REST 的黑盒验证由验收脚本承担, +不在本轮提升为自动化 E2E。Core 原有检查继续负责其变化,不把 CLI 检查伪装成 Extension wheel 检查。 + +新增 .github/workflows/cli-publish.yml。自动路径只选择已经存在的 CLI 项目在 main 上发生的 version 变更; +普通 feature merge、文档变化和首次加入开发版本均不发布。它从明确 main SHA 自行执行 CLI 检查与构建, +上传这一 release run 的产物,不消费 PR CI 上传的 wheel,也不从 Core image 中取 wheel。 + +```sh +# 下列是实施后的命令形状,本轮未执行。 +pdm install -p cli --frozen-lockfile +pdm run -p cli check +pdm build -p cli +pdm publish -p cli --no-build +``` + +构建与上传分开,避免验证过产物后再次隐式重建。使用 PDM 已有 Trusted Publishing 支持;不手写 GitHub OIDC +换 token,不增加新的发布 SDK。Actions 只拥有触发、并发、环境、工具设置及调用;选择和构建逻辑放可独立 +运行的仓库命令。上传 job 使用现有 production environment 与标准 id-token: write 配置。 + +同一 CLI 发布入口串行,不取消正在上传的 run;保留 main-only 的 workflow_dispatch 作为恢复入口。不照搬 +Extension Registry 的 INITIAL_ONLY / Release association 协议。重复/部分上传使用 PyPI 与 PDM 的标准机制, +必要时显式 --skip-existing 续传缺失文件并报告跳过项;不自制 hash 对账或后台补发服务。准确的恢复操作在 +preflight 与发布文档中说明,不能把不同 run 的重新构建声称为 byte-identical。 + +CLI 的常态发布不永久等待 Core deployment workflow,也不读取 Core 数据库、Peer advertisement 或要求两边 +version 相等。若某个 CLI 版本新增了对新 REST 的依赖,在对应变更的交付顺序与兼容说明中体现。 + +## 首发顺序和实际前置条件 + +本 unit 按先 provider、后 consumer 的顺序安排首次交付:先合入并发布 Core REST / Job 等增量,再合入 +CLI 首发代码与发布入口,最后由 CLI 的正常 Release PR 发布 0.1.0。两者仍属于同一 unit,可以在开发和 +preview 中联合验收;这只是同仓库 PR/发布的顺序,不新增 implementable unit 或通用跨项目 release scheduler。 +如 implementation plan 选择先合入 CLI 开发代码,也必须保留首次 PyPI 发布在 Core 接口实际可用之后的人工 +交付顺序,不能为此把永久 CI/CD 依赖硬编码到 CLI 发布路径。 + +PyPI 需要配置 inkcre-cli 的 pending Trusted Publisher:GitHub owner InKCre、repo core-py、workflow +cli-publish.yml、environment production。首次正常上传可创建项目;不必先在本机手工上传一个版本。 +该配置仍需账号 owner 的实际操作与发布授权;目前没有读取或修改 PyPI 账号,也没有证明发布权限已具备。 +pending publisher 不保留包名,名称状态在真正发布前再确认。 + +首发闭环包括:Core 新接口可用 → CLI 独立构建与真实 REST 验收 → 正式 PyPI 发布 → 从 PyPI 在干净环境 +安装指定版本并重复关键旅程。具体 journey 仍由下一阶段验收合同冻结,不把发布 HTTP 成功等同于产品验收。 + +## 依据与后续检查 + +已读取 scripts/release.py、scripts/automation/release_pr.sh、extension_publication.sh、repository_check.sh, +以及 .github/workflows/release-pr.yml、extension-publish.yml、production-deploy.yml、ci.yml;CONTRIBUTING.md、 +docs/40-deployment/native-extension-distribution.md 与组织 GOVERNANCE.md。当前 Root Towncrier 已固定 25.8.0。 + +Dockerfile 当前采用显式 COPY,尚未复制 cli;新增 .dockerignore 的 cli/ 排除可避免把无关开发文件发入构建 +context。Root Pyrefly 使用显式目录列表,CLI 检查应留在自己的环境;Ruff/pre-commit 的扫描范围需要在 +实施预演核对,防止父子项目重复执行不同配置。这些都是接合检查,不扩展成仓库 cleanup。 + +一手资料:[PDM Build and Publish](https://pdm-project.org/latest/usage/publish/) 确认 Trusted Publishing 与 +--no-build;本机 PDM 2.28.0 publish help 确认 -p、--no-build、--skip-existing。 +[PyPI pending publisher](https://docs.pypi.org/trusted-publishers/creating-a-project-through-oidc/) 说明首发创建与 +名称不被预留;本轮没有执行这些外部写入。 + +ponytail 在本轮收敛了复用范围:现有版本准备 controller、现成 PDM uploader,新增 CLI 所需的项目身份与 +PyPI 交付路径;不新增发布框架。查询续读后已获 D-600 确认,后续交付验证纳入 [Acceptance](acceptance.md)。 diff --git a/tasks/knowledge-lifecycle-capabilities/units/cli-sink/research.md b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/research.md new file mode 100644 index 0000000..835a06d --- /dev/null +++ b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/research.md @@ -0,0 +1,139 @@ +# CLI 首轮研究与现状 + +2026-09-13。本文记录设计依据与候选方向,不拥有批准状态;当前阶段和复核面见 [unit packet](packet.md)。 +读取了 parent 的 design-taste、collaboration 和 common-patterns/agent-tools。未联系其它 session。 + +## xiaoland/svc 的直接经验 + +读取本地 `/Volumes/WorkSSD/Development/svc`,origin 是 `https://github.com/xiaoland/svc.git`: + +- `tasks/agent-friendly-protocol/research.md` 的 Approved Common Contract、Primary Research Evidence; +- `tasks/agent-friendly-protocol/lookup-review.md` 的默认输出、结构化输出和自足 help 设计; +- `tasks/agent-friendly-protocol/interface-topology-review.md` 的命令准入与职责分析; +- `USER_MANUAL.md`、`docs/product-tdd/agent-analysis.md`、`svc_cli/src/svc_cli/cli.py`; +- 实际运行 `svc status .`、`svc lookup --path index.md`、`svc analysis --help` 和 `svc analysis query --schema`。 + +最有价值的经验不是指定一种序列化格式,而是先决定信息选择,再决定呈现;两者共同受内容语义、Agent 的消费 +方式和调用目的影响。Exact Markdown 读取可以直接返回 Markdown,可比较的候选需要保留完整引用,脚本计算 +适合稳定 JSON,大内容需要可继续读取的位置。呈现影响调用者能否识别关系,不能当成装饰。 + +SVC 的普通命令把默认文本定位为 Agent/Human 的阅读界面,把 `--json` 定位为脚本/CI 的显式选择;analysis +query/read 则使用结构化请求与 JSON-first 返回。当前实现验证了按能力选择形式,而非所有命令共享一种包装。 +其 CLI help 自足,不要求先加载 Corpus 或另装 Skill 才能理解命令;schema 按命令查询。命令树按用户意图、 +不同结果与生命周期组织,表面整齐和顶层数量都不是目标。 + +这些是 SVC 场景中的经验,不照搬其 next-command 提示、exit code 表、schema version、无 Skill 选择或测试体系。 +InKCre 已有的 Agent Tool 模式仍约束本任务,尤其不能为了引导而增加 `next_request` 或固定工具调用 SOP。 + +## 公开资料及证据限度 + +| 一手材料 | 可迁移的依据 | 限度 | +| --- | --- | --- | +| [GitHub CLI formatting](https://cli.github.com/manual/gh_help_formatting) | 普通文本与显式 JSON 共存;结构化结果可交给后续程序 | 不意味着 InKCre 要复制内嵌 jq 或模板引擎 | +| [GitHub CLI api](https://cli.github.com/manual/gh_api) | 领域命令之外仍可提供 HTTP 入口;`--input` 支持文件与 stdin,query 和 body 分开 | 不是把整个 REST API 自动展开为领域命令的证据 | +| [Justin Poehnelt:Rewrite Your CLI for AI Agents](https://justin.poehnelt.com/posts/rewrite-your-cli-for-ai-agents/) | 复杂输入保留 JSON 结构、运行时 schema 发现、控制返回体积 | 作者经验,不是所有 Agent 的对照实验;不引入其整套安全、Skill 和 dry-run 设计 | +| [Zbigniew Sobiecki:Building Agent-Friendly CLIs](https://zbigniew.me/writing/building-agent-friendly-clis/) | 可组合结果、真实 ID、stdout/stderr 分工、暴露截断,减少重复查询和猜测 | JSON 偏好与统一 envelope 是其选择;分页需服从具体 domain,不给所有 retrieval 强加分页 | +| [Reddit:Designing an agent-friendly CLI](https://www.reddit.com/r/AI_Agents/comments/1v2gjax/designing_an_agentfriendly_cli_what_am_i_missing/) | 实践者讨论 help、schema、文档与组合路径的使用困难 | 自报告与意见用于发现压力,不能当作标准或定量有效性证明 | +| [EACL 2026:How Good Are LLMs at Processing Tool Outputs?](https://aclanthology.org/2026.eacl-long.134/) | 对 15 个模型的研究表明复杂 JSON 的处理效果依赖内容、大小和推理任务 | 不证明简洁文本普遍优于 JSON,也不是 InKCre CLI 的验收结果 | + +## 对本 unit 的候选推论 + +1. **能力发现有层次。** 根 help 帮助选领域,子命令 help 说明意图、输入、效果和结果;动态 Resolver 方法按需 + 发现。合同来自运行中的能力 owner,不在 CLI 维护 Extension 方法清单。 +2. **结构与阅读各得其所。** 原文保留自然文本;异构实体、引用和批次结果适合结构化形式。提供可脚本消费的 + 稳定输出,但不因调用者是 Agent 就强制 JSON,也不因 stdout 非 TTY 就推断其只想读 JSON。 +3. **嵌套输入不需要独创语法。** 简单选择用普通 argv;GraphForm 和 Resolver 参数保留 JSON,文件/stdin 避免 + 层层 shell escaping。是否需要额外 input schema 查询由实际复杂度决定。 +4. **有限输出仍应可取回完整内容。** 检索候选不冒充完整 Block;大文本/bytes 有明确读取或下载路径。保留领域 + 原有的截断/未命中语义,不把一页、上限或未知状态写成“全部”。 +5. **呈现不歪曲执行。** 非交互调用可完成,诊断与数据流区分;创建 Job 只确认已创建,完成状态从 Job 查询。 + 失败保留有用原因,批次独立项按已有合同处理;不自动重试整批写入或制造 CLI 自己的业务完成状态。 + +评价这些推论要看真实 Agent 能否完成任务及如何恢复参数错误,不能以少几个字符、少一个命令或更多 schema +证明改进。命令名、模式、exit codes、认证和文件输出细节仍待产品范围明确后设计。 + +## core-py 当前 API 证据 + +基线 `b3ccb00ca2e235bfcc9b9f4f4cc17948c59ef54a`。实际检查了 `run.py` 的 router 组合与 `app/routes/*.py`, +没有依据 `docs/openapi.json` 推断已存在未挂载的业务能力。 + +| 能力 | 当前 REST 证据与缺口 | +| --- | --- | +| 词法、语义检索 | `app/routes/lexical_retrieval.py`、`semantic_retrieval.py` 已有 POST 入口;也是对应 Peer inbound 的物理承载 | +| Graph 导航 | `app/business/graph_navigation_retrieval/main.py` 已有领域能力,普通 REST 路由尚未挂载 neighborhood/find_path | +| 基础实体与写入 | `block.py` 有 recent/get/create/patch;`relation.py` 有 create/by_block;`info_base.py` 有 GraphForm 提交。不是完整对称 CRUD | +| Resolver 读取 | MCP `app/business/sink/mcp.py` 已实现读取和动态方法发现/调用;普通 REST 无等价完整入口,CLI 不能直接 import MCP runtime 来获得这些能力 | +| Source / Organization | `source.py` 创建 collect/backfill Job;`organization.py` 的 ruminate 是 Peer inbound,不是 CLI 的任务受理接口。其它已实现 Organization 行为需要按真实 owner 核对,不能以这个旧路由枚举全部能力 | +| Job | Core 有 Job domain;当前 router 组合没有通用 Job 查询路由,CLI 不能在创建后通过数据库自行轮询 | +| 管理 | `extension.py`、`sink.py` 有管理接口;`deployment_config.py` 有按 key get/put/patch。可用接口不等于本 unit 全部纳入 | +| 接入 | `run.py` 的 Core router 使用 `require_peer_jwt`。D-581 确认 CLI 用 JWT secret 自签;验证不要求 peers 登记,不因此把 CLI 当作完整 Peer | + +Sir 已允许重整普通 REST,以 CLI 为首个普通消费者。已核实的现有交点是 client-web 的 +`packages/core/src/semantic-retrieval/main.ts`、`lexical-retrieval/main.ts`、`organization/main.ts` 调用 +`PeerManager.delegate`;对应 core-py routes 声明 `PEER_INBOUND`。这些是 Peer 协议消费者,而非本 unit 所说 +的外部 REST CLI。接口设计须覆盖这个真实交点,不把全部旧 REST 当作不可改变的基线。 + +这是设计准备,不是完整 preflight。CLI package 的物理位置、依赖与最低 Python、安装命令、Core 接口兼容、 +认证、内容传输、PyPI 名称/发布权限和各旅程的实际入口,仍需在相应设计阶段核验。 + +## 管理范围扩展后的核验 + +同日依据 D-573 检查 `app/business/peer/main.py`、`app/schemas/peer/main.py`、`app/business/extension/` 的 +局部指南与 Host、`app/business/source/main.py`、`app/business/cron.py`、`app/business/job.py` 及对应 schema, +并读取最新 `docs/30-unit-tdd/organization.md`。 + +- PeerManager 已有 get/get_all、自身 publication/lease 与 delegation,无 wake 方法;CorePeerConfig 已有 + `http_public_base_url`。Lease 不表示“休眠”;普通 row 更新也不会让进程启动。 +- Extension Host 已实现部署安装记录与 Peer-local enable/disable;`app/routes/extension.py` 可作为 REST + 重整的现有实现依据,不将启用意图与 running 混为一谈。 +- SourceManager 已有 create 与 type/schema 注册;编辑/删除/查询需要补齐对应 domain/REST 接口。 +- CronManager 已有 create/update/run_now/check;CronModel 保存 schedule、job_type、job_parameters、timeout、 + enabled 和调度游标。Source 不保存 schedule。Deployment `core.cron` 的 timezone 默认为 UTC。 +- 已实现 Organization 的七种自动行为分别拥有 exact Job handler 和配置;显式 rumination 的旧 REST 路由 + 不代表这些现有能力全部已经公开。CLI 的实际支持从当前 behavior/Job 合同取得,不重建组织行为 registry。 + D-595 核验补充:已有 rumination automatic Job 的输入只有 max_seeds,不支持指定 Block;本轮需要补齐 + 显式 Job type,不能以立即执行门面或 Peer inbound 代替 CLI 的 Job 受理语义。 + +`docs/40-deployment/render-neon-self-host.md` 已记录:先打开 Core `/readyz` 才能唤醒休眠服务,过期的数据库 +lease 不会触发它启动;错过的 Cron 不补跑。[Render 官方说明](https://render.com/docs/free#spinning-down-on-idle) +确认 Free web service 空闲后停止,新的普通 HTTP 请求可触发恢复。这个事实支持 HTTP wake 的具体场景, +不证明任意 hosting platform 或关闭的 native Peer 均可通过 HTTP 启动。 + +## D-575 后的接口核验 + +- `app/schemas/info_base/block.py` 的 `BlockModel.get_hydrated_content()` 返回 inline str 或经 Storage 加载的 + bytes,不改持久 content。`get` 的 hydration 选项可沿用它;solved content 是 Resolver 的另一个 owner, + 由 `resolver invoke` 承接,不保留独立 `read` 命令。 +- `app/schemas/agent.py` 的 `AgentDefinitionModel` 持久化于 `agents`;字段为 name、system_prompt、tools、 + tool_choice、model 和 max_model_calls_per_turn。`app/business/agent/main.py` 在 run 时绑定 Tool 并创建 + Thread 快照。当前无普通 Agent definition 管理 REST;此管理范围不等于增加 Agent 对话/Thread 管理。 +- `app/business/organization/rumination.py` 等行为向 `DeploymentConfigManager` 注册 owner 的 Pydantic + schema;`BehaviorAgentConfig` 保存 `agent`。媒体解释配置另有 image/audio/video Agent 选择,不能把所有 + behavior config 强制裁成同一形状。现有 `app/routes/deployment_config.py` 已有按 key get/put/patch, + 但 key/schema 发现及 definition 所引用 model/tools 的发现仍需补齐或核对,不在 CLI 复制目录。 +- `app/schemas/job.py` 有 `ABORTED`;`app/business/job.py` 的 run/check 仅有执行、超时及失败收口,没有主动 + 取消入口或跨执行端停止链路。源码检索 `ABORTED|aborted|abort_job|cancel_job` 也未找到其执行实现。主动停止 + 是本 unit 的技术缺口,不是现成 endpoint 包装;限时 wait 本身仍可只观察同一 Job,不改执行期限和状态。 + +本轮 ponytail 用于核对以上技术复用边界;仅更新 packet,未变更源码或 durable docs。 + +## D-588 后:Agent 与配置的发现边界 + +2026-09-13 进一步核验:AgentManager 的 Tool registry 为 peer-local;AIModel 与 AgentDefinition 是共享持久 +记录。不能把本机能否绑定、执行作为 definition 保存的前置条件。具体方案见 +[Agent definition 管理](agent-management-rest.md),后按 D-589 确认,模型发现归 ai 命令组。 + +DeploymentConfigManager.register_schema 仅登记 schema ID 与 ConfigContract,没有 config key 对应关系; +业务模块另持有 CONFIG_KEY 常量。GET /configs 只能发现已经存在的记录,不能解决首次配置一个行为时的 key +发现。后续方案需要让 owner 提供这个信息,不能从 schema 字符串裁剪推导、在 CLI 硬编码,或新建 organization +统一 dispatcher。后续 [配置 REST 提案](deployment-config-rest.md) 建议同一 schema 注册增加 keys 元数据, +不增加独立 registry;已按 D-590 确认。 + +另一个待收敛点是管理读取:DeploymentConfigManager.read 目前会验证并规范化记录,unknown schema 或非法 +持久 value 会使 GET 返回 409。这不同于供业务消费的 typed get;CLI 管理需要能查看和修复实际保存的值。 +源码调用核验后的初稿曾建议 typed get 继续校验;D-590 纠正了这一推论:typed 返回不意味着重复校验已经 +持久化的数据。管理读取保留实际值,PATCH 校验合并后的完整候选;typed get 的表示转换与移除重复校验一起 +进入实现预演,不把源码现状固化为设计。以上尚未实施。 + +Organization config 模型仍由行为 owner 提供;大多数行为为 {agent: int},媒体解释为 image_agent、audio_agent、 +video_agent。配置写入不经 AgentManager 或行为执行入口;本 unit 不为这些 JSON 引用增加 FK、删除联动或预执行。 diff --git a/tasks/knowledge-lifecycle-capabilities/units/cli-sink/retrieval-graph-rest.md b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/retrieval-graph-rest.md new file mode 100644 index 0000000..85c16c7 --- /dev/null +++ b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/retrieval-graph-rest.md @@ -0,0 +1,104 @@ +# 检索与 Graph REST + +状态:按 [D-594](../../decisions/D591-D600.md) 确认;本页只收敛 CLI / 普通 REST 的新增合同,不重开检索算法或 graph +持久化设计,尚未实施。 + +## Recall 的模式组合 + +普通 REST 分别提供 `POST /retrieval/lexical` 与 `POST /retrieval/semantic`,调用对应 Manager.retrieve。 +请求沿用 LexicalRetrievalRequest 与 SemanticRetrievalRequest;可选 query parameter route_to_peer 交给领域 +门面,不写进业务 body。省略时沿用当前门面的本机执行行为;不增加自动切换能力或新的 Peer capability。 + +既有 `/lexical-retrieval`、`/semantic-retrieval` 是已发布的 Peer inbound,继续调用 retrieve_local,保持原 +请求/响应及路径。不能将这两个 handler 直接改为可委托入口,再让目标 Peer 递归调用同一门面。新增普通 REST +与既有 Peer 路径可以共享同一领域实现,不按 HTTP 客户端、JWT 或隐藏 header 猜请求意图。 + +```sh +inkcre-cli recall 'Agent 工具' --mode lexical --mode semantic --limit 10 +inkcre-cli recall '如何减少工具调用的认知负担' --mode semantic --profile 7 +``` + +mode 必须显式选择,重复的相同 mode 按首次出现去重;只发对应请求。两种模式使用同一已选 connection, +可以并发请求,输出仍按选择顺序。limit 是每个模式的限制,不是合并后的总数,沿各 owner 的合法范围校验。 +semantic 的 profile、min_score、entity_types 由相应选项映射到现有 profile/options;不支持它们的模式不能静默 +吞掉这些选项。CLI 可提供 --peer,将同一个明确目标分别传给已选检索请求;不是全局执行 Peer 设置。 + +CLI --json 的顶层始终是模式结果数组,即使只选择一种模式: + +```json +[ + {"mode":"lexical","matches":[]}, + {"mode":"semantic","profile":7,"metric":"cosine","matches":[]} +] +``` + +每项平铺对应 REST 结果并带 mode。lexical match 保留 block、label、excerpt、evidence、rank;semantic match +保留 type、entity、score,模式结果保留 profile 和 metric。不同模式独立保留命中,不跨模式比较分数、去重 +实体或重新排名,也不在 Core 增加一个聚合 RecallManager / recall endpoint。普通 REST 单项结果不添加 mode。 + +某一模式失败时,同位置为 `{mode, error}`,已取得的其它模式结果不丢弃。D-597 已确认保留逐项输出并在 +存在错误时退出 1;不把错误冒充空 matches,不自动重试另一种模式。大结果按已确认的同次 +文件交付处理,JSON 不为体积而删除 Block content。 + +显式 profile 选择需要配套只读发现:`GET /embedding-profiles` 与 `GET /embedding-profiles/{id}`,CLI 提供 +`embedding-profile list/get`。返回现有 profile 记录,不增加写入管理、可用性探测或配置表。该接口由已有的 +SemanticRetrievalManager 提供;当前 profile 消费与选择已在这里,不因模型声明在 schemas/ai 内就把它推给 +AIManager。模型本身仍由已确认的 ai models 发现。 + +## 图查询 + +| CLI | 普通 REST | 参数和结果 | +| --- | --- | --- | +| graph neighborhood block:42 | GET /blocks/42/neighborhood | query 为 direction、contents、limit、cursor;返回 BlockNeighborhood | +| graph neighborhood relation:8 | GET /relations/8/neighborhood | 无额外遍历参数;返回 RelationNeighborhood | +| graph path --from 42 --to 84 | POST /graph/path | body 为 from_block_id、to_block_id、direction、contents、max_hops、max_explored_blocks;返回 PathResult | +| graph components 42 84 | POST /graph/components | body 为 seed_block_ids、contents、max_explored_blocks、max_explored_relations;返回 ConnectedComponentsResult | + +邻域 query 中 contents 使用重复 query parameter;复杂图查询用 JSON body,不给 GET 塞 body,不发明关系 +过滤 DSL。REST Form 的 from_block_id/to_block_id 映射到现有 find_path 的 from_block/to_block。components +仍要求选择关系 contents;不把它扩成全图 community detection。默认值和边界沿用当前 Manager/schema。 + +上述结果沿用 GraphModel 的结构合同:graph.blocks 与 graph.relations 包含已读取的持久记录,关系端点闭合; +不调用 Resolver,不 hydrate,不追加 preview/布局。普通 REST 的 Relation 记录字段沿 D-583 使用 +from_block_id/to_block_id,只在边界映射;不改领域 RelationModel 或既有 Peer wire。其它检索结果中嵌套的 +Relation 记录也用同一普通 REST 投影,避免不同 CLI 命令为同一字段创造不同输出名称。 + +BlockNeighborhood 保留 focal_block、graph、next_cursor;RelationNeighborhood 保留 focal_relation、graph。 +单个 focal 不存在时返回 404。path 保留 found/not_found/limit_reached;components 保留 components、proof_graph、 +missing_seed_block_ids、truncated。未找到路径或达到探索边界仍是正常查询结果,不映射为 HTTP 服务故障。 +这些字段说明一次查询所得的证据与界限,不是新增业务状态,也不让 CLI 自己重新遍历补齐结果。 + +GraphNavigationRetrievalManager 已可直接操作接入 Core 的共享数据库,因此这些查询不增加 --peer 或 Peer +capability;CLI 自身仍不接触数据库。当前模式不需要随机读取 endpoint 或全量导出 graph。 + +## Graph 写入 + +`graph submit --input graph.json` 对应 **POST /graph**。body 继续使用现有 GraphForm,不包一层 graph,不将 +GraphModel 读取结果直接当作 producer form。成功 200 返回既有 SubmitGraphResult,例如 +`{"blocks":[{"local_id":-1,"id":42}]}`,不为成功回执强制重读完整图。 + +当前 `PUT /graph` 调用的 submit_graph 每次创建新的 Block,所以同一表单重复提交不是替换同一 graph 状态。 +将它改为 POST 符合实际的追加 command;不增加独立 Graph row、幂等 key、自动补发或兼容别名。 +这里选择 200 表达 command 的完成,沿用可能为空的既有结果,不伪造一份可由 Location 寻址的“本次 Graph”资源。 +GraphForm 的签名引用、字段与持久化行为保持其现有合同,最终公开 schema 必须与提交输入一致。 + +[RFC 9110 §9.3.3–9.3.4](https://www.rfc-editor.org/rfc/rfc9110.html#section-9.3.3) 将 POST 的请求内容交给 +目标资源按自身语义处理,而 PUT 表达创建/替换目标状态并具有幂等意图;这支持此次 method correction, +不是要求所有 append 操作都实现新的业务幂等机制。 + +## 核验依据与后续落点 + +- app/routes/{lexical_retrieval,semantic_retrieval}.py:现有 HTTP 路径就是 non-delegating Peer inbound。 + app/business/{lexical_retrieval,semantic_retrieval}/main.py 已有带 route_to_peer 的 retrieve 门面。 +- client-web/packages/core/src/{lexical-retrieval,semantic-retrieval}/main.ts 使用 Peer delegation;普通 REST + 的新 DTO 投影不能直接替换它们消费的 Peer response。 +- app/business/graph_navigation_retrieval/main.py 与 app/schemas/graph_navigation_retrieval.py 已拥有本批图查询 + 和结果;当前 run.py 未挂载相应普通 REST route。 +- app/routes/info_base.py 的 PUT /graph 调用 InfoBaseManager.submit_graph;该方法逐项调用 BlockManager.create, + 最后只返回本地负 ID 到持久 ID 的 mapping。Agent Tools 直接调用 Manager,不依赖这个 HTTP method。 +- app/schemas/ai/main.py 的 EmbeddingProfileModel 已持久 id、name、ai_model、dimensions 与 timestamps; + 配套只读发现是使用现有 profile selector 的需要,不增设 profile 生命周期。 + +本批不修改 MCP 的结果拼装。后续 D-595 已将单实体原地编辑/删除纳入 CLI,详见 +[实体修改方案](organization-write-rest.md);同时修正现有 Block PATCH 把省略 storage 当作清空的输入丢失。 +本页的 append graph 保持新增语义,不用于替代已有实体的 PATCH/DELETE。 diff --git a/tasks/knowledge-lifecycle-capabilities/units/cli-sink/source-cron-rest.md b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/source-cron-rest.md new file mode 100644 index 0000000..3665e40 --- /dev/null +++ b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/source-cron-rest.md @@ -0,0 +1,117 @@ +# Source / Cron 配置与执行接入 + +状态:2026-09-13 按 [D-588](../../decisions/D581-D590.md) 确认,尚未实施。沿用 D-573/D-575 的管理范围、D-577 的动态输入和 D-587 的 +Job 接入。Source 保存来源配置与游标,Cron 保存周期模板,Job 承担一次执行;这些仍是不同对象。 + +## 普通 REST + +| 接口 | 用途 | +| --- | --- | +| GET /source-types、GET /source-types/{type} | deployment 的 Source 类型目录及 config/collect/backfill schemas | +| GET /sources、GET /sources/{id} | Source 列表与详情 | +| POST /sources | 创建 Source 配置,不隐式 collect 或创建 Cron | +| PATCH /sources/{id} | 修改 Source 的可编辑字段 | +| DELETE /sources/{id} | 删除 Source 配置,不删除已收集 graph | +| POST /sources/{id}/collect、POST /sources/{id}/backfill | 创建对应 exact Source Job | +| GET /crons、GET /crons/{id} | Cron 列表与详情 | +| POST /crons、PATCH /crons/{id}、DELETE /crons/{id} | 管理周期模板 | +| POST /crons/{id}/run | 从现有模板立即创建一次独立 Job | + +新建配置或 Job 返回 201 与实际记录,读取/更新返回 200 与实际记录,删除返回 204。单条不存在为 404; +列表与 CLI 删除回执沿 D-597 的[共同输出合同](list-error-contract.md)。不重复包 status/result 或另造 command receipt。 + +## 创建与修改 Source + +创建表单为 type、nickname、config、storage;后面三项沿用当前默认 null、空对象、null,并按 Source type +校验 config。CLI 的 --type 仍在 argv,JSON 只填写其余字段。id、timestamps、state、block 不进入该创建表单。 +本轮 PATCH 允许 nickname、storage、config,不允许改变 type;不同 Source 实现的 config、cursor 和 graph +含义不同,不将换实现伪装成普通配置编辑。需要另一个 type 时创建另一个 Source,不迁移旧 state。 + +配置持久化与执行资格分开。类型目录读取 SourceTypesModel 的持久 catalog,保存不要求接入 Peer 已加载 +Source class。建议 SourceManager 沿用 JobManager 的现有模式:本地有模型时用它校验/规范化;没有本地 +模型时用已持久化的 config_schema 和已有 jsonschema 库校验,执行时仍由实际 Source 模型处理自身合同。 +JSON Schema 不能替代所有自定义模型逻辑;这里不声称保存成功证明登录、网络访问或未来 collection 成功。 +不把 fallback 校验放在 CLI,不扩大 ConfigContract 为 schema registry 或 JSON Schema 编译器。 + +Source 管理保持 deployment scope。storage=null 沿用 Source → deployment default → PostgreSQL binary +的既有选择语义;writable catalog/数据库约束继续有效,保存时不要求接入 Peer 实例化并连通目标 Storage。 +Source state 由实际 Source 执行维护,PATCH 不清空游标,也不覆盖正在推进的 state。native identity/config +变化与已有 cursor 是否相容仍由该 Source 的既有合同判断,不添加通用重绑定或重置策略。 + +已有 lazy Source anchor 时,nickname 更新同时经 SourceManager 刷新这个已有 projection;没有 anchor 时 +不为改名创建它。配置记录仍是 nickname/type 的 authority,Source Block 只是 graph 中的投影。 + +## PATCH 只修改提交的顶层字段 + +两类对象都采用浅层更新:省略的字段保持不变;显式 null 只用于允许空值的字段;提交的对象/数组整体替换, +不递归合并。例如: + +```http +PATCH /sources/12 +Content-Type: application/json + +{"nickname":"工作邮箱","storage":null} +``` + +这不改变 config 或 state。若提交 `{"config":{…}}`,则提供的是完整的新 config,而不是其中几项; +`{"job_parameters":{…}}` 对 Cron 同理。这样不会留下调用者以为已删除、实际被深层 merge 保留的旧参数。 +HTTP 使用普通 application/json 的字段更新合同,不冒称 JSON Merge Patch 的 null 删除语义。 + +合并与校验由 owning manager 在持久化边界处理,得到有效的完整候选值后只写可编辑字段。复用现有 +ConfigContract 的浅层更新思路与 Pydantic 的 exclude_unset,不把表单默认值写回所有未提供字段,不将 +整个 ORM 记录 upsert 回去。涉及读取旧配置再合并时沿用 DeploymentConfigManager 的短事务/行锁模式。 +这一方案不改变现有 config API 自身的相对更新层级,也不为本 unit 批量重写 client-web 的普通管理表单。 + +## Cron 是模板,不是 Source 的附属设置 + +沿用 CronForm:schedule、enabled、job_type、job_parameters、job_timeout_seconds。时区仍由 deployment +的 core.cron 配置提供;enabled 默认 true,job_timeout_seconds=null 使用 Job type 的默认值。 +last_job、last_scheduled_for、id 与 timestamps 只读,修改模板不重置调度进度。Cron 的 job_type 可以修改, +更新后完整模板的参数必须满足新类型 schema;不复用 Source 的“不可换 type”限制。 + +例如 CLI `cron create --job-type core.source.collect.v1 --input cron.json` 中的 cron.json 为: + +```json +{"schedule":"*/15 * * * *","enabled":true,"job_parameters":{"source":12,"config":{}},"job_timeout_seconds":300} +``` + +创建/修改模板应校验 schedule 与对应 Job 参数,不调用 JobManager.create 来做校验或额外生成一个 Job。 +从现有 JobManager 参数校验中抽出可复用调用即可;不要求本机 can_handle,不为 backfill 增加 Cron 禁用规则。 +不会新增 SourceCollectCronBinding、sources.collect_at,或由 Source 推断自己的周期设置。 + +CLI cron enable/disable 分别是 PATCH enabled=true/false,不另造 REST action。cron run 则调用 +CronManager.run_now:即使 Cron disabled 也可显式运行,不推进 last_scheduled_for,不替换 last_job, +不复用一个已经存在的 Job。它产生的独立工作与周期 Job 可以并存;不因此改变已有周期 occurrence 去重规则。 + +## 派发与删除的效果 + +Source collect/backfill 的 body 保持单次 config 本身,timeout_seconds 使用可选 query 参数,两者都返回 +创建后的 Job。CLI 增加同义的执行 timeout 选项;它与 job wait 的观察预算分开。默认执行期限继续由 Job type +拥有,Source 缺失/不支持 backfill 等情况由 Source 接入明确报告,而不是靠实例化本地 Source 检查执行资格。 + +创建 Job 后可用 FastAPI 既有 BackgroundTasks 触发本机 JobManager.check,保留现有手动收集的即时扫描机会。 +该扫描是响应后的 best-effort 提示,失败保留日志,不能让已提交的 Job 被报告为创建失败;现有周期 worker +仍是持续发现路径。不在 BackgroundTasks 中承载真正的业务 Job,不增加新的消息队列或调度器。 + +删除 Source 保留已收集 Blocks/Relations、Source anchor、Cron 模板和历史/在途 Jobs,不隐式 abort 或改绑。 +残留模板/Job 仍可能引用不存在的 Source,需要显式编辑、删除或停止;原本依赖该 Source 凭据才能取得的 +未物化内容也可能无法再读取。保留 graph 不等于承诺继续访问外部邮箱或文件。管理接口不遍历全图做级联修复。 + +删除或 disable Cron 只移除/改变未来调度意图,不取消已经创建的 Job;停止现有工作使用 job abort。 +调度与删除并发时,已经提交的 Job 同样保留。命令 help 与结果说明这些操作影响的对象,不承诺撤回既有副作用。 + +## 证据与实施前压力 + +- app/business/source/main.py 的 create 目前只接受本机 class,缺 Source update/delete;ensure_block 已能刷新 + 既有 anchor。这些是预期需要补齐的 owner 方法,不应由新 REST route 直接操作数据库代替。 +- SourceTypesModel 已持久化 config/collect/backfill schemas;SourceModel 的 state 与 block 是独立字段。 +- app/configuration.py 的 ConfigContract.prepare_patch、DeploymentConfigManager.patch 已有浅层合并与完整校验。 +- CronManager 已有 create/update/run_now;现有 update 接收完整表单,新 PATCH 不能直接把缺省字段填入它。 + 现有 create/update 只显式校验 schedule,Job template 参数校验需要补齐,但不会产生试运行 Job。 +- app/routes/source.py 在提交 Job 后同步 await check;当前提案将这个附带扫描与创建响应分离,保留扫描机会。 +- [FastAPI 更新文档](https://fastapi.tiangolo.com/tutorial/body-updates/) 说明 exclude_unset 能区分未提交字段; + 最终模型校验仍由 owner 承担,不能把无校验的 model_copy(update=…) 当作完整候选值验证。 + +预演仍需核对无本地 Source class 的保存/发现、参数默认值和模型自定义 validator 差异、并发配置/游标更新、 +已有/无 anchor 的改名,以及删除后实际的 template/Job/未物化内容表现。已获批的黑盒验收策略不变,未增加 +源码、依赖、迁移或自动化测试。python-backend-code 与 ponytail 用于保持普通 HTTP、Pydantic 与既有 owner 边界。 diff --git a/tasks/knowledge-lifecycle-capabilities/units/cli-sink/validation-boundary-correction.md b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/validation-boundary-correction.md new file mode 100644 index 0000000..2328ad6 --- /dev/null +++ b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/validation-boundary-correction.md @@ -0,0 +1,70 @@ +# 同类校验边界修正 + +状态:D-591 已纳入单元范围;本页为初步源码核验与预演清单,尚未实施。准则来自 +[校验边界指南](../../common-patterns/validation-boundaries.md),不在这里重复规则。 + +## 已定位调用链 + +| 路径 | 当前事实 | 修正方向 / 待核验 | +| --- | --- | --- | +| DeploymentConfigManager get/read/patch | 数据库 value 经 _validate_record 重验;PATCH 先验旧值 | 按 D-590 分离写入校验与读取表示转换 | +| SourceBase.get_config | 读取 source.config 后 model_validate;SourceManager.create 已按类型校验写入 | 一并处理 Source 管理与 extension 内直接加载相同配置的路径 | +| Storage.__init__ | storage_record.config 在构造实例时重验 | 定位现有配置写入边界,读取不再附带重复业务检查 | +| SinkManager → SinkBase | create/update 校验;实例加载、live update 再校验同一值 | 传递已建立的 typed 值;从数据库恢复时只转换表示 | +| AIManager._load_target | 读取 Provider config 后调用 dialect 模型校验 | 保留 adapter 所需类型转换;不混入当下启用/能力判断 | +| PeerManager / PeerModel.capability_snapshot | 配置读取、发布前及 capability snapshot 读取均可重验已持久字段 | 将写入/发布规范化与读取转换分开;不改变 delegation 的使用条件 | +| JobManager.create → _prepare | 创建规范化参数,准备执行再次 validate_parameters;非法参数被跳过 | 创建仍是输入边界;执行取得 typed 参数,不将重复校验失败变为永久 pending | +| client-web JobManager.create → prepare | parameters.parse 后持久化,prepare 又 safeParse 同一参数 | 与本轮 worker 停止能力一起配套修正;不在两端保留相反原则 | +| Agent / AI SQL TypeDecorator | process_result_value 调用与写入相同的 canonicalization helper | tuple、union branch、嵌套模型转换与唯一性/约束校验分开核对 | +| Extension runtime config/state | bind/get_config/get_state 和 mutation 前后均有 model_validate | 在实际 runtime 源码 owner 修正,不只覆盖 Core facade | + +表中是已定位的机制,不代表每一项的写入路径、表示转换和全部消费者已经完成预演。first-party Extensions 的 +相同 Source config/state 读取也在范围内:已定位 mail、telegram、github 等调用点,不以 extension 目录排除。 +Resolver 对外部 Storage bytes 的解码另辨输入来源,不能由“Block 在数据库”推导其指针指向的 bytes 也已验证。 + +## 不应混删的路径 + +- Agent runtime 验证模型生成的 Tool arguments、Resolver method 验证外部调用参数,仍是实际输入边界。 +- Provider / Registry / Peer HTTP 响应的解码来自外部协议,不属于本次数据库记录再校验问题。 +- SourceCollectParameters.config 当前只是 dict;source/job.py 调用 validate_collect_config 是首次进入具体 + Source schema,不是已经验过同一 schema。应检查如何在入库时建立该合同,不能直接删掉唯一的具体校验。 +- BlockForm → BlockModel 的 producer 映射、Typed union 解码和日期/SecretStr 转换不能仅凭 validate/parse 名称 + 判定多余。目标是正确的边界,不是把这些函数出现次数降到零。 + +## 转换实验与代价控制 + +后续 [Runtime 预演](preflight-runtime.md) 已区分直接记录读取、typed 值传递、复杂模型恢复三类路径; +仅最后一类保留一次 Pydantic 原生构造及附带约束,避免自制通用 decoder。此取舍已于 2026-09-14 按 +D-602 获 Sir 确认;实际调用链仍待实施验证。 + +本机 Pydantic 2.13.4 的一次内存实验确认:验证后 model_dump 得到的嵌套 JSON 再 model_validate,会再次调用 +field validator;浅层 model_construct 则使 nested field 保持 dict,不能保证既有嵌套属性访问继续工作。 +实验没有网络/数据库、源码文件或自动化测试产物;只证明这两个库行为,不证明具体领域已修复。 + +MailSourceConfig.parameters 是 IMAPParameters,AI Provider config 的 api_key 是 SecretStr;它们是具体转换 +压力。优先保留已得到的 typed 对象并减少重复转换,其他路径核验 Pydantic/既有库的原生能力。不为“零校验调用” +自建通用递归解码器或用 cast 假装完成了转换。按 D-602 接受复杂类型恢复的一次原生构造,不再为同一库取舍重复请求复核。 + +参考:[Pydantic model_construct](https://docs.pydantic.dev/latest/concepts/models/#creating-models-without-validation); +本机也读取了对应 BaseModel 方法实现,证据版本以 2.13.4 为准。 + +## Owner 与交付压力 + +Core 主要落点:app/configuration.py、各 owning manager/base、app/schemas 中的 persistence codec,以及这些 +入口在 first-party Extensions 的调用。client-web 已在本单元因 Job 停止协议在范围内,配套纳入相同 worker / +持久对象读取问题,实际转换须保留 Zod-class 的实例行为与日期转换。 + +Core 当前依赖 inkcre-extension-runtime-core-py 0.1.2,下载自 ext-reg 的 runtime-core-py-v0.1.2 Release, +该 Release 的 target_commitish 为 be9629a6c24625f4e43b82fc39b9c39ab3735b1e。已读取安装包 base.py 的对应路径。 +2026-09-13 继续核验发现,本地 ../ext-reg HEAD 60879eb 是旧 checkout;GitHub main 与 origin/main 均为 +3fc45236afcaeaa500f6cef907812a1ac8c2040b,源码位于 +`runtimes/core-py/src/inkcre_extension_runtime_core_py/base.py`,实际重复链路已读。源码 owner 已定位; +先前的缺失不能再作为未决 blocker。本地 main ahead 1/behind 17,后续使用独立 feature 工作位置, +保留原 checkout 的提交和未跟踪文件。修改需经过 runtime 发布及 Core dependency pin;不可编辑 .venv +或在 facade 打替代补丁。嵌套表示转换仍待预演,见 [preflight](preflight.md)。不向关闭的 session 发消息。 + +## 完成条件 + +按每条确认的调用链记录输入边界、读回表示与实际消费者,完成共同机制及必要调用方修正;不能只修第一个 +显眼 GET。静态检查与原定黑盒 journey 覆盖真实使用,必要时增加一次性脚本压力,不自动扩成新测试套件。 +该工作进入 CLI 的 implementation plan / impact handshake,不另建 task 或独立 implementable unit。 From a2d2f9353e66e5aa0b9b462f404b1880af15ad32 Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Mon, 14 Sep 2026 17:17:26 +0800 Subject: [PATCH 2/8] =?UTF-8?q?feat(cli):=20=E5=A2=9E=E5=8A=A0=E7=8B=AC?= =?UTF-8?q?=E7=AB=8B=20REST=20CLI=20=E4=B8=8E=E8=B7=A8=20Peer=20Job=20?= =?UTF-8?q?=E6=8E=A7=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changes/+cli-rest.added.md | 1 + .dockerignore | 1 + .github/workflows/ci.yml | 12 + .github/workflows/cli-publish.yml | 55 + .pre-commit-config.yaml | 1 + CONTRIBUTING.md | 4 +- app/business/agent/main.py | 88 +- app/business/ai/dialects/openai_compatible.py | 2 +- app/business/ai/main.py | 22 + app/business/cron.py | 108 +- app/business/deployment_config.py | 133 +- app/business/extension/config.py | 4 +- app/business/extension/main.py | 3 + app/business/info_base/resolver/__init__.py | 4 + app/business/info_base/resolver/audio.py | 4 +- app/business/info_base/resolver/contracts.py | 10 + app/business/info_base/resolver/image.py | 4 +- app/business/info_base/resolver/main.py | 22 +- app/business/info_base/resolver/video.py | 4 +- app/business/job.py | 195 +- .../organization/duplicate_assertion.py | 1 + app/business/organization/evidence_stance.py | 1 + app/business/organization/jobs.py | 23 +- .../organization/referent_anchoring.py | 1 + app/business/organization/refinement.py | 4 +- app/business/organization/rumination.py | 4 +- app/business/organization/supersession.py | 1 + app/business/organization/synthesis.py | 4 +- app/business/organization_media.py | 1 + app/business/peer/main.py | 40 + app/business/semantic_retrieval/main.py | 23 + app/business/sink/base.py | 9 +- app/business/sink/main.py | 5 +- app/business/source/config.py | 8 +- app/business/source/job.py | 30 +- app/business/source/main.py | 127 +- app/configuration.py | 15 +- app/database_contract/profile.py | 13 + app/routes/agent.py | 72 + app/routes/ai.py | 27 + app/routes/block.py | 25 +- app/routes/content.py | 86 + app/routes/cron.py | 84 + app/routes/deployment_config.py | 75 +- app/routes/entities.py | 79 + app/routes/extension.py | 55 +- app/routes/graph_navigation.py | 77 + app/routes/info_base.py | 6 +- app/routes/job.py | 78 + app/routes/peer.py | 32 + app/routes/relation.py | 46 +- app/routes/resolver.py | 106 + app/routes/retrieval.py | 65 + app/routes/source.py | 137 +- app/routes/validation.py | 60 + app/schemas/AGENTS.md | 5 +- app/schemas/agent.py | 33 +- app/schemas/ai/capability.py | 4 +- app/schemas/ai/main.py | 4 +- app/schemas/cron.py | 14 + app/schemas/deployment_config.py | 2 +- app/schemas/info_base/rest.py | 60 + app/schemas/job.py | 6 + app/schemas/peer/main.py | 2 +- app/schemas/source/__init__.py | 4 + app/schemas/source/main.py | 19 + app/validation.py | 31 + cli/.changes/+cli-sink.added.md | 1 + cli/.gitignore | 6 + cli/CHANGELOG.md | 2 + cli/README.md | 64 + cli/docs/development.md | 28 + cli/pdm.lock | 288 ++ cli/pyproject.toml | 46 + cli/src/inkcre_cli/__init__.py | 1 + cli/src/inkcre_cli/command.py | 187 + cli/src/inkcre_cli/commands/__init__.py | 1 + cli/src/inkcre_cli/commands/agent.py | 121 + cli/src/inkcre_cli/commands/config.py | 95 + cli/src/inkcre_cli/commands/connection.py | 109 + cli/src/inkcre_cli/commands/extension.py | 125 + cli/src/inkcre_cli/commands/info.py | 245 + cli/src/inkcre_cli/commands/jobs.py | 279 + cli/src/inkcre_cli/commands/peer.py | 67 + cli/src/inkcre_cli/commands/source.py | 147 + cli/src/inkcre_cli/connection.py | 64 + cli/src/inkcre_cli/errors.py | 17 + cli/src/inkcre_cli/http.py | 107 + cli/src/inkcre_cli/main.py | 75 + cli/src/inkcre_cli/output.py | 92 + cli/src/inkcre_cli/schema.py | 71 + .../business-pipeline-and-authority.md | 7 + docs/30-unit-tdd/rest-interface.md | 51 + .../native-extension-distribution.md | 5 + docs/40-deployment/runtime-orchestration.md | 18 +- docs/openapi.json | 4528 +++++++++++++++-- .../mail/.changes/+cli-job-control.fixed.md | 1 + extensions/mail/adapter.py | 34 +- extensions/mail/source.py | 3 +- .../rss/.changes/+typed-config.fixed.md | 1 + extensions/rss/source.py | 2 +- .../twitter/.changes/+typed-config.fixed.md | 1 + extensions/twitter/__init__.py | 4 +- extensions/twitter/api.py | 8 +- extensions/twitter/bookmark.py | 3 +- extensions/twitter/setup_flow.py | 24 +- migrations/revision-integrity.json | 1 + migrations/script.py.mako | 8 +- .../d41cc84db0c5_add_job_abort_request.py | 35 + pdm.lock | 48 +- pyproject.toml | 1 + ruff.toml | 2 +- run.py | 31 +- scripts/automation/cli_publication.sh | 36 + scripts/automation/extension_publication.sh | 17 +- scripts/release.py | 59 +- tests/test_extension_registry_config.py | 4 +- 117 files changed, 8838 insertions(+), 613 deletions(-) create mode 100644 .changes/+cli-rest.added.md create mode 100644 .github/workflows/cli-publish.yml create mode 100644 app/routes/agent.py create mode 100644 app/routes/ai.py create mode 100644 app/routes/content.py create mode 100644 app/routes/cron.py create mode 100644 app/routes/entities.py create mode 100644 app/routes/graph_navigation.py create mode 100644 app/routes/job.py create mode 100644 app/routes/peer.py create mode 100644 app/routes/resolver.py create mode 100644 app/routes/retrieval.py create mode 100644 app/routes/validation.py create mode 100644 app/schemas/info_base/rest.py create mode 100644 app/validation.py create mode 100644 cli/.changes/+cli-sink.added.md create mode 100644 cli/.gitignore create mode 100644 cli/CHANGELOG.md create mode 100644 cli/README.md create mode 100644 cli/docs/development.md create mode 100644 cli/pdm.lock create mode 100644 cli/pyproject.toml create mode 100644 cli/src/inkcre_cli/__init__.py create mode 100644 cli/src/inkcre_cli/command.py create mode 100644 cli/src/inkcre_cli/commands/__init__.py create mode 100644 cli/src/inkcre_cli/commands/agent.py create mode 100644 cli/src/inkcre_cli/commands/config.py create mode 100644 cli/src/inkcre_cli/commands/connection.py create mode 100644 cli/src/inkcre_cli/commands/extension.py create mode 100644 cli/src/inkcre_cli/commands/info.py create mode 100644 cli/src/inkcre_cli/commands/jobs.py create mode 100644 cli/src/inkcre_cli/commands/peer.py create mode 100644 cli/src/inkcre_cli/commands/source.py create mode 100644 cli/src/inkcre_cli/connection.py create mode 100644 cli/src/inkcre_cli/errors.py create mode 100644 cli/src/inkcre_cli/http.py create mode 100644 cli/src/inkcre_cli/main.py create mode 100644 cli/src/inkcre_cli/output.py create mode 100644 cli/src/inkcre_cli/schema.py create mode 100644 docs/30-unit-tdd/rest-interface.md create mode 100644 extensions/mail/.changes/+cli-job-control.fixed.md create mode 100644 extensions/rss/.changes/+typed-config.fixed.md create mode 100644 extensions/twitter/.changes/+typed-config.fixed.md create mode 100644 migrations/versions/d41cc84db0c5_add_job_abort_request.py create mode 100644 scripts/automation/cli_publication.sh diff --git a/.changes/+cli-rest.added.md b/.changes/+cli-rest.added.md new file mode 100644 index 0000000..c3a2d30 --- /dev/null +++ b/.changes/+cli-rest.added.md @@ -0,0 +1 @@ +提供独立 CLI 使用的普通 REST 管理与检索接口、动态输入 schema 发现、原生二进制与 multipart 内容交付;Job 新增 best-effort 停止请求,显式 rumination 通过 Job 受理。减少已验证配置在内部传递时的重复校验。 diff --git a/.dockerignore b/.dockerignore index 1742931..5f70fd2 100644 --- a/.dockerignore +++ b/.dockerignore @@ -15,6 +15,7 @@ docs/ tasks/ tests/ +cli/ data/extensions/ extensions/*/pdm.lock diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ed74dc..abeb9a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,18 @@ env: PDM_CHECK_UPDATE: "false" jobs: + cli: + name: inkcre-cli checks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + - uses: pdm-project/setup-pdm@973541a5febeafcfdadf8a51211435be6ecfd90f # v4.5 + with: + python-version: '3.12' + version: 2.28.0 + - name: 检查独立依赖、静态合同和分发构建 + run: bash scripts/automation/cli_publication.sh check-build + repository: name: Hermetic repository contract runs-on: ubuntu-latest diff --git a/.github/workflows/cli-publish.yml b/.github/workflows/cli-publish.yml new file mode 100644 index 0000000..b915141 --- /dev/null +++ b/.github/workflows/cli-publish.yml @@ -0,0 +1,55 @@ +name: Publish inkcre-cli + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: publish-inkcre-cli + cancel-in-progress: false + +jobs: + select: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + outputs: + selected: ${{ steps.selection.outputs.selected }} + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: '3.12' + - name: 选择已准备的新 CLI 版本 + id: selection + env: + EVENT_NAME: ${{ github.event_name }} + BEFORE_SHA: ${{ github.event.before }} + run: bash scripts/automation/cli_publication.sh select + + publish: + needs: select + if: needs.select.outputs.selected == 'true' + runs-on: ubuntu-latest + environment: production + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + with: + ref: ${{ github.sha }} + - uses: pdm-project/setup-pdm@973541a5febeafcfdadf8a51211435be6ecfd90f # v4.5 + with: + python-version: '3.12' + version: 2.28.0 + - name: 独立检查并构建本次发布产物 + run: bash scripts/automation/cli_publication.sh check-build + - name: 使用 PyPI Trusted Publishing 上传 + run: bash scripts/automation/cli_publication.sh publish diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 27e4689..e5e0ba0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,6 +14,7 @@ repos: entry: ruff format --check . language: system files: ^(.*\.py|pyproject\.toml|ruff\.toml)$ + exclude: ^(cli/|\.agents/) pass_filenames: false - id: typecheck name: Check Pyrefly types diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 02982de..feae76b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -40,7 +40,7 @@ authorize new unit, schema, helper, mocked-manager, or route tests by analogy. ## Release intent -Core and each first-party Extension are independent release projects. A feature pull request that +Core, `cli/`, and each first-party Extension are independent release projects. A feature pull request that changes delivered project behavior adds at least one non-empty project-local Towncrier fragment; it does not change a version or generated changelog: @@ -49,7 +49,7 @@ pdm run towncrier create --config towncrier.toml --dir extensions/ pdm run check:releases --base origin/main ``` -Core fragments live in `.changes/`; Extension fragments live in +Core fragments live in `.changes/`; CLI fragments live in `cli/.changes/`; Extension fragments live in `extensions//.changes/`. Valid types are `added`, `changed`, `deprecated`, `removed`, `fixed`, and `security`. Pure release-tooling or contributor-documentation changes do not invent project news. diff --git a/app/business/agent/main.py b/app/business/agent/main.py index 16fff05..7235683 100644 --- a/app/business/agent/main.py +++ b/app/business/agent/main.py @@ -7,11 +7,12 @@ import typing import pydantic +import sqlmodel from app.business.ai import AIExecutionRequirement, AIManager from app.engine import SessionLocal from app.schemas import AgentDefinitionModel -from app.schemas.agent import AgentID +from app.schemas.agent import AgentID, AgentForm, AgentUpdateForm from app.schemas.ai import FunctionTool, SystemMessage, UserMessage from .contracts import ( @@ -70,6 +71,91 @@ class AgentManager: _TOOLS: dict[str, _ToolRegistration] = {} _persistence: ThreadPersistenceBackend = InMemoryThreadPersistenceBackend() + @classmethod + def get_definition(cls, agent_id: AgentID) -> AgentDefinitionModel | None: + with SessionLocal() as db: + return db.get(AgentDefinitionModel, agent_id) + + @classmethod + def list_definitions( + cls, *, limit: int | None = None, cursor: int | None = None + ) -> tuple[list[AgentDefinitionModel], int | None]: + statement = sqlmodel.select(AgentDefinitionModel).order_by( + sqlmodel.col(AgentDefinitionModel.id) + ) + if cursor is not None: + statement = statement.where(sqlmodel.col(AgentDefinitionModel.id) > cursor) + if limit is not None: + statement = statement.limit(limit + 1) + with SessionLocal() as db: + rows = list(db.exec(statement).all()) + more = limit is not None and len(rows) > limit + rows = rows[:limit] + return rows, rows[-1].id if more else None + + @classmethod + def create_definition(cls, form: AgentForm) -> AgentDefinitionModel: + with SessionLocal() as db: + record = AgentDefinitionModel(**form.model_dump()) + db.add(record) + db.commit() + db.refresh(record) + return record + + @classmethod + def update_definition( + cls, agent_id: AgentID, form: AgentUpdateForm + ) -> AgentDefinitionModel: + with SessionLocal() as db: + record = db.exec( + sqlmodel.select(AgentDefinitionModel) + .where(AgentDefinitionModel.id == agent_id) + .with_for_update() + ).one_or_none() + if record is None: + raise AgentNotFoundError(f"Agent {agent_id} does not exist") + changes = form.model_dump(exclude_unset=True) + candidate = AgentForm.model_validate( + { + **{field: getattr(record, field) for field in AgentForm.model_fields}, + **changes, + } + ) + for field in changes: + setattr(record, field, getattr(candidate, field)) + db.add(record) + db.commit() + db.refresh(record) + return record + + @classmethod + def delete_definition(cls, agent_id: AgentID) -> bool: + with SessionLocal() as db: + record = db.get(AgentDefinitionModel, agent_id) + if record is None: + return False + db.delete(record) + db.commit() + return True + + @classmethod + def list_tools( + cls, *, limit: int | None = None, cursor: str | None = None + ) -> tuple[list[dict[str, str]], str | None]: + ids = sorted(key for key in cls._TOOLS if cursor is None or key > cursor) + more = limit is not None and len(ids) > limit + ids = ids[:limit] + return [{"id": key, "description": cls._TOOLS[key].description} for key in ids], ids[ + -1 + ] if more else None + + @classmethod + def get_tool(cls, tool_id: str) -> FunctionTool: + registration = cls._TOOLS.get(tool_id) + if registration is None: + raise MissingAgentToolError(f"Agent Tool {tool_id!r} is not registered") + return registration.bind(tool_id).definition + @classmethod def can_execute(cls, agent_id: AgentID, input_modality: str) -> bool: """Return static local eligibility for one Agent and canonical input modality.""" diff --git a/app/business/ai/dialects/openai_compatible.py b/app/business/ai/dialects/openai_compatible.py index c3bf85e..7dd45c6 100644 --- a/app/business/ai/dialects/openai_compatible.py +++ b/app/business/ai/dialects/openai_compatible.py @@ -70,7 +70,7 @@ def _create_client(config: OpenAICompatibleConfig) -> AsyncOpenAI: @staticmethod def _config(config: pydantic.BaseModel) -> OpenAICompatibleConfig: - return OpenAICompatibleConfig.model_validate(config) + return typing.cast(OpenAICompatibleConfig, config) async def embed( self, diff --git a/app/business/ai/main.py b/app/business/ai/main.py index a1860a2..a45d14d 100644 --- a/app/business/ai/main.py +++ b/app/business/ai/main.py @@ -7,6 +7,7 @@ import pydantic import sqlalchemy.dialects.postgresql +import sqlmodel from app.database_contract.profile import BUILTIN_AI_DIALECTS_BY_ID from app.engine import SessionLocal @@ -73,6 +74,27 @@ class AIManager: _DIALECTS: dict[AIDialectID, _DialectRegistration] = {} + @classmethod + def get_model(cls, model_id: AIModelID) -> AIModelModel | None: + """Read a model record without requiring its provider/dialect to execute here.""" + with SessionLocal() as db: + return db.get(AIModelModel, model_id) + + @classmethod + def list_models( + cls, *, limit: int | None = None, cursor: int | None = None + ) -> tuple[list[AIModelModel], int | None]: + statement = sqlmodel.select(AIModelModel).order_by(sqlmodel.col(AIModelModel.id)) + if cursor is not None: + statement = statement.where(sqlmodel.col(AIModelModel.id) > cursor) + if limit is not None: + statement = statement.limit(limit + 1) + with SessionLocal() as db: + rows = list(db.exec(statement).all()) + more = limit is not None and len(rows) > limit + rows = rows[:limit] + return rows, rows[-1].id if more else None + @classmethod def register_dialect( cls, diff --git a/app/business/cron.py b/app/business/cron.py index 7785392..83c3850 100644 --- a/app/business/cron.py +++ b/app/business/cron.py @@ -1,6 +1,9 @@ """Application-owned materialization of deployment-wide Cron occurrences.""" +from __future__ import annotations + import datetime +import typing from zoneinfo import ZoneInfo, ZoneInfoNotFoundError import croniter # pyrefly: ignore[untyped-import] @@ -10,9 +13,10 @@ from app.business.deployment_config import DeploymentConfigManager from app.business.job import JobManager from app.engine import SessionLocal -from app.schemas.cron import CronForm, CronID, CronModel +from app.schemas.cron import CronForm, CronID, CronModel, CronUpdateForm from app.schemas.job import JobModel, JobStatus from libs.obsrv.main import get_logger +from app.validation import input_path CRON_CONFIG_KEY = "core.cron" @@ -20,6 +24,14 @@ LOGGER = get_logger().getChild(__name__) +class CronNotFoundError(LookupError): + """A Cron template does not exist.""" + + +class InvalidCronScheduleError(ValueError): + """A submitted schedule is not a five-field UNIX Cron expression.""" + + class CronDeploymentConfig(pydantic.BaseModel): model_config = pydantic.ConfigDict(extra="forbid") @@ -35,7 +47,9 @@ def validate_timezone(cls, value: str) -> str: return value -DeploymentConfigManager.register_schema(CRON_CONFIG_SCHEMA_ID, CronDeploymentConfig) +DeploymentConfigManager.register_schema( + CRON_CONFIG_SCHEMA_ID, CronDeploymentConfig, keys=(CRON_CONFIG_KEY,) +) class CronManager: @@ -46,7 +60,7 @@ def _timezone(cls) -> ZoneInfo: config = DeploymentConfigManager.get(CRON_CONFIG_KEY) if config is None: return ZoneInfo("UTC") - return ZoneInfo(CronDeploymentConfig.model_validate(config).timezone) + return ZoneInfo(typing.cast(CronDeploymentConfig, config).timezone) @classmethod def _database_now(cls, db_session: sqlmodel.Session) -> datetime.datetime: @@ -122,7 +136,7 @@ def run_now(cls, cron_id: CronID) -> JobModel: with SessionLocal() as db_session: cron = db_session.get(CronModel, cron_id) if cron is None: - raise ValueError(f"Cron {cron_id} does not exist") + raise CronNotFoundError(f"Cron {cron_id} does not exist") job = JobManager.create( cron.job_type, cron.job_parameters, @@ -136,10 +150,16 @@ def run_now(cls, cron_id: CronID) -> JobModel: @classmethod def create(cls, form: CronForm) -> CronModel: """Validate and create one Cron template.""" - if not croniter.croniter.is_valid(form.schedule): - raise ValueError("Cron schedule must be a valid five-field UNIX expression") + if len(form.schedule.split()) != 5 or not croniter.croniter.is_valid(form.schedule): + raise InvalidCronScheduleError( + "Cron schedule must be a valid five-field UNIX expression" + ) with SessionLocal() as db_session: cron = CronModel(**form.model_dump()) + with input_path("job_parameters"): + cron.job_parameters = JobManager.normalize_parameters( + form.job_type, form.job_parameters, db_session + ) db_session.add(cron) db_session.commit() db_session.refresh(cron) @@ -148,18 +168,86 @@ def create(cls, form: CronForm) -> CronModel: @classmethod def update(cls, cron_id: CronID, form: CronForm) -> CronModel: """Validate and replace the editable fields of one Cron template.""" - if not croniter.croniter.is_valid(form.schedule): - raise ValueError("Cron schedule must be a valid five-field UNIX expression") + if len(form.schedule.split()) != 5 or not croniter.croniter.is_valid(form.schedule): + raise InvalidCronScheduleError( + "Cron schedule must be a valid five-field UNIX expression" + ) with SessionLocal() as db_session: cron = db_session.get(CronModel, cron_id) if cron is None: - raise ValueError(f"Cron {cron_id} does not exist") + raise CronNotFoundError(f"Cron {cron_id} does not exist") cron.schedule = form.schedule cron.enabled = form.enabled cron.job_type = form.job_type - cron.job_parameters = dict(form.job_parameters) + with input_path("job_parameters"): + cron.job_parameters = JobManager.normalize_parameters( + form.job_type, form.job_parameters, db_session + ) cron.job_timeout_seconds = form.job_timeout_seconds db_session.add(cron) db_session.commit() db_session.refresh(cron) return cron + + @classmethod + def get(cls, cron_id: CronID) -> CronModel | None: + with SessionLocal() as db: + return db.get(CronModel, cron_id) + + @classmethod + def list_crons( + cls, *, limit: int | None = None, cursor: CronID | None = None + ) -> tuple[list[CronModel], CronID | None]: + statement = sqlmodel.select(CronModel).order_by(sqlmodel.col(CronModel.id)) + if cursor is not None: + statement = statement.where(sqlmodel.col(CronModel.id) > cursor) + if limit is not None: + statement = statement.limit(limit + 1) + with SessionLocal() as db: + rows = list(db.exec(statement).all()) + more = limit is not None and len(rows) > limit + rows = rows[:limit] + return rows, rows[-1].id if more else None + + @classmethod + def patch(cls, cron_id: CronID, form: CronUpdateForm) -> CronModel: + with SessionLocal() as db: + cron = db.exec( + sqlmodel.select(CronModel).where(CronModel.id == cron_id).with_for_update() + ).one_or_none() + if cron is None: + raise CronNotFoundError(f"Cron {cron_id} does not exist") + changes = form.model_dump(exclude_unset=True) + candidate = CronForm.model_validate( + { + **{field: getattr(cron, field) for field in CronForm.model_fields}, + **changes, + } + ) + if len(candidate.schedule.split()) != 5 or not croniter.croniter.is_valid( + candidate.schedule + ): + raise InvalidCronScheduleError( + "Cron schedule must be a valid five-field UNIX expression" + ) + with input_path("job_parameters"): + parameters = JobManager.normalize_parameters( + candidate.job_type, candidate.job_parameters, db + ) + for field in changes: + setattr(cron, field, getattr(candidate, field)) + cron.job_parameters = parameters + db.add(cron) + db.commit() + db.refresh(cron) + return cron + + @classmethod + def delete(cls, cron_id: CronID) -> bool: + with SessionLocal() as db: + cron = db.get(CronModel, cron_id) + if cron is None: + return False + db.delete(cron) + db.commit() + return True diff --git a/app/business/deployment_config.py b/app/business/deployment_config.py index bb913e1..6fa1b39 100644 --- a/app/business/deployment_config.py +++ b/app/business/deployment_config.py @@ -1,6 +1,7 @@ """Deployment-scoped configuration registry and persistence.""" import typing +import dataclasses import pydantic import sqlalchemy.dialects.postgresql @@ -28,31 +29,38 @@ class DeploymentConfigNotFoundError(LookupError): """A patch addressed a deployment config that does not exist.""" -class InvalidPersistedDeploymentConfigError(RuntimeError): - """A stored value does not satisfy its stored exact schema contract.""" +@dataclasses.dataclass(frozen=True) +class _SchemaRegistration: + contract: ConfigContract + keys: tuple[str, ...] class DeploymentConfigManager: """Own exact schema registration and the shared ``configs`` relation.""" - _contracts: dict[DeploymentConfigSchemaID, ConfigContract] = {} + _contracts: dict[DeploymentConfigSchemaID, _SchemaRegistration] = {} @classmethod def register_schema( cls, schema_id: DeploymentConfigSchemaID, model: type[pydantic.BaseModel], + *, + keys: tuple[str, ...] = (), ) -> None: """Register one exact schema ID idempotently for the same model.""" existing = cls._contracts.get(schema_id) if existing is not None: - if existing.model is model: + if existing.contract.model is model: + cls._contracts[schema_id] = _SchemaRegistration( + existing.contract, tuple(sorted(set(existing.keys) | set(keys))) + ) return raise DeploymentConfigSchemaCollisionError( f"Deployment config schema {schema_id!r} is already registered " - f"by {existing.model.__qualname__}" + f"by {existing.contract.model.__qualname__}" ) - cls._contracts[schema_id] = ConfigContract(model) + cls._contracts[schema_id] = _SchemaRegistration(ConfigContract(model), keys) @classmethod def _contract( @@ -60,48 +68,41 @@ def _contract( schema_id: DeploymentConfigSchemaID, ) -> ConfigContract: try: - return cls._contracts[schema_id] + return cls._contracts[schema_id].contract except KeyError as error: raise UnknownDeploymentConfigSchemaError( f"Unknown deployment config schema: {schema_id}" ) from error @classmethod - def _validate_record( + def _restore_record( cls, record: DeploymentConfigModel, ) -> pydantic.BaseModel: - contract = cls._contract(record.schema_id) - try: - return contract.validate(record.value) - except pydantic.ValidationError as error: - raise InvalidPersistedDeploymentConfigError( - f"Deployment config {record.key!r} does not satisfy {record.schema_id!r}" - ) from error + return cls._contract(record.schema_id).validate(record.value) @classmethod def _view(cls, record: DeploymentConfigModel) -> DeploymentConfigView: - value = cls._validate_record(record).model_dump(mode="json") return DeploymentConfigView( key=record.key, schema=record.schema_id, - value=value, + value=record.value, created_at=record.created_at, updated_at=record.updated_at, ) @classmethod def get(cls, key: DeploymentConfigKey) -> pydantic.BaseModel | None: - """Load and validate one config, returning its owner-defined typed value.""" + """Restore the owner's Python model once, including nested/union types.""" with SessionLocal() as db: record = db.get(DeploymentConfigModel, key) if record is None: return None - return cls._validate_record(record) + return cls._restore_record(record) @classmethod def read(cls, key: DeploymentConfigKey) -> DeploymentConfigView | None: - """Read one fully validated config with persistence metadata.""" + """Read stored data without requiring its schema to be loaded on this Peer.""" with SessionLocal() as db: record = db.get(DeploymentConfigModel, key) if record is None: @@ -115,6 +116,16 @@ def replace( schema_id: DeploymentConfigSchemaID, complete_value: dict[str, typing.Any], ) -> DeploymentConfigView: + result, _ = cls.replace_with_status(key, schema_id, complete_value) + return result + + @classmethod + def replace_with_status( + cls, + key: DeploymentConfigKey, + schema_id: DeploymentConfigSchemaID, + complete_value: dict[str, typing.Any], + ) -> tuple[DeploymentConfigView, bool]: """Atomically upsert a complete value and, if needed, its schema.""" contract = cls._contract(schema_id) normalized = contract.normalize(complete_value) @@ -125,19 +136,75 @@ def replace( schema_id=schema_id, value=normalized, ) - statement = statement.on_conflict_do_update( - index_elements=[DeploymentConfigModel.key], - set_={ - DeploymentConfigModel.__table__.c.schema: statement.excluded.schema, # pyrefly: ignore[missing-attribute] - DeploymentConfigModel.__table__.c.value: statement.excluded.value, # pyrefly: ignore[missing-attribute] - }, - ) - db.exec(statement) # type: ignore + statement = statement.on_conflict_do_nothing( + index_elements=[DeploymentConfigModel.key] + ).returning(sqlmodel.col(DeploymentConfigModel.key)) + created = db.exec(typing.cast(typing.Any, statement)).scalar_one_or_none() is not None + # The insert outcome is authoritative even under concurrent PUTs. A + # conflicting row is updated in this transaction, not guessed by a prior GET. + if not created: + db.exec( + typing.cast( + typing.Any, + sqlalchemy.update(DeploymentConfigModel) + .where(sqlmodel.col(DeploymentConfigModel.key) == key) + .values(schema_id=schema_id, value=normalized), + ) + ) db.commit() record = db.get(DeploymentConfigModel, key) if record is None: # pragma: no cover - database upsert invariant raise RuntimeError(f"Deployment config upsert did not return {key!r}") - return cls._view(record) + return cls._view(record), created + + @classmethod + def list_configs( + cls, *, limit: int | None = None, cursor: str | None = None + ) -> tuple[list[DeploymentConfigView], str | None]: + statement = sqlmodel.select(DeploymentConfigModel).order_by(DeploymentConfigModel.key) + if cursor is not None: + statement = statement.where(DeploymentConfigModel.key > cursor) + if limit is not None: + statement = statement.limit(limit + 1) + with SessionLocal() as db: + rows = list(db.exec(statement).all()) + more = limit is not None and len(rows) > limit + rows = rows[:limit] + return [cls._view(row) for row in rows], rows[-1].key if more else None + + @classmethod + def delete(cls, key: DeploymentConfigKey) -> bool: + with SessionLocal() as db: + row = db.get(DeploymentConfigModel, key) + if row is None: + return False + db.delete(row) + db.commit() + return True + + @classmethod + def get_schema(cls, schema_id: str, *, include_schema: bool = True) -> dict: + contract = cls._contract(schema_id) + entry = cls._contracts[schema_id] + result: dict[str, typing.Any] = { + "id": schema_id, + "keys": entry.keys, + "description": contract.model.__doc__ or "", + } + if include_schema: + result["input_schema"] = contract.json_schema() + return result + + @classmethod + def list_schemas( + cls, *, limit: int | None = None, cursor: str | None = None + ) -> tuple[list[dict], str | None]: + ids = sorted(key for key in cls._contracts if cursor is None or key > cursor) + more = limit is not None and len(ids) > limit + ids = ids[:limit] + return [cls.get_schema(key, include_schema=False) for key in ids], ids[ + -1 + ] if more else None @classmethod def patch( @@ -157,13 +224,7 @@ def patch( raise DeploymentConfigNotFoundError(f"Deployment config {key!r} does not exist") contract = cls._contract(record.schema_id) - try: - validated_current = contract.validate(record.value) - except pydantic.ValidationError as error: - raise InvalidPersistedDeploymentConfigError( - f"Deployment config {key!r} does not satisfy {record.schema_id!r}" - ) from error - validated = contract.prepare_patch(validated_current, partial_value) + validated = contract.prepare_patch(record.value, partial_value) record.value = validated.model_dump(mode="json") db.add(record) db.commit() diff --git a/app/business/extension/config.py b/app/business/extension/config.py index 581f6f5..e6b8df0 100644 --- a/app/business/extension/config.py +++ b/app/business/extension/config.py @@ -1,6 +1,7 @@ """Registry-origin authority for Core Extension Host operations.""" from urllib.parse import urlsplit, urlunsplit +import typing import pydantic @@ -46,6 +47,7 @@ def validate_registry_origin(cls, value: str | None) -> str | None: DeploymentConfigManager.register_schema( EXTENSION_REGISTRY_CONFIG_SCHEMA, ExtensionRegistryDeploymentConfig, + keys=(EXTENSION_REGISTRY_CONFIG_KEY,), ) @@ -56,7 +58,7 @@ def resolve_extension_registry_origin() -> str: return peer_override deployment = DeploymentConfigManager.get(EXTENSION_REGISTRY_CONFIG_KEY) if deployment is not None: - configured = ExtensionRegistryDeploymentConfig.model_validate(deployment) + configured = typing.cast(ExtensionRegistryDeploymentConfig, deployment) if configured.extension_registry_url is not None: return configured.extension_registry_url return normalize_registry_origin(settings.extension_registry_url) diff --git a/app/business/extension/main.py b/app/business/extension/main.py index 1cfe5f9..a7c46bf 100644 --- a/app/business/extension/main.py +++ b/app/business/extension/main.py @@ -11,6 +11,7 @@ from inkcre_extension_runtime_core_py import EmptyConfig from inkcre_extension_runtime_core_py import ExtensionBase as RuntimeExtensionBase import pydantic +import jsonschema # pyrefly: ignore[untyped-import] import sqlmodel from app.business.peer import PeerManager @@ -274,6 +275,8 @@ def update_config( state = self.get(name) running = self.running.get(name) if running is None: + if state.config_schema is not None: + jsonschema.Draft202012Validator(state.config_schema).validate(config) return self.store.update_config(name, config) config_class = typing.cast( type[sqlmodel.SQLModel], diff --git a/app/business/info_base/resolver/__init__.py b/app/business/info_base/resolver/__init__.py index 2e5b1d1..80b815b 100644 --- a/app/business/info_base/resolver/__init__.py +++ b/app/business/info_base/resolver/__init__.py @@ -9,6 +9,8 @@ UnknownDraftResolverError, UnknownResolverError, UnsupportedResolverCapability, + ResolverMethodInputError, + UnknownResolverMethodError, ) from .main import ( Resolver, @@ -32,6 +34,8 @@ "UnsupportedResolverCapability", "ResolverDraftCapability", "ResolverMethodContract", + "ResolverMethodInputError", + "UnknownResolverMethodError", "AudioResolver", "EPUBResolver", "FileResolver", diff --git a/app/business/info_base/resolver/audio.py b/app/business/info_base/resolver/audio.py index f6dbe74..00745d4 100644 --- a/app/business/info_base/resolver/audio.py +++ b/app/business/info_base/resolver/audio.py @@ -37,7 +37,9 @@ class AudioResolverConfig(pydantic.BaseModel): transcript_model: int -DeploymentConfigManager.register_schema(AUDIO_RESOLVER_CONFIG_SCHEMA, AudioResolverConfig) +DeploymentConfigManager.register_schema( + AUDIO_RESOLVER_CONFIG_SCHEMA, AudioResolverConfig, keys=(AUDIO_RESOLVER_CONFIG_KEY,) +) @dataclass(frozen=True, slots=True) diff --git a/app/business/info_base/resolver/contracts.py b/app/business/info_base/resolver/contracts.py index 872ed18..c3b1f14 100644 --- a/app/business/info_base/resolver/contracts.py +++ b/app/business/info_base/resolver/contracts.py @@ -2,6 +2,8 @@ from typing import Literal, TypeAlias +import pydantic + TextProjectionContext: TypeAlias = Literal["default", "lexical"] @@ -35,6 +37,14 @@ class ResolverContractError(RuntimeError): """Base failure for resolver selection and capability contracts.""" +class ResolverMethodInputError(pydantic.ValidationError): + """Native input details, distinguishable from errors inside a content method.""" + + +class UnknownResolverMethodError(ResolverContractError, ValueError): + """An exact Resolver has no projected read method with this name.""" + + class UnknownResolverError(ResolverContractError): """No exact resolver decoder is registered for a persisted resolver ID.""" diff --git a/app/business/info_base/resolver/image.py b/app/business/info_base/resolver/image.py index 8626091..fb39b54 100644 --- a/app/business/info_base/resolver/image.py +++ b/app/business/info_base/resolver/image.py @@ -39,7 +39,9 @@ class ImageResolverConfig(pydantic.BaseModel): text_model: int -DeploymentConfigManager.register_schema(IMAGE_RESOLVER_CONFIG_SCHEMA, ImageResolverConfig) +DeploymentConfigManager.register_schema( + IMAGE_RESOLVER_CONFIG_SCHEMA, ImageResolverConfig, keys=(IMAGE_RESOLVER_CONFIG_KEY,) +) @dataclass(frozen=True, slots=True) diff --git a/app/business/info_base/resolver/main.py b/app/business/info_base/resolver/main.py index 09e96ea..9b550e7 100644 --- a/app/business/info_base/resolver/main.py +++ b/app/business/info_base/resolver/main.py @@ -19,6 +19,8 @@ TextProjectionContext, UnknownDraftResolverError, UnknownResolverError, + UnknownResolverMethodError, + ResolverMethodInputError, ) @@ -34,7 +36,7 @@ class ResolverDraftCapability: @dataclass(frozen=True) class ResolverMethodContract: - """One Agent-projectable public read method on a registered Resolver.""" + """One typed public read method on a registered Resolver.""" name: str description: str @@ -181,11 +183,23 @@ async def invoke_method( arguments: dict[str, typing.Any], ) -> typing.Any: """Validate and invoke one projected read method on an exact Block Resolver.""" + resolver = cls.get(block) contract = cls.get_method_contract(block.resolver, name) if contract is None: - raise ValueError("Resolver method is not available") - validated = contract.input_model.model_validate(arguments) - value = getattr(cls.get(block), name)(**validated.model_dump()) + raise UnknownResolverMethodError( + f"Resolver {block.resolver!r} has no method {name!r}" + ) + try: + validated = contract.input_model.model_validate(arguments) + except pydantic.ValidationError as error: + raise ResolverMethodInputError.from_exception_data( + error.title, typing.cast(typing.Any, error.errors(include_url=False)) + ) from error + # Keep nested Python values typed when calling the annotated domain method; + # model_dump would convert nested input models back into dictionaries. + value = getattr(resolver, name)( + **{field: getattr(validated, field) for field in contract.input_model.model_fields} + ) return await value if inspect.isawaitable(value) else value @classmethod diff --git a/app/business/info_base/resolver/video.py b/app/business/info_base/resolver/video.py index 887d01c..c7f5bf4 100644 --- a/app/business/info_base/resolver/video.py +++ b/app/business/info_base/resolver/video.py @@ -41,7 +41,9 @@ class VideoResolverConfig(pydantic.BaseModel): transcript_model: int -DeploymentConfigManager.register_schema(VIDEO_RESOLVER_CONFIG_SCHEMA, VideoResolverConfig) +DeploymentConfigManager.register_schema( + VIDEO_RESOLVER_CONFIG_SCHEMA, VideoResolverConfig, keys=(VIDEO_RESOLVER_CONFIG_KEY,) +) @dataclass(frozen=True, slots=True) diff --git a/app/business/job.py b/app/business/job.py index f166bac..def844e 100644 --- a/app/business/job.py +++ b/app/business/job.py @@ -2,6 +2,7 @@ import abc import asyncio +import dataclasses import typing import jsonschema # pyrefly: ignore[untyped-import] @@ -21,6 +22,23 @@ ParametersTV = typing.TypeVar("ParametersTV", bound=pydantic.BaseModel) +class UnknownJobTypeError(ValueError): + """A submitted exact Job type is absent from the deployment catalog.""" + + +@dataclasses.dataclass +class _Execution: + task: asyncio.Task + cancellation_requested: bool = False + + def cancel(self) -> None: + # Do not interrupt cleanup already started by timeout or an earlier abort. + if not self.cancellation_requested: + self.cancellation_requested = True + if not self.task.cancelling(): + self.task.cancel() + + class JobHandler(abc.ABC, typing.Generic[ParametersTV]): """One exact executable Job contract registered by its owning module.""" @@ -51,6 +69,18 @@ def __init_subclass__( def validate_parameters(cls, parameters: dict[str, typing.Any]) -> ParametersTV: return typing.cast(ParametersTV, cls.parameters_model.model_validate(parameters)) + @classmethod + def normalize_parameters( + cls, parameters: dict[str, typing.Any], db_session: sqlmodel.Session + ) -> dict[str, typing.Any]: + """Validate a new Job/Cron input; owners may resolve nested catalog contracts. + + Execution restores the persisted model through validate_parameters instead; + it does not repeat submission-only normalization. + """ + del db_session + return cls.validate_parameters(parameters).model_dump(mode="json") + @classmethod @abc.abstractmethod def can_handle(cls, parameters: ParametersTV) -> bool: @@ -68,6 +98,8 @@ class JobManager: """Own Job Handler registration, typed creation, claim and terminal closure.""" _handlers: dict[JobTypeID, type[JobHandler]] = {} + _active: dict[JobID, _Execution] = {} + _accepting = True @classmethod def register_handler(cls, handler: type[JobHandler]) -> None: @@ -113,7 +145,7 @@ def sync_job_types(cls) -> None: db_session.commit() @classmethod - def _normalize_parameters( + def normalize_parameters( cls, job_type: JobTypeID, parameters: dict[str, typing.Any], @@ -121,11 +153,11 @@ def _normalize_parameters( ) -> dict[str, typing.Any]: handler = cls._handlers.get(job_type) if handler is not None: - return handler.validate_parameters(parameters).model_dump(mode="json") + return handler.normalize_parameters(parameters, db_session) persisted_type = db_session.get(JobTypeModel, job_type) if persisted_type is None: - raise ValueError(f"Unknown Job type: {job_type}") + raise UnknownJobTypeError(f"Unknown Job type: {job_type}") jsonschema.Draft202012Validator(persisted_type.parameters_schema).validate(parameters) return parameters @@ -151,11 +183,13 @@ def create( owned_session.refresh(job) return job - normalized = cls._normalize_parameters(job_type, parameters, db_session) + normalized = cls.normalize_parameters(job_type, parameters, db_session) persisted_type = db_session.get(JobTypeModel, job_type) if persisted_type is None: - raise ValueError(f"Unknown Job type: {job_type}") - effective_timeout = timeout_seconds or persisted_type.default_timeout_seconds + raise UnknownJobTypeError(f"Unknown Job type: {job_type}") + effective_timeout = ( + persisted_type.default_timeout_seconds if timeout_seconds is None else timeout_seconds + ) if effective_timeout <= 0: raise ValueError("Job timeout_seconds must be positive") @@ -177,13 +211,123 @@ def _prepare( handler = cls._handlers.get(job.type) if handler is None: return None - try: - parameters = handler.validate_parameters(job.parameters) - except pydantic.ValidationError: - LOGGER.exception("Persisted Job parameters are invalid", extra={"job_id": job.id}) - return None + parameters = handler.validate_parameters(job.parameters) return (handler, parameters) if handler.can_handle(parameters) else None + @classmethod + def get(cls, job_id: JobID) -> JobModel | None: + with SessionLocal() as db: + return db.get(JobModel, job_id) + + @classmethod + def get_type(cls, type_: JobTypeID) -> JobTypeModel | None: + with SessionLocal() as db: + return db.get(JobTypeModel, type_) + + @classmethod + def list_types( + cls, *, limit: int | None = None, cursor: str | None = None + ) -> tuple[list[JobTypeModel], str | None]: + statement = sqlmodel.select(JobTypeModel).order_by(JobTypeModel.id) + if cursor is not None: + statement = statement.where(JobTypeModel.id > cursor) + if limit is not None: + statement = statement.limit(limit + 1) + with SessionLocal() as db: + rows = list(db.exec(statement).all()) + more = limit is not None and len(rows) > limit + rows = rows[:limit] + return rows, rows[-1].id if more else None + + @classmethod + def list_jobs( + cls, + *, + limit: int = 20, + cursor: JobID | None = None, + type_: JobTypeID | None = None, + status: JobStatus | None = None, + ) -> tuple[list[JobModel], JobID | None]: + statement = sqlmodel.select(JobModel).order_by(sqlmodel.col(JobModel.id).desc()) + if cursor is not None: + statement = statement.where(sqlmodel.col(JobModel.id) < cursor) + if type_ is not None: + statement = statement.where(JobModel.type == type_) + if status is not None: + statement = statement.where(JobModel.status == status) + with SessionLocal() as db: + rows = list(db.exec(statement.limit(limit + 1)).all()) + more = len(rows) > limit + rows = rows[:limit] + return rows, rows[-1].id if more else None + + @classmethod + async def notify_worker(cls) -> None: + """Best-effort post-commit hint; scan failure cannot undo accepted work.""" + try: + await cls.check() + except Exception: + LOGGER.exception("Job discovery hint failed; periodic discovery remains active") + + @classmethod + def abort(cls, job_id: JobID) -> JobModel | None: + """Close pending work or request running work to stop; retain any terminal result. + + A running record acknowledges intent, not completion. The executing Peer owns + cancellation and cleanup; the caller never needs to locate that Peer. + """ + table = typing.cast(typing.Any, getattr(JobModel, "__table__")) + with SessionLocal() as db_session: + statement = sqlalchemy.update(table) + statement = statement.where( + table.c.id == job_id, + table.c.status.in_((JobStatus.PENDING, JobStatus.RUNNING)), + ).values( + abort_requested=True, + status=sqlalchemy.case( + ( + table.c.status == JobStatus.PENDING, + sqlalchemy.cast(JobStatus.ABORTED.value, table.c.status.type), + ), + else_=table.c.status, + ), + ) + db_session.exec(typing.cast(typing.Any, statement)) + db_session.commit() + return db_session.get(JobModel, job_id) + + @classmethod + async def check_abort_requests(cls) -> None: + """One batch for this Peer's active work; handlers do not poll the database.""" + if not cls._active: + return + with SessionLocal() as db_session: + ids = db_session.exec( + sqlmodel.select(JobModel.id).where( + sqlalchemy.column("id").in_(tuple(cls._active)), + sqlalchemy.column("abort_requested").is_(True), + ) + ).all() + for job_id in ids: + if job_id is None: + continue + execution = cls._active.get(job_id) + if execution is not None: + execution.cancel() + + @classmethod + def start(cls) -> None: + cls._accepting = True + + @classmethod + async def shutdown(cls) -> None: + """Stop admission and drain handlers before their Extension resources close.""" + cls._accepting = False + executions = tuple(cls._active.values()) + for execution in executions: + execution.cancel() + await asyncio.gather(*(item.task for item in executions), return_exceptions=True) + @classmethod def _claim(cls, job_id: JobID) -> JobModel | None: table = typing.cast(typing.Any, getattr(JobModel, "__table__")) @@ -222,11 +366,24 @@ def _close(cls, job: JobModel, status: JobStatus) -> bool: @classmethod async def run(cls, job_id: JobID) -> bool: """Check local eligibility, atomically claim, then execute one Job.""" + if not cls._accepting: + return False with SessionLocal() as db_session: candidate = db_session.get(JobModel, job_id) - if candidate is None: + if candidate is None or candidate.status != JobStatus.PENDING: return False - prepared = cls._prepare(candidate) + try: + prepared = cls._prepare(candidate) + except pydantic.ValidationError as error: + # A broken persisted command must be visible as failed, not remain pending + # forever. Unknown/unavailable handlers still leave work for another Peer. + LOGGER.exception("Persisted Job parameters are invalid", extra={"job_id": job_id}) + claimed = cls._claim(job_id) + if claimed is None: + return False + claimed.state = {**claimed.state, "error": str(error)} + cls._close(claimed, JobStatus.FAILED) + return True if prepared is None: return False @@ -234,9 +391,15 @@ async def run(cls, job_id: JobID) -> bool: if claimed is None: return False handler, parameters = prepared + task = asyncio.current_task() + assert task is not None + cls._active[job_id] = _Execution(task) try: async with asyncio.timeout(claimed.timeout_seconds): await handler.handle(claimed, parameters) + except asyncio.CancelledError: + LOGGER.info("Job execution aborted", extra={"job_id": job_id}) + cls._close(claimed, JobStatus.ABORTED) except TimeoutError: LOGGER.warning("Job execution timed out", extra={"job_id": job_id}) cls._close(claimed, JobStatus.TIMED_OUT) @@ -246,6 +409,8 @@ async def run(cls, job_id: JobID) -> bool: cls._close(claimed, JobStatus.FAILED) else: cls._close(claimed, JobStatus.FINISHED) + finally: + cls._active.pop(job_id, None) return True @classmethod @@ -272,13 +437,15 @@ def expire_overdue(cls) -> int: @classmethod async def check(cls) -> None: """Schedule locally eligible pending Jobs and converge running timeouts.""" + if not cls._accepting: + return with SessionLocal() as db_session: pending = db_session.exec( sqlmodel.select(JobModel).where(JobModel.status == JobStatus.PENDING) ).all() for job in pending: - if job.id is None or cls._prepare(job) is None: + if job.id is None or job.type not in cls._handlers: continue scheduler.add_job( func=with_trace_id(f"job.{job.id}", cls.run), diff --git a/app/business/organization/duplicate_assertion.py b/app/business/organization/duplicate_assertion.py index 661a400..aa55e0b 100644 --- a/app/business/organization/duplicate_assertion.py +++ b/app/business/organization/duplicate_assertion.py @@ -43,6 +43,7 @@ DeploymentConfigManager.register_schema( DUPLICATE_ASSERTION_CONFIG_SCHEMA, BehaviorAgentConfig, + keys=(DUPLICATE_ASSERTION_CONFIG_KEY,), ) diff --git a/app/business/organization/evidence_stance.py b/app/business/organization/evidence_stance.py index 150be08..87055b2 100644 --- a/app/business/organization/evidence_stance.py +++ b/app/business/organization/evidence_stance.py @@ -46,6 +46,7 @@ DeploymentConfigManager.register_schema( EVIDENCE_STANCE_CONFIG_SCHEMA, BehaviorAgentConfig, + keys=(EVIDENCE_STANCE_CONFIG_KEY,), ) diff --git a/app/business/organization/jobs.py b/app/business/organization/jobs.py index bbe2437..fe60e97 100644 --- a/app/business/organization/jobs.py +++ b/app/business/organization/jobs.py @@ -1,7 +1,8 @@ -"""Seven independent automatic Organization Job routes.""" +"""Independent automatic Organization routes and explicit rumination Jobs.""" from app.business.job import JobHandler from app.schemas.job import JobModel +from app.schemas.organization import RuminationRequest from app.schemas.organization_behavior import AutomaticOrganizationJobParameters from .duplicate_assertion import DuplicateAssertionBehaviorResolver @@ -14,6 +15,7 @@ RUMINATION_JOB_TYPE = "core.organization.rumination.automatic.v1" +EXPLICIT_RUMINATION_JOB_TYPE = "core.organization.rumination.explicit.v1" SUPERSESSION_JOB_TYPE = "core.organization.supersession.automatic.v1" REFINEMENT_JOB_TYPE = "core.organization.refinement.automatic.v1" EVIDENCE_STANCE_JOB_TYPE = "core.organization.evidence-stance.automatic.v1" @@ -22,6 +24,25 @@ DUPLICATE_ASSERTION_JOB_TYPE = "core.organization.duplicate-assertion.automatic.v1" +class ExplicitRuminationJobHandler( + JobHandler[RuminationRequest], + job_type=EXPLICIT_RUMINATION_JOB_TYPE, + description="Reconsider one explicitly selected Block through rumination.", + parameters_model=RuminationRequest, + default_timeout_seconds=1800, +): + @classmethod + def can_handle(cls, parameters: RuminationRequest) -> bool: + del parameters + return RuminationBehaviorResolver.can_run_automatic() + + @classmethod + async def handle(cls, job: JobModel, parameters: RuminationRequest) -> None: + del job + # A claimed Job executes here; it must not delegate another execution. + await RuminationBehaviorResolver.ruminate_local(parameters.block) + + class RuminationJobHandler( JobHandler[AutomaticOrganizationJobParameters], job_type=RUMINATION_JOB_TYPE, diff --git a/app/business/organization/referent_anchoring.py b/app/business/organization/referent_anchoring.py index d75c61a..2b393c6 100644 --- a/app/business/organization/referent_anchoring.py +++ b/app/business/organization/referent_anchoring.py @@ -48,6 +48,7 @@ DeploymentConfigManager.register_schema( REFERENT_ANCHORING_CONFIG_SCHEMA, BehaviorAgentConfig, + keys=(REFERENT_ANCHORING_CONFIG_KEY,), ) diff --git a/app/business/organization/refinement.py b/app/business/organization/refinement.py index 71a9fcf..dd6808b 100644 --- a/app/business/organization/refinement.py +++ b/app/business/organization/refinement.py @@ -43,7 +43,9 @@ REFINEMENT_CONFIG_KEY = "core.organization.refinement" REFINEMENT_CONFIG_SCHEMA = "core.organization.refinement.config.v1" -DeploymentConfigManager.register_schema(REFINEMENT_CONFIG_SCHEMA, BehaviorAgentConfig) +DeploymentConfigManager.register_schema( + REFINEMENT_CONFIG_SCHEMA, BehaviorAgentConfig, keys=(REFINEMENT_CONFIG_KEY,) +) class RefinementBehaviorResolver( diff --git a/app/business/organization/rumination.py b/app/business/organization/rumination.py index b594871..5bd092d 100644 --- a/app/business/organization/rumination.py +++ b/app/business/organization/rumination.py @@ -50,7 +50,9 @@ RUMINATION_CAPABILITY = "core.organization.rumination.v1" RUMINATION_BEHAVIOR = "core.organization.behavior.rumination.v1" -DeploymentConfigManager.register_schema(RUMINATION_CONFIG_SCHEMA, RuminationConfig) +DeploymentConfigManager.register_schema( + RUMINATION_CONFIG_SCHEMA, RuminationConfig, keys=(RUMINATION_CONFIG_KEY,) +) class RuminationBehaviorResolver( diff --git a/app/business/organization/supersession.py b/app/business/organization/supersession.py index 2624ccf..55b1932 100644 --- a/app/business/organization/supersession.py +++ b/app/business/organization/supersession.py @@ -53,6 +53,7 @@ DeploymentConfigManager.register_schema( SUPERSESSION_CONFIG_SCHEMA, BehaviorAgentConfig, + keys=(SUPERSESSION_CONFIG_KEY,), ) diff --git a/app/business/organization/synthesis.py b/app/business/organization/synthesis.py index 8adc3f0..f239711 100644 --- a/app/business/organization/synthesis.py +++ b/app/business/organization/synthesis.py @@ -44,7 +44,9 @@ SYNTHESIS_CONFIG_KEY = "core.organization.synthesis" SYNTHESIS_CONFIG_SCHEMA = "core.organization.synthesis.config.v1" -DeploymentConfigManager.register_schema(SYNTHESIS_CONFIG_SCHEMA, BehaviorAgentConfig) +DeploymentConfigManager.register_schema( + SYNTHESIS_CONFIG_SCHEMA, BehaviorAgentConfig, keys=(SYNTHESIS_CONFIG_KEY,) +) class SynthesisBehaviorResolver( diff --git a/app/business/organization_media.py b/app/business/organization_media.py index 35cce77..7372fe1 100644 --- a/app/business/organization_media.py +++ b/app/business/organization_media.py @@ -50,6 +50,7 @@ DeploymentConfigManager.register_schema( MEDIA_INTERPRETATION_CONFIG_SCHEMA, MediaInterpretationConfig, + keys=(MEDIA_INTERPRETATION_CONFIG_KEY,), ) diff --git a/app/business/peer/main.py b/app/business/peer/main.py index 8e23688..3772311 100644 --- a/app/business/peer/main.py +++ b/app/business/peer/main.py @@ -113,6 +113,46 @@ def get_all(cls) -> tuple[PeerModel, ...]: with SessionLocal() as db: return tuple(db.exec(sqlmodel.select(PeerModel)).all()) + @classmethod + def get_with_lease(cls, peer: PeerRef) -> dict[str, typing.Any] | None: + with SessionLocal() as db: + result = db.exec( + sqlmodel.select( + PeerModel, + sqlmodel.func.coalesce( + sqlmodel.col(PeerModel.lease_expires_at) > sqlmodel.func.statement_timestamp(), + False, + ), + ).where(PeerModel.id == peer) + ).one_or_none() + if result is None: + return None + row, active = result + return {**row.model_dump(mode="json"), "lease_active": active} + + @classmethod + def list_with_leases( + cls, *, limit: int | None = None, cursor: PeerRef | None = None + ) -> tuple[list[dict[str, typing.Any]], PeerRef | None]: + statement = sqlmodel.select( + PeerModel, + sqlmodel.func.coalesce( + sqlmodel.col(PeerModel.lease_expires_at) > sqlmodel.func.statement_timestamp(), + False, + ), + ).order_by(sqlmodel.col(PeerModel.id)) + if cursor is not None: + statement = statement.where(sqlmodel.col(PeerModel.id) > cursor) + if limit is not None: + statement = statement.limit(limit + 1) + with SessionLocal() as db: + rows = list(db.exec(statement).all()) + more = limit is not None and len(rows) > limit + rows = rows[:limit] + return [ + {**row.model_dump(mode="json"), "lease_active": active} for row, active in rows + ], rows[-1][0].id if more else None + @classmethod def register_inbound(cls, inbound: PeerInbound) -> bool: """Register one inbound and report whether this call added it.""" diff --git a/app/business/semantic_retrieval/main.py b/app/business/semantic_retrieval/main.py index 07b959f..2029cfb 100644 --- a/app/business/semantic_retrieval/main.py +++ b/app/business/semantic_retrieval/main.py @@ -141,12 +141,35 @@ def build(self) -> EmbeddingMaintenanceReport: DeploymentConfigManager.register_schema( SEMANTIC_RETRIEVAL_CONFIG_SCHEMA, SemanticRetrievalConfig, + keys=(SEMANTIC_RETRIEVAL_CONFIG_KEY,), ) class SemanticRetrievalManager: """Single use-domain owner for projection, records, ranking and defaults.""" + @classmethod + def get_profile(cls, profile_id: EmbeddingProfileID) -> EmbeddingProfileModel | None: + with SessionLocal() as db: + return db.get(EmbeddingProfileModel, profile_id) + + @classmethod + def list_profiles( + cls, *, limit: int | None = None, cursor: int | None = None + ) -> tuple[list[EmbeddingProfileModel], int | None]: + statement = sqlmodel.select(EmbeddingProfileModel).order_by( + sqlmodel.col(EmbeddingProfileModel.id) + ) + if cursor is not None: + statement = statement.where(sqlmodel.col(EmbeddingProfileModel.id) > cursor) + if limit is not None: + statement = statement.limit(limit + 1) + with SessionLocal() as db: + rows = list(db.exec(statement).all()) + more = limit is not None and len(rows) > limit + rows = rows[:limit] + return rows, rows[-1].id if more else None + @classmethod def _configured_profile_id(cls) -> EmbeddingProfileID: value = DeploymentConfigManager.get(SEMANTIC_RETRIEVAL_CONFIG_KEY) diff --git a/app/business/sink/base.py b/app/business/sink/base.py index 05d15f8..bd92396 100644 --- a/app/business/sink/base.py +++ b/app/business/sink/base.py @@ -49,7 +49,12 @@ async def on_start(self, app: fastapi.FastAPI) -> None: async def on_close(self) -> None: """Withdraw active effects for this exact instance.""" - def update_config(self, value: dict[str, typing.Any]) -> ConfigT: + def update_config(self, value: ConfigT | dict[str, typing.Any]) -> ConfigT: """Replace the live validated config without restarting the instance.""" - self.config = typing.cast(ConfigT, self.__configcls__.model_validate(value)) + self.config = typing.cast( + ConfigT, + value + if isinstance(value, self.__configcls__) + else self.__configcls__.model_validate(value), + ) return self.config diff --git a/app/business/sink/main.py b/app/business/sink/main.py index 23bf8b3..38ed74d 100644 --- a/app/business/sink/main.py +++ b/app/business/sink/main.py @@ -108,7 +108,8 @@ def update_config( ) -> SinkModel: current = cls.get(sink_id) sink_cls = cls._require_type(current.type) - normalized = sink_cls.__configcls__.model_validate(value).model_dump(mode="json") + validated = sink_cls.__configcls__.model_validate(value) + normalized = validated.model_dump(mode="json") with SessionLocal() as db: sink = db.get(SinkModel, sink_id) if sink is None: @@ -119,7 +120,7 @@ def update_config( db.refresh(sink) running = cls._running.get(sink_id) if running is not None: - running.update_config(normalized) + running.update_config(validated) return sink @classmethod diff --git a/app/business/source/config.py b/app/business/source/config.py index c562cb0..0f91b12 100644 --- a/app/business/source/config.py +++ b/app/business/source/config.py @@ -1,5 +1,7 @@ """Deployment-wide policy used by Source-owned content materialization.""" +import typing + import pydantic from app.business.deployment_config import DeploymentConfigManager @@ -20,7 +22,9 @@ class SourceDeploymentConfig(pydantic.BaseModel): default_storage: StorageID = POSTGRESQL_BINARY_STORAGE_ID -DeploymentConfigManager.register_schema(SOURCE_CONFIG_SCHEMA_ID, SourceDeploymentConfig) +DeploymentConfigManager.register_schema( + SOURCE_CONFIG_SCHEMA_ID, SourceDeploymentConfig, keys=(SOURCE_CONFIG_KEY,) +) def resolve_writable_storage( @@ -34,7 +38,7 @@ def resolve_writable_storage( storage_id = ( POSTGRESQL_BINARY_STORAGE_ID if persisted is None - else SourceDeploymentConfig.model_validate(persisted).default_storage + else typing.cast(SourceDeploymentConfig, persisted).default_storage ) storage = StorageManager.get_storage(storage_id, db_session) if not isinstance(storage, WritableStorage): diff --git a/app/business/source/job.py b/app/business/source/job.py index d19469e..76aaa9c 100644 --- a/app/business/source/job.py +++ b/app/business/source/job.py @@ -1,5 +1,7 @@ """Exact Source command handlers hosted by the global Job runtime.""" +import sqlmodel + from app.business.job import JobHandler from app.engine import SessionLocal from app.schemas.job import JobModel @@ -9,7 +11,9 @@ SourceModel, ) -from .main import SourceManager +from app.validation import input_path + +from .main import SourceManager, SourceNotFoundError SOURCE_COLLECT_JOB_TYPE = "core.source.collect.v1" @@ -29,6 +33,18 @@ class SourceCollectJobHandler( parameters_model=SourceCollectParameters, default_timeout_seconds=300, ): + @classmethod + def normalize_parameters(cls, parameters: dict, db_session: sqlmodel.Session) -> dict: + normalized = super().normalize_parameters(parameters, db_session) + source = db_session.get(SourceModel, normalized["source"]) + if source is None: + raise SourceNotFoundError(f"Source {normalized['source']} does not exist") + with input_path("config"): + normalized["config"] = SourceManager.normalize_config( + source.type, normalized["config"], db_session, command="collect" + ) + return normalized + @classmethod def can_handle(cls, parameters: SourceCollectParameters) -> bool: source_type = _source_type(parameters) @@ -48,6 +64,18 @@ class SourceBackfillJobHandler( parameters_model=SourceBackfillParameters, default_timeout_seconds=1800, ): + @classmethod + def normalize_parameters(cls, parameters: dict, db_session: sqlmodel.Session) -> dict: + normalized = super().normalize_parameters(parameters, db_session) + source = db_session.get(SourceModel, normalized["source"]) + if source is None: + raise SourceNotFoundError(f"Source {normalized['source']} does not exist") + with input_path("config"): + normalized["config"] = SourceManager.normalize_config( + source.type, normalized["config"], db_session, command="backfill" + ) + return normalized + @classmethod def can_handle(cls, parameters: SourceBackfillParameters) -> bool: source_type = _source_type(parameters) diff --git a/app/business/source/main.py b/app/business/source/main.py index 0bf474c..7f98188 100644 --- a/app/business/source/main.py +++ b/app/business/source/main.py @@ -1,4 +1,7 @@ +from __future__ import annotations + import abc +import jsonschema # pyrefly: ignore[untyped-import] import pydantic import sqlalchemy import sqlalchemy.dialects.postgresql @@ -10,12 +13,21 @@ from app.business.info_base.block import BlockManager from app.schemas.info_base.block import BlockForm, BlockModel from app.schemas.job import JobModel -from app.schemas.source import SourceModel, SourceID, SourceTypesModel +from app.schemas.source import SourceModel, SourceID, SourceTypesModel, SourceUpdateForm +from app.validation import input_path from .resolver import SOURCE_RESOLVER_ID, SourceContent ConfigTV = typing.TypeVar("ConfigTV", bound=pydantic.BaseModel) +class SourceNotFoundError(LookupError): + """A Source instance or persisted type does not exist.""" + + +class UnsupportedSourceCommandError(ValueError): + """The catalog explicitly does not support the requested collection mode.""" + + class EmptySourceCommandConfig(pydantic.BaseModel): model_config = pydantic.ConfigDict(extra="forbid") @@ -197,13 +209,9 @@ def create( storage: int | None = None, ) -> SourceModel: """Add a new source.""" - source_class = cls._SOURCE_CLASSES.get(type_) - if source_class is None: - raise ValueError(f"Source class {type_} not registered.") - normalized = source_class.__configcls__.model_validate(config or {}).model_dump( # pyrefly: ignore[missing-attribute] - mode="json" - ) with SessionLocal() as db: + with input_path("config"): + normalized = cls.normalize_config(type_, config or {}, db) source = SourceModel( type=type_, nickname=nickname, @@ -216,6 +224,111 @@ def create( return source + @classmethod + def normalize_config( + cls, + type_: str, + config: dict, + db_session: sqlmodel.Session, + *, + command: typing.Literal["collect", "backfill"] | None = None, + ) -> dict: + """Accept catalog-only input without requiring local execution capability.""" + catalog = db_session.get(SourceTypesModel, type_) + if catalog is None: + raise SourceNotFoundError(f"Source type {type_!r} does not exist") + source_class = cls._SOURCE_CLASSES.get(type_) + if command == "backfill": + schema = catalog.backfill_config_schema + model = source_class.__backfillconfigcls__ if source_class else None + elif command == "collect": + schema = catalog.collect_config_schema + model = source_class.__collectconfigcls__ if source_class else None + else: + schema = catalog.config_schema + model = source_class.__configcls__ if source_class else None # pyrefly: ignore[missing-attribute] + if schema is None: + raise UnsupportedSourceCommandError( + f"Source type {type_!r} does not support {command}" + ) + if model is not None: + return model.model_validate(config).model_dump(mode="json") + jsonschema.Draft202012Validator(schema).validate(config) + return config + + @classmethod + def get(cls, source_id: SourceID) -> SourceModel | None: + with SessionLocal() as db: + return db.get(SourceModel, source_id) + + @classmethod + def get_type(cls, type_: str) -> SourceTypesModel | None: + with SessionLocal() as db: + return db.get(SourceTypesModel, type_) + + @classmethod + def list_sources( + cls, *, limit: int | None = None, cursor: SourceID | None = None + ) -> tuple[list[SourceModel], SourceID | None]: + statement = sqlmodel.select(SourceModel).order_by(sqlmodel.col(SourceModel.id)) + if cursor is not None: + statement = statement.where(sqlmodel.col(SourceModel.id) > cursor) + if limit is not None: + statement = statement.limit(limit + 1) + with SessionLocal() as db: + rows = list(db.exec(statement).all()) + more = limit is not None and len(rows) > limit + rows = rows[:limit] + return rows, rows[-1].id if more else None + + @classmethod + def list_types( + cls, *, limit: int | None = None, cursor: str | None = None + ) -> tuple[list[SourceTypesModel], str | None]: + statement = sqlmodel.select(SourceTypesModel).order_by(SourceTypesModel.id) + if cursor is not None: + statement = statement.where(SourceTypesModel.id > cursor) + if limit is not None: + statement = statement.limit(limit + 1) + with SessionLocal() as db: + rows = list(db.exec(statement).all()) + more = limit is not None and len(rows) > limit + rows = rows[:limit] + return rows, rows[-1].id if more else None + + @classmethod + def update(cls, source_id: SourceID, form: SourceUpdateForm) -> SourceModel: + with SessionLocal() as db: + source = db.exec( + sqlmodel.select(SourceModel).where(SourceModel.id == source_id).with_for_update() + ).one_or_none() + if source is None: + raise SourceNotFoundError(f"Source {source_id} does not exist") + changes = form.model_dump(exclude_unset=True) + if "config" in changes: + with input_path("config"): + changes["config"] = cls.normalize_config(source.type, changes["config"], db) + for field, value in changes.items(): + setattr(source, field, value) + db.add(source) + if "nickname" in changes and source.block is not None: + cls.ensure_block(source, db) + db.commit() + db.refresh(source) + return source + + @classmethod + def delete(cls, source_id: SourceID) -> bool: + """Delete configuration only; collected graph and scheduled commands remain.""" + with SessionLocal() as db: + source = db.get(SourceModel, source_id) + if source is None: + return False + db.delete(source) + db.commit() + cls.SOURCES.pop(source_id, None) + return True + @classmethod def resolve_writable_storage( cls, diff --git a/app/configuration.py b/app/configuration.py index 7b746ea..c09431a 100644 --- a/app/configuration.py +++ b/app/configuration.py @@ -23,7 +23,14 @@ def validate( self, value: ConfigModelT | Mapping[str, typing.Any], ) -> ConfigModelT: - """Validate one complete value against the authoritative model.""" + """Validate new input; pass an already constructed target model through. + + Passing a model back through Pydantic can rerun its model validators. + Typed callers have already crossed that boundary; persistence consumers may + still use one native model construction to restore nested Python types. + """ + if isinstance(value, self.model): + return value return self.model.model_validate(value) def normalize( @@ -39,7 +46,11 @@ def prepare_patch( patch: Mapping[str, typing.Any], ) -> ConfigModelT: """Shallow-merge a patch and validate the resulting complete value.""" - candidate = self.normalize(current) + candidate = ( + current.model_dump(mode="json") + if isinstance(current, pydantic.BaseModel) + else dict(current) + ) candidate.update(patch) return self.validate(candidate) diff --git a/app/database_contract/profile.py b/app/database_contract/profile.py index 51dcb7b..757174b 100644 --- a/app/database_contract/profile.py +++ b/app/database_contract/profile.py @@ -303,6 +303,19 @@ def _boolean(default: bool) -> JsonObject: MEDIA_INTERPRETATION_PARAMETERS_SCHEMA, 1800, ), + JobTypeProfile( + "core.organization.rumination.explicit.v1", + "Reconsider one explicitly selected Block through rumination.", + { + "additionalProperties": False, + "description": "Explicit request to reconsider one focal Block.", + "properties": {"block": {"title": "Block", "type": "integer"}}, + "required": ["block"], + "title": "RuminationRequest", + "type": "object", + }, + 1800, + ), JobTypeProfile( "core.organization.rumination.automatic.v1", "Automatically reconsider bounded information seeds through rumination.", diff --git a/app/routes/agent.py b/app/routes/agent.py new file mode 100644 index 0000000..6aca677 --- /dev/null +++ b/app/routes/agent.py @@ -0,0 +1,72 @@ +"""Ordinary Agent definition management and local Tool discovery.""" + +import typing + +import fastapi + +from app.business.agent.main import AgentManager +from app.business.agent.contracts import AgentNotFoundError, MissingAgentToolError +from app.schemas.agent import AgentDefinitionModel, AgentForm, AgentUpdateForm +from app.schemas.ai import FunctionTool + +from .validation import database_write, request_input + + +ROUTER = fastapi.APIRouter(tags=["agent"]) + + +@ROUTER.get("/agents") +def list_agents( + limit: int | None = fastapi.Query(None, gt=0), cursor: int | None = None +) -> dict[str, typing.Any]: + rows, next_cursor = AgentManager.list_definitions(limit=limit, cursor=cursor) + return {"agents": rows, "next_cursor": next_cursor} + + +@ROUTER.get("/agents/{agent_id}") +def get_agent(agent_id: int) -> AgentDefinitionModel: + result = AgentManager.get_definition(agent_id) + if result is None: + raise fastapi.HTTPException(404, f"Agent {agent_id} not found") + return result + + +@ROUTER.post("/agents", status_code=201) +def create_agent( + body: AgentForm, request: fastapi.Request, response: fastapi.Response +) -> AgentDefinitionModel: + with database_write(): + result = AgentManager.create_definition(body) + response.headers["Location"] = str(request.url_for("get_agent", agent_id=result.id)) + return result + + +@ROUTER.patch("/agents/{agent_id}") +def update_agent(agent_id: int, body: AgentUpdateForm) -> AgentDefinitionModel: + try: + with database_write(), request_input(): + return AgentManager.update_definition(agent_id, body) + except AgentNotFoundError as error: + raise fastapi.HTTPException(404, str(error)) from error + + +@ROUTER.delete("/agents/{agent_id}", status_code=204) +def delete_agent(agent_id: int) -> None: + if not AgentManager.delete_definition(agent_id): + raise fastapi.HTTPException(404, f"Agent {agent_id} not found") + + +@ROUTER.get("/agent-tools") +def list_agent_tools( + limit: int | None = fastapi.Query(None, gt=0), cursor: str | None = None +) -> dict[str, typing.Any]: + tools, next_cursor = AgentManager.list_tools(limit=limit, cursor=cursor) + return {"tools": tools, "next_cursor": next_cursor} + + +@ROUTER.get("/agent-tools/{tool_id}") +def get_agent_tool(tool_id: str) -> FunctionTool: + try: + return AgentManager.get_tool(tool_id) + except MissingAgentToolError as error: + raise fastapi.HTTPException(404, str(error)) from error diff --git a/app/routes/ai.py b/app/routes/ai.py new file mode 100644 index 0000000..764aae8 --- /dev/null +++ b/app/routes/ai.py @@ -0,0 +1,27 @@ +"""AI model discovery, independent of Agent definition management.""" + +import typing + +import fastapi + +from app.business.ai import AIManager +from app.schemas.ai import AIModelModel + + +ROUTER = fastapi.APIRouter(prefix="/ai", tags=["ai"]) + + +@ROUTER.get("/models") +def list_ai_models( + limit: int | None = fastapi.Query(None, gt=0), cursor: int | None = None +) -> dict[str, typing.Any]: + rows, next_cursor = AIManager.list_models(limit=limit, cursor=cursor) + return {"models": rows, "next_cursor": next_cursor} + + +@ROUTER.get("/models/{model_id}") +def get_ai_model(model_id: int) -> AIModelModel: + model = AIManager.get_model(model_id) + if model is None: + raise fastapi.HTTPException(404, f"AI model {model_id} not found") + return model diff --git a/app/routes/block.py b/app/routes/block.py index 70e6ec8..b8d14e5 100644 --- a/app/routes/block.py +++ b/app/routes/block.py @@ -5,6 +5,8 @@ import fastapi from app.business.info_base.block import BlockManager, BlockModel from app.schemas.info_base.block import BlockForm +from app.schemas.info_base.rest import BlockUpdateForm +from .validation import database_write ROUTER = fastapi.APIRouter( prefix="/blocks", @@ -32,7 +34,8 @@ def create_block( response: fastapi.Response, ) -> BlockModel: """创建块""" - body = BlockManager.create(body) + with database_write(): + body = BlockManager.create(body) response.status_code = 201 return body @@ -41,20 +44,22 @@ def create_block( @ROUTER.patch("/{block_id}") def edit_block( block_id: int, - body: BlockModel, -) -> BlockModel: + body: BlockUpdateForm, +) -> dict: """编辑块(部分更新),只更新请求中提供的字段。""" try: - updated = BlockManager.edit_block( - block_id, - content=body.content, - resolver=body.resolver, - storage=body.storage, - ) + with database_write(): + updated = BlockManager.edit_block(block_id, **body.model_dump(exclude_unset=True)) except ValueError: raise fastapi.HTTPException( status_code=fastapi.status.HTTP_404_NOT_FOUND, detail=f"Block with id {block_id} not found.", ) else: - return updated + return {"type": "block", **updated.model_dump(mode="json")} + + +@ROUTER.delete("/{block_id}", status_code=204) +def delete_block(block_id: int) -> None: + if not BlockManager.delete(block_id): + raise fastapi.HTTPException(404, f"Block {block_id} not found") diff --git a/app/routes/content.py b/app/routes/content.py new file mode 100644 index 0000000..9dda954 --- /dev/null +++ b/app/routes/content.py @@ -0,0 +1,86 @@ +"""Same-response JSON/binary/MIME delivery for ordinary REST content reads.""" + +import json +import typing + +import aiohttp +import fastapi +import pydantic +from pydantic_core import to_jsonable_python + + +_VALUE = pydantic.TypeAdapter(typing.Any) +CONTENT_RESPONSES: dict[int | str, dict[str, typing.Any]] = { + 200: { + "content": { + "application/json": {}, + "application/octet-stream": {}, + "multipart/related": {}, + } + }, +} + + +def _json_bytes(value: typing.Any) -> bytes: + return json.dumps( + value, + ensure_ascii=False, + separators=(",", ":"), + allow_nan=False, + default=to_jsonable_python, + ).encode("utf-8") + + +async def content_response(value: typing.Any) -> fastapi.Response: + """Keep bytes typed without reserving sentinel shapes in authored JSON. + + Multipart's first part contains {value, parts}: parts maps RFC 6901 pointers + into value to binary Content-IDs. Only mapped nulls stand for bytes. No result + cache or second Resolver call is involved; current producers already hold bytes + in memory, so this adapter does not promise bounded-memory streaming. + """ + value = _VALUE.dump_python(value, mode="python", by_alias=True) + if isinstance(value, bytes): + return fastapi.Response(value, media_type="application/octet-stream") + blobs: list[tuple[str, bytes]] = [] + parts: dict[str, str] = {} + + def split(item: typing.Any, pointer: str) -> typing.Any: + if isinstance(item, bytes): + content_id = f"body-{len(blobs) + 1}@inkcre" + parts[pointer] = content_id + blobs.append((content_id, item)) + return None + if isinstance(item, dict): + return { + str(key): split( + child, pointer + "/" + str(key).replace("~", "~0").replace("/", "~1") + ) + for key, child in item.items() + } + if isinstance(item, (list, tuple)): + return [split(child, f"{pointer}/{index}") for index, child in enumerate(item)] + return item + + projected = split(value, "") + if not blobs: + return fastapi.Response(_json_bytes(projected), media_type="application/json") + writer = aiohttp.MultipartWriter("related") + writer.append( + _json_bytes({"value": projected, "parts": parts}), + { + "Content-Type": "application/json", + }, + ) + for content_id, blob in blobs: + writer.append( + blob, + { + "Content-Type": "application/octet-stream", + "Content-ID": f"<{content_id}>", + }, + ) + return fastapi.Response( + await writer.as_bytes(), + headers={"Content-Type": writer.content_type + '; type="application/json"'}, + ) diff --git a/app/routes/cron.py b/app/routes/cron.py new file mode 100644 index 0000000..b9a5fe3 --- /dev/null +++ b/app/routes/cron.py @@ -0,0 +1,84 @@ +"""Ordinary deployment-wide Cron resources.""" + +import contextlib +import typing + +import fastapi + +from app.business.cron import CronManager, CronNotFoundError, InvalidCronScheduleError +from app.business.job import JobManager, UnknownJobTypeError +from app.business.source.main import SourceNotFoundError, UnsupportedSourceCommandError +from app.schemas.cron import CronForm, CronModel, CronUpdateForm +from app.schemas.job import JobModel + +from .validation import request_input + + +ROUTER = fastapi.APIRouter(prefix="/crons", tags=["cron"]) + + +@contextlib.contextmanager +def _template_input(): + try: + with request_input(): + yield + except (CronNotFoundError, SourceNotFoundError) as error: + raise fastapi.HTTPException(404, str(error)) from error + except ( + InvalidCronScheduleError, + UnknownJobTypeError, + UnsupportedSourceCommandError, + ) as error: + raise fastapi.HTTPException(422, str(error)) from error + + +@ROUTER.get("") +def list_crons( + limit: int | None = fastapi.Query(None, gt=0), cursor: int | None = None +) -> dict[str, typing.Any]: + rows, next_cursor = CronManager.list_crons(limit=limit, cursor=cursor) + return {"crons": rows, "next_cursor": next_cursor} + + +@ROUTER.get("/{cron_id}") +def get_cron(cron_id: int) -> CronModel: + result = CronManager.get(cron_id) + if result is None: + raise fastapi.HTTPException(404, f"Cron {cron_id} not found") + return result + + +@ROUTER.post("", status_code=201) +def create_cron( + body: CronForm, request: fastapi.Request, response: fastapi.Response +) -> CronModel: + with _template_input(): + result = CronManager.create(body) + response.headers["Location"] = str(request.url_for("get_cron", cron_id=result.id)) + return result + + +@ROUTER.patch("/{cron_id}") +def update_cron(cron_id: int, body: CronUpdateForm) -> CronModel: + with _template_input(): + return CronManager.patch(cron_id, body) + + +@ROUTER.delete("/{cron_id}", status_code=204) +def delete_cron(cron_id: int) -> None: + if not CronManager.delete(cron_id): + raise fastapi.HTTPException(404, f"Cron {cron_id} not found") + + +@ROUTER.post("/{cron_id}/run", status_code=201) +def run_cron( + cron_id: int, + request: fastapi.Request, + response: fastapi.Response, + background: fastapi.BackgroundTasks, +) -> JobModel: + with _template_input(): + job = CronManager.run_now(cron_id) + response.headers["Location"] = str(request.url_for("get_job", job_id=job.id)) + background.add_task(JobManager.notify_worker) + return job diff --git a/app/routes/deployment_config.py b/app/routes/deployment_config.py index 73a6a61..50c7e5c 100644 --- a/app/routes/deployment_config.py +++ b/app/routes/deployment_config.py @@ -5,12 +5,10 @@ import typing import fastapi -import pydantic from app.business.deployment_config import ( DeploymentConfigManager, DeploymentConfigNotFoundError, - InvalidPersistedDeploymentConfigError, UnknownDeploymentConfigSchemaError, ) from app.schemas.deployment_config import ( @@ -18,9 +16,10 @@ DeploymentConfigReplaceForm, DeploymentConfigView, ) +from .validation import request_input -ROUTER = fastapi.APIRouter(prefix="/configs", tags=["config"]) +ROUTER = fastapi.APIRouter(tags=["config"]) def _unusable_config(error: Exception) -> typing.NoReturn: @@ -30,15 +29,33 @@ def _unusable_config(error: Exception) -> typing.NoReturn: ) from error -@ROUTER.get("/{key}", response_model_by_alias=True) -def get_config(key: DeploymentConfigKey) -> DeploymentConfigView: +@ROUTER.get("/configs") +def list_configs( + limit: int | None = fastapi.Query(None, gt=0), cursor: str | None = None +) -> dict[str, typing.Any]: + rows, next_cursor = DeploymentConfigManager.list_configs(limit=limit, cursor=cursor) + return {"configs": rows, "next_cursor": next_cursor} + + +@ROUTER.get("/config-schemas") +def list_config_schemas( + limit: int | None = fastapi.Query(None, gt=0), cursor: str | None = None +) -> dict[str, typing.Any]: + rows, next_cursor = DeploymentConfigManager.list_schemas(limit=limit, cursor=cursor) + return {"schemas": rows, "next_cursor": next_cursor} + + +@ROUTER.get("/config-schemas/{schema_id}") +def get_config_schema(schema_id: str) -> dict: try: - config = DeploymentConfigManager.read(key) - except ( - UnknownDeploymentConfigSchemaError, - InvalidPersistedDeploymentConfigError, - ) as error: - _unusable_config(error) + return DeploymentConfigManager.get_schema(schema_id) + except UnknownDeploymentConfigSchemaError as error: + raise fastapi.HTTPException(404, str(error)) from error + + +@ROUTER.get("/configs/{key}", response_model_by_alias=True) +def get_config(key: DeploymentConfigKey) -> DeploymentConfigView: + config = DeploymentConfigManager.read(key) if config is None: raise fastapi.HTTPException( status_code=fastapi.status.HTTP_404_NOT_FOUND, @@ -47,44 +64,44 @@ def get_config(key: DeploymentConfigKey) -> DeploymentConfigView: return config -@ROUTER.put("/{key}", response_model_by_alias=True) +@ROUTER.put("/configs/{key}", response_model_by_alias=True) def replace_config( key: DeploymentConfigKey, body: DeploymentConfigReplaceForm, + response: fastapi.Response, ) -> DeploymentConfigView: try: - return DeploymentConfigManager.replace(key, body.schema_id, body.value) + with request_input("value"): + result, created = DeploymentConfigManager.replace_with_status( + key, body.schema_id, body.value + ) + response.status_code = 201 if created else 200 + return result except UnknownDeploymentConfigSchemaError as error: raise fastapi.HTTPException( status_code=fastapi.status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(error), ) from error - except pydantic.ValidationError as error: - raise fastapi.HTTPException( - status_code=fastapi.status.HTTP_422_UNPROCESSABLE_CONTENT, - detail=error.errors(), - ) from error -@ROUTER.patch("/{key}", response_model_by_alias=True) +@ROUTER.patch("/configs/{key}", response_model_by_alias=True) def patch_config( key: DeploymentConfigKey, body: dict[str, typing.Any] = fastapi.Body(...), ) -> DeploymentConfigView: try: - return DeploymentConfigManager.patch(key, body) + with request_input(): + return DeploymentConfigManager.patch(key, body) except DeploymentConfigNotFoundError as error: raise fastapi.HTTPException( status_code=fastapi.status.HTTP_404_NOT_FOUND, detail=str(error), ) from error - except ( - UnknownDeploymentConfigSchemaError, - InvalidPersistedDeploymentConfigError, - ) as error: + except UnknownDeploymentConfigSchemaError as error: _unusable_config(error) - except pydantic.ValidationError as error: - raise fastapi.HTTPException( - status_code=fastapi.status.HTTP_422_UNPROCESSABLE_CONTENT, - detail=error.errors(), - ) from error + + +@ROUTER.delete("/configs/{key}", status_code=204) +def delete_config(key: str) -> None: + if not DeploymentConfigManager.delete(key): + raise fastapi.HTTPException(404, f"Deployment config {key!r} not found") diff --git a/app/routes/entities.py b/app/routes/entities.py new file mode 100644 index 0000000..a0fb687 --- /dev/null +++ b/app/routes/entities.py @@ -0,0 +1,79 @@ +"""Batch record projection, distinct from Resolver content methods.""" + +import typing + +import fastapi + +from app.business.info_base import BlockManager, RelationManager +from app.schemas.info_base.relation import RelationModel +from app.schemas.info_base.rest import EntitiesGetForm +from libs.obsrv.main import get_logger + +from .content import CONTENT_RESPONSES, content_response + + +ROUTER = fastapi.APIRouter(tags=["info-base"]) +LOGGER = get_logger().getChild(__name__) + + +def relation_record(relation: RelationModel) -> dict[str, typing.Any]: + record = relation.model_dump(mode="json", exclude={"from_", "to_"}) + return {**record, "from_block_id": relation.from_, "to_block_id": relation.to_} + + +@ROUTER.post("/entities/get", responses=CONTENT_RESPONSES) +async def get_entities(body: EntitiesGetForm) -> fastapi.Response: + # Shared database faults remain request failures. Only a requested entity's + # content read can fail independently after these batch record queries. + blocks = { + block.id: block + for block in BlockManager.get_many( + [ref.id for ref in body.entities if ref.type == "block"] + ) + } + relations = { + relation.id: relation + for relation in RelationManager.get_many( + [ref.id for ref in body.entities if ref.type == "relation"] + ) + } + results: list[dict[str, typing.Any]] = [] + for ref in body.entities: + record = blocks.get(ref.id) if ref.type == "block" else relations.get(ref.id) + correlation = ref.model_dump() + if record is None: + results.append( + { + **correlation, + "error": { + "code": "not_found", + "message": f"{ref.type} {ref.id} not found", + }, + } + ) + continue + if isinstance(record, RelationModel): + projected = relation_record(record) + if body.content == "none": + projected.pop("content") + else: + projected = record.model_dump(mode="json", exclude={"content"}) + if body.content == "raw": + projected["content"] = record.content + elif body.content == "hydrated": + try: + projected["hydrated_content"] = await record.get_hydrated_content() + except Exception as error: + LOGGER.exception("Block hydration failed", extra={"block_id": ref.id}) + results.append( + { + **correlation, + "error": { + "code": "content_read_failed", + "message": str(error), + }, + } + ) + continue + results.append({**correlation, **projected}) + return await content_response(results) diff --git a/app/routes/extension.py b/app/routes/extension.py index 99b242a..4c4b788 100644 --- a/app/routes/extension.py +++ b/app/routes/extension.py @@ -10,7 +10,7 @@ EXTENSION_MANAGEMENT_CAPABILITY, InstalledExtension, ) -from app.business.peer import PeerHTTPInbound +from app.business.peer import PeerHTTPInbound, PeerManager from app.business.extension.errors import ( ExtensionAcquisitionError, ExtensionCompatibilityError, @@ -22,6 +22,10 @@ ExtensionStateConflictError, ) from app.schemas.extension import ExtensionManagementCommand +from app.schemas.extension.main import EnableExtensionCommand, DisableExtensionCommand +from app.schemas.peer import PeerRef + +from .validation import request_input ROUTER = fastapi.APIRouter(tags=["extension"]) @@ -58,8 +62,16 @@ def _raise_http_error(error: ExtensionHostError) -> typing.NoReturn: @ROUTER.get("/extensions") -def list_extensions() -> tuple[InstalledExtension, ...]: - return EXTENSION_HOST.list() +def list_extensions( + limit: int | None = fastapi.Query(None, gt=0), cursor: str | None = None +) -> dict[str, typing.Any]: + rows = sorted( + (row for row in EXTENSION_HOST.list() if cursor is None or row.name > cursor), + key=lambda row: row.name, + ) + more = limit is not None and len(rows) > limit + rows = rows[:limit] + return {"extensions": rows, "next_cursor": rows[-1].name if more else None} @ROUTER.get("/extensions/{namespace}/{name}") @@ -99,28 +111,45 @@ def update_extension_config( body: dict[str, typing.Any] = fastapi.Body(...), ) -> InstalledExtension: try: - return EXTENSION_HOST.update_config(_coordinate(namespace, name), body) - except pydantic.ValidationError as error: - raise fastapi.HTTPException( - status_code=fastapi.status.HTTP_422_UNPROCESSABLE_CONTENT, - detail=error.errors(include_url=False, include_context=False), - ) from error + with request_input(): + return EXTENSION_HOST.update_config(_coordinate(namespace, name), body) + except ExtensionHostError as error: + _raise_http_error(error) + + +@ROUTER.patch("/extensions/{namespace}/{name}/config") +def patch_extension_config( + namespace: str, name: str, body: dict[str, typing.Any] = fastapi.Body(...) +) -> InstalledExtension: + try: + with request_input(): + return EXTENSION_HOST.patch_config(_coordinate(namespace, name), body) except ExtensionHostError as error: _raise_http_error(error) @ROUTER.post("/extensions/{namespace}/{name}/enable") -async def enable_extension(namespace: str, name: str) -> InstalledExtension: +async def enable_extension( + namespace: str, name: str, route_to_peer: PeerRef | None = None +) -> InstalledExtension: try: - return await EXTENSION_HOST.enable(_coordinate(namespace, name)) + return await EXTENSION_HOST.manage( + EnableExtensionCommand(action="enable", extension=_coordinate(namespace, name)), + route_to_peer=route_to_peer or PeerManager.get_current_peer_ref(), + ) except ExtensionHostError as error: _raise_http_error(error) @ROUTER.post("/extensions/{namespace}/{name}/disable") -async def disable_extension(namespace: str, name: str) -> InstalledExtension: +async def disable_extension( + namespace: str, name: str, route_to_peer: PeerRef | None = None +) -> InstalledExtension: try: - return await EXTENSION_HOST.disable(_coordinate(namespace, name)) + return await EXTENSION_HOST.manage( + DisableExtensionCommand(action="disable", extension=_coordinate(namespace, name)), + route_to_peer=route_to_peer or PeerManager.get_current_peer_ref(), + ) except ExtensionHostError as error: _raise_http_error(error) diff --git a/app/routes/graph_navigation.py b/app/routes/graph_navigation.py new file mode 100644 index 0000000..c832731 --- /dev/null +++ b/app/routes/graph_navigation.py @@ -0,0 +1,77 @@ +"""Ordinary bounded graph queries; no Resolver, layout or Peer invocation.""" + +import fastapi +import pydantic + +from app.business.graph_navigation_retrieval import GraphNavigationRetrievalManager +from app.schemas.graph_navigation_retrieval import ( + GraphDirection, + GraphModel, + DEFAULT_NEIGHBORHOOD_LIMIT, + MAX_NEIGHBORHOOD_LIMIT, +) +from app.schemas.info_base.rest import GraphPathForm, GraphComponentsForm + +from .entities import relation_record + + +ROUTER = fastapi.APIRouter(tags=["graph-navigation"]) + + +def _result(value: pydantic.BaseModel) -> dict: + result = value.model_dump(mode="json") + for field in ("graph", "proof_graph"): + graph = getattr(value, field, None) + if isinstance(graph, GraphModel): + result[field]["relations"] = [relation_record(row) for row in graph.relations] + return result + + +@ROUTER.get("/blocks/{block_id}/neighborhood") +def block_neighborhood( + block_id: int, + direction: GraphDirection = "both", + contents: list[str] = fastapi.Query(default_factory=list), + limit: int = fastapi.Query(DEFAULT_NEIGHBORHOOD_LIMIT, ge=1, le=MAX_NEIGHBORHOOD_LIMIT), + cursor: int | None = None, +) -> dict: + result = GraphNavigationRetrievalManager.get_block_neighborhood( + block_id, direction=direction, contents=contents, limit=limit, cursor=cursor + ) + if result is None: + raise fastapi.HTTPException(404, f"Block {block_id} not found") + return _result(result) + + +@ROUTER.get("/relations/{relation_id}/neighborhood") +def relation_neighborhood(relation_id: int) -> dict: + result = GraphNavigationRetrievalManager.get_relation_neighborhood(relation_id) + if result is None: + raise fastapi.HTTPException(404, f"Relation {relation_id} not found") + return _result(result) + + +@ROUTER.post("/graph/path") +def graph_path(body: GraphPathForm) -> dict: + return _result( + GraphNavigationRetrievalManager.find_path( + body.from_block_id, + body.to_block_id, + direction=body.direction, + contents=body.contents, + max_hops=body.max_hops, + max_explored_blocks=body.max_explored_blocks, + ) + ) + + +@ROUTER.post("/graph/components") +def graph_components(body: GraphComponentsForm) -> dict: + return _result( + GraphNavigationRetrievalManager.get_connected_components( + body.seed_block_ids, + contents=body.contents, + max_explored_blocks=body.max_explored_blocks, + max_explored_relations=body.max_explored_relations, + ) + ) diff --git a/app/routes/info_base.py b/app/routes/info_base.py index 920bd80..c369aa5 100644 --- a/app/routes/info_base.py +++ b/app/routes/info_base.py @@ -6,11 +6,13 @@ from app.business.info_base.main import InfoBaseManager from app.schemas.info_base.main import GraphForm, SubmitGraphResult +from .validation import database_write ROUTER = fastapi.APIRouter(tags=["info-base"]) -@ROUTER.put("/graph") +@ROUTER.post("/graph") def submit_graph(body: GraphForm) -> SubmitGraphResult: - return InfoBaseManager.submit_graph(body) + with database_write(): + return InfoBaseManager.submit_graph(body) diff --git a/app/routes/job.py b/app/routes/job.py new file mode 100644 index 0000000..024e935 --- /dev/null +++ b/app/routes/job.py @@ -0,0 +1,78 @@ +"""Ordinary Job resources; admission is independent of local execution eligibility.""" + +import typing + +import fastapi + +from app.business.job import JobManager, UnknownJobTypeError +from app.business.source.main import SourceNotFoundError, UnsupportedSourceCommandError +from app.schemas.job import JobCreateForm, JobModel, JobStatus, JobTypeModel + +from .validation import request_input + + +ROUTER = fastapi.APIRouter(tags=["job"]) + + +@ROUTER.get("/job-types") +def list_job_types( + limit: int | None = fastapi.Query(None, gt=0), cursor: str | None = None +) -> dict[str, typing.Any]: + rows, next_cursor = JobManager.list_types(limit=limit, cursor=cursor) + return {"job_types": rows, "next_cursor": next_cursor} + + +@ROUTER.get("/job-types/{type_}") +def get_job_type(type_: str) -> JobTypeModel: + result = JobManager.get_type(type_) + if result is None: + raise fastapi.HTTPException(404, f"Job type {type_!r} not found") + return result + + +@ROUTER.get("/jobs") +def list_jobs( + limit: int = fastapi.Query(20, gt=0), + cursor: int | None = None, + type_: str | None = fastapi.Query(None, alias="type"), + status: JobStatus | None = None, +) -> dict[str, typing.Any]: + rows, next_cursor = JobManager.list_jobs( + limit=limit, cursor=cursor, type_=type_, status=status + ) + return {"jobs": rows, "next_cursor": next_cursor} + + +@ROUTER.get("/jobs/{job_id}") +def get_job(job_id: int) -> JobModel: + result = JobManager.get(job_id) + if result is None: + raise fastapi.HTTPException(404, f"Job {job_id} not found") + return result + + +@ROUTER.post("/jobs", status_code=201) +def create_job( + body: JobCreateForm, + request: fastapi.Request, + response: fastapi.Response, + background: fastapi.BackgroundTasks, +) -> JobModel: + try: + with request_input("parameters"): + job = JobManager.create(body.type, body.parameters, body.timeout_seconds) + except SourceNotFoundError as error: + raise fastapi.HTTPException(404, str(error)) from error + except (UnknownJobTypeError, UnsupportedSourceCommandError) as error: + raise fastapi.HTTPException(422, str(error)) from error + response.headers["Location"] = str(request.url_for("get_job", job_id=job.id)) + background.add_task(JobManager.notify_worker) + return job + + +@ROUTER.post("/jobs/{job_id}/abort") +def abort_job(job_id: int) -> JobModel: + result = JobManager.abort(job_id) + if result is None: + raise fastapi.HTTPException(404, f"Job {job_id} not found") + return result diff --git a/app/routes/peer.py b/app/routes/peer.py new file mode 100644 index 0000000..fbc3eca --- /dev/null +++ b/app/routes/peer.py @@ -0,0 +1,32 @@ +"""Peer record/lease observation; no capability invocation or wake protocol.""" + +import typing + +import fastapi + +from app.business.peer import PeerManager +from app.schemas.peer import PeerRef + + +ROUTER = fastapi.APIRouter(prefix="/peers", tags=["peer"]) + + +@ROUTER.get("") +def list_peers( + limit: int | None = fastapi.Query(None, gt=0), cursor: PeerRef | None = None +) -> dict[str, typing.Any]: + peers, next_cursor = PeerManager.list_with_leases(limit=limit, cursor=cursor) + return {"peers": peers, "next_cursor": next_cursor} + + +@ROUTER.get("/self") +def get_self_peer() -> dict: + return get_peer(PeerManager.get_current_peer_ref()) + + +@ROUTER.get("/{peer_id}") +def get_peer(peer_id: PeerRef) -> dict: + result = PeerManager.get_with_lease(peer_id) + if result is None: + raise fastapi.HTTPException(404, f"Peer {peer_id} not found") + return result diff --git a/app/routes/relation.py b/app/routes/relation.py index 79b00e8..b61a0de 100644 --- a/app/routes/relation.py +++ b/app/routes/relation.py @@ -6,7 +6,11 @@ import fastapi from app.business.info_base.relation import RelationManager -from app.schemas.info_base.relation import RelationCreateForm, RelationModel +from app.schemas.info_base.relation import RelationCreateForm +from app.schemas.info_base.rest import RelationUpdateForm + +from .entities import relation_record +from .validation import database_write ROUTER = fastapi.APIRouter( @@ -16,16 +20,40 @@ @ROUTER.post("") -def create_relation(body: RelationCreateForm) -> RelationModel: - return RelationManager.create( - from_=body.from_, - to_=body.to_, - content=body.content, - ) +def create_relation(body: RelationCreateForm) -> dict: + with database_write(): + return relation_record( + RelationManager.create( + from_=body.from_, + to_=body.to_, + content=body.content, + ) + ) @ROUTER.get("/by_block/{block_id}") def get_relations_by_block( block_id: int, -) -> tuple[RelationModel, ...]: - return RelationManager.get(block_id=block_id) +) -> tuple[dict, ...]: + return tuple(relation_record(item) for item in RelationManager.get(block_id=block_id)) + + +@ROUTER.patch("/{relation_id}") +def update_relation(relation_id: int, body: RelationUpdateForm) -> dict: + changes = body.model_dump(exclude_unset=True) + if "from_block_id" in changes: + changes["from_"] = changes.pop("from_block_id") + if "to_block_id" in changes: + changes["to_"] = changes.pop("to_block_id") + try: + with database_write(): + updated = RelationManager.update(relation_id, **changes) + except ValueError as error: + raise fastapi.HTTPException(404, str(error)) from error + return {"type": "relation", **relation_record(updated)} + + +@ROUTER.delete("/{relation_id}", status_code=204) +def delete_relation(relation_id: int) -> None: + if not RelationManager.delete(relation_id): + raise fastapi.HTTPException(404, f"Relation {relation_id} not found") diff --git a/app/routes/resolver.py b/app/routes/resolver.py new file mode 100644 index 0000000..79ea74e --- /dev/null +++ b/app/routes/resolver.py @@ -0,0 +1,106 @@ +"""Dynamic ordinary Resolver method discovery and invocation.""" + +import typing + +import fastapi +from fastapi.exceptions import RequestValidationError + +from app.business.info_base import BlockManager +from app.business.info_base.resolver import ( + ResolverManager, + ResolverMethodInputError, + UnknownResolverError, + UnknownResolverMethodError, + UnsupportedResolverCapability, +) + +from .content import CONTENT_RESPONSES, content_response + + +ROUTER = fastapi.APIRouter(tags=["resolver"]) + + +@ROUTER.get("/resolvers") +def list_resolvers( + limit: int | None = fastapi.Query(None, gt=0), cursor: str | None = None +) -> dict[str, typing.Any]: + ids = sorted( + key for key in ResolverManager.RESOLVER_CLS if cursor is None or key > cursor + ) + more = limit is not None and len(ids) > limit + ids = ids[:limit] + return { + "resolvers": [ + {"id": key, "description": ResolverManager.RESOLVER_CLS[key].__doc__ or ""} + for key in ids + ], + "next_cursor": ids[-1] if more else None, + } + + +def _methods(resolver_id: str, limit: int | None, cursor: str | None) -> dict: + if resolver_id not in ResolverManager.RESOLVER_CLS: + raise fastapi.HTTPException(404, f"Resolver {resolver_id!r} not registered") + methods = sorted( + ( + item + for item in ResolverManager.get_method_contracts(resolver_id) + if cursor is None or item.name > cursor + ), + key=lambda item: item.name, + ) + more = limit is not None and len(methods) > limit + methods = methods[:limit] + return { + "resolver": resolver_id, + "methods": [ + { + "name": item.name, + "description": item.description, + "input_schema": item.input_schema, + } + for item in methods + ], + "next_cursor": methods[-1].name if more else None, + } + + +@ROUTER.get("/resolvers/{resolver_id}/methods") +def resolver_methods( + resolver_id: str, + limit: int | None = fastapi.Query(None, gt=0), + cursor: str | None = None, +) -> dict: + return _methods(resolver_id, limit, cursor) + + +@ROUTER.get("/blocks/{block_id}/resolver/methods") +def block_resolver_methods( + block_id: int, + limit: int | None = fastapi.Query(None, gt=0), + cursor: str | None = None, +) -> dict: + block = BlockManager.get(block_id) + if block is None: + raise fastapi.HTTPException(404, f"Block {block_id} not found") + return _methods(block.resolver, limit, cursor) + + +@ROUTER.post("/blocks/{block_id}/resolver/methods/{method}", responses=CONTENT_RESPONSES) +async def invoke_resolver_method( + block_id: int, method: str, body: dict = fastapi.Body(default_factory=dict) +) -> fastapi.Response: + block = BlockManager.get(block_id) + if block is None: + raise fastapi.HTTPException(404, f"Block {block_id} not found") + try: + value = await ResolverManager.invoke_method(block, method, body) + except ResolverMethodInputError as error: + raise RequestValidationError( + [{**item, "loc": ("body", *item["loc"])} for item in error.errors(include_url=False)] + ) from error + except (UnknownResolverError, UnknownResolverMethodError) as error: + raise fastapi.HTTPException(404, str(error)) from error + except UnsupportedResolverCapability as error: + raise fastapi.HTTPException(409, str(error)) from error + return await content_response(value) diff --git a/app/routes/retrieval.py b/app/routes/retrieval.py new file mode 100644 index 0000000..be28ea6 --- /dev/null +++ b/app/routes/retrieval.py @@ -0,0 +1,65 @@ +"""Ordinary retrieval facades, distinct from non-delegating Peer inbounds.""" + +import typing + +import fastapi + +from app.business.lexical_retrieval import LexicalRetrievalManager +from app.business.semantic_retrieval import ( + SemanticRetrievalManager, + SemanticRetrievalNotConfiguredError, + EmbeddingProfileNotFoundError, +) +from app.schemas.ai import EmbeddingProfileModel +from app.schemas.peer import PeerRef +from app.schemas.lexical_retrieval import LexicalRetrievalRequest, LexicalRetrievalResult +from app.schemas.semantic_retrieval import SemanticRetrievalRequest + +from .entities import relation_record + + +ROUTER = fastapi.APIRouter(tags=["retrieval"]) + + +@ROUTER.post("/retrieval/lexical") +async def retrieve_lexical( + body: LexicalRetrievalRequest, route_to_peer: PeerRef | None = None +) -> LexicalRetrievalResult: + return await LexicalRetrievalManager.retrieve( + body.query, body.limit, route_to_peer=route_to_peer + ) + + +@ROUTER.post("/retrieval/semantic") +async def retrieve_semantic( + body: SemanticRetrievalRequest, route_to_peer: PeerRef | None = None +) -> dict[str, typing.Any]: + try: + result = await SemanticRetrievalManager.retrieve( + body.query, body.profile, body.options, route_to_peer=route_to_peer + ) + except SemanticRetrievalNotConfiguredError as error: + raise fastapi.HTTPException(409, str(error)) from error + except EmbeddingProfileNotFoundError as error: + raise fastapi.HTTPException(404, str(error)) from error + projected = result.model_dump(mode="json") + for match, output in zip(result.matches, projected["matches"]): + if match.type == "relation": + output["entity"] = relation_record(match.entity) + return projected + + +@ROUTER.get("/embedding-profiles") +def list_embedding_profiles( + limit: int | None = fastapi.Query(None, gt=0), cursor: int | None = None +) -> dict[str, typing.Any]: + rows, next_cursor = SemanticRetrievalManager.list_profiles(limit=limit, cursor=cursor) + return {"profiles": rows, "next_cursor": next_cursor} + + +@ROUTER.get("/embedding-profiles/{profile_id}") +def get_embedding_profile(profile_id: int) -> EmbeddingProfileModel: + result = SemanticRetrievalManager.get_profile(profile_id) + if result is None: + raise fastapi.HTTPException(404, f"Embedding profile {profile_id} not found") + return result diff --git a/app/routes/source.py b/app/routes/source.py index e6e0247..31e0d1c 100644 --- a/app/routes/source.py +++ b/app/routes/source.py @@ -1,40 +1,125 @@ -"""Source Module's API Endpoints""" +"""Source configuration resources and Job admission conveniences.""" -__all__ = ["ROUTER"] +import typing import fastapi -from typing import Optional as Opt + from app.business.job import JobManager from app.business.source import SOURCE_BACKFILL_JOB_TYPE, SOURCE_COLLECT_JOB_TYPE +from app.business.source.main import ( + SourceManager, + SourceNotFoundError, + UnsupportedSourceCommandError, +) from app.schemas.job import JobModel -from app.schemas.source import SourceID - -ROUTER = fastapi.APIRouter( - prefix="/sources", - tags=["source"], +from app.schemas.source import ( + SourceCreateForm, + SourceModel, + SourceTypesModel, + SourceUpdateForm, ) +from .validation import database_write, request_input + + +ROUTER = fastapi.APIRouter(tags=["source"]) + + +@ROUTER.get("/source-types") +def list_source_types( + limit: int | None = fastapi.Query(None, gt=0), cursor: str | None = None +) -> dict[str, typing.Any]: + rows, next_cursor = SourceManager.list_types(limit=limit, cursor=cursor) + return {"source_types": rows, "next_cursor": next_cursor} + + +@ROUTER.get("/source-types/{type_}") +def get_source_type(type_: str) -> SourceTypesModel: + result = SourceManager.get_type(type_) + if result is None: + raise fastapi.HTTPException(404, f"Source type {type_!r} not found") + return result + + +@ROUTER.get("/sources") +def list_sources( + limit: int | None = fastapi.Query(None, gt=0), cursor: int | None = None +) -> dict[str, typing.Any]: + rows, next_cursor = SourceManager.list_sources(limit=limit, cursor=cursor) + return {"sources": rows, "next_cursor": next_cursor} + -@ROUTER.post("/{source_id}/collect") -async def run_source_collect(source_id: SourceID, body: Opt[dict] = None) -> JobModel: - """Run source collect (by creating a source collect job.)""" - job = JobManager.create( - SOURCE_COLLECT_JOB_TYPE, - {"source": source_id, "config": body or {}}, - ) - await JobManager.check() +@ROUTER.get("/sources/{source_id}") +def get_source(source_id: int) -> SourceModel: + result = SourceManager.get(source_id) + if result is None: + raise fastapi.HTTPException(404, f"Source {source_id} not found") + return result + + +@ROUTER.post("/sources", status_code=201) +def create_source( + body: SourceCreateForm, request: fastapi.Request, response: fastapi.Response +) -> SourceModel: + try: + with database_write(), request_input(): + source = SourceManager.create(body.type, body.nickname, body.config, body.storage) + except SourceNotFoundError as error: + raise fastapi.HTTPException(404, str(error)) from error + response.headers["Location"] = str(request.url_for("get_source", source_id=source.id)) + return source + + +@ROUTER.patch("/sources/{source_id}") +def update_source(source_id: int, body: SourceUpdateForm) -> SourceModel: + try: + with database_write(), request_input(): + return SourceManager.update(source_id, body) + except SourceNotFoundError as error: + raise fastapi.HTTPException(404, str(error)) from error + + +@ROUTER.delete("/sources/{source_id}", status_code=204) +def delete_source(source_id: int) -> None: + if not SourceManager.delete(source_id): + raise fastapi.HTTPException(404, f"Source {source_id} not found") + + +def _collect_job(source_id: int, type_: str, config: dict, timeout: int | None) -> JobModel: + try: + with request_input(strip=("config",)): + return JobManager.create(type_, {"source": source_id, "config": config}, timeout) + except SourceNotFoundError as error: + raise fastapi.HTTPException(404, str(error)) from error + except UnsupportedSourceCommandError as error: + raise fastapi.HTTPException(422, str(error)) from error + + +@ROUTER.post("/sources/{source_id}/collect", status_code=201) +def run_source_collect( # noqa: PLR0913 - includes FastAPI request/response injection + source_id: int, + request: fastapi.Request, + response: fastapi.Response, + background: fastapi.BackgroundTasks, + body: dict = fastapi.Body(default_factory=dict), + timeout_seconds: int | None = fastapi.Query(None, gt=0), +) -> JobModel: + job = _collect_job(source_id, SOURCE_COLLECT_JOB_TYPE, body, timeout_seconds) + response.headers["Location"] = str(request.url_for("get_job", job_id=job.id)) + background.add_task(JobManager.notify_worker) return job -@ROUTER.post("/{source_id}/backfill") -async def run_source_backfill( - source_id: SourceID, body: dict, timeout_seconds: int | None = None +@ROUTER.post("/sources/{source_id}/backfill", status_code=201) +def run_source_backfill( # noqa: PLR0913 - includes FastAPI request/response injection + source_id: int, + body: dict, + request: fastapi.Request, + response: fastapi.Response, + background: fastapi.BackgroundTasks, + timeout_seconds: int | None = fastapi.Query(None, gt=0), ) -> JobModel: - """Create one explicit historical Source Job.""" - job = JobManager.create( - SOURCE_BACKFILL_JOB_TYPE, - {"source": source_id, "config": body}, - timeout_seconds, - ) - await JobManager.check() + job = _collect_job(source_id, SOURCE_BACKFILL_JOB_TYPE, body, timeout_seconds) + response.headers["Location"] = str(request.url_for("get_job", job_id=job.id)) + background.add_task(JobManager.notify_worker) return job diff --git a/app/routes/validation.py b/app/routes/validation.py new file mode 100644 index 0000000..f2b0bcc --- /dev/null +++ b/app/routes/validation.py @@ -0,0 +1,60 @@ +"""Native dynamic-input errors at ordinary REST boundaries.""" + +import contextlib + +import fastapi.exceptions +import jsonschema # pyrefly: ignore[untyped-import] +import pydantic +import psycopg.errors +from sqlalchemy.exc import IntegrityError + + +@contextlib.contextmanager +def database_write(): + """Translate database-owned reference/invariant conflicts for an explicit write. + + Do not pre-read referenced rows or repeat CHECK predicates in the adapter. + This is not a global database-error handler: connection faults, programming + errors and generated-key collisions still propagate as server failures. + """ + try: + yield + except IntegrityError as error: + if not isinstance( + error.orig, (psycopg.errors.ForeignKeyViolation, psycopg.errors.CheckViolation) + ): + raise + detail = {"message": error.orig.diag.message_primary} + if error.orig.diag.constraint_name: + detail["constraint"] = error.orig.diag.constraint_name + raise fastapi.exceptions.HTTPException(409, detail) from error + + +@contextlib.contextmanager +def request_input(*path: str | int, strip: tuple[str | int, ...] = ()): + """Map only the enclosed input operation, never Resolver execution/output.""" + + def location(parts): + parts = tuple(parts) + if strip and parts[: len(strip)] == strip: + parts = parts[len(strip) :] + return ("body", *path, *parts) + + try: + yield + except pydantic.ValidationError as error: + details = [ + {**item, "loc": location(item["loc"])} for item in error.errors(include_url=False) + ] + raise fastapi.exceptions.RequestValidationError(details) from error + except jsonschema.ValidationError as error: + raise fastapi.exceptions.RequestValidationError( + [ + { + "loc": location(error.absolute_path), + "msg": error.message, + "type": "json_schema", + "input": error.instance, + } + ] + ) from error diff --git a/app/schemas/AGENTS.md b/app/schemas/AGENTS.md index 7f7adcd..658aa47 100644 --- a/app/schemas/AGENTS.md +++ b/app/schemas/AGENTS.md @@ -1 +1,4 @@ -- Import your schema in `app/schemas/__init__.py` to make Alembic discovers your schema while generating migrations. \ No newline at end of file +- Import your schema in `app/schemas/__init__.py` so Alembic discovers it while generating migrations. +- Follow the [Python input/restoration boundary](../../docs/30-unit-tdd/business-pipeline-and-authority.md#cross-subtree-constraints) + when changing Pydantic forms or persistence codecs. A typed input should not be dumped and revalidated merely to cross + an internal call boundary; necessary restoration of nested persisted types is a different operation. diff --git a/app/schemas/agent.py b/app/schemas/agent.py index fbd5671..5151700 100644 --- a/app/schemas/agent.py +++ b/app/schemas/agent.py @@ -38,7 +38,7 @@ def process_bind_param(self, value, dialect): def process_result_value(self, value, dialect): del dialect - return normalize_agent_tools(value or ()) + return tuple(value or ()) class ToolChoiceType(sqlalchemy.TypeDecorator): @@ -62,6 +62,37 @@ def process_result_value(self, value, dialect): return _TOOL_CHOICE_ADAPTER.validate_python(value) +class AgentForm(pydantic.BaseModel): + """Editable Agent definition, without execution or database-managed fields.""" + + model_config = pydantic.ConfigDict(extra="forbid") + + name: str + system_prompt: str + model: AIModelID + tools: tuple[str, ...] = () + tool_choice: ToolChoice | None = None + max_model_calls_per_turn: int = pydantic.Field(gt=0) + + @pydantic.field_validator("tools") + @classmethod + def canonical_tools(cls, value: tuple[str, ...]) -> tuple[str, ...]: + return normalize_agent_tools(value) + + +class AgentUpdateForm(pydantic.BaseModel): + """PATCH omission preserves a field; only tool_choice accepts explicit null.""" + + model_config = pydantic.ConfigDict(extra="forbid") + + name: str = pydantic.Field(default=None) # pyrefly: ignore[bad-assignment] + system_prompt: str = pydantic.Field(default=None) # pyrefly: ignore[bad-assignment] + model: AIModelID = pydantic.Field(default=None) # pyrefly: ignore[bad-assignment] + tools: tuple[str, ...] = () + tool_choice: ToolChoice | None = None + max_model_calls_per_turn: int = pydantic.Field(default=None, gt=0) # pyrefly: ignore[bad-assignment] + + class AgentDefinitionModel(sqlmodel.SQLModel, table=True): """One reusable system-prompt, model, Tool and turn-budget composition.""" diff --git a/app/schemas/ai/capability.py b/app/schemas/ai/capability.py index 0f4de8c..4a52afc 100644 --- a/app/schemas/ai/capability.py +++ b/app/schemas/ai/capability.py @@ -55,12 +55,12 @@ class ChatCapability(AICapability): pydantic.Field(discriminator="type"), ] -_CAPABILITIES_ADAPTER = pydantic.TypeAdapter(tuple[AIModelCapability, ...]) +CAPABILITIES_ADAPTER = pydantic.TypeAdapter(tuple[AIModelCapability, ...]) def normalize_capabilities(value: typing.Any) -> tuple[AIModelCapability, ...]: """Validate and canonically order one declaration per capability type.""" - capabilities = _CAPABILITIES_ADAPTER.validate_python(value) + capabilities = CAPABILITIES_ADAPTER.validate_python(value) types = tuple(capability.type for capability in capabilities) if len(types) != len(set(types)): raise ValueError("duplicate AI capability types are not allowed") diff --git a/app/schemas/ai/main.py b/app/schemas/ai/main.py index 9a4810c..f189d7e 100644 --- a/app/schemas/ai/main.py +++ b/app/schemas/ai/main.py @@ -8,7 +8,7 @@ import sqlalchemy.dialects.postgresql import sqlmodel -from .capability import AIModelCapability, normalize_capabilities +from .capability import CAPABILITIES_ADAPTER, AIModelCapability, normalize_capabilities AIDialectID: typing.TypeAlias = str @@ -31,7 +31,7 @@ def process_bind_param(self, value, dialect): def process_result_value(self, value, dialect): del dialect - return normalize_capabilities(value or ()) + return CAPABILITIES_ADAPTER.validate_python(value or ()) class AIDialectModel(sqlmodel.SQLModel, table=True): diff --git a/app/schemas/cron.py b/app/schemas/cron.py index f4d130f..bf452e1 100644 --- a/app/schemas/cron.py +++ b/app/schemas/cron.py @@ -104,3 +104,17 @@ class CronForm(pydantic.BaseModel): job_type: JobTypeID job_parameters: dict[str, typing.Any] = pydantic.Field(default_factory=dict) job_timeout_seconds: int | None = pydantic.Field(default=None, gt=0) + + +class CronUpdateForm(pydantic.BaseModel): + """Optional submitted fields; null only clears the timeout override.""" + + model_config = pydantic.ConfigDict(extra="forbid") + + # Omission is not null input: exclude_unset removes these defaults, whereas + # an explicitly submitted null still fails the non-null field type. + schedule: str = pydantic.Field(default=None, min_length=1) # pyrefly: ignore[bad-assignment] + enabled: bool = pydantic.Field(default=None) # pyrefly: ignore[bad-assignment] + job_type: JobTypeID = pydantic.Field(default=None) # pyrefly: ignore[bad-assignment] + job_parameters: dict[str, typing.Any] = pydantic.Field(default_factory=dict) + job_timeout_seconds: int | None = pydantic.Field(default=None, gt=0) diff --git a/app/schemas/deployment_config.py b/app/schemas/deployment_config.py index 972c87c..0c4e4c6 100644 --- a/app/schemas/deployment_config.py +++ b/app/schemas/deployment_config.py @@ -58,7 +58,7 @@ class DeploymentConfigReplaceForm(pydantic.BaseModel): class DeploymentConfigView(pydantic.BaseModel): - """Validated deployment config projection exposed by the HTTP resource.""" + """Stored deployment config and metadata; reading does not revalidate value.""" model_config = pydantic.ConfigDict(populate_by_name=True) diff --git a/app/schemas/info_base/rest.py b/app/schemas/info_base/rest.py new file mode 100644 index 0000000..8981040 --- /dev/null +++ b/app/schemas/info_base/rest.py @@ -0,0 +1,60 @@ +"""Ordinary REST input forms; persisted entities and Peer wire formats stay separate.""" + +import typing + +import pydantic + +from app.schemas.graph_navigation_retrieval import ( + DEFAULT_MAX_HOPS, + MAX_MAX_HOPS, + DEFAULT_MAX_EXPLORED_BLOCKS, + MAX_MAX_EXPLORED_BLOCKS, + DEFAULT_MAX_EXPLORED_RELATIONS, + GraphDirection, +) + + +class EntityReference(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid") + type: typing.Literal["block", "relation"] + id: int + + +class EntitiesGetForm(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid") + entities: list[EntityReference] + content: typing.Literal["none", "raw", "hydrated"] = "raw" + + +class BlockUpdateForm(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid") + content: str = pydantic.Field(default=None) # pyrefly: ignore[bad-assignment] + resolver: str = pydantic.Field(default=None) # pyrefly: ignore[bad-assignment] + storage: int | None = None + + +class RelationUpdateForm(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid") + from_block_id: int = pydantic.Field(default=None) # pyrefly: ignore[bad-assignment] + to_block_id: int = pydantic.Field(default=None) # pyrefly: ignore[bad-assignment] + content: str = pydantic.Field(default=None) # pyrefly: ignore[bad-assignment] + + +class GraphPathForm(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid") + from_block_id: int + to_block_id: int + direction: GraphDirection = "both" + contents: tuple[str, ...] = () + max_hops: int = pydantic.Field(DEFAULT_MAX_HOPS, ge=0, le=MAX_MAX_HOPS) + max_explored_blocks: int = pydantic.Field( + DEFAULT_MAX_EXPLORED_BLOCKS, ge=1, le=MAX_MAX_EXPLORED_BLOCKS + ) + + +class GraphComponentsForm(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid") + seed_block_ids: tuple[int, ...] + contents: tuple[str, ...] = pydantic.Field(min_length=1) + max_explored_blocks: int = pydantic.Field(DEFAULT_MAX_EXPLORED_BLOCKS, ge=1) + max_explored_relations: int = pydantic.Field(DEFAULT_MAX_EXPLORED_RELATIONS, ge=1) diff --git a/app/schemas/job.py b/app/schemas/job.py index ff6a368..6e7fc6e 100644 --- a/app/schemas/job.py +++ b/app/schemas/job.py @@ -108,6 +108,12 @@ class JobModel(sqlmodel.SQLModel, table=True): server_default=sqlalchemy.text("'{}'::jsonb"), ), ) + abort_requested: bool = sqlmodel.Field( + default=False, + sa_column=sqlalchemy.Column( + sqlalchemy.Boolean, nullable=False, server_default=sqlalchemy.false() + ), + ) timeout_seconds: int = sqlmodel.Field( gt=0, sa_column=sqlalchemy.Column(sqlalchemy.Integer, nullable=False), diff --git a/app/schemas/peer/main.py b/app/schemas/peer/main.py index c4ccbaa..382e165 100644 --- a/app/schemas/peer/main.py +++ b/app/schemas/peer/main.py @@ -169,4 +169,4 @@ class PeerModel(sqlmodel.SQLModel, table=True): def capability_snapshot(self) -> tuple[PeerCapabilityAdvertisement, ...]: """Validate a possibly externally edited persisted snapshot at use time.""" - return normalize_capability_snapshot(self.capabilities) + return _CAPABILITIES_ADAPTER.validate_python(self.capabilities or ()) diff --git a/app/schemas/source/__init__.py b/app/schemas/source/__init__.py index 016ab35..a400ba1 100644 --- a/app/schemas/source/__init__.py +++ b/app/schemas/source/__init__.py @@ -4,6 +4,8 @@ "SourceTypesModel", "SourceCollectParameters", "SourceBackfillParameters", + "SourceCreateForm", + "SourceUpdateForm", ] from .main import ( @@ -12,4 +14,6 @@ SourceID, SourceModel, SourceTypesModel, + SourceCreateForm, + SourceUpdateForm, ) diff --git a/app/schemas/source/main.py b/app/schemas/source/main.py index ba1e0d1..04792aa 100644 --- a/app/schemas/source/main.py +++ b/app/schemas/source/main.py @@ -21,6 +21,25 @@ SourceID: typing.TypeAlias = int +class SourceCreateForm(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid") + + type: str + nickname: str | None = None + config: dict = pydantic.Field(default_factory=dict) + storage: StorageID | None = None + + +class SourceUpdateForm(pydantic.BaseModel): + """Only submitted fields are written; config is replaced, not deep-merged.""" + + model_config = pydantic.ConfigDict(extra="forbid") + + nickname: str | None = None + config: dict = pydantic.Field(default_factory=dict) + storage: StorageID | None = None + + class CollectAt(sqlmodel.SQLModel): """Historical migration-only value retained for the append-only baseline.""" diff --git a/app/validation.py b/app/validation.py new file mode 100644 index 0000000..beaee8e --- /dev/null +++ b/app/validation.py @@ -0,0 +1,31 @@ +"""Path projection for nested input contracts; validation stays with their owners.""" + +import contextlib +import typing + +import jsonschema # pyrefly: ignore[untyped-import] +import pydantic + + +@contextlib.contextmanager +def input_path(*prefix: str | int): + """Attach a form nesting path to native errors without validating again.""" + try: + yield + except pydantic.ValidationError as error: + details = [ + {**item, "loc": (*prefix, *item["loc"])} for item in error.errors(include_url=False) + ] + raise pydantic.ValidationError.from_exception_data( + error.title, typing.cast(typing.Any, details) + ) from error + except jsonschema.ValidationError as error: + raise jsonschema.ValidationError( + error.message, + validator=error.validator, + validator_value=error.validator_value, + instance=error.instance, + schema=error.schema, + path=(*prefix, *error.absolute_path), + schema_path=error.absolute_schema_path, + ) from error diff --git a/cli/.changes/+cli-sink.added.md b/cli/.changes/+cli-sink.added.md new file mode 100644 index 0000000..87e1259 --- /dev/null +++ b/cli/.changes/+cli-sink.added.md @@ -0,0 +1 @@ +提供独立的 inkcre-cli:经 Core REST 检索与读取信息、动态发现 Resolver 方法和输入 schema、管理 Source/Agent/config/Extension/Cron,并支持 Job 有界观察、停止请求及完整二进制文件交付。 diff --git a/cli/.gitignore b/cli/.gitignore new file mode 100644 index 0000000..6953a9b --- /dev/null +++ b/cli/.gitignore @@ -0,0 +1,6 @@ +.venv/ +.pdm-python +dist/ +__pycache__/ +.ruff_cache/ + diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md new file mode 100644 index 0000000..7edc81e --- /dev/null +++ b/cli/CHANGELOG.md @@ -0,0 +1,2 @@ +# inkcre-cli Changelog + diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 0000000..1961fdc --- /dev/null +++ b/cli/README.md @@ -0,0 +1,64 @@ +# inkcre-cli + +InKCre 的独立命令行界面,通过 Core REST API 检索信息、读取 Resolver 内容和管理部署。 +它不安装 Core、不访问数据库,也不是执行 Job 的 Peer。 + +```sh +python -m pip install inkcre-cli +inkcre-cli --help +inkcre-cli connection set personal --input connection.json +inkcre-cli recall 'Agent 工具' --mode lexical --json +inkcre-cli get block:42 relation:8 --json +inkcre-cli resolver invoke block:42 --method get_solved_content +``` + +connection.json 包含 `base_url` 与 `jwt_secret`。默认连接文件是 +`~/.inkcre/cli/connections.json`;可用 `--connections-file` 指定隔离文件。 +这里的地址是 Core REST 入口,不是 PostgREST 或 MCP endpoint。 +首个连接自动成为默认连接;`connection use personal` 修改默认值,`--connection NAME` 只选择本次接入。 + +`--help` 离线可用;有结构化输入的命令提供 `--schema`,从当前 Core 发现准确合同。 +使用 `--input FILE`、`--input -` 或 `--input-json JSON` 提供输入。 +`--json` 输出完整紧凑 JSON;二进制内容保存为文件,不做 Base64 编码。 +`--output-dir DIR` 显式保存完整结果,stdout 返回入口文件路径。 + +## 发现与管理 + +先发现 exact type、method 或 schema,再提交它所描述的输入。例如: + +```sh +inkcre-cli source types --json +inkcre-cli source create --type SOURCE_TYPE --schema +inkcre-cli source create --type SOURCE_TYPE --input source.json +inkcre-cli source collect 42 --input-json '{}' +inkcre-cli job wait 17 --for 10s --json +inkcre-cli job abort 17 +inkcre-cli cron create --job-type core.source.collect.v1 --schema +inkcre-cli agent tools --json +inkcre-cli ai models --json +inkcre-cli config schemas --json +``` + +这里的 ID 仅为示例,应使用实际创建回执或查询返回的 ID。`source collect`、`source backfill` 和 +`organization ruminate` 创建 Job,而不是在 CLI 或接入 Core 中同步执行。`job wait --for 10s` 最多观察 +十秒,可能返回仍为 pending/running 的记录;停止观察不取消 Job。`job abort` 请求 best-effort 停止, +`abort_requested: true` 不代表执行端已经完成清理。只有后续记录才能说明实际结果。 + +`connection` 只管理本机连接,`config` 管理远端 deployment config;Agent definition 由 `agent` 管理, +模型目录由 `ai` 发现。Extension 配置和运行目标使用 `extension`,不混入 connection。 + +## 输出与续读 + +结果写 stdout,命令错误写 stderr。退出码 0 表示命令成功,1 表示远端/IO/部分结果失败,2 表示本机输入 +错误,130 表示观察被中断。读取一个状态为 failed 的 Job 仍是成功读取,脚本应检查 Job 的实际 status。 +批量读取与混合 recall 保留各项结果,不因一项失败丢弃其它成功项。 + +支持分页的目录使用 `--limit`、`--cursor` 和返回的 `next_cursor`;CLI 不自动翻页。 +检索仍保留各模式自身的 limit 和结果结构,混合 recall 不融合排名。 + +人类可读输出过长时显示摘要和完整文件路径;`--json` 不截断、不 pretty-print。 +二进制自动保存为文件;显式 `--output-dir DIR` 导出的入口 JSON 使用相对文件引用,整个子目录可以搬移。 +这些是同一次 HTTP 响应的本地文件,不是新下载会话,也不会再次调用 Resolver。 + +开发使用独立 PDM 项目:`pdm install -p cli`、`pdm run -p cli check`、`pdm build -p cli`。 +0.0.0 是未发布的开发版本;首次发行由仓库的 Release PR 准备。 diff --git a/cli/docs/development.md b/cli/docs/development.md new file mode 100644 index 0000000..03d98ac --- /dev/null +++ b/cli/docs/development.md @@ -0,0 +1,28 @@ +# 开发与发布 + +遵循仓库的 [贡献流程](../../CONTRIBUTING.md)、组织 [Git workflow](https://github.com/InKCre/.github/blob/main/GOVERNANCE.md) +和 [验证策略](https://github.com/InKCre/.github/blob/main/TESTING.md)。手工/脚本端到端验收不默认成为 CI 自动化。 + +CLI 使用独立 PDM project,不是 root workspace 成员;不要添加 Core path dependency。 +从仓库根运行声明的脚本会进入子项目:`pdm install -p cli --frozen-lockfile`、`pdm run -p cli check`、 +`pdm build -p cli`。直接传给 `pdm run` 的任意命令则要注意工作目录,不用 `ruff check src` 误查根目录。 + +运行边界是 Click command → CoreRESTClient → 普通 REST。命令直接映射协议,不另加逐 endpoint 转发 service。 +CLI 使用 Pydantic 验证本机连接和自身 JSON 输入;可信 REST 返回只解码/投影,不做响应 model_validate。 +schema 查询取得 Core OpenAPI 与运行时 owner schema,机械调整输入位置和局部引用,不编译 Extension 模型。 + +同步 HTTPX 处理普通请求;有界 Job/wake 观察使用一个原生 asyncio timeout 作用域和 AsyncClient,以总预算 +覆盖慢响应。HTTPX 单阶段 timeout 不是总期限。取消这个作用域关闭真实请求,不留下后台请求线程。 + +## 版本与 PyPI + +Feature PR 只放 `cli/.changes/` fragment,使用根 Towncrier 类型。单一 `release/next` PR 消费 CLI intent, +修改 CLI 自己的 version/changelog;CLI 不进入 Extension matrix。0.0.0 是初始开发版本,added fragment +经过正常准备得到 0.1.0;不能因为 base 中尚无 CLI 就直接发布。 + +`cli-publish.yml` 从精确 main SHA 自行检查、构建 wheel/sdist,并通过 PDM 原生 Trusted Publishing 上传。 +PyPI pending publisher 配置:owner InKCre,repository core-py,workflow cli-publish.yml,environment production。 +安装者只需 pip,不需 PDM。新增 REST 依赖时先交付 Core provider,再发布 CLI consumer;没有永久版本相等 gate。 + +恢复使用 main 上的 workflow_dispatch。PDM `--no-build --skip-existing` 保留当前 run 已检查的构建并续传缺失 +文件;重跑会重新构建,不声称跨 run byte-identical。不要手写 OIDC 换 token 或重新使用 CI 上传的产物。 diff --git a/cli/pdm.lock b/cli/pdm.lock new file mode 100644 index 0000000..a72186a --- /dev/null +++ b/cli/pdm.lock @@ -0,0 +1,288 @@ +# This file is @generated by PDM. +# It is not intended for manual editing. + +[metadata] +groups = ["default", "dev"] +strategy = ["inherit_metadata"] +lock_version = "4.5.0" +content_hash = "sha256:51f33df83363cd1f97261a482e42bbc8901aae120c4ecbf06684e25bf67642ea" + +[[metadata.targets]] +requires_python = ">=3.12" + +[[package]] +name = "annotated-types" +version = "0.8.0" +requires_python = ">=3.10" +summary = "Reusable constraint types to use with typing.Annotated" +groups = ["default"] +files = [ + {file = "annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0"}, + {file = "annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7"}, +] + +[[package]] +name = "anyio" +version = "4.15.1" +requires_python = ">=3.10" +summary = "High-level concurrency and networking framework on top of asyncio or Trio" +groups = ["default"] +dependencies = [ + "exceptiongroup>=1.0.2; python_version < \"3.11\"", + "idna>=2.8", + "typing-extensions>=4.16.0; python_version < \"3.15\"", +] +files = [ + {file = "anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101"}, + {file = "anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94"}, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +requires_python = ">=3.7" +summary = "Python package for providing Mozilla's CA Bundle." +groups = ["default"] +files = [ + {file = "certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775"}, + {file = "certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55"}, +] + +[[package]] +name = "click" +version = "8.5.0" +requires_python = ">=3.10" +summary = "Composable command line interface toolkit" +groups = ["default"] +files = [ + {file = "click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360"}, + {file = "click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34"}, +] + +[[package]] +name = "h11" +version = "0.16.0" +requires_python = ">=3.8" +summary = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +groups = ["default"] +files = [ + {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, + {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +requires_python = ">=3.8" +summary = "A minimal low-level HTTP client." +groups = ["default"] +dependencies = [ + "certifi", + "h11>=0.16", +] +files = [ + {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, + {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, +] + +[[package]] +name = "httpx" +version = "0.28.1" +requires_python = ">=3.8" +summary = "The next generation HTTP client." +groups = ["default"] +dependencies = [ + "anyio", + "certifi", + "httpcore==1.*", + "idna", +] +files = [ + {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, + {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, +] + +[[package]] +name = "idna" +version = "3.19" +requires_python = ">=3.9" +summary = "Internationalized Domain Names in Applications (IDNA)" +groups = ["default"] +files = [ + {file = "idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4"}, + {file = "idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15"}, +] + +[[package]] +name = "pydantic" +version = "2.13.5" +requires_python = ">=3.9" +summary = "Data validation using Python type hints" +groups = ["default"] +dependencies = [ + "annotated-types>=0.6.0", + "pydantic-core==2.46.5", + "typing-extensions>=4.14.1", + "typing-inspection>=0.4.2", +] +files = [ + {file = "pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73"}, + {file = "pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08"}, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +requires_python = ">=3.9" +summary = "Core functionality for Pydantic validation and serialization" +groups = ["default"] +dependencies = [ + "typing-extensions>=4.14.1", +] +files = [ + {file = "pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d"}, + {file = "pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e"}, + {file = "pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29"}, + {file = "pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4"}, + {file = "pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a"}, + {file = "pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62"}, + {file = "pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2"}, + {file = "pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869"}, + {file = "pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5"}, + {file = "pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3"}, + {file = "pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b"}, + {file = "pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0"}, + {file = "pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b"}, + {file = "pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8"}, + {file = "pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084"}, + {file = "pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0"}, + {file = "pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff"}, + {file = "pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931"}, + {file = "pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f"}, + {file = "pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038"}, + {file = "pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f"}, + {file = "pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1"}, + {file = "pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761"}, + {file = "pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5"}, + {file = "pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e"}, + {file = "pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed"}, + {file = "pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519"}, + {file = "pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea"}, + {file = "pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5"}, + {file = "pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575"}, + {file = "pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355"}, + {file = "pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e"}, + {file = "pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3"}, + {file = "pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c"}, + {file = "pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21"}, + {file = "pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f"}, + {file = "pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f"}, + {file = "pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a"}, + {file = "pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821"}, + {file = "pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2"}, + {file = "pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47"}, + {file = "pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a"}, + {file = "pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074"}, + {file = "pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0"}, + {file = "pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290"}, + {file = "pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f"}, + {file = "pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc"}, +] + +[[package]] +name = "pyjwt" +version = "2.14.0" +requires_python = ">=3.9" +summary = "JSON Web Token implementation in Python" +groups = ["default"] +dependencies = [ + "typing-extensions>=4.0; python_version < \"3.11\"", +] +files = [ + {file = "pyjwt-2.14.0-py3-none-any.whl", hash = "sha256:ad0cef71c756a56e74863c2919cf0985f72decbcfcb550ee2f422e7c62b5eedc"}, + {file = "pyjwt-2.14.0.tar.gz", hash = "sha256:77283c83fb56ecf566a886c757a714bc83668e38156de2cce8263302f42e0b86"}, +] + +[[package]] +name = "pyrefly" +version = "0.56.0" +requires_python = ">=3.8" +summary = "A fast type checker and language server for Python with powerful IDE features" +groups = ["dev"] +files = [ + {file = "pyrefly-0.56.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:21f018f47debc0842b2c3072201e53c138ae32bcda4f3119bfc8d23f59c16b3e"}, + {file = "pyrefly-0.56.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:95366056ceb224571b9f1c20e801d949f2c1fa2cf4ed6ceaadf85ca2ebe6fb27"}, + {file = "pyrefly-0.56.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5478229b09f4bba5bfea000b5ba20ea405f62dc7619ea81197e7ea637d6cba8d"}, + {file = "pyrefly-0.56.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e5f53781875024086a5b9f31a89c57d2977487fc3f819d9255008ad34b86fe2"}, + {file = "pyrefly-0.56.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec6ab3f9e2c03bae8dfa520f52778f47b6762020929a664177d36aa3b941db22"}, + {file = "pyrefly-0.56.0-py3-none-win32.whl", hash = "sha256:f0440a4bbf119ab646468f360e0bd047df051352db1e5d5b9fd58f89e8458809"}, + {file = "pyrefly-0.56.0-py3-none-win_amd64.whl", hash = "sha256:f4948021639288b1ccda5f124c9562dc7f0a2679111eb314fa266c7bfd9f8603"}, + {file = "pyrefly-0.56.0-py3-none-win_arm64.whl", hash = "sha256:4683f5e8820d5fbfb84231b643b2c5f6cd40b982cac48ef756d4e3d9b09a39cc"}, + {file = "pyrefly-0.56.0.tar.gz", hash = "sha256:f84d21d9b9b58481eea02204e2f73cabb93751b21ab2cd99178b4bde24be6a82"}, +] + +[[package]] +name = "ruff" +version = "0.15.22" +requires_python = ">=3.7" +summary = "An extremely fast Python linter and code formatter, written in Rust." +groups = ["dev"] +files = [ + {file = "ruff-0.15.22-py3-none-linux_armv6l.whl", hash = "sha256:44423e73493737f5e7c5b41d475483898ff37afcdae38bc3da5085e29af1c2d8"}, + {file = "ruff-0.15.22-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b82c6482946e9eda7ff2e091d25b8bad3f718684e1916d41bd56873cee05b697"}, + {file = "ruff-0.15.22-py3-none-macosx_11_0_arm64.whl", hash = "sha256:11c1c715af53a09f714e011106bffc419751ec8232fcb5da42173284ea3fec6f"}, + {file = "ruff-0.15.22-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:742a29cf29bddb7c8327895d6a10e0e6c5b38a96dd407af9b5d0857f809c0576"}, + {file = "ruff-0.15.22-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72af58b951b0ae395935ae79763dc349bc0eb706319d28f7a33ad2cfb3cfc178"}, + {file = "ruff-0.15.22-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62d425005c1835eb24e2ee4161cb90e8db263415f4a71c8c72c33abaa6c0c224"}, + {file = "ruff-0.15.22-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8b9b3f8779a4f08c969defc3c8c35abffaa757e601ed5ae66d6d1db6519969a"}, + {file = "ruff-0.15.22-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1e0dd1b2e4d3d585f897a0d137cbf4eaf6223bef4e8ce34d6bb12556c5f9249e"}, + {file = "ruff-0.15.22-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:365523eb91d9224e1bcb03b022fbf0facb8f9e23792a2c53d9d4b3924bdbdebb"}, + {file = "ruff-0.15.22-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:fabfd168afdf29fee5be98b831efa9683c94d7c5a3b58b9ce5a2e38444589a74"}, + {file = "ruff-0.15.22-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:225dbf095a87f1d9f90f5fd7924d2613ee452a75a4308c63a8f50f761787aa7c"}, + {file = "ruff-0.15.22-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1877d63b9d24ed278744f1523fd11b85540566d54641f97c566d7d9dc5ca5296"}, + {file = "ruff-0.15.22-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a1606c510bd7215680d32efab38965f7cdec3ef69f5170a3f4791404ffdd5262"}, + {file = "ruff-0.15.22-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:630479b18625f5ffc373f77603a22a9f8ac0acd7ff0501178b5db28ec71e9c64"}, + {file = "ruff-0.15.22-py3-none-win32.whl", hash = "sha256:e5ba0e4a13fd14abbed2a77b517a3911290c6c6c59ef67784328d1668fab76cf"}, + {file = "ruff-0.15.22-py3-none-win_amd64.whl", hash = "sha256:9be63ba1eb936acd2d1342fb8337c356353706fce233b2a15a09a97037e6acde"}, + {file = "ruff-0.15.22-py3-none-win_arm64.whl", hash = "sha256:e1168075b72158510839f250027659cdd78476f40507dd517892304c41318661"}, + {file = "ruff-0.15.22.tar.gz", hash = "sha256:3f15175b1fb580126f58285a5dae6b2ea89000136d980c64499211f116b54809"}, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +requires_python = ">=3.9" +summary = "Backported and Experimental Type Hints for Python 3.9+" +groups = ["default"] +files = [ + {file = "typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8"}, + {file = "typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5"}, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +requires_python = ">=3.10" +summary = "Runtime typing introspection tools" +groups = ["default"] +dependencies = [ + "typing-extensions>=4.15.0", +] +files = [ + {file = "typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147"}, + {file = "typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47"}, +] diff --git a/cli/pyproject.toml b/cli/pyproject.toml new file mode 100644 index 0000000..b82a875 --- /dev/null +++ b/cli/pyproject.toml @@ -0,0 +1,46 @@ +[project] +name = "inkcre-cli" +version = "0.0.0" +description = "An agent-friendly REST interface to InKCre" +readme = "README.md" +requires-python = ">=3.12" +license = "GPL-3.0-only" +dependencies = [ + "click>=8.1,<9", + "pydantic>=2.10,<3", + "httpx>=0.28.1,<0.29", + "PyJWT>=2.10,<3", +] + +[project.scripts] +inkcre-cli = "inkcre_cli.main:main" + +[project.urls] +Repository = "https://github.com/InKCre/core-py" + +[build-system] +requires = ["pdm-backend"] +build-backend = "pdm.backend" + +[dependency-groups] +dev = ["ruff>=0.15,<0.16", "pyrefly>=0.56,<0.57"] + +[tool.pdm] +distribution = true + +[tool.pdm.scripts] +lint = "ruff check src" +format = "ruff format src" +"format:check" = "ruff format --check src" +typecheck = "pyrefly check" +check = {composite = ["lint", "format:check", "typecheck"]} + +[tool.ruff] +line-length = 100 +target-version = "py312" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B"] + +[tool.pyrefly] +project-includes = ["src"] diff --git a/cli/src/inkcre_cli/__init__.py b/cli/src/inkcre_cli/__init__.py new file mode 100644 index 0000000..76c87b3 --- /dev/null +++ b/cli/src/inkcre_cli/__init__.py @@ -0,0 +1 @@ +"""Independent InKCre REST consumer; no Core runtime imports.""" diff --git a/cli/src/inkcre_cli/command.py b/cli/src/inkcre_cli/command.py new file mode 100644 index 0000000..dadd443 --- /dev/null +++ b/cli/src/inkcre_cli/command.py @@ -0,0 +1,187 @@ +"""Small shared Click options and one invocation's resources.""" + +import sys +from pathlib import Path +from typing import Any, Literal +from urllib.parse import quote + +import click +from pydantic import BaseModel, TypeAdapter + +from . import connection +from .errors import CommandError +from .http import CoreRESTClient +from .output import Output + + +class Command(click.Command): + def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]: + original = args.copy() + try: + return super().parse_args(ctx, args) + except click.UsageError: + # Click rejects unknown options before eager callbacks run. Its native + # tolerant parse can still find presentation options without scanning + # raw argv (which would mistake an --input-json value for an option). + with self.make_context( + ctx.info_name, + original, + parent=ctx.parent, + obj=ctx.obj, + resilient_parsing=True, + ignore_unknown_options=True, + ): + pass + raise + + +class Group(Command, click.Group): + command_class = Command + group_class = type + + +class Entity(BaseModel): + type: Literal["block", "relation"] + id: int + + +def entity(value: str, *, block_only: bool = False) -> Entity: + kind, separator, identity = value.partition(":") + if not separator: + raise click.BadParameter("使用 block: 或 relation:") + result = Entity.model_validate({"type": kind, "id": identity}) + if block_only and result.type != "block": + raise click.BadParameter("此操作需要 block:") + return result + + +def segment(value: str | int) -> str: + return quote(str(value), safe="") + + +def duration(value: str) -> float: + suffix = value[-1:] if value[-1:].isalpha() else "" + scale = {"": 1, "s": 1, "m": 60, "h": 3600} + try: + result = float(value[:-1] if suffix else value) * scale[suffix] + if not 0 < result < float("inf"): + raise ValueError + return result + except (ValueError, KeyError) as error: + raise click.BadParameter("使用正数秒,或 30s / 2m / 1h") from error + + +class Invocation: + def __init__(self): + self.connection_name: str | None = None + self.connections_file: Path | None = None + self.output = Output() + self._client: CoreRESTClient | None = None + + @property + def local_file(self) -> Path: + return connection.file_path(self.connections_file) + + @property + def client(self) -> CoreRESTClient: + if self._client is None: + _, selected = connection.select(connection.load(self.local_file), self.connection_name) + self._client = CoreRESTClient(selected) + return self._client + + def close(self) -> None: + if self._client: + self._client.close() + + def send( + self, + method: str, + path: str, + *, + params: dict | None = None, + body: Any = None, + strip: tuple[str, ...] = (), + selectors: dict[str, str] | None = None, + ) -> Any: + try: + return self.client.request(method, path, params=params, body=body) + except CommandError as error: + if error.status == 422 and isinstance(error.detail, list): + detail = [] + for item in error.detail: + loc = list(item.get("loc", ())) + if loc[:1] == ["body"]: + loc = loc[1:] + if loc[: len(strip)] == list(strip): + loc = loc[len(strip) :] + if loc and selectors and loc[0] in selectors: + loc[0] = selectors[loc[0]] + detail.append({**item, "loc": loc}) + raise CommandError(detail, status=error.status) from error + raise + + def show(self, value: Any, *, no_content: bool = False, partial_failure: bool = False) -> None: + self.output.emit(value, no_content=no_content) + if partial_failure: + raise CommandError("结果中存在逐项错误;成功项已保留") + + +def _option(ctx: click.Context, parameter: click.Parameter, value: Any) -> Any: + invocation = ctx.ensure_object(Invocation) + if value is not None: + if parameter.name == "json_mode": + invocation.output.json_mode = value + elif parameter.name == "output_dir": + invocation.output.directory = value + else: + setattr(invocation, parameter.name, value) + return value + + +def common(function): + options: list[tuple[tuple[str, ...], dict[str, Any]]] = [ + (("--connection", "connection_name"), {"help": "本次使用的本机连接名"}), + (("--connections-file",), {"type": click.Path(path_type=Path), "help": "替换连接配置文件"}), + ( + ("--output-dir",), + {"type": click.Path(path_type=Path), "help": "保存完整结果,返回入口文件"}, + ), + ( + ("--json", "json_mode"), + {"is_flag": True, "default": None, "is_eager": True, "help": "完整紧凑 JSON"}, + ), + ] + for args, kwargs in options: + function = click.option(*args, callback=_option, expose_value=False, **kwargs)(function) + return function + + +def input_options(function): + function = click.option("--schema", is_flag=True, help="只查询输入 JSON Schema,不执行操作")( + function + ) + function = click.option("--input-json", help="直接提供 JSON 对象")(function) + return click.option( + "--input", "input_file", type=click.Path(path_type=Path), help="JSON 文件,- 表示 stdin" + )(function) + + +def paging(function): + function = click.option("--cursor", help="前一页返回的 next_cursor;保留相同过滤条件")(function) + return click.option( + "--limit", type=click.IntRange(min=1), help="本次最多返回多少项;不自动翻页" + )(function) + + +def load_input(input_file: Path | None, input_json: str | None) -> dict[str, Any]: + if input_file is not None and input_json is not None: + raise click.UsageError("--input 与 --input-json 不能同时使用") + if input_file is not None: + text = ( + sys.stdin.read() if str(input_file) == "-" else input_file.read_text(encoding="utf-8") + ) + elif input_json is not None: + text = input_json + else: + return {} + return TypeAdapter(dict[str, Any]).validate_json(text) diff --git a/cli/src/inkcre_cli/commands/__init__.py b/cli/src/inkcre_cli/commands/__init__.py new file mode 100644 index 0000000..b1414ce --- /dev/null +++ b/cli/src/inkcre_cli/commands/__init__.py @@ -0,0 +1 @@ +"""Ordinary command groups, not an Extension command registry.""" diff --git a/cli/src/inkcre_cli/commands/agent.py b/cli/src/inkcre_cli/commands/agent.py new file mode 100644 index 0000000..127be38 --- /dev/null +++ b/cli/src/inkcre_cli/commands/agent.py @@ -0,0 +1,121 @@ +"""Agent definitions and read-only AI/profile discovery.""" + +import click + +from ..command import Group, Invocation, common, input_options, load_input, paging, segment +from ..schema import request_schema + + +@click.group(cls=Group) +def agent(): + """管理 Agent definition;不启动对话或本机 Agent runtime。""" + + +@agent.command("list") +@paging +@common +@click.pass_obj +def list_agents(inv: Invocation, limit, cursor): + """列出 Agent definition。""" + inv.show(inv.send("GET", "/agents", params={"limit": limit, "cursor": cursor})) + + +@agent.command() +@click.argument("agent_id", type=int) +@common +@click.pass_obj +def get(inv: Invocation, agent_id): + """读取一个 Agent definition。""" + inv.show(inv.send("GET", f"/agents/{agent_id}")) + + +@agent.command() +@input_options +@common +@click.pass_obj +def create(inv: Invocation, schema, input_file, input_json): + """创建 Agent definition;模型用 ai models、工具用 agent tools 发现。""" + if schema: + return inv.show(request_schema(inv.client, "/agents", "POST")) + inv.show(inv.send("POST", "/agents", body=load_input(input_file, input_json))) + + +@agent.command() +@click.argument("agent_id", type=int) +@input_options +@common +@click.pass_obj +def update(inv: Invocation, agent_id, schema, input_file, input_json): + """修改提交的 Agent definition 字段。""" + if schema: + return inv.show(request_schema(inv.client, "/agents/{agent_id}", "PATCH")) + inv.show(inv.send("PATCH", f"/agents/{agent_id}", body=load_input(input_file, input_json))) + + +@agent.command() +@click.argument("agent_id", type=int) +@common +@click.pass_obj +def delete(inv: Invocation, agent_id): + """删除 Agent definition。""" + inv.show(inv.send("DELETE", f"/agents/{agent_id}"), no_content=True) + + +@agent.command() +@click.argument("tool_id", required=False) +@paging +@common +@click.pass_obj +def tools(inv: Invocation, tool_id, limit, cursor): + """列出本机已注册 Agent Tool;给定 ID 时取得完整合同,不执行工具。""" + inv.show( + inv.send( + "GET", + "/agent-tools" + ("/" + segment(tool_id) if tool_id else ""), + params={"limit": limit, "cursor": cursor}, + ) + ) + + +@click.group(cls=Group) +def ai(): + """发现 AI 模型,不混入 Agent definition 的管理范围。""" + + +@ai.command() +@click.argument("model_id", type=int, required=False) +@paging +@common +@click.pass_obj +def models(inv: Invocation, model_id, limit, cursor): + """列出 AIModel,或读取指定 ID;不返回 Provider 的 secret config。""" + inv.show( + inv.send( + "GET", + "/ai/models" + (f"/{model_id}" if model_id is not None else ""), + params={"limit": limit, "cursor": cursor}, + ) + ) + + +@click.group("embedding-profile", cls=Group) +def embedding_profile(): + """只读发现语义检索可选的 embedding profile。""" + + +@embedding_profile.command("list") +@paging +@common +@click.pass_obj +def list_profiles(inv: Invocation, limit, cursor): + """列出 EmbeddingProfile。""" + inv.show(inv.send("GET", "/embedding-profiles", params={"limit": limit, "cursor": cursor})) + + +@embedding_profile.command("get") +@click.argument("profile_id", type=int) +@common +@click.pass_obj +def get_profile(inv: Invocation, profile_id): + """读取 EmbeddingProfile。""" + inv.show(inv.send("GET", f"/embedding-profiles/{profile_id}")) diff --git a/cli/src/inkcre_cli/commands/config.py b/cli/src/inkcre_cli/commands/config.py new file mode 100644 index 0000000..4a04e6b --- /dev/null +++ b/cli/src/inkcre_cli/commands/config.py @@ -0,0 +1,95 @@ +"""Deployment config values and their runtime-owned input schemas.""" + +import click + +from ..command import Group, Invocation, common, input_options, load_input, paging, segment +from ..schema import partial + + +@click.group(cls=Group) +def config(): + """远端 deployment 配置,与本机 connection 文件分开。""" + + +@config.command("list") +@paging +@common +@click.pass_obj +def list_configs(inv: Invocation, limit, cursor): + """列出保存的 deployment config,不要求 schema 已加载。""" + inv.show(inv.send("GET", "/configs", params={"limit": limit, "cursor": cursor})) + + +@config.command() +@click.argument("key") +@common +@click.pass_obj +def get(inv: Invocation, key): + """读取配置的实际 schema/value。""" + inv.show(inv.send("GET", "/configs/" + segment(key))) + + +@config.command() +@click.argument("schema_id", required=False) +@paging +@common +@click.pass_obj +def schemas(inv: Invocation, schema_id, limit, cursor): + """发现接入 Peer 已加载的 schema;给出 ID 时读取输入合同。""" + inv.show( + inv.send( + "GET", + "/config-schemas" + ("/" + segment(schema_id) if schema_id else ""), + params={"limit": limit, "cursor": cursor}, + ) + ) + + +@config.command() +@click.argument("key") +@click.option("--schema-id", required=True) +@input_options +@common +@click.pass_obj +def replace(inv: Invocation, key, schema_id, schema, input_file, input_json): + """创建或完整替换;JSON 是 value 本身,不再包装 value。""" + if schema: + return inv.show(inv.send("GET", "/config-schemas/" + segment(schema_id))["input_schema"]) + inv.show( + inv.send( + "PUT", + "/configs/" + segment(key), + body={ + "schema": schema_id, + "value": load_input(input_file, input_json), + }, + strip=("value",), + selectors={"schema": "--schema-id"}, + ) + ) + + +@config.command() +@click.argument("key") +@input_options +@common +@click.pass_obj +def update(inv: Invocation, key, schema, input_file, input_json): + """更新 value 的提交字段;嵌套值按 owner 合同处理。""" + if schema: + existing = inv.send("GET", "/configs/" + segment(key)) + return inv.show( + partial( + inv.send("GET", "/config-schemas/" + segment(existing["schema"]))["input_schema"] + ) + ) + inv.show(inv.send("PATCH", "/configs/" + segment(key), body=load_input(input_file, input_json))) + + +@config.command() +@click.argument("key") +@common +@click.pass_obj +def delete(inv: Invocation, key): + """删除保存的配置值。""" + inv.show(inv.send("DELETE", "/configs/" + segment(key)), no_content=True) diff --git a/cli/src/inkcre_cli/commands/connection.py b/cli/src/inkcre_cli/commands/connection.py new file mode 100644 index 0000000..4a3811a --- /dev/null +++ b/cli/src/inkcre_cli/commands/connection.py @@ -0,0 +1,109 @@ +"""Offline connection editing and explicit connectivity observation.""" + +from typing import Any + +import click +import httpx + +from .. import connection as store +from ..command import Group, Invocation, common, input_options, load_input +from ..errors import CommandError + + +@click.group(cls=Group) +def connection(): + """本机命名连接,不是远端 deployment config 或 Peer 注册。""" + + +@connection.command("list") +@common +@click.pass_obj +def list_connections(inv: Invocation): + """离线列出名称、地址与保存的默认名。""" + data = store.load(inv.local_file) + inv.show( + { + "default": data.default, + "connections": { + name: {"base_url": str(value.base_url)} for name, value in data.connections.items() + }, + } + ) + + +@connection.command() +@click.argument("name") +@common +@click.pass_obj +def get(inv: Invocation, name): + """离线读取指定连接,包括保存的 JWT secret。""" + _, value = store.select(store.load(inv.local_file), name) + inv.show(value.model_dump(mode="json")) + + +@connection.command("set") +@click.argument("name") +@input_options +@common +@click.pass_obj +def set_connection(inv: Invocation, name, schema, input_file, input_json): + """创建或完整替换命名连接;输入 base_url 与 jwt_secret。""" + if schema: + return inv.show(store.Connection.model_json_schema()) + value = store.Connection.model_validate(load_input(input_file, input_json)) + data = store.load(inv.local_file) + data.connections[name] = value + if data.default is None: + data.default = name + store.save(inv.local_file, data) + inv.show({"name": name, "base_url": str(value.base_url), "default": data.default}) + + +@connection.command() +@click.argument("name") +@common +@click.pass_obj +def use(inv: Invocation, name): + """修改本机默认连接。""" + data = store.load(inv.local_file) + store.select(data, name) + data.default = name + store.save(inv.local_file, data) + inv.show({"default": name}) + + +@connection.command() +@click.argument("name") +@common +@click.pass_obj +def delete(inv: Invocation, name): + """删除本机连接;不操作远端部署。""" + data = store.load(inv.local_file) + store.select(data, name) + del data.connections[name] + if data.default == name: + data.default = None + store.save(inv.local_file, data) + inv.show(None, no_content=True) + + +@connection.command() +@common +@click.pass_obj +def check(inv: Invocation): + """分别检查公共 readyz 与受保护读取,不把就绪误当成认证成功。""" + result: dict[str, Any] = {"endpoint": inv.client.base_url} + failed = False + for key, path, authenticated in ( + ("readyz", "/readyz", False), + ("authenticated_read", "/peers/self", True), + ): + try: + result[key] = inv.client.request("GET", path, authenticated=authenticated) + except CommandError as error: + result[key] = {"error": error.as_dict()} + failed = True + except httpx.RequestError as error: + result[key] = {"error": {"detail": str(error)}} + failed = True + inv.show(result, partial_failure=failed) diff --git a/cli/src/inkcre_cli/commands/extension.py b/cli/src/inkcre_cli/commands/extension.py new file mode 100644 index 0000000..2a979a5 --- /dev/null +++ b/cli/src/inkcre_cli/commands/extension.py @@ -0,0 +1,125 @@ +"""Extension Host REST management; no CLI-specific Extension adapters.""" + +import click + +from ..command import Group, Invocation, common, input_options, load_input, paging, segment +from ..errors import CommandError +from ..schema import partial + + +def route(coordinate: str) -> str: + namespace, separator, name = coordinate.partition("/") + if not separator or not namespace or not name or "/" in name: + raise click.BadParameter("Extension 使用 namespace/name coordinate") + return "/extensions/" + segment(namespace) + "/" + segment(name) + + +@click.group(cls=Group) +def extension(): + """安装、配置和启停 Extension;发布仍由 Extension 的交付流程负责。""" + + +@extension.command("list") +@paging +@common +@click.pass_obj +def list_extensions(inv: Invocation, limit, cursor): + """列出 Extension 的安装、启用意图与运行状态。""" + inv.show(inv.send("GET", "/extensions", params={"limit": limit, "cursor": cursor})) + + +@extension.command() +@click.argument("coordinate") +@common +@click.pass_obj +def get(inv: Invocation, coordinate): + """读取一个已安装 Extension。""" + inv.show(inv.send("GET", route(coordinate))) + + +@extension.command() +@click.argument("coordinate") +@click.option("--version", required=True, help="准确的已发布版本,不解析 latest") +@common +@click.pass_obj +def install(inv: Invocation, coordinate, version): + """安装准确版本;不隐式启用。""" + inv.show(inv.send("POST", route(coordinate), params={"version": version})) + + +@extension.command() +@click.argument("coordinate") +@common +@click.pass_obj +def uninstall(inv: Invocation, coordinate): + """卸载 Extension。""" + inv.show(inv.send("DELETE", route(coordinate)), no_content=True) + + +@extension.command() +@click.argument("coordinate") +@click.option("--peer", help="启用意图的目标 Peer;省略时为接入 Peer") +@common +@click.pass_obj +def enable(inv: Invocation, coordinate, peer): + """在指定或当前 Peer 启用 Extension。""" + inv.show(inv.send("POST", route(coordinate) + "/enable", params={"route_to_peer": peer})) + + +@extension.command() +@click.argument("coordinate") +@click.option("--peer", help="禁用意图的目标 Peer;省略时为接入 Peer") +@common +@click.pass_obj +def disable(inv: Invocation, coordinate, peer): + """在指定或当前 Peer 禁用 Extension。""" + inv.show(inv.send("POST", route(coordinate) + "/disable", params={"route_to_peer": peer})) + + +@extension.group() +def config(): + """读写 Extension 的持久配置,不要求先启用。""" + + +@config.command("get") +@click.argument("coordinate") +@common +@click.pass_obj +def get_config(inv: Invocation, coordinate): + """从安装记录读取 config;不调用业务 schema 验证。""" + inv.show(inv.send("GET", route(coordinate))["config"]) + + +def config_schema(inv: Invocation, coordinate: str) -> dict: + contract = inv.send("GET", route(coordinate))["config_schema"] + if contract is None: + raise CommandError("该 Extension 尚未发布配置 schema;可在启用后重新发现") + return contract + + +@config.command("replace") +@click.argument("coordinate") +@input_options +@common +@click.pass_obj +def replace_config(inv: Invocation, coordinate, schema, input_file, input_json): + """完整替换 Extension config。""" + if schema: + return inv.show(config_schema(inv, coordinate)) + inv.show( + inv.send("PUT", route(coordinate) + "/config", body=load_input(input_file, input_json)) + ) + + +@config.command("update") +@click.argument("coordinate") +@input_options +@common +@click.pass_obj +def update_config(inv: Invocation, coordinate, schema, input_file, input_json): + """更新提交的 config 字段。""" + if schema: + return inv.show(partial(config_schema(inv, coordinate))) + inv.show( + inv.send("PATCH", route(coordinate) + "/config", body=load_input(input_file, input_json)) + ) diff --git a/cli/src/inkcre_cli/commands/info.py b/cli/src/inkcre_cli/commands/info.py new file mode 100644 index 0000000..d6e4d01 --- /dev/null +++ b/cli/src/inkcre_cli/commands/info.py @@ -0,0 +1,245 @@ +"""Information retrieval, record writes and dynamic Resolver calls.""" + +import click +import httpx + +from ..command import ( + Command, + Group, + Invocation, + common, + entity, + input_options, + load_input, + paging, + segment, +) +from ..errors import CommandError +from ..schema import request_schema + + +@click.command("get", cls=Command) +@click.argument("references", nargs=-1) +@click.option( + "--content", type=click.Choice(["none", "raw", "hydrated"]), default="raw", show_default=True +) +@common +@click.pass_obj +def get(inv: Invocation, references, content): + """批量取得 info-base Block / Relation 记录;引用为 block:42 / relation:8。""" + result = inv.send( + "POST", + "/entities/get", + body={ + "entities": [entity(ref).model_dump() for ref in references], + "content": content, + }, + ) + inv.show(result, partial_failure=any("error" in item for item in result)) + + +@click.command("update", cls=Command) +@click.argument("reference") +@input_options +@common +@click.pass_obj +def update(inv: Invocation, reference, schema, input_file, input_json): + """原地修改一个 info-base 实体;省略字段保持不变。""" + ref = entity(reference) + if schema: + return inv.show(request_schema(inv.client, f"/{ref.type}s/{{{ref.type}_id}}", "PATCH")) + inv.show(inv.send("PATCH", f"/{ref.type}s/{ref.id}", body=load_input(input_file, input_json))) + + +@click.command("delete", cls=Command) +@click.argument("reference") +@common +@click.pass_obj +def delete(inv: Invocation, reference): + """删除明确的 info-base Block / Relation;不是删除 Source 或其它管理对象。""" + ref = entity(reference) + inv.show(inv.send("DELETE", f"/{ref.type}s/{ref.id}"), no_content=True) + + +@click.command(cls=Command) +@click.argument("query") +@click.option("--mode", multiple=True, required=True, type=click.Choice(["lexical", "semantic"])) +@click.option("--limit", type=click.IntRange(min=1)) +@click.option("--profile", type=int, help="Semantic embedding profile ID") +@click.option("--min-score", type=float) +@click.option("--entity-type", multiple=True, type=click.Choice(["block", "relation"])) +@click.option("--peer", help="明确的检索执行 Peer;不改变本机接入连接") +@common +@click.pass_obj +def recall(inv: Invocation, query, mode, limit, profile, min_score, entity_type, peer): + """显式选择检索模式;每个模式独立返回,不合并排名或自动重试。""" + if "semantic" not in mode and (profile is not None or min_score is not None or entity_type): + raise click.UsageError("--profile / --min-score / --entity-type 需要 --mode semantic") + _ = inv.client # Resolve the shared connection before entering the independent-mode batch. + results = [] + for selected in dict.fromkeys(mode): + body = {"query": query} + if selected == "semantic": + options = { + key: value + for key, value in { + "limit": limit, + "min_score": min_score, + "entity_types": entity_type or None, + }.items() + if value is not None + } + body.update({"profile": profile, "options": options}) + elif limit is not None: + body["limit"] = limit + try: + result = inv.send( + "POST", f"/retrieval/{selected}", params={"route_to_peer": peer}, body=body + ) + results.append({"mode": selected, **result}) + except CommandError as error: + results.append({"mode": selected, "error": error.as_dict()}) + except httpx.RequestError as error: + results.append({"mode": selected, "error": {"detail": str(error)}}) + inv.show(results, partial_failure=any("error" in item for item in results)) + + +@click.group(cls=Group) +def graph(): + """查询图结构,或提交互相引用的新实体;不隐式读取 Resolver 内容。""" + + +@graph.command() +@click.argument("reference") +@click.option("--direction", type=click.Choice(["both", "out", "in"])) +@click.option("--content", "contents", multiple=True, help="重复传入关系 content 过滤值") +@paging +@common +@click.pass_obj +def neighborhood(inv: Invocation, reference, direction, contents, limit, cursor): + """展开一个实体的一跳邻域。""" + ref = entity(reference) + if ref.type == "relation" and (direction or contents or limit or cursor): + raise click.UsageError("Relation 邻域只有两个端点,不接受 Block 的遍历参数") + inv.show( + inv.send( + "GET", + f"/{ref.type}s/{ref.id}/neighborhood", + params={ + "direction": direction, + "contents": contents or None, + "limit": limit, + "cursor": cursor, + }, + ) + ) + + +@graph.command("path") +@click.option("--from", "from_id", required=True, type=int) +@click.option("--to", "to_id", required=True, type=int) +@input_options +@common +@click.pass_obj +def path(inv: Invocation, from_id, to_id, schema, input_file, input_json): + """在边界内查找路径;JSON 可填写 direction、contents 和探索预算。""" + if schema: + return inv.show( + request_schema(inv.client, "/graph/path", "POST", omit=("from_block_id", "to_block_id")) + ) + inv.show( + inv.send( + "POST", + "/graph/path", + body={ + **load_input(input_file, input_json), + "from_block_id": from_id, + "to_block_id": to_id, + }, + selectors={"from_block_id": "--from", "to_block_id": "--to"}, + ) + ) + + +@graph.command() +@click.argument("seeds", nargs=-1, type=int) +@input_options +@common +@click.pass_obj +def components(inv: Invocation, seeds, schema, input_file, input_json): + """按 JSON 中明确的关系 contents 查询种子 Block 的连通分量。""" + if schema: + return inv.show( + request_schema(inv.client, "/graph/components", "POST", omit=("seed_block_ids",)) + ) + inv.show( + inv.send( + "POST", + "/graph/components", + body={ + **load_input(input_file, input_json), + "seed_block_ids": seeds, + }, + selectors={"seed_block_ids": "seeds"}, + ) + ) + + +@graph.command() +@input_options +@common +@click.pass_obj +def submit(inv: Invocation, schema, input_file, input_json): + """追加 GraphForm;负数 ID 表达本次新实体之间的引用。""" + if schema: + return inv.show(request_schema(inv.client, "/graph", "POST")) + inv.show(inv.send("POST", "/graph", body=load_input(input_file, input_json))) + + +@click.group(cls=Group) +def resolver(): + """发现并调用 Core 当前加载的 Resolver 读取方法。""" + + +@resolver.command("list") +@paging +@common +@click.pass_obj +def list_resolvers(inv: Invocation, limit, cursor): + """列出当前接入 Core 的 Resolver,不枚举每个方法的 schema。""" + inv.show(inv.send("GET", "/resolvers", params={"limit": limit, "cursor": cursor})) + + +@resolver.command() +@click.argument("target") +@paging +@common +@click.pass_obj +def methods(inv: Invocation, target, limit, cursor): + """TARGET 为 block: 或 exact Resolver ID。""" + route = ( + f"/blocks/{entity(target, block_only=True).id}/resolver" + if target.startswith("block:") + else f"/resolvers/{segment(target)}" + ) + inv.show(inv.send("GET", route + "/methods", params={"limit": limit, "cursor": cursor})) + + +@resolver.command() +@click.argument("reference") +@click.option("--method", required=True, help="准确的方法名,例如 get_solved_content") +@input_options +@common +@click.pass_obj +def invoke(inv: Invocation, reference, method, schema, input_file, input_json): + """在指定 Block 的 Resolver 上调用一个方法;读取可能触发 materialization。""" + route = f"/blocks/{entity(reference, block_only=True).id}/resolver/methods" + if schema: + contract = inv.send("GET", route) + selected = next((item for item in contract["methods"] if item["name"] == method), None) + if selected is None: + raise CommandError(f"未发现 Resolver method {method!r}") + return inv.show(selected["input_schema"]) + inv.show( + inv.send("POST", route + "/" + segment(method), body=load_input(input_file, input_json)) + ) diff --git a/cli/src/inkcre_cli/commands/jobs.py b/cli/src/inkcre_cli/commands/jobs.py new file mode 100644 index 0000000..9511db8 --- /dev/null +++ b/cli/src/inkcre_cli/commands/jobs.py @@ -0,0 +1,279 @@ +"""Job admission and bounded observation; the CLI never executes a Job.""" + +import asyncio + +import click +import httpx + +from ..command import ( + Group, + Invocation, + common, + duration, + input_options, + load_input, + paging, + segment, +) +from ..errors import CommandError +from ..schema import nested, request_schema + + +@click.group(cls=Group) +def job(): + """创建、观察和请求停止后台 Job。""" + + +@job.command() +@click.argument("type_id", required=False) +@paging +@common +@click.pass_obj +def types(inv: Invocation, type_id, limit, cursor): + """列出持久化 Job type;给出 exact ID 时读取其参数合同。""" + inv.show( + inv.send( + "GET", + "/job-types" + ("/" + segment(type_id) if type_id else ""), + params={"limit": limit, "cursor": cursor}, + ) + ) + + +@job.command() +@click.option("--type", "type_id", required=True) +@input_options +@common +@click.pass_obj +def create(inv: Invocation, type_id, schema, input_file, input_json): + """受理一个 Job;JSON 包含 parameters 与可选 timeout_seconds。""" + if schema: + owner = inv.send("GET", "/job-types/" + segment(type_id)) + return inv.show( + nested( + request_schema(inv.client, "/jobs", "POST", omit=("type",)), + "parameters", + owner["parameters_schema"], + ) + ) + inv.show( + inv.send( + "POST", + "/jobs", + body={**load_input(input_file, input_json), "type": type_id}, + selectors={"type": "--type"}, + ) + ) + + +@job.command("list") +@click.option("--type", "type_id") +@click.option( + "--status", + type=click.Choice(["pending", "running", "finished", "failed", "timed_out", "aborted"]), +) +@paging +@common +@click.pass_obj +def list_jobs(inv: Invocation, type_id, status, limit, cursor): + """最近的 Job,默认 20 项;cursor 由上一页返回。""" + inv.show( + inv.send( + "GET", + "/jobs", + params={"type": type_id, "status": status, "limit": limit, "cursor": cursor}, + ) + ) + + +@job.command() +@click.argument("job_id", type=int) +@common +@click.pass_obj +def get(inv: Invocation, job_id): + """读取实际记录;failed Job 也是一次成功的读取。""" + inv.show(inv.send("GET", f"/jobs/{job_id}")) + + +@job.command() +@click.argument("job_id", type=int) +@common +@click.pass_obj +def abort(inv: Invocation, job_id): + """请求 best-effort 停止;running + abort_requested 不等于已经结束。""" + inv.show(inv.send("POST", f"/jobs/{job_id}/abort")) + + +async def observe(inv: Invocation, job_id: int, seconds: float): + client = inv.client + last = None + # HTTPX phase timeouts are not a wall-clock deadline. This small asynchronous + # scope bounds the entire observation, including a slow in-flight response; + # cancellation closes the actual request, not an abandoned worker thread. + async with httpx.AsyncClient(timeout=30) as http: + try: + async with asyncio.timeout(seconds): + while True: + response = await http.send(client.build_request("GET", f"/jobs/{job_id}")) + last = client.read_response(response) + if last["status"] not in {"pending", "running"}: + return last + await asyncio.sleep(2) + except TimeoutError: + if last is None: + raise CommandError("观察预算已用尽,尚未取得 Job 记录") from None + return last + + +@job.command() +@click.argument("job_id", type=int) +@click.option("--for", "wait_for", default="30s", show_default=True) +@common +@click.pass_obj +def wait(inv: Invocation, job_id, wait_for): + """最多观察指定时间,终态提前返回;用尽预算不会停止远端 Job。""" + inv.show(asyncio.run(observe(inv, job_id, duration(wait_for)))) + + +@click.group(cls=Group) +def organization(): + """为整理创建 Job;自动行为与 Agent 的选择通过 deployment config 管理。""" + + +@organization.command() +@click.argument("block_id", type=int) +@click.option("--timeout-seconds", type=click.IntRange(min=1)) +@common +@click.pass_obj +def ruminate(inv: Invocation, block_id, timeout_seconds): + """为一个 Block 派发 explicit rumination Job,不直接调用 Peer inbound。""" + inv.show( + inv.send( + "POST", + "/jobs", + body={ + "type": "core.organization.rumination.explicit.v1", + "parameters": {"block": block_id}, + "timeout_seconds": timeout_seconds, + }, + selectors={"parameters": "block_id"}, + ) + ) + + +@click.group(cls=Group) +def cron(): + """配置服务端定期派发的 Job 模板;CLI 不运行 scheduler。""" + + +@cron.command("list") +@paging +@common +@click.pass_obj +def list_crons(inv: Invocation, limit, cursor): + """列出 Cron。""" + inv.show(inv.send("GET", "/crons", params={"limit": limit, "cursor": cursor})) + + +@cron.command("get") +@click.argument("cron_id", type=int) +@common +@click.pass_obj +def get_cron(inv: Invocation, cron_id): + """读取一个 Cron。""" + inv.show(inv.send("GET", f"/crons/{cron_id}")) + + +@cron.command("create") +@click.option("--job-type", required=True) +@input_options +@common +@click.pass_obj +def create_cron(inv: Invocation, job_type, schema, input_file, input_json): + """创建五字段 UNIX Cron;时区来自 deployment config。""" + if schema: + owner = inv.send("GET", "/job-types/" + segment(job_type)) + return inv.show( + nested( + request_schema(inv.client, "/crons", "POST", omit=("job_type",)), + "job_parameters", + owner["parameters_schema"], + ) + ) + inv.show( + inv.send( + "POST", + "/crons", + body={**load_input(input_file, input_json), "job_type": job_type}, + selectors={"job_type": "--job-type"}, + ) + ) + + +@cron.command("update") +@click.argument("cron_id", type=int) +@click.option("--job-type", help="同时替换 Job type;--schema 据此投影参数") +@input_options +@common +@click.pass_obj +def update_cron(inv: Invocation, cron_id, job_type, schema, input_file, input_json): + """修改提交的 Cron 字段;省略字段保持。""" + if schema: + selected = job_type or inv.send("GET", f"/crons/{cron_id}")["job_type"] + owner = inv.send("GET", "/job-types/" + segment(selected)) + return inv.show( + nested( + request_schema( + inv.client, "/crons/{cron_id}", "PATCH", omit=("job_type",) if job_type else () + ), + "job_parameters", + owner["parameters_schema"], + ) + ) + body = load_input(input_file, input_json) + if job_type: + body["job_type"] = job_type + inv.show( + inv.send( + "PATCH", + f"/crons/{cron_id}", + body=body, + selectors={"job_type": "--job-type"} if job_type else None, + ) + ) + + +@cron.command("delete") +@click.argument("cron_id", type=int) +@common +@click.pass_obj +def delete_cron(inv: Invocation, cron_id): + """删除 Cron 模板,不删除已经创建的 Job。""" + inv.show(inv.send("DELETE", f"/crons/{cron_id}"), no_content=True) + + +@cron.command() +@click.argument("cron_id", type=int) +@common +@click.pass_obj +def enable(inv: Invocation, cron_id): + """启用未来的定期派发。""" + inv.show(inv.send("PATCH", f"/crons/{cron_id}", body={"enabled": True})) + + +@cron.command() +@click.argument("cron_id", type=int) +@common +@click.pass_obj +def disable(inv: Invocation, cron_id): + """停止未来的定期派发,不取消已经创建的 Job。""" + inv.show(inv.send("PATCH", f"/crons/{cron_id}", body={"enabled": False})) + + +@cron.command() +@click.argument("cron_id", type=int) +@common +@click.pass_obj +def run(inv: Invocation, cron_id): + """从模板立即派发一次 Job,不改变定期进度。""" + inv.show(inv.send("POST", f"/crons/{cron_id}/run")) diff --git a/cli/src/inkcre_cli/commands/peer.py b/cli/src/inkcre_cli/commands/peer.py new file mode 100644 index 0000000..101ad4b --- /dev/null +++ b/cli/src/inkcre_cli/commands/peer.py @@ -0,0 +1,67 @@ +"""Peer record discovery and explicit HTTP readiness observation.""" + +import asyncio + +import click +import httpx + +from ..command import Group, Invocation, common, duration, paging, segment +from ..errors import CommandError + + +@click.group(cls=Group) +def peer(): + """读取 Peer 记录;wake 只针对已配置的 HTTP connection。""" + + +@peer.command("list") +@paging +@common +@click.pass_obj +def list_peers(inv: Invocation, limit, cursor): + """lease_active 来自数据库时间,不等于所有 capability 可用。""" + inv.show(inv.send("GET", "/peers", params={"limit": limit, "cursor": cursor})) + + +@peer.command() +@click.argument("peer_id", default="self") +@common +@click.pass_obj +def get(inv: Invocation, peer_id): + """读取 UUID 或内置别名 self 所指向的接入 Peer。""" + inv.show(inv.send("GET", "/peers/" + segment(peer_id))) + + +async def wait_ready(inv: Invocation, seconds: float): + client = inv.client + last_error = None + async with httpx.AsyncClient(timeout=30) as http: + try: + async with asyncio.timeout(seconds): + while True: + response = await http.send( + client.build_request("GET", "/readyz", authenticated=False) + ) + try: + return { + "endpoint": client.base_url, + "readyz": client.read_response(response), + } + except CommandError as error: + if error.status != 503: + raise + last_error = error + await asyncio.sleep(2) + except TimeoutError: + if last_error: + raise last_error from None + raise CommandError("唤醒观察预算已用尽,尚未取得就绪响应") from None + + +@peer.command() +@click.option("--for", "wait_for", default="30s", show_default=True) +@common +@click.pass_obj +def wake(inv: Invocation, wait_for): + """在有界时间内请求当前 connection 的 /readyz;不发现或猜测其它 Peer 地址。""" + inv.show(asyncio.run(wait_ready(inv, duration(wait_for)))) diff --git a/cli/src/inkcre_cli/commands/source.py b/cli/src/inkcre_cli/commands/source.py new file mode 100644 index 0000000..918299c --- /dev/null +++ b/cli/src/inkcre_cli/commands/source.py @@ -0,0 +1,147 @@ +"""Source configuration and collection admission, with catalog-owned schemas.""" + +import click + +from ..command import Group, Invocation, common, input_options, load_input, paging, segment +from ..errors import CommandError +from ..schema import nested, request_schema + + +@click.group(cls=Group) +def source(): + """管理 Source,或派发 ordinary / backfill collect Job。""" + + +@source.command() +@click.argument("type_id", required=False) +@paging +@common +@click.pass_obj +def types(inv: Invocation, type_id, limit, cursor): + """发现 deployment 的 Source type;不等于接入 Peer 都可执行。""" + inv.show( + inv.send( + "GET", + "/source-types" + ("/" + segment(type_id) if type_id else ""), + params={"limit": limit, "cursor": cursor}, + ) + ) + + +@source.command("list") +@paging +@common +@click.pass_obj +def list_sources(inv: Invocation, limit, cursor): + """列出 Source 配置。""" + inv.show(inv.send("GET", "/sources", params={"limit": limit, "cursor": cursor})) + + +@source.command() +@click.argument("source_id", type=int) +@common +@click.pass_obj +def get(inv: Invocation, source_id): + """读取 Source 记录。""" + inv.show(inv.send("GET", f"/sources/{source_id}")) + + +@source.command() +@click.option("--type", "type_id", required=True) +@input_options +@common +@click.pass_obj +def create(inv: Invocation, type_id, schema, input_file, input_json): + """创建 Source;输入为 nickname/storage/config,不包含 type 或数据库字段。""" + if schema: + owner = inv.send("GET", "/source-types/" + segment(type_id)) + return inv.show( + nested( + request_schema(inv.client, "/sources", "POST", omit=("type",)), + "config", + owner["config_schema"], + ) + ) + inv.show( + inv.send( + "POST", + "/sources", + body={**load_input(input_file, input_json), "type": type_id}, + selectors={"type": "--type"}, + ) + ) + + +def source_type(inv: Invocation, source_id: int) -> dict: + record = inv.send("GET", f"/sources/{source_id}") + return inv.send("GET", "/source-types/" + segment(record["type"])) + + +@source.command() +@click.argument("source_id", type=int) +@input_options +@common +@click.pass_obj +def update(inv: Invocation, source_id, schema, input_file, input_json): + """只改提交的字段;提供 config 时完整替换该 config。""" + if schema: + return inv.show( + nested( + request_schema(inv.client, "/sources/{source_id}", "PATCH"), + "config", + source_type(inv, source_id)["config_schema"], + ) + ) + inv.show(inv.send("PATCH", f"/sources/{source_id}", body=load_input(input_file, input_json))) + + +@source.command() +@click.argument("source_id", type=int) +@common +@click.pass_obj +def delete(inv: Invocation, source_id): + """删除 Source 配置;已收集的图不随之删除。""" + inv.show(inv.send("DELETE", f"/sources/{source_id}"), no_content=True) + + +@source.command() +@click.argument("source_id", type=int) +@click.option("--timeout-seconds", type=click.IntRange(min=1)) +@input_options +@common +@click.pass_obj +def collect(inv: Invocation, source_id, timeout_seconds, schema, input_file, input_json): + """派发普通收集 Job;JSON 是该 Source 的 collect config。""" + if schema: + return inv.show(source_type(inv, source_id)["collect_config_schema"]) + inv.show( + inv.send( + "POST", + f"/sources/{source_id}/collect", + params={"timeout_seconds": timeout_seconds}, + body=load_input(input_file, input_json), + ) + ) + + +@source.command() +@click.argument("source_id", type=int) +@click.option("--timeout-seconds", type=click.IntRange(min=1)) +@input_options +@common +@click.pass_obj +def backfill(inv: Invocation, source_id, timeout_seconds, schema, input_file, input_json): + """派发指定历史边界的收集 Job;不改变 ordinary collect 范围。""" + if schema: + contract = source_type(inv, source_id)["backfill_config_schema"] + if contract is None: + raise CommandError("此 Source type 不支持 backfill") + return inv.show(contract) + inv.show( + inv.send( + "POST", + f"/sources/{source_id}/backfill", + params={"timeout_seconds": timeout_seconds}, + body=load_input(input_file, input_json), + ) + ) diff --git a/cli/src/inkcre_cli/connection.py b/cli/src/inkcre_cli/connection.py new file mode 100644 index 0000000..24d07b3 --- /dev/null +++ b/cli/src/inkcre_cli/connection.py @@ -0,0 +1,64 @@ +"""One local file of complete named connections; no deployment config ownership.""" + +import json +import os +import tempfile +from pathlib import Path + +from pydantic import BaseModel, ConfigDict, Field, HttpUrl + +from .errors import CommandError + + +class Connection(BaseModel): + model_config = ConfigDict(extra="forbid") + + base_url: HttpUrl + jwt_secret: str = Field(min_length=1) + + +class Connections(BaseModel): + default: str | None = None + connections: dict[str, Connection] = Field(default_factory=dict) + + +def file_path(override: Path | None) -> Path: + configured = override or os.environ.get("INKCRE_CLI_CONNECTIONS_FILE") + return ( + Path(configured).expanduser() + if configured + else Path.home() / ".inkcre/cli/connections.json" + ) + + +def load(path: Path) -> Connections: + return Connections.model_validate_json(path.read_bytes()) if path.exists() else Connections() + + +def save(path: Path, data: Connections) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + # Replace one complete file so a reader never observes a partially written JSON document. + with tempfile.NamedTemporaryFile( + mode="w", dir=path.parent, delete=False, encoding="utf-8" + ) as out: + temporary = Path(out.name) + try: + json.dump(data.model_dump(mode="json"), out, ensure_ascii=False, separators=(",", ":")) + out.close() + temporary.replace(path) + finally: + temporary.unlink(missing_ok=True) + + +def select(data: Connections, name: str | None) -> tuple[str, Connection]: + selected = name or os.environ.get("INKCRE_CLI_CONNECTION") or data.default + if selected not in data.connections: + raise CommandError( + { + "connection": selected, + "available": sorted(data.connections), + "message": "请选择已保存的连接", + }, + exit_code=2, + ) + return selected, data.connections[selected] diff --git a/cli/src/inkcre_cli/errors.py b/cli/src/inkcre_cli/errors.py new file mode 100644 index 0000000..35ec893 --- /dev/null +++ b/cli/src/inkcre_cli/errors.py @@ -0,0 +1,17 @@ +"""Command failures, separate from successfully observed domain outcomes.""" + +from typing import Any + + +class CommandError(Exception): + def __init__(self, detail: Any, *, status: int | None = None, exit_code: int = 1): + super().__init__(str(detail)) + self.detail = detail + self.status = status + self.exit_code = exit_code + + def as_dict(self) -> dict[str, Any]: + result = {"detail": self.detail} + if self.status is not None: + result["http_status"] = self.status + return result diff --git a/cli/src/inkcre_cli/http.py b/cli/src/inkcre_cli/http.py new file mode 100644 index 0000000..8a5b0ff --- /dev/null +++ b/cli/src/inkcre_cli/http.py @@ -0,0 +1,107 @@ +"""REST lifecycle, authentication and native JSON/binary/multipart decoding.""" + +import json +import time +from email import policy +from email.parser import BytesParser +from typing import Any, cast + +import httpx +import jwt + +from .connection import Connection +from .errors import CommandError + + +def decode(response: httpx.Response) -> Any: + if response.status_code == 204: + return None + content_type = response.headers.get("content-type", "") + if content_type.split(";", 1)[0].strip().lower() == "multipart/related": + message = BytesParser(policy=policy.default).parsebytes( + f"Content-Type: {content_type}\r\nMIME-Version: 1.0\r\n\r\n".encode() + response.content + ) + parts = list(message.iter_parts()) + root = json.loads(cast(bytes, parts[0].get_payload(decode=True))) + binaries = { + str(part["Content-ID"]).strip("<>"): cast(bytes, part.get_payload(decode=True)) + for part in parts[1:] + } + value: Any = root["value"] + for pointer, content_id in root["parts"].items(): + binary = binaries[content_id.removeprefix("cid:")] + if pointer == "": + value = binary + continue + keys = [key.replace("~1", "/").replace("~0", "~") for key in pointer[1:].split("/")] + parent: Any = value + for key in keys[:-1]: + parent = parent[int(key)] if isinstance(parent, list) else parent[key] + parent[int(keys[-1]) if isinstance(parent, list) else keys[-1]] = binary + return value + if content_type.split(";", 1)[0].strip().lower() == "application/octet-stream": + return response.content + return response.json() + + +class CoreRESTClient: + def __init__(self, connection: Connection): + self.connection = connection + self.base_url = str(connection.base_url).rstrip("/") + self.client = httpx.Client(timeout=30) + + def close(self) -> None: + self.client.close() + + def build_request( + self, + method: str, + path: str, + *, + params: dict[str, Any] | None = None, + body: Any = None, + authenticated: bool = True, + timeout: float | None = None, + ) -> httpx.Request: + headers = {} + if authenticated: + issued = int(time.time()) - 5 + token = jwt.encode( + { + "role": "authenticated", + "iss": "inkcre-peer", + "aud": "inkcre-api", + "iat": issued, + "exp": issued + 15 * 60, + }, + self.connection.jwt_secret, + algorithm="HS256", + ) + headers["Authorization"] = f"Bearer {token}" + kwargs: dict[str, Any] = {"headers": headers} + if timeout is not None: + kwargs["timeout"] = timeout + if params: + kwargs["params"] = {key: value for key, value in params.items() if value is not None} + if body is not None: + kwargs["json"] = body + return self.client.build_request(method, self.base_url + path, **kwargs) + + @staticmethod + def read_response(response: httpx.Response) -> Any: + if response.is_error: + try: + payload = response.json() + detail = payload.get("detail", payload) if isinstance(payload, dict) else payload + except ValueError: + detail = response.text + raise CommandError(detail, status=response.status_code) + try: + return decode(response) + except (ValueError, KeyError, IndexError, TypeError) as error: + raise CommandError( + f"无法解码 Core 响应: {error}", status=response.status_code + ) from error + + def request(self, method: str, path: str, **kwargs: Any) -> Any: + return self.read_response(self.client.send(self.build_request(method, path, **kwargs))) diff --git a/cli/src/inkcre_cli/main.py b/cli/src/inkcre_cli/main.py new file mode 100644 index 0000000..86e499c --- /dev/null +++ b/cli/src/inkcre_cli/main.py @@ -0,0 +1,75 @@ +"""Click's native dispatch, with the CLI's stdout/stderr and exit contract.""" + +import json +import sys + +import click +import httpx +from pydantic import ValidationError + +from .command import Group, Invocation, common +from .commands import agent, config, connection, extension, info, jobs, peer, source +from .errors import CommandError +from .output import compact + + +@click.group(cls=Group, context_settings={"help_option_names": ["-h", "--help"]}) +@common +def cli(): + """InKCre 的 REST 命令行界面。用 --help 离线发现操作,用 --schema 查询当前输入合同。 + + CLI 不是 Core/Peer,不访问数据库或执行 Job。connection 管本机接入,config 管远端配置。 + """ + + +for command in ( + info.recall, + info.get, + info.update, + info.delete, + info.graph, + info.resolver, + source.source, + jobs.organization, + jobs.job, + jobs.cron, + agent.agent, + agent.ai, + agent.embedding_profile, + config.config, + connection.connection, + extension.extension, + peer.peer, +): + cli.add_command(command) + + +def main() -> None: + invocation = Invocation() + code = 0 + failure = None + try: + result = cli.main(standalone_mode=False, obj=invocation) + if isinstance(result, int): + code = result + except CommandError as error: + failure, code = error.as_dict(), error.exit_code + except click.ClickException as error: + failure, code = {"detail": error.format_message()}, 2 + except ValidationError as error: + failure, code = {"detail": json.loads(error.json(include_url=False))}, 2 + except (OSError, httpx.RequestError) as error: + failure, code = {"detail": str(error)}, 1 + except (KeyboardInterrupt, click.Abort): + failure, code = {"detail": "观察已中断;没有请求停止远端 Job"}, 130 + finally: + invocation.close() + if failure is not None: + click.echo( + compact(failure) if invocation.output.json_mode else str(failure["detail"]), err=True + ) + sys.exit(code) + + +if __name__ == "__main__": + main() diff --git a/cli/src/inkcre_cli/output.py b/cli/src/inkcre_cli/output.py new file mode 100644 index 0000000..630f34f --- /dev/null +++ b/cli/src/inkcre_cli/output.py @@ -0,0 +1,92 @@ +"""Bounded presentation and complete local content delivery from the same response.""" + +import json +import tempfile +from pathlib import Path +from typing import Any + +import click + +from .errors import CommandError + + +def compact(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, separators=(",", ":"), allow_nan=False) + + +def has_bytes(value: Any) -> bool: + if isinstance(value, bytes): + return True + if isinstance(value, dict): + return any(has_bytes(item) for item in value.values()) + if isinstance(value, list): + return any(has_bytes(item) for item in value) + return False + + +def preview_binary(value: Any) -> Any: + if isinstance(value, bytes): + return f"<{len(value)} bytes; 完整内容见导出文件>" + raise TypeError(f"Cannot present {type(value).__name__}") + + +class Output: + def __init__(self, json_mode: bool = False, directory: Path | None = None): + self.json_mode = json_mode + self.directory = directory + + def _save(self, value: Any, *, entry: bool) -> Any: + if self.directory: + self.directory.mkdir(parents=True, exist_ok=True) + directory = Path(tempfile.mkdtemp(prefix="inkcre-", dir=self.directory)).resolve() + number = 0 + + def convert(item: Any) -> Any: + nonlocal number + if isinstance(item, bytes): + number += 1 + path = directory / f"{number:03}.bin" + path.write_bytes(item) + return {"file": path.name if entry else str(path)} + if isinstance(item, dict): + return {key: convert(child) for key, child in item.items()} + if isinstance(item, list): + return [convert(child) for child in item] + return item + + try: + if not entry: + return convert(value) + if isinstance(value, bytes): + path = directory / "result.bin" + path.write_bytes(value) + elif isinstance(value, str): + path = directory / "result.txt" + path.write_text(value, encoding="utf-8") + else: + path = directory / "result.json" + path.write_text(compact(convert(value)), encoding="utf-8") + return {"file": str(path)} + except OSError as error: + raise CommandError({"message": str(error), "directory": str(directory)}) from error + + def emit(self, value: Any, *, no_content: bool = False) -> None: + if self.directory: + value = self._save(value, entry=True) + elif not self.json_mode: + # The preview is deliberately a presentation budget, not a business result limit. + preview = value if isinstance(value, str) else None + if preview is None: + preview = json.dumps(value, ensure_ascii=False, indent=2, default=preview_binary) + if preview is not None and len(preview) > 8000: + receipt = self._save(value, entry=True) + click.echo(preview[:2000] + "\n…\n完整结果: " + receipt["file"]) + return + if has_bytes(value): + value = self._save(value, entry=False) + if self.json_mode: + click.echo(compact(value)) + elif not no_content: + click.echo( + value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, indent=2) + ) diff --git a/cli/src/inkcre_cli/schema.py b/cli/src/inkcre_cli/schema.py new file mode 100644 index 0000000..0e99d5b --- /dev/null +++ b/cli/src/inkcre_cli/schema.py @@ -0,0 +1,71 @@ +"""Project the server's input schemas without validating or compiling them.""" + +from copy import deepcopy +from typing import Any + +from .http import CoreRESTClient + + +def request_schema( + client: CoreRESTClient, path: str, method: str, *, omit: tuple[str, ...] = () +) -> dict: + document = client.request("GET", "/openapi.json", authenticated=False) + schema = deepcopy( + document["paths"][path][method.lower()]["requestBody"]["content"]["application/json"][ + "schema" + ] + ) + if "$ref" in schema: + schema = deepcopy(document["components"]["schemas"][schema["$ref"].rsplit("/", 1)[1]]) + for name in omit: + schema.get("properties", {}).pop(name, None) + if "required" in schema: + schema["required"] = [name for name in schema["required"] if name not in omit] + # Copy only reachable definitions. Keep the original JSON pointers, including recursive ones. + definitions: dict[str, Any] = {} + + def collect(value: Any) -> None: + if isinstance(value, dict): + ref = value.get("$ref", "") + if ref.startswith("#/components/schemas/"): + name = ref.rsplit("/", 1)[1] + if name not in definitions: + definitions[name] = deepcopy(document["components"]["schemas"][name]) + collect(definitions[name]) + for child in value.values(): + collect(child) + elif isinstance(value, list): + for child in value: + collect(child) + + collect(schema) + if definitions: + schema["components"] = {"schemas": definitions} + return schema + + +def nested(schema: dict, field: str, child: dict) -> dict: + """Rebase local references when putting an owner schema at its actual input position.""" + prefix = "#/properties/" + field.replace("~", "~0").replace("/", "~1") + + def rebase(value: Any) -> Any: + if isinstance(value, dict): + return { + key: prefix + item[1:] + if key == "$ref" and isinstance(item, str) and item.startswith("#") + else rebase(item) + for key, item in value.items() + } + if isinstance(value, list): + return [rebase(item) for item in value] + return value + + result = deepcopy(schema) + result.setdefault("properties", {})[field] = rebase(child) + return result + + +def partial(schema: dict) -> dict: + result = deepcopy(schema) + result.pop("required", None) + return result diff --git a/docs/30-unit-tdd/business-pipeline-and-authority.md b/docs/30-unit-tdd/business-pipeline-and-authority.md index c7f8391..fb69282 100644 --- a/docs/30-unit-tdd/business-pipeline-and-authority.md +++ b/docs/30-unit-tdd/business-pipeline-and-authority.md @@ -154,6 +154,8 @@ implementation direction; it must not redefine Peer wire behavior or shared capa running instance 的 active resources。Registration 不创建或自动运行实例。 - Sink 是 application/use 的下游 projection,不取得 Block、Relation、Resolver、Storage 或 retrieval authority。 一个 Extension 可以交付 Sink type,但 Extension enable 不等于 Sink instance enable。 +- `cli/` 是独立分发的 REST consumer,也属于产品意义上的 sink,但不是 `SinkBase` runtime instance。它不安装 + Core、不直连数据库、不注册 Peer。普通 REST route 交给对应领域 owner;它不复用 Peer inbound 作为公共 API。 - `core.mcp.v1` 是首个实现:它把现有 retrieval、graph navigation 与 Resolver read behavior 投影为 MCP actions; oversized/binary content 通过 live Resource URI 重新读取当前 authority,不产生 Resource table 或缓存 authority。 - MCP 的 read-only boundary 排除 Agent-intended mutation command;Resolver `get_*` / `read_*` 仍可按其既有 contract @@ -161,6 +163,11 @@ implementation direction; it must not redefine Peer wire behavior or shared capa ## Cross-Subtree Constraints +配置输入先合并成完整候选并验证,再持久化。普通记录读取不因 schema 未加载而拒绝;需要 SecretStr、嵌套 +模型或 union 的执行路径接受一次原生 Pydantic 类型恢复。已经得到正确类型的内部值直接传递,不反复 +dump/model_validate。不要把 model_construct 当作通用递归 decoder,也不要把内部或输出验证错误伪装成 +HTTP 422。普通 REST 的请求输入、错误位置和内容表示见 [rest-interface.md](rest-interface.md)。 + ### Allowed Direction 当前允许的结构性方向可以理解为: diff --git a/docs/30-unit-tdd/rest-interface.md b/docs/30-unit-tdd/rest-interface.md new file mode 100644 index 0000000..d4f7002 --- /dev/null +++ b/docs/30-unit-tdd/rest-interface.md @@ -0,0 +1,51 @@ +# 普通 REST 界面 + +Core 提供普通 REST 供独立消费者使用。`cli/` 是首个完整消费者;它不安装 Core 或直接访问数据库。 +普通 route 只拥有 HTTP 参数、响应和错误映射,管理与执行行为留在各领域 Manager。 +精确路由及静态输入字段由生成的 [OpenAPI](../openapi.json) 维护,不在本文复制第二份 endpoint 清单。 + +## 不把 Peer inbound 当成公共 facade + +`/retrieval/lexical`、`/retrieval/semantic` 调用领域 `retrieve` facade,可携带明确的 `route_to_peer`。 +固定 Peer inbound `/lexical-retrieval`、`/semantic-retrieval` 始终调用 non-delegating local seam。 +它们共用 JWT wire contract,但不因此共用 handler 或递归委托。 + +显式 collection/backfill 和 rumination 受理为 Job。成功创建只证明 Job 已持久化,不要求接入 Peer 能执行它; +异步 worker 扫描在响应之后触发,失败会记录日志,不撤回已受理的 Job。CLI 不调用 rumination Peer inbound。 +Extension enable/disable 的普通 REST 可指定目标 Peer,安装、版本、启用意图和运行状态仍由 Host 拥有。 + +## 输入与发现 + +Core forms 是静态 HTTP 输入 authority。Source/Job types 读取持久 catalog,Resolver/Agent Tool/config schema +读取当前进程的 owner registry。发现不执行内容方法,也不承诺对应能力当前可成功运行。 +schema 目录只投影已有合同,不新建 schema table。普通 config GET 返回保存的 schema/value,不重复验证。 + +创建输入与记录模型分开;PATCH 只写 `exclude_unset` 的字段,省略 storage 不意味着清空 storage。 +Source config 在提交时完整替换;deployment/Extension config PATCH 合并完整候选后由 owner 验证。 +关系记录在普通 REST 用 `from_block_id` / `to_block_id`;领域 `RelationModel` 和已有 Peer wire 不随之改名。 + +`request_input` 只包住本次输入的校验,保留 Pydantic/jsonschema 原始错误并补实际路径。Resolver invocation +把参数验证的异常标为原生 ValidationError 子类,route 只捕获这个输入异常;方法内部和输出故障保持真实 5xx。 +不会将所有 ValueError、IntegrityError 或 ValidationError 全局映射为 422。 + +明确的记录写入通过 `database_write` 将 PostgreSQL 外键与 CHECK 冲突映射为 409,保留数据库给出的原因和 +约束名。route 不预查引用或复制 CHECK 条件;连接故障、程序错误和生成主键碰撞仍保持服务器错误。 + +## 内容交付 + +普通值是 JSON,纯 bytes 是 `application/octet-stream`,嵌套 bytes 使用 `multipart/related`。第一项 JSON +是 `{value, parts}`;parts 把 value 中的 RFC 6901 JSON Pointer 映射到后续 binary part 的 Content-ID, +只有映射的 null 才代表 bytes。业务原有同形对象、普通 null 和字符串不被解释成引用。 + +`app/routes/content.py` 使用 aiohttp 的 MIME writer 完成一次响应,CLI 使用标准库 MIME parser 解码。 +不调用第二次 Resolver、不增加内容缓存或下载会话。当前 producer 已持有全部 bytes,协议不宣称端到端 +有界内存 streaming。raw 是 Block.content,hydrated 是 Block 的 Storage 读取结果;solved 由 Resolver 方法取得。 + +## 有界结果和实际错误 + +管理清单按自然 identity 提供可选 limit/cursor 与 `next_cursor`,Job 默认取最近 20 条。 +没有自动翻页、总数查询或历史快照。Graph/recall 保留自己的查询边界,不伪装成相同分页模型。 + +批量实体读取保留输入顺序、重复引用和逐项内容读取错误;共享数据库故障仍是整个请求失败。 +普通 HTTP 使用 FastAPI detail;不存在为 404,明确的状态冲突为 409,本次输入不合法为 422。 +不额外包装 success/status,不用日志代替逐项结果。 diff --git a/docs/40-deployment/native-extension-distribution.md b/docs/40-deployment/native-extension-distribution.md index c3876a5..71cc210 100644 --- a/docs/40-deployment/native-extension-distribution.md +++ b/docs/40-deployment/native-extension-distribution.md @@ -59,12 +59,17 @@ bytes, bypassing caches, or waiting for edge convergence. Consumer acceptance re manual or black-box activity and does not block the Heroku preview from starting. `.github/workflows/extension-publish.yml` prepares the Python association with source provenance, +finalizes the newly built wheel using the released Toolkit's `python wheel finalize` command, uploads through `/legacy/`, and publishes the exact Release. Automatic runs obtain `before_sha` from the protected-main push and select only new projects or changed project versions; unchanged matrix entries are explicit no-ops. The matrix and repository wheel checks consume the same discovery result rather than separate project lists. An unprovable lineage or immutable prepare conflict fails the job. +Preview and production both finalize before delivering the wheel. The installed runtime manifest belongs inside +`.dist-info/inkcre-extension.json`; Registry association metadata is not a replacement. The raw build output remains separate +from the finalized upload. Repairing a published wheel requires a new project version, not overwriting an old Release. + A checked commit may be older than current `main` only while it remains an ancestor. Immediately before the first Registry mutation, each selected job fetches `main` again and requires its artifact-input surface to be unchanged across `HEAD..origin/main`. A generated changelog-only or diff --git a/docs/40-deployment/runtime-orchestration.md b/docs/40-deployment/runtime-orchestration.md index 823ee9e..6f7c39d 100644 --- a/docs/40-deployment/runtime-orchestration.md +++ b/docs/40-deployment/runtime-orchestration.md @@ -45,8 +45,10 @@ silently retried because extension startup can have partial effects. - `CronManager.check()` evaluates only the current database-time minute,serializes each Cron and materializes at most one typed Job for the matching occurrence;it does not catch up missed occurrences。 -- `JobManager.check()` filters pending Jobs through the peer-local Handler registry and `can_handle()` before scheduling an - atomic database claim。Only the winning Peer executes;each run closes once to finished、failed、timed-out or aborted。 +- `JobManager.check()` selects pending Jobs with a registered Handler. `run()` restores parameters and checks `can_handle()` + before an atomic database claim. Only the winning Peer executes; each run conditionally closes once to a terminal status. +- `abort_requested` is durable intent, not evidence of completion. A pending Job can close immediately; a running Job + remains running until its execution task has exited and released its resources. Repeating abort preserves terminal state. - source ordinary collect/backfill、lexical maintain/rebuild、semantic maintain/rebuild and media interpretation are exact Job types。Their domain Managers do not acquire Cron semantics merely because a Handler calls them。 @@ -55,6 +57,7 @@ Job has no implicit retry。A source may persist checkpoints as useful progress ### 3. Pending work is drained by periodic checks - Cron occurrence and pending Job checks run every 30 seconds +- the process checks abort intent for its active Job IDs every 2 seconds; the domain handler does not poll the Job table - Peer advertisement/lease refresh uses owner-supplied TTL and renewal interval settings;it republishes config-derived inbound URLs before renewing liveness - retrieval maintenance is scheduled only through persisted Cron + exact typed Job parameters。There is no separate @@ -68,10 +71,11 @@ same database-owned config semantics through its runtime owner. ### 4. Shutdown must close long-lived runtime resources -- the APScheduler instance is shut down in application lifespan shutdown +- scheduler admission is paused first; JobManager stops admitting work, cancels its active tasks and awaits their cleanup - running Sink instances close before Extension teardown, so an external endpoint cannot observe disappearing Extension-delivered behavior while it is still published - running extensions are closed asynchronously so they can release resources +- APScheduler shuts down after those resources close, so its cancellation does not interrupt already-running cleanup - a runtime that reached ready clears its Peer lease after scheduler/extension shutdown;abrupt loss relies on expiry Active Agent Turns are ordinary caller-owned asyncio Tasks,not scheduler jobs or deployment work records。The MVP Thread @@ -97,10 +101,12 @@ module import. - health routes do not require JWT credentials and never include connection errors or database URLs in their payloads -### 7. Scheduler ownership is intentionally single-replica +### 7. Each Peer may own a scheduler -The web process still owns APScheduler. Until scheduler work moves to a dedicated process, -deployments must keep web formation at one replica to avoid duplicate periodic work. +APScheduler belongs to each web process. Cron row serialization, occurrence identity and the conditional pending-Job +claim coordinate multiple Peers through PostgreSQL; a single-replica recommendation is not the concurrency mechanism. +An attempt is not reclaimed or retried when its Peer disappears. Expiry and the next independently scheduled occurrence +retain their existing meanings. ## Authoritative Code Anchors diff --git a/docs/openapi.json b/docs/openapi.json index 13f1640..139432d 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -173,7 +173,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BlockModel" + "$ref": "#/components/schemas/BlockUpdateForm" } } } @@ -184,10 +184,45 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BlockModel" + "type": "object", + "additionalProperties": true, + "title": "Response Edit Block Blocks Block Id Patch" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" } } } + } + } + }, + "delete": { + "tags": [ + "block" + ], + "summary": "Delete Block", + "operationId": "delete_block_blocks__block_id__delete", + "parameters": [ + { + "name": "block_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Block Id" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" }, "422": { "description": "Validation Error", @@ -267,7 +302,9 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RelationModel" + "additionalProperties": true, + "type": "object", + "title": "Response Create Relation Relations Post" } } } @@ -311,7 +348,8 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/RelationModel" + "type": "object", + "additionalProperties": true }, "title": "Response Get Relations By Block Relations By Block Block Id Get" } @@ -331,6 +369,93 @@ } } }, + "/relations/{relation_id}": { + "patch": { + "tags": [ + "relation" + ], + "summary": "Update Relation", + "operationId": "update_relation_relations__relation_id__patch", + "parameters": [ + { + "name": "relation_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Relation Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RelationUpdateForm" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Update Relation Relations Relation Id Patch" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "relation" + ], + "summary": "Delete Relation", + "operationId": "delete_relation_relations__relation_id__delete", + "parameters": [ + { + "name": "relation_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Relation Id" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/extensions": { "get": { "tags": [ @@ -338,20 +463,63 @@ ], "summary": "List Extensions", "operationId": "list_extensions_extensions_get", + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer", + "exclusiveMinimum": 0 + }, + { + "type": "null" + } + ], + "title": "Limit" + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cursor" + } + } + ], "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "items": { - "$ref": "#/components/schemas/InstalledExtension" - }, - "type": "array", + "type": "object", + "additionalProperties": true, "title": "Response List Extensions Extensions Get" } } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -569,15 +737,13 @@ } } } - } - }, - "/extensions/{namespace}/{name}/enable": { - "post": { + }, + "patch": { "tags": [ "extension" ], - "summary": "Enable Extension", - "operationId": "enable_extension_extensions__namespace___name__enable_post", + "summary": "Patch Extension Config", + "operationId": "patch_extension_config_extensions__namespace___name__config_patch", "parameters": [ { "name": "namespace", @@ -598,6 +764,18 @@ } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -622,13 +800,13 @@ } } }, - "/extensions/{namespace}/{name}/disable": { + "/extensions/{namespace}/{name}/enable": { "post": { "tags": [ "extension" ], - "summary": "Disable Extension", - "operationId": "disable_extension_extensions__namespace___name__disable_post", + "summary": "Enable Extension", + "operationId": "enable_extension_extensions__namespace___name__enable_post", "parameters": [ { "name": "namespace", @@ -647,6 +825,23 @@ "type": "string", "title": "Name" } + }, + { + "name": "route_to_peer", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "Route To Peer" + } } ], "responses": { @@ -673,50 +868,57 @@ } } }, - "/sources/{source_id}/collect": { + "/extensions/{namespace}/{name}/disable": { "post": { "tags": [ - "source" + "extension" ], - "summary": "Run Source Collect", - "description": "Run source collect (by creating a source collect job.)", - "operationId": "run_source_collect_sources__source_id__collect_post", + "summary": "Disable Extension", + "operationId": "disable_extension_extensions__namespace___name__disable_post", "parameters": [ { - "name": "source_id", + "name": "namespace", "in": "path", "required": true, "schema": { - "type": "integer", - "title": "Source Id" + "type": "string", + "title": "Namespace" } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "anyOf": [ - { - "type": "object", - "additionalProperties": true - }, - { - "type": "null" - } - ], - "title": "Body" - } + }, + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Name" + } + }, + { + "name": "route_to_peer", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "Route To Peer" } } - }, + ], "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/JobModel" + "$ref": "#/components/schemas/InstalledExtension" } } } @@ -734,60 +936,57 @@ } } }, - "/sources/{source_id}/backfill": { - "post": { + "/source-types": { + "get": { "tags": [ "source" ], - "summary": "Run Source Backfill", - "description": "Create one explicit historical Source Job.", - "operationId": "run_source_backfill_sources__source_id__backfill_post", + "summary": "List Source Types", + "operationId": "list_source_types_source_types_get", "parameters": [ { - "name": "source_id", - "in": "path", - "required": true, + "name": "limit", + "in": "query", + "required": false, "schema": { - "type": "integer", - "title": "Source Id" - } + "anyOf": [ + { + "type": "integer", + "exclusiveMinimum": 0 + }, + { + "type": "null" + } + ], + "title": "Limit" + } }, { - "name": "timeout_seconds", + "name": "cursor", "in": "query", "required": false, "schema": { "anyOf": [ { - "type": "integer" + "type": "string" }, { "type": "null" } ], - "title": "Timeout Seconds" + "title": "Cursor" } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "title": "Body" - } - } - } - }, "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/JobModel" + "type": "object", + "additionalProperties": true, + "title": "Response List Source Types Source Types Get" } } } @@ -805,21 +1004,21 @@ } } }, - "/configs/{key}": { + "/source-types/{type_}": { "get": { "tags": [ - "config" + "source" ], - "summary": "Get Config", - "operationId": "get_config_configs__key__get", + "summary": "Get Source Type", + "operationId": "get_source_type_source_types__type___get", "parameters": [ { - "name": "key", + "name": "type_", "in": "path", "required": true, "schema": { "type": "string", - "title": "Key" + "title": "Type " } } ], @@ -829,7 +1028,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeploymentConfigView" + "$ref": "#/components/schemas/SourceTypesModel" } } } @@ -845,41 +1044,59 @@ } } } - }, - "put": { + } + }, + "/sources": { + "get": { "tags": [ - "config" + "source" ], - "summary": "Replace Config", - "operationId": "replace_config_configs__key__put", + "summary": "List Sources", + "operationId": "list_sources_sources_get", "parameters": [ { - "name": "key", - "in": "path", - "required": true, + "name": "limit", + "in": "query", + "required": false, "schema": { - "type": "string", - "title": "Key" + "anyOf": [ + { + "type": "integer", + "exclusiveMinimum": 0 + }, + { + "type": "null" + } + ], + "title": "Limit" } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeploymentConfigReplaceForm" - } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Cursor" } } - }, + ], "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeploymentConfigView" + "type": "object", + "additionalProperties": true, + "title": "Response List Sources Sources Get" } } } @@ -896,42 +1113,29 @@ } } }, - "patch": { + "post": { "tags": [ - "config" - ], - "summary": "Patch Config", - "operationId": "patch_config_configs__key__patch", - "parameters": [ - { - "name": "key", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Key" - } - } + "source" ], + "summary": "Create Source", + "operationId": "create_source_sources_post", "requestBody": { "required": true, "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": true, - "title": "Body" + "$ref": "#/components/schemas/SourceCreateForm" } } } }, "responses": { - "200": { + "201": { "description": "Successful Response", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeploymentConfigView" + "$ref": "#/components/schemas/SourceModel" } } } @@ -949,30 +1153,31 @@ } } }, - "/graph": { - "put": { + "/sources/{source_id}": { + "get": { "tags": [ - "info-base" + "source" ], - "summary": "Submit Graph", - "operationId": "submit_graph_graph_put", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GraphForm" - } + "summary": "Get Source", + "operationId": "get_source_sources__source_id__get", + "parameters": [ + { + "name": "source_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Source Id" } - }, - "required": true - }, + } + ], "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SubmitGraphResult" + "$ref": "#/components/schemas/SourceModel" } } } @@ -988,28 +1193,44 @@ } } } - } - }, - "/organization/ruminate": { - "post": { + }, + "patch": { "tags": [ - "organization" + "source" + ], + "summary": "Update Source", + "operationId": "update_source_sources__source_id__patch", + "parameters": [ + { + "name": "source_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Source Id" + } + } ], - "summary": "Ruminate", - "operationId": "ruminate_organization_ruminate_post", "requestBody": { + "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RuminationRequest" + "$ref": "#/components/schemas/SourceUpdateForm" } } - }, - "required": true + } }, "responses": { - "204": { - "description": "Successful Response" + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SourceModel" + } + } + } }, "422": { "description": "Validation Error", @@ -1022,35 +1243,27 @@ } } } - } - }, - "/semantic-retrieval": { - "post": { + }, + "delete": { "tags": [ - "semantic-retrieval" + "source" ], - "summary": "Retrieve", - "operationId": "retrieve_semantic_retrieval_post", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SemanticRetrievalRequest" - } + "summary": "Delete Source", + "operationId": "delete_source_sources__source_id__delete", + "parameters": [ + { + "name": "source_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Source Id" } - }, - "required": true - }, + } + ], "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SemanticRetrievalResult" - } - } - } + "204": { + "description": "Successful Response" }, "422": { "description": "Validation Error", @@ -1065,30 +1278,59 @@ } } }, - "/lexical-retrieval": { + "/sources/{source_id}/collect": { "post": { "tags": [ - "lexical-retrieval" + "source" + ], + "summary": "Run Source Collect", + "operationId": "run_source_collect_sources__source_id__collect_post", + "parameters": [ + { + "name": "source_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Source Id" + } + }, + { + "name": "timeout_seconds", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer", + "exclusiveMinimum": 0 + }, + { + "type": "null" + } + ], + "title": "Timeout Seconds" + } + } ], - "summary": "Retrieve", - "operationId": "retrieve_lexical_retrieval_post", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LexicalRetrievalRequest" + "type": "object", + "additionalProperties": true, + "title": "Body" } } - }, - "required": true + } }, "responses": { - "200": { + "201": { "description": "Successful Response", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LexicalRetrievalResult" + "$ref": "#/components/schemas/JobModel" } } } @@ -1106,70 +1348,52 @@ } } }, - "/sink-types": { - "get": { - "tags": [ - "sink" - ], - "summary": "List Sink Types", - "operationId": "list_sink_types_sink_types_get", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/SinkTypeModel" - }, - "type": "array", - "title": "Response List Sink Types Sink Types Get" - } - } - } - } - } - } - }, - "/sinks": { - "get": { + "/sources/{source_id}/backfill": { + "post": { "tags": [ - "sink" + "source" ], - "summary": "List Sinks", - "operationId": "list_sinks_sinks_get", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/SinkModel" - }, - "type": "array", - "title": "Response List Sinks Sinks Get" + "summary": "Run Source Backfill", + "operationId": "run_source_backfill_sources__source_id__backfill_post", + "parameters": [ + { + "name": "source_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Source Id" + } + }, + { + "name": "timeout_seconds", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer", + "exclusiveMinimum": 0 + }, + { + "type": "null" } - } + ], + "title": "Timeout Seconds" } } - } - }, - "post": { - "tags": [ - "sink" ], - "summary": "Create Sink", - "operationId": "create_sink_sinks_post", "requestBody": { + "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SinkCreateForm" + "type": "object", + "additionalProperties": true, + "title": "Body" } } - }, - "required": true + } }, "responses": { "201": { @@ -1177,7 +1401,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SinkModel" + "$ref": "#/components/schemas/JobModel" } } } @@ -1195,21 +1419,45 @@ } } }, - "/sinks/{sink_id}": { + "/job-types": { "get": { "tags": [ - "sink" + "job" ], - "summary": "Get Sink", - "operationId": "get_sink_sinks__sink_id__get", + "summary": "List Job Types", + "operationId": "list_job_types_job_types_get", "parameters": [ { - "name": "sink_id", - "in": "path", - "required": true, + "name": "limit", + "in": "query", + "required": false, "schema": { - "type": "integer", - "title": "Sink Id" + "anyOf": [ + { + "type": "integer", + "exclusiveMinimum": 0 + }, + { + "type": "null" + } + ], + "title": "Limit" + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cursor" } } ], @@ -1219,7 +1467,9 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SinkModel" + "type": "object", + "additionalProperties": true, + "title": "Response List Job Types Job Types Get" } } } @@ -1235,27 +1485,36 @@ } } } - }, - "delete": { + } + }, + "/job-types/{type_}": { + "get": { "tags": [ - "sink" + "job" ], - "summary": "Delete Sink", - "operationId": "delete_sink_sinks__sink_id__delete", + "summary": "Get Job Type", + "operationId": "get_job_type_job_types__type___get", "parameters": [ { - "name": "sink_id", + "name": "type_", "in": "path", "required": true, "schema": { - "type": "integer", - "title": "Sink Id" + "type": "string", + "title": "Type " } } ], "responses": { - "204": { - "description": "Successful Response" + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobTypeModel" + } + } + } }, "422": { "description": "Validation Error", @@ -1270,43 +1529,122 @@ } } }, - "/sinks/{sink_id}/config": { - "put": { + "/jobs": { + "get": { "tags": [ - "sink" + "job" ], - "summary": "Update Sink Config", - "operationId": "update_sink_config_sinks__sink_id__config_put", + "summary": "List Jobs", + "operationId": "list_jobs_jobs_get", "parameters": [ { - "name": "sink_id", - "in": "path", - "required": true, + "name": "limit", + "in": "query", + "required": false, "schema": { "type": "integer", - "title": "Sink Id" + "exclusiveMinimum": 0, + "default": 20, + "title": "Limit" + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Cursor" + } + }, + { + "name": "type", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Type" + } + }, + { + "name": "status", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/JobStatus" + }, + { + "type": "null" + } + ], + "title": "Status" } } ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response List Jobs Jobs Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "post": { + "tags": [ + "job" + ], + "summary": "Create Job", + "operationId": "create_job_jobs_post", "requestBody": { "required": true, "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": true, - "title": "Body" + "$ref": "#/components/schemas/JobCreateForm" } } } }, "responses": { - "200": { + "201": { "description": "Successful Response", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SinkModel" + "$ref": "#/components/schemas/JobModel" } } } @@ -1324,21 +1662,21 @@ } } }, - "/sinks/{sink_id}/enable": { - "post": { + "/jobs/{job_id}": { + "get": { "tags": [ - "sink" + "job" ], - "summary": "Enable Sink", - "operationId": "enable_sink_sinks__sink_id__enable_post", + "summary": "Get Job", + "operationId": "get_job_jobs__job_id__get", "parameters": [ { - "name": "sink_id", + "name": "job_id", "in": "path", "required": true, "schema": { "type": "integer", - "title": "Sink Id" + "title": "Job Id" } } ], @@ -1348,7 +1686,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SinkModel" + "$ref": "#/components/schemas/JobModel" } } } @@ -1366,21 +1704,21 @@ } } }, - "/sinks/{sink_id}/disable": { + "/jobs/{job_id}/abort": { "post": { "tags": [ - "sink" + "job" ], - "summary": "Disable Sink", - "operationId": "disable_sink_sinks__sink_id__disable_post", + "summary": "Abort Job", + "operationId": "abort_job_jobs__job_id__abort_post", "parameters": [ { - "name": "sink_id", + "name": "job_id", "in": "path", "required": true, "schema": { "type": "integer", - "title": "Sink Id" + "title": "Job Id" } } ], @@ -1390,7 +1728,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SinkModel" + "$ref": "#/components/schemas/JobModel" } } } @@ -1407,44 +1745,2958 @@ } } } - } - }, - "components": { - "schemas": { - "BlockForm": { - "properties": { - "storage": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" + }, + "/crons": { + "get": { + "tags": [ + "cron" + ], + "summary": "List Crons", + "operationId": "list_crons_crons_get", + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer", + "exclusiveMinimum": 0 + }, + { + "type": "null" + } + ], + "title": "Limit" + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Cursor" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response List Crons Crons Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "post": { + "tags": [ + "cron" + ], + "summary": "Create Cron", + "operationId": "create_cron_crons_post", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CronForm" + } + } + } + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CronModel" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/crons/{cron_id}": { + "get": { + "tags": [ + "cron" + ], + "summary": "Get Cron", + "operationId": "get_cron_crons__cron_id__get", + "parameters": [ + { + "name": "cron_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Cron Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CronModel" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "patch": { + "tags": [ + "cron" + ], + "summary": "Update Cron", + "operationId": "update_cron_crons__cron_id__patch", + "parameters": [ + { + "name": "cron_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Cron Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CronUpdateForm" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CronModel" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "cron" + ], + "summary": "Delete Cron", + "operationId": "delete_cron_crons__cron_id__delete", + "parameters": [ + { + "name": "cron_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Cron Id" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/crons/{cron_id}/run": { + "post": { + "tags": [ + "cron" + ], + "summary": "Run Cron", + "operationId": "run_cron_crons__cron_id__run_post", + "parameters": [ + { + "name": "cron_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Cron Id" + } + } + ], + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobModel" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/agents": { + "get": { + "tags": [ + "agent" + ], + "summary": "List Agents", + "operationId": "list_agents_agents_get", + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer", + "exclusiveMinimum": 0 + }, + { + "type": "null" + } + ], + "title": "Limit" + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Cursor" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response List Agents Agents Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "post": { + "tags": [ + "agent" + ], + "summary": "Create Agent", + "operationId": "create_agent_agents_post", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentForm" + } + } + } + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentDefinitionModel" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/agents/{agent_id}": { + "get": { + "tags": [ + "agent" + ], + "summary": "Get Agent", + "operationId": "get_agent_agents__agent_id__get", + "parameters": [ + { + "name": "agent_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Agent Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentDefinitionModel" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "patch": { + "tags": [ + "agent" + ], + "summary": "Update Agent", + "operationId": "update_agent_agents__agent_id__patch", + "parameters": [ + { + "name": "agent_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Agent Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentUpdateForm" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentDefinitionModel" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "agent" + ], + "summary": "Delete Agent", + "operationId": "delete_agent_agents__agent_id__delete", + "parameters": [ + { + "name": "agent_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Agent Id" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/agent-tools": { + "get": { + "tags": [ + "agent" + ], + "summary": "List Agent Tools", + "operationId": "list_agent_tools_agent_tools_get", + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer", + "exclusiveMinimum": 0 + }, + { + "type": "null" + } + ], + "title": "Limit" + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cursor" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response List Agent Tools Agent Tools Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/agent-tools/{tool_id}": { + "get": { + "tags": [ + "agent" + ], + "summary": "Get Agent Tool", + "operationId": "get_agent_tool_agent_tools__tool_id__get", + "parameters": [ + { + "name": "tool_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Tool Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FunctionTool" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/ai/models": { + "get": { + "tags": [ + "ai" + ], + "summary": "List Ai Models", + "operationId": "list_ai_models_ai_models_get", + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer", + "exclusiveMinimum": 0 + }, + { + "type": "null" + } + ], + "title": "Limit" + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Cursor" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response List Ai Models Ai Models Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/ai/models/{model_id}": { + "get": { + "tags": [ + "ai" + ], + "summary": "Get Ai Model", + "operationId": "get_ai_model_ai_models__model_id__get", + "parameters": [ + { + "name": "model_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Model Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AIModelModel" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/entities/get": { + "post": { + "tags": [ + "info-base" + ], + "summary": "Get Entities", + "operationId": "get_entities_entities_get_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EntitiesGetForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + }, + "application/octet-stream": {}, + "multipart/related": {} + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/resolvers": { + "get": { + "tags": [ + "resolver" + ], + "summary": "List Resolvers", + "operationId": "list_resolvers_resolvers_get", + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer", + "exclusiveMinimum": 0 + }, + { + "type": "null" + } + ], + "title": "Limit" + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cursor" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response List Resolvers Resolvers Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/resolvers/{resolver_id}/methods": { + "get": { + "tags": [ + "resolver" + ], + "summary": "Resolver Methods", + "operationId": "resolver_methods_resolvers__resolver_id__methods_get", + "parameters": [ + { + "name": "resolver_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Resolver Id" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer", + "exclusiveMinimum": 0 + }, + { + "type": "null" + } + ], + "title": "Limit" + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cursor" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Resolver Methods Resolvers Resolver Id Methods Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/blocks/{block_id}/resolver/methods": { + "get": { + "tags": [ + "resolver" + ], + "summary": "Block Resolver Methods", + "operationId": "block_resolver_methods_blocks__block_id__resolver_methods_get", + "parameters": [ + { + "name": "block_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Block Id" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer", + "exclusiveMinimum": 0 + }, + { + "type": "null" + } + ], + "title": "Limit" + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cursor" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Block Resolver Methods Blocks Block Id Resolver Methods Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/blocks/{block_id}/resolver/methods/{method}": { + "post": { + "tags": [ + "resolver" + ], + "summary": "Invoke Resolver Method", + "operationId": "invoke_resolver_method_blocks__block_id__resolver_methods__method__post", + "parameters": [ + { + "name": "block_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Block Id" + } + }, + { + "name": "method", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Method" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Body" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + }, + "application/octet-stream": {}, + "multipart/related": {} + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/blocks/{block_id}/neighborhood": { + "get": { + "tags": [ + "graph-navigation" + ], + "summary": "Block Neighborhood", + "operationId": "block_neighborhood_blocks__block_id__neighborhood_get", + "parameters": [ + { + "name": "block_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Block Id" + } + }, + { + "name": "direction", + "in": "query", + "required": false, + "schema": { + "enum": [ + "in", + "out", + "both" + ], + "type": "string", + "default": "both", + "title": "Direction" + } + }, + { + "name": "contents", + "in": "query", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "title": "Contents" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 100, + "minimum": 1, + "default": 20, + "title": "Limit" + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Cursor" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Block Neighborhood Blocks Block Id Neighborhood Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/relations/{relation_id}/neighborhood": { + "get": { + "tags": [ + "graph-navigation" + ], + "summary": "Relation Neighborhood", + "operationId": "relation_neighborhood_relations__relation_id__neighborhood_get", + "parameters": [ + { + "name": "relation_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Relation Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Relation Neighborhood Relations Relation Id Neighborhood Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/graph/path": { + "post": { + "tags": [ + "graph-navigation" + ], + "summary": "Graph Path", + "operationId": "graph_path_graph_path_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GraphPathForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Graph Path Graph Path Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/graph/components": { + "post": { + "tags": [ + "graph-navigation" + ], + "summary": "Graph Components", + "operationId": "graph_components_graph_components_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GraphComponentsForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Graph Components Graph Components Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/retrieval/lexical": { + "post": { + "tags": [ + "retrieval" + ], + "summary": "Retrieve Lexical", + "operationId": "retrieve_lexical_retrieval_lexical_post", + "parameters": [ + { + "name": "route_to_peer", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "Route To Peer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LexicalRetrievalRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LexicalRetrievalResult" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/retrieval/semantic": { + "post": { + "tags": [ + "retrieval" + ], + "summary": "Retrieve Semantic", + "operationId": "retrieve_semantic_retrieval_semantic_post", + "parameters": [ + { + "name": "route_to_peer", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "Route To Peer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SemanticRetrievalRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Retrieve Semantic Retrieval Semantic Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/embedding-profiles": { + "get": { + "tags": [ + "retrieval" + ], + "summary": "List Embedding Profiles", + "operationId": "list_embedding_profiles_embedding_profiles_get", + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer", + "exclusiveMinimum": 0 + }, + { + "type": "null" + } + ], + "title": "Limit" + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Cursor" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response List Embedding Profiles Embedding Profiles Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/embedding-profiles/{profile_id}": { + "get": { + "tags": [ + "retrieval" + ], + "summary": "Get Embedding Profile", + "operationId": "get_embedding_profile_embedding_profiles__profile_id__get", + "parameters": [ + { + "name": "profile_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Profile Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EmbeddingProfileModel" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/peers": { + "get": { + "tags": [ + "peer" + ], + "summary": "List Peers", + "operationId": "list_peers_peers_get", + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer", + "exclusiveMinimum": 0 + }, + { + "type": "null" + } + ], + "title": "Limit" + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "Cursor" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response List Peers Peers Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/peers/self": { + "get": { + "tags": [ + "peer" + ], + "summary": "Get Self Peer", + "operationId": "get_self_peer_peers_self_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Get Self Peer Peers Self Get" + } + } + } + } + } + } + }, + "/peers/{peer_id}": { + "get": { + "tags": [ + "peer" + ], + "summary": "Get Peer", + "operationId": "get_peer_peers__peer_id__get", + "parameters": [ + { + "name": "peer_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Peer Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Get Peer Peers Peer Id Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/configs": { + "get": { + "tags": [ + "config" + ], + "summary": "List Configs", + "operationId": "list_configs_configs_get", + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer", + "exclusiveMinimum": 0 + }, + { + "type": "null" + } + ], + "title": "Limit" + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cursor" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response List Configs Configs Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/config-schemas": { + "get": { + "tags": [ + "config" + ], + "summary": "List Config Schemas", + "operationId": "list_config_schemas_config_schemas_get", + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer", + "exclusiveMinimum": 0 + }, + { + "type": "null" + } + ], + "title": "Limit" + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cursor" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response List Config Schemas Config Schemas Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/config-schemas/{schema_id}": { + "get": { + "tags": [ + "config" + ], + "summary": "Get Config Schema", + "operationId": "get_config_schema_config_schemas__schema_id__get", + "parameters": [ + { + "name": "schema_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Schema Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Get Config Schema Config Schemas Schema Id Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/configs/{key}": { + "get": { + "tags": [ + "config" + ], + "summary": "Get Config", + "operationId": "get_config_configs__key__get", + "parameters": [ + { + "name": "key", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Key" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeploymentConfigView" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "put": { + "tags": [ + "config" + ], + "summary": "Replace Config", + "operationId": "replace_config_configs__key__put", + "parameters": [ + { + "name": "key", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Key" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeploymentConfigReplaceForm" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeploymentConfigView" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "patch": { + "tags": [ + "config" + ], + "summary": "Patch Config", + "operationId": "patch_config_configs__key__patch", + "parameters": [ + { + "name": "key", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Key" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Body" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeploymentConfigView" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "config" + ], + "summary": "Delete Config", + "operationId": "delete_config_configs__key__delete", + "parameters": [ + { + "name": "key", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Key" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/graph": { + "post": { + "tags": [ + "info-base" + ], + "summary": "Submit Graph", + "operationId": "submit_graph_graph_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GraphForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubmitGraphResult" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/organization/ruminate": { + "post": { + "tags": [ + "organization" + ], + "summary": "Ruminate", + "operationId": "ruminate_organization_ruminate_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuminationRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/semantic-retrieval": { + "post": { + "tags": [ + "semantic-retrieval" + ], + "summary": "Retrieve", + "operationId": "retrieve_semantic_retrieval_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SemanticRetrievalRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SemanticRetrievalResult" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/lexical-retrieval": { + "post": { + "tags": [ + "lexical-retrieval" + ], + "summary": "Retrieve", + "operationId": "retrieve_lexical_retrieval_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LexicalRetrievalRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LexicalRetrievalResult" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/sink-types": { + "get": { + "tags": [ + "sink" + ], + "summary": "List Sink Types", + "operationId": "list_sink_types_sink_types_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/SinkTypeModel" + }, + "type": "array", + "title": "Response List Sink Types Sink Types Get" + } + } + } + } + } + } + }, + "/sinks": { + "get": { + "tags": [ + "sink" + ], + "summary": "List Sinks", + "operationId": "list_sinks_sinks_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/SinkModel" + }, + "type": "array", + "title": "Response List Sinks Sinks Get" + } + } + } + } + } + }, + "post": { + "tags": [ + "sink" + ], + "summary": "Create Sink", + "operationId": "create_sink_sinks_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SinkCreateForm" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SinkModel" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/sinks/{sink_id}": { + "get": { + "tags": [ + "sink" + ], + "summary": "Get Sink", + "operationId": "get_sink_sinks__sink_id__get", + "parameters": [ + { + "name": "sink_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Sink Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SinkModel" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "sink" + ], + "summary": "Delete Sink", + "operationId": "delete_sink_sinks__sink_id__delete", + "parameters": [ + { + "name": "sink_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Sink Id" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/sinks/{sink_id}/config": { + "put": { + "tags": [ + "sink" + ], + "summary": "Update Sink Config", + "operationId": "update_sink_config_sinks__sink_id__config_put", + "parameters": [ + { + "name": "sink_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Sink Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Body" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SinkModel" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/sinks/{sink_id}/enable": { + "post": { + "tags": [ + "sink" + ], + "summary": "Enable Sink", + "operationId": "enable_sink_sinks__sink_id__enable_post", + "parameters": [ + { + "name": "sink_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Sink Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SinkModel" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/sinks/{sink_id}/disable": { + "post": { + "tags": [ + "sink" + ], + "summary": "Disable Sink", + "operationId": "disable_sink_sinks__sink_id__disable_post", + "parameters": [ + { + "name": "sink_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Sink Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SinkModel" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "AIModelModel": { + "properties": { + "id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Id" + }, + "provider": { + "type": "integer", + "title": "Provider" + }, + "native_model_id": { + "type": "string", + "title": "Native Model Id" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "capabilities": { + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/EmbeddingCapability" + }, + { + "$ref": "#/components/schemas/ChatCapability" + } + ], + "discriminator": { + "propertyName": "type", + "mapping": { + "chat": "#/components/schemas/ChatCapability", + "embedding": "#/components/schemas/EmbeddingCapability" + } + } + }, + "type": "array", + "title": "Capabilities", + "default": [] + }, + "enabled": { + "type": "boolean", + "title": "Enabled", + "default": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "title": "Updated At" + } + }, + "type": "object", + "required": [ + "provider", + "native_model_id" + ], + "title": "AIModelModel", + "description": "One provider-bound native model offering and its effective capabilities." + }, + "AgentDefinitionModel": { + "properties": { + "id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "system_prompt": { + "type": "string", + "title": "System Prompt" + }, + "tools": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Tools", + "default": [] + }, + "tool_choice": { + "anyOf": [ + { + "type": "string", + "enum": [ + "none", + "auto", + "required" + ] + }, + { + "$ref": "#/components/schemas/NamedToolChoice" + }, + { + "type": "null" + } + ], + "title": "Tool Choice" + }, + "model": { + "type": "integer", + "title": "Model" + }, + "max_model_calls_per_turn": { + "type": "integer", + "title": "Max Model Calls Per Turn" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "title": "Updated At" + } + }, + "type": "object", + "required": [ + "name", + "system_prompt", + "model", + "max_model_calls_per_turn" + ], + "title": "AgentDefinitionModel", + "description": "One reusable system-prompt, model, Tool and turn-budget composition." + }, + "AgentForm": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "system_prompt": { + "type": "string", + "title": "System Prompt" + }, + "model": { + "type": "integer", + "title": "Model" + }, + "tools": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Tools", + "default": [] + }, + "tool_choice": { + "anyOf": [ + { + "type": "string", + "enum": [ + "none", + "auto", + "required" + ] + }, + { + "$ref": "#/components/schemas/NamedToolChoice" + }, + { + "type": "null" + } + ], + "title": "Tool Choice" + }, + "max_model_calls_per_turn": { + "type": "integer", + "exclusiveMinimum": 0.0, + "title": "Max Model Calls Per Turn" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "name", + "system_prompt", + "model", + "max_model_calls_per_turn" + ], + "title": "AgentForm", + "description": "Editable Agent definition, without execution or database-managed fields." + }, + "AgentUpdateForm": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "system_prompt": { + "type": "string", + "title": "System Prompt" + }, + "model": { + "type": "integer", + "title": "Model" + }, + "tools": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Tools", + "default": [] + }, + "tool_choice": { + "anyOf": [ + { + "type": "string", + "enum": [ + "none", + "auto", + "required" + ] + }, + { + "$ref": "#/components/schemas/NamedToolChoice" + }, + { + "type": "null" + } + ], + "title": "Tool Choice" + }, + "max_model_calls_per_turn": { + "type": "integer", + "exclusiveMinimum": 0.0, + "title": "Max Model Calls Per Turn" + } + }, + "additionalProperties": false, + "type": "object", + "title": "AgentUpdateForm", + "description": "PATCH omission preserves a field; only tool_choice accepts explicit null." + }, + "BlockForm": { + "properties": { + "storage": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Storage" + }, + "resolver": { + "type": "string", + "title": "Resolver" + }, + "content": { + "type": "string", + "title": "Content" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "resolver", + "content" + ], + "title": "BlockForm", + "description": "Producer-owned values for creating one Block." + }, + "BlockModel": { + "properties": { + "storage": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Storage" + }, + "resolver": { + "type": "string", + "title": "Resolver" + }, + "content": { + "type": "string", + "title": "Content" + }, + "id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Id" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "title": "Updated At" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "resolver", + "content" + ], + "title": "BlockModel" + }, + "BlockSemanticRetrievalMatch": { + "properties": { + "type": { + "type": "string", + "const": "block", + "title": "Type", + "default": "block" + }, + "entity": { + "$ref": "#/components/schemas/BlockModel" + }, + "score": { + "type": "number", + "maximum": 1.0, + "minimum": -1.0, + "title": "Score" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "entity", + "score" + ], + "title": "BlockSemanticRetrievalMatch" + }, + "BlockUpdateForm": { + "properties": { + "content": { + "type": "string", + "title": "Content" + }, + "resolver": { + "type": "string", + "title": "Resolver" + }, + "storage": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" } ], "title": "Storage" + } + }, + "additionalProperties": false, + "type": "object", + "title": "BlockUpdateForm" + }, + "ChatCapability": { + "properties": { + "type": { + "type": "string", + "const": "chat", + "title": "Type", + "default": "chat" + }, + "input_modalities": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Input Modalities" + }, + "output_modalities": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Output Modalities" + }, + "features": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Features", + "default": [] + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "input_modalities", + "output_modalities" + ], + "title": "ChatCapability" + }, + "CronForm": { + "properties": { + "schedule": { + "type": "string", + "minLength": 1, + "title": "Schedule" + }, + "enabled": { + "type": "boolean", + "title": "Enabled", + "default": true }, - "resolver": { + "job_type": { "type": "string", - "title": "Resolver" + "title": "Job Type" }, - "content": { - "type": "string", - "title": "Content" + "job_parameters": { + "additionalProperties": true, + "type": "object", + "title": "Job Parameters" + }, + "job_timeout_seconds": { + "anyOf": [ + { + "type": "integer", + "exclusiveMinimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Job Timeout Seconds" } }, "additionalProperties": false, "type": "object", "required": [ - "resolver", - "content" + "schedule", + "job_type" ], - "title": "BlockForm", - "description": "Producer-owned values for creating one Block." + "title": "CronForm" }, - "BlockModel": { + "CronModel": { "properties": { - "storage": { + "id": { "anyOf": [ { "type": "integer" @@ -1453,17 +4705,40 @@ "type": "null" } ], - "title": "Storage" + "title": "Id" }, - "resolver": { + "schedule": { "type": "string", - "title": "Resolver" + "minLength": 1, + "title": "Schedule" }, - "content": { + "enabled": { + "type": "boolean", + "title": "Enabled", + "default": true + }, + "job_type": { "type": "string", - "title": "Content" + "title": "Job Type" }, - "id": { + "job_parameters": { + "additionalProperties": true, + "type": "object", + "title": "Job Parameters" + }, + "job_timeout_seconds": { + "anyOf": [ + { + "type": "integer", + "exclusiveMinimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Job Timeout Seconds" + }, + "last_job": { "anyOf": [ { "type": "integer" @@ -1472,7 +4747,19 @@ "type": "null" } ], - "title": "Id" + "title": "Last Job" + }, + "last_scheduled_for": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Last Scheduled For" }, "created_at": { "type": "string", @@ -1485,39 +4772,51 @@ "title": "Updated At" } }, - "additionalProperties": false, "type": "object", "required": [ - "resolver", - "content" + "schedule", + "job_type" ], - "title": "BlockModel" + "title": "CronModel", + "description": "One five-field UNIX schedule that materializes typed Jobs." }, - "BlockSemanticRetrievalMatch": { + "CronUpdateForm": { "properties": { - "type": { + "schedule": { "type": "string", - "const": "block", - "title": "Type", - "default": "block" + "minLength": 1, + "title": "Schedule" }, - "entity": { - "$ref": "#/components/schemas/BlockModel" + "enabled": { + "type": "boolean", + "title": "Enabled" }, - "score": { - "type": "number", - "maximum": 1.0, - "minimum": -1.0, - "title": "Score" + "job_type": { + "type": "string", + "title": "Job Type" + }, + "job_parameters": { + "additionalProperties": true, + "type": "object", + "title": "Job Parameters" + }, + "job_timeout_seconds": { + "anyOf": [ + { + "type": "integer", + "exclusiveMinimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Job Timeout Seconds" } }, "additionalProperties": false, "type": "object", - "required": [ - "entity", - "score" - ], - "title": "BlockSemanticRetrievalMatch" + "title": "CronUpdateForm", + "description": "Optional submitted fields; null only clears the timeout override." }, "DeploymentConfigReplaceForm": { "properties": { @@ -1574,7 +4873,176 @@ "updated_at" ], "title": "DeploymentConfigView", - "description": "Validated deployment config projection exposed by the HTTP resource." + "description": "Stored deployment config and metadata; reading does not revalidate value." + }, + "EmbeddingCapability": { + "properties": { + "type": { + "type": "string", + "const": "embedding", + "title": "Type", + "default": "embedding" + }, + "input_modalities": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Input Modalities" + }, + "output_modalities": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Output Modalities" + }, + "features": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Features", + "default": [] + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "input_modalities", + "output_modalities" + ], + "title": "EmbeddingCapability" + }, + "EmbeddingProfileModel": { + "properties": { + "id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Id" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "ai_model": { + "type": "integer", + "title": "Ai Model" + }, + "dimensions": { + "type": "integer", + "title": "Dimensions" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "title": "Updated At" + } + }, + "type": "object", + "required": [ + "ai_model", + "dimensions" + ], + "title": "EmbeddingProfileModel", + "description": "One durable vector-space compatibility contract." + }, + "EntitiesGetForm": { + "properties": { + "entities": { + "items": { + "$ref": "#/components/schemas/EntityReference" + }, + "type": "array", + "title": "Entities" + }, + "content": { + "type": "string", + "enum": [ + "none", + "raw", + "hydrated" + ], + "title": "Content", + "default": "raw" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "entities" + ], + "title": "EntitiesGetForm" + }, + "EntityReference": { + "properties": { + "type": { + "type": "string", + "enum": [ + "block", + "relation" + ], + "title": "Type" + }, + "id": { + "type": "integer", + "title": "Id" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "type", + "id" + ], + "title": "EntityReference" + }, + "FunctionTool": { + "properties": { + "id": { + "type": "string", + "minLength": 1, + "title": "Id" + }, + "description": { + "type": "string", + "title": "Description" + }, + "input_schema": { + "additionalProperties": { + "$ref": "#/components/schemas/JsonValue" + }, + "type": "object", + "title": "Input Schema" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "id", + "description", + "input_schema" + ], + "title": "FunctionTool", + "description": "One caller-owned function Tool exposed to an AI model." }, "GraphBlockForm": { "properties": { @@ -1633,6 +5101,44 @@ "title": "GraphBlockIDMapping", "description": "Map one command-local Block identity to its persisted identity." }, + "GraphComponentsForm": { + "properties": { + "seed_block_ids": { + "items": { + "type": "integer" + }, + "type": "array", + "title": "Seed Block Ids" + }, + "contents": { + "items": { + "type": "string" + }, + "type": "array", + "minItems": 1, + "title": "Contents" + }, + "max_explored_blocks": { + "type": "integer", + "minimum": 1.0, + "title": "Max Explored Blocks", + "default": 1000 + }, + "max_explored_relations": { + "type": "integer", + "minimum": 1.0, + "title": "Max Explored Relations", + "default": 10000 + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "seed_block_ids", + "contents" + ], + "title": "GraphComponentsForm" + }, "GraphForm": { "properties": { "blocks": { @@ -1657,6 +5163,57 @@ "title": "GraphForm", "description": "Flat command for adding arbitrarily connected Blocks and Relations." }, + "GraphPathForm": { + "properties": { + "from_block_id": { + "type": "integer", + "title": "From Block Id" + }, + "to_block_id": { + "type": "integer", + "title": "To Block Id" + }, + "direction": { + "type": "string", + "enum": [ + "in", + "out", + "both" + ], + "title": "Direction", + "default": "both" + }, + "contents": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Contents", + "default": [] + }, + "max_hops": { + "type": "integer", + "maximum": 8.0, + "minimum": 0.0, + "title": "Max Hops", + "default": 4 + }, + "max_explored_blocks": { + "type": "integer", + "maximum": 10000.0, + "minimum": 1.0, + "title": "Max Explored Blocks", + "default": 1000 + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "from_block_id", + "to_block_id" + ], + "title": "GraphPathForm" + }, "GraphRelationForm": { "properties": { "content": { @@ -1751,6 +5308,37 @@ "title": "InstalledExtension", "description": "Stable Host-facing projection; SQLModel and table details remain private." }, + "JobCreateForm": { + "properties": { + "type": { + "type": "string", + "title": "Type" + }, + "parameters": { + "additionalProperties": true, + "type": "object", + "title": "Parameters" + }, + "timeout_seconds": { + "anyOf": [ + { + "type": "integer", + "exclusiveMinimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Timeout Seconds" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "type" + ], + "title": "JobCreateForm" + }, "JobModel": { "properties": { "id": { @@ -1778,6 +5366,11 @@ "type": "object", "title": "State" }, + "abort_requested": { + "type": "boolean", + "title": "Abort Requested", + "default": false + }, "timeout_seconds": { "type": "integer", "exclusiveMinimum": 0.0, @@ -1837,6 +5430,37 @@ ], "title": "JobStatus" }, + "JobTypeModel": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "description": { + "type": "string", + "title": "Description" + }, + "parameters_schema": { + "additionalProperties": true, + "type": "object", + "title": "Parameters Schema" + }, + "default_timeout_seconds": { + "type": "integer", + "exclusiveMinimum": 0.0, + "title": "Default Timeout Seconds" + } + }, + "type": "object", + "required": [ + "id", + "description", + "default_timeout_seconds" + ], + "title": "JobTypeModel", + "description": "Database projection of one exact runtime Job Handler contract." + }, + "JsonValue": {}, "LexicalRetrievalMatch": { "properties": { "block": { @@ -1915,6 +5539,28 @@ ], "title": "LexicalRetrievalResult" }, + "NamedToolChoice": { + "properties": { + "type": { + "type": "string", + "const": "function", + "title": "Type", + "default": "function" + }, + "tool": { + "type": "string", + "minLength": 1, + "title": "Tool" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "tool" + ], + "title": "NamedToolChoice", + "description": "Require one exact caller-owned Tool." + }, "RelationCreateForm": { "properties": { "content": { @@ -2006,6 +5652,25 @@ ], "title": "RelationSemanticRetrievalMatch" }, + "RelationUpdateForm": { + "properties": { + "from_block_id": { + "type": "integer", + "title": "From Block Id" + }, + "to_block_id": { + "type": "integer", + "title": "To Block Id" + }, + "content": { + "type": "string", + "title": "Content" + } + }, + "additionalProperties": false, + "type": "object", + "title": "RelationUpdateForm" + }, "RuminationRequest": { "properties": { "block": { @@ -2192,6 +5857,199 @@ ], "title": "SinkTypeModel" }, + "SourceCreateForm": { + "properties": { + "type": { + "type": "string", + "title": "Type" + }, + "nickname": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Nickname" + }, + "config": { + "additionalProperties": true, + "type": "object", + "title": "Config" + }, + "storage": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Storage" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "type" + ], + "title": "SourceCreateForm" + }, + "SourceModel": { + "properties": { + "id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Id" + }, + "type": { + "type": "string", + "title": "Type" + }, + "nickname": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Nickname" + }, + "config": { + "additionalProperties": true, + "type": "object", + "title": "Config" + }, + "storage": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Storage" + }, + "block": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Block" + }, + "state": { + "additionalProperties": true, + "type": "object", + "title": "State" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "title": "Updated At" + } + }, + "type": "object", + "required": [ + "type" + ], + "title": "SourceModel" + }, + "SourceTypesModel": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "description": { + "type": "string", + "title": "Description" + }, + "config_schema": { + "additionalProperties": true, + "type": "object", + "title": "Config Schema" + }, + "collect_config_schema": { + "additionalProperties": true, + "type": "object", + "title": "Collect Config Schema" + }, + "backfill_config_schema": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Backfill Config Schema" + } + }, + "type": "object", + "required": [ + "id", + "description" + ], + "title": "SourceTypesModel" + }, + "SourceUpdateForm": { + "properties": { + "nickname": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Nickname" + }, + "config": { + "additionalProperties": true, + "type": "object", + "title": "Config" + }, + "storage": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Storage" + } + }, + "additionalProperties": false, + "type": "object", + "title": "SourceUpdateForm", + "description": "Only submitted fields are written; config is replaced, not deep-merged." + }, "SubmitGraphResult": { "properties": { "blocks": { diff --git a/extensions/mail/.changes/+cli-job-control.fixed.md b/extensions/mail/.changes/+cli-job-control.fixed.md new file mode 100644 index 0000000..292be7a --- /dev/null +++ b/extensions/mail/.changes/+cli-job-control.fixed.md @@ -0,0 +1 @@ +取消收集时等待同一次 IMAP 阻塞调用结束后再释放连接,避免取消提前释放锁或并行断连;使用有限的连接和读取超时,保留已恢复的 typed 配置。 diff --git a/extensions/mail/adapter.py b/extensions/mail/adapter.py index a8203d5..43c06f2 100644 --- a/extensions/mail/adapter.py +++ b/extensions/mail/adapter.py @@ -16,6 +16,7 @@ from imapclient import IMAPClient from imapclient.exceptions import IMAPClientError +from imapclient.imapclient import SocketTimeout import pydantic from .schema import ( @@ -281,7 +282,12 @@ def __init__(self, parameters: IMAPParameters): self._lock = asyncio.Lock() async def __aenter__(self) -> "IMAPAdapter": - await self._run(self._connect) + try: + await self._run(self._connect) + except BaseException: + # Python does not call __aexit__ when entering the context fails/cancels. + await self._run(self._disconnect) + raise return self async def __aexit__(self, exc_type, exc, traceback) -> None: @@ -290,10 +296,28 @@ async def __aexit__(self, exc_type, exc, traceback) -> None: async def _run(self, function, *args): async with self._lock: + operation = asyncio.create_task(asyncio.to_thread(function, *args)) + cancellation: asyncio.CancelledError | None = None + # Cancelling to_thread's await does not stop its thread. Keep the lock and + # drain this same call before disconnect or another IMAP command can run. + while not operation.done(): + try: + await asyncio.shield(operation) + except asyncio.CancelledError as error: + cancellation = error + except Exception: + break # Retrieve the original exception below, once the call has ended. try: - return await asyncio.to_thread(function, *args) - except (IMAPClientError, OSError) as error: - raise MailAdapterError(str(error)) from error + result = operation.result() + except Exception as error: + if cancellation is not None: + raise cancellation from error + if isinstance(error, (IMAPClientError, OSError)): + raise MailAdapterError(str(error)) from error + raise + if cancellation is not None: + raise cancellation + return result def _connect(self) -> None: use_tls = self.parameters.security == "tls" @@ -301,6 +325,8 @@ def _connect(self) -> None: self.parameters.host, port=self.parameters.port, ssl=use_tls, + # IMAPClient supports its own SocketTimeout despite its narrower annotation. + timeout=SocketTimeout(connect=15, read=30), # pyrefly: ignore[bad-argument-type] ) try: if self.parameters.security == "starttls": diff --git a/extensions/mail/source.py b/extensions/mail/source.py index 1d1687c..86fc188 100644 --- a/extensions/mail/source.py +++ b/extensions/mail/source.py @@ -56,7 +56,6 @@ class Source( """Collect Mail through one configured public protocol access context.""" async def collect(self, job: JobModel, config: pydantic.BaseModel) -> None: - MailCollectConfig.model_validate(config) source, setup = self._load_effective_source() state = MailSourceState.model_validate(source.state or {}) diagnostics: list[dict[str, typing.Any]] = [] @@ -127,7 +126,7 @@ async def collect(self, job: JobModel, config: pydantic.BaseModel) -> None: ) async def backfill(self, job: JobModel, config: pydantic.BaseModel) -> None: - interval = MailBackfillConfig.model_validate(config) + interval = typing.cast(MailBackfillConfig, config) _source, setup = self._load_effective_source() state = MailSourceState.model_validate(self.get_state()) diagnostics: list[dict[str, typing.Any]] = [] diff --git a/extensions/rss/.changes/+typed-config.fixed.md b/extensions/rss/.changes/+typed-config.fixed.md new file mode 100644 index 0000000..b94abf8 --- /dev/null +++ b/extensions/rss/.changes/+typed-config.fixed.md @@ -0,0 +1 @@ +在采集路径复用已恢复的 typed config,避免重复转换和校验。 diff --git a/extensions/rss/source.py b/extensions/rss/source.py index bc8301b..2a03d6f 100644 --- a/extensions/rss/source.py +++ b/extensions/rss/source.py @@ -19,7 +19,7 @@ class FeedSourceMixin: async def collect(self, job: JobModel, config: pydantic.BaseModel) -> None: source = typing.cast(SourceBase[FeedSourceConfig], self) - collect_config = FeedCollectJobConfig.model_validate(config) + collect_config = typing.cast(FeedCollectJobConfig, config) service = FeedCollectionService(source._id, self.expected_family) state = await service.collect( source.get_config(), diff --git a/extensions/twitter/.changes/+typed-config.fixed.md b/extensions/twitter/.changes/+typed-config.fixed.md new file mode 100644 index 0000000..79d12ad --- /dev/null +++ b/extensions/twitter/.changes/+typed-config.fixed.md @@ -0,0 +1 @@ +配置与 state 已由 runtime 恢复时直接使用 typed 值,保留真正的输入边界校验。 diff --git a/extensions/twitter/__init__.py b/extensions/twitter/__init__.py index 4bbd424..ac1298b 100644 --- a/extensions/twitter/__init__.py +++ b/extensions/twitter/__init__.py @@ -96,8 +96,8 @@ def update_config( ) def update(config_model, state_model): - config = TwitterExtensionConfig.model_validate(config_model) - state = TwitterExtensionState.model_validate(state_model) + config = typing.cast(TwitterExtensionConfig, config_model) + state = typing.cast(TwitterExtensionState, state_model) if _fingerprint(validated) != _fingerprint(config): state, _ = _invalidate_mismatched_oauth_state( validated, diff --git a/extensions/twitter/api.py b/extensions/twitter/api.py index d6561b8..955b531 100644 --- a/extensions/twitter/api.py +++ b/extensions/twitter/api.py @@ -151,7 +151,7 @@ def from_extension( from .setup_flow import TwitterExtensionState, TwitterSetupConflict, _fingerprint config = Extension.get_config() - state = TwitterExtensionState.model_validate(Extension.get_state()) + state = typing.cast(TwitterExtensionState, Extension.get_state()) account = state.account if ( account is None @@ -214,7 +214,7 @@ async def _request( # noqa: PLR0913 """ from .setup_flow import TwitterExtensionState, TwitterSetupConflict, _fingerprint - latest = TwitterExtensionState.model_validate(Extension.get_state()) + latest = typing.cast(TwitterExtensionState, Extension.get_state()) latest_config = Extension.get_config() if ( latest.account is None @@ -255,7 +255,7 @@ async def update_token(token: dict[str, typing.Any], **_: typing.Any) -> None: from .setup_flow import TwitterExtensionState, TwitterSetupConflict def update(model: pydantic.BaseModel) -> pydantic.BaseModel: - state = TwitterExtensionState.model_validate(model) + state = typing.cast(TwitterExtensionState, model) if ( state.account is None or state.account.authorization_id != self.__authorization_id ): @@ -272,7 +272,7 @@ def require_reconnect() -> None: from .setup_flow import TwitterExtensionState def update(model: pydantic.BaseModel) -> pydantic.BaseModel: - state = TwitterExtensionState.model_validate(model) + state = typing.cast(TwitterExtensionState, model) if ( state.account is not None and state.account.authorization_id == self.__authorization_id diff --git a/extensions/twitter/bookmark.py b/extensions/twitter/bookmark.py index 0891891..99f65be 100644 --- a/extensions/twitter/bookmark.py +++ b/extensions/twitter/bookmark.py @@ -2,6 +2,7 @@ import pydantic import sqlmodel +import typing from app.business.info_base.main import InfoBaseManager from app.business.info_base.resolver import ImageResolver, VideoResolver, HTMLResolver from app.business.source import SourceBase @@ -97,7 +98,7 @@ async def collect(self, job: JobModel, config: pydantic.BaseModel) -> None: Docs https://docs.x.com/x-api/bookmarks/get-bookmarks """ - collect_config = CollectConfig.model_validate(config) + collect_config = typing.cast(CollectConfig, config) full = collect_config.full result_limit = collect_config.result_limit diff --git a/extensions/twitter/setup_flow.py b/extensions/twitter/setup_flow.py index e424504..e146533 100644 --- a/extensions/twitter/setup_flow.py +++ b/extensions/twitter/setup_flow.py @@ -131,7 +131,7 @@ def _extension(): def _state() -> TwitterExtensionState: - return TwitterExtensionState.model_validate(_extension().get_state()) + return typing.cast(TwitterExtensionState, _extension().get_state()) def _config() -> TwitterExtensionConfig: @@ -237,8 +237,8 @@ def _reconcile_oauth_state() -> TwitterExtensionState: def reconcile(config_model, state_model): from . import TwitterExtensionConfig - current_config = TwitterExtensionConfig.model_validate(config_model) - current_state = TwitterExtensionState.model_validate(state_model) + current_config = typing.cast(TwitterExtensionConfig, config_model) + current_state = typing.cast(TwitterExtensionState, state_model) reconciled, _ = _invalidate_mismatched_oauth_state( current_config, current_state, @@ -247,7 +247,7 @@ def reconcile(config_model, state_model): return current_config, reconciled _, reconciled = _extension().mutate_config_and_state(reconcile) - return TwitterExtensionState.model_validate(reconciled) + return typing.cast(TwitterExtensionState, reconciled) def get_setup_status() -> TwitterSetupStatus: @@ -294,8 +294,8 @@ def save_oauth_app(body: SaveOAuthAppRequest) -> TwitterSetupStatus: def update(config_model, state_model): from . import TwitterExtensionConfig - config = TwitterExtensionConfig.model_validate(config_model) - state = TwitterExtensionState.model_validate(state_model) + config = typing.cast(TwitterExtensionConfig, config_model) + state = typing.cast(TwitterExtensionState, state_model) next_config = config.model_copy( update={ "backend": "official", @@ -344,7 +344,7 @@ def begin_oauth() -> OAuthTransactionView: ) def update(model: pydantic.BaseModel) -> pydantic.BaseModel: - state = TwitterExtensionState.model_validate(model) + state = typing.cast(TwitterExtensionState, model) current_time = _now() transactions = { key: _terminal(value, "expired", error="Superseded by a newer setup") @@ -389,7 +389,7 @@ def get_oauth_transaction(transaction_id: str) -> OAuthTransactionView: if transaction.status in {"pending", "exchanging"} and transaction.expires_at <= _now(): def expire(model: pydantic.BaseModel) -> pydantic.BaseModel: - state = TwitterExtensionState.model_validate(model) + state = typing.cast(TwitterExtensionState, model) current = state.oauth_transactions.get(transaction_id) if current is not None and current.status in {"pending", "exchanging"}: state.oauth_transactions[transaction_id] = _terminal( @@ -397,7 +397,7 @@ def expire(model: pydantic.BaseModel) -> pydantic.BaseModel: ) return state - state = TwitterExtensionState.model_validate(_extension().mutate_state(expire)) + state = typing.cast(TwitterExtensionState, _extension().mutate_state(expire)) transaction = state.oauth_transactions[transaction_id] return _transaction_view(transaction_id, transaction) @@ -475,7 +475,7 @@ def _claim_callback(provider_state: str) -> tuple[str, OAuthTransaction]: box: dict[str, typing.Any] = {} def claim(model: pydantic.BaseModel) -> pydantic.BaseModel: - state = TwitterExtensionState.model_validate(model) + state = typing.cast(TwitterExtensionState, model) matched = next( ( (key, value) @@ -508,7 +508,7 @@ def _finish_callback( error: str | None = None, ) -> None: def finish(model: pydantic.BaseModel) -> pydantic.BaseModel: - state = TwitterExtensionState.model_validate(model) + state = typing.cast(TwitterExtensionState, model) current = state.oauth_transactions.get(transaction_id) if ( current is None @@ -600,7 +600,7 @@ async def oauth_callback( def disconnect_account() -> TwitterSetupStatus: def disconnect(model: pydantic.BaseModel) -> pydantic.BaseModel: - state = TwitterExtensionState.model_validate(model) + state = typing.cast(TwitterExtensionState, model) state.account = None state.oauth_transactions = { key: _terminal(value, "expired", error="Account disconnected") diff --git a/migrations/revision-integrity.json b/migrations/revision-integrity.json index dafaf76..ffd12e4 100644 --- a/migrations/revision-integrity.json +++ b/migrations/revision-integrity.json @@ -18,6 +18,7 @@ "c6d7e8f9a0b1_add_extension_setup_state.py": "5aab0d1dda6932fbac53f5a6adf4b504f994e8c860833b64fc21fb9cb8d8fc3e", "c9d2e3f4a5b6_move_trigger_helper_internal.py": "6fab826c66cef1b16540142ae3d17ff953dc2ca49c79a4cffea95d4520701e6e", "d0e3f4a5b6c7_add_octet_stream_handler.py": "0d7127d340b878243d894e27f8b97d5982aa01c757703528d3982eb89ae9219e", + "d41cc84db0c5_add_job_abort_request.py": "fc1fe04942e95c7b8c5f43dbcf369bb88479cb83f8e7dc58528f08caf7a536d2", "d4e6f8a0b2c3_merge_extension_setup_main.py": "2073996504ae5d5cb5d8cc0d468d3c9e93870a44bd5f454986662c22da48fba8", "d9f4e2a1b7c3_adopt_peer_database_protocol.py": "9a163533b5a0619e51bfb94ba7ad0c5c3d168fba5808b08f67531fd8f7e5f263", "e1f4a5b6c7d8_migrate_memos_attachment_v2.py": "5f3691149e06b28378f79eed7eaacd1c415ee6c92fb83b3b5745a429346d5e01", diff --git a/migrations/script.py.mako b/migrations/script.py.mako index 021d6ad..a8d5e89 100644 --- a/migrations/script.py.mako +++ b/migrations/script.py.mako @@ -19,10 +19,10 @@ depends_on: str | Sequence[str] | None = ${repr(depends_on)} def upgrade() -> None: - """Upgrade schema.""" - ${upgrades if upgrades else "pass"} + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} def downgrade() -> None: - """Downgrade schema.""" - ${downgrades if downgrades else "pass"} + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/d41cc84db0c5_add_job_abort_request.py b/migrations/versions/d41cc84db0c5_add_job_abort_request.py new file mode 100644 index 0000000..5025704 --- /dev/null +++ b/migrations/versions/d41cc84db0c5_add_job_abort_request.py @@ -0,0 +1,35 @@ +"""add job abort request + +Revision ID: d41cc84db0c5 +Revises: 143c4f4adc85 +Create Date: 2026-09-14 13:16:59.543435 + +""" + +from typing import Sequence + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "d41cc84db0c5" +down_revision: str | Sequence[str] | None = "143c4f4adc85" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.add_column( + "jobs", + sa.Column( + "abort_requested", sa.Boolean(), server_default=sa.text("false"), nullable=False + ), + schema="inkcre", + ) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_column("jobs", "abort_requested", schema="inkcre") diff --git a/pdm.lock b/pdm.lock index fc15716..c3f52a1 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "dev", "extension-preview", "extension-publisher"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:03d30a82278b8fcad0bcd6b8f3ef6714ccb4a46fb141e245cbdaad1a6fbcf269" +content_hash = "sha256:82a4849d2ba8bdfc6c76f331e67a03bab48fa84de76326c203e1267ad0efa086" [[metadata.targets]] requires_python = ">=3.12,<3.13" @@ -130,7 +130,7 @@ name = "annotated-doc" version = "0.0.5" requires_python = ">=3.9" summary = "Document parameters, class attributes, return types, and variables inline, with Annotated." -groups = ["default", "extension-preview"] +groups = ["default", "extension-preview", "extension-publisher"] files = [ {file = "annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101"}, {file = "annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb"}, @@ -141,7 +141,7 @@ name = "annotated-types" version = "0.8.0" requires_python = ">=3.10" summary = "Reusable constraint types to use with typing.Annotated" -groups = ["default", "extension-preview"] +groups = ["default", "extension-preview", "extension-publisher"] files = [ {file = "annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0"}, {file = "annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7"}, @@ -152,7 +152,7 @@ name = "anyio" version = "4.14.2" requires_python = ">=3.10" summary = "High-level concurrency and networking framework on top of asyncio or Trio" -groups = ["default", "extension-preview"] +groups = ["default", "extension-preview", "extension-publisher"] dependencies = [ "exceptiongroup>=1.0.2; python_version < \"3.11\"", "idna>=2.8", @@ -275,7 +275,7 @@ name = "certifi" version = "2026.7.22" requires_python = ">=3.7" summary = "Python package for providing Mozilla's CA Bundle." -groups = ["default", "extension-preview"] +groups = ["default", "extension-preview", "extension-publisher"] files = [ {file = "certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775"}, {file = "certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55"}, @@ -377,7 +377,7 @@ name = "click" version = "8.4.2" requires_python = ">=3.10" summary = "Composable command line interface toolkit" -groups = ["default", "dev", "extension-preview"] +groups = ["default", "dev", "extension-preview", "extension-publisher"] dependencies = [ "colorama; platform_system == \"Windows\"", ] @@ -656,7 +656,7 @@ name = "h11" version = "0.16.0" requires_python = ">=3.8" summary = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" -groups = ["default", "extension-preview"] +groups = ["default", "extension-preview", "extension-publisher"] files = [ {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, @@ -696,7 +696,7 @@ name = "httpcore" version = "1.0.9" requires_python = ">=3.8" summary = "A minimal low-level HTTP client." -groups = ["default", "extension-preview"] +groups = ["default", "extension-preview", "extension-publisher"] dependencies = [ "certifi", "h11>=0.16", @@ -727,7 +727,7 @@ name = "httpx" version = "0.28.1" requires_python = ">=3.8" summary = "The next generation HTTP client." -groups = ["default", "extension-preview"] +groups = ["default", "extension-preview", "extension-publisher"] dependencies = [ "anyio", "certifi", @@ -802,7 +802,7 @@ name = "idna" version = "3.19" requires_python = ">=3.9" summary = "Internationalized Domain Names in Applications (IDNA)" -groups = ["default", "extension-preview"] +groups = ["default", "extension-preview", "extension-publisher"] files = [ {file = "idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4"}, {file = "idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15"}, @@ -854,7 +854,7 @@ version = "0.2.1" requires_python = "<3.14,>=3.12" url = "https://github.com/InKCre/ext-reg/releases/download/toolkit-v0.2.1/inkcre_extension_toolkit-0.2.1-py3-none-any.whl" summary = "Developer and delivery tooling for InKCre Extensions" -groups = ["extension-preview"] +groups = ["extension-preview", "extension-publisher"] dependencies = [ "packaging<27,>=25", "pydantic<3,>=2.10.6", @@ -871,7 +871,7 @@ extras = ["cli"] requires_python = "<3.14,>=3.12" url = "https://github.com/InKCre/ext-reg/releases/download/toolkit-v0.2.1/inkcre_extension_toolkit-0.2.1-py3-none-any.whl" summary = "Developer and delivery tooling for InKCre Extensions" -groups = ["extension-preview"] +groups = ["extension-preview", "extension-publisher"] dependencies = [ "httpx<0.29,>=0.28", "inkcre-extension-toolkit @ https://github.com/InKCre/ext-reg/releases/download/toolkit-v0.2.1/inkcre_extension_toolkit-0.2.1-py3-none-any.whl", @@ -1116,7 +1116,7 @@ name = "markdown-it-py" version = "4.2.0" requires_python = ">=3.10" summary = "Python port of markdown-it. Markdown parsing, done right!" -groups = ["extension-preview"] +groups = ["extension-preview", "extension-publisher"] dependencies = [ "mdurl~=0.1", ] @@ -1195,7 +1195,7 @@ name = "mdurl" version = "0.1.2" requires_python = ">=3.7" summary = "Markdown URL utilities" -groups = ["extension-preview"] +groups = ["extension-preview", "extension-publisher"] files = [ {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, @@ -1534,7 +1534,7 @@ name = "pydantic" version = "2.13.4" requires_python = ">=3.9" summary = "Data validation using Python type hints" -groups = ["default", "extension-preview"] +groups = ["default", "extension-preview", "extension-publisher"] dependencies = [ "annotated-types>=0.6.0", "pydantic-core==2.46.4", @@ -1551,7 +1551,7 @@ name = "pydantic-core" version = "2.46.4" requires_python = ">=3.9" summary = "Core functionality for Pydantic validation and serialization" -groups = ["default", "extension-preview"] +groups = ["default", "extension-preview", "extension-publisher"] dependencies = [ "typing-extensions>=4.14.1", ] @@ -1613,7 +1613,7 @@ name = "pygments" version = "2.21.0" requires_python = ">=3.9" summary = "Pygments is a syntax highlighting package written in Python." -groups = ["dev", "extension-preview"] +groups = ["dev", "extension-preview", "extension-publisher"] files = [ {file = "pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9"}, {file = "pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c"}, @@ -1949,7 +1949,7 @@ name = "rich" version = "15.0.0" requires_python = ">=3.9.0" summary = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" -groups = ["extension-preview"] +groups = ["extension-preview", "extension-publisher"] dependencies = [ "markdown-it-py>=2.2.0", "pygments<3.0.0,>=2.13.0", @@ -2016,7 +2016,7 @@ name = "semantic-version" version = "2.10.0" requires_python = ">=2.7" summary = "A library implementing the 'SemVer' scheme." -groups = ["default", "extension-preview"] +groups = ["default", "extension-preview", "extension-publisher"] files = [ {file = "semantic_version-2.10.0-py2.py3-none-any.whl", hash = "sha256:de78a3b8e0feda74cabc54aab2da702113e33ac9d9eb9d2389bcf1f58b7d9177"}, {file = "semantic_version-2.10.0.tar.gz", hash = "sha256:bdabb6d336998cbb378d4b9db3a4b56a1e3235701dc05ea2690d9a997ed5041c"}, @@ -2038,7 +2038,7 @@ name = "shellingham" version = "1.5.4" requires_python = ">=3.7" summary = "Tool to Detect Surrounding Shell" -groups = ["extension-preview"] +groups = ["extension-preview", "extension-publisher"] files = [ {file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"}, {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, @@ -2283,7 +2283,7 @@ name = "typer" version = "0.21.2" requires_python = ">=3.9" summary = "Typer, build great CLIs. Easy to code. Based on Python type hints." -groups = ["extension-preview"] +groups = ["extension-preview", "extension-publisher"] dependencies = [ "annotated-doc>=0.0.2", "click>=8.0.0", @@ -2300,7 +2300,7 @@ name = "typing-extensions" version = "4.16.0" requires_python = ">=3.9" summary = "Backported and Experimental Type Hints for Python 3.9+" -groups = ["default", "extension-preview"] +groups = ["default", "extension-preview", "extension-publisher"] files = [ {file = "typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8"}, {file = "typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5"}, @@ -2311,7 +2311,7 @@ name = "typing-inspection" version = "0.4.4" requires_python = ">=3.10" summary = "Runtime typing introspection tools" -groups = ["default", "extension-preview"] +groups = ["default", "extension-preview", "extension-publisher"] dependencies = [ "typing-extensions>=4.15.0", ] @@ -2436,7 +2436,7 @@ name = "wheel" version = "0.47.0" requires_python = ">=3.9" summary = "Command line tool for manipulating wheel files" -groups = ["extension-preview"] +groups = ["extension-preview", "extension-publisher"] dependencies = [ "packaging>=24.0", ] diff --git a/pyproject.toml b/pyproject.toml index 0dc1f8c..b97d98e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,6 +61,7 @@ extension-preview = [ ] extension-publisher = [ "build>=1.3.0,<2.0.0", + "inkcre-extension-toolkit[cli] @ https://github.com/InKCre/ext-reg/releases/download/toolkit-v0.2.1/inkcre_extension_toolkit-0.2.1-py3-none-any.whl", "setuptools>=80.0.0,<81.0.0", "uv>=0.8.0,<1.0.0", ] diff --git a/ruff.toml b/ruff.toml index 63d2bf7..238fb9c 100644 --- a/ruff.toml +++ b/ruff.toml @@ -1,6 +1,6 @@ line-length = 92 indent-width = 2 -extend-exclude = ["migrations/versions", "*.md"] +extend-exclude = ["migrations/versions", "*.md", "cli", ".agents"] [format] indent-style = "space" diff --git a/run.py b/run.py index 3e187a4..ab0c743 100644 --- a/run.py +++ b/run.py @@ -29,6 +29,15 @@ from app.routes.extension import PEER_INBOUND as extension_peer_inbound from app.routes.extension import ROUTER as extension_router from app.routes.source import ROUTER as source_router +from app.routes.job import ROUTER as job_router +from app.routes.cron import ROUTER as cron_router +from app.routes.agent import ROUTER as agent_router +from app.routes.ai import ROUTER as ai_router +from app.routes.entities import ROUTER as entities_router +from app.routes.resolver import ROUTER as resolver_router +from app.routes.graph_navigation import ROUTER as graph_navigation_router +from app.routes.retrieval import ROUTER as retrieval_router +from app.routes.peer import ROUTER as peer_router from app.routes.deployment_config import ROUTER as deployment_config_router from app.routes.info_base import ROUTER as info_base_router from app.routes.lexical_retrieval import PEER_INBOUND as lexical_retrieval_peer_inbound @@ -98,6 +107,7 @@ async def bootstrap_runtime(app: fastapi.FastAPI) -> None: await SinkManager.startup(app, PeerManager.get_current_peer_ref()) JobManager.sync_job_types() + JobManager.start() AIManager.sync_dialects() @@ -123,6 +133,13 @@ async def bootstrap_runtime(app: fastapi.FastAPI) -> None: id="jobs.check", replace_existing=True, ) + scheduler.add_job( + JobManager.check_abort_requests, + "interval", + seconds=2, + id="jobs.check_abort_requests", + replace_existing=True, + ) scheduler.add_job( CronManager.check, "interval", @@ -169,9 +186,12 @@ async def lifespan(app: fastapi.FastAPI): with contextlib.suppress(asyncio.CancelledError): await bootstrap_task if scheduler.running: - scheduler.shutdown(wait=True) + scheduler.pause() + await JobManager.shutdown() await SinkManager.shutdown() await EXTENSION_HOST.close_running() + if scheduler.running: + scheduler.shutdown(wait=True) if runtime_was_ready: await asyncio.to_thread(PeerManager.clear_self_lease) @@ -226,6 +246,15 @@ async def readiness() -> JSONResponse: core_router.include_router(relation_router) core_router.include_router(extension_router) core_router.include_router(source_router) +core_router.include_router(job_router) +core_router.include_router(cron_router) +core_router.include_router(agent_router) +core_router.include_router(ai_router) +core_router.include_router(entities_router) +core_router.include_router(resolver_router) +core_router.include_router(graph_navigation_router) +core_router.include_router(retrieval_router) +core_router.include_router(peer_router) core_router.include_router(deployment_config_router) core_router.include_router(info_base_router) core_router.include_router(organization_router) diff --git a/scripts/automation/cli_publication.sh b/scripts/automation/cli_publication.sh new file mode 100644 index 0000000..074b8c7 --- /dev/null +++ b/scripts/automation/cli_publication.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash + +source "$(dirname "$0")/_common.sh" + +case "${1:-}" in + select) + require_env EVENT_NAME + if [ "$EVENT_NAME" = workflow_dispatch ]; then + version="$(python3 -c 'import tomllib; print(tomllib.load(open("cli/pyproject.toml", "rb"))["project"]["version"])')" + if [ "$version" = 0.0.0 ]; then + echo "CLI 尚未经过 Release PR 准备,不能发布开发版本。" >&2 + exit 1 + fi + selected=true + else + require_env BEFORE_SHA + if [ "$BEFORE_SHA" = 0000000000000000000000000000000000000000 ]; then + selected=false + else + selected="$(python3 scripts/release.py version-changed --project cli --base "$BEFORE_SHA")" + fi + fi + emit_output selected "$selected" + ;; + check-build) + pdm lock -p cli --check + pdm install -p cli --frozen-lockfile + pdm run -p cli check + pdm build -p cli + ;; + publish) + # PDM owns GitHub OIDC and PyPI upload/recovery; never rebuild after validation. + pdm publish -p cli --no-build --skip-existing + ;; + *) echo "unknown CLI publication command: ${1:-}" >&2; exit 2 ;; +esac diff --git a/scripts/automation/extension_publication.sh b/scripts/automation/extension_publication.sh index 586d01e..d8cd1ff 100644 --- a/scripts/automation/extension_publication.sh +++ b/scripts/automation/extension_publication.sh @@ -63,13 +63,20 @@ PY for name in EXTENSION GITHUB_REPOSITORY GITHUB_RUN_ID GITHUB_SERVER_URL; do require_env "$name" done - output="${RUNNER_TEMP:-/tmp}/$EXTENSION" - mkdir --parents "$output" - pdm run python -m build --wheel --no-isolation --outdir "$output" \ + output="$(mktemp -d "${RUNNER_TEMP:-/tmp}/$EXTENSION.XXXXXX")" + mkdir --parents "$output/raw" "$output/finalized" + pdm run python -m build --wheel --no-isolation --outdir "$output/raw" \ "extensions/$EXTENSION" - wheel="$(find "$output" -maxdepth 1 -type f -name '*.whl')" + wheel="$(find "$output/raw" -maxdepth 1 -type f -name '*.whl')" test -n "$wheel" - test "$(find "$output" -maxdepth 1 -type f -name '*.whl' | wc -l)" = 1 + test "$(find "$output/raw" -maxdepth 1 -type f -name '*.whl' | wc -l)" = 1 + # The runtime reads the Toolkit manifest inside .dist-info, not registry metadata. + # Match the preview path before verification and upload; never publish the raw wheel. + pdm run inkcre-ext python wheel finalize \ + --project "extensions/$EXTENSION/pyproject.toml" --wheel "$wheel" \ + --output-dir "$output/finalized" + wheel="$(find "$output/finalized" -maxdepth 1 -type f -name '*.whl')" + test "$(find "$output/finalized" -maxdepth 1 -type f -name '*.whl' | wc -l)" = 1 metadata="${RUNNER_TEMP:-/tmp}/$EXTENSION-metadata.json" pdm run python scripts/extension_distribution.py verify-wheel \ --project "extensions/$EXTENSION" --wheel "$wheel" diff --git a/scripts/release.py b/scripts/release.py index 24172f7..6e2be11 100644 --- a/scripts/release.py +++ b/scripts/release.py @@ -1,4 +1,4 @@ -"""Core and first-party Extension release intent and preparation.""" +"""Core, CLI and first-party Extension release intent and preparation.""" from __future__ import annotations @@ -78,7 +78,9 @@ def fragments(self) -> Path: return self.directory / FRAGMENTS_NAME -def _project_from_pyproject(directory: Path, *, extension: bool) -> ReleaseProject: +def _project_from_pyproject( + directory: Path, *, extension: bool, key: str | None = None +) -> ReleaseProject: value = tomllib.loads((directory / "pyproject.toml").read_text(encoding="utf-8")) project = value.get("project") if not isinstance(project, dict): @@ -93,7 +95,8 @@ def _project_from_pyproject(directory: Path, *, extension: bool) -> ReleaseProje key = directory.name coordinate = producer["name"] else: - key = "core" + if key is None: + raise ReleaseContractError("a non-Extension project requires an explicit key") coordinate = project.get("name", "inkcre-core") return ReleaseProject( key, directory, coordinate, SemanticVersion.parse(version), extension @@ -103,7 +106,10 @@ def _project_from_pyproject(directory: Path, *, extension: bool) -> ReleaseProje def discover_projects(*, extensions_only: bool = False) -> tuple[ReleaseProject, ...]: projects: list[ReleaseProject] = [] if not extensions_only: - projects.append(_project_from_pyproject(PROJECT_ROOT, extension=False)) + projects.append(_project_from_pyproject(PROJECT_ROOT, extension=False, key="core")) + projects.append( + _project_from_pyproject(PROJECT_ROOT / "cli", extension=False, key="cli") + ) for directory in sorted(EXTENSIONS_DIRECTORY.iterdir()): pyproject = directory / "pyproject.toml" if not pyproject.is_file(): @@ -197,6 +203,19 @@ def _production_dependencies_at(revision: str) -> tuple[str, ...] | None: return tuple(value) if isinstance(value, list) else None +def _cli_packaging_at(revision: str) -> dict | None: + content = _show(revision, PROJECT_ROOT / "cli/pyproject.toml") + if content is None: + return None + value = tomllib.loads(content) + project = value.get("project", {}) + return { + "project": {key: item for key, item in project.items() if key not in {"version"}}, + "build-system": value.get("build-system"), + "build": value.get("tool", {}).get("pdm", {}).get("build"), + } + + def affected_projects(base: str) -> set[str]: paths = _changed_paths(base) affected: set[str] = set() @@ -222,10 +241,14 @@ def affected_projects(base: str) -> set[str]: affected.add(parts[1]) elif path.startswith(core_prefixes) or path in core_files: affected.add("core") + elif path.startswith("cli/src/") or path in {"cli/README.md", "cli/LICENSE"}: + affected.add("cli") if "pyproject.toml" in paths and ( _production_dependencies_at(base) != _production_dependencies_at("HEAD") ): affected.add("core") + if "cli/pyproject.toml" in paths and _cli_packaging_at(base) != _cli_packaging_at("HEAD"): + affected.add("cli") return affected @@ -268,32 +291,26 @@ def check_release_contract( ) if release_pr: allowed = { - "pyproject.toml", - CHANGELOG_NAME, - *( - path - for project in projects - for path in ( - f"extensions/{project.key}/pyproject.toml", - f"extensions/{project.key}/{CHANGELOG_NAME}", - ) - if project.extension - ), + (project.directory / name).relative_to(PROJECT_ROOT).as_posix() + for project in projects + for name in ("pyproject.toml", CHANGELOG_NAME) } + fragment_prefixes = tuple( + project.fragments.relative_to(PROJECT_ROOT).as_posix() + "/" for project in projects + ) unexpected = [ path for path in changed_paths - if path not in allowed - and not path.startswith(f"{FRAGMENTS_NAME}/") - and not (path.startswith("extensions/") and f"/{FRAGMENTS_NAME}/" in path) + if path not in allowed and not path.startswith(fragment_prefixes) ] if unexpected: problems.append( "Release PR contains non-preparation paths: " + ", ".join(unexpected) ) for project in projects: - version_changed = _version_at(project, base) not in {None, project.version} - changelog_changed = _file_changed(project.changelog, base) + previous = _version_at(project, base) + version_changed = previous not in {None, project.version} + changelog_changed = previous is not None and _file_changed(project.changelog, base) fragment_changed = _fragment_changed(project, changed_paths) if not release_pr and project.key in affected and not fragment_changed: problems.append(f"{project.key}: delivered behavior changed without a fragment") @@ -391,7 +408,7 @@ def prepare(project_keys: tuple[str, ...] = ()) -> tuple[str, ...]: def version_changed(project: ReleaseProject, base: str) -> bool: - return _version_at(project, base) != project.version + return _version_at(project, base) not in {None, project.version} def verify_artifact_unchanged( diff --git a/tests/test_extension_registry_config.py b/tests/test_extension_registry_config.py index 2ee6f35..b58c74f 100644 --- a/tests/test_extension_registry_config.py +++ b/tests/test_extension_registry_config.py @@ -56,7 +56,9 @@ def test_deployment_registry_default_precedes_process_fallback(monkeypatch): DeploymentConfigManager, "get", lambda key: ( - {"extension_registry_url": "https://deployment.registry.test/"} + ExtensionRegistryDeploymentConfig( + extension_registry_url="https://deployment.registry.test/" + ) if key == EXTENSION_REGISTRY_CONFIG_KEY else None ), From a206c99a034678f78e73594689f13a61669a7a0a Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Mon, 14 Sep 2026 17:18:44 +0800 Subject: [PATCH 3/8] =?UTF-8?q?docs(cli):=20=E8=AE=B0=E5=BD=95=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E9=AA=8C=E6=94=B6=E4=B8=8E=E6=8C=89=20owner=20?= =?UTF-8?q?=E4=BA=A4=E4=BB=98=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../collaboration/roster.md | 4 +- .../documentation-promotion/index.md | 4 + .../packet.md | 10 +- .../units/cli-sink/delivery.md | 31 ++++++ .../units/cli-sink/implementation.md | 63 +++++++++++++ .../units/cli-sink/local-acceptance.md | 94 +++++++++++++++++++ .../units/cli-sink/packet.md | 8 +- 7 files changed, 204 insertions(+), 10 deletions(-) create mode 100644 tasks/knowledge-lifecycle-capabilities/units/cli-sink/delivery.md create mode 100644 tasks/knowledge-lifecycle-capabilities/units/cli-sink/implementation.md create mode 100644 tasks/knowledge-lifecycle-capabilities/units/cli-sink/local-acceptance.md diff --git a/tasks/knowledge-lifecycle-capabilities/collaboration/roster.md b/tasks/knowledge-lifecycle-capabilities/collaboration/roster.md index 555e85e..a276664 100644 --- a/tasks/knowledge-lifecycle-capabilities/collaboration/roster.md +++ b/tasks/knowledge-lifecycle-capabilities/collaboration/roster.md @@ -8,12 +8,12 @@ | `mcp-sink` | `01a04610-338e-7311-93df-847f9801c5af` | merged through PR #88;the current root worktree retains only local task-control state | protected `main` merge `459a6df` | Closed | D-381–D-420 | No active implementation ownership;MCP runtime、ChatGPT Tool acceptance and production delivery are complete | | `telegram-extension` | `01a04685-aa31-7682-a4a2-824727eacce5` | core-py PR #89 / `.github` PR #28 | merged as core-py `42d8527` and `.github` `f7269b9` | Closed / merged | D-421–D-460 | Telegram、repository-wide Changie→Towncrier cutover and organization guidance are complete;Unit worktrees are retired and Release PR #90 is independently owned by the release lifecycle | | `organization-nowledge-study` | current session | PR #100 / #101 merged;root worktree on main retains local task-control records | protected `main` `b3ccb00` | Closed / production delivered | D-461–D-570 | Core 0.2.0、production probes and stable admission complete;no active implementation ownership | -| `cli-sink` | 当前 CLI session | `feat/inkcre-cli` / root core-py worktree | `b3ccb00`,已核对 origin/main | 完整 Preflight 已完成,等待 Impact Handshake 后实施 | D-571–D-610 | cli/ 独立 PDM 项目;Core REST/Job/校验边界,Mail 取消清理,client-web worker 配套;ext-reg runtime 和 Core pin。预演发现的 production wheel finalize 缺口纳入 P5。跨仓库 feature 工作位置在实施时建立;当前没有业务源码改动 | +| `cli-sink` | 当前 CLI session | `feat/inkcre-cli` / root core-py worktree | `b3ccb00` base;设计提交 `a953676` | Local verified / PR review | D-571–D-610 | cli/、Core REST/Job/校验边界、Mail 取消、client-web worker;四条本地旅程通过。ext-reg、client-web、Hub 使用各自 `cli-sink-*` worktree。Sir 已授权提交/推送/PR、禁止合并;状态见 unit delivery.md | ## Shared-worktree coordination MCP、Telegram 和 Organization 均已关闭;root worktree 保留 parent 生命周期内的本地 task-control 记录, -现已切到 CLI feature branch,没有本轮待交付的源码。CLI 保留上一单元的本地收尾和未跟踪 skill,不将其视为 +现已切到 CLI feature branch,CLI 实现正在工作区内进行。CLI 保留上一单元的本地收尾和未跟踪 skill,不将其视为 本 unit 的修改。没有其他已登记的 active unit,也不进行 cross-session 通信。Historical task-control and operational state can still intersect: - `mcp-sink` has no remaining implementation ownership。Its Core、Extension Runtime and production changes are authoritative diff --git a/tasks/knowledge-lifecycle-capabilities/documentation-promotion/index.md b/tasks/knowledge-lifecycle-capabilities/documentation-promotion/index.md index 884e506..c42e0e5 100644 --- a/tasks/knowledge-lifecycle-capabilities/documentation-promotion/index.md +++ b/tasks/knowledge-lifecycle-capabilities/documentation-promotion/index.md @@ -19,6 +19,10 @@ 边界与 model_construct 的局限,不只提升为抽象的“使用成熟库”原则。 从“所有读回只转换、零校验”修正为“无类型恢复需求时不重验;需要时接受一次成熟库构造的附带约束”, 保持写入 owner、CLI 可信响应和使用时能力判断不变。不因此建立新的文档权威或把整份 skill 提升为 Hub truth。 + CLI 实施后:Spoke `business-pipeline-and-authority.md`、`rest-interface.md` 与 `app/schemas/AGENTS.md` + 已落地/引用该边界;CLI 开发文档明确可信 REST 返回只转换。Hub 的跨语言边界和 Job 停止合同候选位于 + `../.worktrees/cli-sink-docs`,已提交为 `f84d9ed` 并创建 Hub PR #25,尚未合并;不提前改 Spoke shared ref。 + 原有未跟踪 skill 保持独立。 ## Control diff --git a/tasks/knowledge-lifecycle-capabilities/packet.md b/tasks/knowledge-lifecycle-capabilities/packet.md index 33ac95c..6374ddf 100644 --- a/tasks/knowledge-lifecycle-capabilities/packet.md +++ b/tasks/knowledge-lifecycle-capabilities/packet.md @@ -21,8 +21,10 @@ - **Next Step**: [CLI sink / inkcre-cli](units/cli-sink/packet.md) 已完成完整 [preflight](units/cli-sink/preflight.md),Execution baseline 冻结至 D-603。真实调用链、独立工具链与双 Core/IMAP/浏览器/AI 环境已核验;production Extension publisher 漏 finalize 的修正纳入实施计划。 - 下一步是 [Impact Handshake](units/cli-sink/impact-handshake.md) 后等待 Sir 明确“开始”。 - 目前没有业务源码实施、push 或发布;独立 CLI 的四条旅程仍在实施后验收。 + Sir 已在 Impact Handshake 后明确授权实施;源码与本地候选旅程已通过,见 + [implementation](units/cli-sink/implementation.md) 和 [本地验收](units/cli-sink/local-acceptance.md)。 + Sir 已授权 owner 分离的提交、推送和 PR,明确禁止合并;当前进入 [交付 review](units/cli-sink/delivery.md)。 + Preview、正式 Extension/PyPI 验收尚未完成,不关闭 unit。 ## Program Boundary @@ -52,7 +54,7 @@ preflight 已整理为独立提交;前一单元的本地收尾仍未提交。 核对并保留或显式转交所需 packet,不能把旧提交里的 active 状态当作现状。环境入口是 `AGENTS.local.md` 与 `svc.local.json`,不要复制凭据或以本机没有 Docker/PostgreSQL 推断数据库不可用。 -当前最新决策为 D-603;Pydantic 取舍与完整预演已闭合,CLI 等待实施握手。 +当前最新决策为 D-603;CLI 源码与本地候选验收已完成,进入禁止自动合并的 PR review。 Organization 保留 D-461–D-570,CLI 保留 D-571–D-610,不复用历史空号。 CLI 已在 root worktree 从与 origin/main 一致的 `b3ccb00` 切出 `feat/inkcre-cli`,保留原有未提交 task-control。 已关闭 session 不再持有源码锁,历史授权和 deferred 项也不自动成为新 unit 的实施范围。 @@ -60,7 +62,7 @@ CLI 已在 root worktree 从与 origin/main 一致的 `b3ccb00` 切出 `feat/ink ## Unit 状态与选择 [CLI sink](units/cli-sink/packet.md) 为当前 active unit,公开接口、验收设计和完整 preflight 已完成, -等待 Impact Handshake 后实施。研究依据包括本任务 Agent Tool 模式、xiaoland/svc 的 CLI 实践及一手公开材料。 +本地实现/验证已完成,依各 owner PR 推进交付。研究依据包括本任务 Agent Tool 模式、xiaoland/svc 的 CLI 实践及一手公开材料。 [MCP sink](units/mcp-sink/packet.md) 已通过 PR #88 合并并关闭。 diff --git a/tasks/knowledge-lifecycle-capabilities/units/cli-sink/delivery.md b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/delivery.md new file mode 100644 index 0000000..5190bd7 --- /dev/null +++ b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/delivery.md @@ -0,0 +1,31 @@ +# CLI 按 owner 交付 + +2026-09-14,Sir 授权自由提交、推送和创建 PR,但明确禁止合并。继续使用 feature → main;不启用 auto-merge, +不从 PR 发布 canonical package,不借授权改写 shared main。没有跨 session 通信。 + +## 批次 + +| Owner | 分支 | 内容 | 合并前依赖 | +| --- | --- | --- | --- | +| ext-reg | feat/cli-sink-runtime | Python runtime typed config/state 传递;按现有 Changie 准备 0.1.3 | 独立 review/CI;随后由 Sir 合并并触发既有发布 | +| Hub | feat/cli-sink-contract | 跨 Peer Job 停止、观察预算与可信记录读取边界 | 独立 review/CI;不含具体 Extension 或 CLI 实现 | +| Core | feat/inkcre-cli | 独立 CLI、普通 REST、Job 控制、校验边界、Extension publisher finalize、文档与本 unit packet | runtime 0.1.3 发布后更新 pin;Hub 合并后单独 shared-ref 提交;候选 Preview | +| client-web | feat/cli-sink-job-control | 两端 Job 协议的浏览器 worker;生成数据库类型与 Changeset | Core 新数据库/执行合同交付并通过当前 stable admission;Hub ref 单独更新 | + +这些是跨仓库依赖,不是同仓库 ancestry stack。Core/client-web 在外部依赖未交付时保留 draft,PR body 明确 +前置项;不得合并后再补 runtime pin 或两端 worker 验证。 + +## 已推送 PR + +- [ext-reg #35](https://github.com/InKCre/ext-reg/pull/35):`f22641d`,runtime 0.1.3 候选,等待 review/CI。 +- [Hub #25](https://github.com/InKCre/docs/pull/25):`f84d9ed`,只含两个跨单元合同增量,等待 review/CI。 +- Core 源码提交 `a2d2f93`;client-web 源码提交 `83a60ce`,PR 创建后在此补齐链接。 + +原有 Organization 收尾、共享 packet 中相应旧 delta 和未跟踪 Python skill 保持本地。只有 CLI 对共享 +control 文件的行级变化进入本 unit;不为清空工作区提交其它任务。 + +## 剩余关闭条件 + +四条本地旅程见 [local-acceptance.md](local-acceptance.md)。它们不替代正式 runtime/Registry 安装、 +Core/client-web Preview 与 production、CLI 0.1.0 的 Release PR/PyPI 安装,以及 CLI 消费实际云端冷启动。 +PyPI Trusted Publisher 首次配置仍需确认。当前不合并任何 PR。 diff --git a/tasks/knowledge-lifecycle-capabilities/units/cli-sink/implementation.md b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/implementation.md new file mode 100644 index 0000000..ca89db5 --- /dev/null +++ b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/implementation.md @@ -0,0 +1,63 @@ +# CLI 实施记录 + +2026-09-14,Sir 在 a953676 完整 preflight/Impact Handshake 后明确授权开始。源码修改与验证在此授权内; +当时不自动授权新的提交、push、PR、合并或发布,也不进行跨 session 通信。 +2026-09-14 Sir 后续明确授权提交、推送和创建 PR,禁止合并;当前交付状态由 [delivery.md](delivery.md) 记录。 + +## 工作位置 + +- Core:root worktree / feat/inkcre-cli,基线 main b3ccb00,加设计提交 a953676。 +- ext-reg:../.worktrees/cli-sink-ext-reg / feat/cli-sink-runtime,基线 3fc4523。 +- client-web:../.worktrees/cli-sink-client-web / feat/cli-sink-job-control,基线 54882ac。 +- Hub:../.worktrees/cli-sink-docs / feat/cli-sink-contract,基线 528d735。 + +源码仓库 origin/main 在开始时重新 fetch,未漂移。旧 checkout、前一单元收尾与未跟踪 skill 保留不动。 + +## 进展 + +P1 源码与本地验证完成:ConfigContract、DeploymentConfig、Extension runtime 及其直接 typed 消费者。 +ext-reg 按既有 Changie 工作流已在本地准备 runtime 0.1.3,静态检查、原生 bindings 检查和 wheel/sdist 构建通过。 +尚未发布,Core 仍 pin 正式 0.1.2;候选 runtime 只进入隔离 Docker image,不覆盖根环境的 site-packages。 + +P2 已接入 Core/client-web 的停止意图观察、执行句柄、清理后关闭和 shutdown 顺序。Mail 持有同一次阻塞 +调用直到结束,再释放锁/断连;新增 explicit rumination Job。候选 migration 为 d41cc84db0c5,只添加 +jobs.abort_requested,已通过 migration integrity 检查。生成候选时发现既有 Mako 模板的两空格缩进与 +Alembic 原生四空格片段冲突,已修正模板及未发布候选;未修改已发布 revision、未升级共享开发库。 +双 Core 和真实浏览器 worker 的停止验收均已通过。client-web 数据库类型从隔离候选数据库生成;除新列外, +也自然补齐前一 MCP migration 的 sinks/sink_types,未手改生成物。真实 PostgreSQL 暴露 pending abort 的 +CASE enum/VARCHAR 不兼容,已在条件写中显式使用现有列的 enum 类型,原地修复后通过重跑。 + +P3 已接入并实际执行:Source/Cron/Agent 管理、AI/profile 发现、Job REST、deployment config 目录与 schema keys, +以及实体、Resolver、graph、recall、Peer 和 Extension 普通 REST。OpenAPI 已从实际 router 生成。 +新提交的嵌套 Source 参数由 Source handler 复用 catalog 校验;app/validation.py 只补原生错误路径, +不重复校验。普通管理 GET 不加载业务 schema。明确记录写入的 FK/CHECK 冲突在 route 映射为 409, +不预查引用、不复制数据库约束。既有 MCP SDK 和指定 Peer 的检索路径均已回归。 + +P4 的独立 CLI project、完整命令组、动态 schema 和文件交付已实现,独立 lock/lint/typecheck +通过。有界观察使用很小的 asyncio.timeout + HTTPX AsyncClient 作用域,确保整个预算包含慢请求;其它请求 +保持同步 HTTPX。没有留下 to_thread 请求或新增异步 CLI framework。wheel 在 checkout 外的干净 consumer +环境运行,不可导入 app/FastAPI/SQLAlchemy/PDM;本地四条旅程的具体证据见 [local-acceptance.md](local-acceptance.md)。 + +P5 发布脚本、CI 静态构建、Towncrier 独立 CLI project、文档已接入。另核实 aiohttp 已是 Core 直接运行依赖,早期草案将它称为传递依赖已过时;不重复 +新增依赖。Root gate 明确排除独立 cli/ 与非产品 .agents/,CLI 使用自己的静态配置。 +真实 release prepare 在隔离文件副本中只修改 CLI,从 0.0.0 准备到 0.1.0,实际 index 不变。 +production Extension publisher 现在复用 Toolkit finalize;七个候选 wheel 的本地构建/finalize/校验通过。 + +P6 本地候选验证完成,尚未完成交付验收。Core `pdm run check` 为 14 passed / 53 skipped;跳过的 opt-in +数据库套件不声称执行。CLI/ext-reg 静态检查与构建、client-web core 和 app 类型检查与构建均通过。 +未增加自动化测试,仅修正一处既有测试 mock,使其符合实际 typed 返回合同。 + +本次独立验收的八个容器、数据库卷与网络已删除,共享 Core readyz 保持 200。命令与文件证据保留在 ignored +目录。Pydantic 指南已进入本 Spoke 的 business-pipeline-and-authority,并由 app/schemas/AGENTS.md 引用; +原有未跟踪的 python-backend-code skill 未整份混入本 unit 交付。 + +## 后续交付顺序 + +1. 按已获授权提交 ext-reg runtime 0.1.3 并创建 PR;等待 Sir 合并和原生发布,再更新 Core dependency pin/lock。 +2. Hub 的 Job/校验合同单独发布,再做 Spoke shared-ref bump。Spoke REST/CLI/Job 变更不与 ref bump 混提交。 +3. Core/client-web 候选 PR/Preview 跑交付回归;Mail 下一正式版本需实际 Registry 安装/启用。 +4. Core 新 REST 与两端 worker 交付后,Release PR 准备 CLI 0.1.0;配置 PyPI Trusted Publisher 并正式发布。 +5. 在 checkout 外从正式 PyPI 安装复跑,补真实平台冷启动证据,随后才关闭 unit。 + +CLI 不是完整 Peer;这些步骤不新增 CLI 数据库访问、内部 runtime 替身或发布版本绕过。提交/PR 进度见 +delivery.md;不能把本地通过或 PR 创建标为整个单元完成。 diff --git a/tasks/knowledge-lifecycle-capabilities/units/cli-sink/local-acceptance.md b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/local-acceptance.md new file mode 100644 index 0000000..c98d8b4 --- /dev/null +++ b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/local-acceptance.md @@ -0,0 +1,94 @@ +# CLI 本地候选验收 + +2026-09-14,源码基线 a953676 加本 unit 未提交修改。四条旅程使用已安装 wheel 的实际 CLI 进程,经真实 HTTP +进入 Core;准备数据允许使用数据库/协议控制工具,不用 Manager 调用替代正在验收的 CLI 操作。 +本页不是 Preview、Registry 正式发行或 PyPI 通过声明,也不将这些脚本加入 CI。 + +## 环境与证据位置 + +CoreA、CoreB、PostgREST、PostgreSQL、Dovecot 和只读候选 Registry 位于远程 Docker 的独立网络 +`inkcre-cli-acceptance-bylnlu_default`。全新数据库迁移至 d41cc84db0c5;共享 SVC 数据库保持原版本和状态。 +Core A/B 的 Peer ID 分别为 `00000000-0000-4000-8000-000000000002` / `...0003`。 +CLI wheel 0.0.0 安装在 `.runtime/cli-acceptance.bYlNlu/consumer`,执行 cwd 是 `/Volumes/WorkSSD`。 +其中没有 Core、FastAPI、SQLAlchemy、PDM。runtime 修改以独立本地 wheel 注入候选 image;不是已发布依赖。 + +原始命令、退出码、完整 stdout/stderr、材料与导出文件保存在 ignored 的 +`.runtime/cli-acceptance.bYlNlu/`。主要记录是 `records-evidence/`、`collection-evidence.json`、 +`running-abort-evidence.json`、`media-ai-evidence.json`、`ai-remaining-evidence.json`、 +`browser-worker.json`、`final-roundtrip-evidence.json`、`mcp-regression-evidence.json`。 +脚本和临时材料不安装为产品,也不声明是独立盲测。 + +## 实际结果 + +| 旅程 | 已观察到的结果 | +| --- | --- | +| 信息取用 | 真实 Python asyncio、SQLite architecture、Anthropic Agent tools 原文入图;lexical / semantic / 两模式独立组合、实体记录、邻域续页、路径、分量和动态 Resolver 方法可用。超长正文保留完整文件,缺失实体保留其它成果并退出 1。 | +| 采集与调度 | CLI 安装并配置候选 Mail,仅 B 启用;A 创建 Source、普通/历史 Job 后由 B 真实 IMAP 执行。自然 Cron 与显式 run 均完成;图可继续导航至 Email,在 B 读取 solved 内容。删除 Source/Cron、禁用/卸载 Extension 后原 Email 与 Source anchor 仍可读。 | +| 整理与 AI 管理 | CLI 创建/编辑 Agent、选择 deployment config;实际 Qwen rumination 完成。实际 text-embedding-v4 生成 256 维,维护 Job 报告 embedded=15、failed=0、unavailable=19,semantic recall 返回结果。删除配置/Agent/Cron 后清理管理对象。 | +| 执行控制与安装 | pending Job 可原子停止;跨 Core running Mail Job 接受停止意图,等待真实 I/O 清理后 aborted。实际浏览器 JobManager 经 PostgREST claim,处理停止意图与 stopWorker 后均先清理再终结。CLI 自身不处理 Job。 | + +最后补充执行 relation PATCH(包括反转端点)、Block/Relation DELETE、图第二页与普通 REST 指定 Peer 检索。 +关系记录在这些普通 endpoint 一致使用 from_block_id/to_block_id;既有 Peer wire 没有随之改变。 +使用真实 MCP SDK 对现有 MCP Sink 执行方法发现、open、read 成功,临时 Sink 随后禁用并删除。 + +### 材料与文件完整性 + +原文来源是 `https://docs.python.org/3.12/library/asyncio-task.html`、`https://www.sqlite.org/arch.html`、 +`https://www.anthropic.com/engineering/writing-tools-for-agents`,保存的是运行时取得的完整 HTML。 +引用取自创建回执;本地别名没有进入实现。 + +沿用既有 NASA 图片、音频和视频材料,经 PostgreSQL binary storage 读取。实际 bytes 大小分别为 +53,569 / 2,741,982 / 3,670,265。raw pointer、hydrated bytes、Resolver 纯 bytes 与图片 solved 嵌套 bytes +之间的层次保持;导出目录搬移后相对文件引用仍有效。对应 SHA-256: + +- 图片:`e244038ef1ffbbe4fc20170e0c3d69db850afeb6805c505fc0847befe2c6512c` +- 音频:`2a577394996b525f36946034d6cce9dcdb270c24851fc2078c5db810065f8c7f` +- 视频:`03168dd86fe492fed362cb64d5ce3d29989b8573cd9dfdaebfed0e11512f7427` + +### 没有掩盖的执行结果 + +一个实际 rumination Job 已 finished;经 Cron 再次调用时耗尽 Agent 三次模型调用预算,Job 保留 failed +和原始原因。CLI 读取该记录退出 0,表示读取成功,不假装业务成功,也没有为通过验收反复调用模型。 + +第一次 running abort 在执行端下一次观察前恢复 IMAP,Job 自然 finished,同时保留 abort_requested=true。 +这是已确认的 best-effort 竞争。第二次保留真实 I/O 阻塞窗口,观察停止意图后恢复服务,最终 aborted; +两个结果都保留。没有额外线程强杀、内部重试或回滚。 + +PostgreSQL 的实际 CASE enum/VARCHAR 不兼容曾使 pending abort 返回 500;已修复 SQL 表达式类型并重跑。 +另有验收脚本误认 SubmitGraphResult 返回 relations、误认 backfill report 和 SDK 属性名等,均仅修脚本, +没有反过来改变产品合同。AI 准备阶段把 output modality 填成 embedding,改为实际 vector 后正常运行; +没有在产品新增别名或兜底。 + +## 静态与发布准备 + +- Core `pdm run check`:14 passed、53 skipped;静态检查零诊断。跳过的 opt-in 套件未执行。 +- CLI 独立 lock、Ruff、Pyrefly、sdist/wheel 构建通过;不引入 Core/path/database dependency。 +- client-web core 与 app 类型检查/构建通过;真实 Chromium worker 脚本通过。构建保留既有依赖注解、动态 + import 与 chunk 大小警告,没有为压制警告改业务。 +- ext-reg runtime 的 Ruff、Pyright、原生 bindings 检查与构建通过,Changie 本地准备 0.1.3,未发布。 +- OpenAPI 从 import-only 入口生成,使用无效端口的占位数据库 URL,未连接数据库。既有 default=dict + metadata 产生一条 Pydantic schema warning;生成成功,本轮不扩成全 schema 重写。 +- CLI release prepare 在隔离副本中将 0.0.0 变为 0.1.0,仅修改 CLI 路径,真实 Git index 不变。 +- 七个 Extension 候选 wheel 用成熟 Toolkit finalize 并验证。正式 Mail 0.2.0 的既有 artifact 未覆盖。 + +## 未完成的关闭条件 + +runtime 正式发布与 Core pin、Hub-first/ref bump、Core/client-web PR/Preview、正式 Mail 新版本安装、 +Core production 接口交付、CLI Release PR / PyPI 发布和安装复跑仍未执行。真实平台冷启动的前期证据 +保留在 preflight-environment.md,尚未用正式 CLI 消费候选云端冷启动。 + +本地验收完成时暂停于提交/PR/发布授权及首次 PyPI Trusted Publisher 配置。Sir 随后已授权提交/推送/PR、 +明确禁止合并;当前交付进度见 [delivery.md](delivery.md),本页不提前声明线上通过。 + +## 清理 + +最终 A image 实际安装 runtime 0.1.3,并再次通过 MCP SDK 回归。隔离数据库最终有 18 个 Job: +12 finished、5 aborted、1 failed,没有 pending/running。临时 Source/Cron 已经经 CLI 删除。 +按精确名称和本次 label 核对后,八个独立验收容器、独立 PostgreSQL volume、独立 network 已删除; +该测试数据库没有备份,不可恢复,其中临时借用的 Provider 配置也随卷删除。共享 readyz 清理前后均为 200。 +两个本次 SSH 转发进程也已停止。SVC probe 的 ready/converged 均为 true,但 source_matches=false:共享实例 +仍刻意保留 b3ccb00 / migration 143c4f4adc85,不等于当前 CLI 候选;没有为消除此提示升级或重置共享库。 + +最终镜像 ID、逐源码文件 SHA-256、wheel 哈希及清理结果保存在 +`.runtime/cli-acceptance.bYlNlu/cleanup-evidence.json`;未导出数据库或 Provider secret。 +命令证据、原文、媒体导出、脚本与本地 wheel 保留供 review,不清理仍在进行中的 parent task。 diff --git a/tasks/knowledge-lifecycle-capabilities/units/cli-sink/packet.md b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/packet.md index d8a1642..2dad9fd 100644 --- a/tasks/knowledge-lifecycle-capabilities/units/cli-sink/packet.md +++ b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/packet.md @@ -1,12 +1,12 @@ # CLI Sink / inkcre-cli -- **阶段**:完整 preflight 已完成,Execution baseline 冻结至 D-603;等待 [Impact Handshake](impact-handshake.md) 后的“开始”。 +- **阶段**:Implementation。2026-09-14 Sir 已批准 [Impact Handshake](impact-handshake.md) 并明确“开始”;基线为 D-603。 - **目标**:让 Agent 和命令行用户在工作上下文中,通过独立 CLI 取用信息并操作 Core。 - **边界**:同仓库 cli/ 独立 Python/PDM project,pip 包 inkcre-cli。仅 Core 普通 REST;不导入 Core、不直接访问数据库、不作为 Peer,不是未来 Rust single-binary Peer。 - **位置**:core-py feat/inkcre-cli,base b3ccb00ca2e235bfcc9b9f4f4cc17948c59ef54a;决策 D-571–D-610。 -- **授权**:调查/隔离实验/packet 可继续;本轮已按 Sir 的条件授权整理设计/preflight 提交。尚未实施源码,也未授权 push 或发布。 -- **当前事实**:[完整预演](preflight.md) 已核对真实 owner、调用链和验收拓扑。发现 production Extension publisher 漏 finalize,现有 Toolkit 替代路径已实测通过,纳入 P5。共享 DB 未 reset。 -- **下一步**:按 [实现计划](implementation-plan.md) 执行 P1/P2/P3→独立 CLI→交付验收;需先取得本次实施授权。 +- **授权**:获批范围内的源码、文档与验证可以执行;设计/preflight 已提交为 a953676。2026-09-14 Sir 进一步授权自由提交、推送和创建 PR;明确禁止合并。正式发布仍必须经过各 owner 的 protected-main 流程。 +- **当前事实**:P1–P5 源码/本地文档已落地,[本地候选验收](local-acceptance.md) 四条旅程及相关静态构建通过。runtime 0.1.3 本地已准备、未发布,Core pin 未提前改变。共享 DB 未 reset。 +- **下一步**:按 owner 提交、推送并创建 PR,先 runtime 与 Hub,再 Core/client-web。runtime 正式发布后才更新 Core pin;Hub 合并后单独更新 refs。遵守禁止合并的授权边界,交付跟进见 [delivery.md](delivery.md)。 - **关闭条件**:[四条旅程](acceptance.md)、相关 runtime/Core/client-web 交付、CLI 正式 PyPI 安装复跑全部通过。preflight 不替代它们。 ## 产品与命令 From 281b5e1cf070266bee10e28e1365878be899854c Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Mon, 14 Sep 2026 17:22:12 +0800 Subject: [PATCH 4/8] =?UTF-8?q?docs(cli):=20=E7=99=BB=E8=AE=B0=E5=9B=9B?= =?UTF-8?q?=E4=B8=AA=20owner=20PR=20=E4=B8=8E=20review=20=E4=BE=9D?= =?UTF-8?q?=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../units/cli-sink/delivery.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tasks/knowledge-lifecycle-capabilities/units/cli-sink/delivery.md b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/delivery.md index 5190bd7..2b876dc 100644 --- a/tasks/knowledge-lifecycle-capabilities/units/cli-sink/delivery.md +++ b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/delivery.md @@ -17,9 +17,13 @@ ## 已推送 PR -- [ext-reg #35](https://github.com/InKCre/ext-reg/pull/35):`f22641d`,runtime 0.1.3 候选,等待 review/CI。 -- [Hub #25](https://github.com/InKCre/docs/pull/25):`f84d9ed`,只含两个跨单元合同增量,等待 review/CI。 -- Core 源码提交 `a2d2f93`;client-web 源码提交 `83a60ce`,PR 创建后在此补齐链接。 +- [ext-reg #35](https://github.com/InKCre/ext-reg/pull/35):源码 `f22641d`,补格式 `82a0b69`;runtime 0.1.3 候选,等待 review。首轮 CI 的 Changie Markdown 格式问题已按 Prettier 修正并推送。 +- [Hub #25](https://github.com/InKCre/docs/pull/25):`f84d9ed`,只含两个跨单元合同增量;Website contract 已通过,等待 review。 +- [Core #102](https://github.com/InKCre/core-py/pull/102):源码 `a2d2f93`,实施记录 `a206c99`;保留 draft,等待 runtime/Hub 前置交付。独立 inkcre-cli checks 首轮已通过。 +- [client-web #106](https://github.com/InKCre/client-web/pull/106):`83a60ce`;保留 draft,等待 Core stable 合同与 Hub ref。 + +四个 PR 均 base main、未合并、未启用 auto-merge。所有 PR body 已互相链接并写明跨仓库依赖和验收残余。 +CI/Preview 的最新状态以各 PR 的 GitHub checks 为准,不将创建时的 pending 快照当作永久失败或通过。 原有 Organization 收尾、共享 packet 中相应旧 delta 和未跟踪 Python skill 保持本地。只有 CLI 对共享 control 文件的行级变化进入本 unit;不为清空工作区提交其它任务。 From 3c3f2aa8637055194f914c7be5ee09459ea98288 Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Mon, 14 Sep 2026 18:10:43 +0800 Subject: [PATCH 5/8] =?UTF-8?q?docs(shared):=20=E5=90=8C=E6=AD=A5=20CLI=20?= =?UTF-8?q?=E4=B8=8E=20Job=20=E6=8E=A7=E5=88=B6=E7=9A=84=E5=B7=B2=E5=90=88?= =?UTF-8?q?=E5=85=A5=E5=90=88=E5=90=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/_shared | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/_shared b/docs/_shared index ecfe286..5d0d8d7 160000 --- a/docs/_shared +++ b/docs/_shared @@ -1 +1 @@ -Subproject commit ecfe286a650f712c92600e977b5532642129d796 +Subproject commit 5d0d8d7facfe68227c5bbc93361ba77f4573e7fa From 454d5531ea022f1655e3569bed32a9acc9a32e0c Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Mon, 14 Sep 2026 18:12:52 +0800 Subject: [PATCH 6/8] =?UTF-8?q?docs(cli):=20=E8=AE=B0=E5=BD=95=E5=90=88?= =?UTF-8?q?=E5=B9=B6=E5=8F=91=E5=B8=83=E6=8E=88=E6=9D=83=E4=B8=8E=E4=BA=A4?= =?UTF-8?q?=E4=BB=98=E8=BF=9B=E5=B1=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../units/cli-sink/delivery.md | 16 +++++++++++++--- .../units/cli-sink/packet.md | 4 ++-- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/tasks/knowledge-lifecycle-capabilities/units/cli-sink/delivery.md b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/delivery.md index 2b876dc..54ebb53 100644 --- a/tasks/knowledge-lifecycle-capabilities/units/cli-sink/delivery.md +++ b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/delivery.md @@ -1,7 +1,8 @@ # CLI 按 owner 交付 -2026-09-14,Sir 授权自由提交、推送和创建 PR,但明确禁止合并。继续使用 feature → main;不启用 auto-merge, -不从 PR 发布 canonical package,不借授权改写 shared main。没有跨 session 通信。 +2026-09-14,Sir 最新授权按依赖顺序合并本组 PR、确认 CLI 发布到 PyPI、本地安装连接生产 Core,验收通过后关闭 +unit;这取代此前的禁止合并限制。继续使用 feature → main,不从 PR 发布 canonical package,不直接推 shared main。 +没有跨 session 通信。上一条工具架构审查请求已撤回,没有因此修改源码。 ## 批次 @@ -32,4 +33,13 @@ control 文件的行级变化进入本 unit;不为清空工作区提交其它 四条本地旅程见 [local-acceptance.md](local-acceptance.md)。它们不替代正式 runtime/Registry 安装、 Core/client-web Preview 与 production、CLI 0.1.0 的 Release PR/PyPI 安装,以及 CLI 消费实际云端冷启动。 -PyPI Trusted Publisher 首次配置仍需确认。当前不合并任何 PR。 +PyPI Trusted Publisher 首次配置仍需确认。 + +## 正式交付进展 + +- ext-reg #35 已 squash merge 为 `71cbf6b`;Hub #25 已 squash merge 为 `5d0d8d7`。 +- Core Preview run 34827581213 已成功。Core/client-web 的 Hub ref 分别以独立提交更新到 `5d0d8d7`。 +- runtime release run 34831716076 整体成功,但 publication job 被 commit-title prefix 条件跳过,0.1.3 尚未发布。 + 补充 PR 只移除这个错误触发条件,保留已存在的版本检查、main 构建、已发布 Release 跳过逻辑;不新增发布框架。 +- PyPI 首发需要 pending Trusted Publisher:InKCre/core-py、cli-publish.yml、production。已向 Sir 请求确认, + 其余交付并行推进,不把权限前置项写成发布通过。 diff --git a/tasks/knowledge-lifecycle-capabilities/units/cli-sink/packet.md b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/packet.md index 2dad9fd..64c52b0 100644 --- a/tasks/knowledge-lifecycle-capabilities/units/cli-sink/packet.md +++ b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/packet.md @@ -4,9 +4,9 @@ - **目标**:让 Agent 和命令行用户在工作上下文中,通过独立 CLI 取用信息并操作 Core。 - **边界**:同仓库 cli/ 独立 Python/PDM project,pip 包 inkcre-cli。仅 Core 普通 REST;不导入 Core、不直接访问数据库、不作为 Peer,不是未来 Rust single-binary Peer。 - **位置**:core-py feat/inkcre-cli,base b3ccb00ca2e235bfcc9b9f4f4cc17948c59ef54a;决策 D-571–D-610。 -- **授权**:获批范围内的源码、文档与验证可以执行;设计/preflight 已提交为 a953676。2026-09-14 Sir 进一步授权自由提交、推送和创建 PR;明确禁止合并。正式发布仍必须经过各 owner 的 protected-main 流程。 +- **授权**:2026-09-14 Sir 最新授权按依赖顺序合并相关 PR、发布 CLI、从 PyPI 本地安装并连接生产 Core 验收;通过后关闭 unit。此授权取代此前的禁止合并限制;正式发布仍经过各 owner 的 protected-main 流程。 - **当前事实**:P1–P5 源码/本地文档已落地,[本地候选验收](local-acceptance.md) 四条旅程及相关静态构建通过。runtime 0.1.3 本地已准备、未发布,Core pin 未提前改变。共享 DB 未 reset。 -- **下一步**:按 owner 提交、推送并创建 PR,先 runtime 与 Hub,再 Core/client-web。runtime 正式发布后才更新 Core pin;Hub 合并后单独更新 refs。遵守禁止合并的授权边界,交付跟进见 [delivery.md](delivery.md)。 +- **下一步**:runtime #35 与 Hub #25 已合并。先补齐 runtime 发布触发,再更新 Core pin 并交付 Core/client-web;最后正常 Release PR → PyPI → 生产验收。详见 [delivery.md](delivery.md)。 - **关闭条件**:[四条旅程](acceptance.md)、相关 runtime/Core/client-web 交付、CLI 正式 PyPI 安装复跑全部通过。preflight 不替代它们。 ## 产品与命令 From d5679e6957a0fde3fb27d76adbec7db8ab7a2459 Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Mon, 14 Sep 2026 18:21:05 +0800 Subject: [PATCH 7/8] =?UTF-8?q?docs(cli):=20=E8=AE=B0=E5=BD=95=20runtime?= =?UTF-8?q?=20=E5=8F=91=E5=B8=83=E6=8E=92=E9=9A=9C=E4=B8=8E=E9=A6=96?= =?UTF-8?q?=E5=8F=91=E9=83=A8=E7=BD=B2=E9=A1=BA=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../units/cli-sink/delivery.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tasks/knowledge-lifecycle-capabilities/units/cli-sink/delivery.md b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/delivery.md index 54ebb53..681fe0a 100644 --- a/tasks/knowledge-lifecycle-capabilities/units/cli-sink/delivery.md +++ b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/delivery.md @@ -43,3 +43,8 @@ PyPI Trusted Publisher 首次配置仍需确认。 补充 PR 只移除这个错误触发条件,保留已存在的版本检查、main 构建、已发布 Release 跳过逻辑;不新增发布框架。 - PyPI 首发需要 pending Trusted Publisher:InKCre/core-py、cli-publish.yml、production。已向 Sir 请求确认, 其余交付并行推进,不把权限前置项写成发布通过。 +- runtime 触发修正 #36 已合并为 `0f05bb2`;首次实际发布 run 34832208751 进一步暴露 package lane 调用 + Registry 数据库验收、缺少 `REGISTRY_TEST_DATABASE_URL`。#37 保留包的静态/生成合同检查与独立发布构建, + 不把 Registry 服务数据库变成独立包发布的前置;完整 PR CI 已通过后合并,等待新发布结果。 +- Core 生产自动路径按 Core version 变化选择。#102 合并后的首发将用既有 workflow_dispatch 交付其精确 + main artifact,确认新 REST 可用后再合入生成的 Release PR,避免 CLI 首发先于 provider;不添加长期依赖 gate。 From e057cad7a65b4b1aec5e93a6b91c9520527dd704 Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Mon, 14 Sep 2026 18:24:37 +0800 Subject: [PATCH 8/8] =?UTF-8?q?build(runtime):=20=E6=8E=A5=E5=85=A5?= =?UTF-8?q?=E5=B7=B2=E5=8F=91=E5=B8=83=E7=9A=84=20Extension=20runtime=200.?= =?UTF-8?q?1.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pdm.lock | 8 ++++---- pyproject.toml | 2 +- .../units/cli-sink/delivery.md | 3 +++ 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/pdm.lock b/pdm.lock index c3f52a1..9f3c44e 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "dev", "extension-preview", "extension-publisher"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:82a4849d2ba8bdfc6c76f331e67a03bab48fa84de76326c203e1267ad0efa086" +content_hash = "sha256:ed714523dc112e5a15a1533808b74141e592b843a1f0a10def093de40633faf2" [[metadata.targets]] requires_python = ">=3.12,<3.13" @@ -832,9 +832,9 @@ files = [ [[package]] name = "inkcre-extension-runtime-core-py" -version = "0.1.2" +version = "0.1.3" requires_python = "<3.14,>=3.12" -url = "https://github.com/InKCre/ext-reg/releases/download/runtime-core-py-v0.1.2/inkcre_extension_runtime_core_py-0.1.2-py3-none-any.whl" +url = "https://github.com/InKCre/ext-reg/releases/download/runtime-core-py-v0.1.3/inkcre_extension_runtime_core_py-0.1.3-py3-none-any.whl" summary = "InKCre Core Python Extension Host Runtime" groups = ["default"] dependencies = [ @@ -845,7 +845,7 @@ dependencies = [ "semantic-version<3,>=2.10", ] files = [ - {file = "inkcre_extension_runtime_core_py-0.1.2-py3-none-any.whl", hash = "sha256:0d2f21390c580fb823e6c5f0c175f2bdcd03591a3dff45e4c8510fe64765d0c0"}, + {file = "inkcre_extension_runtime_core_py-0.1.3-py3-none-any.whl", hash = "sha256:613b500183ffd8afa6a6dd560a226a8994e73e24148ad84d37536ec804023075"}, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index b97d98e..2ffa722 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ authors = [{ name = "Lan_zhijiang", email = "lanzhijiang@foxmail.com" }] readme = "README.md" requires-python = ">=3.12,<3.13" dependencies = [ - "inkcre-extension-runtime-core-py @ https://github.com/InKCre/ext-reg/releases/download/runtime-core-py-v0.1.2/inkcre_extension_runtime_core_py-0.1.2-py3-none-any.whl", + "inkcre-extension-runtime-core-py @ https://github.com/InKCre/ext-reg/releases/download/runtime-core-py-v0.1.3/inkcre_extension_runtime_core_py-0.1.3-py3-none-any.whl", "authlib>=1.7.2,<2.0.0", "pydantic (>=2.11.7,<3.0.0)", "pydantic-settings (>=2.14.2,<3.0.0)", diff --git a/tasks/knowledge-lifecycle-capabilities/units/cli-sink/delivery.md b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/delivery.md index 681fe0a..0582099 100644 --- a/tasks/knowledge-lifecycle-capabilities/units/cli-sink/delivery.md +++ b/tasks/knowledge-lifecycle-capabilities/units/cli-sink/delivery.md @@ -48,3 +48,6 @@ PyPI Trusted Publisher 首次配置仍需确认。 不把 Registry 服务数据库变成独立包发布的前置;完整 PR CI 已通过后合并,等待新发布结果。 - Core 生产自动路径按 Core version 变化选择。#102 合并后的首发将用既有 workflow_dispatch 交付其精确 main artifact,确认新 REST 可用后再合入生成的 Release PR,避免 CLI 首发先于 provider;不添加长期依赖 gate。 +- runtime #37 合并为 `006759a`;正式发布 run 34832733241 成功。`runtime-core-py-v0.1.3` 的 wheel/sdist 已可下载, + Core 更新到该发布 URL;锁文件除 content hash 外仅改变这个 package,wheel SHA-256 为 + `613b500183ffd8afa6a6dd560a226a8994e73e24148ad84d37536ec804023075`。