@linxin666/dsh-client-ui-task-board 0.3.5 → 0.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +9 -4
  3. package/README.zh.md +9 -4
  4. package/lib/client.js +947 -354
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +712 -180
  7. package/lib/types/client/TaskBoardSettingsCard.d.ts +2 -1
  8. package/lib/types/client/board/TaskBoard.d.ts +3 -0
  9. package/lib/types/client/board/TaskForm.d.ts +28 -0
  10. package/lib/types/client/host-api.d.ts +2 -2
  11. package/lib/types/client/index.d.ts +10 -2
  12. package/lib/types/client/locales.d.ts +19 -0
  13. package/lib/types/client/settings-form.d.ts +35 -43
  14. package/lib/types/core/controller.d.ts +11 -2
  15. package/lib/types/core/freeze-snapshot.d.ts +71 -0
  16. package/lib/types/core/handover.d.ts +58 -0
  17. package/lib/types/core/tasks.d.ts +70 -1
  18. package/lib/types/core/use-cases/task-update.d.ts +16 -7
  19. package/lib/types/host-ledger.d.ts +17 -2
  20. package/lib/types/host-runner.d.ts +44 -17
  21. package/lib/types/host-service.d.ts +8 -5
  22. package/lib/types/index.d.ts +9 -1
  23. package/lib/types/protocol.d.ts +15 -2
  24. package/package.json +26 -17
  25. package/src/client/TaskBoardSettingsCard.tsx +2 -1
  26. package/src/client/board/EditTaskModal.tsx +19 -55
  27. package/src/client/board/NewTaskModal.tsx +59 -42
  28. package/src/client/board/TaskBoard.tsx +5 -3
  29. package/src/client/board/TaskCard.tsx +3 -0
  30. package/src/client/board/TaskDetail.tsx +54 -0
  31. package/src/client/board/TaskForm.tsx +111 -0
  32. package/src/client/board-mount.tsx +7 -1
  33. package/src/client/host-api.ts +5 -5
  34. package/src/client/index.ts +67 -7
  35. package/src/client/locales.ts +40 -2
  36. package/src/client/settings-form.ts +78 -92
  37. package/src/core/controller.ts +36 -16
  38. package/src/core/freeze-snapshot.ts +213 -0
  39. package/src/core/handover.ts +117 -0
  40. package/src/core/store.ts +48 -1
  41. package/src/core/tasks.ts +87 -0
  42. package/src/core/use-cases/task-update.ts +30 -10
  43. package/src/dsh-home.ts +1 -1
  44. package/src/host-ledger.ts +137 -59
  45. package/src/host-routes.ts +1 -1
  46. package/src/host-runner.ts +223 -83
  47. package/src/host-service.ts +14 -10
  48. package/src/index.ts +24 -10
  49. package/src/protocol.ts +98 -14
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Shared task-modal pieces: the overlay shell (backdrop, form, title, error,
3
+ * footer) and the title/description/prompt field trio used by both the
4
+ * NewTaskModal and the EditTaskModal. State stays in the owning modal; these
5
+ * are controlled components.
6
+ */
7
+ import type { ReactNode } from 'react'
8
+ import { t } from '../locales.ts'
9
+ import css from '../board.module.css'
10
+
11
+ /** Modal overlay: closes on backdrop press, submits through the form. */
12
+ export function ModalShell({
13
+ ariaLabel,
14
+ title,
15
+ error,
16
+ pending,
17
+ submitLabel,
18
+ onSubmit,
19
+ onClose,
20
+ children,
21
+ }: {
22
+ ariaLabel: string
23
+ title: string
24
+ error: string | undefined
25
+ pending: boolean
26
+ submitLabel: string
27
+ onSubmit: () => void
28
+ onClose: () => void
29
+ children: ReactNode
30
+ }) {
31
+ return (
32
+ <div className={css.modalBackdrop} onMouseDown={event => { if (event.target === event.currentTarget) onClose() }}>
33
+ <form
34
+ className={css.modal}
35
+ role="dialog"
36
+ aria-label={ariaLabel}
37
+ onSubmit={event => { event.preventDefault(); onSubmit() }}
38
+ >
39
+ <h2 className={css.modalTitle}>{title}</h2>
40
+
41
+ {children}
42
+
43
+ {error !== undefined && <p className={css.formError}>{error}</p>}
44
+
45
+ <footer className={css.modalFooter}>
46
+ <button type="button" className={css.ghostButton} onClick={onClose}>
47
+ {t('new.cancel')}
48
+ </button>
49
+ <button type="submit" className={css.primaryButton} disabled={pending}>
50
+ {submitLabel}
51
+ </button>
52
+ </footer>
53
+ </form>
54
+ </div>
55
+ )
56
+ }
57
+
58
+ /** Title + description + prompt fields shared by the new and edit task forms. */
59
+ export function TaskContentFields({
60
+ title,
61
+ description,
62
+ prompt,
63
+ onTitleChange,
64
+ onDescriptionChange,
65
+ onPromptChange,
66
+ }: {
67
+ title: string
68
+ description: string
69
+ prompt: string
70
+ /** Receives the new title; the owner also clears its error state. */
71
+ onTitleChange: (value: string) => void
72
+ onDescriptionChange: (value: string) => void
73
+ onPromptChange: (value: string) => void
74
+ }) {
75
+ return (
76
+ <>
77
+ <label className={css.field}>
78
+ <span className={css.fieldLabel}>{t('new.title')}</span>
79
+ <input
80
+ className={css.input}
81
+ value={title}
82
+ autoFocus
83
+ placeholder={t('new.titlePlaceholder')}
84
+ onChange={event => onTitleChange(event.target.value)}
85
+ />
86
+ </label>
87
+
88
+ <label className={css.field}>
89
+ <span className={css.fieldLabel}>{t('new.description')}</span>
90
+ <textarea
91
+ className={css.input}
92
+ rows={3}
93
+ value={description}
94
+ placeholder={t('new.descriptionPlaceholder')}
95
+ onChange={event => onDescriptionChange(event.target.value)}
96
+ />
97
+ </label>
98
+
99
+ <label className={css.field}>
100
+ <span className={css.fieldLabel}>{t('new.prompt')}</span>
101
+ <textarea
102
+ className={css.input}
103
+ rows={4}
104
+ value={prompt}
105
+ placeholder={t('new.promptPlaceholder')}
106
+ onChange={event => onPromptChange(event.target.value)}
107
+ />
108
+ </label>
109
+ </>
110
+ )
111
+ }
@@ -44,7 +44,13 @@ export function mountBoard(controller: BoardController): () => void {
44
44
  let container: HTMLDivElement | undefined
45
45
 
46
46
  const ensure = (): void => {
47
- if (container !== undefined) return
47
+ if (container !== undefined) {
48
+ if (container.isConnected) return
49
+ root?.unmount()
50
+ root = undefined
51
+ container.remove()
52
+ container = undefined
53
+ }
48
54
  const column = conversationColumn()
49
55
  if (column === undefined) return
50
56
  container = document.createElement('div')
@@ -25,7 +25,7 @@ async function readJson<T>(response: Response): Promise<T> {
25
25
  export interface TaskBoardHostTransport {
26
26
  bootstrap(legacy: readonly TaskRecord[]): Promise<TaskBoardSnapshot>
27
27
  state(): Promise<TaskBoardSnapshot>
28
- action(action: TaskBoardAction): Promise<TaskBoardSnapshot>
28
+ action(action: TaskBoardAction, initiator?: string): Promise<TaskBoardSnapshot>
29
29
  subscribe(listener: (event?: TaskBoardEventPayload) => void): () => void
30
30
  }
31
31
 
@@ -57,12 +57,12 @@ export class HttpTaskBoardHostTransport implements TaskBoardHostTransport {
57
57
  return await this.request(`${TASK_BOARD_API_PREFIX}/state`, { cache: 'no-store' })
58
58
  }
59
59
 
60
- async action(action: TaskBoardAction): Promise<TaskBoardSnapshot> {
61
- return await this.post(uuid(), action)
60
+ async action(action: TaskBoardAction, initiator?: string): Promise<TaskBoardSnapshot> {
61
+ return await this.post(uuid(), action, initiator)
62
62
  }
63
63
 
64
- private async post(requestId: string, action: TaskBoardAction): Promise<TaskBoardSnapshot> {
65
- const envelope: TaskBoardActionEnvelope = { requestId, action }
64
+ private async post(requestId: string, action: TaskBoardAction, initiator?: string): Promise<TaskBoardSnapshot> {
65
+ const envelope: TaskBoardActionEnvelope = { requestId, action, ...(initiator === undefined || initiator === '' ? {} : { initiator }) }
66
66
  return await this.request(`${TASK_BOARD_API_PREFIX}/action`, {
67
67
  method: 'POST',
68
68
  headers: { 'content-type': 'application/json' },
@@ -8,8 +8,13 @@
8
8
  * shell fails the whole boot when a plugin apply throws, and an external
9
9
  * plugin must not take the GUI down.
10
10
  */
11
- import type { ClientContext, ISessions, IWorkspaces, SessionId, SettingsScope, SettingsScopeSpec } from '@deepseek-ai/dsh-client-runtime/client'
12
- import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
11
+ import type { Context as ClientContext } from '@deepseek-ai/cordis'
12
+ import type { ClientRemote, SessionId } from '@deepseek-ai/dsh-api-remotes/client'
13
+ import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client'
14
+ import type { IWorkspaces } from '@deepseek-ai/dsh-api-workspace-controller/client'
15
+ import type { SettingsScope, SettingsScopeSpec } from '@deepseek-ai/dsh-client-ui-settings/client'
16
+ import type {} from '@deepseek-ai/dsh-api-remotes/client'
17
+ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
13
18
  import type {} from '@deepseek-ai/dsh-client-ui-slots'
14
19
  // Type-only: pulls the locale plugin's Context merge (ctx.locale) and its
15
20
  // LocaleNamespaceMap merge table.
@@ -67,9 +72,64 @@ declare module '@deepseek-ai/cordis' {
67
72
  }
68
73
 
69
74
 
70
- /** Required services (fiber inject waiting — the runtime must be up first). */
75
+ /**
76
+ * Required services (fiber inject waiting — the runtime must be up first).
77
+ * The generated remote faces are probed at use time instead of injected:
78
+ * `remote.agentPresets` only registers on 0.1.2-alpha.2 hosts (the
79
+ * api-remotes contribution), so a hard wait would pend the entry forever
80
+ * on hosts below that cohort, which serve the same roster through the
81
+ * connection RPC face.
82
+ */
71
83
  export const inject = ['slots', 'sessions', 'workspaces', 'connection', 'settingsScope', 'locale', 'remote']
72
84
 
85
+ /** One agent-preset row the mode picker consumes (either face's wire shape). */
86
+ interface PresetRosterRow {
87
+ id: string
88
+ name?: string
89
+ description?: string
90
+ /** Why this preset cannot compose a session; absent when it can. */
91
+ broken?: string
92
+ isDefault: boolean
93
+ }
94
+
95
+ /**
96
+ * Read the agent-preset roster through whichever face the running host
97
+ * serves: the generated api-remotes face (`remote.agentPresets`,
98
+ * 0.1.2-alpha.2) or the connection RPC face
99
+ * (`connection.api.agentPresets`, hosts below that cohort). Answers
100
+ * undefined when the host serves neither, so the caller leaves the picker
101
+ * options untouched instead of erroring.
102
+ */
103
+ async function readPresetRoster(
104
+ ctx: ClientContext,
105
+ remote: ClientRemote,
106
+ ): Promise<{ ok: boolean; presets: readonly PresetRosterRow[] } | undefined> {
107
+ // The cordis `remote` proxy throws on a property that was never injected
108
+ // ("cannot get property X without inject") rather than returning undefined,
109
+ // so the probe must guard the access — a hard read would abort mounting on
110
+ // hosts below the 0.1.2-alpha.2 cohort instead of degrading to the legacy
111
+ // connection RPC face below.
112
+ let remotes: ClientRemote['agentPresets'] | undefined
113
+ try {
114
+ remotes = (remote as Partial<ClientRemote>).agentPresets
115
+ } catch {
116
+ remotes = undefined
117
+ }
118
+ if (remotes !== undefined) {
119
+ const response = await remotes.list()
120
+ if (!response.ok) return { ok: false, presets: [] }
121
+ return { ok: true, presets: response.value.presets }
122
+ }
123
+ const connection = ctx.get('connection') as unknown as {
124
+ api?: { agentPresets?: { list(request: Record<string, never>): Promise<{ result: { ok: boolean; value?: { presets?: readonly PresetRosterRow[] } } }> } }
125
+ }
126
+ const legacy = connection.api?.agentPresets
127
+ if (legacy === undefined) return undefined
128
+ const response = await legacy.list({})
129
+ if (!response.result.ok || response.result.value === undefined) return { ok: false, presets: [] }
130
+ return { ok: true, presets: response.result.value.presets ?? [] }
131
+ }
132
+
73
133
  /**
74
134
  * Mount the task board.
75
135
  * @param ctx - client root context (services: sessions, workspaces).
@@ -132,7 +192,7 @@ export function apply(ctx: ClientContext): void {
132
192
  // narrow these two client services during a combined package build.
133
193
  const sessions = ctx.get('sessions') as unknown as ISessions
134
194
  const workspaces = ctx.get('workspaces') as unknown as IWorkspaces
135
- const connection = ctx.get('connection') as ConnectionHandle
195
+ const remote = ctx.get('remote') as unknown as ClientRemote
136
196
 
137
197
  // Core wiring: real runtime faces into the framework-free services.
138
198
  const store = new LocalStorageTaskStore()
@@ -166,10 +226,10 @@ export function apply(ctx: ClientContext): void {
166
226
  disposers.push(workspaces.list.subscribe(pushWorkspaceOptions))
167
227
  const pushPresetOptions = async (): Promise<void> => {
168
228
  try {
169
- const response = await connection.api.agentPresets.list({})
170
- if (!response.result.ok) return
229
+ const roster = await readPresetRoster(ctx, remote)
230
+ if (roster === undefined || !roster.ok) return
171
231
  controller.setExecutionOptions({
172
- presets: response.result.value.presets.map(preset => ({
232
+ presets: roster.presets.map(preset => ({
173
233
  id: preset.id,
174
234
  name: preset.name,
175
235
  description: preset.description,
@@ -40,6 +40,24 @@ export const zh = {
40
40
  'new.submit': '创建',
41
41
  'new.cancel': '取消',
42
42
  'new.required': '标题不能为空',
43
+ 'new.freeze': '冻结快照(可选)',
44
+ 'new.freezePlaceholder': '粘贴会话输出的 <<<FREEZE … >>>FREEZE 冻结块,解析为目标/进度/下一步(可选)',
45
+ 'detail.freeze': '冻结快照',
46
+ 'detail.freeze.goal': '目标',
47
+ 'detail.freeze.progress': '进度',
48
+ 'detail.freeze.next': '下一步',
49
+ 'detail.freeze.frozenAt': '冻结于 {time}',
50
+ 'detail.freeze.frozenBy': '来源会话 {session}',
51
+ 'detail.freeze.redacted': '冻结文本命中敏感模式,已自动替换为 [REDACTED];接手前请人工复核。',
52
+ 'card.frozen': '冻结',
53
+ 'new.handover': '交接包引用(可选)',
54
+ 'new.handoverPlaceholder': '每行一个文档/脚本引用;填写后,上面选择的钉住三元组(工作区/模式/权限)会作为交接包附加到卡片',
55
+ 'detail.handover': '交接包',
56
+ 'detail.handover.bundledAt': '打包于 {time}',
57
+ 'detail.handover.references': '引用',
58
+ 'detail.permissionPending': '权限待确认:本卡有效权限({permission})高于会话默认,手动执行与定时调度都会被拒绝,需人工确认后才能运行。',
59
+ 'detail.permissionConfirm': '确认权限绑定',
60
+ 'detail.permissionConfirmed': '已确认权限绑定 · {time}',
43
61
  'detail.title': '任务详情',
44
62
  'detail.edit': '编辑',
45
63
  'detail.close': '关闭',
@@ -58,6 +76,7 @@ export const zh = {
58
76
  'detail.viewSession': '查看会话',
59
77
  'detail.noSession': '暂无会话',
60
78
  'detail.executionStarted': '已启动',
79
+ 'detail.execution.initiator': '发起会话 {session}',
61
80
  'detail.executionEnded': '已结束',
62
81
  'detail.result.succeeded': '成功',
63
82
  'detail.result.failed': '失败',
@@ -119,7 +138,7 @@ export const zh = {
119
138
  'settings.off': '关',
120
139
  'settings.overridden': '已覆盖',
121
140
  'settings.reset': '恢复默认',
122
- 'settings.notExposed': '当前 DSH 版本未向设置页暴露本插件的配置命名空间,表单不可用。可编辑 ~/.dsh/settings.yaml 直接配置,或为 dsh-host-apiproxy 的 WEB_SETTINGS_NAMESPACES 白名单补充本命名空间后重启。',
141
+ 'settings.notExposed': '当前 DSH 版本未向设置页暴露本插件的配置命名空间,表单不可用。可编辑 ~/.dsh/settings.yaml 直接配置,或将本命名空间加入 Host 设置白名单后重启。',
123
142
  'settings.readOnly': '当前部署的设置只读。',
124
143
  'settings.expand': '展开设置',
125
144
  'settings.collapse': '收起设置',
@@ -166,6 +185,24 @@ export const en: Record<keyof typeof zh, string> = {
166
185
  'new.submit': 'Create',
167
186
  'new.cancel': 'Cancel',
168
187
  'new.required': 'Title is required',
188
+ 'new.freeze': 'Frozen snapshot (optional)',
189
+ 'new.freezePlaceholder': 'Paste a <<<FREEZE ... >>>FREEZE block from a session; it parses into goal/progress/next (optional)',
190
+ 'detail.freeze': 'Frozen snapshot',
191
+ 'detail.freeze.goal': 'Goal',
192
+ 'detail.freeze.progress': 'Progress',
193
+ 'detail.freeze.next': 'Next',
194
+ 'detail.freeze.frozenAt': 'Frozen at {time}',
195
+ 'detail.freeze.frozenBy': 'Source session {session}',
196
+ 'detail.freeze.redacted': 'Sensitive patterns were detected in the frozen text and replaced with [REDACTED]; review manually before resuming.',
197
+ 'card.frozen': 'frozen',
198
+ 'new.handover': 'Handover references (optional)',
199
+ 'new.handoverPlaceholder': 'One doc/script reference per line; when filled, the pinned triplet picked above (workspace/mode/permission) is attached to the card as a handover bundle',
200
+ 'detail.handover': 'Handover bundle',
201
+ 'detail.handover.bundledAt': 'Bundled at {time}',
202
+ 'detail.handover.references': 'References',
203
+ 'detail.permissionPending': 'Permission pending confirmation: this card\'s effective permission ({permission}) is above the session default; manual runs and cron are refused until a human confirms.',
204
+ 'detail.permissionConfirm': 'Confirm permission binding',
205
+ 'detail.permissionConfirmed': 'Permission confirmed · {time}',
169
206
  'detail.title': 'Task Detail',
170
207
  'detail.edit': 'Edit',
171
208
  'detail.close': 'Close',
@@ -184,6 +221,7 @@ export const en: Record<keyof typeof zh, string> = {
184
221
  'detail.viewSession': 'View Session',
185
222
  'detail.noSession': 'No session',
186
223
  'detail.executionStarted': 'Started',
224
+ 'detail.execution.initiator': 'Initiated by session {session}',
187
225
  'detail.executionEnded': 'Ended',
188
226
  'detail.result.succeeded': 'Succeeded',
189
227
  'detail.result.failed': 'Failed',
@@ -245,7 +283,7 @@ export const en: Record<keyof typeof zh, string> = {
245
283
  'settings.off': 'Off',
246
284
  'settings.overridden': 'Overridden',
247
285
  'settings.reset': 'Reset to default',
248
- 'settings.notExposed': 'This DSH version does not expose this plugin\'s settings namespace to the configuration page, so the form is unavailable. Edit ~/.dsh/settings.yaml directly, or add the namespace to dsh-host-apiproxy\'s WEB_SETTINGS_NAMESPACES allowlist and restart.',
286
+ 'settings.notExposed': 'This DSH version does not expose this plugin\'s settings namespace to the configuration page, so the form is unavailable. Edit ~/.dsh/settings.yaml directly, or add the namespace to the Host settings allowlist and restart.',
249
287
  'settings.readOnly': 'This deployment stores settings read-only.',
250
288
  'settings.expand': 'Show settings',
251
289
  'settings.collapse': 'Hide settings',
@@ -8,8 +8,9 @@
8
8
  * card-store pattern.
9
9
  */
10
10
 
11
- import type { SettingsScope, SettingsScopeSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
12
- import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
11
+ import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client'
12
+ import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
13
+ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
13
14
 
14
15
  /** The write one field's staged text performs when the card is saved. */
15
16
  export type FieldWrite =
@@ -23,9 +24,11 @@ export interface FieldSpec {
23
24
  /**
24
25
  * Whether the Host treats this field as a secret and redacts its value from
25
26
  * the read-back (role('secret') in the section schema). Redacted secrets are
26
- * never compared against the draft on save; the field lands when the scope
27
- * reports the write succeeded (its secret-set marker under the bridge), so
28
- * a successful secret save is not misreported as failed.
27
+ * never compared against the draft on save: the Host strips them from every
28
+ * wire view layer, so the settled snapshot carries nothing to read back. A
29
+ * staged secret set is judged by the mutation settling; the rest of its
30
+ * batch, when one exists, is still judged by read-back, and the atomic
31
+ * mutation lands every write or none.
29
32
  */
30
33
  secret?: boolean
31
34
  /** Render a stored value as draft text; the empty string when the section carries none. */
@@ -53,8 +56,8 @@ export interface CardShell {
53
56
  available: boolean
54
57
  /**
55
58
  * Whether the namespace is actually served to this client. False when the
56
- * Host deployment does not expose it (e.g. the official apiproxy settings
57
- * allowlist omits third-party namespaces): the card renders an explanation
59
+ * Host deployment does not expose it (e.g. the owning plugin's settings
60
+ * domain is not mounted): the card renders an explanation
58
61
  * instead of its form, so a missing namespace never looks like a missing
59
62
  * plugin.
60
63
  */
@@ -101,13 +104,17 @@ interface StagedEdit {
101
104
  interface PlannedWrite {
102
105
  /** Field this entry writes. */
103
106
  field: string
104
- /** The durable write this entry performs, described for a batched scope. */
107
+ /** The durable write this entry performs, inside the save's one atomic mutation. */
105
108
  op: BatchedWrite
106
- /** Perform the write and report whether the Host holds the staged value afterwards. */
107
- run: (() => Promise<boolean>) | undefined
109
+ /**
110
+ * Read the settled snapshot back and report whether the Host holds this
111
+ * write's effect. Undefined when the draft is not a value the field
112
+ * accepts: there is nothing to write, and the entry blocks the save.
113
+ */
114
+ judge: (() => boolean) | undefined
108
115
  }
109
116
 
110
- /** One durable write a batched settings scope performs. */
117
+ /** One durable write inside the save's atomic scope mutation. */
111
118
  export interface BatchedWrite {
112
119
  /** Field this entry writes. */
113
120
  field: string
@@ -117,36 +124,6 @@ export interface BatchedWrite {
117
124
  value?: unknown
118
125
  }
119
126
 
120
- /** Per-field outcome of one batched scope write. */
121
- export interface BatchedFieldResult {
122
- /** Field this entry writes. */
123
- field: string
124
- /** Whether the Host accepted this field's write (per the read-back view). */
125
- landed: boolean
126
- }
127
-
128
- /**
129
- * Result of a batched scope write. The bridge scope posts every planned write
130
- * in one /mutate so the Host validate hook judges baseURL+model together; a
131
- * batched refusal fails the whole save rather than per-field.
132
- */
133
- export interface BatchResult {
134
- /** Whether the whole mutate was accepted. */
135
- ok: boolean
136
- /** Per-field success, in the request order (always present when ok). */
137
- fields: BatchedFieldResult[]
138
- /** Host rejection code (mutate refused). */
139
- code?: string
140
- /** Host rejection message (mutate refused). */
141
- message?: string
142
- }
143
-
144
- /** The optional batch surface the bridge scope adds over the SettingsScope contract. */
145
- interface BatchedSettingsScope {
146
- /** Write every operation in one scope mutation, reporting per-field success. */
147
- mutate: (writes: BatchedWrite[]) => Promise<BatchResult>
148
- }
149
-
150
127
  /** Constraints a numeric field's accepted drafts must satisfy, mirroring the host schema. */
151
128
  export interface NumberConstraints {
152
129
  /** The accepted value must be a whole number. */
@@ -188,8 +165,8 @@ export function textField(field: string): FieldSpec {
188
165
  /**
189
166
  * A free-text field the Host treats as a secret and redacts from the read-back
190
167
  * (role('secret') in the section schema). The card still edits it like text,
191
- * but a save never compares the redacted value back and relies on the scope
192
- * reporting the write landed.
168
+ * but a save never compares the redacted value back: the staged set is judged
169
+ * by the mutation settling (see {@link FieldSpec.secret}).
193
170
  */
194
171
  export function secretField(field: string): FieldSpec {
195
172
  return { ...textField(field), secret: true }
@@ -277,7 +254,7 @@ export class CardForm<T> {
277
254
  exposed: snapshot.status === 'ready',
278
255
  writable: snapshot.writable,
279
256
  dirty: plan.length > 0,
280
- invalid: plan.some(item => item.run === undefined),
257
+ invalid: plan.some(item => item.judge === undefined),
281
258
  saving: this.saving,
282
259
  failed: this.failed,
283
260
  ...this.failedReason === undefined ? {} : { failedReason: this.failedReason },
@@ -318,21 +295,25 @@ export class CardForm<T> {
318
295
  }
319
296
 
320
297
  /**
321
- * Write every staged edit, then re-seed from what the Host accepted.
298
+ * Write every staged edit in one atomic scope mutation, then re-seed from
299
+ * what the Host accepted.
322
300
  *
323
- * When the scope carries the optional batch surface (the dsh-web
324
- * bridge scope), every planned write rides one mutation so cross-field
325
- * validate hooks (baseURL+model) judge the batch as a unit instead of
326
- * deadlocking on per-field writes. Otherwise the per-field loop runs.
327
- * A field lands only when the Host reports it held the staged value; a
328
- * landed field's draft is dropped, a failed one stays staged for the user.
329
- * @returns settlement after every write and the read-back.
301
+ * The whole batch rides one mutate, so cross-field validate hooks
302
+ * (baseURL+model) judge it as a unit: the Host either applies every write
303
+ * or refuses the batch. The 0.1.2 scope contract never rejects a refused
304
+ * mutation — the scope recovers with a fresh Host view and resolves — so
305
+ * resolution alone proves nothing: the outcome is judged by reading the
306
+ * settled snapshot back, one planned write at a time, and one missed write
307
+ * fails the whole save. A scope that still rejects on refusal (the dsh-web
308
+ * bridge scope) reports through the same failure path with its rejection
309
+ * message. A save that did not land keeps its drafts, so the user can
310
+ * correct them instead of retyping.
311
+ * @returns settlement after the mutation and the read-back.
330
312
  */
331
313
  async save(): Promise<void> {
332
314
  const plan = this.plan()
333
- const valid = plan.filter(item => item.run !== undefined)
315
+ const valid = plan.filter((item): item is PlannedWrite & { judge: () => boolean } => item.judge !== undefined)
334
316
  if (plan.length === 0 || this.saving || valid.length !== plan.length) return
335
- const plannedWrites = valid.map(item => item.op)
336
317
  // Snapshot the staged entries this save writes, so an edit staged while it
337
318
  // is in flight (which replaces the same key) survives: only delete the key
338
319
  // when the entry is still the one this save started from.
@@ -342,36 +323,34 @@ export class CardForm<T> {
342
323
  this.failed = false
343
324
  this.failedReason = undefined
344
325
  this.publish()
345
- const landed = new Set<string>()
346
- const batch = this.batchedScope()
347
- if (batch !== undefined) {
348
- const result = await batch.mutate(plannedWrites)
349
- if (result.ok) {
350
- for (const field of result.fields) {
351
- if (field.landed) landed.add(field.field)
352
- }
353
- } else {
354
- this.failedReason = result.message
355
- }
356
- } else {
357
- for (const item of valid) {
358
- if (await item.run!()) landed.add(item.field)
359
- }
326
+ // One atomic namespace mutation: the 0.1.2 scope contract takes ordered
327
+ // path operations, so the whole staged batch is validated, persisted, and
328
+ // recovered together — either every write lands or none does.
329
+ const ops: Array<{ op: 'set'; path: string[]; value: string | number | boolean } | { op: 'unset'; path: string[] }> = valid.map(item => item.op.op === 'set'
330
+ ? { op: 'set', path: [item.field], value: (item.op as { value: string | number | boolean }).value }
331
+ : { op: 'unset', path: [item.field] })
332
+ let failedReason: string | undefined
333
+ try {
334
+ await this.scope.mutate(ops)
335
+ } catch (error) {
336
+ failedReason = error instanceof Error ? error.message : String(error)
360
337
  }
338
+ // The 0.1.2 scope resolves even a refused mutation (it recovers with a
339
+ // fresh view instead of throwing), so resolution alone proves nothing:
340
+ // judge every planned write against the settled snapshot. The mutation is
341
+ // atomic, so one missed write fails the whole save and keeps the drafts.
342
+ const landed = failedReason === undefined && valid.every(item => item.judge())
361
343
  for (const [field, before] of pending) {
362
- if (landed.has(field) && this.staged.get(field) === before) this.staged.delete(field)
344
+ if (landed && this.staged.get(field) === before) this.staged.delete(field)
363
345
  }
364
346
  this.saving = false
365
- this.failed = landed.size !== pending.size
347
+ this.failed = !landed
348
+ // A read-back failure carries no server reason: the card surfaces its
349
+ // generic failure copy; a rejecting scope (the bridge) adds its message.
350
+ this.failedReason = failedReason
366
351
  this.publish()
367
352
  }
368
353
 
369
- /** The scope's batch surface when it supports one; undefined conservatively otherwise. */
370
- private batchedScope(): BatchedSettingsScope | undefined {
371
- const candidate = this.scope as unknown as BatchedSettingsScope | undefined
372
- return typeof candidate?.mutate === 'function' ? candidate : undefined
373
- }
374
-
375
354
  /**
376
355
  * Every staged edit a save would write. An entry whose draft is not a value
377
356
  * its field accepts carries no write: the form is still dirty, and the save
@@ -384,33 +363,40 @@ export class CardForm<T> {
384
363
  for (const [field, staged] of this.staged) {
385
364
  const spec = this.specOf(field)
386
365
  if (staged.clear) {
387
- if (this.stored(field)) plan.push({ field, op: { field, op: 'unset' }, run: () => this.clear(field) })
366
+ if (this.stored(field)) plan.push({ field, op: { field, op: 'unset' }, judge: () => this.landedUnset(field) })
388
367
  continue
389
368
  }
390
369
  if (staged.text === spec.format(this.sectionValue(field))) continue
391
370
  const write = spec.parse(staged.text)
392
- if (write === undefined) plan.push({ field, op: { field, op: 'unset' }, run: undefined })
393
- else if (write.kind === 'clear') plan.push({ field, op: { field, op: 'unset' }, run: () => this.clear(field) })
394
- else plan.push({ field, op: { field, op: 'set', value: write.value }, run: () => this.store(field, write.value) })
371
+ if (write === undefined) plan.push({ field, op: { field, op: 'unset' }, judge: undefined })
372
+ else if (write.kind === 'clear') plan.push({ field, op: { field, op: 'unset' }, judge: () => this.landedUnset(field) })
373
+ else plan.push({ field, op: { field, op: 'set', value: write.value }, judge: () => this.landedSet(field, write.value) })
395
374
  }
396
375
  return plan
397
376
  }
398
377
 
399
- private async clear(field: string): Promise<boolean> {
400
- await this.scope.unset(field)
401
- return !this.stored(field)
402
- }
403
-
404
- private async store(field: string, value: unknown): Promise<boolean> {
405
- await this.scope.set(field, value)
406
- // A redacted secret never appears in the user layer read-back; judging it
407
- // by value would misreport a successful secret save as failed. The bridge
408
- // reports secret writes through its secret-set markers (batch path); on
409
- // the per-field path the scope resolved, so the write is landed.
378
+ /**
379
+ * Read-back judgment for a planned set: the user layer must hold the
380
+ * intended value once the mutation has settled.
381
+ */
382
+ private landedSet(field: string, value: unknown): boolean {
383
+ // A redacted secret never appears in any wire view layer: the Host strips
384
+ // role('secret') fields and reports them through a sidecar the scope
385
+ // snapshot does not expose, so there is nothing to compare the draft
386
+ // against. Settling is the only signal the form has; the rest of the
387
+ // batch, when one exists, still carries the atomic verdict by read-back.
410
388
  if (this.specOf(field).secret) return true
411
389
  return this.userLayer()?.[field] === value
412
390
  }
413
391
 
392
+ /**
393
+ * Read-back judgment for a planned unset: the field must be gone from the
394
+ * user layer once the mutation has settled.
395
+ */
396
+ private landedUnset(field: string): boolean {
397
+ return !this.stored(field)
398
+ }
399
+
414
400
  private stage(field: string, edit: StagedEdit): void {
415
401
  this.staged.set(field, edit)
416
402
  this.failed = false