@linxin666/dsh-client-ui-task-board 0.4.4 → 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.
- package/README.i18n.yaml +2 -2
- package/README.md +42 -11
- package/README.zh.md +42 -11
- package/lib/client.js +4958 -472
- package/lib/client.js.map +1 -1
- package/lib/index.js +2841 -121
- package/lib/types/client/PluginSettingsCard.d.ts +7 -0
- package/lib/types/client/TaskBoardSettingsCard.d.ts +19 -3
- package/lib/types/client/board/ClampedMarkdown.d.ts +3 -0
- package/lib/types/client/board/ConfirmDialog.d.ts +4 -1
- package/lib/types/client/board/EditTaskModal.d.ts +5 -2
- package/lib/types/client/board/LinkSubtaskModal.d.ts +5 -2
- package/lib/types/client/board/NewTaskModal.d.ts +4 -1
- package/lib/types/client/board/TagManagerModal.d.ts +26 -0
- package/lib/types/client/board/TaskBoard.d.ts +10 -1
- package/lib/types/client/board/TaskCard.d.ts +14 -1
- package/lib/types/client/board/TaskDetail.d.ts +3 -1
- package/lib/types/client/board/TaskForm.d.ts +4 -1
- package/lib/types/client/board/VerificationReport.d.ts +23 -0
- package/lib/types/client/board/card-view.d.ts +113 -0
- package/lib/types/client/board/icons.d.ts +32 -0
- package/lib/types/client/board/overlay.d.ts +69 -0
- package/lib/types/client/board/status-key.d.ts +15 -0
- package/lib/types/client/board/task-markdown.d.ts +19 -0
- package/lib/types/client/board-card-seat.d.ts +34 -0
- package/lib/types/client/index.d.ts +19 -0
- package/lib/types/client/locales.d.ts +103 -1
- package/lib/types/client/native-panel.d.ts +6 -20
- package/lib/types/client/seats.d.ts +18 -0
- package/lib/types/client/service.d.ts +32 -0
- package/lib/types/client/settings-form.d.ts +16 -0
- package/lib/types/core/controller.d.ts +37 -1
- package/lib/types/core/extension.d.ts +283 -0
- package/lib/types/core/handover.d.ts +13 -1
- package/lib/types/core/issue-form.d.ts +16 -0
- package/lib/types/core/tasks.d.ts +84 -3
- package/lib/types/core/use-cases/task-tag.d.ts +48 -0
- package/lib/types/core/use-cases/task-update.d.ts +5 -3
- package/lib/types/core/verification.d.ts +386 -0
- package/lib/types/core/workspace-target.d.ts +41 -0
- package/lib/types/host/agent-tools.d.ts +1 -1
- package/lib/types/host/extension-registry.d.ts +111 -0
- package/lib/types/host/llm-dispatch.d.ts +63 -0
- package/lib/types/host/verification-gate.d.ts +71 -0
- package/lib/types/host/verification-runner.d.ts +108 -0
- package/lib/types/host/workspace-evidence.d.ts +53 -0
- package/lib/types/host-ledger.d.ts +52 -8
- package/lib/types/host-runner.d.ts +23 -1
- package/lib/types/host-service.d.ts +88 -2
- package/lib/types/index.d.ts +80 -26
- package/lib/types/protocol.d.ts +49 -5
- package/lib/types/tool-surface.d.ts +71 -0
- package/package.json +27 -26
- package/src/client/PluginSettingsCard.tsx +9 -0
- package/src/client/TaskBoardSettingsCard.tsx +304 -65
- package/src/client/board/ClampedMarkdown.tsx +69 -0
- package/src/client/board/ConfirmDialog.tsx +7 -3
- package/src/client/board/EditTaskModal.tsx +5 -2
- package/src/client/board/LinkSubtaskModal.tsx +12 -7
- package/src/client/board/NewTaskModal.tsx +17 -1
- package/src/client/board/TagManagerModal.tsx +235 -0
- package/src/client/board/TaskBoard.tsx +259 -103
- package/src/client/board/TaskCard.tsx +214 -81
- package/src/client/board/TaskDetail.tsx +71 -26
- package/src/client/board/TaskForm.tsx +13 -3
- package/src/client/board/VerificationReport.tsx +203 -0
- package/src/client/board/card-view.ts +220 -0
- package/src/client/board/icons.tsx +89 -0
- package/src/client/board/overlay.tsx +189 -0
- package/src/client/board/status-key.ts +27 -0
- package/src/client/board/task-markdown.module.css +137 -0
- package/src/client/board/task-markdown.tsx +106 -0
- package/src/client/board-card-seat.ts +102 -0
- package/src/client/board-settings.module.css +66 -0
- package/src/client/board.module.css +488 -63
- package/src/client/index.ts +43 -5
- package/src/client/locales.ts +208 -4
- package/src/client/native-panel.tsx +18 -2
- package/src/client/seats.tsx +58 -0
- package/src/client/service.ts +94 -0
- package/src/client/settings-card.module.css +31 -5
- package/src/client/settings-form.ts +27 -1
- package/src/core/controller.ts +94 -3
- package/src/core/extension.ts +376 -0
- package/src/core/freeze-snapshot.ts +2 -2
- package/src/core/handover.ts +16 -1
- package/src/core/issue-form.ts +19 -0
- package/src/core/store.ts +16 -0
- package/src/core/tasks.ts +100 -6
- package/src/core/use-cases/task-parent.ts +3 -2
- package/src/core/use-cases/task-tag.ts +110 -0
- package/src/core/use-cases/task-update.ts +10 -4
- package/src/core/verification.ts +826 -0
- package/src/core/workspace-target.ts +59 -0
- package/src/host/agent-tools.ts +20 -18
- package/src/host/extension-registry.ts +509 -0
- package/src/host/llm-dispatch.ts +87 -0
- package/src/host/verification-gate.ts +331 -0
- package/src/host/verification-runner.ts +535 -0
- package/src/host/workspace-evidence.ts +176 -0
- package/src/host-ai.ts +15 -9
- package/src/host-ledger.ts +143 -32
- package/src/host-routes.ts +15 -2
- package/src/host-runner.ts +60 -11
- package/src/host-service.ts +253 -10
- package/src/index.ts +204 -16
- package/src/protocol.ts +102 -8
- package/src/tool-surface.ts +95 -0
|
@@ -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
|
+
}
|