@linxin666/dsh-client-ui-task-board 0.4.3 → 0.4.5

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 (128) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +56 -18
  3. package/README.zh.md +56 -18
  4. package/lib/client.js +6242 -958
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +3475 -234
  7. package/lib/types/client/PluginSettingsCard.d.ts +7 -0
  8. package/lib/types/client/TaskBoardSettingsCard.d.ts +19 -3
  9. package/lib/types/client/board/ClampedMarkdown.d.ts +3 -0
  10. package/lib/types/client/board/ConfirmDialog.d.ts +4 -1
  11. package/lib/types/client/board/EditTaskModal.d.ts +5 -2
  12. package/lib/types/client/board/LinkSubtaskModal.d.ts +5 -2
  13. package/lib/types/client/board/NewTaskModal.d.ts +4 -1
  14. package/lib/types/client/board/TagManagerModal.d.ts +26 -0
  15. package/lib/types/client/board/TaskBoard.d.ts +10 -1
  16. package/lib/types/client/board/TaskCard.d.ts +14 -1
  17. package/lib/types/client/board/TaskDetail.d.ts +3 -1
  18. package/lib/types/client/board/TaskForm.d.ts +23 -2
  19. package/lib/types/client/board/VerificationReport.d.ts +23 -0
  20. package/lib/types/client/board/card-view.d.ts +113 -0
  21. package/lib/types/client/board/icons.d.ts +32 -0
  22. package/lib/types/client/board/overlay.d.ts +69 -0
  23. package/lib/types/client/board/preset-label.d.ts +22 -0
  24. package/lib/types/client/board/status-key.d.ts +15 -0
  25. package/lib/types/client/board/task-markdown.d.ts +19 -0
  26. package/lib/types/client/board-card-seat.d.ts +34 -0
  27. package/lib/types/client/host-api.d.ts +24 -1
  28. package/lib/types/client/index.d.ts +19 -0
  29. package/lib/types/client/locales.d.ts +141 -3
  30. package/lib/types/client/native-panel.d.ts +6 -20
  31. package/lib/types/client/schedule-zone.d.ts +47 -0
  32. package/lib/types/client/seats.d.ts +18 -0
  33. package/lib/types/client/service.d.ts +32 -0
  34. package/lib/types/client/settings-form.d.ts +16 -0
  35. package/lib/types/core/controller.d.ts +38 -1
  36. package/lib/types/core/degraded-reason.d.ts +46 -0
  37. package/lib/types/core/extension.d.ts +283 -0
  38. package/lib/types/core/handover.d.ts +13 -1
  39. package/lib/types/core/issue-form.d.ts +16 -0
  40. package/lib/types/core/package-name.d.ts +9 -0
  41. package/lib/types/core/schedule.d.ts +95 -20
  42. package/lib/types/core/subtask.d.ts +30 -11
  43. package/lib/types/core/tasks.d.ts +110 -5
  44. package/lib/types/core/use-cases/task-create.d.ts +2 -1
  45. package/lib/types/core/use-cases/task-schedule.d.ts +8 -4
  46. package/lib/types/core/use-cases/task-tag.d.ts +48 -0
  47. package/lib/types/core/use-cases/task-update.d.ts +5 -3
  48. package/lib/types/core/verification.d.ts +386 -0
  49. package/lib/types/core/workspace-target.d.ts +41 -0
  50. package/lib/types/host/agent-tools.d.ts +1 -1
  51. package/lib/types/host/extension-registry.d.ts +111 -0
  52. package/lib/types/host/llm-dispatch.d.ts +63 -0
  53. package/lib/types/host/verification-gate.d.ts +71 -0
  54. package/lib/types/host/verification-runner.d.ts +108 -0
  55. package/lib/types/host/workspace-evidence.d.ts +53 -0
  56. package/lib/types/host-ledger.d.ts +107 -11
  57. package/lib/types/host-runner.d.ts +99 -4
  58. package/lib/types/host-service.d.ts +114 -2
  59. package/lib/types/index.d.ts +80 -26
  60. package/lib/types/protocol.d.ts +53 -3
  61. package/lib/types/tool-surface.d.ts +71 -0
  62. package/package.json +27 -26
  63. package/src/client/PluginSettingsCard.tsx +9 -0
  64. package/src/client/TaskBoardSettingsCard.tsx +304 -65
  65. package/src/client/board/ClampedMarkdown.tsx +69 -0
  66. package/src/client/board/ConfirmDialog.tsx +7 -3
  67. package/src/client/board/EditTaskModal.tsx +5 -2
  68. package/src/client/board/LinkSubtaskModal.tsx +30 -15
  69. package/src/client/board/NewTaskModal.tsx +288 -151
  70. package/src/client/board/TagManagerModal.tsx +235 -0
  71. package/src/client/board/TaskBoard.tsx +259 -103
  72. package/src/client/board/TaskCard.tsx +238 -86
  73. package/src/client/board/TaskDetail.tsx +154 -59
  74. package/src/client/board/TaskForm.tsx +61 -6
  75. package/src/client/board/VerificationReport.tsx +203 -0
  76. package/src/client/board/card-view.ts +220 -0
  77. package/src/client/board/icons.tsx +89 -0
  78. package/src/client/board/overlay.tsx +189 -0
  79. package/src/client/board/preset-label.ts +44 -0
  80. package/src/client/board/status-key.ts +27 -0
  81. package/src/client/board/task-markdown.module.css +137 -0
  82. package/src/client/board/task-markdown.tsx +106 -0
  83. package/src/client/board-card-seat.ts +102 -0
  84. package/src/client/board-settings.module.css +66 -0
  85. package/src/client/board.module.css +602 -68
  86. package/src/client/host-api.ts +109 -6
  87. package/src/client/index.ts +48 -6
  88. package/src/client/locales.ts +290 -12
  89. package/src/client/native-panel.tsx +18 -2
  90. package/src/client/schedule-zone.ts +135 -0
  91. package/src/client/seats.tsx +58 -0
  92. package/src/client/service.ts +94 -0
  93. package/src/client/settings-card.module.css +31 -5
  94. package/src/client/settings-form.ts +27 -1
  95. package/src/core/controller.ts +101 -5
  96. package/src/core/degraded-reason.ts +81 -0
  97. package/src/core/extension.ts +376 -0
  98. package/src/core/freeze-snapshot.ts +2 -2
  99. package/src/core/handover.ts +16 -1
  100. package/src/core/issue-form.ts +19 -0
  101. package/src/core/package-name.ts +9 -0
  102. package/src/core/schedule.ts +211 -55
  103. package/src/core/store.ts +26 -1
  104. package/src/core/subtask.ts +77 -22
  105. package/src/core/tasks.ts +138 -10
  106. package/src/core/use-cases/task-create.ts +14 -2
  107. package/src/core/use-cases/task-parent.ts +3 -2
  108. package/src/core/use-cases/task-schedule.ts +17 -6
  109. package/src/core/use-cases/task-tag.ts +110 -0
  110. package/src/core/use-cases/task-update.ts +13 -4
  111. package/src/core/verification.ts +826 -0
  112. package/src/core/workspace-target.ts +59 -0
  113. package/src/host/agent-tools.ts +58 -24
  114. package/src/host/extension-registry.ts +509 -0
  115. package/src/host/llm-dispatch.ts +87 -0
  116. package/src/host/verification-gate.ts +331 -0
  117. package/src/host/verification-runner.ts +535 -0
  118. package/src/host/workspace-evidence.ts +176 -0
  119. package/src/host-ai.ts +15 -9
  120. package/src/host-ledger.ts +342 -51
  121. package/src/host-routes.ts +15 -2
  122. package/src/host-runner.ts +219 -20
  123. package/src/host-service.ts +346 -26
  124. package/src/index.ts +204 -16
  125. package/src/protocol.ts +122 -9
  126. package/src/tool-surface.ts +95 -0
  127. package/lib/types/host/run-guarded.d.ts +0 -39
  128. package/src/host/run-guarded.ts +0 -76
