Merge origin/master into worktree/deepseek-max-tokens-defaults
# Conflicts: # docs/cordis-catalog/services.md # docs/core-data-structures/core.i18n.yaml # docs/core-data-structures/session.i18n.yaml # packages/core/session/README.i18n.yaml
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/README.md
|
||||
README.md: 3f467641bbc9eae14a94aa2d3bff0402116a9d3f
|
||||
README.zh.md: c9d11bbf6239b4239a4e037dac63b05d3a9a58f7
|
||||
README.md: 11179cf6676d1b4382816e34285529b51152fe8d
|
||||
README.zh.md: 100d918287613973604b2f85060572b8ee41d132
|
||||
|
||||
@@ -39,6 +39,7 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
|
||||
| [`session-projection/`](session-projection/README.md) | Projection seam: domain fold units serve whole values | Product — stable surface |
|
||||
| [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, bounded reads, lineage, event relationships, semantic filtering, and SQLite full-text search | Product — stable surface |
|
||||
| [`session-title/`](session-title/README.md) | Log-backed session titles: fallback service and opt-in LLM providers | Product — stable surface |
|
||||
| [`settings/`](settings/README.md) | User-settings seam + file-backed provider | Product — stable surface |
|
||||
| [`telemetry/`](telemetry/README.md) | Session reporting: capture/redact seam, OTel backend | Product — stable surface |
|
||||
| [`storage/`](storage/README.md) | Non-session storage hub + backends + domain form | Product — stable surface |
|
||||
| [`workspace/`](workspace/README.md) | Workspace entity | Product — stable surface |
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
| [`session-projection/`](session-projection/README.md) | 投影 seam:领域折叠单元供给全量值 | 产品:稳定表面 |
|
||||
| [`session-query/`](session-query/README.md) | 会话检索系列:逻辑语料库、有界读取、血缘、事件关系、语义过滤和 SQLite 全文搜索 | 产品:稳定表面 |
|
||||
| [`session-title/`](session-title/README.md) | 日志支撑的会话标题:回退服务与选用 LLM 提供方 | 产品:稳定表面 |
|
||||
| [`settings/`](settings/README.md) | 用户设置 seam + 文件 provider | 产品:稳定表面 |
|
||||
| [`telemetry/`](telemetry/README.md) | 会话上报:捕获/脱敏 seam、OTel 后端 | 产品:稳定表面 |
|
||||
| [`storage/`](storage/README.md) | 非会话存储中枢 + 后端 + 领域形式 | 产品:稳定表面 |
|
||||
| [`workspace/`](workspace/README.md) | Workspace 实体 | 产品:稳定表面 |
|
||||
|
||||
@@ -748,6 +748,32 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'settings',
|
||||
summary: 'Abstract settings service.',
|
||||
methods: [
|
||||
{
|
||||
signature: 'register<T>(ns: SettingsNamespace, schema: z<T>, options?: SettingsRegisterOptions<T>): SettingsScope<T>',
|
||||
jsDoc: '/**\n * Register a namespace schema and receive its owner scope. The registration\n * is an effect on the calling plugin\'s fiber: disposing that fiber removes\n * the namespace and its observers. An invalid stored section fails the\n * registration itself — the earliest point where the schema can judge it.\n * @param ns - unique namespace; duplicate registration fails loud.\n * @param schema - schemastery schema resolving this namespace\'s value.\n * @param options - composition `base` layer and effect timing.\n * @returns the owner scope for reads, observation, and updates.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'describe(): SettingsDescriptor[]',
|
||||
jsDoc: '/**\n * Describe every registered namespace for configuration surfaces.\n * @returns one descriptor per registered namespace, in registration order.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'get(ns: SettingsNamespace): unknown',
|
||||
jsDoc: '/**\n * Read one registered namespace\'s resolved value.\n * @param ns - the namespace to read.\n * @returns the resolved value, or `undefined` while unregistered.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'async update(ns: SettingsNamespace, patch: object): Promise<void>',
|
||||
jsDoc: '/**\n * Merge a patch into one registered namespace\'s user layer, validate the\n * resolved candidate, persist through the provider, then commit and emit.\n * A validation failure rejects before anything is persisted. Writes to one\n * namespace are serialized: concurrent updates apply in call order, each\n * merging over the previous write\'s committed section.\n * @param ns - the registered namespace to update.\n * @param patch - plain-object patch over the user section.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'async replace(ns: SettingsNamespace, section: object): Promise<void>',
|
||||
jsDoc: '/**\n * Replace one registered namespace\'s user section wholesale, validate,\n * persist, then commit and emit. Keys absent from `section` fall back to the\n * composition `base` and schema defaults — this is the removal/reset path a\n * merge-only patch cannot express (`replace({})` re-inherits everything).\n * @param ns - the registered namespace to replace.\n * @param section - the complete next user section.\n */',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'skills',
|
||||
summary: 'Registry of skill providers.',
|
||||
@@ -1315,6 +1341,13 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
||||
jsDoc: '/**\n * Awaited parallel durability checkpoint: every listener runs and the\n * caller awaits all of them, with no waterfall veto. Dispatch through\n * {@link SessionStore.flush}. Scope-filtered dispatch\n * (`@deepseek-ai/dsh-scope`) reuses the session\'s owner scope.\n * @param session - the session whose buffered events must reach durable storage.\n * @dshScopeScan unsupported\n * @mode parallel\n */',
|
||||
summary: 'Awaited parallel durability checkpoint: every listener runs and the caller awaits all of them, with no waterfall veto.',
|
||||
},
|
||||
{
|
||||
name: 'settings/updated',
|
||||
mode: 'emit',
|
||||
signature: '\'settings/updated\'(ns: SettingsNamespace, next: unknown, prev: unknown, source: SettingsUpdateSource): void',
|
||||
jsDoc: '/**\n * Committed change to one registered namespace\'s resolved value. Emitted\n * after the provider persisted (for `update`) or published (`provider`)\n * the change; never emitted when the resolved value is deep-equal.\n * Listener failures are contained and logged — a sync throw and an async\n * rejection alike — except `INVARIANT`-coded failures, which rethrow\n * after every listener ran; that rethrow reaches the emitter only from\n * synchronous listeners, so invariant checks on this event must not be\n * async functions.\n * @param ns - the namespace whose resolved value changed.\n * @param next - the new resolved value.\n * @param prev - the previous resolved value.\n * @param source - whether the change entered through `update()` or the provider.\n * @mode emit\n */',
|
||||
summary: 'Committed change to one registered namespace\'s resolved value.',
|
||||
},
|
||||
{
|
||||
name: 'skills/change',
|
||||
mode: 'emit',
|
||||
@@ -2375,6 +2408,26 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'SessionTitleUserMessage',
|
||||
declaration: 'export interface SessionTitleUserMessage {\n readonly seq: number;\n readonly text: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SettingsApplies',
|
||||
declaration: 'export type SettingsApplies = \'live\' | \'restart\';',
|
||||
},
|
||||
{
|
||||
name: 'SettingsDescriptor',
|
||||
declaration: 'export interface SettingsDescriptor {\n ns: SettingsNamespace;\n schema: unknown;\n value: unknown;\n applies: SettingsApplies;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SettingsNamespace',
|
||||
declaration: 'export type SettingsNamespace = Branded<\'SettingsNamespace\'>;',
|
||||
},
|
||||
{
|
||||
name: 'SettingsRegisterOptions',
|
||||
declaration: 'export interface SettingsRegisterOptions<T> {\n base?: Partial<T>;\n applies?: SettingsApplies;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SettingsScope',
|
||||
declaration: 'export interface SettingsScope<T> {\n get(): T;\n watch(callback: (next: T, prev: T) => void | Promise<void>): () => void;\n update(patch: object): Promise<void>;\n replace(section: object): Promise<void>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SkillCandidate',
|
||||
declaration: 'export interface SkillCandidate extends SkillSummary {\n readonly rank: number;\n readonly locator: unknown;\n readonly path?: string;\n readonly metadata?: Readonly<Record<string, unknown>>;\n}',
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/core/session/README.md
|
||||
README.md: a4944fd974fda5ee1bd6ecadf29ef5fa322e9dc3
|
||||
README.zh.md: 3578ea3aa48382609e075518b0f8a7f8851761e4
|
||||
README.md: 132e627387aff54381d10e87a52dc1440d0a5b56
|
||||
README.zh.md: edf94f765b81a81dfe39662e967cd6dfdca140fa
|
||||
|
||||
@@ -60,6 +60,7 @@ Providers stream token-sized deltas, so a raw log stores hundreds of `assistant/
|
||||
- `SessionSurface` — the readonly live `nodes` and `replaceGeneration` projection exposed by `session.surface`; candidate validation remains private to `Session`.
|
||||
- `foldSurface(events)` — replay the canonical surface contract into detached current event sequences and actual replacement ranges. The same pass rejects non-contiguous seqs, misplaced or malformed metadata, empty or duplicate provenance, non-earlier sources, invalid positional ranges, replacements that fail to cite every shadowed surface entry, and a `tool/result` replacement that changes anything except one current result's `content`; `SurfaceManager` shares the atomic transition while retaining only its incremental sequence cache.
|
||||
- `isSurfaceEvent(event)` / `isSurfaceEligibleType(type)` — the first narrows a `SessionEvent` to a fully formed surface event; the second detects a surface-eligible event missing its marker when validating a seed or loaded log.
|
||||
- `isAppendSurfaceEvent(event)` / `isReplacementSurfaceEvent(event)` — split a formed surface event by marker variant. Append-origin events are the durable source for a human transcript, which is not the model-visible surface: a landed replacement shadows the range it summarizes, so projecting a transcript from `session.surface` erases conversation the reader already saw. Consumers that must send exactly what the model sees keep reading `session.surface`.
|
||||
|
||||
### Request-header reconstruction (`request-header.ts`)
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
- `SessionSurface`:实时只读 `nodes` 和 `replaceGeneration` 投影,由 `session.surface` 暴露;候选校验仍由 `Session` 私有。
|
||||
- `foldSurface(events)`:回放规范 surface 契约,得到脱离的当前事件序列与实际替换范围。同一趟处理会拒绝不连续序号、错位或畸形元数据、空或重复溯源信息、来源并非更早事件、无效位置范围,以及没有引用所有已遮蔽 surface 条目的替换。如果一个 `tool/result` 替换修改了当前某个结果的 `content` 之外的任何内容,也会被拒绝;`SurfaceManager` 共享该原子状态转换,但只保留自己的增量序列缓存。
|
||||
- `isSurfaceEvent(event)`/`isSurfaceEligibleType(type)`:前者将 `SessionEvent` 收窄为形态完整的 surface 事件;后者在校验种子或已加载日志时,检测缺少标记的可进入 surface 事件。
|
||||
- `isAppendSurfaceEvent(event)`/`isReplacementSurfaceEvent(event)`:按标记变体拆分形态完整的 surface 事件。追加来源的事件是人类可读记录(transcript)的持久来源,而该记录并非模型可见的 surface:已落地的替换会遮蔽它所概括的范围,因此从 `session.surface` 投影记录会抹掉读者已经看到的对话。必须准确发送模型所见内容的消费方仍继续读取 `session.surface`。
|
||||
|
||||
### 请求头重建(`request-header.ts`)
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ export { interruptedTurnClosers, lastActivityTime, TOOL_NOT_STARTED, TOOL_OUTCOM
|
||||
export { decodeStorageRecord, packChunkRuns } from './chunk-rows.ts'
|
||||
export type { ChunkRow, StorageRecord } from './chunk-rows.ts'
|
||||
export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult } from './surface.ts'
|
||||
export { foldSurface, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts'
|
||||
export { foldSurface, isAppendSurfaceEvent, isReplacementSurfaceEvent, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts'
|
||||
export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts'
|
||||
|
||||
/**
|
||||
@@ -505,7 +505,8 @@ export class Session {
|
||||
* the ordered surface; `sourceEventSeqs` records provenance (the seq
|
||||
* numbers of events this one derives from). REQUIRED for
|
||||
* {@link SurfaceEventType} events (every message-producing event must
|
||||
* declare how it joins the surface, the sole source of derived history) and
|
||||
* declare how it joins the surface, the sole source of derived model
|
||||
* history) and
|
||||
* rejected by the compiler for non-surface types like `turn/start` or
|
||||
* `assistant/chunk`.
|
||||
* @returns the logged event — its assigned `seq`/`time` plus the SNAPSHOT of
|
||||
|
||||
@@ -37,6 +37,36 @@ export function isSurfaceEvent(event: SessionEvent): event is SurfaceEvent {
|
||||
return (event as SessionEvent<SurfaceEventType>).surfaceOp !== undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow an event to an append-origin surface event: one that entered the
|
||||
* surface at its own log position and was never itself a replacement copy.
|
||||
*
|
||||
* The model-visible surface deliberately shadows replaced ranges, so it is the
|
||||
* wrong source for a human transcript — a landed replacement would erase
|
||||
* conversation the user already saw. Append-origin events are that transcript's
|
||||
* durable source material; replacement copies stay model-only.
|
||||
* @param event - event to test.
|
||||
* @returns true when the event appended to the surface tail.
|
||||
*/
|
||||
export function isAppendSurfaceEvent(
|
||||
event: SessionEvent,
|
||||
): event is SurfaceEvent & { surfaceOp: 'append' } {
|
||||
return isSurfaceEvent(event) && event.surfaceOp === 'append'
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow an event to a surface replacement: a node that shadowed an existing
|
||||
* surface range instead of appending to the tail. The counterpart of
|
||||
* {@link isAppendSurfaceEvent} over the two {@link SurfaceOp} variants.
|
||||
* @param event - event to test.
|
||||
* @returns true when the event replaced a surface range.
|
||||
*/
|
||||
export function isReplacementSurfaceEvent(
|
||||
event: SessionEvent,
|
||||
): event is SurfaceEvent & { surfaceOp: Extract<SurfaceOp, { op: 'replace' }> } {
|
||||
return isSurfaceEvent(event) && event.surfaceOp !== 'append'
|
||||
}
|
||||
|
||||
/** One replacement operation observed while folding a session surface. */
|
||||
export interface SurfaceFoldReplacement {
|
||||
/** Seq of the event that replaced the prior surface range. */
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
Session,
|
||||
SessionId,
|
||||
foldSurface,
|
||||
isAppendSurfaceEvent,
|
||||
isReplacementSurfaceEvent,
|
||||
isSurfaceEligibleType,
|
||||
isSurfaceEvent,
|
||||
} from '@deepseek-ai/dsh-session'
|
||||
@@ -861,6 +863,40 @@ describe('surface type guards', () => {
|
||||
expect(isSurfaceEligibleType(markerless.type)).toBe(true)
|
||||
expect(isSurfaceEvent(markerless)).toBe(false)
|
||||
})
|
||||
|
||||
it('splits surface events into append-origin and replacement by their marker', () => {
|
||||
const s = surfaceSession()
|
||||
s.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'checkpoint' }], source: { kind: 'plugin', plugin: 'compact' },
|
||||
}), { surfaceOp: { op: 'replace', start: 1, end: 2 }, sourceEventSeqs: [1, 2] })
|
||||
const appended = s.events.find(e => e.type === 'user/message')!
|
||||
const replacement = s.events.at(-1)!
|
||||
|
||||
expect(isAppendSurfaceEvent(appended)).toBe(true)
|
||||
expect(isReplacementSurfaceEvent(appended)).toBe(false)
|
||||
expect(isAppendSurfaceEvent(replacement)).toBe(false)
|
||||
expect(isReplacementSurfaceEvent(replacement)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects log-only and markerless events from both marker guards', () => {
|
||||
const s = surfaceSession()
|
||||
const turnStart = s.events.find(e => e.type === 'turn/start')!
|
||||
// A surface-eligible type whose mandatory marker is absent has no origin at
|
||||
// all: it never entered the surface.
|
||||
const markerless: SessionEvent = {
|
||||
type: 'user/message',
|
||||
seq: 0,
|
||||
time: 0,
|
||||
data: createUserMessage({
|
||||
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
|
||||
}),
|
||||
}
|
||||
|
||||
expect(isAppendSurfaceEvent(turnStart)).toBe(false)
|
||||
expect(isReplacementSurfaceEvent(turnStart)).toBe(false)
|
||||
expect(isAppendSurfaceEvent(markerless)).toBe(false)
|
||||
expect(isReplacementSurfaceEvent(markerless)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SurfaceManager.replaceGeneration', () => {
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
|
||||
README.md: 0a3d29e41dd0e203c2f576192ea7e88ecb904fac
|
||||
README.zh.md: b91f4e697ff04eedaaa2fc093229f1c459a1655a
|
||||
README.md: 8f9deb6add7d30bf1609cc7febcb1febafe392c1
|
||||
README.zh.md: 399d45208b6d3f4152c27556523b6944432bec66
|
||||
|
||||
@@ -10,6 +10,8 @@ Wire messages form a four-quadrant discriminated union — who initiates × requ
|
||||
|
||||
The layering/protocol decisions are recorded in the [GUI layering and RPC protocol RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md); the browser-side consumption architecture in the [web client architecture RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md).
|
||||
|
||||
`session.history` pages on append-origin message boundaries: `maxMessages` counts `user/message`, `assistant/message`, and `steering/message` events that entered the surface by appending, so a model-only replacement copy consumes no quota. Each page stays one contiguous raw event range, which keeps a compaction's log-only provenance on the same page as the replacement that cites it.
|
||||
|
||||
`session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds zero domain knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface.
|
||||
|
||||
Session titles ride the generic projection pair like every other domain — the history-tail `projections` block plus `session/projection` frames under the `title` key (the bespoke `session/title` frame is retired). Titles do not join `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs. `session.rename` accepts an explicit user title (resuming a cold session first), delegating to `ctx.sessionTitle.rename` — the accepted `session/title` event pins the title against automatic regeneration — and returns the normalized title plus its event seq so a client settles its `title` projection cell ahead of the push frame; a title that normalizes to empty returns `title-invalid`.
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
|
||||
分层与协议决策记录在 [GUI 分层与 RPC 协议 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)中;浏览器侧消费架构记录在 [Web 客户端架构 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md)中。
|
||||
|
||||
`session.history` 按追加来源的消息边界分页:`maxMessages` 统计以追加方式进入 surface 的 `user/message`、`assistant/message` 和 `steering/message` 事件,因此仅供模型使用的替换副本不占用配额。每一页仍是一段连续的原始事件区间,从而让压缩(compaction)的仅日志溯源信息与引用它的替换留在同一页。
|
||||
|
||||
`session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections`(`@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq(空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元铸造一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有任何领域知识(每个值在注册表内部已过其单元自己的 schema;协议 schema 对 `values`/`value` 保持宽松);loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。
|
||||
|
||||
会话标题与其他所有领域一样搭乘这对通用投影机制——历史尾页的 `projections` 块外加 `title` 键下的 `session/projection` 帧(专设的 `session/title` 帧已下线)。标题不会加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。`session.rename` 接受用户显式标题(冷会话先恢复),委托给 `ctx.sessionTitle.rename`——被接受的 `session/title` 事件将标题钉住、不再被自动生成覆盖——并返回规范化后的标题及其事件 seq,让 client 在推送帧到达前就结算自己的 `title` 投影格;规范化后为空的标题返回 `title-invalid`。
|
||||
|
||||
@@ -14,7 +14,7 @@ import type {
|
||||
import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import { errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import type { MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import { lastActivityTime } from '@deepseek-ai/dsh-session'
|
||||
import { isAppendSurfaceEvent, lastActivityTime } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
||||
import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace'
|
||||
@@ -60,14 +60,18 @@ import { openNativePath } from './native-path-opener.ts'
|
||||
/** Page size when history is called without maxMessages. */
|
||||
const DEFAULT_MAX_MESSAGES = 50
|
||||
|
||||
/** Surface message event types (the pagination counting unit). */
|
||||
/** Conversation message event types (the pagination counting unit). */
|
||||
const MESSAGE_TYPES = new Set(['user/message', 'assistant/message', 'steering/message'])
|
||||
|
||||
/**
|
||||
* Message-boundary pagination: count maxMessages surface messages backwards from
|
||||
* the window tail; the cut is the starting seq of the oldest message group
|
||||
* (chunks group via sourceEventSeqs — never cut mid-message). The tail page
|
||||
* naturally includes the in-progress partial.
|
||||
* Message-boundary pagination: count maxMessages append-origin messages
|
||||
* backwards from the window tail. Replacement copies never entered the
|
||||
* conversation a reader sees — they restate a shadowed range for the model
|
||||
* alone — so they consume no quota; the page stays one contiguous raw range,
|
||||
* which keeps a compaction's log-only provenance on the same page as its
|
||||
* replacement. The cut is the starting seq of the oldest message group (chunks
|
||||
* group via sourceEventSeqs — never cut mid-message). The tail page naturally
|
||||
* includes the in-progress partial.
|
||||
*/
|
||||
function paginate(
|
||||
events: readonly SessionEvent[],
|
||||
@@ -79,7 +83,7 @@ function paginate(
|
||||
let cut = 0
|
||||
for (let i = window.length - 1; i >= 0; i--) {
|
||||
const event = window[i] as SessionEvent
|
||||
if (!MESSAGE_TYPES.has(event.type)) continue
|
||||
if (!MESSAGE_TYPES.has(event.type) || !isAppendSurfaceEvent(event)) continue
|
||||
count++
|
||||
const sources = (event as { sourceEventSeqs?: number[] }).sourceEventSeqs
|
||||
const groupStart = sources !== undefined && sources.length > 0 ? Math.min(event.seq, ...sources) : event.seq
|
||||
|
||||
@@ -186,9 +186,11 @@ export interface SessionsApi {
|
||||
Promise<RpcResponse<{ sessionId: SessionId }>>
|
||||
|
||||
/**
|
||||
* Reads a window of history events; page boundaries align to message boundaries: one page =
|
||||
* all raw events owned by a whole number of messages (including their chunk / tool events),
|
||||
* never cut mid-message. The tail page (beforeSeq absent) additionally carries the in-flight
|
||||
* Reads a window of history events; page boundaries align to append-origin message
|
||||
* boundaries: one page = all raw events owned by a whole number of such messages (including
|
||||
* their chunk / tool events), never cut mid-message. Model-only replacement copies consume no
|
||||
* `maxMessages`, so a compaction's provenance stays on the page of its replacement. The tail
|
||||
* page (beforeSeq absent) additionally carries the in-flight
|
||||
* partial — chunk events already emitted for the last unfinalized message.
|
||||
* Each entry pairs the raw SessionEvent with the host-computed view (tool events whose
|
||||
* presenter produced one, evaluated against the registry at pagination time); the client
|
||||
|
||||
@@ -14,7 +14,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
|
||||
import { CallId, createToolResultMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { CallId, createMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
|
||||
@@ -35,6 +35,35 @@ function tool(name: string, presenters: Pick<ToolDefinition, 'presentCall' | 'pr
|
||||
})
|
||||
}
|
||||
|
||||
/** Append a production-shaped human prompt to the session surface. */
|
||||
function appendUserText(session: Session, text: string): SessionEvent {
|
||||
return session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
}
|
||||
|
||||
/** Append a production-shaped assistant message to the session surface. */
|
||||
function appendAssistantText(session: Session, text: string, step: number): SessionEvent {
|
||||
return session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'model', provider: 'p', model: 'm' },
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a plugin-owned log-only event. The host proxy is projection-only, so it
|
||||
* declares no compaction vocabulary; the cast writes the real event shape without
|
||||
* depending on the owning package.
|
||||
*/
|
||||
function appendExtension(session: Session, type: string, data: unknown): SessionEvent {
|
||||
return (session.append as unknown as (type: string, data: unknown) => SessionEvent)(type, data)
|
||||
}
|
||||
|
||||
async function harness(): Promise<{ ctx: Context }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
@@ -207,6 +236,55 @@ describe('mux live view computation', () => {
|
||||
expect('view' in (byKey.get('tool/result:h-plain') ?? {})).toBe(false)
|
||||
})
|
||||
|
||||
it('counts only append-origin messages toward maxMessages and keeps compaction provenance whole', async () => {
|
||||
const { ctx } = await harness()
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const session = ctx.sessions.create()
|
||||
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
const first = appendUserText(session, 'first prompt')
|
||||
appendAssistantText(session, 'first reply', 1)
|
||||
const third = appendUserText(session, 'second prompt')
|
||||
appendAssistantText(session, 'second reply', 2)
|
||||
const shadowed = [...session.surface.nodes]
|
||||
// A compaction transaction: log-only provenance immediately followed by the
|
||||
// replacement that shadows the range.
|
||||
const summary = appendExtension(session, 'compact/summary', {
|
||||
summary: [{ type: 'text', text: 'summary' }],
|
||||
shadowedRange: { start: shadowed[0], end: shadowed.at(-1) },
|
||||
shadowedSeqs: shadowed,
|
||||
shadowedTokenCount: 0,
|
||||
provider: 'p',
|
||||
model: 'm',
|
||||
})
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: '<context_checkpoint>summary</context_checkpoint>' }],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
}), {
|
||||
surfaceOp: { op: 'replace', start: shadowed[0] as number, end: shadowed.at(-1) as number },
|
||||
sourceEventSeqs: [...shadowed, summary.seq],
|
||||
})
|
||||
|
||||
const response = await api.sessions.history({
|
||||
rpcId: RpcId('t-hist-compact'),
|
||||
payload: { sessionId: session.id, maxMessages: 2 },
|
||||
})
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
const page = response.result.value.events.map(entry => entry.event)
|
||||
// Two append-origin messages fill the page even though a replacement copy of
|
||||
// the same event type sits in the window: the copy is model-only.
|
||||
const messages = page.filter(event => event.type === 'user/message' || event.type === 'assistant/message')
|
||||
expect(messages.map(event => event.seq)).toEqual([third.seq, third.seq + 1, third.seq + 3])
|
||||
expect(page.some(event => event.seq === first.seq)).toBe(false)
|
||||
expect(response.result.value.hasMore).toBe(true)
|
||||
// The range stays contiguous, so the checkpoint's provenance is readable on
|
||||
// the same page as the checkpoint itself.
|
||||
const summaryIndex = page.findIndex(event => event.seq === summary.seq)
|
||||
expect(summaryIndex).toBeGreaterThan(-1)
|
||||
expect(page[summaryIndex + 1]?.seq).toBe(summary.seq + 1)
|
||||
expect(page.map(event => event.seq)).toEqual(page.map((_event, index) => third.seq + index))
|
||||
})
|
||||
|
||||
it('drops a disposed session from the live open-call table (result after dispose gets no view)', async () => {
|
||||
const { ctx } = await harness()
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
|
||||
6
packages/settings/README.i18n.yaml
Normal file
6
packages/settings/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/settings/README.md
|
||||
README.md: 7a91355dd01805938944f0abce77765021288e6d
|
||||
README.zh.md: 2df40b67eb8ce6cfc693ed6bf3574815c0219ec0
|
||||
12
packages/settings/README.md
Normal file
12
packages/settings/README.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# settings/ — user-settings capability family
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The user-settings seam and its providers. The interface package owns the abstract `Settings` service — namespace registration, layered resolution, and change commits; providers implement raw-document storage and push external edits through the seam. All **product** packages.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `settings/` | Settings seam: namespace registry, layered resolution, commit events | `ctx.settings` |
|
||||
| `settings-local/` | File-backed provider (`settings.yaml`/`.json`) with hot reload and comment-preserving write-back | (registers `ctx.settings`) |
|
||||
|
||||
The interface lives at `settings/settings/`; providers are flat siblings. A network configuration-center provider (for example a nacos-style backend) joins here and registers on `ctx.settings`. Composition config stays in `cordis.yml`: a settings namespace carries only the user-editable subset, resolved as schema defaults, then the registrant's composition `base`, then the user document.
|
||||
12
packages/settings/README.zh.md
Normal file
12
packages/settings/README.zh.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# settings/ — 用户设置能力族
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
用户设置 seam 及其 provider。接口包拥有抽象 `Settings` 服务——namespace 注册、分层解析与变更提交;provider 实现原始文档存储并把外部修改推入 seam。全部为**产品**包。
|
||||
|
||||
| 包 | 角色 | ctx key |
|
||||
|---|---|---|
|
||||
| `settings/` | 设置 seam:namespace 注册表、分层解析、提交事件 | `ctx.settings` |
|
||||
| `settings-local/` | 文件 provider(`settings.yaml`/`.json`),热重载与保留注释的写回 | (注册 `ctx.settings`) |
|
||||
|
||||
接口位于 `settings/settings/`;provider 平级并列。网络配置中心 provider(例如 nacos 类后端)加入本组并注册到 `ctx.settings`。组合配置仍留在 `cordis.yml`:settings namespace 只承载用户可编辑子集,解析顺序为 schema 默认值、注册方的组合 `base`、用户文档。
|
||||
6
packages/settings/settings-local/README.i18n.yaml
Normal file
6
packages/settings/settings-local/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/settings/settings-local/README.md
|
||||
README.md: 2c0817afd2f2fd35fda2d22cd7f7ef3772fe2257
|
||||
README.zh.md: 547abb035368f07d4478a5c3a1793cdaa6743c68
|
||||
43
packages/settings/settings-local/README.md
Normal file
43
packages/settings/settings-local/README.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# @deepseek-ai/dsh-settings-local
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
File-backed settings provider. One YAML or JSON document carries every namespace section; external edits hot-publish through `ctx.settings`, and `update()` re-reads the document under a writer lock before writing back atomically, preserving the user's YAML comments, any section owned by a plugin that is not currently loaded, and any on-disk change this process has not observed yet.
|
||||
|
||||
## Config
|
||||
|
||||
| Field | Meaning | Default |
|
||||
|---|---|---|
|
||||
| `path` | Settings document path; extension picks the format (`.yaml`/`.yml`/`.json`) | `settings.yaml` under the harness home |
|
||||
| `dshHome` | Harness home used when `path` is omitted | `$DSH_HOME` or `~/.dsh` |
|
||||
| `watch` | Watch the document and hot-publish external edits | `true` |
|
||||
| `debounceMs` | Watcher write-settle window in milliseconds | `100` |
|
||||
|
||||
Defaulting is one explicit `resolveSpec(config)` step; an unsupported extension fails at load.
|
||||
|
||||
## Behavior
|
||||
|
||||
- **Boot fails loud, reload keeps last-good.** An existing-but-invalid document fails plugin load; once live, an unreadable or unparsable edit warns and keeps the last good sections. A missing document resolves every namespace from defaults and `base`; deleting it publishes the same empty state.
|
||||
- **Every write is a read-modify-write.** A persist first re-reads the document and publishes any difference into the seam — an external edit still inside the watcher debounce window, a change the watcher missed, or another process's write — then renders against that fresh text, so a write can never resurrect a stale document or drop an unobserved sibling section. If the on-disk document turned invalid, the write rejects loud instead of overwriting the user's manual edit.
|
||||
- **Writes hold a cross-process writer lock.** The read-render-rename cycle runs under a `wx`-created `<file>.lock` sibling with exponential backoff, a 2 s acquisition deadline (the write rejects), and stale-lock takeover after 5 s (a crashed holder, broken with a warning). Readers never take the lock: the rename commit is atomic, so reloads are always consistent.
|
||||
- **Write-back is atomic, owner-only, and symlink-proof.** The render exclusive-creates a random-suffix temp sibling with mode `0600` (`wx` refuses to follow a planted symlink) and renames over the target, cleaning the temp up on failure.
|
||||
- **YAML edits are leaf-level diffs.** A write sets only the values that changed and deletes only the keys that were removed, so comments, anchors, and formatting survive on every untouched node and on the key of every changed pair; a changed array (or other non-map value) replaces wholesale, taking comments inside it along. JSON re-serializes without comments.
|
||||
- **Reloads and writes share one operation chain.** Watcher refreshes and persists from every namespace queue run one at a time in queue order; each render sees the text the previous operation committed.
|
||||
- **The watcher's ready signal reconciles once.** The initial load races the watcher's own setup, so a change written in between never fires an event; the reconcile at ready closes that startup gap.
|
||||
- **Dispose quiesces.** Teardown stops accepting watcher events, closes the watcher, then waits out any queued or in-flight operation, so nothing publishes after disposal.
|
||||
- **Self-write suppression by content.** The provider caches the last good text; a watcher event whose content equals the cache (its own write included) is a no-op.
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through consumers of `ctx.settings`: this provider only stores and publishes namespace sections, and each consumer's own surface documents any model effect.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No direct invalidation; the consuming plugin owns any request-prefix changes.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Same-namespace conflicts stay last-write-wins** — the writer lock and read-modify-write keep concurrent writers from dropping each other's namespaces, but two writers editing one namespace still resolve to the later write; there is no per-value merge or revision check.
|
||||
- **A missed watcher event stays unseen until the next signal** — reads never re-stat the file, so a change the watcher fails to report is only folded in by the next event, the next write, or a restart.
|
||||
- **Comment preservation is YAML-only and map-shaped** — JSON documents re-serialize without comments (JSON has none), and comments inside a changed array (or attached inline to a changed scalar value) go with the value they described.
|
||||
- **No value indirection** — sections hold literal values; `${env:VAR}`-style references for secrets are a deferred seam-level feature.
|
||||
43
packages/settings/settings-local/README.zh.md
Normal file
43
packages/settings/settings-local/README.zh.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# @deepseek-ai/dsh-settings-local
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
文件 settings provider。一个 YAML 或 JSON 文档承载全部 namespace 分节;外部编辑经 `ctx.settings` 热发布,`update()` 在写锁下先重读文档再原子写回,保留用户的 YAML 注释、当前未加载插件所拥有的分节,以及任何本进程尚未观察到的磁盘变更。
|
||||
|
||||
## 配置
|
||||
|
||||
| 字段 | 含义 | 默认 |
|
||||
|---|---|---|
|
||||
| `path` | 设置文档路径;扩展名决定格式(`.yaml`/`.yml`/`.json`) | harness home 下的 `settings.yaml` |
|
||||
| `dshHome` | `path` 省略时使用的 harness home | `$DSH_HOME` 或 `~/.dsh` |
|
||||
| `watch` | 监听文档并热发布外部编辑 | `true` |
|
||||
| `debounceMs` | watcher 写入稳定窗口(毫秒) | `100` |
|
||||
|
||||
默认值解析是一步显式的 `resolveSpec(config)`;不支持的扩展名在加载时报错。
|
||||
|
||||
## 行为
|
||||
|
||||
- **启动报错响亮,重载保留最后可用值。** 存在但非法的文档使插件加载失败;运行中不可读或不可解析的编辑只告警并保留最后可用分节。文档缺失时所有 namespace 按默认值与 `base` 解析;删除文档发布同样的空状态。
|
||||
- **每次写入都是一次读-改-写。** persist 先重读文档并把任何差异发布进 seam——无论是仍在 watcher 防抖窗口内的外部编辑、watcher 漏掉的变更,还是另一个进程的写入——再基于这份新鲜文本渲染,因此写入绝不会复活陈旧文档,也不会丢掉未观察到的同级分节。若磁盘上的文档已变为非法,写入响亮拒绝,而不是覆盖用户的手工编辑。
|
||||
- **写入持有跨进程写锁。** 读-渲染-rename 流程在 `wx` 创建的 `<file>.lock` 同级文件下运行,带指数退避、2 s 的获取期限(到期则写入拒绝)与 5 s 后的陈旧锁接管(持有者已崩溃,破锁并告警)。读取方从不取锁:rename 提交是原子的,重载因此始终一致。
|
||||
- **写回原子、仅属主可读、抗符号链接。** 渲染以 `0600` 权限独占创建随机后缀临时同级文件(`wx` 拒绝跟随预埋符号链接)后 rename 覆盖目标,失败时清理临时文件。
|
||||
- **YAML 编辑是叶子级 diff。** 写入只设置发生变化的值、只删除被移除的键,因此注释、锚点与排版在每个未触碰的节点上以及每个被改键值对的键上都得以保留;被改的数组(或其他非 map 值)整体替换,其中的注释随之一同被换掉。JSON 重新序列化,无注释。
|
||||
- **重载与写入共享一条操作链。** watcher 刷新与来自各 namespace 队列的 persist 按队列顺序逐个执行;每次渲染都基于上一次操作提交后的文本。
|
||||
- **watcher 的 ready 信号做一次对账。** 初始加载与 watcher 自身的建立存在竞态,因此其间写入的变更绝不会触发事件;ready 时的对账补上这个启动缺口。
|
||||
- **Dispose 保证静止。** 卸载先停止接收 watcher 事件、关闭 watcher,再等完排队与进行中的操作,之后不再有任何发布。
|
||||
- **按内容抑制自写。** provider 缓存最后可用文本;watcher 事件内容与缓存相同(含自己的写入)即为 no-op。
|
||||
|
||||
## Model Experience
|
||||
|
||||
间接生效:本 provider 只存储并发布 namespace 分节,模型效果经由 `ctx.settings` 的消费插件产生,由各消费者自己的文档描述。
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
无直接失效;请求前缀的变更由消费插件拥有。
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **同 namespace 冲突仍是后写胜出** — 写锁加读-改-写让并发写入者不会丢掉彼此的 namespace,但两个写入者编辑同一个 namespace 时仍以较后的写入为准;没有按值合并,也没有修订检查。
|
||||
- **漏掉的 watcher 事件在下一个信号前保持不可见** — 读取从不重新 stat 文件,因此 watcher 漏报的变更只会在下一个事件、下一次写入或重启时被并入。
|
||||
- **注释保留仅限 YAML 且仅限 map 形状** — JSON 文档重新序列化,无注释(JSON 本身没有),且被改数组内部的注释(或行内附着在被改标量值上的注释)随其所描述的值一同被换掉。
|
||||
- **无值间接引用** — 分节存字面值;面向密钥的 `${env:VAR}` 式引用是 seam 层的延后特性。
|
||||
46
packages/settings/settings-local/package.json
Normal file
46
packages/settings/settings-local/package.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-settings-local",
|
||||
"description": "File-backed settings provider (settings.yaml) for the DeepSeek Harness",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-paths": "^0.0.1",
|
||||
"@deepseek-ai/dsh-settings": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"chokidar": "^4.0.3",
|
||||
"schemastery": "^3.18.0",
|
||||
"yaml": "^2.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-paths": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
411
packages/settings/settings-local/src/index.ts
Normal file
411
packages/settings/settings-local/src/index.ts
Normal file
@@ -0,0 +1,411 @@
|
||||
/**
|
||||
* File-backed settings provider. One YAML or JSON document under the user's
|
||||
* harness home carries every namespace section; external edits hot-publish
|
||||
* through the seam, and every write re-reads the document under a
|
||||
* cross-process writer lock before patching it as a comment-preserving
|
||||
* leaf-level diff.
|
||||
* @module @deepseek-ai/dsh-settings-local
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { watch as chokidarWatch } from 'chokidar'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { dirname, extname, join, resolve } from 'node:path'
|
||||
import { Document, parseDocument } from 'yaml'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
import { Settings, deepEqualJson, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
|
||||
/** Plugin config: file location and hot-reload behavior. */
|
||||
export interface Config {
|
||||
/** Settings document path; defaults to `settings.yaml` under the harness home. */
|
||||
path?: string
|
||||
/** Harness home used when `path` is omitted; defaults to `$DSH_HOME` or `~/.dsh`. */
|
||||
dshHome?: string
|
||||
/** Watch the document and hot-publish external edits; defaults to true. */
|
||||
watch?: boolean
|
||||
/** Watcher write-settle window in milliseconds; defaults to 100. */
|
||||
debounceMs?: number
|
||||
}
|
||||
|
||||
/** Document format derived from the configured file extension. */
|
||||
type SettingsFormat = 'yaml' | 'json'
|
||||
|
||||
const FORMATS: Record<string, SettingsFormat> = {
|
||||
'.yaml': 'yaml',
|
||||
'.yml': 'yaml',
|
||||
'.json': 'json',
|
||||
}
|
||||
|
||||
/** Fully resolved provider parameters; defaulting happens here, never inline. */
|
||||
interface ResolvedSpec {
|
||||
filename: string
|
||||
format: SettingsFormat
|
||||
watch: boolean
|
||||
debounceMs: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the runtime spec from plugin config: an explicit `path` wins,
|
||||
* otherwise the document lives at `<harness home>/settings.yaml`.
|
||||
* @param config - raw plugin config.
|
||||
* @returns the resolved file location, format, and watch behavior.
|
||||
*/
|
||||
export function resolveSpec(config: Config): ResolvedSpec {
|
||||
const filename = resolve(config.path ?? join(resolveDshHome(config.dshHome), 'settings.yaml'))
|
||||
const format = FORMATS[extname(filename)]
|
||||
if (format === undefined) {
|
||||
throw new Error(`settings-local: extension "${extname(filename)}" is not supported (use .yaml, .yml, or .json)`)
|
||||
}
|
||||
return {
|
||||
filename,
|
||||
format,
|
||||
watch: config.watch ?? true,
|
||||
debounceMs: config.debounceMs ?? 100,
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a parsed YAML value is a map for diffing purposes. */
|
||||
function isMapLike(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the difference between one node's stored and next value as minimal
|
||||
* `setIn`/`deleteIn` edits, recursing through maps, so every untouched node —
|
||||
* and the key node of every changed pair — keeps its comments, anchors, and
|
||||
* formatting. Non-map values (arrays and scalars) replace wholesale when
|
||||
* unequal, taking any comments inside them along.
|
||||
*/
|
||||
function patchNode(document: Document, path: readonly string[], current: unknown, next: unknown): void {
|
||||
if (isMapLike(current) && isMapLike(next)) {
|
||||
for (const key of Object.keys(current)) {
|
||||
if (!(key in next)) document.deleteIn([...path, key])
|
||||
}
|
||||
for (const [key, value] of Object.entries(next)) {
|
||||
patchNode(document, [...path, key], current[key], value)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!deepEqualJson(current, next)) document.setIn([...path], next)
|
||||
}
|
||||
|
||||
/** Whether a filesystem error means absence; every non-ENOENT failure must surface. */
|
||||
function isENOENT(error: unknown): boolean {
|
||||
return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'
|
||||
}
|
||||
|
||||
/** Whether an exclusive create failed because the path already exists. */
|
||||
function isEEXIST(error: unknown): boolean {
|
||||
return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST'
|
||||
}
|
||||
|
||||
/**
|
||||
* Writer-lock protocol constants. These are robustness invariants of the
|
||||
* cross-process write protocol, not deployment tunables: a holder rewrites one
|
||||
* small document in milliseconds, so contention resolves well inside the
|
||||
* retry deadline, and a lock older than the stale age can only belong to a
|
||||
* crashed holder.
|
||||
*/
|
||||
const LOCK_RETRY_INITIAL_MS = 20
|
||||
const LOCK_RETRY_MAX_MS = 200
|
||||
const LOCK_TIMEOUT_MS = 2_000
|
||||
const LOCK_STALE_MS = 5_000
|
||||
|
||||
/** File-backed settings provider (`settings.yaml`/`.json`). */
|
||||
export class SettingsLocal extends Settings {
|
||||
static Config: z<Config> = z.object({
|
||||
path: z.string(),
|
||||
dshHome: z.string(),
|
||||
watch: z.boolean().default(true),
|
||||
debounceMs: z.number().min(0).default(100),
|
||||
})
|
||||
|
||||
private readonly spec: ResolvedSpec
|
||||
/**
|
||||
* Raw text of the last successfully parsed or persisted document;
|
||||
* `undefined` while the file is absent. Watcher events whose content equals
|
||||
* this cache are no-ops, which is also the self-write suppression.
|
||||
*/
|
||||
private text: string | undefined
|
||||
/**
|
||||
* Single exclusive operation chain: watcher reloads and document writes run
|
||||
* one at a time in queue order (settled tail), so a write can never render
|
||||
* from text a concurrent reload is busy replacing, and a reload can never
|
||||
* read a half-committed write.
|
||||
*/
|
||||
private operations: Promise<void> = Promise.resolve()
|
||||
/** Set at dispose: refuse new watcher events and let in-flight work no-op. */
|
||||
private closed = false
|
||||
|
||||
/** Opaque read of {@link closed}: control flow cannot narrow it across awaits. */
|
||||
private isClosed(): boolean {
|
||||
return this.closed
|
||||
}
|
||||
|
||||
constructor(ctx: Context, public config: Config) {
|
||||
super(ctx)
|
||||
// Programmatic construction may bypass Schemastery normalization; resolve
|
||||
// the same defaults in one explicit step either way.
|
||||
this.spec = resolveSpec(config)
|
||||
}
|
||||
|
||||
/** The local document is always writable through {@link Settings.update}. */
|
||||
get writable(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
protected async load(): Promise<Record<string, unknown>> {
|
||||
let text: string
|
||||
try {
|
||||
text = await readFile(this.spec.filename, 'utf8')
|
||||
} catch (error) {
|
||||
if (!isENOENT(error)) throw error
|
||||
this.text = undefined
|
||||
return {}
|
||||
}
|
||||
const doc = this.parse(text)
|
||||
this.text = text
|
||||
return doc
|
||||
}
|
||||
|
||||
protected persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
|
||||
// One document backs every namespace, so writes from different namespace
|
||||
// queues serialize with each other and with watcher reloads on the one
|
||||
// operation chain: each render must see the text the previous operation
|
||||
// committed, or a sibling section silently vanishes from disk.
|
||||
return this.enqueue(() => this.persistSection(ns, section))
|
||||
}
|
||||
|
||||
/** Queue one exclusive document operation behind every earlier one. */
|
||||
private enqueue<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const task = this.operations.then(operation)
|
||||
this.operations = task.then(() => undefined, () => undefined)
|
||||
return task
|
||||
}
|
||||
|
||||
/** Queue a reload; only an invariant violation escaping a commit can reject it. */
|
||||
private queueRefresh(): void {
|
||||
void this.enqueue(() => this.refresh()).catch((error: unknown) => {
|
||||
// Only an invariant violation escaping the commit path can reject a
|
||||
// refresh; keep the operation queue alive and surface it as an error so
|
||||
// one poisoned commit cannot silently end hot reloading forever.
|
||||
this.ctx.logger.error('settings-local: reload commit failed at %s', this.spec.filename)
|
||||
this.ctx.logger.error(error)
|
||||
})
|
||||
}
|
||||
|
||||
private async persistSection(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
|
||||
await mkdir(dirname(this.spec.filename), { recursive: true })
|
||||
await this.withWriterLock(async () => {
|
||||
// Read-modify-write: fold in any on-disk state this process has not
|
||||
// observed yet — an external edit still inside the watcher debounce
|
||||
// window, a change the watcher missed, or another process's write — so
|
||||
// the render below can never resurrect a stale document. An unparsable
|
||||
// on-disk document fails the write loud instead of silently overwriting
|
||||
// a user's manual edit.
|
||||
await this.reconcileFromDisk()
|
||||
const output = this.spec.format === 'yaml'
|
||||
? this.renderYaml(ns, section)
|
||||
: this.renderJson(ns, section)
|
||||
// Exclusive-create (`wx`) a random-suffix sibling: the open refuses to
|
||||
// follow any planted symlink at a guessable temp path, and the fresh inode
|
||||
// carries owner-only permissions that survive the rename — a document that
|
||||
// may hold personal values is never world-readable and never a symlink.
|
||||
const temp = `${this.spec.filename}.${randomBytes(6).toString('hex')}.tmp`
|
||||
// TODO(settings-atomic-durability): Use a replacement that fsyncs the file
|
||||
// and parent directory and preserves owner-only permissions on Windows.
|
||||
try {
|
||||
await writeFile(temp, output, { mode: 0o600, flag: 'wx' })
|
||||
await rename(temp, this.spec.filename)
|
||||
} catch (error) {
|
||||
await rm(temp, { force: true })
|
||||
throw error
|
||||
}
|
||||
this.text = output
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hold the cross-process writer lock around one read-render-rename cycle.
|
||||
* The lock is a `wx`-created sibling (`<file>.lock`); the rename-based
|
||||
* commit keeps readers lock-free, so only writers contend. A lock older
|
||||
* than {@link LOCK_STALE_MS} is a crashed holder and is broken with a
|
||||
* warning; a live holder past {@link LOCK_TIMEOUT_MS} fails the write.
|
||||
*/
|
||||
private async withWriterLock<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const lockPath = `${this.spec.filename}.lock`
|
||||
const deadline = Date.now() + LOCK_TIMEOUT_MS
|
||||
let delay = LOCK_RETRY_INITIAL_MS
|
||||
for (;;) {
|
||||
try {
|
||||
await writeFile(lockPath, `${process.pid}\n`, { mode: 0o600, flag: 'wx' })
|
||||
break
|
||||
} catch (error) {
|
||||
if (!isEEXIST(error)) throw error
|
||||
}
|
||||
const ageMs = await this.lockAgeMs(lockPath)
|
||||
// The holder released between the failed create and the stat: the lock
|
||||
// is free right now, so retry without burning backoff or deadline.
|
||||
if (ageMs === undefined) continue
|
||||
if (ageMs > LOCK_STALE_MS) {
|
||||
// TODO(settings-lock-ownership): Replace age-only takeover with ownership-safe
|
||||
// acquisition and release so a slow writer cannot remove a successor's lock.
|
||||
this.ctx.logger.warn('settings-local: breaking a stale writer lock at %s', lockPath)
|
||||
await rm(lockPath, { force: true })
|
||||
continue
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error(`settings-local: timed out waiting for the writer lock at ${lockPath}`)
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, delay))
|
||||
delay = Math.min(delay * 2, LOCK_RETRY_MAX_MS)
|
||||
}
|
||||
try {
|
||||
return await operation()
|
||||
} finally {
|
||||
await rm(lockPath, { force: true })
|
||||
}
|
||||
}
|
||||
|
||||
/** Age of the writer lock, or `undefined` when it vanished after a failed create. */
|
||||
private async lockAgeMs(lockPath: string): Promise<number | undefined> {
|
||||
try {
|
||||
return Date.now() - (await stat(lockPath)).mtimeMs
|
||||
} catch (error) {
|
||||
if (!isENOENT(error)) throw error
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
override async* [Service.init](): AsyncGenerator<() => Promise<void> | void, void, void> {
|
||||
// The base init loads and publishes; a parse failure there is a boot
|
||||
// failure: an existing-but-invalid document must fail loud, never be
|
||||
// silently ignored or overwritten.
|
||||
yield* super[Service.init]()
|
||||
if (!this.spec.watch) return
|
||||
const watcher = chokidarWatch(this.spec.filename, {
|
||||
ignoreInitial: true,
|
||||
awaitWriteFinish: {
|
||||
stabilityThreshold: this.spec.debounceMs,
|
||||
pollInterval: Math.max(1, Math.min(this.spec.debounceMs, 10)),
|
||||
},
|
||||
})
|
||||
watcher.on('all', () => {
|
||||
if (this.closed) return
|
||||
this.queueRefresh()
|
||||
})
|
||||
watcher.on('ready', () => {
|
||||
// The base init's load raced the watcher's own setup: a change written
|
||||
// between that read and the watcher becoming active never fires an
|
||||
// event. One reconcile at ready closes the gap.
|
||||
if (this.closed) return
|
||||
this.queueRefresh()
|
||||
})
|
||||
watcher.on('error', (error) => {
|
||||
this.ctx.logger.warn('settings-local: watcher error on %s', this.spec.filename)
|
||||
this.ctx.logger.warn(error)
|
||||
})
|
||||
yield async () => {
|
||||
// Quiesce: stop accepting events, close the watcher, then wait out any
|
||||
// queued or in-flight operation so nothing publishes after disposal.
|
||||
this.closed = true
|
||||
await watcher.close()
|
||||
await this.operations
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse one document text into raw sections, failing on a non-map root. */
|
||||
private parse(text: string): Record<string, unknown> {
|
||||
let root: unknown
|
||||
if (this.spec.format === 'yaml') {
|
||||
const document = parseDocument(text, { prettyErrors: true })
|
||||
if (document.errors.length > 0) {
|
||||
throw new Error(`settings-local: invalid document at ${this.spec.filename}: ${
|
||||
document.errors.map(error => error.message).join('; ')}`)
|
||||
}
|
||||
root = document.toJS() ?? {}
|
||||
} else {
|
||||
root = text.trim().length === 0 ? {} : JSON.parse(text)
|
||||
}
|
||||
if (typeof root !== 'object' || root === null || Array.isArray(root)) {
|
||||
throw new TypeError(`settings-local: ${this.spec.filename} must be a map of namespace sections`)
|
||||
}
|
||||
return root as Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-read the document after a watcher event. Unchanged content (including
|
||||
* this provider's own writes) is a no-op; an unreadable or unparsable
|
||||
* document keeps the last good sections and warns — a live hot-reload must
|
||||
* never take the process down. An invariant violation escaping a commit is
|
||||
* not a reload failure and propagates to the queue's error surface.
|
||||
*/
|
||||
private async refresh(): Promise<void> {
|
||||
if (this.closed) return
|
||||
try {
|
||||
await this.reconcileFromDisk()
|
||||
} catch (error) {
|
||||
if ((error as { code?: unknown } | null)?.code === 'INVARIANT') throw error
|
||||
this.ctx.logger.warn('settings-local: reload failed at %s; keeping the last good document', this.spec.filename)
|
||||
this.ctx.logger.warn(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare the on-disk text against the cache and publish any difference
|
||||
* into the seam. Absence publishes the empty document; an unreadable or
|
||||
* unparsable file throws, so each caller picks its policy — a reload warns
|
||||
* and keeps the last good document, a write fails loud.
|
||||
*/
|
||||
private async reconcileFromDisk(): Promise<void> {
|
||||
let text: string | undefined
|
||||
try {
|
||||
text = await readFile(this.spec.filename, 'utf8')
|
||||
} catch (error) {
|
||||
if (!isENOENT(error)) throw error
|
||||
text = undefined
|
||||
}
|
||||
if (text === this.text || this.isClosed()) return
|
||||
if (text === undefined) {
|
||||
this.text = undefined
|
||||
this.publish({})
|
||||
return
|
||||
}
|
||||
const doc = this.parse(text)
|
||||
this.text = text
|
||||
this.publish(doc)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the next YAML text by patching one namespace in the
|
||||
* comment-preserving document. The next section lands as a leaf-level diff
|
||||
* against the stored one — only changed values set, only removed keys
|
||||
* delete — so comments inside the section survive edits to their siblings,
|
||||
* not just comments outside it.
|
||||
*/
|
||||
private renderYaml(ns: SettingsNamespace, section: Record<string, unknown>): string {
|
||||
if (this.text === undefined) {
|
||||
return new Document({ [ns]: section }).toString()
|
||||
}
|
||||
// this.text only ever caches content that parsed successfully, so this
|
||||
// re-parse (for the mutable comment-preserving tree) cannot fail, and
|
||||
// parse() already rejected any non-map root.
|
||||
const document = parseDocument(this.text)
|
||||
const root: unknown = document.toJS()
|
||||
patchNode(document, [ns], isMapLike(root) ? root[ns] : undefined, section)
|
||||
return document.toString()
|
||||
}
|
||||
|
||||
/** Render the next JSON text by replacing one namespace key. */
|
||||
private renderJson(ns: SettingsNamespace, section: Record<string, unknown>): string {
|
||||
const root = this.text === undefined
|
||||
? {}
|
||||
: this.parse(this.text)
|
||||
root[ns] = section
|
||||
return `${JSON.stringify(root, null, 2)}\n`
|
||||
}
|
||||
}
|
||||
|
||||
export default SettingsLocal
|
||||
31
packages/settings/settings-local/src/invariant.ts
Normal file
31
packages/settings/settings-local/src/invariant.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-settings-local`.
|
||||
* @module @deepseek-ai/dsh-settings-local/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-settings-local'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'settings-local-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this provider's contracts are file round-trip,
|
||||
* watcher timing, and atomic-write behavior — IO effects proven by package
|
||||
* tests; the in-process commit relation is owned by `@deepseek-ai/dsh-settings`.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
103
packages/settings/settings-local/tests/concurrency.spec.ts
Normal file
103
packages/settings/settings-local/tests/concurrency.spec.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
// Cross-instance and writer-lock behavior: two providers on one document are
|
||||
// the in-process equivalent of two dsh processes sharing a harness home —
|
||||
// neither knows the other's cache, so only the read-modify-write cycle under
|
||||
// the `<file>.lock` sibling keeps both namespaces alive on disk.
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { chmod, mkdtemp, readFile, rm, utimes, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { SettingsLocal } from '../src/index.ts'
|
||||
|
||||
const AlphaSchema: z<{ value: number }> = z.object({ value: z.number().default(0) })
|
||||
const BetaSchema: z<{ value: number }> = z.object({ value: z.number().default(0) })
|
||||
|
||||
const cleanups: Array<() => Promise<void>> = []
|
||||
|
||||
afterEach(async () => {
|
||||
while (cleanups.length > 0) await cleanups.pop()!()
|
||||
})
|
||||
|
||||
async function tempDir(): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-settings-lock-'))
|
||||
cleanups.push(() => rm(dir, { recursive: true, force: true }))
|
||||
return dir
|
||||
}
|
||||
|
||||
async function boot(config: ConstructorParameters<typeof SettingsLocal>[1]): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
const fiber = ctx.plugin(SettingsLocal, config)
|
||||
cleanups.push(async () => { await fiber.dispose() })
|
||||
await fiber
|
||||
return ctx
|
||||
}
|
||||
|
||||
describe('cross-instance writes', () => {
|
||||
it('keeps both namespaces when two providers write the same document concurrently', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'settings.yaml')
|
||||
const first = await boot({ path, watch: false })
|
||||
const second = await boot({ path, watch: false })
|
||||
const alpha = first.settings.register(settingsNamespace('alpha'), AlphaSchema)
|
||||
const beta = second.settings.register(settingsNamespace('beta'), BetaSchema)
|
||||
const rounds = [1, 2, 3, 4, 5]
|
||||
await Promise.all([
|
||||
(async () => { for (const value of rounds) await alpha.update({ value }) })(),
|
||||
(async () => { for (const value of rounds) await beta.update({ value }) })(),
|
||||
])
|
||||
const text = await readFile(path, 'utf8')
|
||||
expect(text).toContain('alpha:')
|
||||
expect(text).toContain('beta:')
|
||||
// A third instance resolves both final values from the shared document.
|
||||
const third = await boot({ path, watch: false })
|
||||
expect(third.settings.register(settingsNamespace('alpha'), AlphaSchema).get()).toEqual({ value: 5 })
|
||||
expect(third.settings.register(settingsNamespace('beta'), BetaSchema).get()).toEqual({ value: 5 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('writer lock', () => {
|
||||
it('waits for a busy writer lock instead of failing', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'settings.yaml')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
const scope = ctx.settings.register(settingsNamespace('alpha'), AlphaSchema)
|
||||
await writeFile(`${path}.lock`, 'holder\n')
|
||||
const release = setTimeout(() => { void rm(`${path}.lock`, { force: true }) }, 120)
|
||||
cleanups.push(async () => { clearTimeout(release) })
|
||||
await scope.update({ value: 7 })
|
||||
expect(await readFile(path, 'utf8')).toContain('value: 7')
|
||||
})
|
||||
|
||||
it('breaks a stale writer lock with a warning and writes through', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'settings.yaml')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
const scope = ctx.settings.register(settingsNamespace('alpha'), AlphaSchema)
|
||||
await writeFile(`${path}.lock`, 'crashed-holder\n')
|
||||
const past = (Date.now() - 60_000) / 1000
|
||||
await utimes(`${path}.lock`, past, past)
|
||||
await scope.update({ value: 9 })
|
||||
expect(await readFile(path, 'utf8')).toContain('value: 9')
|
||||
})
|
||||
|
||||
it('times out on a lock a live holder never releases', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'settings.yaml')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
const scope = ctx.settings.register(settingsNamespace('alpha'), AlphaSchema)
|
||||
await writeFile(`${path}.lock`, 'busy-holder\n')
|
||||
await expect(scope.update({ value: 1 })).rejects.toThrow(/timed out waiting for the writer lock/)
|
||||
}, 10_000)
|
||||
|
||||
it('surfaces a non-contention lock failure as the write error', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'settings.yaml')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
const scope = ctx.settings.register(settingsNamespace('alpha'), AlphaSchema)
|
||||
await chmod(dir, 0o500)
|
||||
cleanups.push(() => chmod(dir, 0o700))
|
||||
await expect(scope.update({ value: 1 })).rejects.toThrow(/EACCES|permission/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Real-composition guard: the provider and a consumer plugin boot from a
|
||||
* test-only cordis.yml through the actual Loader + Include path, an external
|
||||
* edit of settings.yaml hot-publishes into the consumer's scope, and the same
|
||||
* consumer booted WITHOUT a settings entry keeps its entry-config resolution —
|
||||
* the documented optional-inject fallback.
|
||||
*/
|
||||
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import Include from '@cordisjs/plugin-include'
|
||||
import z from 'schemastery'
|
||||
import { settingsNamespace, type SettingsScope } from '@deepseek-ai/dsh-settings'
|
||||
import SettingsLocal from '../src/index.ts'
|
||||
|
||||
interface ThemeConfig {
|
||||
theme: 'dark' | 'light'
|
||||
fontSize: number
|
||||
}
|
||||
|
||||
const ThemeSchema: z<ThemeConfig> = z.object({
|
||||
theme: z.union(['dark', 'light']).default('dark'),
|
||||
fontSize: z.number().default(14),
|
||||
})
|
||||
|
||||
let root: string | undefined
|
||||
let context: Context | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
await context?.fiber.dispose()
|
||||
context = undefined
|
||||
if (root !== undefined) await rm(root, { recursive: true, force: true })
|
||||
root = undefined
|
||||
})
|
||||
|
||||
interface ConsumerState {
|
||||
scope: SettingsScope<ThemeConfig> | undefined
|
||||
seen: ThemeConfig[]
|
||||
/** What the consumer is actually running with, settings or not. */
|
||||
applied: ThemeConfig | undefined
|
||||
}
|
||||
|
||||
async function loadComposition(
|
||||
options?: { withSettings?: boolean },
|
||||
): Promise<{ ctx: Context; state: ConsumerState; settingsPath: string }> {
|
||||
const withSettings = options?.withSettings ?? true
|
||||
root = await mkdtemp(join(tmpdir(), 'dsh-settings-composition-'))
|
||||
const settingsPath = join(root, 'settings.yaml')
|
||||
await writeFile(settingsPath, 'ui-theme:\n theme: light\n')
|
||||
|
||||
const state: ConsumerState = { scope: undefined, seen: [], applied: undefined }
|
||||
const consumer = {
|
||||
name: 'settings-consumer',
|
||||
apply: (ctx: Context) => {
|
||||
// The documented consumer shape: no hard dependency — entry config alone
|
||||
// is the running state, and the scoped inject overlays the user layer
|
||||
// only while a settings service exists.
|
||||
const base: Partial<ThemeConfig> = { fontSize: 16 }
|
||||
state.applied = ThemeSchema(base as ThemeConfig)
|
||||
ctx.inject(['settings'], (child: Context) => {
|
||||
const scope = child.settings.register(settingsNamespace('ui-theme'), ThemeSchema, { base })
|
||||
state.scope = scope
|
||||
state.applied = scope.get()
|
||||
scope.watch((next) => {
|
||||
state.seen.push(next)
|
||||
state.applied = next
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
const configPath = join(root, 'cordis.yml')
|
||||
await writeFile(configPath, [
|
||||
...withSettings
|
||||
? [
|
||||
'- id: settings',
|
||||
" name: '@deepseek-ai/dsh-settings-local'",
|
||||
' config:',
|
||||
` path: ${JSON.stringify(settingsPath)}`,
|
||||
' debounceMs: 10',
|
||||
]
|
||||
: [],
|
||||
'- id: consumer',
|
||||
' name: test-settings-consumer',
|
||||
'',
|
||||
].join('\n'))
|
||||
|
||||
const ctx = new Context()
|
||||
context = ctx
|
||||
ctx.baseUrl = pathToFileURL(root).href + '/'
|
||||
await ctx.plugin(Loader)
|
||||
ctx.loader.builtins.include = Include
|
||||
const modules = new Map<string, unknown>([
|
||||
['@deepseek-ai/dsh-settings-local', SettingsLocal],
|
||||
['test-settings-consumer', consumer],
|
||||
])
|
||||
ctx.loader.internal = {
|
||||
version: 'v2',
|
||||
async import(specifier: string) {
|
||||
if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
|
||||
return modules.get(specifier)
|
||||
},
|
||||
} as unknown as NonNullable<typeof ctx.loader.internal>
|
||||
await ctx.loader.create({
|
||||
name: 'cordis:include',
|
||||
config: { path: pathToFileURL(configPath).href },
|
||||
})
|
||||
await ctx.loader.await()
|
||||
return { ctx, state, settingsPath }
|
||||
}
|
||||
|
||||
describe('settings-local real composition', () => {
|
||||
it('boots from cordis.yml and hot-publishes an external settings edit', async () => {
|
||||
const { ctx, state, settingsPath } = await loadComposition()
|
||||
|
||||
// Composition resolution: user layer over the consumer's composition base.
|
||||
await vi.waitFor(() => {
|
||||
expect(state.scope!.get()).toEqual({ theme: 'light', fontSize: 16 })
|
||||
})
|
||||
expect(ctx.get('settings')!.describe().map(entry => entry.ns)).toEqual(['ui-theme'])
|
||||
|
||||
await writeFile(settingsPath, 'ui-theme:\n theme: dark\n fontSize: 20\n')
|
||||
await vi.waitFor(() => {
|
||||
expect(state.scope!.get()).toEqual({ theme: 'dark', fontSize: 20 })
|
||||
}, { timeout: 5000 })
|
||||
expect(state.seen.at(-1)).toEqual({ theme: 'dark', fontSize: 20 })
|
||||
})
|
||||
|
||||
it('boots the same consumer without a settings entry and keeps entry-config resolution', async () => {
|
||||
const { ctx, state } = await loadComposition({ withSettings: false })
|
||||
|
||||
// No settings service anywhere in the composition…
|
||||
expect(ctx.get('settings')).toBeUndefined()
|
||||
// …so the consumer runs on schema defaults plus its composition base, and
|
||||
// never receives a scope.
|
||||
expect(state.applied).toEqual({ theme: 'dark', fontSize: 16 })
|
||||
expect(state.scope).toBeUndefined()
|
||||
expect(state.seen).toEqual([])
|
||||
})
|
||||
})
|
||||
401
packages/settings/settings-local/tests/local.spec.ts
Normal file
401
packages/settings/settings-local/tests/local.spec.ts
Normal file
@@ -0,0 +1,401 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { chmod, lstat, mkdtemp, readFile, readdir, rm, stat, symlink, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { SettingsLocal, resolveSpec } from '../src/index.ts'
|
||||
|
||||
interface ThemeConfig {
|
||||
theme: 'dark' | 'light'
|
||||
fontSize: number
|
||||
}
|
||||
|
||||
const ThemeSchema: z<ThemeConfig> = z.object({
|
||||
theme: z.union(['dark', 'light']).default('dark'),
|
||||
fontSize: z.number().default(14),
|
||||
})
|
||||
|
||||
const cleanups: Array<() => Promise<void>> = []
|
||||
|
||||
afterEach(async () => {
|
||||
while (cleanups.length > 0) await cleanups.pop()!()
|
||||
})
|
||||
|
||||
async function tempDir(): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-settings-local-'))
|
||||
cleanups.push(() => rm(dir, { recursive: true, force: true }))
|
||||
return dir
|
||||
}
|
||||
|
||||
async function boot(config: ConstructorParameters<typeof SettingsLocal>[1]): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
const fiber = ctx.plugin(SettingsLocal, config)
|
||||
cleanups.push(async () => { await fiber.dispose() })
|
||||
await fiber
|
||||
return ctx
|
||||
}
|
||||
|
||||
describe('resolveSpec', () => {
|
||||
it('defaults watch and debounce when construction bypasses schema normalization', () => {
|
||||
const spec = resolveSpec({ path: '/tmp/anywhere/settings.yaml' })
|
||||
expect(spec.watch).toBe(true)
|
||||
expect(spec.debounceMs).toBe(100)
|
||||
})
|
||||
})
|
||||
|
||||
describe('boot and reads', () => {
|
||||
it('resolves defaults over an absent file and reports writable', async () => {
|
||||
const dir = await tempDir()
|
||||
const ctx = await boot({ path: join(dir, 'settings.yaml'), watch: false })
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema, {
|
||||
base: { fontSize: 16 },
|
||||
})
|
||||
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 16 })
|
||||
expect(ctx.settings.writable).toBe(true)
|
||||
})
|
||||
|
||||
it('reads sections from an existing yaml document', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'settings.yaml')
|
||||
await writeFile(path, 'ui-theme:\n theme: light\n')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
expect(scope.get()).toEqual({ theme: 'light', fontSize: 14 })
|
||||
})
|
||||
|
||||
it('reads sections from a json document', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'settings.json')
|
||||
await writeFile(path, JSON.stringify({ 'ui-theme': { fontSize: 18 } }))
|
||||
const ctx = await boot({ path, watch: false })
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 18 })
|
||||
})
|
||||
|
||||
it('defaults the file location under the configured harness home', async () => {
|
||||
const dir = await tempDir()
|
||||
const ctx = await boot({ dshHome: dir, watch: false })
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
await scope.update({ theme: 'light' })
|
||||
const written = await readFile(join(dir, 'settings.yaml'), 'utf8')
|
||||
expect(written).toContain('theme: light')
|
||||
})
|
||||
|
||||
it('reads an empty yaml document as no sections', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'settings.yaml')
|
||||
await writeFile(path, '')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 })
|
||||
})
|
||||
|
||||
it('reads an empty json document as no sections', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'settings.json')
|
||||
await writeFile(path, '')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 })
|
||||
})
|
||||
|
||||
it('fails loud at boot when the document exists but is unreadable', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'settings.yaml')
|
||||
await writeFile(path, 'ui-theme:\n theme: light\n')
|
||||
await chmod(path, 0o000)
|
||||
cleanups.push(() => chmod(path, 0o600))
|
||||
await expect(boot({ path, watch: false })).rejects.toThrow(/EACCES|permission/i)
|
||||
})
|
||||
|
||||
it('fails loud on an unsupported extension', async () => {
|
||||
const dir = await tempDir()
|
||||
await expect(boot({ path: join(dir, 'settings.toml'), watch: false }))
|
||||
.rejects.toThrow(/not supported/)
|
||||
})
|
||||
|
||||
it('fails loud at boot on unparsable yaml', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'settings.yaml')
|
||||
await writeFile(path, 'ui-theme: [unclosed\n')
|
||||
await expect(boot({ path, watch: false })).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('fails loud at boot when the root is not a map of sections', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'settings.yaml')
|
||||
await writeFile(path, '- just\n- a list\n')
|
||||
await expect(boot({ path, watch: false })).rejects.toThrow(/map of namespace sections/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('persist', () => {
|
||||
it('writes the merged section, creating the file with owner-only permissions', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'settings.yaml')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
await scope.update({ theme: 'light' })
|
||||
|
||||
const written = await readFile(path, 'utf8')
|
||||
expect(written).toContain('theme: light')
|
||||
expect((await stat(path)).mode & 0o777).toBe(0o600)
|
||||
// Atomic replace leaves no temp artifact behind.
|
||||
expect((await readdir(dir)).sort()).toEqual(['settings.yaml'])
|
||||
})
|
||||
|
||||
it('serializes cross-namespace writes into one on-disk document', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'settings.yaml')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
const alpha = ctx.settings.register(settingsNamespace('alpha'), ThemeSchema)
|
||||
const beta = ctx.settings.register(settingsNamespace('beta'), ThemeSchema)
|
||||
await Promise.all([
|
||||
alpha.update({ theme: 'light' }),
|
||||
beta.update({ fontSize: 20 }),
|
||||
])
|
||||
const text = await readFile(path, 'utf8')
|
||||
expect(text).toContain('alpha:')
|
||||
expect(text).toContain('beta:')
|
||||
expect(alpha.get().theme).toBe('light')
|
||||
expect(beta.get().fontSize).toBe(20)
|
||||
})
|
||||
|
||||
it('never follows a planted symlink at a temp path and never leaves the document a symlink', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'settings.yaml')
|
||||
const victim = join(dir, 'victim.txt')
|
||||
await writeFile(victim, 'precious')
|
||||
// A hostile sibling plants the historic fixed temp name as a symlink.
|
||||
await symlink(victim, `${path}.tmp`)
|
||||
const ctx = await boot({ path, watch: false })
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
await scope.update({ theme: 'light' })
|
||||
|
||||
expect(await readFile(victim, 'utf8')).toBe('precious')
|
||||
expect((await lstat(path)).isSymbolicLink()).toBe(false)
|
||||
expect((await stat(path)).mode & 0o777).toBe(0o600)
|
||||
expect(await readFile(path, 'utf8')).toContain('theme: light')
|
||||
})
|
||||
|
||||
it('preserves comments and unregistered sections across updates', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'settings.yaml')
|
||||
await writeFile(path, [
|
||||
'# personal settings',
|
||||
'ui-theme:',
|
||||
' theme: light',
|
||||
'# owned by a plugin that is not loaded right now',
|
||||
'future-plugin:',
|
||||
' keep: me',
|
||||
'',
|
||||
].join('\n'))
|
||||
const ctx = await boot({ path, watch: false })
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
await scope.update({ fontSize: 18 })
|
||||
|
||||
const written = await readFile(path, 'utf8')
|
||||
expect(written).toContain('# personal settings')
|
||||
expect(written).toContain('# owned by a plugin that is not loaded right now')
|
||||
expect(written).toContain('keep: me')
|
||||
expect(written).toContain('fontSize: 18')
|
||||
expect(written).toContain('theme: light')
|
||||
})
|
||||
|
||||
it('keeps comments inside the section when a sibling key changes', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'settings.yaml')
|
||||
await writeFile(path, [
|
||||
'ui-theme:',
|
||||
' # chosen during onboarding',
|
||||
' theme: light',
|
||||
' fontSize: 12',
|
||||
'',
|
||||
].join('\n'))
|
||||
const ctx = await boot({ path, watch: false })
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
await scope.update({ fontSize: 18 })
|
||||
const written = await readFile(path, 'utf8')
|
||||
expect(written).toContain('# chosen during onboarding')
|
||||
expect(written).toContain('theme: light')
|
||||
expect(written).toContain('fontSize: 18')
|
||||
})
|
||||
|
||||
it('keeps a changed key\'s own-line comment while replacing its value', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'settings.yaml')
|
||||
await writeFile(path, [
|
||||
'ui-theme:',
|
||||
' # chosen during onboarding',
|
||||
' theme: light',
|
||||
'',
|
||||
].join('\n'))
|
||||
const ctx = await boot({ path, watch: false })
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
await scope.update({ theme: 'dark' })
|
||||
const written = await readFile(path, 'utf8')
|
||||
expect(written).toContain('# chosen during onboarding')
|
||||
expect(written).toContain('theme: dark')
|
||||
})
|
||||
|
||||
it('deletes only the removed key on replace, keeping sibling comments', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'settings.yaml')
|
||||
await writeFile(path, [
|
||||
'ui-theme:',
|
||||
' # chosen during onboarding',
|
||||
' theme: light',
|
||||
' fontSize: 12',
|
||||
'',
|
||||
].join('\n'))
|
||||
const ctx = await boot({ path, watch: false })
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
await scope.replace({ theme: 'light' })
|
||||
const written = await readFile(path, 'utf8')
|
||||
expect(written).toContain('# chosen during onboarding')
|
||||
expect(written).toContain('theme: light')
|
||||
expect(written).not.toContain('fontSize')
|
||||
})
|
||||
|
||||
it('keeps an unchanged array\'s comments and replaces a changed array wholesale', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'settings.yaml')
|
||||
const TagsSchema: z<{ tags: string[]; label: string }> = z.object({
|
||||
tags: z.array(z.string()).default([]),
|
||||
label: z.string().default(''),
|
||||
})
|
||||
await writeFile(path, [
|
||||
'workspace:',
|
||||
' tags:',
|
||||
' # pinned by hand',
|
||||
' - alpha',
|
||||
' label: draft',
|
||||
'',
|
||||
].join('\n'))
|
||||
const ctx = await boot({ path, watch: false })
|
||||
const scope = ctx.settings.register(settingsNamespace('workspace'), TagsSchema)
|
||||
await scope.update({ label: 'final' })
|
||||
const untouched = await readFile(path, 'utf8')
|
||||
expect(untouched).toContain('# pinned by hand')
|
||||
expect(untouched).toContain('label: final')
|
||||
// A changed array replaces wholesale; comments inside it go with it.
|
||||
await scope.update({ tags: ['beta'] })
|
||||
const replaced = await readFile(path, 'utf8')
|
||||
expect(replaced).not.toContain('# pinned by hand')
|
||||
expect(replaced).toContain('- beta')
|
||||
})
|
||||
|
||||
it('keeps a comment-only document\'s comment when the first section lands', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'settings.yaml')
|
||||
// Parses to a null root: the document exists but holds no sections yet.
|
||||
await writeFile(path, '# reserved for future settings\n')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
await scope.update({ theme: 'light' })
|
||||
const written = await readFile(path, 'utf8')
|
||||
expect(written).toContain('# reserved for future settings')
|
||||
expect(written).toContain('theme: light')
|
||||
})
|
||||
|
||||
it('creates a json document from scratch', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'settings.json')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
await scope.update({ theme: 'light' })
|
||||
const written = JSON.parse(await readFile(path, 'utf8')) as Record<string, unknown>
|
||||
expect(written).toEqual({ 'ui-theme': { theme: 'light' } })
|
||||
})
|
||||
|
||||
it('rejects and leaves no temp residue when the directory turns unwritable', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'settings.yaml')
|
||||
await writeFile(path, 'ui-theme:\n theme: light\n')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
await chmod(dir, 0o500)
|
||||
cleanups.push(() => chmod(dir, 0o700))
|
||||
await expect(scope.update({ theme: 'dark' })).rejects.toThrow()
|
||||
await chmod(dir, 0o700)
|
||||
expect((await readdir(dir)).sort()).toEqual(['settings.yaml'])
|
||||
expect(scope.get().theme).toBe('light')
|
||||
// The failed persist must not poison the document write chain.
|
||||
await scope.update({ theme: 'dark' })
|
||||
expect(scope.get().theme).toBe('dark')
|
||||
})
|
||||
|
||||
it('round-trips a json document', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'settings.json')
|
||||
await writeFile(path, JSON.stringify({ other: { keep: true } }, null, 2))
|
||||
const ctx = await boot({ path, watch: false })
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
await scope.update({ theme: 'light' })
|
||||
const written = JSON.parse(await readFile(path, 'utf8')) as Record<string, unknown>
|
||||
expect(written).toEqual({ other: { keep: true }, 'ui-theme': { theme: 'light' } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('watch', () => {
|
||||
it('publishes an external edit to registered scopes', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'settings.yaml')
|
||||
await writeFile(path, 'ui-theme:\n theme: light\n')
|
||||
const ctx = await boot({ path, debounceMs: 10 })
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
expect(scope.get().theme).toBe('light')
|
||||
|
||||
await writeFile(path, 'ui-theme:\n theme: dark\n fontSize: 20\n')
|
||||
await vi.waitFor(() => {
|
||||
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 20 })
|
||||
}, { timeout: 5000 })
|
||||
})
|
||||
|
||||
it('keeps the last good document over an invalid edit, then recovers', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'settings.yaml')
|
||||
await writeFile(path, 'ui-theme:\n theme: light\n')
|
||||
const ctx = await boot({ path, debounceMs: 10 })
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
|
||||
await writeFile(path, 'ui-theme: [unclosed\n')
|
||||
// The bad edit must never take the live tree down or reset the value.
|
||||
await new Promise(resolve => setTimeout(resolve, 300))
|
||||
expect(scope.get()).toEqual({ theme: 'light', fontSize: 14 })
|
||||
|
||||
await writeFile(path, 'ui-theme:\n theme: dark\n')
|
||||
await vi.waitFor(() => {
|
||||
expect(scope.get().theme).toBe('dark')
|
||||
}, { timeout: 5000 })
|
||||
})
|
||||
|
||||
it('treats file removal as an empty document', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'settings.yaml')
|
||||
await writeFile(path, 'ui-theme:\n theme: light\n')
|
||||
const ctx = await boot({ path, debounceMs: 10 })
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
|
||||
await rm(path)
|
||||
await vi.waitFor(() => {
|
||||
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 })
|
||||
}, { timeout: 5000 })
|
||||
})
|
||||
|
||||
it('does not republish its own persisted write', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'settings.yaml')
|
||||
const ctx = await boot({ path, debounceMs: 10 })
|
||||
const events: unknown[] = []
|
||||
ctx.on('settings/updated', (ns, _next, _prev, source) => {
|
||||
events.push({ ns, source })
|
||||
})
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
await scope.update({ theme: 'light' })
|
||||
await new Promise(resolve => setTimeout(resolve, 300))
|
||||
expect(events).toEqual([{ ns: 'ui-theme', source: 'update' }])
|
||||
})
|
||||
})
|
||||
100
packages/settings/settings-local/tests/lock-race.spec.ts
Normal file
100
packages/settings/settings-local/tests/lock-race.spec.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
// Writer-lock races that cannot be timed from outside: a contender whose lock
|
||||
// vanishes between the failed exclusive create and the stat, a stat failing
|
||||
// for a reason other than absence, and a temp-file write failing mid-cycle.
|
||||
// The fs/promises seam is partially mocked to inject exactly one failure at a
|
||||
// chosen path suffix; everything else passes through to the real filesystem.
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { access, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { SettingsLocal } from '../src/index.ts'
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
/** One-shot failure injections keyed by operation, matched on a path suffix. */
|
||||
failures: [] as Array<{ op: 'writeFile' | 'stat'; suffix: string; code: string }>,
|
||||
}))
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>()
|
||||
const inject = (op: 'writeFile' | 'stat', path: unknown): void => {
|
||||
const index = state.failures.findIndex(f => f.op === op && String(path).endsWith(f.suffix))
|
||||
if (index === -1) return
|
||||
const [failure] = state.failures.splice(index, 1)
|
||||
throw Object.assign(new Error(`${failure!.code}: injected ${op} failure`), { code: failure!.code })
|
||||
}
|
||||
return {
|
||||
...actual,
|
||||
writeFile: (async (path: unknown, ...rest: never[]) => {
|
||||
inject('writeFile', path)
|
||||
return (actual.writeFile as (path: unknown, ...args: never[]) => Promise<void>)(path, ...rest)
|
||||
}) as typeof actual.writeFile,
|
||||
stat: (async (path: unknown, ...rest: never[]) => {
|
||||
inject('stat', path)
|
||||
return (actual.stat as (path: unknown, ...args: never[]) => Promise<unknown>)(path, ...rest)
|
||||
}) as typeof actual.stat,
|
||||
}
|
||||
})
|
||||
|
||||
const AlphaSchema: z<{ value: number }> = z.object({ value: z.number().default(0) })
|
||||
|
||||
const cleanups: Array<() => Promise<void>> = []
|
||||
|
||||
afterEach(async () => {
|
||||
state.failures.length = 0
|
||||
while (cleanups.length > 0) await cleanups.pop()!()
|
||||
})
|
||||
|
||||
async function tempDir(): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-settings-lockrace-'))
|
||||
cleanups.push(() => rm(dir, { recursive: true, force: true }))
|
||||
return dir
|
||||
}
|
||||
|
||||
async function boot(config: ConstructorParameters<typeof SettingsLocal>[1]): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
const fiber = ctx.plugin(SettingsLocal, config)
|
||||
cleanups.push(async () => { await fiber.dispose() })
|
||||
await fiber
|
||||
return ctx
|
||||
}
|
||||
|
||||
describe('writer-lock races', () => {
|
||||
it('retries immediately when the contending lock vanished before the stat', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'settings.yaml')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
const scope = ctx.settings.register(settingsNamespace('alpha'), AlphaSchema)
|
||||
// The exclusive create loses to a holder that releases before the stat:
|
||||
// no lock file actually exists, so the stat sees honest absence and the
|
||||
// very next attempt takes the lock.
|
||||
state.failures.push({ op: 'writeFile', suffix: '.lock', code: 'EEXIST' })
|
||||
await scope.update({ value: 3 })
|
||||
expect(await readFile(path, 'utf8')).toContain('value: 3')
|
||||
})
|
||||
|
||||
it('propagates a stat failure that does not mean absence', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'settings.yaml')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
const scope = ctx.settings.register(settingsNamespace('alpha'), AlphaSchema)
|
||||
state.failures.push({ op: 'writeFile', suffix: '.lock', code: 'EEXIST' })
|
||||
state.failures.push({ op: 'stat', suffix: '.lock', code: 'EACCES' })
|
||||
await expect(scope.update({ value: 3 })).rejects.toThrow(/EACCES/)
|
||||
})
|
||||
|
||||
it('cleans up the temp file and releases the lock when the write fails mid-cycle', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'settings.yaml')
|
||||
await writeFile(path, 'alpha:\n value: 1\n')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
const scope = ctx.settings.register(settingsNamespace('alpha'), AlphaSchema)
|
||||
state.failures.push({ op: 'writeFile', suffix: '.tmp', code: 'ENOSPC' })
|
||||
await expect(scope.update({ value: 9 })).rejects.toThrow(/ENOSPC/)
|
||||
// The document is untouched and the writer lock was released on the way out.
|
||||
expect(await readFile(path, 'utf8')).toContain('value: 1')
|
||||
await expect(access(`${path}.lock`)).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
225
packages/settings/settings-local/tests/watcher.spec.ts
Normal file
225
packages/settings/settings-local/tests/watcher.spec.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { SettingsLocal } from '../src/index.ts'
|
||||
|
||||
// chokidar is the nondeterministic OS boundary: faking it lets these tests
|
||||
// drive the event pipeline (error events, races with unreadable files)
|
||||
// deterministically. Real end-to-end watching stays covered by local.spec.ts.
|
||||
vi.mock('chokidar', async () => {
|
||||
const { EventEmitter } = await import('node:events')
|
||||
class FakeWatcher extends EventEmitter {
|
||||
close = vi.fn(() => Promise.resolve())
|
||||
}
|
||||
const instances: Array<{ path: string; options: unknown; watcher: InstanceType<typeof FakeWatcher> }> = []
|
||||
return {
|
||||
watch: vi.fn((path: string, options: unknown) => {
|
||||
const watcher = new FakeWatcher()
|
||||
instances.push({ path, options, watcher })
|
||||
return watcher
|
||||
}),
|
||||
__instances: instances,
|
||||
}
|
||||
})
|
||||
|
||||
interface FakeChokidar {
|
||||
__instances: Array<{
|
||||
path: string
|
||||
options: { awaitWriteFinish: { stabilityThreshold: number; pollInterval: number } }
|
||||
watcher: import('node:events').EventEmitter
|
||||
}>
|
||||
}
|
||||
|
||||
async function fakeInstances(): Promise<FakeChokidar['__instances']> {
|
||||
const chokidar = await import('chokidar') as unknown as FakeChokidar
|
||||
return chokidar.__instances
|
||||
}
|
||||
|
||||
const ThemeSchema: z<{ theme: string }> = z.object({
|
||||
theme: z.string().default('dark'),
|
||||
})
|
||||
|
||||
const cleanups: Array<() => Promise<void>> = []
|
||||
|
||||
afterEach(async () => {
|
||||
while (cleanups.length > 0) await cleanups.pop()!()
|
||||
;(await fakeInstances()).length = 0
|
||||
})
|
||||
|
||||
async function tempDir(): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-settings-watch-'))
|
||||
cleanups.push(() => rm(dir, { recursive: true, force: true }))
|
||||
return dir
|
||||
}
|
||||
|
||||
async function boot(config: ConstructorParameters<typeof SettingsLocal>[1]): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
const fiber = ctx.plugin(SettingsLocal, config)
|
||||
cleanups.push(async () => { await fiber.dispose() })
|
||||
await fiber
|
||||
return ctx
|
||||
}
|
||||
|
||||
describe('watcher pipeline', () => {
|
||||
it('clamps the write-settle poll interval for a zero debounce', async () => {
|
||||
const dir = await tempDir()
|
||||
await boot({ path: join(dir, 'settings.yaml'), debounceMs: 0 })
|
||||
const [instance] = await fakeInstances()
|
||||
expect(instance!.options.awaitWriteFinish).toEqual({ stabilityThreshold: 0, pollInterval: 1 })
|
||||
})
|
||||
|
||||
it('survives a watcher error and keeps publishing later edits', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'settings.yaml')
|
||||
const ctx = await boot({ path, debounceMs: 5 })
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
const [instance] = await fakeInstances()
|
||||
|
||||
instance!.watcher.emit('error', new Error('watch backend failure'))
|
||||
expect(scope.get()).toEqual({ theme: 'dark' })
|
||||
|
||||
await writeFile(path, 'ui-theme:\n theme: light\n')
|
||||
instance!.watcher.emit('all', 'change', path)
|
||||
await vi.waitFor(() => {
|
||||
expect(scope.get()).toEqual({ theme: 'light' })
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the last good document when the file turns unreadable at runtime', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'settings.yaml')
|
||||
await writeFile(path, 'ui-theme:\n theme: light\n')
|
||||
const ctx = await boot({ path, debounceMs: 5 })
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
|
||||
await chmod(path, 0o000)
|
||||
cleanups.push(() => chmod(path, 0o600))
|
||||
const [instance] = await fakeInstances()
|
||||
instance!.watcher.emit('all', 'change', path)
|
||||
// The warn-and-keep path is asynchronous; give the serialized refresh a turn.
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
expect(scope.get()).toEqual({ theme: 'light' })
|
||||
})
|
||||
|
||||
it('keeps the reload queue alive after an invariant violation escapes a commit', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'settings.yaml')
|
||||
await writeFile(path, 'ui-theme:\n theme: light\n')
|
||||
const ctx = await boot({ path, debounceMs: 5 })
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
let arm = true
|
||||
ctx.on('settings/updated', () => {
|
||||
if (!arm) return
|
||||
throw Object.assign(new Error('forged relation'), { code: 'INVARIANT' })
|
||||
})
|
||||
const [instance] = await fakeInstances()
|
||||
|
||||
await writeFile(path, 'ui-theme:\n theme: broken-commit\n')
|
||||
instance!.watcher.emit('all', 'change', path)
|
||||
await vi.waitFor(() => {
|
||||
expect(scope.get().theme).toBe('broken-commit')
|
||||
})
|
||||
|
||||
arm = false
|
||||
await writeFile(path, 'ui-theme:\n theme: recovered\n')
|
||||
instance!.watcher.emit('all', 'change', path)
|
||||
await vi.waitFor(() => {
|
||||
expect(scope.get().theme).toBe('recovered')
|
||||
})
|
||||
})
|
||||
|
||||
it('quiesces the refresh pipeline before dispose completes', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'settings.yaml')
|
||||
await writeFile(path, 'ui-theme:\n theme: light\n')
|
||||
const ctx = new Context()
|
||||
const fiber = ctx.plugin(SettingsLocal, { path, debounceMs: 5 })
|
||||
await fiber
|
||||
ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
let disposed = false
|
||||
let postDisposeCommits = 0
|
||||
ctx.on('settings/updated', () => {
|
||||
if (disposed) postDisposeCommits += 1
|
||||
})
|
||||
|
||||
await writeFile(path, 'ui-theme:\n theme: darker\n')
|
||||
const [instance] = await fakeInstances()
|
||||
// Two queued refreshes: dispose interrupts one mid-flight and the other
|
||||
// before it starts, so both closed guards must hold.
|
||||
instance!.watcher.emit('all', 'change', path)
|
||||
instance!.watcher.emit('all', 'change', path)
|
||||
await fiber.dispose()
|
||||
disposed = true
|
||||
instance!.watcher.emit('all', 'change', path)
|
||||
instance!.watcher.emit('ready')
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
expect(postDisposeCommits).toBe(0)
|
||||
})
|
||||
|
||||
it('treats an event for a still-absent file as a no-op', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'settings.yaml')
|
||||
const ctx = await boot({ path, debounceMs: 5 })
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
const [instance] = await fakeInstances()
|
||||
instance!.watcher.emit('all', 'add', path)
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
expect(scope.get()).toEqual({ theme: 'dark' })
|
||||
})
|
||||
|
||||
it('folds an unobserved external edit into a write instead of overwriting it', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'settings.yaml')
|
||||
await writeFile(path, 'ui-theme:\n theme: light\n')
|
||||
const ctx = await boot({ path, debounceMs: 5 })
|
||||
const theme = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
const editor = ctx.settings.register(settingsNamespace('editor'), z.object({
|
||||
tabWidth: z.number().default(2),
|
||||
}))
|
||||
// The external edit has landed on disk but its watcher event has not
|
||||
// fired yet (a debounce window, or a missed event): the write must fold
|
||||
// it in, not resurrect the stale document.
|
||||
await writeFile(path, 'ui-theme:\n theme: light\neditor:\n tabWidth: 8\n')
|
||||
await theme.update({ theme: 'darker' })
|
||||
const text = await readFile(path, 'utf8')
|
||||
expect(text).toContain('tabWidth: 8')
|
||||
expect(text).toContain('theme: darker')
|
||||
// The fold published the unobserved section before the write committed.
|
||||
expect(editor.get()).toEqual({ tabWidth: 8 })
|
||||
})
|
||||
|
||||
it('reconciles at watcher ready so a change during setup is not missed', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'settings.yaml')
|
||||
await writeFile(path, 'ui-theme:\n theme: light\n')
|
||||
const ctx = await boot({ path, debounceMs: 5 })
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
// Written after the initial load but before the watcher became active:
|
||||
// no 'all' event will ever fire for it.
|
||||
await writeFile(path, 'ui-theme:\n theme: written-before-ready\n')
|
||||
const [instance] = await fakeInstances()
|
||||
instance!.watcher.emit('ready')
|
||||
await vi.waitFor(() => {
|
||||
expect(scope.get().theme).toBe('written-before-ready')
|
||||
})
|
||||
})
|
||||
|
||||
it('fails a write loud when the on-disk document turned invalid unobserved', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'settings.yaml')
|
||||
await writeFile(path, 'ui-theme:\n theme: light\n')
|
||||
const ctx = await boot({ path, debounceMs: 5 })
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
const broken = 'ui-theme: [unclosed\n flow: {\n'
|
||||
await writeFile(path, broken)
|
||||
await expect(scope.update({ theme: 'darker' })).rejects.toThrow(/invalid document/)
|
||||
// The user's manual edit stays on disk untouched and the cache keeps the
|
||||
// last good value.
|
||||
expect(await readFile(path, 'utf8')).toBe(broken)
|
||||
expect(scope.get()).toEqual({ theme: 'light' })
|
||||
})
|
||||
})
|
||||
30
packages/settings/settings-local/tsconfig.json
Normal file
30
packages/settings/settings-local/tsconfig.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../util/paths"
|
||||
},
|
||||
{
|
||||
"path": "../settings"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
6
packages/settings/settings/README.i18n.yaml
Normal file
6
packages/settings/settings/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/settings/settings/README.md
|
||||
README.md: ec9f0e09c47015edd8495dac48beb610e0b5cdc5
|
||||
README.zh.md: 6d0a760f9b1bbef21881a03933d0fe5b9fc3cd0d
|
||||
37
packages/settings/settings/README.md
Normal file
37
packages/settings/settings/README.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# @deepseek-ai/dsh-settings
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Abstract user-settings seam (`ctx.settings`). One provider holds a raw document of per-namespace sections; plugins register a namespace schema and read a resolved value layered as schema defaults, then the registrant's composition `base` (its cordis.yml entry-config subset), then the user document section. Without a mounted provider nothing changes for consumers: they keep resolving entry config alone, so every composition works with or without settings.
|
||||
|
||||
## Service API
|
||||
|
||||
- `register(ns, schema, { base?, applies? })` — returns the owner `SettingsScope` (`get`/`watch`/`update`). The registration is an effect on the calling plugin's fiber: disposing that fiber removes the namespace and its observers. A stored section the schema rejects fails the registration itself; a duplicate namespace fails loud.
|
||||
- `describe()` — one descriptor per namespace (`schema.toJSON()` envelope, resolved value, `applies`) for configuration surfaces.
|
||||
- `get(ns)` — resolved value, `undefined` while unregistered.
|
||||
- `update(ns, patch)` — deep-merges the plain-object patch into the user section only (never the `base`), validates the resolved candidate, persists through the provider, then commits. Patches must be JSON-shaped data: a Date, Map, BigInt, non-finite number, or circular reference rejects with its `$`-rooted path before anything persists (YAML/JSON storage would silently distort such values on reload). Validation failure rejects before anything is persisted; a read-only provider (`writable: false`) rejects every write. Writes to one namespace are serialized in call order.
|
||||
- `replace(ns, section)` — sets the user section wholesale: the removal/reset path a merge cannot express (`replace({})` re-inherits `base` and schema defaults).
|
||||
- Resolved values are deep-frozen snapshots. Watchers receive `(next, prev)` after each commit: invocations of one callback run asynchronously, one at a time, in commit order (a slow stale invocation can never apply after a newer one), and failures — sync throws and async rejections alike — are contained. After a watch disposer returns, no further invocation starts (one already queued is skipped); an invocation already started still settles. The `settings/updated` event fans out one listener at a time, so one throwing listener cannot starve the rest; an async listener's rejection is contained and logged, which is why `INVARIANT`-coded failures rethrow only from synchronous listeners.
|
||||
- Service teardown refuses new writes and watcher starts, then drains every queued write and every started watcher invocation before disposal completes; a write whose registrant fiber was disposed mid-flight still reaches storage but commits and notifies nobody.
|
||||
|
||||
## Provider contract
|
||||
|
||||
Subclasses implement `writable`, `load()`, and `persist(ns, section)`, and push externally observed documents through the protected `publish(doc)`. The base service init loads and publishes the document once before the service becomes injectable; a provider with its own init (watcher, connection) delegates first via `yield* super[Service.init]()`. At publish, each registered namespace re-resolves independently: an invalid section keeps that namespace's last good value and warns — a live reload never takes the process down — while boot-time and registration-time validation fail loud.
|
||||
|
||||
## Events
|
||||
|
||||
`settings/updated (ns, next, prev, source)` fires after each commit; `source` is `update` (in-process write) or `provider` (external change). It never fires for a deep-equal resolved value.
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through consumer plugins that resolve model-affecting values (for example a default model route) from their namespaces; each consumer's own surface documents the effect.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No direct invalidation; a consumer that folds a settings value into the request prefix owns that change.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Single user layer** — resolution knows schema defaults, one composition `base`, and one user document; there is no project/managed layering or per-value provenance yet.
|
||||
- **Cross-process concurrency is provider-defined** — the seam serializes writes per namespace in-process only; concurrent processes converge by provider behavior (the local file provider read-modify-writes under a writer lock, so namespaces survive concurrent writers and same-namespace conflicts resolve last-write-wins).
|
||||
- **No secret-field redaction** — `describe()` returns resolved values verbatim; a wire surface (RPC/UI) must redact `role('secret')` fields before exposure.
|
||||
37
packages/settings/settings/README.zh.md
Normal file
37
packages/settings/settings/README.zh.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# @deepseek-ai/dsh-settings
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
抽象用户设置 seam(`ctx.settings`)。一个 provider 持有按 namespace 分节的原始文档;插件注册 namespace schema 并读取分层解析值:schema 默认值,然后注册方的组合 `base`(其 cordis.yml entry 配置子集),最后用户文档分节。不挂载 provider 时消费者行为不变:仍只按 entry 配置解析,因此任何组合有无 settings 都能工作。
|
||||
|
||||
## 服务 API
|
||||
|
||||
- `register(ns, schema, { base?, applies? })` — 返回 owner 的 `SettingsScope`(`get`/`watch`/`update`)。注册是调用方插件 fiber 上的 effect:dispose 该 fiber 即移除 namespace 及其观察者。schema 拒绝的存量分节会使注册本身失败;重复 namespace 立即报错。
|
||||
- `describe()` — 每个 namespace 一条描述(`schema.toJSON()` 信封、解析值、`applies`),供配置界面使用。
|
||||
- `get(ns)` — 解析值;未注册时为 `undefined`。
|
||||
- `update(ns, patch)` — 把普通对象 patch 深合并进用户分节(绝不合并进 `base`),校验解析候选值,经 provider 持久化后提交。patch 必须是 JSON 形状的数据:Date、Map、BigInt、非有限数或循环引用会在任何内容持久化前带着以 `$` 为根的路径拒绝(YAML/JSON 存储在重载时会静默扭曲这类值)。校验失败在持久化前拒绝;只读 provider(`writable: false`)拒绝一切写入。同一 namespace 的写入按调用顺序串行。
|
||||
- `replace(ns, section)` — 整体替换用户分节:merge 表达不了的删除/重置路径(`replace({})` 重新继承 `base` 与 schema 默认值)。
|
||||
- 解析值是深冻结快照。每次提交后观察者收到 `(next, prev)`:同一回调的调用异步、逐次、按提交顺序执行(慢的旧调用绝不会覆盖更新的结果),异常——同步抛出与异步拒绝——均被隔离。watch 的 disposer 返回后不再启动新的调用(已排队的那一次会被跳过);已启动的调用仍会结算。`settings/updated` 事件逐 listener 扇出,一个抛错的 listener 不会饿死其余 listener;异步 listener 的拒绝会被隔离并记入日志,这正是 `INVARIANT` 编码的失败只从同步 listener 重新抛出的原因。
|
||||
- 服务卸载先拒绝新写入与观察者调用的启动,再排干全部排队写入与已启动的观察者调用后才完成;registrant fiber 在写入途中被 dispose 时,该写入仍到达存储,但不向任何人提交或通知。
|
||||
|
||||
## Provider 契约
|
||||
|
||||
子类实现 `writable`、`load()`、`persist(ns, section)`,并通过受保护的 `publish(doc)` 推入外部观察到的文档。基类 service init 在服务可注入前加载并发布一次文档;自有 init(watcher、连接)的 provider 先经 `yield* super[Service.init]()` 委托。publish 时每个已注册 namespace 独立重解析:非法分节保留该 namespace 的最后可用值并告警——热重载绝不拖垮进程;启动期与注册期校验则立即报错。
|
||||
|
||||
## 事件
|
||||
|
||||
`settings/updated (ns, next, prev, source)` 在每次提交后触发;`source` 为 `update`(进程内写入)或 `provider`(外部变更)。解析值深相等时绝不触发。
|
||||
|
||||
## Model Experience
|
||||
|
||||
间接生效:消费插件从各自 namespace 解析影响模型的值(例如默认模型路由);效果由各消费者自己的文档描述。
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
无直接失效;把设置值折叠进请求前缀的消费者拥有该变更。
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **单一用户层** — 解析只认识 schema 默认值、一个组合 `base` 与一个用户文档;尚无 project/managed 分层或按值溯源。
|
||||
- **跨进程并发由 provider 定义** — seam 仅在进程内按 namespace 串行化写入;跨进程并发按 provider 行为收敛(本地文件 provider 在写锁下读-改-写,因此 namespace 在并发写入者下不会丢失,同 namespace 冲突按后写胜出解决)。
|
||||
- **无 secret 字段脱敏** — `describe()` 原样返回解析值;wire 面(RPC/UI)在暴露前必须对 `role('secret')` 字段脱敏。
|
||||
41
packages/settings/settings/package.json
Normal file
41
packages/settings/settings/package.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-settings",
|
||||
"description": "Abstract user-settings seam (ctx.settings) for the DeepSeek Harness",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"schemastery": "^3.18.0"
|
||||
}
|
||||
}
|
||||
549
packages/settings/settings/src/index.ts
Normal file
549
packages/settings/settings/src/index.ts
Normal file
@@ -0,0 +1,549 @@
|
||||
/**
|
||||
* User-settings seam (`ctx.settings`). Providers store one raw document of
|
||||
* per-namespace sections; plugins register a namespace schema and read the
|
||||
* resolved value, which layers schema defaults, the registrant's composition
|
||||
* `base`, and the user document section, in that order.
|
||||
* @module @deepseek-ai/dsh-settings
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import type z from 'schemastery'
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
|
||||
/** Nominal id of one registered settings namespace. */
|
||||
export type SettingsNamespace = Branded<'SettingsNamespace'>
|
||||
|
||||
const NAMESPACE_PATTERN = /^[a-z][a-z0-9-]*$/
|
||||
|
||||
/**
|
||||
* Brand a raw string as a {@link SettingsNamespace}.
|
||||
* @param value - candidate namespace; lowercase kebab-case, as in plugin short names.
|
||||
* @returns the branded namespace.
|
||||
*/
|
||||
export function settingsNamespace(value: string): SettingsNamespace {
|
||||
if (!NAMESPACE_PATTERN.test(value)) {
|
||||
throw new TypeError(`settings namespace "${value}" must match ${String(NAMESPACE_PATTERN)}`)
|
||||
}
|
||||
return value as SettingsNamespace
|
||||
}
|
||||
|
||||
/** When a namespace's changes take effect for its owner. */
|
||||
export type SettingsApplies = 'live' | 'restart'
|
||||
|
||||
/** Origin of one committed settings change. */
|
||||
export type SettingsUpdateSource = 'update' | 'provider'
|
||||
|
||||
/** Registration options beyond the namespace schema. */
|
||||
export interface SettingsRegisterOptions<T> {
|
||||
/** Composition-layer values resolved below the user layer (entry-config subset). */
|
||||
base?: Partial<T>
|
||||
/** Owner's effect timing, surfaced to configuration UIs; defaults to `live`. */
|
||||
applies?: SettingsApplies
|
||||
}
|
||||
|
||||
/** One registered namespace as surfaced to configuration UIs. */
|
||||
export interface SettingsDescriptor {
|
||||
// TODO(settings-namespace-vocabulary): Rename `ns` to `namespace` across the
|
||||
// public seam, provider contract, implementations, tests, and consumers.
|
||||
/** The registered namespace. */
|
||||
ns: SettingsNamespace
|
||||
/** Serialized schemastery schema (`schema.toJSON()`). */
|
||||
schema: unknown
|
||||
/** Current resolved value. */
|
||||
value: unknown
|
||||
/** Owner's declared effect timing. */
|
||||
applies: SettingsApplies
|
||||
}
|
||||
|
||||
/** Owner-facing handle for one registered namespace. */
|
||||
export interface SettingsScope<T> {
|
||||
/** Current resolved value: schema defaults, then `base`, then the user layer. */
|
||||
get(): T
|
||||
/**
|
||||
* Observe committed changes to this namespace's resolved value. Invocations
|
||||
* of one callback run asynchronously, one at a time, in commit order; a
|
||||
* rejection is contained and logged like a sync throw. After the disposer
|
||||
* returns, no further invocation starts — one already queued is skipped;
|
||||
* one already started still settles, and service disposal waits for it.
|
||||
* @param callback - invoked after each commit with the next and previous values.
|
||||
* @returns the disposer removing this observer.
|
||||
*/
|
||||
watch(callback: (next: T, prev: T) => void | Promise<void>): () => void
|
||||
/**
|
||||
* Merge a partial patch into this namespace's user layer and persist it.
|
||||
* @param patch - plain-object patch over the user section; JSON-shaped data
|
||||
* only (non-JSON values reject with their path before anything persists).
|
||||
*/
|
||||
update(patch: object): Promise<void>
|
||||
/**
|
||||
* Replace this namespace's user section wholesale; absent keys re-inherit
|
||||
* the composition `base` and schema defaults (`replace({})` resets all).
|
||||
* @param section - the complete next user section; JSON-shaped data only,
|
||||
* as for {@link update}.
|
||||
*/
|
||||
replace(section: object): Promise<void>
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
settings: Settings
|
||||
}
|
||||
|
||||
interface Events {
|
||||
/**
|
||||
* Committed change to one registered namespace's resolved value. Emitted
|
||||
* after the provider persisted (for `update`) or published (`provider`)
|
||||
* the change; never emitted when the resolved value is deep-equal.
|
||||
* Listener failures are contained and logged — a sync throw and an async
|
||||
* rejection alike — except `INVARIANT`-coded failures, which rethrow
|
||||
* after every listener ran; that rethrow reaches the emitter only from
|
||||
* synchronous listeners, so invariant checks on this event must not be
|
||||
* async functions.
|
||||
* @param ns - the namespace whose resolved value changed.
|
||||
* @param next - the new resolved value.
|
||||
* @param prev - the previous resolved value.
|
||||
* @param source - whether the change entered through `update()` or the provider.
|
||||
* @mode emit
|
||||
*/
|
||||
'settings/updated'(ns: SettingsNamespace, next: unknown, prev: unknown, source: SettingsUpdateSource): void
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep equality over JSON-shaped data (objects, arrays, primitives) — the
|
||||
* seam's single change-detection predicate, exported so the invariant
|
||||
* companion checks exactly the implementation's relation.
|
||||
* @param a - one JSON-shaped value.
|
||||
* @param b - the other JSON-shaped value.
|
||||
* @returns whether the two values are structurally equal.
|
||||
*/
|
||||
export function deepEqualJson(a: unknown, b: unknown): boolean {
|
||||
if (a === b) return true
|
||||
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false
|
||||
if (Array.isArray(a) || Array.isArray(b)) {
|
||||
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false
|
||||
return a.every((entry, index) => deepEqualJson(entry, b[index]))
|
||||
}
|
||||
const left = a as Record<string, unknown>
|
||||
const right = b as Record<string, unknown>
|
||||
const keys = Object.keys(left)
|
||||
if (keys.length !== Object.keys(right).length) return false
|
||||
return keys.every(key => key in right && deepEqualJson(left[key], right[key]))
|
||||
}
|
||||
|
||||
/** Whether a value is a plain data object (not an array, null, or class instance). */
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
|
||||
const proto: unknown = Object.getPrototypeOf(value)
|
||||
return proto === Object.prototype || proto === null
|
||||
}
|
||||
|
||||
/** Human label for a value rejected by the JSON-shape boundary (numbers reject inline). */
|
||||
function describeRejected(value: unknown): string {
|
||||
if (value === undefined) return 'undefined'
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
const proto = Object.getPrototypeOf(value) as { constructor?: { name?: string } } | null
|
||||
const name = proto?.constructor?.name
|
||||
return name === undefined || name === 'Object' ? 'a non-plain object' : `a ${name}`
|
||||
}
|
||||
return `a ${typeof value}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach one write input in a single walk that doubles as the durable-boundary
|
||||
* shape check: only JSON data (plain objects, arrays, strings, finite numbers,
|
||||
* booleans, `null`) may reach a provider document. `structuredClone` alone
|
||||
* would admit Dates, Maps, BigInts, and cycles that YAML/JSON storage then
|
||||
* silently distorts on the reload round-trip. `undefined` entries in objects
|
||||
* are skipped — the same sparse-patch semantics as {@link mergeLayers} — while
|
||||
* an `undefined` array entry is rejected rather than coerced.
|
||||
* @param root - plain-object write input (caller-checked).
|
||||
* @param reject - builds the boundary error from a value label and its `$`-rooted path.
|
||||
* @returns the detached JSON-shaped clone.
|
||||
*/
|
||||
function cloneJsonShaped(
|
||||
root: Record<string, unknown>,
|
||||
reject: (label: string, path: string) => TypeError,
|
||||
): Record<string, unknown> {
|
||||
const visiting = new WeakSet<object>()
|
||||
const clone = (value: unknown, path: string): unknown => {
|
||||
if (value === null || typeof value === 'string' || typeof value === 'boolean') return value
|
||||
if (typeof value === 'number') {
|
||||
if (!Number.isFinite(value)) throw reject('a non-finite number', path)
|
||||
return value
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (visiting.has(value)) throw reject('a circular reference', path)
|
||||
visiting.add(value)
|
||||
const entries = value.map((entry, index) => clone(entry, `${path}[${index}]`))
|
||||
// Un-mark on exit so one object referenced twice without a cycle passes.
|
||||
visiting.delete(value)
|
||||
return entries
|
||||
}
|
||||
if (isPlainObject(value)) {
|
||||
if (visiting.has(value)) throw reject('a circular reference', path)
|
||||
visiting.add(value)
|
||||
// TODO(settings-json-properties): Use property-safe construction here and
|
||||
// in mergeLayers so valid JSON keys such as "__proto__" remain own data.
|
||||
const out: Record<string, unknown> = {}
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
if (entry === undefined) continue
|
||||
out[key] = clone(entry, `${path}.${key}`)
|
||||
}
|
||||
visiting.delete(value)
|
||||
return out
|
||||
}
|
||||
throw reject(describeRejected(value), path)
|
||||
}
|
||||
return clone(root, '$') as Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Layer `over` onto `under`: plain objects merge recursively, every other
|
||||
* value (arrays included) replaces the lower layer wholesale. `over` never
|
||||
* carries `undefined` entries — sections come from parsed documents and write
|
||||
* snapshots pass {@link cloneJsonShaped}, which strips them so a sparse patch
|
||||
* cannot erase lower keys.
|
||||
*/
|
||||
function mergeLayers(under: unknown, over: unknown): unknown {
|
||||
if (over === undefined) return under
|
||||
if (!isPlainObject(under) || !isPlainObject(over)) return over
|
||||
const merged: Record<string, unknown> = { ...under }
|
||||
for (const [key, value] of Object.entries(over)) {
|
||||
merged[key] = key in merged ? mergeLayers(merged[key], value) : value
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
/** Recursively freeze one resolved value so handed-out snapshots stay immutable. */
|
||||
function deepFreeze<T>(value: T): T {
|
||||
if (typeof value !== 'object' || value === null || Object.isFrozen(value)) return value
|
||||
for (const entry of Object.values(value)) deepFreeze(entry)
|
||||
return Object.freeze(value)
|
||||
}
|
||||
|
||||
/** One registered watcher and its serialized invocation chain. */
|
||||
interface SettingsWatcher {
|
||||
callback: (next: never, prev: never) => void | Promise<void>
|
||||
/** Settled tail: invocations of this callback run one at a time, in commit order. */
|
||||
tail: Promise<void>
|
||||
/** Cleared by the disposer: a queued invocation checks this before starting. */
|
||||
active: boolean
|
||||
}
|
||||
|
||||
/** One live namespace registration owned by a registrant fiber. */
|
||||
interface SettingsRegistration {
|
||||
ns: SettingsNamespace
|
||||
schema: z<unknown>
|
||||
base: unknown
|
||||
applies: SettingsApplies
|
||||
resolved: unknown
|
||||
watchers: Set<SettingsWatcher>
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract settings service. Providers implement raw-document storage
|
||||
* (`load`/`persist`) and push external changes through {@link Settings.publish};
|
||||
* the base class owns namespace registration, resolution, validation, change
|
||||
* detection, and the `settings/updated` commit event.
|
||||
*/
|
||||
export abstract class Settings extends Service {
|
||||
private readonly registrations = new Map<SettingsNamespace, SettingsRegistration>()
|
||||
/** Latest published raw document; empty until the provider's first publish. */
|
||||
private document: Record<string, unknown> = {}
|
||||
/** Per-namespace write chains; settled tails, so a failure never poisons the queue. */
|
||||
private readonly writeQueues = new Map<SettingsNamespace, Promise<unknown>>()
|
||||
/** In-flight watcher invocation segments, drained by the dispose teardown. */
|
||||
private readonly pendingTails = new Set<Promise<void>>()
|
||||
/** Set at service dispose: refuse new writes while queued ones drain. */
|
||||
private stopped = false
|
||||
|
||||
/** Opaque read of {@link stopped}: control flow cannot narrow it across awaits. */
|
||||
private isStopped(): boolean {
|
||||
return this.stopped
|
||||
}
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'settings')
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the provider's document once and publish it before the service
|
||||
* becomes injectable, and register the write-drain teardown. Providers with
|
||||
* their own init (watchers, connections) delegate here first via
|
||||
* `yield* super[Service.init]()`; their disposers then run before the drain.
|
||||
*/
|
||||
async* [Service.init](): AsyncGenerator<() => Promise<void> | void, void, void> {
|
||||
yield async () => {
|
||||
// Teardown: refuse new writes and new watcher starts, then wait until
|
||||
// every queued write chain and every started watcher invocation settles
|
||||
// so disposal completes only once storage and observers are quiescent.
|
||||
// Invocations queued but not yet started skip via the stopped check.
|
||||
this.stopped = true
|
||||
await Promise.allSettled([...this.writeQueues.values(), ...this.pendingTails])
|
||||
}
|
||||
this.publish(await this.load())
|
||||
}
|
||||
|
||||
/** Whether {@link update} may persist through this provider. */
|
||||
abstract readonly writable: boolean
|
||||
|
||||
/**
|
||||
* Read the provider's current raw document (namespace to raw section).
|
||||
* @returns the detached raw document.
|
||||
*/
|
||||
protected abstract load(): Promise<Record<string, unknown>>
|
||||
|
||||
/**
|
||||
* Durably store one namespace's merged user section.
|
||||
* @param ns - the namespace being written.
|
||||
* @param section - the complete merged user section to store.
|
||||
*/
|
||||
protected abstract persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void>
|
||||
|
||||
/**
|
||||
* Register a namespace schema and receive its owner scope. The registration
|
||||
* is an effect on the calling plugin's fiber: disposing that fiber removes
|
||||
* the namespace and its observers. An invalid stored section fails the
|
||||
* registration itself — the earliest point where the schema can judge it.
|
||||
* @param ns - unique namespace; duplicate registration fails loud.
|
||||
* @param schema - schemastery schema resolving this namespace's value.
|
||||
* @param options - composition `base` layer and effect timing.
|
||||
* @returns the owner scope for reads, observation, and updates.
|
||||
*/
|
||||
register<T>(ns: SettingsNamespace, schema: z<T>, options?: SettingsRegisterOptions<T>): SettingsScope<T> {
|
||||
if (this.registrations.has(ns)) {
|
||||
throw new Error(`settings namespace "${ns}" is already registered`)
|
||||
}
|
||||
const registration: SettingsRegistration = {
|
||||
ns,
|
||||
schema: schema as z<unknown>,
|
||||
base: options?.base,
|
||||
applies: options?.applies ?? 'live',
|
||||
resolved: deepFreeze(this.resolve(schema, options?.base, this.section(ns))),
|
||||
watchers: new Set(),
|
||||
}
|
||||
this.ctx.effect(() => {
|
||||
this.registrations.set(ns, registration)
|
||||
// TODO(settings-registration-quiescence): Deactivate every watcher and await
|
||||
// its tail on disposal so callbacks cannot outlive the registrant fiber.
|
||||
return () => this.registrations.delete(ns)
|
||||
}, `settings.register(${JSON.stringify(String(ns))})`)
|
||||
return {
|
||||
get: () => registration.resolved as T,
|
||||
watch: (callback) => {
|
||||
const watcher: SettingsWatcher = { callback: callback, tail: Promise.resolve(), active: true }
|
||||
registration.watchers.add(watcher)
|
||||
return () => {
|
||||
watcher.active = false
|
||||
registration.watchers.delete(watcher)
|
||||
}
|
||||
},
|
||||
update: patch => this.update(ns, patch),
|
||||
replace: section => this.replace(ns, section),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Describe every registered namespace for configuration surfaces.
|
||||
* @returns one descriptor per registered namespace, in registration order.
|
||||
*/
|
||||
describe(): SettingsDescriptor[] {
|
||||
return [...this.registrations.values()].map(registration => ({
|
||||
ns: registration.ns,
|
||||
schema: registration.schema.toJSON(),
|
||||
value: registration.resolved,
|
||||
applies: registration.applies,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one registered namespace's resolved value.
|
||||
* @param ns - the namespace to read.
|
||||
* @returns the resolved value, or `undefined` while unregistered.
|
||||
*/
|
||||
get(ns: SettingsNamespace): unknown {
|
||||
return this.registrations.get(ns)?.resolved
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a patch into one registered namespace's user layer, validate the
|
||||
* resolved candidate, persist through the provider, then commit and emit.
|
||||
* A validation failure rejects before anything is persisted. Writes to one
|
||||
* namespace are serialized: concurrent updates apply in call order, each
|
||||
* merging over the previous write's committed section.
|
||||
* @param ns - the registered namespace to update.
|
||||
* @param patch - plain-object patch over the user section.
|
||||
*/
|
||||
async update(ns: SettingsNamespace, patch: object): Promise<void> {
|
||||
return this.write(ns, patch, 'merge')
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace one registered namespace's user section wholesale, validate,
|
||||
* persist, then commit and emit. Keys absent from `section` fall back to the
|
||||
* composition `base` and schema defaults — this is the removal/reset path a
|
||||
* merge-only patch cannot express (`replace({})` re-inherits everything).
|
||||
* @param ns - the registered namespace to replace.
|
||||
* @param section - the complete next user section.
|
||||
*/
|
||||
async replace(ns: SettingsNamespace, section: object): Promise<void> {
|
||||
return this.write(ns, section, 'replace')
|
||||
}
|
||||
|
||||
/** Validate a write, then queue it on the namespace's serialized write chain. */
|
||||
private write(ns: SettingsNamespace, input: object, mode: 'merge' | 'replace'): Promise<void> {
|
||||
const verb = mode === 'merge' ? 'update' : 'replace'
|
||||
const registration = this.registrations.get(ns)
|
||||
if (registration === undefined) {
|
||||
throw new Error(`settings namespace "${ns}" is not registered`)
|
||||
}
|
||||
if (this.isStopped()) {
|
||||
throw new Error(`settings service is disposed: "${ns}" cannot be written`)
|
||||
}
|
||||
if (!this.writable) {
|
||||
throw new Error(`settings provider is read-only: "${ns}" cannot be updated in-process`)
|
||||
}
|
||||
if (!isPlainObject(input)) {
|
||||
throw new TypeError(`settings ${verb} for "${ns}" must be a plain object`)
|
||||
}
|
||||
// Snapshot at call time: the queue must never read a caller-owned object
|
||||
// the caller may keep mutating while the write waits its turn. The same
|
||||
// walk is the JSON-shape boundary check (see cloneJsonShaped).
|
||||
const snapshot = cloneJsonShaped(input, (label, path) =>
|
||||
new TypeError(`settings ${verb} for "${ns}" must be JSON-shaped data (found ${label} at ${path})`))
|
||||
const previous = this.writeQueues.get(ns) ?? Promise.resolve()
|
||||
// Chain past a failed predecessor: one rejected write must not poison the
|
||||
// namespace queue for every later caller.
|
||||
const run = previous.catch(() => undefined).then(async () => {
|
||||
if (this.isStopped()) {
|
||||
throw new Error(`settings service was disposed before the queued "${ns}" ${verb} ran`)
|
||||
}
|
||||
if (this.registrations.get(ns) !== registration) {
|
||||
throw new Error(`settings namespace "${ns}" registration was disposed before the queued ${verb} ran`)
|
||||
}
|
||||
const section = mode === 'merge'
|
||||
? mergeLayers(this.section(ns) ?? {}, snapshot) as Record<string, unknown>
|
||||
: snapshot
|
||||
const next = deepFreeze(this.resolve(registration.schema, registration.base, section))
|
||||
await this.persist(ns, section)
|
||||
// The write reached storage either way; the cache must say so. Commit
|
||||
// only when this registration is still the namespace owner — a fiber
|
||||
// disposed (or replaced) mid-persist must not receive the notification.
|
||||
this.document[ns] = section
|
||||
// TODO(settings-replacement-resync): Re-resolve any replacement registration
|
||||
// from this persisted section so an old in-flight write cannot leave it stale.
|
||||
if (this.registrations.get(ns) === registration && !this.isStopped()) {
|
||||
this.commit(registration, next, 'update')
|
||||
}
|
||||
})
|
||||
this.writeQueues.set(ns, run)
|
||||
return run
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider hook: commit a complete raw document observed in storage. Each
|
||||
* registered namespace re-resolves; an invalid section keeps that
|
||||
* namespace's last good value and warns, other namespaces still commit.
|
||||
* @param doc - the detached raw document (unregistered sections preserved).
|
||||
* @param source - change origin; defaults to `provider`.
|
||||
*/
|
||||
protected publish(doc: Record<string, unknown>, source: SettingsUpdateSource = 'provider'): void {
|
||||
this.document = doc
|
||||
for (const registration of this.registrations.values()) {
|
||||
let next: unknown
|
||||
try {
|
||||
next = deepFreeze(this.resolve(registration.schema, registration.base, this.section(registration.ns)))
|
||||
} catch (error) {
|
||||
this.ctx.logger.warn('settings: keeping last good "%s" after invalid stored section', registration.ns)
|
||||
this.ctx.logger.warn(error)
|
||||
continue
|
||||
}
|
||||
this.commit(registration, next, source)
|
||||
}
|
||||
}
|
||||
|
||||
/** Read one namespace's raw user section, rejecting non-object sections. */
|
||||
private section(ns: SettingsNamespace): Record<string, unknown> | undefined {
|
||||
const section = this.document[ns]
|
||||
if (section === undefined) return undefined
|
||||
if (!isPlainObject(section)) {
|
||||
throw new TypeError(`settings section "${ns}" must be an object of keys`)
|
||||
}
|
||||
return section
|
||||
}
|
||||
|
||||
/** Resolve one namespace value: schema defaults, then `base`, then the user layer. */
|
||||
private resolve<T>(schema: z<T>, base: unknown, section: Record<string, unknown> | undefined): T {
|
||||
// The merged candidate is untyped by construction; the schema call is the
|
||||
// runtime validation that admits it into T.
|
||||
return schema(mergeLayers(base, section) as never)
|
||||
}
|
||||
|
||||
/** Commit a resolved value when changed: swap, notify watchers, emit the event. */
|
||||
private commit(registration: SettingsRegistration, next: unknown, source: SettingsUpdateSource): void {
|
||||
const prev = registration.resolved
|
||||
if (deepEqualJson(next, prev)) return
|
||||
registration.resolved = next
|
||||
for (const watcher of [...registration.watchers]) {
|
||||
// Serialize per watcher: invocations of one callback run one at a time
|
||||
// in commit order, so a slow stale invocation can never apply after a
|
||||
// newer one. Sync throws and async rejections land in the same handler.
|
||||
// The activity check runs when the queued invocation would start, so a
|
||||
// disposer (or service stop) that ran while it waited prevents the
|
||||
// start entirely; started invocations drain at service dispose.
|
||||
const segment = watcher.tail
|
||||
.then(() => {
|
||||
if (!watcher.active || this.isStopped()) return
|
||||
return watcher.callback(next as never, prev as never)
|
||||
})
|
||||
.then(() => undefined, (error: unknown) => {
|
||||
this.warnWatcherFailure(registration.ns, error)
|
||||
})
|
||||
watcher.tail = segment
|
||||
this.pendingTails.add(segment)
|
||||
void segment.then(() => this.pendingTails.delete(segment))
|
||||
}
|
||||
// Fan the event out one listener at a time (the plain emit stops at the
|
||||
// first throwing listener, starving the rest). Invariant violations are
|
||||
// harness-fatal by design and rethrow after every listener ran; any other
|
||||
// failure is contained so one broken observer cannot wedge the commit
|
||||
// path (and, through it, a provider's reload loop).
|
||||
let invariantFailure: unknown
|
||||
const args = ['settings/updated', registration.ns, next, prev, source]
|
||||
for (const listener of this.ctx.events.dispatch('emit', args) as Array<(...listenerArgs: unknown[]) => unknown>) {
|
||||
try {
|
||||
const returned = listener(registration.ns, next, prev, source)
|
||||
if (returned != null && typeof (returned as PromiseLike<unknown>).then === 'function') {
|
||||
// An emit listener may still be an async function; its rejection
|
||||
// cannot reach the synchronous INVARIANT rethrow below, so it is
|
||||
// contained here instead of becoming an unhandled rejection.
|
||||
void Promise.resolve(returned as PromiseLike<unknown>).then(undefined, (error: unknown) => {
|
||||
this.warnListenerFailure(registration.ns, error)
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as { code?: unknown } | null)?.code === 'INVARIANT') {
|
||||
invariantFailure ??= error
|
||||
continue
|
||||
}
|
||||
this.warnListenerFailure(registration.ns, error)
|
||||
}
|
||||
}
|
||||
if (invariantFailure !== undefined) throw invariantFailure as Error
|
||||
}
|
||||
|
||||
/** Contained-watcher diagnostic shared by the sync and async failure paths. */
|
||||
private warnWatcherFailure(ns: SettingsNamespace, error: unknown): void {
|
||||
this.ctx.logger.warn('settings: watcher for "%s" failed', ns)
|
||||
this.ctx.logger.warn(error)
|
||||
}
|
||||
|
||||
/** Contained-listener diagnostic shared by the sync and async failure paths. */
|
||||
private warnListenerFailure(ns: SettingsNamespace, error: unknown): void {
|
||||
this.ctx.logger.warn('settings: a settings/updated listener for "%s" failed', ns)
|
||||
this.ctx.logger.warn(error)
|
||||
}
|
||||
}
|
||||
|
||||
export default Settings
|
||||
48
packages/settings/settings/src/invariant.ts
Normal file
48
packages/settings/settings/src/invariant.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-settings`.
|
||||
* @module @deepseek-ai/dsh-settings/invariant
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import { deepEqualJson } from './index.ts'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-settings'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'settings-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* Install the commit-event contract: `settings/updated` fires only for a
|
||||
* currently registered namespace, only when the resolved value changed, and
|
||||
* only with the service's authoritative resolved value — all judged with the
|
||||
* seam's own equality predicate.
|
||||
*/
|
||||
const install: InvariantInstaller = (ctx: Context, fail: InvariantFailure) => {
|
||||
ctx.on('settings/updated', (ns, next, prev) => {
|
||||
const settings = ctx.get('settings')
|
||||
if (settings === undefined) {
|
||||
fail(`settings/updated for "${ns}" emitted without a live settings service`)
|
||||
}
|
||||
const current = settings.get(ns)
|
||||
if (current === undefined) {
|
||||
fail(`settings/updated for "${ns}" emitted while the namespace is unregistered`)
|
||||
}
|
||||
if (!deepEqualJson(current, next)) {
|
||||
fail(`settings/updated for "${ns}" does not match the authoritative resolved value`)
|
||||
}
|
||||
if (deepEqualJson(next, prev)) {
|
||||
fail(`settings/updated for "${ns}" emitted without a resolved-value change`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
52
packages/settings/settings/tests/invariant.spec.ts
Normal file
52
packages/settings/settings/tests/invariant.spec.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import * as SettingsInvariant from '../src/invariant.ts'
|
||||
import { settingsNamespace } from '../src/index.ts'
|
||||
import { MemorySettings } from './memory.ts'
|
||||
|
||||
async function setup(withProvider: boolean): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(SettingsInvariant)
|
||||
if (withProvider) await ctx.plugin(MemorySettings)
|
||||
return ctx
|
||||
}
|
||||
|
||||
describe('settings invariants', () => {
|
||||
it('fails a settings/updated emission without a live settings service', async () => {
|
||||
const ctx = await setup(false)
|
||||
expect(() => {
|
||||
ctx.emit('settings/updated', settingsNamespace('ghost'), { a: 1 }, { a: 2 }, 'provider')
|
||||
}).toThrow(/without a live settings service/)
|
||||
})
|
||||
|
||||
it('fails a settings/updated emission for an unregistered namespace', async () => {
|
||||
const ctx = await setup(true)
|
||||
expect(() => {
|
||||
ctx.emit('settings/updated', settingsNamespace('ghost'), { a: 1 }, { a: 2 }, 'provider')
|
||||
}).toThrow(/unregistered/)
|
||||
})
|
||||
|
||||
it('fails a settings/updated emission without a resolved-value change', async () => {
|
||||
const ctx = await setup(true)
|
||||
ctx.settings.register(settingsNamespace('ui-theme'), z.object({
|
||||
theme: z.string().default('dark'),
|
||||
}))
|
||||
expect(() => {
|
||||
ctx.emit('settings/updated', settingsNamespace('ui-theme'), { theme: 'dark' }, { theme: 'dark' }, 'update')
|
||||
}).toThrow(/without a resolved-value change/)
|
||||
})
|
||||
|
||||
it('fails a settings/updated emission whose value diverges from the authoritative state', async () => {
|
||||
const ctx = await setup(true)
|
||||
ctx.settings.register(settingsNamespace('ui-theme'), z.object({
|
||||
theme: z.string().default('dark'),
|
||||
}))
|
||||
// Fabricated next ≠ the service's current resolved value ({theme: 'dark'}).
|
||||
expect(() => {
|
||||
ctx.emit('settings/updated', settingsNamespace('ui-theme'), { theme: 'forged' }, { theme: 'dark' }, 'update')
|
||||
}).toThrow(/authoritative/)
|
||||
})
|
||||
})
|
||||
54
packages/settings/settings/tests/memory.ts
Normal file
54
packages/settings/settings/tests/memory.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* In-memory settings provider fixture: the smallest real subclass of the seam,
|
||||
* used by the base-class behavior suite in place of a file- or network-backed
|
||||
* provider. Kept in `tests/` because production providers live in their own
|
||||
* packages.
|
||||
*/
|
||||
|
||||
import { Settings, type SettingsNamespace } from '../src/index.ts'
|
||||
|
||||
/** In-memory provider exposing the protected seam hooks to tests. */
|
||||
export class MemorySettings extends Settings {
|
||||
/** Raw document the provider "storage" currently holds. */
|
||||
doc: Record<string, unknown>
|
||||
/** Every persist() call observed, in order. */
|
||||
persisted: Array<{ ns: SettingsNamespace; section: Record<string, unknown> }> = []
|
||||
/** When false, update() must reject before reaching persist(). */
|
||||
writableFlag: boolean
|
||||
|
||||
/** Artificial persist latency so tests can interleave concurrent updates. */
|
||||
persistDelayMs: number
|
||||
|
||||
constructor(ctx: ConstructorParameters<typeof Settings>[0], options?: {
|
||||
doc?: Record<string, unknown>
|
||||
writable?: boolean
|
||||
persistDelayMs?: number
|
||||
}) {
|
||||
super(ctx)
|
||||
this.doc = structuredClone(options?.doc ?? {})
|
||||
this.writableFlag = options?.writable ?? true
|
||||
this.persistDelayMs = options?.persistDelayMs ?? 0
|
||||
}
|
||||
|
||||
get writable(): boolean {
|
||||
return this.writableFlag
|
||||
}
|
||||
|
||||
protected load(): Promise<Record<string, unknown>> {
|
||||
return Promise.resolve(structuredClone(this.doc))
|
||||
}
|
||||
|
||||
protected async persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
|
||||
if (this.persistDelayMs > 0) {
|
||||
await new Promise(resolve => setTimeout(resolve, this.persistDelayMs))
|
||||
}
|
||||
this.persisted.push({ ns, section: structuredClone(section) })
|
||||
this.doc[ns] = structuredClone(section)
|
||||
}
|
||||
|
||||
/** Simulate an external storage change reaching the provider. */
|
||||
pushExternal(doc: Record<string, unknown>): void {
|
||||
this.doc = structuredClone(doc)
|
||||
this.publish(structuredClone(doc))
|
||||
}
|
||||
}
|
||||
654
packages/settings/settings/tests/settings.spec.ts
Normal file
654
packages/settings/settings/tests/settings.spec.ts
Normal file
@@ -0,0 +1,654 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { Settings, deepEqualJson, settingsNamespace, type SettingsNamespace, type SettingsScope, type SettingsUpdateSource } from '../src/index.ts'
|
||||
import { MemorySettings } from './memory.ts'
|
||||
|
||||
/** A provider implementing only the three primitives: the seam owns init. */
|
||||
class BareProvider extends Settings {
|
||||
doc: Record<string, unknown>
|
||||
|
||||
constructor(ctx: ConstructorParameters<typeof Settings>[0], options?: { doc?: Record<string, unknown> }) {
|
||||
super(ctx)
|
||||
this.doc = structuredClone(options?.doc ?? {})
|
||||
}
|
||||
|
||||
get writable(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
protected load(): Promise<Record<string, unknown>> {
|
||||
return Promise.resolve(structuredClone(this.doc))
|
||||
}
|
||||
|
||||
protected persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
|
||||
this.doc[ns] = structuredClone(section)
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
interface ThemeConfig {
|
||||
theme: 'dark' | 'light'
|
||||
fontSize: number
|
||||
}
|
||||
|
||||
const ThemeSchema: z<ThemeConfig> = z.object({
|
||||
theme: z.union(['dark', 'light']).default('dark'),
|
||||
fontSize: z.number().default(14),
|
||||
})
|
||||
|
||||
interface NestedConfig {
|
||||
retry: { attempts: number; delayMs: number }
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
const NestedSchema: z<NestedConfig> = z.object({
|
||||
retry: z.object({
|
||||
attempts: z.number().default(2),
|
||||
delayMs: z.number().default(100),
|
||||
}),
|
||||
tags: z.array(z.string()).default(['default']),
|
||||
})
|
||||
|
||||
async function boot(options?: ConstructorParameters<typeof MemorySettings>[1]) {
|
||||
const ctx = new Context()
|
||||
const fiber = ctx.plugin(MemorySettings, options)
|
||||
await fiber
|
||||
const provider = ctx.get('settings') as MemorySettings
|
||||
return { ctx, provider, fiber }
|
||||
}
|
||||
|
||||
/** Record every settings/updated emission. */
|
||||
function recordUpdates(ctx: Context) {
|
||||
const events: Array<{ ns: string; next: unknown; prev: unknown; source: SettingsUpdateSource }> = []
|
||||
ctx.on('settings/updated', (ns, next, prev, source) => {
|
||||
events.push({ ns, next, prev, source })
|
||||
})
|
||||
return events
|
||||
}
|
||||
|
||||
describe('settingsNamespace', () => {
|
||||
it('brands lowercase kebab-case names', () => {
|
||||
expect(settingsNamespace('ui-theme')).toBe('ui-theme')
|
||||
})
|
||||
|
||||
it.each(['', 'UI', '9lives', 'a_b', '-lead'])('rejects %j', (value) => {
|
||||
expect(() => settingsNamespace(value)).toThrow(TypeError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('registration', () => {
|
||||
it('resolves schema defaults, then composition base, then the user layer', async () => {
|
||||
const { ctx } = await boot({ doc: { 'ui-theme': { theme: 'light' } } })
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema, {
|
||||
base: { fontSize: 16 },
|
||||
})
|
||||
// theme: user layer wins; fontSize: base wins over the schema default.
|
||||
expect(scope.get()).toEqual({ theme: 'light', fontSize: 16 })
|
||||
})
|
||||
|
||||
it('rejects a duplicate namespace loud', async () => {
|
||||
const { ctx } = await boot()
|
||||
ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
expect(() => ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema))
|
||||
.toThrow(/already registered/)
|
||||
})
|
||||
|
||||
it('fails registration when the stored section is invalid for the schema', async () => {
|
||||
const { ctx } = await boot({ doc: { 'ui-theme': { fontSize: 'big' } } })
|
||||
expect(() => ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)).toThrow()
|
||||
})
|
||||
|
||||
it('fails registration when the stored section is not an object', async () => {
|
||||
const { ctx } = await boot({ doc: { 'ui-theme': 'dark' } })
|
||||
expect(() => ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema))
|
||||
.toThrow(/must be an object/)
|
||||
})
|
||||
|
||||
it('describes registered namespaces with schema JSON, value, and applies', async () => {
|
||||
const { ctx } = await boot()
|
||||
ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
ctx.settings.register(settingsNamespace('workspace'), NestedSchema, { applies: 'restart' })
|
||||
const descriptors = ctx.settings.describe()
|
||||
expect(descriptors.map(entry => [entry.ns, entry.applies])).toEqual([
|
||||
['ui-theme', 'live'],
|
||||
['workspace', 'restart'],
|
||||
])
|
||||
expect(descriptors[0]!.value).toEqual({ theme: 'dark', fontSize: 14 })
|
||||
// schemastery's canonical wire form: a { uid, refs } envelope whose root ref
|
||||
// is the object schema — the shape schema-driven form UIs reconstruct from.
|
||||
const serialized = descriptors[0]!.schema as { uid: number; refs: Record<string, { type: string }> }
|
||||
expect(serialized.refs[String(serialized.uid)]?.type).toBe('object')
|
||||
})
|
||||
|
||||
it('reads undefined for an unregistered namespace', async () => {
|
||||
const { ctx } = await boot()
|
||||
expect(ctx.settings.get(settingsNamespace('missing'))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('hands out frozen resolved values', async () => {
|
||||
const { ctx } = await boot({ doc: { workspace: { retry: { attempts: 5 } } } })
|
||||
const scope = ctx.settings.register(settingsNamespace('workspace'), NestedSchema)
|
||||
const value = scope.get()
|
||||
expect(Object.isFrozen(value)).toBe(true)
|
||||
expect(Object.isFrozen(value.retry)).toBe(true)
|
||||
expect(() => { (value.retry as { attempts: number }).attempts = 0 }).toThrow(TypeError)
|
||||
})
|
||||
|
||||
it('removes the namespace and its observers when the registrant fiber disposes', async () => {
|
||||
const { ctx, provider } = await boot()
|
||||
const seen: unknown[] = []
|
||||
let scope: SettingsScope<ThemeConfig> | undefined
|
||||
const fiber = ctx.plugin({
|
||||
inject: ['settings'],
|
||||
apply: (child: Context) => {
|
||||
scope = child.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
scope.watch((next) => { seen.push(next) })
|
||||
},
|
||||
})
|
||||
await fiber
|
||||
expect(ctx.settings.get(settingsNamespace('ui-theme'))).toEqual({ theme: 'dark', fontSize: 14 })
|
||||
|
||||
await fiber.dispose()
|
||||
expect(ctx.settings.get(settingsNamespace('ui-theme'))).toBeUndefined()
|
||||
expect(ctx.settings.describe()).toEqual([])
|
||||
provider.pushExternal({ 'ui-theme': { theme: 'light' } })
|
||||
expect(seen).toEqual([])
|
||||
|
||||
// The namespace is free again, and re-registration resolves the user layer
|
||||
// that kept living in storage while nobody owned the namespace.
|
||||
const again = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
expect(again.get()).toEqual({ theme: 'light', fontSize: 14 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('update', () => {
|
||||
it('persists the merged user section without baking in the base layer', async () => {
|
||||
const { ctx, provider } = await boot({ doc: { 'ui-theme': { theme: 'light' } } })
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema, {
|
||||
base: { fontSize: 16 },
|
||||
})
|
||||
await scope.update({ theme: 'dark' })
|
||||
expect(provider.persisted).toEqual([
|
||||
{ ns: 'ui-theme', section: { theme: 'dark' } },
|
||||
])
|
||||
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 16 })
|
||||
})
|
||||
|
||||
it('deep-merges nested objects and replaces arrays wholesale', async () => {
|
||||
const { ctx, provider } = await boot({
|
||||
doc: { workspace: { retry: { attempts: 5, delayMs: 300 }, tags: ['a', 'b'] } },
|
||||
})
|
||||
const scope = ctx.settings.register(settingsNamespace('workspace'), NestedSchema)
|
||||
await scope.update({ retry: { attempts: 7 }, tags: ['c'] })
|
||||
expect(provider.persisted[0]!.section).toEqual({
|
||||
retry: { attempts: 7, delayMs: 300 },
|
||||
tags: ['c'],
|
||||
})
|
||||
expect(scope.get()).toEqual({ retry: { attempts: 7, delayMs: 300 }, tags: ['c'] })
|
||||
})
|
||||
|
||||
it('commits, notifies watchers, and emits with source update', async () => {
|
||||
const { ctx } = await boot()
|
||||
const events = recordUpdates(ctx)
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
const watcher = vi.fn()
|
||||
scope.watch(watcher)
|
||||
await scope.update({ theme: 'light' })
|
||||
expect(watcher).toHaveBeenCalledWith(
|
||||
{ theme: 'light', fontSize: 14 },
|
||||
{ theme: 'dark', fontSize: 14 },
|
||||
)
|
||||
expect(events).toEqual([{
|
||||
ns: 'ui-theme',
|
||||
next: { theme: 'light', fontSize: 14 },
|
||||
prev: { theme: 'dark', fontSize: 14 },
|
||||
source: 'update',
|
||||
}])
|
||||
})
|
||||
|
||||
it('rejects an invalid patch before persisting anything', async () => {
|
||||
const { ctx, provider } = await boot()
|
||||
const events = recordUpdates(ctx)
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
await expect(scope.update({ fontSize: 'big' })).rejects.toThrow()
|
||||
expect(provider.persisted).toEqual([])
|
||||
expect(events).toEqual([])
|
||||
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 })
|
||||
// The failed write must not poison the namespace queue for later writers.
|
||||
await scope.update({ fontSize: 18 })
|
||||
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 18 })
|
||||
})
|
||||
|
||||
it('ignores explicit undefined entries so a sparse patch cannot erase keys', async () => {
|
||||
const { ctx, provider } = await boot({ doc: { 'ui-theme': { theme: 'light' } } })
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
await scope.update({ theme: undefined, fontSize: 18 })
|
||||
expect(provider.persisted[0]!.section).toEqual({ theme: 'light', fontSize: 18 })
|
||||
expect(scope.get()).toEqual({ theme: 'light', fontSize: 18 })
|
||||
})
|
||||
|
||||
it('rejects a non-object patch', async () => {
|
||||
const { ctx } = await boot()
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
await expect(scope.update([1])).rejects.toThrow(TypeError)
|
||||
await expect(scope.update(new Date() as unknown as object)).rejects.toThrow(TypeError)
|
||||
await expect(scope.replace([1])).rejects.toThrow(/replace for "ui-theme"/)
|
||||
})
|
||||
|
||||
it('accepts a null-prototype patch object', async () => {
|
||||
const { ctx } = await boot()
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
const patch: { fontSize?: number } = Object.create(null) as { fontSize?: number }
|
||||
patch.fontSize = 18
|
||||
await scope.update(patch)
|
||||
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 18 })
|
||||
})
|
||||
|
||||
it('rejects an unregistered namespace', async () => {
|
||||
const { ctx } = await boot()
|
||||
await expect(ctx.settings.update(settingsNamespace('missing'), {}))
|
||||
.rejects.toThrow(/not registered/)
|
||||
})
|
||||
|
||||
it('rejects on a read-only provider before reaching persist', async () => {
|
||||
const { ctx, provider } = await boot({ writable: false })
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
await expect(scope.update({ theme: 'light' })).rejects.toThrow(/read-only/)
|
||||
expect(provider.persisted).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('deepEqualJson', () => {
|
||||
it.each([
|
||||
[{ a: [1, 2] }, { a: [1, 2] }, true],
|
||||
[{ a: [1, 2] }, { a: [1] }, false],
|
||||
[{ a: [1] }, { a: { 0: 1 } }, false],
|
||||
[{ a: 1 }, { b: 1 }, false],
|
||||
[{ a: 1 }, {}, false],
|
||||
[{ a: null }, { a: null }, true],
|
||||
[{ a: null }, { a: {} }, false],
|
||||
])('compares %j vs %j as %s', (a, b, equal) => {
|
||||
expect(deepEqualJson(a, b)).toBe(equal)
|
||||
})
|
||||
})
|
||||
|
||||
describe('review regressions', () => {
|
||||
it('propagates an invariant-coded listener failure instead of containing it', async () => {
|
||||
const { ctx, provider } = await boot()
|
||||
ctx.on('settings/updated', () => {
|
||||
throw Object.assign(new Error('forged relation'), { code: 'INVARIANT' })
|
||||
})
|
||||
ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
expect(() => { provider.pushExternal({ 'ui-theme': { theme: 'light' } }) })
|
||||
.toThrow(/forged relation/)
|
||||
})
|
||||
|
||||
it('serializes concurrent updates so neither patch is lost', async () => {
|
||||
const { ctx, provider } = await boot({ persistDelayMs: 10 })
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
await Promise.all([
|
||||
scope.update({ theme: 'light' }),
|
||||
scope.update({ fontSize: 20 }),
|
||||
])
|
||||
expect(provider.doc['ui-theme']).toEqual({ theme: 'light', fontSize: 20 })
|
||||
expect(scope.get()).toEqual({ theme: 'light', fontSize: 20 })
|
||||
})
|
||||
|
||||
it('contains a throwing settings/updated listener and keeps later commits alive', async () => {
|
||||
const { ctx, provider } = await boot()
|
||||
ctx.on('settings/updated', () => {
|
||||
throw new Error('listener boom')
|
||||
})
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
expect(() => { provider.pushExternal({ 'ui-theme': { theme: 'light' } }) }).not.toThrow()
|
||||
expect(scope.get().theme).toBe('light')
|
||||
provider.pushExternal({ 'ui-theme': { theme: 'dark' } })
|
||||
expect(scope.get().theme).toBe('dark')
|
||||
})
|
||||
|
||||
it('contains an async watcher rejection', async () => {
|
||||
const { ctx, provider } = await boot()
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
scope.watch(async () => {
|
||||
throw new Error('async watcher boom')
|
||||
})
|
||||
provider.pushExternal({ 'ui-theme': { theme: 'light' } })
|
||||
expect(scope.get().theme).toBe('light')
|
||||
// Give the rejected watcher promise a microtask turn; containment means
|
||||
// vitest observes no unhandled rejection out of this test.
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
})
|
||||
|
||||
it('loads the provider document through the base init without provider boilerplate', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(BareProvider, { doc: { 'ui-theme': { fontSize: 7 } } })
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 7 })
|
||||
})
|
||||
|
||||
it('replaces the user section wholesale so overrides can be removed', async () => {
|
||||
const { ctx, provider } = await boot({ doc: { 'ui-theme': { theme: 'light', fontSize: 20 } } })
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema, {
|
||||
base: { fontSize: 16 },
|
||||
})
|
||||
await scope.replace({ theme: 'light' })
|
||||
// fontSize override is gone: resolution falls back to the base layer.
|
||||
expect(scope.get()).toEqual({ theme: 'light', fontSize: 16 })
|
||||
expect(provider.doc['ui-theme']).toEqual({ theme: 'light' })
|
||||
await scope.replace({})
|
||||
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 16 })
|
||||
expect(provider.doc['ui-theme']).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('second review regressions', () => {
|
||||
it('runs every settings/updated listener even when an earlier one throws', async () => {
|
||||
const { ctx, provider } = await boot()
|
||||
ctx.on('settings/updated', () => {
|
||||
throw new Error('first listener boom')
|
||||
})
|
||||
const second = vi.fn()
|
||||
ctx.on('settings/updated', second)
|
||||
ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
provider.pushExternal({ 'ui-theme': { theme: 'light' } })
|
||||
expect(second).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('rejects an update queued after the registrant fiber disposed', async () => {
|
||||
const { ctx } = await boot()
|
||||
let scope: SettingsScope<ThemeConfig> | undefined
|
||||
const fiber = ctx.plugin({
|
||||
inject: ['settings'],
|
||||
apply: (child: Context) => {
|
||||
scope = child.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
},
|
||||
})
|
||||
await fiber
|
||||
await fiber.dispose()
|
||||
await expect(scope!.update({ theme: 'light' })).rejects.toThrow(/disposed|not registered/)
|
||||
})
|
||||
|
||||
it('does not notify a registrant disposed while its update was in flight', async () => {
|
||||
const { ctx, provider } = await boot({ persistDelayMs: 30 })
|
||||
const events = recordUpdates(ctx)
|
||||
let scope: SettingsScope<ThemeConfig> | undefined
|
||||
const watcher = vi.fn()
|
||||
const fiber = ctx.plugin({
|
||||
inject: ['settings'],
|
||||
apply: (child: Context) => {
|
||||
scope = child.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
scope.watch(watcher)
|
||||
},
|
||||
})
|
||||
await fiber
|
||||
const pending = scope!.update({ theme: 'light' })
|
||||
await new Promise(resolve => setTimeout(resolve, 5))
|
||||
await fiber.dispose()
|
||||
await pending.catch(() => undefined)
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
expect(watcher).not.toHaveBeenCalled()
|
||||
expect(events).toEqual([])
|
||||
// The persist was already in flight, so storage keeps the write — but no
|
||||
// commit reached the disposed registration.
|
||||
expect(provider.doc['ui-theme']).toEqual({ theme: 'light' })
|
||||
})
|
||||
|
||||
it('drains in-flight writes at service dispose and rejects later ones', async () => {
|
||||
const { ctx, provider, fiber } = await boot({ persistDelayMs: 20 })
|
||||
const service = ctx.settings
|
||||
const scope = service.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
const pending = scope.update({ theme: 'light' })
|
||||
await new Promise(resolve => setTimeout(resolve, 5))
|
||||
await fiber.dispose()
|
||||
// The teardown drained the in-flight write before completing…
|
||||
await pending.catch(() => undefined)
|
||||
const persistedAtDispose = provider.persisted.length
|
||||
expect(persistedAtDispose).toBe(1)
|
||||
// …and afterwards nothing writes and new writes reject.
|
||||
await expect(service.update(settingsNamespace('ui-theme'), { theme: 'dark' }))
|
||||
.rejects.toThrow(/disposed|not registered/)
|
||||
await new Promise(resolve => setTimeout(resolve, 40))
|
||||
expect(provider.persisted.length).toBe(persistedAtDispose)
|
||||
})
|
||||
|
||||
it('serializes invocations of one async watcher in commit order', async () => {
|
||||
const { ctx, provider } = await boot()
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
const applied: number[] = []
|
||||
let firstCall = true
|
||||
scope.watch(async (next) => {
|
||||
// The first (stale) invocation is slow; unserialised it would finish
|
||||
// last and clobber the newer applied state.
|
||||
const delay = firstCall ? 30 : 0
|
||||
firstCall = false
|
||||
await new Promise(resolve => setTimeout(resolve, delay))
|
||||
applied.push(next.fontSize)
|
||||
})
|
||||
provider.pushExternal({ 'ui-theme': { fontSize: 1 } })
|
||||
provider.pushExternal({ 'ui-theme': { fontSize: 2 } })
|
||||
await vi.waitFor(() => {
|
||||
expect(applied).toHaveLength(2)
|
||||
})
|
||||
expect(applied).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('rejects a function value as not JSON-shaped', async () => {
|
||||
const { ctx } = await boot()
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
await expect(scope.update({ theme: () => 'dark' }))
|
||||
.rejects.toThrow(/JSON-shaped.*function at \$\.theme/)
|
||||
})
|
||||
|
||||
it('rejects a write still queued when the service disposes', async () => {
|
||||
const { ctx, fiber } = await boot({ persistDelayMs: 20 })
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
const first = scope.update({ theme: 'light' })
|
||||
const second = scope.update({ fontSize: 20 })
|
||||
await new Promise(resolve => setTimeout(resolve, 5))
|
||||
await fiber.dispose()
|
||||
await first
|
||||
await expect(second).rejects.toThrow(/disposed before the queued/)
|
||||
})
|
||||
|
||||
it('rejects a write still queued when the registrant disposes', async () => {
|
||||
const { ctx } = await boot({ persistDelayMs: 20 })
|
||||
let scope: SettingsScope<ThemeConfig> | undefined
|
||||
const fiber = ctx.plugin({
|
||||
inject: ['settings'],
|
||||
apply: (child: Context) => {
|
||||
scope = child.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
},
|
||||
})
|
||||
await fiber
|
||||
const first = scope!.update({ theme: 'light' })
|
||||
const second = scope!.update({ fontSize: 20 })
|
||||
await new Promise(resolve => setTimeout(resolve, 5))
|
||||
await fiber.dispose()
|
||||
await first
|
||||
await expect(second).rejects.toThrow(/registration was disposed before the queued/)
|
||||
})
|
||||
|
||||
it('snapshots the patch at call time so caller mutation cannot leak in', async () => {
|
||||
const { ctx } = await boot()
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
const patch = { fontSize: 18 }
|
||||
const pending = scope.update(patch)
|
||||
patch.fontSize = 99
|
||||
await pending
|
||||
expect(scope.get().fontSize).toBe(18)
|
||||
})
|
||||
})
|
||||
|
||||
describe('publish', () => {
|
||||
it('notifies watchers of an external change with source provider', async () => {
|
||||
const { ctx, provider } = await boot()
|
||||
const events = recordUpdates(ctx)
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
const watcher = vi.fn()
|
||||
scope.watch(watcher)
|
||||
provider.pushExternal({ 'ui-theme': { theme: 'light' } })
|
||||
await vi.waitFor(() => {
|
||||
expect(watcher).toHaveBeenCalledWith(
|
||||
{ theme: 'light', fontSize: 14 },
|
||||
{ theme: 'dark', fontSize: 14 },
|
||||
)
|
||||
})
|
||||
expect(events[0]!.source).toBe('provider')
|
||||
})
|
||||
|
||||
it('stays silent when the resolved value is deep-equal', async () => {
|
||||
const { ctx, provider } = await boot({ doc: { 'ui-theme': { theme: 'light' } } })
|
||||
const events = recordUpdates(ctx)
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
const watcher = vi.fn()
|
||||
scope.watch(watcher)
|
||||
provider.pushExternal({ 'ui-theme': { theme: 'light' } })
|
||||
expect(watcher).not.toHaveBeenCalled()
|
||||
expect(events).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps the last good value for an invalid section while other namespaces commit', async () => {
|
||||
const { ctx, provider } = await boot()
|
||||
const events = recordUpdates(ctx)
|
||||
const theme = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
const workspace = ctx.settings.register(settingsNamespace('workspace'), NestedSchema)
|
||||
provider.pushExternal({
|
||||
'ui-theme': { fontSize: 'broken' },
|
||||
workspace: { retry: { attempts: 9 } },
|
||||
})
|
||||
expect(theme.get()).toEqual({ theme: 'dark', fontSize: 14 })
|
||||
expect(workspace.get()).toEqual({ retry: { attempts: 9, delayMs: 100 }, tags: ['default'] })
|
||||
expect(events.map(event => event.ns)).toEqual(['workspace'])
|
||||
})
|
||||
|
||||
it('recovers from a bad section once storage turns valid again', async () => {
|
||||
const { ctx, provider } = await boot()
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
provider.pushExternal({ 'ui-theme': { fontSize: 'broken' } })
|
||||
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 })
|
||||
provider.pushExternal({ 'ui-theme': { fontSize: 18 } })
|
||||
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 18 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('third review regressions', () => {
|
||||
it('skips a queued watch invocation whose disposer ran before it started', async () => {
|
||||
const { ctx, provider } = await boot()
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
const watcher = vi.fn()
|
||||
const dispose = scope.watch(watcher)
|
||||
// The commit chains the invocation as a microtask; the disposer runs in
|
||||
// the same synchronous frame, before that invocation could start.
|
||||
provider.pushExternal({ 'ui-theme': { theme: 'light' } })
|
||||
dispose()
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
expect(watcher).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('waits for an in-flight watch invocation at service dispose', async () => {
|
||||
const { ctx, provider, fiber } = await boot()
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
let release: (() => void) | undefined
|
||||
let finished = false
|
||||
scope.watch(async () => {
|
||||
await new Promise<void>((resolve) => { release = resolve })
|
||||
finished = true
|
||||
})
|
||||
provider.pushExternal({ 'ui-theme': { theme: 'light' } })
|
||||
await vi.waitFor(() => { expect(release).toBeDefined() })
|
||||
let disposed = false
|
||||
const disposal = fiber.dispose().then(() => { disposed = true })
|
||||
await new Promise(resolve => setTimeout(resolve, 15))
|
||||
expect(disposed).toBe(false)
|
||||
release!()
|
||||
await disposal
|
||||
expect(finished).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a Date at its path before anything persists', async () => {
|
||||
const { ctx, provider } = await boot()
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), z.object({ value: z.any() }))
|
||||
await expect(scope.update({ value: { at: new Date(0) } }))
|
||||
.rejects.toThrow(/JSON-shaped.*Date at \$\.value\.at/)
|
||||
expect(provider.persisted).toEqual([])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a Map', { value: new Map() }, /Map at \$\.value/],
|
||||
['a bigint', { value: [10n] }, /bigint at \$\.value\[0\]/],
|
||||
['a symbol', { value: Symbol('x') }, /symbol at \$\.value/],
|
||||
['a non-finite number', { value: Number.NaN }, /non-finite number at \$\.value/],
|
||||
['an undefined array entry', { value: [undefined] }, /undefined at \$\.value\[0\]/],
|
||||
['a class instance', { value: Object.create({ marker: true }) as object }, /non-plain object at \$\.value/],
|
||||
])('rejects %s that structuredClone would admit', async (_label, patch, message) => {
|
||||
const { ctx } = await boot()
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), z.object({ value: z.any() }))
|
||||
await expect(scope.update(patch)).rejects.toThrow(message)
|
||||
})
|
||||
|
||||
it('rejects a circular patch instead of storing an alias-looped document', async () => {
|
||||
const { ctx } = await boot()
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), z.object({ value: z.any() }))
|
||||
const cyclic: Record<string, unknown> = {}
|
||||
cyclic['self'] = cyclic
|
||||
await expect(scope.update({ value: cyclic })).rejects.toThrow(/circular reference at \$\.value\.self/)
|
||||
const loop: unknown[] = []
|
||||
loop.push(loop)
|
||||
await expect(scope.update({ value: loop })).rejects.toThrow(/circular reference at \$\.value\[0\]/)
|
||||
})
|
||||
|
||||
it('accepts one object referenced twice without a cycle', async () => {
|
||||
const { ctx } = await boot()
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), z.object({ value: z.any() }))
|
||||
const shared = { leaf: 1 }
|
||||
await scope.update({ value: { left: shared, right: shared } })
|
||||
expect(scope.get()).toEqual({ value: { left: { leaf: 1 }, right: { leaf: 1 } } })
|
||||
})
|
||||
|
||||
it('contains an async settings/updated listener rejection and keeps other listeners running', async () => {
|
||||
const { ctx, provider } = await boot()
|
||||
// An async listener violates the event's synchronous signature, but an
|
||||
// unlinted JS plugin can still register one. Declaring the return as
|
||||
// unknown keeps this file's typed surface legal (unknown-returning
|
||||
// functions are assignable to void positions) while the runtime value is
|
||||
// still the rejected promise the containment guard must handle.
|
||||
const boom = (): unknown => Promise.reject(new Error('async listener boom'))
|
||||
ctx.on('settings/updated', boom)
|
||||
const second = vi.fn()
|
||||
ctx.on('settings/updated', second)
|
||||
ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
provider.pushExternal({ 'ui-theme': { theme: 'light' } })
|
||||
expect(second).toHaveBeenCalledTimes(1)
|
||||
// Containment gives the rejection a handler; vitest observes no unhandled
|
||||
// rejection out of this test.
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
})
|
||||
})
|
||||
|
||||
describe('watch', () => {
|
||||
it('stops after its disposer runs', async () => {
|
||||
const { ctx, provider } = await boot()
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
const watcher = vi.fn()
|
||||
const dispose = scope.watch(watcher)
|
||||
dispose()
|
||||
provider.pushExternal({ 'ui-theme': { theme: 'light' } })
|
||||
expect(watcher).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('contains a throwing watcher without blocking the commit or other watchers', async () => {
|
||||
const { ctx, provider } = await boot()
|
||||
const events = recordUpdates(ctx)
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
scope.watch(() => { throw new Error('watcher boom') })
|
||||
const second = vi.fn()
|
||||
scope.watch(second)
|
||||
provider.pushExternal({ 'ui-theme': { theme: 'light' } })
|
||||
await vi.waitFor(() => {
|
||||
expect(second).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(events).toHaveLength(1)
|
||||
expect(scope.get()).toEqual({ theme: 'light', fontSize: 14 })
|
||||
})
|
||||
})
|
||||
27
packages/settings/settings/tsconfig.json
Normal file
27
packages/settings/settings/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/ui/tui/README.md
|
||||
README.md: dc8af7796d62ca1588423dc63aa592fd3308d218
|
||||
README.zh.md: 8e5fb632d8903c1915015396af20a791a9a3ab70
|
||||
README.md: 63c888b1d51c02fa85a8f0cc1617874debd87c4e
|
||||
README.zh.md: ca5efc9ae26a9833d271991f73a21c607d8fb09d
|
||||
|
||||
@@ -12,7 +12,7 @@ This package owns interactive terminal presentation and input only. It injects `
|
||||
|
||||
After terminal startup succeeds, the package provides the terminal-local `ctx.tui` extension service. A plugin that injects it can call `openOverlay()` with a component factory and constrained layout options; the host exposes the viewport, semantic theme, display-text escaping, redraw, close, and a lifetime signal, but not the pi-tui tree, terminal, focus controller, or overlay handle. Plugin overlays, the model selector, and user questions share one FIFO modal queue. Each request is an effect of the calling plugin fiber, so unload removes queued work or closes visible work before cleanup settles; terminal shutdown unloads dependents before stopping pi-tui. Overlay state is not logged or replayed. Component code is trusted and may render ANSI styling, but must pass untrusted text through `host.display()`. The [interactive-extension Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md) owns the boundary and rejected alternatives.
|
||||
|
||||
The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the standing `todo/write` plan above the editor (cleared on the next `turn/start`), and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. The latest logged session title becomes the header subtitle, with `welcome` before a title exists, and the terminal window title becomes `<session title> — <configured title>`. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelInfo()` context for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode plus the current model and any explicitly selected reasoning effort; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. Surface replacement events rebuild the transcript so compacted history does not reappear.
|
||||
The TUI rebuilds resumed history from the append-origin session events, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the standing `todo/write` plan above the editor (cleared on the next `turn/start`), and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. The latest logged session title becomes the header subtitle, with `welcome` before a title exists, and the terminal window title becomes `<session title> — <configured title>`. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelInfo()` context for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode plus the current model and any explicitly selected reasoning effort; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. A surface replacement never rewrites the rendered transcript: the conversation it shadows stays readable, and a landed compaction checkpoint adds one dim `… earlier context was compacted …` marker at its log position, so the terminal reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies — a pruned tool result, a regenerated assistant message — render nothing.
|
||||
|
||||
An embedding may provide `TuiRuntime.formatCwd` when its logical workspace label differs from the session's host directory. The override changes only the footer label; tools continue to use the session `cwd`.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ DeepSeek Harness agent(智能体)的交互式终端入口,基于 [`@earend
|
||||
|
||||
终端成功启动后,本包会提供终端本地的 `ctx.tui` 扩展服务。注入该服务的插件可以使用组件工厂和受限布局选项调用 `openOverlay()`;宿主会公开 viewport、语义化主题、显示文本转义、重绘、关闭和生命周期信号,但不公开 pi-tui 树、终端、焦点控制器或 overlay 句柄。插件 overlay、模型选择器和用户问题共用一个 FIFO 模态队列。每个请求都是调用方插件 fiber 的 effect,因此卸载会移除排队工作,或在清理结算前关闭可见工作;终端关闭会先卸载依赖项,再停止 pi-tui。Overlay 状态不会记录或回放。组件代码受信任,可以渲染 ANSI 样式,但必须通过 `host.display()` 处理不受信任文本。[交互式扩展 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md)持有该边界和未采用的替代方案。
|
||||
|
||||
TUI 从活跃会话表层重建已恢复历史,渲染 Markdown 响应与 reasoning,将每个工具的 `presentCall` / `presentResult` 意图应用到终端、diff 或通用卡片,把站立的 `todo/write` 计划保留在编辑器上方(下一个 `turn/start` 时清空),并在左下方宽键盘面板中展示 `ctx.userInteraction` 问题,包含进度、编号选项和对齐说明。最新记录的会话标题成为 header 副标题;标题不存在时使用 `welcome`,终端窗口标题则变为 `<session title> — <configured title>`。持久 `llm/retry` 事件会撤回失败步骤的实时 chunk,并在 transcript(文本记录)中渲染计划重试次数、延迟和失败;成功、耗尽与取消随后通过普通会话事件结算。Footer 会对每个已记录模型步骤的用量只计一次,包括失败尝试;对于没有用量 chunk 的日志,以已提交消息的用量回退。其空闲视图会将 token-meter 压力与 `ctx.llm.resolveModelInfo()` 为当前路由返回的上下文容量进行比较;适配器没有容量元数据时显示 `context unknown`,并显示工具卡片模式、当前模型,以及任何显式选择的推理强度。Agent 运行时,这些摘要会替换为已经过工作时间指示器和 `esc interrupt`。表层替换事件会重建 transcript,使经过压缩(compaction)的历史不会再次出现。
|
||||
TUI 从追加来源的会话事件重建已恢复历史,渲染 Markdown 响应与 reasoning,将每个工具的 `presentCall` / `presentResult` 意图应用到终端、diff 或通用卡片,把站立的 `todo/write` 计划保留在编辑器上方(下一个 `turn/start` 时清空),并在左下方宽键盘面板中展示 `ctx.userInteraction` 问题,包含进度、编号选项和对齐说明。最新记录的会话标题成为 header 副标题;标题不存在时使用 `welcome`,终端窗口标题则变为 `<session title> — <configured title>`。持久 `llm/retry` 事件会撤回失败步骤的实时 chunk,并在 transcript(文本记录)中渲染计划重试次数、延迟和失败;成功、耗尽与取消随后通过普通会话事件结算。Footer 会对每个已记录模型步骤的用量只计一次,包括失败尝试;对于没有用量 chunk 的日志,以已提交消息的用量回退。其空闲视图会将 token-meter 压力与 `ctx.llm.resolveModelInfo()` 为当前路由返回的上下文容量进行比较;适配器没有容量元数据时显示 `context unknown`,并显示工具卡片模式、当前模型,以及任何显式选择的推理强度。Agent 运行时,这些摘要会替换为已经过工作时间指示器和 `esc interrupt`。表层替换从不重写已渲染的 transcript:被它遮蔽的对话仍可阅读,而已落地的压缩(compaction)检查点会在其日志位置添加一行暗色 `… earlier context was compacted …` 标记,因此终端报告的是模型从何处起不再看到那段历史,而不是把它抹掉。仅供模型使用的替换副本——被裁剪的工具结果、重新生成的 assistant 消息——不渲染任何内容。
|
||||
|
||||
如果逻辑工作区标签与会话宿主目录不同,嵌入方可以提供 `TuiRuntime.formatCwd`。该覆盖只改变 footer 标签;工具仍使用会话 `cwd`。
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-agent-loop": "^0.0.1",
|
||||
"@deepseek-ai/dsh-commands": "^0.0.1",
|
||||
"@deepseek-ai/dsh-compact": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm-retry": "^0.0.1",
|
||||
@@ -74,6 +75,7 @@
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-compact": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-retry": "workspace:^",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Zero-state helpers for the interactive chat channel: prompt-directory and
|
||||
* Git-branch formatting, surface/tool-call derivations over the session log,
|
||||
* Git-branch formatting, transcript/tool-call derivations over the session log,
|
||||
* session-reference context cards, the placeholder editor, and banner-reveal
|
||||
* timing constants. None of these close over channel state.
|
||||
* @module @deepseek-ai/dsh-tui/chat/helpers
|
||||
@@ -15,7 +15,9 @@ import {
|
||||
truncateToWidth,
|
||||
visibleWidth,
|
||||
} from '@earendil-works/pi-tui'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import { isCompactCheckpointSource } from '@deepseek-ai/dsh-compact'
|
||||
import { isAppendSurfaceEvent, isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
|
||||
|
||||
/** Editor that shows a placeholder without making it editable content. */
|
||||
@@ -81,24 +83,16 @@ export function gitBranch(cwd: string): string | undefined {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sequence numbers currently visible on the session surface.
|
||||
* @param session - session whose surface nodes to read.
|
||||
* @returns the set of visible event sequence numbers.
|
||||
*/
|
||||
export function activeSurfaceSeqs(session: Session): Set<number> {
|
||||
return new Set(session.surface.nodes)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool-call ids whose owning assistant message is on the active surface.
|
||||
* Tool-call ids whose owning assistant message is append-origin, so its tool
|
||||
* cards stay paired in the transcript after a replacement shadowed the message
|
||||
* on the model surface.
|
||||
* @param session - session whose events to scan.
|
||||
* @param active - sequence numbers currently on the surface.
|
||||
* @returns the set of active tool-call ids.
|
||||
* @returns the set of transcript tool-call ids.
|
||||
*/
|
||||
export function activeToolCallIds(session: Session, active: ReadonlySet<number>): Set<string> {
|
||||
export function transcriptToolCallIds(session: Session): Set<string> {
|
||||
const ids = new Set<string>()
|
||||
for (const event of session.events) {
|
||||
if (event.type !== 'assistant/message' || !active.has(event.seq)) continue
|
||||
if (event.type !== 'assistant/message' || !isAppendSurfaceEvent(event)) continue
|
||||
for (const block of event.data.message.content) {
|
||||
if (block.type === 'tool-call') ids.add(block.id)
|
||||
}
|
||||
@@ -106,6 +100,26 @@ export function activeToolCallIds(session: Session, active: ReadonlySet<number>)
|
||||
return ids
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an event is a landed compaction checkpoint. Recognition goes through
|
||||
* {@link isCompactCheckpointSource} — the compaction seam's backend-independent
|
||||
* contract for the source every backend stamps on its replacement user message —
|
||||
* rather than the shape of the replacement. Other replacements (a pruned
|
||||
* `tool/result`, a regenerated `assistant/message`) rewrite one node for the
|
||||
* model and mark no boundary in the conversation.
|
||||
*
|
||||
* Both current call sites already test the replacement themselves. The check
|
||||
* keeps the exported predicate true to its name for a third caller, rather than
|
||||
* making that caller repeat it.
|
||||
* @param event - event to test.
|
||||
* @returns true when the event compacted a surface range.
|
||||
*/
|
||||
export function isCompactCheckpoint(event: SessionEvent): boolean {
|
||||
return event.type === 'user/message'
|
||||
&& isCompactCheckpointSource(event.data.source)
|
||||
&& isReplacementSurfaceEvent(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a session-reference context card's display labels from an event source.
|
||||
* @param source - event source to inspect.
|
||||
|
||||
@@ -35,6 +35,7 @@ import type { ContentBlock, MessageId } from '@deepseek-ai/dsh-llm'
|
||||
import type {} from '@deepseek-ai/dsh-llm-retry'
|
||||
import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import {
|
||||
isReplacementSurfaceEvent,
|
||||
lastActivityTime,
|
||||
SessionId,
|
||||
type SessionEvent,
|
||||
@@ -120,14 +121,14 @@ import {
|
||||
} from './chat/skill-invocation.ts'
|
||||
import { ReferenceAutocompleteProvider } from './chat/autocomplete.ts'
|
||||
import {
|
||||
activeSurfaceSeqs,
|
||||
activeToolCallIds,
|
||||
BANNER_REVEAL_INTERVAL_MS,
|
||||
BANNER_REVEAL_STEPS,
|
||||
formatCwd,
|
||||
gitBranch,
|
||||
HintEditor,
|
||||
isCompactCheckpoint,
|
||||
sessionReferenceCard,
|
||||
transcriptToolCallIds,
|
||||
} from './chat/helpers.ts'
|
||||
import {
|
||||
createModelController,
|
||||
@@ -261,6 +262,13 @@ export const inject = ['agents', 'sessions', 'commands', 'userInteraction', 'too
|
||||
/** Model guidance for path-only file references selected through the TUI. */
|
||||
export const FILE_REFERENCE_PROMPT = 'Paths prefixed with @ are files explicitly referenced by the user. Use the read tool when their contents are needed; do not claim to have inspected a file before reading it.'
|
||||
|
||||
/**
|
||||
* Transcript row standing in for one compacted range. The conversation the
|
||||
* compaction replaced stays rendered above it: the marker reports where the
|
||||
* model stopped seeing that history, not that the history is gone.
|
||||
*/
|
||||
const COMPACTION_MARKER = '… earlier context was compacted …'
|
||||
|
||||
interface RunningStatus {
|
||||
turn: number | undefined
|
||||
timer: ReturnType<typeof setInterval>
|
||||
@@ -808,6 +816,23 @@ export function createTuiChat(
|
||||
}
|
||||
}
|
||||
|
||||
const renderCompactionMarker = (): void => {
|
||||
chat.addChild(new Spacer(1))
|
||||
chat.addChild(new Text(palette.dim(COMPACTION_MARKER), 0, 0))
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay the human transcript from the append-only log. The model-visible
|
||||
* surface shadows compacted ranges, so it is not the source here: every
|
||||
* append-origin message stays rendered, and a replacement contributes at most
|
||||
* the compaction marker at its own log position.
|
||||
*
|
||||
* The `tool/call` pairing check has no live counterpart, because only replay
|
||||
* can meet an orphan: `tool/call` carries no `surfaceOp` of its own, so it
|
||||
* inherits transcript membership from the `assistant/message` that advertised
|
||||
* it, which the live listener has necessarily just rendered. A loaded log is a
|
||||
* replay boundary, so the pairing is re-derived here instead of assumed.
|
||||
*/
|
||||
const rebuildTranscript = (populateHistory: boolean): void => {
|
||||
chat.clear()
|
||||
toolCards.clear()
|
||||
@@ -815,15 +840,13 @@ export function createTuiChat(
|
||||
contextCards.clear()
|
||||
streaming = undefined
|
||||
todo.update([])
|
||||
const active = activeSurfaceSeqs(agent.session)
|
||||
const activeCalls = activeToolCallIds(agent.session, active)
|
||||
const transcriptCalls = transcriptToolCallIds(agent.session)
|
||||
for (const event of agent.session.events) {
|
||||
const isSurface = event.type === 'user/message'
|
||||
|| event.type === 'assistant/message'
|
||||
|| event.type === 'tool/result'
|
||||
|| event.type === 'steering/message'
|
||||
if (isSurface && !active.has(event.seq)) continue
|
||||
if (event.type === 'tool/call' && !activeCalls.has(event.data.callId)) continue
|
||||
if (isReplacementSurfaceEvent(event)) {
|
||||
if (isCompactCheckpoint(event)) renderCompactionMarker()
|
||||
continue
|
||||
}
|
||||
if (event.type === 'tool/call' && !transcriptCalls.has(event.data.callId)) continue
|
||||
renderEvent(event, { addHistory: populateHistory, renderChunks: false })
|
||||
}
|
||||
requestRender()
|
||||
@@ -1475,8 +1498,11 @@ export function createTuiChat(
|
||||
recordEventUsage(tokens, event)
|
||||
if (event.type === 'turn/start' && runningStatus !== undefined) runningStatus.turn = event.data.turn
|
||||
if (event.type === 'assistant/message' && streaming?.isSettled()) streaming = undefined
|
||||
if ('surfaceOp' in event && typeof event.surfaceOp === 'object') {
|
||||
rebuildTranscript(false)
|
||||
// A replacement mutates only the model surface, so the rendered transcript
|
||||
// keeps what it already showed; a landed summary checkpoint adds its marker.
|
||||
if (isReplacementSurfaceEvent(event)) {
|
||||
if (isCompactCheckpoint(event)) renderCompactionMarker()
|
||||
requestRender()
|
||||
return
|
||||
}
|
||||
renderEvent(event, { addHistory: false, renderChunks: true })
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
terminal 44x18 buffer=normal length=18 base=0 viewport=0
|
||||
terminal 44x18 buffer=normal length=24 base=6 viewport=6
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=7 viewportRow=14 bufferRow=14
|
||||
cursor hidden column=7 viewportRow=17 bufferRow=23
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-magenta bold
|
||||
@@ -13,25 +13,39 @@ buffer
|
||||
3| <blank>
|
||||
4| "Assistant "
|
||||
style 0-8 fg=bright-magenta bold underline
|
||||
5| "Model wait 0.0s "
|
||||
5| <blank>
|
||||
6| "You "
|
||||
style 0-2 fg=bright-magenta bold underline
|
||||
7| "Old prompt with a long line that exercises "
|
||||
8| "wrapping and stays visible after compaction."
|
||||
9| <blank>
|
||||
10| "● Tool / bash / Run the coverage gate"
|
||||
style 0-36 fg=green
|
||||
11| "$ pnpm run test:coverage "
|
||||
style 0-23 dim
|
||||
12| "/workspace/project "
|
||||
style 0-17 dim
|
||||
13| "packages/ui/tui 100% "
|
||||
style 0-19 dim
|
||||
14| "… +1 lines (Ctrl+O to expand) "
|
||||
style 0-28 dim
|
||||
15| "1 test skipped "
|
||||
style 0-13 dim
|
||||
16| "coverage complete "
|
||||
style 0-16 dim
|
||||
17| "[exit 0] "
|
||||
style 0-7 dim
|
||||
18| "Model wait 0.0s "
|
||||
style 0-14 dim
|
||||
6| <blank>
|
||||
7| "Context · workspace-context"
|
||||
style 0-26 dim
|
||||
8| "Additional instructions from: "
|
||||
style 0-43 dim
|
||||
9| "nested/AGENTS.md "
|
||||
style 0-15 dim
|
||||
10| " "
|
||||
11| "Render workspace context XML clearly. "
|
||||
style 0-36 dim
|
||||
12| <blank>
|
||||
13| "/workspace/project (tui-staging) deepseek-v"
|
||||
19| <blank>
|
||||
20| "… earlier context was compacted … "
|
||||
style 0-32 dim
|
||||
21| <blank>
|
||||
22| "/workspace/project (tui-staging) deepseek-v"
|
||||
style 0-17 fg=bright-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-43 dim
|
||||
14| " dsh > "
|
||||
23| " dsh > "
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
15-17| <blank>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
terminal 104x30 buffer=normal length=30 base=0 viewport=0
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=7 viewportRow=13 bufferRow=13
|
||||
cursor hidden column=7 viewportRow=22 bufferRow=22
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-magenta bold
|
||||
@@ -13,25 +13,41 @@ buffer
|
||||
3| <blank>
|
||||
4| "Assistant "
|
||||
style 0-8 fg=bright-magenta bold underline
|
||||
5| "Model wait 0.0s "
|
||||
5| <blank>
|
||||
6| "You "
|
||||
style 0-2 fg=bright-magenta bold underline
|
||||
7| "Old prompt with a long line that exercises wrapping and stays visible after compaction. "
|
||||
8| <blank>
|
||||
9| "● Tool / bash / Run the coverage gate"
|
||||
style 0-36 fg=green
|
||||
10| "$ pnpm run test:coverage "
|
||||
style 0-23 dim
|
||||
11| "/workspace/project "
|
||||
style 0-17 dim
|
||||
12| "packages/ui/tui 100% "
|
||||
style 0-19 dim
|
||||
13| "… +1 lines (Ctrl+O to expand) "
|
||||
style 0-28 dim
|
||||
14| "1 test skipped "
|
||||
style 0-13 dim
|
||||
15| "coverage complete "
|
||||
style 0-16 dim
|
||||
16| "[exit 0] "
|
||||
style 0-7 dim
|
||||
17| "Model wait 0.0s "
|
||||
style 0-14 dim
|
||||
6| <blank>
|
||||
7| "Context · workspace-context"
|
||||
style 0-26 dim
|
||||
8| "Additional instructions from: nested/AGENTS.md "
|
||||
style 0-45 dim
|
||||
9| " "
|
||||
10| "Render workspace context XML clearly. "
|
||||
style 0-36 dim
|
||||
11| <blank>
|
||||
12| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
18| <blank>
|
||||
19| "… earlier context was compacted … "
|
||||
style 0-32 dim
|
||||
20| <blank>
|
||||
21| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-50 dim
|
||||
style 53-57 dim
|
||||
style 60-69 dim
|
||||
13| " dsh > "
|
||||
22| " dsh > "
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
14-29| <blank>
|
||||
23-29| <blank>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
terminal 80x24 buffer=normal length=24 base=0 viewport=0
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=7 viewportRow=20 bufferRow=20
|
||||
cursor hidden column=7 viewportRow=21 bufferRow=21
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-magenta bold
|
||||
@@ -16,35 +16,36 @@ buffer
|
||||
5| <blank>
|
||||
6| "You "
|
||||
style 0-2 fg=bright-magenta bold underline
|
||||
7| "Old prompt with a long line that exercises wrapping before compaction. "
|
||||
8| <blank>
|
||||
9| "● Tool / bash / Run the coverage gate"
|
||||
7| "Old prompt with a long line that exercises wrapping and stays visible after "
|
||||
8| "compaction. "
|
||||
9| <blank>
|
||||
10| "● Tool / bash / Run the coverage gate"
|
||||
style 0-36 fg=green
|
||||
10| "$ pnpm run test:coverage "
|
||||
11| "$ pnpm run test:coverage "
|
||||
style 0-23 dim
|
||||
11| "/workspace/project "
|
||||
12| "/workspace/project "
|
||||
style 0-17 dim
|
||||
12| "packages/ui/tui 100% "
|
||||
13| "packages/ui/tui 100% "
|
||||
style 0-19 dim
|
||||
13| "… +1 lines (Ctrl+O to expand) "
|
||||
14| "… +1 lines (Ctrl+O to expand) "
|
||||
style 0-28 dim
|
||||
14| "1 test skipped "
|
||||
15| "1 test skipped "
|
||||
style 0-13 dim
|
||||
15| "coverage complete "
|
||||
16| "coverage complete "
|
||||
style 0-16 dim
|
||||
16| "[exit 0] "
|
||||
17| "[exit 0] "
|
||||
style 0-7 dim
|
||||
17| "Model wait 0.0s "
|
||||
18| "Model wait 0.0s "
|
||||
style 0-14 dim
|
||||
18| <blank>
|
||||
19| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
19| <blank>
|
||||
20| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-50 dim
|
||||
style 53-57 dim
|
||||
style 60-69 dim
|
||||
20| " dsh > "
|
||||
21| " dsh > "
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
21-23| <blank>
|
||||
22-23| <blank>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
terminal 104x30 buffer=normal length=30 base=0 viewport=0
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=7 viewportRow=22 bufferRow=22
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
4| "Assistant "
|
||||
style 0-8 fg=bright-magenta bold underline
|
||||
5| <blank>
|
||||
6| "You "
|
||||
style 0-2 fg=bright-magenta bold underline
|
||||
7| "Old prompt with a long line that exercises wrapping and stays visible after compaction. "
|
||||
8| <blank>
|
||||
9| "● Tool / bash / Run the coverage gate"
|
||||
style 0-36 fg=green
|
||||
10| "$ pnpm run test:coverage "
|
||||
style 0-23 dim
|
||||
11| "/workspace/project "
|
||||
style 0-17 dim
|
||||
12| "packages/ui/tui 100% "
|
||||
style 0-19 dim
|
||||
13| "… +1 lines (Ctrl+O to expand) "
|
||||
style 0-28 dim
|
||||
14| "1 test skipped "
|
||||
style 0-13 dim
|
||||
15| "coverage complete "
|
||||
style 0-16 dim
|
||||
16| "[exit 0] "
|
||||
style 0-7 dim
|
||||
17| "Model wait 0.0s "
|
||||
style 0-14 dim
|
||||
18| <blank>
|
||||
19| "… earlier context was compacted … "
|
||||
style 0-32 dim
|
||||
20| <blank>
|
||||
21| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-magenta bold
|
||||
style 18-31 dim
|
||||
style 34-50 dim
|
||||
style 53-57 dim
|
||||
style 60-69 dim
|
||||
22| " dsh > "
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
23-29| <blank>
|
||||
@@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url'
|
||||
import { afterAll, describe, expect, it, vi } from 'vitest'
|
||||
import type { Context } from 'cordis'
|
||||
import { agentEvents } from '@deepseek-ai/dsh-agent'
|
||||
import { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact'
|
||||
import { createUserMessage, CallId, type ContentBlock , createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type {} from '@deepseek-ai/dsh-llm-retry'
|
||||
import { SessionId, type JsonValue, type Session, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
@@ -50,6 +51,7 @@ const CHECKPOINTS = [
|
||||
'surface-before-compaction',
|
||||
'surface-after-compaction-narrow',
|
||||
'surface-after-compaction-wide',
|
||||
'surface-replayed-compaction',
|
||||
'model-selector',
|
||||
'model-selector-filtered',
|
||||
'model-switching',
|
||||
@@ -181,6 +183,67 @@ function appendToolResult(
|
||||
}, { surfaceOp: 'append' })
|
||||
}
|
||||
|
||||
/** Frozen clock for the compaction fixtures; see the live scenario for why. */
|
||||
const COMPACTION_FIXTURE_TIME = new Date(2026, 6, 21, 14, 40, 0).getTime()
|
||||
|
||||
/** The surface range a compaction checkpoint replaces, with its provenance. */
|
||||
interface CompactionRange {
|
||||
start: number
|
||||
end: number
|
||||
sources: number[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Append one prompt / tool-call / tool-result step, the history a compaction
|
||||
* shadows on the model surface and the transcript must keep showing. The prompt
|
||||
* text is rendered verbatim; the tool card's body comes from `bash`'s static
|
||||
* presenter, so the fixtures pin that the shadowed step's card survives rather
|
||||
* than the result content below.
|
||||
*/
|
||||
function appendPreCompactionLog(session: Session): CompactionRange {
|
||||
const user = session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'Old prompt with a long line that exercises wrapping and stays visible after compaction.' }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
const assistant = session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool-call', id: CallId('old-tool'), name: 'bash', arguments: '{}' }],
|
||||
source: {
|
||||
kind: 'model',
|
||||
...{ provider: 'mock', model: 'deepseek-v4-flash' },
|
||||
},
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: CallId('old-tool'), name: 'bash', arguments: '{}' })
|
||||
const result = session.append('tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: CallId('old-tool'),
|
||||
content: [{ type: 'text', text: 'shadowed step tool output' }],
|
||||
isError: false,
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
return { start: user.seq, end: result.seq, sources: [user.seq, assistant.seq, result.seq] }
|
||||
}
|
||||
|
||||
/** Land a compaction: replace the range with the framed model-only checkpoint. */
|
||||
function appendCompactionCheckpoint(session: Session, range: CompactionRange): void {
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: '<context_checkpoint>\nModel-only summary payload that must never reach the transcript.\n</context_checkpoint>',
|
||||
}],
|
||||
source: COMPACT_CHECKPOINT_SOURCE,
|
||||
}), {
|
||||
surfaceOp: { op: 'replace', start: range.start, end: range.end },
|
||||
sourceEventSeqs: range.sources,
|
||||
})
|
||||
}
|
||||
|
||||
function visualTool(
|
||||
name: string,
|
||||
call: NonNullable<ToolDefinition['presentCall']>,
|
||||
@@ -684,61 +747,22 @@ describe('TUI terminal-state snapshots', () => {
|
||||
await disposeSnapshot(harness)
|
||||
})
|
||||
|
||||
it('pins compaction surface replacement and narrow-to-wide reflow', async () => {
|
||||
it('pins preserved history, the compaction marker, and narrow-to-wide reflow', async () => {
|
||||
// Freeze the clock: the timing header hides zero-duration buckets, so a
|
||||
// real-clock millisecond tick between the fixture appends and the render
|
||||
// would flip `Tools 0.0s` in and out of the pinned header.
|
||||
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 21, 14, 40, 0).getTime())
|
||||
let replacementStart = 0
|
||||
let replacementEnd = 0
|
||||
let replacementSources: number[] = []
|
||||
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(COMPACTION_FIXTURE_TIME)
|
||||
// The awaited setup always invokes beforeMount, so the range the checkpoint
|
||||
// replaces is assigned by the time the appends below need it.
|
||||
let compacted!: CompactionRange
|
||||
const harness = await setupSnapshot({
|
||||
tools: ADVANCED_CARD_TOOLS,
|
||||
beforeMount(session) {
|
||||
const user = session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'Old prompt with a long line that exercises wrapping before compaction.' }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
const assistant = session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool-call', id: CallId('old-tool'), name: 'bash', arguments: '{}' }],
|
||||
source: {
|
||||
kind: 'model',
|
||||
...{ provider: 'mock', model: 'deepseek-v4-flash' },
|
||||
},
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: CallId('old-tool'), name: 'bash', arguments: '{}' })
|
||||
const result = session.append('tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: CallId('old-tool'),
|
||||
content: [{ type: 'text', text: 'obsolete output that must disappear' }],
|
||||
isError: false,
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
replacementStart = user.seq
|
||||
replacementEnd = result.seq
|
||||
replacementSources = [user.seq, assistant.seq, result.seq]
|
||||
},
|
||||
beforeMount(session) { compacted = appendPreCompactionLog(session) },
|
||||
}, { columns: 80, rows: 24 })
|
||||
await checkpoint('surface-before-compaction', harness.terminal, { includeScrollback: true })
|
||||
|
||||
await renderAfter(harness, () => {
|
||||
harness.session.append('user/message', createUserMessage({
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: '<system-reminder>\nAdditional instructions from: nested/AGENTS.md\n\nRender workspace context XML clearly.\n</system-reminder>',
|
||||
}],
|
||||
source: { kind: 'plugin', plugin: 'workspace-context' },
|
||||
}), {
|
||||
surfaceOp: { op: 'replace', start: replacementStart, end: replacementEnd },
|
||||
sourceEventSeqs: replacementSources,
|
||||
})
|
||||
appendCompactionCheckpoint(harness.session, compacted)
|
||||
harness.terminal.resize(44, 18)
|
||||
})
|
||||
await checkpoint('surface-after-compaction-narrow', harness.terminal, { includeScrollback: true })
|
||||
@@ -749,6 +773,23 @@ describe('TUI terminal-state snapshots', () => {
|
||||
nowSpy.mockRestore()
|
||||
})
|
||||
|
||||
// The resume path, which is what regressed for real users: the replacement is
|
||||
// already stored when the terminal mounts, so the transcript comes from replay
|
||||
// rather than from live appends. Pinned against the same log the live scenario
|
||||
// ends on, at its wide size, so the two fixtures are directly comparable.
|
||||
it('pins a stored compaction replayed at mount', async () => {
|
||||
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(COMPACTION_FIXTURE_TIME)
|
||||
const harness = await setupSnapshot({
|
||||
tools: ADVANCED_CARD_TOOLS,
|
||||
beforeMount(session) {
|
||||
appendCompactionCheckpoint(session, appendPreCompactionLog(session))
|
||||
},
|
||||
}, { columns: 104, rows: 30 })
|
||||
await checkpoint('surface-replayed-compaction', harness.terminal, { includeScrollback: true })
|
||||
await disposeSnapshot(harness)
|
||||
nowSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('pins wrapped and explicit multiline shell-prompt input', async () => {
|
||||
const harness = await setupSnapshot({}, { columns: 44, rows: 18 })
|
||||
await renderAfter(harness, () => {
|
||||
|
||||
@@ -19,6 +19,7 @@ import { createUserMessage,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import { GOAL_CHANGE_VERSION, GoalId, renderGoalChange, type GoalSnapshotChangeMeta } from '@deepseek-ai/dsh-goal'
|
||||
import CommandService, { type CommandInvocation } from '@deepseek-ai/dsh-commands'
|
||||
import { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact'
|
||||
import SessionStore, { SessionId, type JsonValue, type SessionEvent, type SessionHeader, type TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionRecord } from '@deepseek-ai/dsh-session-query'
|
||||
import SkillService, { type SkillCatalogSnapshot, type SkillDefinition, type SkillProvider, type SkillSummary } from '@deepseek-ai/dsh-skill'
|
||||
@@ -4661,10 +4662,10 @@ describe('tool cards and surface replay', () => {
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('rebuilds after a surface replacement and hides shadowed tool calls', async () => {
|
||||
it('keeps append-origin history and marks a landed compaction, live and on rebuild', async () => {
|
||||
const result = await setup({ tools })
|
||||
appendUser(result.session, 'old prompt')
|
||||
const assistant = result.session.append('assistant/message', {
|
||||
result.session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createMessage({
|
||||
@@ -4687,21 +4688,116 @@ describe('tool cards and surface replay', () => {
|
||||
isError: false,
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
const start = result.session.surface.nodes[0] as number
|
||||
result.session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'summary replacement' }],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
}), {
|
||||
surfaceOp: { op: 'replace', start, end: toolResult.seq },
|
||||
sourceEventSeqs: [start, assistant.seq, toolResult.seq],
|
||||
// Result pruning rewrites one node's content in place: model-only, and no
|
||||
// boundary in the conversation, so the terminal keeps the full output.
|
||||
const originalResult = toolResult.data.message.content[0]
|
||||
result.session.append('tool/result', {
|
||||
...toolResult.data,
|
||||
message: freezeMessage({
|
||||
...toolResult.data.message,
|
||||
content: [{ ...originalResult, content: [{ type: 'text', text: 'pruned result copy' }] }] as [typeof originalResult],
|
||||
}),
|
||||
}, {
|
||||
surfaceOp: { op: 'replace', start: toolResult.seq, end: toolResult.seq },
|
||||
sourceEventSeqs: [toolResult.seq],
|
||||
})
|
||||
const nodes = [...result.session.surface.nodes]
|
||||
const checkpoint = result.session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: '<context_checkpoint>model-only summary payload</context_checkpoint>' }],
|
||||
source: COMPACT_CHECKPOINT_SOURCE,
|
||||
}), {
|
||||
surfaceOp: { op: 'replace', start: nodes[0] as number, end: nodes.at(-1) as number },
|
||||
sourceEventSeqs: nodes,
|
||||
})
|
||||
// A regenerated assistant message replaces one node without summarizing
|
||||
// anything, so it marks no boundary either.
|
||||
const generic = result.session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'generic replacement copy' }],
|
||||
source: {
|
||||
kind: 'model',
|
||||
...{ provider: 'mock', model: 'deepseek-v4-flash' },
|
||||
},
|
||||
}),
|
||||
}, { surfaceOp: { op: 'replace', start: checkpoint.seq, end: checkpoint.seq }, sourceEventSeqs: [checkpoint.seq] })
|
||||
// Only a checkpoint carrying the compaction seam's source marks a boundary:
|
||||
// another plugin replacing a node is model-only.
|
||||
result.session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'foreign plugin replacement copy' }],
|
||||
source: { kind: 'plugin', plugin: 'other' },
|
||||
}), { surfaceOp: { op: 'replace', start: generic.seq, end: generic.seq }, sourceEventSeqs: [generic.seq] })
|
||||
await tick()
|
||||
|
||||
result.terminal.resize(89)
|
||||
await tick()
|
||||
const lastFullRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J'))
|
||||
expect(lastFullRender).toContain('summary replacement')
|
||||
expect(lastFullRender).not.toContain('old output')
|
||||
const liveRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J'))
|
||||
expect(liveRender).toContain('old prompt')
|
||||
// The shadowed step keeps its card: one call row, one full result, no
|
||||
// second card from the pruned copy.
|
||||
expect(liveRender.split('$ printf hello')).toHaveLength(2)
|
||||
expect(liveRender).toContain('third')
|
||||
expect(liveRender.split('[exit 0]')).toHaveLength(2)
|
||||
expect(liveRender.split('… earlier context was compacted …')).toHaveLength(2)
|
||||
expect(liveRender).not.toContain('model-only summary payload')
|
||||
expect(liveRender).not.toContain('generic replacement copy')
|
||||
expect(liveRender).not.toContain('foreign plugin replacement copy')
|
||||
|
||||
// Ctrl+R toggles reasoning, which rebuilds the transcript from the log; the
|
||||
// replayed projection matches what the live appends produced, including the
|
||||
// shadowed assistant message's tool card.
|
||||
result.terminal.send('\x12')
|
||||
await tick()
|
||||
result.terminal.resize(90)
|
||||
await tick()
|
||||
const replayRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J'))
|
||||
expect(replayRender).toContain('old prompt')
|
||||
expect(replayRender.split('$ printf hello')).toHaveLength(2)
|
||||
expect(replayRender).toContain('third')
|
||||
expect(replayRender.split('[exit 0]')).toHaveLength(2)
|
||||
expect(replayRender.split('… earlier context was compacted …')).toHaveLength(2)
|
||||
expect(replayRender).not.toContain('model-only summary payload')
|
||||
expect(replayRender).not.toContain('generic replacement copy')
|
||||
expect(replayRender).not.toContain('foreign plugin replacement copy')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('replays a stored compaction as preserved history plus its marker', async () => {
|
||||
const result = await setup({
|
||||
beforeMount(session) {
|
||||
appendUser(session, 'prompt before compaction')
|
||||
session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'reply before compaction' }],
|
||||
source: {
|
||||
kind: 'model',
|
||||
...{ provider: 'mock', model: 'deepseek-v4-flash' },
|
||||
},
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
const nodes = [...session.surface.nodes]
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: '<context_checkpoint>stored model-only payload</context_checkpoint>' }],
|
||||
source: COMPACT_CHECKPOINT_SOURCE,
|
||||
}), {
|
||||
surfaceOp: { op: 'replace', start: nodes[0] as number, end: nodes.at(-1) as number },
|
||||
sourceEventSeqs: nodes,
|
||||
})
|
||||
},
|
||||
})
|
||||
result.terminal.resize(89)
|
||||
await tick()
|
||||
|
||||
const mounted = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J'))
|
||||
expect(mounted).toContain('prompt before compaction')
|
||||
expect(mounted).toContain('reply before compaction')
|
||||
expect(mounted.split('… earlier context was compacted …')).toHaveLength(2)
|
||||
expect(mounted).not.toContain('stored model-only payload')
|
||||
await dispose(result)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -53,6 +53,9 @@
|
||||
{
|
||||
"path": "../commands"
|
||||
},
|
||||
{
|
||||
"path": "../../compact/compact"
|
||||
},
|
||||
{
|
||||
"path": "../../skill/skill"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user