dsh-taskboard 0.5.4 → 0.5.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.md +7 -160
- package/lib/client.js +60 -11
- package/lib/host/execution.js.map +1 -1
- package/lib/host/session-sync.js +249 -0
- package/lib/host/session-sync.js.map +1 -0
- package/lib/index.js +9 -1
- package/lib/index.js.map +1 -1
- package/lib/shared/api.js.map +1 -1
- package/lib/shared/protocol.js +9 -1
- package/lib/shared/protocol.js.map +1 -1
- package/package.json +1 -1
- package/src/client/board/SettingsModal.tsx +45 -9
- package/src/host/execution.ts +1 -1
- package/src/host/session-sync.ts +330 -0
- package/src/index.ts +11 -1
- package/src/shared/api.ts +2 -0
- package/src/shared/protocol.ts +13 -0
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* External workspace session synchronization service.
|
|
3
|
+
*
|
|
4
|
+
* When `settings.syncExternalSessions` is enabled (0.5.4):
|
|
5
|
+
* - Listens to session lifecycle events from outside the taskboard.
|
|
6
|
+
* - On `turn/start`: automatically captures or resumes the session on the board
|
|
7
|
+
* (status: `in_progress`, claimedBy: sessionId).
|
|
8
|
+
* - On `user/message` / `session/title`: enriches/updates task title & description.
|
|
9
|
+
* - On `turn/end`: settles the execution (success -> `in_review` 待验收, failure -> `todo`).
|
|
10
|
+
*
|
|
11
|
+
* @module dsh-taskboard/host/session-sync
|
|
12
|
+
*/
|
|
13
|
+
import {
|
|
14
|
+
defaultSyncExternalSessionsOf,
|
|
15
|
+
newCommentId,
|
|
16
|
+
newExecutionId,
|
|
17
|
+
newTaskId,
|
|
18
|
+
normalizeBody,
|
|
19
|
+
normalizeTitle,
|
|
20
|
+
type TaskRecord,
|
|
21
|
+
} from '../shared/protocol.ts'
|
|
22
|
+
import type { EventsFace } from './execution.ts'
|
|
23
|
+
import type { TaskStore } from './store.ts'
|
|
24
|
+
import type { WorkspaceFace } from './tools.ts'
|
|
25
|
+
|
|
26
|
+
/** Extract text content from a user message event payload. */
|
|
27
|
+
export function extractUserMessageText(msg: unknown): string {
|
|
28
|
+
if (typeof msg !== 'object' || msg === null) return ''
|
|
29
|
+
const content = (msg as { content?: unknown }).content
|
|
30
|
+
if (typeof content === 'string') return content
|
|
31
|
+
if (Array.isArray(content)) {
|
|
32
|
+
return content
|
|
33
|
+
.map(part => {
|
|
34
|
+
if (typeof part === 'string') return part
|
|
35
|
+
if (typeof part === 'object' && part !== null && 'text' in part && typeof (part as { text: unknown }).text === 'string') {
|
|
36
|
+
return (part as { text: string }).text
|
|
37
|
+
}
|
|
38
|
+
return ''
|
|
39
|
+
})
|
|
40
|
+
.filter(Boolean)
|
|
41
|
+
.join('\n')
|
|
42
|
+
}
|
|
43
|
+
return ''
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Extract a short one-line title from prompt text. */
|
|
47
|
+
export function titleFromText(text: string): string {
|
|
48
|
+
const clean = text.trim().replace(/^#+\s*/, '')
|
|
49
|
+
const firstLine = clean.split('\n')[0]?.trim() ?? ''
|
|
50
|
+
return firstLine.slice(0, 50).trim()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Dependencies required by the external session sync service. */
|
|
54
|
+
export interface SessionSyncDeps {
|
|
55
|
+
store: TaskStore
|
|
56
|
+
workspaces: WorkspaceFace
|
|
57
|
+
events: EventsFace
|
|
58
|
+
now: () => number
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Service that synchronizes external workspace sessions into the taskboard.
|
|
63
|
+
*/
|
|
64
|
+
export class ExternalSessionSyncService {
|
|
65
|
+
private readonly unsubscribe: () => void
|
|
66
|
+
|
|
67
|
+
constructor(private readonly deps: SessionSyncDeps) {
|
|
68
|
+
this.unsubscribe = deps.events.onSessionEvent((sessionId, event, sessionMeta) => {
|
|
69
|
+
void this.handleSessionEvent(sessionId, event, sessionMeta)
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Detach listener on teardown. */
|
|
74
|
+
dispose(): void {
|
|
75
|
+
this.unsubscribe()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private async handleSessionEvent(
|
|
79
|
+
sessionId: string,
|
|
80
|
+
event: { type: string; data?: unknown },
|
|
81
|
+
sessionMeta?: { header?: { cwd?: string } },
|
|
82
|
+
): Promise<void> {
|
|
83
|
+
// 1. Ignore taskboard's internal execution sessions
|
|
84
|
+
if (sessionId.startsWith('session-taskboard-')) return
|
|
85
|
+
|
|
86
|
+
// 2. Check if external session sync is enabled in board settings
|
|
87
|
+
const snapshot = this.deps.store.snapshot()
|
|
88
|
+
if (!defaultSyncExternalSessionsOf(snapshot.settings)) return
|
|
89
|
+
|
|
90
|
+
const now = this.deps.now()
|
|
91
|
+
|
|
92
|
+
if (event.type === 'turn/start') {
|
|
93
|
+
await this.handleTurnStart(sessionId, sessionMeta?.header?.cwd, now)
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (event.type === 'user/message') {
|
|
98
|
+
await this.handleUserMessage(sessionId, event.data, now)
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (event.type === 'session/title') {
|
|
103
|
+
await this.handleSessionTitle(sessionId, event.data, now)
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (event.type === 'turn/end') {
|
|
108
|
+
await this.handleTurnEnd(sessionId, event.data, now)
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private async handleTurnStart(sessionId: string, cwd: string | undefined, now: number): Promise<void> {
|
|
114
|
+
// Resolve workspace
|
|
115
|
+
let wsId: string | undefined
|
|
116
|
+
if (cwd !== undefined && cwd.length > 0) {
|
|
117
|
+
const resolved = await this.deps.workspaces.resolveByPath(cwd)
|
|
118
|
+
wsId = resolved?.id
|
|
119
|
+
}
|
|
120
|
+
if (wsId === undefined) {
|
|
121
|
+
wsId = this.deps.workspaces.list()[0]?.id ?? 'default'
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
await this.deps.store.mutate('task-created', (ledger) => {
|
|
125
|
+
// Find existing task linked to this session
|
|
126
|
+
const existing = ledger.tasks.find(
|
|
127
|
+
t => t.claimedBy === sessionId || t.executions.some(e => e.sessionId === sessionId),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
if (existing !== undefined) {
|
|
131
|
+
if (existing.trashedAt !== undefined) return undefined
|
|
132
|
+
// If already in_progress and holding claim, ensure running execution
|
|
133
|
+
if (existing.status === 'in_progress' && existing.claimedBy === sessionId) {
|
|
134
|
+
const hasRunning = existing.executions.some(e => e.sessionId === sessionId && e.outcome === 'running')
|
|
135
|
+
if (!hasRunning) {
|
|
136
|
+
existing.executions.push({
|
|
137
|
+
id: newExecutionId(),
|
|
138
|
+
sessionId,
|
|
139
|
+
trigger: 'manual',
|
|
140
|
+
startedAt: now,
|
|
141
|
+
outcome: 'running',
|
|
142
|
+
isolation: 'none',
|
|
143
|
+
})
|
|
144
|
+
existing.updatedAt = now
|
|
145
|
+
existing.updatedBy = { kind: 'agent', sessionId }
|
|
146
|
+
return [existing]
|
|
147
|
+
}
|
|
148
|
+
return undefined
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Resumed or continued turn (e.g. from in_review or todo)
|
|
152
|
+
existing.status = 'in_progress'
|
|
153
|
+
existing.claimedBy = sessionId
|
|
154
|
+
existing.claimedAt = now
|
|
155
|
+
existing.updatedAt = now
|
|
156
|
+
existing.updatedBy = { kind: 'agent', sessionId }
|
|
157
|
+
existing.executions.push({
|
|
158
|
+
id: newExecutionId(),
|
|
159
|
+
sessionId,
|
|
160
|
+
trigger: 'manual',
|
|
161
|
+
startedAt: now,
|
|
162
|
+
outcome: 'running',
|
|
163
|
+
isolation: 'none',
|
|
164
|
+
})
|
|
165
|
+
return [existing]
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Create new task for this external session
|
|
169
|
+
const shortId = sessionId.replace(/^session-/, '').slice(0, 8)
|
|
170
|
+
const newTask: TaskRecord = {
|
|
171
|
+
id: newTaskId(),
|
|
172
|
+
title: `会话 ${shortId}`,
|
|
173
|
+
description: '',
|
|
174
|
+
prompt: '',
|
|
175
|
+
workspaceId: wsId,
|
|
176
|
+
urgency: 'normal',
|
|
177
|
+
status: 'in_progress',
|
|
178
|
+
blocked: false,
|
|
179
|
+
execution: { mode: 'claim' },
|
|
180
|
+
isolation: 'none',
|
|
181
|
+
claimedBy: sessionId,
|
|
182
|
+
claimedAt: now,
|
|
183
|
+
version: 1,
|
|
184
|
+
createdAt: now,
|
|
185
|
+
updatedAt: now,
|
|
186
|
+
createdBy: { kind: 'agent', sessionId },
|
|
187
|
+
updatedBy: { kind: 'agent', sessionId },
|
|
188
|
+
comments: [],
|
|
189
|
+
executions: [
|
|
190
|
+
{
|
|
191
|
+
id: newExecutionId(),
|
|
192
|
+
sessionId,
|
|
193
|
+
trigger: 'manual',
|
|
194
|
+
startedAt: now,
|
|
195
|
+
outcome: 'running',
|
|
196
|
+
isolation: 'none',
|
|
197
|
+
},
|
|
198
|
+
],
|
|
199
|
+
}
|
|
200
|
+
ledger.tasks.push(newTask)
|
|
201
|
+
return [newTask]
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private async handleUserMessage(sessionId: string, msgData: unknown, now: number): Promise<void> {
|
|
206
|
+
const text = extractUserMessageText(msgData)
|
|
207
|
+
if (text.trim().length === 0) return
|
|
208
|
+
|
|
209
|
+
await this.deps.store.mutate('task-updated', (ledger) => {
|
|
210
|
+
const task = ledger.tasks.find(
|
|
211
|
+
t => t.claimedBy === sessionId || t.executions.some(e => e.sessionId === sessionId),
|
|
212
|
+
)
|
|
213
|
+
if (task === undefined || task.trashedAt !== undefined) return undefined
|
|
214
|
+
|
|
215
|
+
let changed = false
|
|
216
|
+
// If title is default placeholder "会话 ...", replace with prompt summary
|
|
217
|
+
if (task.title.startsWith('会话 ') && task.title.length <= 16) {
|
|
218
|
+
const derived = titleFromText(text)
|
|
219
|
+
if (derived.length > 0) {
|
|
220
|
+
task.title = normalizeTitle(derived)
|
|
221
|
+
changed = true
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
// If description is empty, record initial prompt
|
|
225
|
+
if (task.description.length === 0) {
|
|
226
|
+
task.description = text.slice(0, 2000)
|
|
227
|
+
changed = true
|
|
228
|
+
}
|
|
229
|
+
if (changed) {
|
|
230
|
+
task.updatedAt = now
|
|
231
|
+
task.updatedBy = { kind: 'user' }
|
|
232
|
+
return [task]
|
|
233
|
+
}
|
|
234
|
+
return undefined
|
|
235
|
+
})
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private async handleSessionTitle(sessionId: string, titleData: unknown, now: number): Promise<void> {
|
|
239
|
+
const rawTitle = typeof titleData === 'object' && titleData !== null && 'title' in titleData && typeof (titleData as { title: unknown }).title === 'string'
|
|
240
|
+
? (titleData as { title: string }).title
|
|
241
|
+
: typeof titleData === 'string'
|
|
242
|
+
? titleData
|
|
243
|
+
: ''
|
|
244
|
+
if (rawTitle.trim().length === 0) return
|
|
245
|
+
|
|
246
|
+
await this.deps.store.mutate('task-updated', (ledger) => {
|
|
247
|
+
const task = ledger.tasks.find(
|
|
248
|
+
t => t.claimedBy === sessionId || t.executions.some(e => e.sessionId === sessionId),
|
|
249
|
+
)
|
|
250
|
+
if (task === undefined || task.trashedAt !== undefined) return undefined
|
|
251
|
+
task.title = normalizeTitle(rawTitle)
|
|
252
|
+
task.updatedAt = now
|
|
253
|
+
task.updatedBy = { kind: 'user' }
|
|
254
|
+
return [task]
|
|
255
|
+
})
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private async handleTurnEnd(sessionId: string, endData: unknown, now: number): Promise<void> {
|
|
259
|
+
const reason = typeof endData === 'object' && endData !== null && 'reason' in endData
|
|
260
|
+
? (endData as { reason: unknown }).reason
|
|
261
|
+
: endData
|
|
262
|
+
|
|
263
|
+
// Check if error or failure
|
|
264
|
+
let isFailure = false
|
|
265
|
+
let errorMessage = ''
|
|
266
|
+
if (typeof reason === 'object' && reason !== null) {
|
|
267
|
+
const r = reason as Record<string, unknown>
|
|
268
|
+
if (r.kind === 'error' || r.kind === 'failure') {
|
|
269
|
+
isFailure = true
|
|
270
|
+
errorMessage = typeof r.error === 'string' ? r.error : typeof r.message === 'string' ? r.message : 'turn error'
|
|
271
|
+
} else if (r.kind === 'cancel') {
|
|
272
|
+
isFailure = true
|
|
273
|
+
errorMessage = 'cancelled'
|
|
274
|
+
}
|
|
275
|
+
} else if (typeof reason === 'string' && (reason.includes('error') || reason.includes('fail'))) {
|
|
276
|
+
isFailure = true
|
|
277
|
+
errorMessage = reason
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
await this.deps.store.mutate('execution-recorded', (ledger) => {
|
|
281
|
+
const task = ledger.tasks.find(
|
|
282
|
+
t => t.claimedBy === sessionId || t.executions.some(e => e.sessionId === sessionId),
|
|
283
|
+
)
|
|
284
|
+
if (task === undefined || task.trashedAt !== undefined) return undefined
|
|
285
|
+
|
|
286
|
+
// Settle running execution
|
|
287
|
+
for (const exec of task.executions) {
|
|
288
|
+
if (exec.sessionId === sessionId && exec.outcome === 'running') {
|
|
289
|
+
exec.endedAt = now
|
|
290
|
+
if (isFailure) {
|
|
291
|
+
exec.outcome = 'failed'
|
|
292
|
+
exec.error = errorMessage.slice(0, 500)
|
|
293
|
+
} else {
|
|
294
|
+
exec.outcome = 'succeeded'
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
delete task.claimedBy
|
|
300
|
+
delete task.claimedAt
|
|
301
|
+
task.updatedAt = now
|
|
302
|
+
task.updatedBy = { kind: 'agent', sessionId }
|
|
303
|
+
|
|
304
|
+
if (isFailure) {
|
|
305
|
+
// Failed session hands back to todo with comment
|
|
306
|
+
if (task.status === 'in_progress') {
|
|
307
|
+
task.status = 'todo'
|
|
308
|
+
task.comments.push({
|
|
309
|
+
id: newCommentId(),
|
|
310
|
+
body: normalizeBody(`[系统] 会话执行异常:${errorMessage.slice(0, 300)};任务已退回待办。`),
|
|
311
|
+
version: 1,
|
|
312
|
+
createdAt: now,
|
|
313
|
+
})
|
|
314
|
+
}
|
|
315
|
+
} else {
|
|
316
|
+
// Successful settlement automatically moves to in_review (待验收)
|
|
317
|
+
if (task.status === 'in_progress') {
|
|
318
|
+
task.status = 'in_review'
|
|
319
|
+
task.comments.push({
|
|
320
|
+
id: newCommentId(),
|
|
321
|
+
body: normalizeBody('[系统] 会话执行完毕,已自动进入待验收。'),
|
|
322
|
+
version: 1,
|
|
323
|
+
createdAt: now,
|
|
324
|
+
})
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return [task]
|
|
328
|
+
})
|
|
329
|
+
}
|
|
330
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -27,6 +27,7 @@ import { SchedulerService } from './host/scheduler.ts'
|
|
|
27
27
|
import { dshHomePath } from './host/sdk.ts'
|
|
28
28
|
import { TaskStore } from './host/store.ts'
|
|
29
29
|
import { TemplateStore } from './host/templates.ts'
|
|
30
|
+
import { ExternalSessionSyncService } from './host/session-sync.ts'
|
|
30
31
|
import { registerTaskboardTools, workspaceFace } from './host/tools.ts'
|
|
31
32
|
|
|
32
33
|
/** Ledger file name under the DSH home. */
|
|
@@ -93,10 +94,19 @@ export function apply(ctx: Context): void {
|
|
|
93
94
|
// Settlement listener over the session event bus.
|
|
94
95
|
const events: EventsFace = {
|
|
95
96
|
onSessionEvent: (listener) => wsCtx.on('session/event', (session, event) => {
|
|
96
|
-
listener(session.id, event as { type: string; data?: unknown })
|
|
97
|
+
listener(session.id, event as { type: string; data?: unknown }, session as { header?: { cwd?: string } })
|
|
97
98
|
}),
|
|
98
99
|
}
|
|
99
100
|
|
|
101
|
+
// External workspace sessions sync service (0.5.4).
|
|
102
|
+
const sessionSync = new ExternalSessionSyncService({
|
|
103
|
+
store,
|
|
104
|
+
workspaces: workspaceFace(wsCtx.workspaceRegistry),
|
|
105
|
+
events,
|
|
106
|
+
now,
|
|
107
|
+
})
|
|
108
|
+
disposers.push(() => sessionSync.dispose())
|
|
109
|
+
|
|
100
110
|
// The narrow git face shared by execution (worktree isolation) and the
|
|
101
111
|
// routes (merge / remove / workspace detection).
|
|
102
112
|
const git = createGitFace()
|
package/src/shared/api.ts
CHANGED
|
@@ -158,6 +158,8 @@ export type SettingsResponse = BoardSettings
|
|
|
158
158
|
export type UpdateSettingsBody = {
|
|
159
159
|
/** Default code isolation for NEW tasks ('worktree' | 'none'). */
|
|
160
160
|
defaultIsolation?: string
|
|
161
|
+
/** Automatically capture external workspace sessions into the taskboard. */
|
|
162
|
+
syncExternalSessions?: boolean
|
|
161
163
|
}
|
|
162
164
|
|
|
163
165
|
/** Import dry-run response (0.4.0): every task classified, nothing written. */
|
package/src/shared/protocol.ts
CHANGED
|
@@ -141,6 +141,8 @@ export function effectiveIsolation(task: Pick<TaskRecord, 'isolation'>): Isolati
|
|
|
141
141
|
export type BoardSettings = {
|
|
142
142
|
/** Default code isolation applied when a NEW task is created without an explicit choice. */
|
|
143
143
|
defaultIsolation?: IsolationMode
|
|
144
|
+
/** Automatically capture external workspace sessions into the taskboard (default: false). */
|
|
145
|
+
syncExternalSessions?: boolean
|
|
144
146
|
}
|
|
145
147
|
|
|
146
148
|
/** Validate raw input into sanitized {@link BoardSettings} (unknown fields dropped). */
|
|
@@ -156,6 +158,12 @@ export function asBoardSettings(raw: unknown): BoardSettings {
|
|
|
156
158
|
}
|
|
157
159
|
out.defaultIsolation = asIsolation(e.defaultIsolation)
|
|
158
160
|
}
|
|
161
|
+
if (e.syncExternalSessions !== undefined) {
|
|
162
|
+
if (typeof e.syncExternalSessions !== 'boolean') {
|
|
163
|
+
throw new Error('syncExternalSessions must be a boolean')
|
|
164
|
+
}
|
|
165
|
+
out.syncExternalSessions = e.syncExternalSessions
|
|
166
|
+
}
|
|
159
167
|
return out
|
|
160
168
|
}
|
|
161
169
|
|
|
@@ -164,6 +172,11 @@ export function defaultIsolationOf(settings?: BoardSettings): IsolationMode {
|
|
|
164
172
|
return settings?.defaultIsolation ?? DEFAULT_ISOLATION
|
|
165
173
|
}
|
|
166
174
|
|
|
175
|
+
/** The effective external session sync switch (board setting → factory default false). */
|
|
176
|
+
export function defaultSyncExternalSessionsOf(settings?: BoardSettings): boolean {
|
|
177
|
+
return settings?.syncExternalSessions ?? false
|
|
178
|
+
}
|
|
179
|
+
|
|
167
180
|
/** How a task may run. */
|
|
168
181
|
export type ExecutionMode = 'claim' | 'scheduled'
|
|
169
182
|
|