@@ -0,0 +1,509 @@
1
+ /**
2
+ * Host-side extension registry: the one place that admits external providers,
3
+ * negotiates the contract version, gates them on the board's own enable state,
4
+ * fans board events out to them, and routes dispatched provider actions back.
5
+ *
6
+ * Failure policy (mirrors the board's plugin philosophy): a provider that
7
+ * throws, mis-declares its version, or disables itself degrades only its own
8
+ * surface. The board keeps serving; the registry records what it refused.
9
+ *
10
+ * @module dsh-task-board/host/extension-registry
11
+ */
12
+ import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
13
+ import type { HostTaskLedger } from '../host-ledger.ts'
14
+ import type { TaskBoardAction, TaskBoardSnapshot } from '../protocol.ts'
15
+ import { canEditTaskContent } from '../core/use-cases/task-update.ts'
16
+ import {
17
+ TASK_BOARD_API_VERSION,
18
+ isTaskBoardExtensionPayload,
19
+ taskBoardPayloadBytes,
20
+ type TaskBoardCreateOptions,
21
+ type TaskBoardEventFace,
22
+ type TaskBoardExecutionSettledEvent,
23
+ type TaskBoardExtension,
24
+ type TaskBoardExtensionActionRequest,
25
+ type TaskBoardExtensionHost,
26
+ type TaskBoardExtensionPayload,
27
+ type TaskBoardIntegrationFace,
28
+ type TaskBoardLinkedTask,
29
+ type TaskBoardStatusChangedEvent,
30
+ type TaskBoardTaskDeletedEvent,
31
+ type TaskBoardTasksFace,
32
+ } from '../core/extension.ts'
33
+ import { isTaskStatus, type TaskRecord, type TaskStatus } from '../core/tasks.ts'
34
+
35
+ /** Diagnosable refusal raised by the registry and the capability face. */
36
+ export class TaskBoardExtensionError extends Error {
37
+ readonly code: string
38
+ constructor(code: string, message: string) {
39
+ super(`task-board extension [${code}]: ${message}`)
40
+ this.name = 'TaskBoardExtensionError'
41
+ this.code = code
42
+ }
43
+ }
44
+
45
+ /** Logging seat the registry uses; a deployment may supply its own. */
46
+ export interface TaskBoardExtensionLogger {
47
+ warn(message: string, ...rest: unknown[]): void
48
+ error(message: string, ...rest: unknown[]): void
49
+ }
50
+
51
+ /** The deployment's model-visible tool registry, resolved lazily. */
52
+ export interface TaskBoardExtensionToolRegistry {
53
+ register(definition: ToolDefinition): () => void
54
+ }
55
+
56
+ /** Applies one board action with every board gate intact (never an extension action). */
57
+ export type TaskBoardActionApplier = (action: Exclude<TaskBoardAction, { kind: 'extension-action' }>, initiator?: string) => TaskBoardSnapshot
58
+
59
+ /** Registry dependencies. */
60
+ export interface TaskBoardExtensionRegistryOptions {
61
+ ledger: HostTaskLedger
62
+ /** Apply an action through the board service (gates, run dispatch, schedule). */
63
+ apply?: TaskBoardActionApplier
64
+ /** Resolve the deployment's tool registry; absent loses the tool surface only. */
65
+ toolRegistry?: () => TaskBoardExtensionToolRegistry | undefined
66
+ now?: () => number
67
+ uuid?: () => string
68
+ logger?: TaskBoardExtensionLogger
69
+ }
70
+
71
+ /** Event subscription callback, erased because one entry owns three families. */
72
+ type RegistryEventCallback = (event: unknown) => void
73
+
74
+ /** One registered provider. */
75
+ interface RegistryEntry {
76
+ readonly extension: TaskBoardExtension
77
+ /** Whether provider-side work is currently started (board x extension gate). */
78
+ active: boolean
79
+ /** Live event subscriptions, one set per event family. */
80
+ readonly subscriptions: Record<keyof TaskBoardEventFace, Set<RegistryEventCallback>>
81
+ /** Tool definitions the provider registered, with their live disposers. */
82
+ tools: Array<{ definition: ToolDefinition; dispose?: () => void }>
83
+ published?: TaskBoardExtensionPayload
84
+ }
85
+
86
+ const DEFAULT_LOGGER: TaskBoardExtensionLogger = {
87
+ warn(message: string, ...rest: unknown[]): void {
88
+ try { console.warn(message, ...rest) } catch { /* best-effort */ }
89
+ },
90
+ error(message: string, ...rest: unknown[]): void {
91
+ try { console.error(message, ...rest) } catch { /* best-effort */ }
92
+ },
93
+ }
94
+
95
+ /**
96
+ * Whether a tool registration was refused because the deployment's fiber is
97
+ * already unloading, rather than because the registry rejected the definition.
98
+ * cordis reports that refusal as `INACTIVE_EFFECT` on the thrown error, and it
99
+ * is the expected answer to any registration a shutdown path makes too late: the
100
+ * teardown releases those tools moments later, so a refusal reports nothing.
101
+ * Detected by the documented error code, which keeps the board free of any
102
+ * runtime dependency on the host framework.
103
+ *
104
+ * @param error - the refusal the tool registry raised.
105
+ * @returns true when the host context is already disposing.
106
+ */
107
+ function isInactiveContext(error: unknown): boolean {
108
+ return typeof error === 'object' && error !== null && (error as { code?: unknown }).code === 'INACTIVE_EFFECT'
109
+ }
110
+
111
+ function emptySubscriptions(): Record<keyof TaskBoardEventFace, Set<RegistryEventCallback>> {
112
+ return {
113
+ onStatusChanged: new Set(),
114
+ onExecutionSettled: new Set(),
115
+ onTaskDeleted: new Set(),
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Shallow-merge one provider payload, dropping keys explicitly cleared with
121
+ * undefined so a provider can remove a field without replacing the entry.
122
+ */
123
+ function mergePayload(
124
+ current: TaskBoardExtensionPayload | undefined,
125
+ patch: TaskBoardExtensionPayload,
126
+ ): TaskBoardExtensionPayload {
127
+ const next: TaskBoardExtensionPayload = { ...(current ?? {}) }
128
+ for (const [key, value] of Object.entries(patch)) {
129
+ if (value === undefined) delete next[key]
130
+ else next[key] = value
131
+ }
132
+ return next
133
+ }
134
+
135
+ /** Whether a stored integration entry is a usable extension payload. */
136
+ function readPayload(value: unknown): TaskBoardExtensionPayload | undefined {
137
+ return isTaskBoardExtensionPayload(value) ? value : undefined
138
+ }
139
+
140
+ export class TaskBoardExtensionRegistry {
141
+ private readonly entries = new Map<string, RegistryEntry>()
142
+ private readonly ledger: HostTaskLedger
143
+ private readonly applyAction: TaskBoardActionApplier | undefined
144
+ private readonly now: () => number
145
+ private readonly uuid: () => string
146
+ private readonly logger: TaskBoardExtensionLogger
147
+ private toolResolver: (() => TaskBoardExtensionToolRegistry | undefined) | undefined
148
+ /** Board master switch (enabled config); providers start only while it is on. */
149
+ private boardEnabled = true
150
+ private disposed = false
151
+
152
+ constructor(options: TaskBoardExtensionRegistryOptions) {
153
+ this.ledger = options.ledger
154
+ this.applyAction = options.apply
155
+ this.now = options.now ?? Date.now
156
+ this.uuid = options.uuid ?? (() => crypto.randomUUID())
157
+ this.logger = options.logger ?? DEFAULT_LOGGER
158
+ this.toolResolver = options.toolRegistry
159
+ }
160
+
161
+ /**
162
+ * Pin (or replace) the deployment's tool registry. A registry that appears
163
+ * after a provider started is picked up here, which is what keeps the tool
164
+ * surface alive across the board's own late service resolution.
165
+ */
166
+ setToolRegistry(resolver: (() => TaskBoardExtensionToolRegistry | undefined) | undefined): void {
167
+ this.toolResolver = resolver
168
+ // The resolver may now name a different registry (the deployment replaced
169
+ // its tool provider fiber): release what the previous one held and let
170
+ // syncTools adopt the current one.
171
+ for (const entry of this.entries.values()) {
172
+ this.disposeTools(entry)
173
+ this.syncTools(entry)
174
+ }
175
+ }
176
+
177
+ /** Apply the board's master switch; providers start and stop with it. */
178
+ setEnabled(enabled: boolean): void {
179
+ this.boardEnabled = enabled
180
+ for (const entry of this.entries.values()) this.reconcile(entry)
181
+ }
182
+
183
+ /** Whether the board currently runs a given extension (board x extension). */
184
+ isActive(extensionId: string): boolean {
185
+ return this.entries.get(extensionId)?.active === true
186
+ }
187
+
188
+ /**
189
+ * Admit one provider. Idempotent by id: a second registration of the same id
190
+ * is a no-op that returns the same release handle. A contract-version
191
+ * mismatch is refused visibly (logged and thrown) without disturbing the
192
+ * board or any other provider.
193
+ * @param extension - the provider to admit.
194
+ * @returns a disposer releasing the registration (idempotent).
195
+ */
196
+ registerExtension(extension: TaskBoardExtension): () => void {
197
+ if (this.disposed) throw new TaskBoardExtensionError('registry-disposed', 'the board is shutting down')
198
+ const existing = this.entries.get(extension.id)
199
+ if (existing !== undefined) {
200
+ if (existing.extension !== extension) {
201
+ this.logger.warn(`[dsh-task-board] extension "${extension.id}" is already registered; the second registration is ignored`)
202
+ }
203
+ return () => { this.unregister(extension.id) }
204
+ }
205
+ if (extension.apiVersion !== TASK_BOARD_API_VERSION) {
206
+ const message = `extension "${extension.id}" declares apiVersion ${String(extension.apiVersion)} but this board serves ${TASK_BOARD_API_VERSION}`
207
+ this.logger.error('[dsh-task-board] ' + message)
208
+ throw new TaskBoardExtensionError('api-version-mismatch', message)
209
+ }
210
+ const entry: RegistryEntry = { extension, active: false, subscriptions: emptySubscriptions(), tools: [] }
211
+ this.entries.set(extension.id, entry)
212
+ this.reconcile(entry)
213
+ return () => { this.unregister(extension.id) }
214
+ }
215
+
216
+ /** Remove one provider; its provider-side work stops and its payload stays stored. */
217
+ unregister(extensionId: string): void {
218
+ const entry = this.entries.get(extensionId)
219
+ if (entry === undefined) return
220
+ this.stopEntry(entry)
221
+ this.entries.delete(extensionId)
222
+ }
223
+
224
+ /**
225
+ * Route one dispatched provider action. Refuses visibly when the extension is
226
+ * unknown, currently gated off, or does not implement the action.
227
+ */
228
+ async handleAction(request: TaskBoardExtensionActionRequest): Promise<unknown> {
229
+ const entry = this.entries.get(request.extensionId)
230
+ if (entry === undefined) {
231
+ throw new TaskBoardExtensionError('unknown-extension', `no extension "${request.extensionId}" is registered`)
232
+ }
233
+ if (!entry.active) {
234
+ throw new TaskBoardExtensionError('extension-disabled', `extension "${request.extensionId}" is not enabled`)
235
+ }
236
+ const handler = entry.extension.handleAction
237
+ if (typeof handler !== 'function') {
238
+ throw new TaskBoardExtensionError('unsupported-action', `extension "${request.extensionId}" handles no actions`)
239
+ }
240
+ return await handler.call(entry.extension, request)
241
+ }
242
+
243
+ /** Published summaries of currently running providers, keyed by extension id. */
244
+ published(): Record<string, TaskBoardExtensionPayload> {
245
+ const result: Record<string, TaskBoardExtensionPayload> = {}
246
+ for (const [id, entry] of this.entries) {
247
+ if (entry.active && entry.published !== undefined) result[id] = entry.published
248
+ }
249
+ return result
250
+ }
251
+
252
+ /** Fan a status change out to every running provider, isolated per callback. */
253
+ emitStatusChanged(event: TaskBoardStatusChangedEvent): void {
254
+ this.emit('onStatusChanged', event)
255
+ }
256
+
257
+ /** Fan an execution settlement out to every running provider. */
258
+ emitExecutionSettled(event: TaskBoardExecutionSettledEvent): void {
259
+ this.emit('onExecutionSettled', event)
260
+ }
261
+
262
+ /** Fan a task deletion out to every running provider. */
263
+ emitTaskDeleted(event: TaskBoardTaskDeletedEvent): void {
264
+ this.emit('onTaskDeleted', event)
265
+ }
266
+
267
+ /** Stop every provider and release the registry (idempotent). */
268
+ dispose(): void {
269
+ if (this.disposed) return
270
+ this.disposed = true
271
+ for (const entry of this.entries.values()) this.stopEntry(entry)
272
+ this.entries.clear()
273
+ }
274
+
275
+ // --- internals ---------------------------------------------------------------
276
+
277
+ private emit(kind: keyof TaskBoardEventFace, payload: unknown): void {
278
+ if (this.disposed) return
279
+ for (const entry of this.entries.values()) {
280
+ if (!entry.active) continue
281
+ for (const callback of [...entry.subscriptions[kind]]) {
282
+ try {
283
+ callback(payload)
284
+ } catch (error) {
285
+ this.logger.error(`[dsh-task-board] extension "${entry.extension.id}" ${kind} callback failed`, error)
286
+ }
287
+ }
288
+ }
289
+ }
290
+
291
+ /** Start or stop one entry against the current board + extension gate. */
292
+ private reconcile(entry: RegistryEntry): void {
293
+ if (this.disposed) return
294
+ const wanted = this.boardEnabled && (entry.extension.enabled?.() ?? true)
295
+ if (wanted === entry.active) {
296
+ if (wanted) this.syncTools(entry)
297
+ return
298
+ }
299
+ if (wanted) this.startEntry(entry)
300
+ else this.stopEntry(entry)
301
+ }
302
+
303
+ private startEntry(entry: RegistryEntry): void {
304
+ entry.active = true
305
+ try {
306
+ entry.extension.start?.(this.capabilities(entry))
307
+ } catch (error) {
308
+ this.logger.error(`[dsh-task-board] extension "${entry.extension.id}" start() failed`, error)
309
+ }
310
+ this.syncTools(entry)
311
+ }
312
+
313
+ private stopEntry(entry: RegistryEntry): void {
314
+ if (entry.active) {
315
+ entry.active = false
316
+ for (const kind of Object.keys(entry.subscriptions) as Array<keyof TaskBoardEventFace>) {
317
+ entry.subscriptions[kind].clear()
318
+ }
319
+ try {
320
+ entry.extension.stop?.()
321
+ } catch (error) {
322
+ this.logger.error(`[dsh-task-board] extension "${entry.extension.id}" stop() failed`, error)
323
+ }
324
+ }
325
+ this.disposeTools(entry)
326
+ // A stopped provider re-registers its tools from start(); keeping the old
327
+ // definitions would double-register them on the next enable.
328
+ entry.tools.length = 0
329
+ }
330
+
331
+ /** (Re)register every buffered tool against the current tool registry. */
332
+ private syncTools(entry: RegistryEntry): void {
333
+ if (!entry.active) return
334
+ const registry = this.toolResolver?.()
335
+ for (const tool of entry.tools) {
336
+ if (registry === undefined) {
337
+ tool.dispose = undefined
338
+ continue
339
+ }
340
+ if (tool.dispose !== undefined) continue
341
+ try {
342
+ tool.dispose = registry.register(tool.definition)
343
+ } catch (error) {
344
+ tool.dispose = undefined
345
+ // A refusal from a host that is already unloading is the expected answer
346
+ // to a registration the teardown made too late, not a fault of the tool
347
+ // definition; only a real refusal of the definition is reported.
348
+ if (isInactiveContext(error)) return
349
+ this.logger.error(`[dsh-task-board] extension "${entry.extension.id}" tool "${tool.definition.name}" registration failed`, error)
350
+ }
351
+ }
352
+ }
353
+
354
+ private disposeTools(entry: RegistryEntry): void {
355
+ for (const tool of entry.tools) {
356
+ const dispose = tool.dispose
357
+ tool.dispose = undefined
358
+ if (dispose === undefined) continue
359
+ try { dispose() } catch (error) { this.logger.error('[dsh-task-board] tool disposer failed', error) }
360
+ }
361
+ }
362
+
363
+ private assertActive(entry: RegistryEntry, what: string): void {
364
+ if (this.disposed) throw new TaskBoardExtensionError('registry-disposed', 'the board is shutting down')
365
+ if (!entry.active) throw new TaskBoardExtensionError('extension-disabled', `extension "${entry.extension.id}" cannot ${what} while disabled`)
366
+ }
367
+
368
+ /** Build the per-extension capability face. */
369
+ private capabilities(entry: RegistryEntry): TaskBoardExtensionHost {
370
+ const id = entry.extension.id
371
+ const assertActive = (what: string): void => { this.assertActive(entry, what) }
372
+ const subscribe = (kind: keyof TaskBoardEventFace, callback: RegistryEventCallback): (() => void) => {
373
+ assertActive('subscribe')
374
+ entry.subscriptions[kind].add(callback)
375
+ return () => { entry.subscriptions[kind].delete(callback) }
376
+ }
377
+ const tasks: TaskBoardTasksFace = {
378
+ list: () => this.ledger.allTasks(),
379
+ get: taskId => this.ledger.getTask(taskId),
380
+ create: (draft, options) => this.createTask(entry, draft, options),
381
+ patchContent: (taskId, patch) => this.patchContent(entry, taskId, patch),
382
+ setStatus: (taskId, status, initiator) => this.setStatus(entry, taskId, status, initiator),
383
+ linked: () => this.linkedTasks(entry),
384
+ }
385
+ const integration: TaskBoardIntegrationFace = {
386
+ read: (taskId) => {
387
+ assertActive('read')
388
+ return readPayload(this.ledger.getTask(taskId)?.integrations?.[id])
389
+ },
390
+ write: (taskId, payload) => { this.writeIntegration(entry, taskId, payload) },
391
+ }
392
+ const events: TaskBoardEventFace = {
393
+ onStatusChanged: (callback) => subscribe('onStatusChanged', callback as RegistryEventCallback),
394
+ onExecutionSettled: (callback) => subscribe('onExecutionSettled', callback as RegistryEventCallback),
395
+ onTaskDeleted: (callback) => subscribe('onTaskDeleted', callback as RegistryEventCallback),
396
+ }
397
+ return {
398
+ tasks,
399
+ integration,
400
+ events,
401
+ publish: (summary) => { this.publish(entry, summary) },
402
+ registerTool: (definition) => { assertActive('register a tool'); return this.registerTool(entry, definition) },
403
+ }
404
+ }
405
+
406
+ private createTask(entry: RegistryEntry, draft: { title: string; description: string; prompt: string; status?: TaskStatus; parentId?: string; workspaceId?: string }, options?: TaskBoardCreateOptions): TaskRecord {
407
+ this.assertActive(entry, 'create a task')
408
+ if (this.applyAction === undefined) throw new TaskBoardExtensionError('unavailable', 'this deployment serves no action applier')
409
+ if (draft.status !== undefined && !isTaskStatus(draft.status)) throw new TaskBoardExtensionError('invalid-status', 'unknown task status')
410
+ const payload = options?.payload
411
+ if (payload !== undefined && !isTaskBoardExtensionPayload(payload)) {
412
+ throw new TaskBoardExtensionError('invalid-payload', 'create payload must be a pure JSON object within the size limit')
413
+ }
414
+ const recordId = this.uuid()
415
+ const snapshot = this.applyAction({
416
+ kind: 'create',
417
+ id: recordId,
418
+ input: {
419
+ title: draft.title,
420
+ description: draft.description,
421
+ prompt: draft.prompt,
422
+ ...(draft.status === undefined ? {} : { status: draft.status }),
423
+ ...(draft.parentId === undefined ? {} : { parentId: draft.parentId }),
424
+ ...(draft.workspaceId === undefined ? {} : { workspaceId: draft.workspaceId }),
425
+ ...(payload === undefined ? {} : { integrations: { [entry.extension.id]: payload } }),
426
+ ...(options?.hidden === true ? { hidden: true } : {}),
427
+ },
428
+ })
429
+ const task = snapshot.tasks.find(item => item.id === recordId)
430
+ if (task === undefined) throw new TaskBoardExtensionError('create-refused', 'the board refused the task')
431
+ return task
432
+ }
433
+
434
+ private patchContent(entry: RegistryEntry, taskId: string, patch: { title?: string; description?: string; prompt?: string }): void {
435
+ this.assertActive(entry, 'patch content')
436
+ if (this.applyAction === undefined) throw new TaskBoardExtensionError('unavailable', 'this deployment serves no action applier')
437
+ const task = this.ledger.getTask(taskId)
438
+ if (task === undefined) throw new TaskBoardExtensionError('task-not-found', `no task with id ${taskId}`)
439
+ // The board's own content authority: an executed (or archived) card keeps
440
+ // the title/description/prompt that were actually planned and run.
441
+ if (!canEditTaskContent(task)) throw new TaskBoardExtensionError('content-frozen', `task ${taskId} has started executing; its content is read-only`)
442
+ const keys = (['title', 'description', 'prompt'] as const).filter(field => patch[field] !== undefined)
443
+ if (keys.length === 0) return
444
+ this.applyAction({ kind: 'update', taskId, patch: { ...patch } })
445
+ }
446
+
447
+ private setStatus(entry: RegistryEntry, taskId: string, status: TaskStatus, initiator?: string): void {
448
+ this.assertActive(entry, 'set a status')
449
+ if (this.applyAction === undefined) throw new TaskBoardExtensionError('unavailable', 'this deployment serves no action applier')
450
+ if (!isTaskStatus(status)) throw new TaskBoardExtensionError('invalid-status', 'unknown task status')
451
+ // The board's existing move gates (running lock, archived lock, manual
452
+ // column set) decide; a refusal surfaces unchanged.
453
+ this.applyAction({ kind: 'move', taskId, status }, initiator)
454
+ }
455
+
456
+ private linkedTasks(entry: RegistryEntry): readonly TaskBoardLinkedTask[] {
457
+ const id = entry.extension.id
458
+ const result: TaskBoardLinkedTask[] = []
459
+ for (const task of this.ledger.allTasks()) {
460
+ const payload = readPayload(task.integrations?.[id])
461
+ if (payload === undefined) continue
462
+ result.push({ task, payload })
463
+ }
464
+ return result
465
+ }
466
+
467
+ private writeIntegration(entry: RegistryEntry, taskId: string, payload: TaskBoardExtensionPayload): void {
468
+ this.assertActive(entry, 'write integration data')
469
+ if (!isTaskBoardExtensionPayload(payload)) {
470
+ throw new TaskBoardExtensionError('invalid-payload', 'integration payload must be a pure JSON object within the size limit')
471
+ }
472
+ const task = this.ledger.getTask(taskId)
473
+ if (task === undefined) throw new TaskBoardExtensionError('task-not-found', `no task with id ${taskId}`)
474
+ const id = entry.extension.id
475
+ const next = mergePayload(readPayload(task.integrations?.[id]), payload)
476
+ if (!isTaskBoardExtensionPayload(next)) {
477
+ throw new TaskBoardExtensionError('invalid-payload', `merged integration payload for ${id} exceeds ${taskBoardPayloadBytes(next) ?? '?'} bytes`)
478
+ }
479
+ this.ledger.saveTaskRecord({
480
+ ...task,
481
+ updatedAt: this.now(),
482
+ integrations: { ...(task.integrations ?? {}), [id]: next },
483
+ })
484
+ }
485
+
486
+ private publish(entry: RegistryEntry, summary: TaskBoardExtensionPayload): void {
487
+ this.assertActive(entry, 'publish a summary')
488
+ if (!isTaskBoardExtensionPayload(summary)) {
489
+ throw new TaskBoardExtensionError('invalid-payload', 'published summary must be a pure JSON object within the size limit')
490
+ }
491
+ entry.published = summary
492
+ }
493
+
494
+ private registerTool(entry: RegistryEntry, definition: ToolDefinition): () => void {
495
+ const tool: { definition: ToolDefinition; dispose?: () => void } = { definition }
496
+ entry.tools.push(tool)
497
+ this.syncTools(entry)
498
+ return () => {
499
+ const index = entry.tools.indexOf(tool)
500
+ if (index === -1) return
501
+ entry.tools.splice(index, 1)
502
+ const dispose = tool.dispose
503
+ tool.dispose = undefined
504
+ if (dispose !== undefined) {
505
+ try { dispose() } catch (error) { this.logger.error('[dsh-task-board] tool disposer failed', error) }
506
+ }
507
+ }
508
+ }
509
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * One-shot model dispatch for the board's Host half.
3
+ *
4
+ * The board makes two one-shot model calls: the goal-acceptance judge
5
+ * (`verification-runner.ts`) and the "parse pasted text" action
6
+ * (`host-ai.ts`). Both call the official `llm` service, and both must call it
7
+ * the way the official agent loop does.
8
+ *
9
+ * The runtime exposes two entry points with the same name shape. The public
10
+ * `stream(options)` is a single-argument convenience method, and it is an
11
+ * ordinary instance property — any plugin may overwrite it. The registration
12
+ * bound path `prepareCall(config)` returns a one-shot handle whose
13
+ * `stream(request)` reaches the adapter through the runtime's internal
14
+ * dispatch instead, and `ctx.on('llm/stream')` remains the supported seam every
15
+ * extension is expected to register on.
16
+ *
17
+ * A third-party provider plugin that patched `llm.stream` with the waterfall
18
+ * listener signature `(options, next)` broke every caller that used the public
19
+ * method: `llm.stream(options)` invoked the listener with `next === undefined`,
20
+ * so the guard's `for await (const chunk of next())` raised
21
+ * `TypeError: next(...) is not a function or its return value is not async
22
+ * iterable`. The agent loop was unaffected because it already dispatches
23
+ * through `prepareCall(...).stream(...)`; the board's two one-shot calls were
24
+ * not, and each completed acceptance was recorded as an anomaly
25
+ * ("验收异常(裁判请求失败)") after two judge attempts.
26
+ *
27
+ * Dispatching through the prepared call fixes that at the owner: the board no
28
+ * longer depends on a mutable instance property that a plugin may legitimately
29
+ * replace, and a plugin that registers on `llm/stream` is still invoked. The
30
+ * public method remains the fallback for a runtime that exposes no
31
+ * `prepareCall` at all, so an older or proxied service keeps working.
32
+ */
33
+ import { ReasoningEffortId, type GenerateOptions, type LlmCallConfig, type LlmRuntime, type StreamChunk } from '@deepseek-ai/dsh-llm'
34
+
35
+ /** The request fields the board adds around a call config. */
36
+ export type OneShotRequest = Pick<GenerateOptions, 'messages' | 'signal'> & Pick<GenerateOptions, 'system'>
37
+
38
+ /**
39
+ * The call config as this board's own domain types carry it.
40
+ *
41
+ * `VerificationRoute.reasoningEffort` and the parse route are plain strings (the
42
+ * catalog publishes effort ids as strings), while the SDK brands them
43
+ * nominally; the single crossing is branded here so no caller repeats the
44
+ * assertion.
45
+ */
46
+ export interface OneShotCallConfig {
47
+ provider: string
48
+ model: string
49
+ reasoningEffort?: string
50
+ temperature?: number
51
+ maxTokens?: number
52
+ }
53
+
54
+ /** The runtime shape this dispatch looks for beyond the public interface. */
55
+ type PreparedRuntime = LlmRuntime & { prepareCall?: LlmRuntime['prepareCall'] }
56
+
57
+ /**
58
+ * Open one one-shot model stream on the registration-bound dispatch path.
59
+ *
60
+ * The call-config fields must travel inside `config` (not the request): the
61
+ * prepared handle refuses a request whose config fields differ from the ones it
62
+ * resolved, so the request carries only messages, signal, and the optional
63
+ * one-shot system prompt.
64
+ * @param llm - the Host's llm service.
65
+ * @param config - provider/model route plus the caller's sampling controls.
66
+ * @param request - the assembled messages and cancellation for this call.
67
+ * @returns the chunk stream, including any registered `llm/stream` listener.
68
+ */
69
+ export async function openOneShotStream(
70
+ llm: LlmRuntime,
71
+ config: OneShotCallConfig,
72
+ request: OneShotRequest,
73
+ ): Promise<AsyncIterable<StreamChunk>> {
74
+ const call: LlmCallConfig = {
75
+ provider: config.provider,
76
+ model: config.model,
77
+ ...(config.reasoningEffort === undefined ? {} : { reasoningEffort: ReasoningEffortId(config.reasoningEffort) }),
78
+ ...(config.temperature === undefined ? {} : { temperature: config.temperature }),
79
+ ...(config.maxTokens === undefined ? {} : { maxTokens: config.maxTokens }),
80
+ }
81
+ const runtime = llm as PreparedRuntime
82
+ if (typeof runtime.prepareCall !== 'function') {
83
+ return llm.stream({ ...call, ...request } as GenerateOptions)
84
+ }
85
+ const prepared = await runtime.prepareCall(call, request.signal)
86
+ return prepared.stream({ ...prepared.config, ...request })
87
+ }