@linxin666/dsh-client-ui-task-board 0.3.6 → 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.
- package/README.i18n.yaml +2 -2
- package/README.md +9 -4
- package/README.zh.md +9 -4
- package/lib/client.js +688 -66
- package/lib/client.js.map +1 -1
- package/lib/index.js +698 -190
- package/lib/types/client/TaskBoardSettingsCard.d.ts +2 -1
- package/lib/types/client/board/TaskBoard.d.ts +3 -0
- package/lib/types/client/host-api.d.ts +2 -2
- package/lib/types/client/index.d.ts +10 -2
- package/lib/types/client/locales.d.ts +19 -0
- package/lib/types/client/settings-form.d.ts +35 -43
- package/lib/types/core/controller.d.ts +11 -2
- package/lib/types/core/freeze-snapshot.d.ts +71 -0
- package/lib/types/core/handover.d.ts +58 -0
- package/lib/types/core/tasks.d.ts +70 -1
- package/lib/types/core/use-cases/task-update.d.ts +16 -7
- package/lib/types/host-ledger.d.ts +17 -2
- package/lib/types/host-runner.d.ts +43 -23
- package/lib/types/host-service.d.ts +8 -5
- package/lib/types/index.d.ts +9 -1
- package/lib/types/protocol.d.ts +15 -2
- package/package.json +26 -17
- package/src/client/TaskBoardSettingsCard.tsx +2 -1
- package/src/client/board/NewTaskModal.tsx +52 -0
- package/src/client/board/TaskBoard.tsx +5 -3
- package/src/client/board/TaskCard.tsx +3 -0
- package/src/client/board/TaskDetail.tsx +54 -0
- package/src/client/host-api.ts +5 -5
- package/src/client/index.ts +67 -7
- package/src/client/locales.ts +40 -2
- package/src/client/settings-form.ts +78 -92
- package/src/core/controller.ts +35 -10
- package/src/core/freeze-snapshot.ts +213 -0
- package/src/core/handover.ts +117 -0
- package/src/core/store.ts +48 -1
- package/src/core/tasks.ts +87 -0
- package/src/core/use-cases/task-update.ts +30 -10
- package/src/dsh-home.ts +1 -1
- package/src/host-ledger.ts +137 -59
- package/src/host-routes.ts +1 -1
- package/src/host-runner.ts +211 -97
- package/src/host-service.ts +14 -10
- package/src/index.ts +24 -10
- package/src/protocol.ts +98 -14
package/src/client/locales.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
12
|
-
import {
|
|
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
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
|
57
|
-
*
|
|
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,
|
|
107
|
+
/** The durable write this entry performs, inside the save's one atomic mutation. */
|
|
105
108
|
op: BatchedWrite
|
|
106
|
-
/**
|
|
107
|
-
|
|
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
|
|
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
|
|
192
|
-
*
|
|
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.
|
|
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
|
|
298
|
+
* Write every staged edit in one atomic scope mutation, then re-seed from
|
|
299
|
+
* what the Host accepted.
|
|
322
300
|
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
*
|
|
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.
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
|
344
|
+
if (landed && this.staged.get(field) === before) this.staged.delete(field)
|
|
363
345
|
}
|
|
364
346
|
this.saving = false
|
|
365
|
-
this.failed = landed
|
|
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' },
|
|
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' },
|
|
393
|
-
else if (write.kind === 'clear') plan.push({ field, op: { field, op: 'unset' },
|
|
394
|
-
else plan.push({ field, op: { field, op: 'set', value: 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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
//
|
|
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
|
package/src/core/controller.ts
CHANGED
|
@@ -23,7 +23,11 @@ import type { TaskBoardAction, TaskBoardEventPayload, TaskBoardSnapshot } from '
|
|
|
23
23
|
export interface TaskBoardTransport {
|
|
24
24
|
bootstrap(legacy: readonly TaskRecord[]): Promise<TaskBoardSnapshot>
|
|
25
25
|
state(): Promise<TaskBoardSnapshot>
|
|
26
|
-
|
|
26
|
+
/**
|
|
27
|
+
* Submit one action; the optional initiator is the current DSH session id
|
|
28
|
+
* (issue #6 audit origin), asserted by the client and recorded by the Host.
|
|
29
|
+
*/
|
|
30
|
+
action(action: TaskBoardAction, initiator?: string): Promise<TaskBoardSnapshot>
|
|
27
31
|
subscribe(listener: (event?: TaskBoardEventPayload) => void): () => void
|
|
28
32
|
}
|
|
29
33
|
|
|
@@ -37,6 +41,10 @@ export interface SessionsControllerFace {
|
|
|
37
41
|
open(id: string): void
|
|
38
42
|
}
|
|
39
43
|
|
|
44
|
+
function currentOf(sessions: SessionsControllerFace | undefined): string | undefined {
|
|
45
|
+
return sessions?.list.getSnapshot().current
|
|
46
|
+
}
|
|
47
|
+
|
|
40
48
|
/** Controller dependencies (all swappable in tests). */
|
|
41
49
|
export interface ControllerDeps {
|
|
42
50
|
store: TaskStore
|
|
@@ -83,7 +91,7 @@ export interface ControllerSnapshot {
|
|
|
83
91
|
executionOptions: ExecutionOptionsSnapshot
|
|
84
92
|
pendingTaskIds: readonly string[]
|
|
85
93
|
transportError?: string
|
|
86
|
-
host?: Pick<TaskBoardSnapshot, 'revision' | 'scheduler' | 'power'>
|
|
94
|
+
host?: Pick<TaskBoardSnapshot, 'revision' | 'scheduler' | 'power' | 'sessionDefaultPermission'>
|
|
87
95
|
}
|
|
88
96
|
|
|
89
97
|
/** The selected task (resolved from the ledger), or undefined. */
|
|
@@ -129,7 +137,7 @@ export class BoardController {
|
|
|
129
137
|
private readonly pendingTaskIds = new Set<string>()
|
|
130
138
|
private readonly taskQueues = new Map<string, Promise<void>>()
|
|
131
139
|
private transportError: string | undefined
|
|
132
|
-
private hostState: Pick<TaskBoardSnapshot, 'revision' | 'scheduler' | 'power'> | undefined
|
|
140
|
+
private hostState: Pick<TaskBoardSnapshot, 'revision' | 'scheduler' | 'power' | 'sessionDefaultPermission'> | undefined
|
|
133
141
|
private remoteSubscribed = false
|
|
134
142
|
private remoteInitialization: Promise<boolean> | undefined
|
|
135
143
|
|
|
@@ -406,7 +414,24 @@ export class BoardController {
|
|
|
406
414
|
const task = this.tasks.find(candidate => candidate.id === id)
|
|
407
415
|
if (task === undefined || task.archivedAt !== undefined || task.status === 'running') return false
|
|
408
416
|
if (this.deps.transport === undefined) return false
|
|
409
|
-
return await this.commitRemote({ kind: 'run', taskId: id }, id)
|
|
417
|
+
return await this.commitRemote({ kind: 'run', taskId: id }, id, currentOf(this.deps.sessions))
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Confirm a card's above-default permission binding through the Host
|
|
422
|
+
* (resolves the pending-confirmation transaction; no-op otherwise).
|
|
423
|
+
*/
|
|
424
|
+
async confirmPermission(id: string): Promise<boolean> {
|
|
425
|
+
const task = this.tasks.find(candidate => candidate.id === id)
|
|
426
|
+
if (task === undefined) return false
|
|
427
|
+
if (this.deps.transport === undefined) {
|
|
428
|
+
this.tasks = this.tasks.map(candidate => candidate.id === id
|
|
429
|
+
? { ...candidate, permissionConfirmedAt: this.now(), updatedAt: this.now() }
|
|
430
|
+
: candidate)
|
|
431
|
+
this.persistAndNotify()
|
|
432
|
+
return true
|
|
433
|
+
}
|
|
434
|
+
return await this.commitRemote({ kind: 'confirm-permission', taskId: id }, id)
|
|
410
435
|
}
|
|
411
436
|
|
|
412
437
|
/** Re-run a settled task through the Host (the Host replans and executes). */
|
|
@@ -414,7 +439,7 @@ export class BoardController {
|
|
|
414
439
|
const task = this.tasks.find(candidate => candidate.id === id)
|
|
415
440
|
if (task === undefined || task.archivedAt !== undefined) return
|
|
416
441
|
if (this.deps.transport === undefined) return
|
|
417
|
-
await this.commitRemote({ kind: 'rerun', taskId: id }, id)
|
|
442
|
+
await this.commitRemote({ kind: 'rerun', taskId: id }, id, currentOf(this.deps.sessions))
|
|
418
443
|
}
|
|
419
444
|
|
|
420
445
|
// --- internals ---------------------------------------------------------------
|
|
@@ -438,12 +463,12 @@ export class BoardController {
|
|
|
438
463
|
this.notify()
|
|
439
464
|
}
|
|
440
465
|
|
|
441
|
-
private async commitRemote(action: TaskBoardAction, taskId?: string): Promise<boolean> {
|
|
466
|
+
private async commitRemote(action: TaskBoardAction, taskId?: string, initiator?: string): Promise<boolean> {
|
|
442
467
|
const transport = this.deps.transport
|
|
443
468
|
if (transport === undefined) return true
|
|
444
|
-
if (taskId === undefined) return await this.performRemote(action)
|
|
469
|
+
if (taskId === undefined) return await this.performRemote(action, initiator)
|
|
445
470
|
const previous = this.taskQueues.get(taskId) ?? Promise.resolve()
|
|
446
|
-
const operation = previous.catch(() => {}).then(async () => await this.performRemote(action))
|
|
471
|
+
const operation = previous.catch(() => {}).then(async () => await this.performRemote(action, initiator))
|
|
447
472
|
const tail = operation.then(() => {}, () => {})
|
|
448
473
|
this.taskQueues.set(taskId, tail)
|
|
449
474
|
this.pendingTaskIds.add(taskId)
|
|
@@ -459,13 +484,13 @@ export class BoardController {
|
|
|
459
484
|
}
|
|
460
485
|
}
|
|
461
486
|
|
|
462
|
-
private async performRemote(action: TaskBoardAction): Promise<boolean> {
|
|
487
|
+
private async performRemote(action: TaskBoardAction, initiator?: string): Promise<boolean> {
|
|
463
488
|
const transport = this.deps.transport
|
|
464
489
|
if (transport === undefined) return true
|
|
465
490
|
this.transportError = undefined
|
|
466
491
|
this.notify()
|
|
467
492
|
try {
|
|
468
|
-
const accepted = this.acceptRemote(await transport.action(action))
|
|
493
|
+
const accepted = this.acceptRemote(await transport.action(action, initiator))
|
|
469
494
|
return accepted || await this.refreshRemote()
|
|
470
495
|
} catch (error) {
|
|
471
496
|
await this.refreshRemote(messageOf(error))
|