dsh-data-contracts
v0.1.0-alpha.9
Published
Shared wire contracts for Tita Data Agent plugins
Readme
dsh-data-contracts
Tita Data Agent 插件之间共享的 wire-level 契约。这里不放领域逻辑,只放形状: 结果信封、跨插件引用与血缘、版本模型、跨插件 HTTP 形状、磁盘布局。
它不是一个界面,也不该有界面:没有 React、没有 CSS、零运行时依赖——Host(Node) 与浏览器半边都要 import 它。
按变更频率分子路径
根入口(dsh-data-contracts)只是全部重导出,留给向后兼容与一次性探查。
正常用法是走子路径,import 语句本身就说明依赖的是哪一类契约:
import { projectRootOf } from 'dsh-data-contracts/layout'| 子路径 | 装什么 | 变更频率 |
| --- | --- | --- |
| .../envelope | 结果信封(Envelope / ToolStatus / toolStatus / isRecord) | 最慢:跨所有人的约定 |
| .../refs | 跨插件引用与血缘(SheetRef / SelectionRef / LineageContext / LineageRef) | 慢 |
| .../version | 版本模型 + 表格地址 + 检查点保留 + 时间线 | 中 |
| .../wire | 跨插件 HTTP 形状 + 校验器 | 中:改了两端一起改 |
| .../layout | 磁盘布局(projectRootOf / STORAGES_DIRECTORY / isProjectDirectory / 存储文件名规则) | 最慢:持久化契约 |
结果信封(./envelope)
跨工具 / HTTP 边界的返回形状:
| 形状 | 键 | 用在哪 |
| --- | --- | --- |
| Envelope<T, E> | 5:ok / status / data / warnings / error | 工具与 HTTP 返回 |
- 构造只走
envelopeSuccess()/envelopeFailure(),不要手写字面量:手写正是error: null从 intent 结果里漏掉的原因。 status由toolStatus(warnings)派生:warnings非空即partial,否则success;failed只与ok: false同行,所以成功的信封永远不会带它。- 错误类型是泛型:知识插件用
Envelope<T, KnowledgeError>保住retryable/suggestion,而不必 fork 一份信封。 - 诚实说明:目前只有 knowledge 插件(
/knowledge、/convert、/intent)完整走这个 信封;table 与 notebook 的 HTTP 面还是各自的{ ok, ... }/{ ok, error }形状。 别把这里的类型当成「全产品已经统一」。
跨插件线协议(./wire)
全仓唯一的跨插件 HTTP 调用方向是 notebook → table,两条只读路由:
GET /data-sheet/workbooks.json
GET /data-sheet/workbook/{workbookId}/range?sheet=&session=&range=&styles=1形状与校验器都定义在这里一次,两端都从它派生:
- 生产端(table)用契约类型标注返回值,字段改名在它自己那里编译不过;
- 消费端(notebook)用契约自带的校验器(
isWorkbookCatalogResponse/isSheetRangeResponse/isWireErrorBody),形状不对就明确报错,而不是静默降级成 空目录 / 不画样式。
跨插件引用与血缘(./refs)
LineageContext 是跨插件的可选关联字段,推荐由 Host 在一次会话中维护:
sessionId → turnId → intentId → taskId → subtaskId → runId
taskId表示用户的业务目标;同一目标下的多个工作流节点共享它。subtaskId表示一个可执行的计划节点;组合意图的子节点会记录parentSubtaskId。runId表示一次具体执行尝试,重试时应变化。traceId仅用于技术追踪,不能替代业务taskId。
ID 由 Host / runtime 生成或继承,模型只提供已有 ID(例如恢复、重试时),不让模型自行编造。
知识卡片用 provenance 记录创建、派生、召回和执行关联;Sheet 操作、Notebook 执行请求、
知识编译报告和召回结果都返回或持久化同一组 lineage 字段。
SheetRef 与 SelectionRef 是 Table / Notebook / 画布之间的最小引用形状。
版本模型(./version)
data-table 与 data-notebook 各保留自己的版本流,只共享语义:
ArtifactKind:workbook/notebook。VersionStatus:open/committed/restored/superseded。committed取代了历史上的final(工作表)与「没有状态」(Notebook)。VersionKind:named(手动保存、一次 Agent 调用、一次恢复)与auto(编辑会话自动产生的检查点)。v3 之前没有kind的记录按named读。RevisionKind:只有content推进内容 revision 并形成 Version;derived/metadata只进审计流。VersionRecord/VersionStepRecord:版本与版本内的可回放步骤。VersionChange/TextDiffLine:跨插件中立的差异条目。group(工作表 / Notebook Block)用于分组 tab,row/column让相邻改动能折叠成 hunk,lines承载文本行级 diff,styleOnly把「只改了格式」单独归类——否则给一片区域设格式会把 473 个样式单元格 报成 473 处编辑。
版本策略
| 导出 | 规则 |
| --- | --- |
| VERSION_IDLE_CLOSE_MS = 10 分钟 | 编辑会话的空闲窗口,不是输入防抖 |
| VERSION_MAX_STEPS = 20 | 一个 Version 的步数上限,是除空闲窗口外唯一的自动边界 |
| AUTO_CHECKPOINT_KEEP = 100 | 保留的最近 auto 检查点数 |
| selectAutoCheckpointsToPrune() | 存储裁剪:named 全留、最近 N 条、每天最后一条,并且保护仍然被引用的恢复来源与父版本 |
| versionTimeline() | 抽屉列表:收录条件与裁剪规则刻意同形,所以列表不会藏掉存储仍保留的版本,也不会给出已被删掉的版本 |
| versionDayLabel() | 今天 / 昨天 / 更早 分组 |
| undoTargetOf() | 「撤销这次改动」的目标;优先非 superseded 的前一个版本 |
| restoredFromNumberOf() | 把 restoredFromVersionId 解析成用户看到的 V3 |
地址与冲突
columnLabel/cellAddress/formatCellReference:唯一的列字母实现 (0 → A、26 → AA、701 → ZZ)。单点与区间分开拼装,所以一个单元格不会写成退化的C3:C3;引用表格时也不会出现Sheet1!R3C2与R3C2两套写法。ConflictPayload/conflictPayload():两个插件统一的 409 body (REVISION_CONFLICT+ base/current revision + conflictSet +nextAction),让界面能 给出「重新读取 / 查看差异」而不是一句错误字符串。
磁盘布局(./layout)
projectRootOf() / isProjectDirectory() / STORAGES_DIRECTORY:table / notebook /
excalidraw 三个插件都必须对「项目根在哪、工作簿和 notebook 存哪」给出同一个答案。这不是
审美问题——notebook 的 Sheet 单元格通过 table 的 HTTP 路由读工作簿,两边算法不一致就是
找不到文件。
存储文件名(storageNameOf / storageSegment / compactDate)
落盘名就是 id,所以「叫什么」也是持久化契约:<project>/storages/<plugin>/<名字>.<扩展名>
(工作簿与笔记本 .json,画布 .excalidraw)。新建的文件叫 yyyymmdd-中文名含义,中文名
(日期前缀之后那整段)≤ 25 个字——超过一屏半行的名字等于没有名字。
storageNameOf(含义):新建时用。带yyyymmdd-前缀的输入保留自己的日期,不叠第二个。storageSegment(文本):只管字符集,保留汉字 / 字母 / 数字 /_ - .,其余并成-。 已存在的 id 也走它(normalizeWorkbookId/normalizeNotebookId),所以它不含日期、 不截断——给老文件补日期或截短名字,就是让老文件再也找不到。- 日期是本地日期,和知识插件
output/<YYYY-MM-DD>/按本地日分桶同一条理由:用户说 「今天建的」就是他日历上的今天。
调用方给的 id 原样使用(只过字符集):id 是地址,改一次日期就等于昨天建的文件今天用名字 找不到。
三个插件都要接,但各接各的入口——「新建」与「改一个已存在的」必须分开:
| 插件 | 新建走规则 | 原样使用(地址) |
| --- | --- | --- |
| Table | workbook_catalog 的 create + name | 给了 workbookId 时 |
| Notebook | notebook_catalog 的 create + name | 给了 notebookId 时 |
| Excalidraw | excalidraw_create(title)与面板「新建画布」 | 给了 scene / path 时;main.excalidraw 是省略名字时的默认地址,不是新建名 |
画布接得最晚:它长期把新文件也叫 main.excalidraw / 画布 N,于是这条规则对画布的新建
没有生效。两侧共用 scene-names.ts 的 mintedSceneName / uniqueSceneName,不是各写一份。
谁在用
| 消费者 | 用到 |
| --- | --- |
| Table | ./version ./layout ./wire(生产端) |
| Notebook | ./version ./refs ./layout ./wire(消费端) |
| Excalidraw | ./layout |
| Knowledge | ./envelope ./refs |
源码
发布的包里带 src/。这些函数与形状决定用户看得见的行为(地址怎么写成 Sheet1!C3、
版本时间线怎么分组、diff 往哪个方向渲染),所以改它们属于改界面,不属于重构。
pnpm build
pnpm test