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.
@@ -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. */
@@ -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