dsh-taskboard 0.5.4 → 0.6.0
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 +27 -160
- package/lib/client.js +2564 -678
- package/lib/host/execution.js +3 -0
- package/lib/host/execution.js.map +1 -1
- package/lib/host/routes.js +31 -1
- package/lib/host/routes.js.map +1 -1
- package/lib/host/session-sync.js +449 -0
- package/lib/host/session-sync.js.map +1 -0
- package/lib/host/store.js +9 -2
- package/lib/host/store.js.map +1 -1
- package/lib/index.js +109 -2
- package/lib/index.js.map +1 -1
- package/lib/shared/api.js.map +1 -1
- package/lib/shared/protocol.js +27 -1
- package/lib/shared/protocol.js.map +1 -1
- package/package.json +75 -75
- package/src/client/api.ts +8 -0
- package/src/client/board/AlertModal.tsx +3 -1
- package/src/client/board/ImportModal.tsx +26 -24
- package/src/client/board/SettingsModal.tsx +98 -22
- package/src/client/board/SlashPromptInput.tsx +272 -0
- package/src/client/board/TaskBoard.tsx +53 -49
- package/src/client/board/TaskCard.tsx +33 -21
- package/src/client/board/TaskDetail.tsx +169 -104
- package/src/client/board/TaskFormModal.tsx +254 -202
- package/src/client/board/TemplateManager.tsx +32 -29
- package/src/client/board/labels.ts +36 -27
- package/src/client/controller.ts +62 -2
- package/src/client/i18n/en.ts +455 -0
- package/src/client/i18n/runtime.ts +155 -0
- package/src/client/i18n/zh.ts +460 -0
- package/src/client/index.ts +182 -42
- package/src/client/sidebar-entry.ts +13 -3
- package/src/client/styles.ts +131 -0
- package/src/host/execution.ts +14 -1
- package/src/host/routes.ts +49 -1
- package/src/host/session-sync.ts +650 -0
- package/src/host/store.ts +15 -1
- package/src/index.ts +125 -1
- package/src/shared/api.ts +49 -0
- package/src/shared/protocol.ts +54 -0
- package/src/shared/version.ts +1 -1
|
@@ -0,0 +1,650 @@
|
|
|
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
|
+
/**
|
|
54
|
+
* Detect whether a session represents a subagent child conversation.
|
|
55
|
+
* Subagents are created by agent delegation (e.g. invoke_subagent / subagents service)
|
|
56
|
+
* and should never be automatically converted into user tasks on the taskboard.
|
|
57
|
+
*/
|
|
58
|
+
export function isSubagentSession(
|
|
59
|
+
sessionId: string,
|
|
60
|
+
sessionMeta?: unknown,
|
|
61
|
+
event?: { type: string; data?: unknown },
|
|
62
|
+
): boolean {
|
|
63
|
+
if (typeof sessionId === 'string') {
|
|
64
|
+
if (sessionId.startsWith('subagent-') || sessionId.startsWith('child-') || sessionId.startsWith('delegate-')) {
|
|
65
|
+
return true
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (typeof sessionMeta === 'object' && sessionMeta !== null) {
|
|
70
|
+
const s = sessionMeta as {
|
|
71
|
+
header?: Record<string, unknown>
|
|
72
|
+
meta?: Record<string, unknown>
|
|
73
|
+
options?: Record<string, unknown>
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const header = s.header
|
|
77
|
+
const meta = s.meta
|
|
78
|
+
const options = s.options
|
|
79
|
+
|
|
80
|
+
// Check origin
|
|
81
|
+
if (header?.origin === 'subagent' || meta?.origin === 'subagent') return true
|
|
82
|
+
|
|
83
|
+
// Check parent session lineage
|
|
84
|
+
if (
|
|
85
|
+
header?.parentSession !== undefined
|
|
86
|
+
|| header?.parentSessionId !== undefined
|
|
87
|
+
|| meta?.parentSession !== undefined
|
|
88
|
+
|| meta?.parentSessionId !== undefined
|
|
89
|
+
) {
|
|
90
|
+
return true
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Check delegation depth
|
|
94
|
+
if (typeof header?.delegationDepth === 'number' && header.delegationDepth > 0) return true
|
|
95
|
+
if (typeof meta?.delegationDepth === 'number' && meta.delegationDepth > 0) return true
|
|
96
|
+
if (typeof options?.subagentDepth === 'number' && options.subagentDepth > 0) return true
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (event !== undefined) {
|
|
100
|
+
if (event.type === 'subagent/descriptor' || event.type === 'subagent/start' || event.type === 'subagent/end') {
|
|
101
|
+
return true
|
|
102
|
+
}
|
|
103
|
+
if (typeof event.data === 'object' && event.data !== null) {
|
|
104
|
+
const d = event.data as Record<string, unknown>
|
|
105
|
+
if (
|
|
106
|
+
d.origin === 'subagent'
|
|
107
|
+
|| d.subagent === true
|
|
108
|
+
|| typeof d.subagentId === 'string'
|
|
109
|
+
|| typeof d.parentSession === 'string'
|
|
110
|
+
) {
|
|
111
|
+
return true
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return false
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Detect whether a session represents an active working conversation.
|
|
121
|
+
* Inspects state, status, isWorking/isBusy methods, running flags, and active turns.
|
|
122
|
+
*/
|
|
123
|
+
export function isSessionActiveWorking(session: unknown): boolean {
|
|
124
|
+
if (typeof session !== 'object' || session === null) return false
|
|
125
|
+
const s = session as Record<string, unknown>
|
|
126
|
+
|
|
127
|
+
// 1. Method checks
|
|
128
|
+
if (typeof s.isWorking === 'function') {
|
|
129
|
+
try { if (Boolean((s.isWorking as () => boolean)())) return true } catch { /* ignore */ }
|
|
130
|
+
} else if (s.isWorking === true) {
|
|
131
|
+
return true
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (typeof s.isBusy === 'function') {
|
|
135
|
+
try { if (Boolean((s.isBusy as () => boolean)())) return true } catch { /* ignore */ }
|
|
136
|
+
} else if (s.busy === true || s.isBusy === true) {
|
|
137
|
+
return true
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// 2. Boolean flags
|
|
141
|
+
if (s.running === true || s.active === true || s.isGenerating === true || s.generating === true) {
|
|
142
|
+
return true
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// 3. State string
|
|
146
|
+
if (typeof s.state === 'string') {
|
|
147
|
+
const st = s.state.toLowerCase()
|
|
148
|
+
if (st === 'running' || st === 'working' || st === 'busy' || st === 'generating' || st === 'executing') {
|
|
149
|
+
return true
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 4. Status string
|
|
154
|
+
if (typeof s.status === 'string') {
|
|
155
|
+
const st = s.status.toLowerCase()
|
|
156
|
+
if (st === 'running' || st === 'working' || st === 'busy' || st === 'active' || st === 'generating' || st === 'executing') {
|
|
157
|
+
return true
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// 5. Active turn checks
|
|
162
|
+
if (s.activeTurn !== undefined && s.activeTurn !== null && s.activeTurn !== false) {
|
|
163
|
+
return true
|
|
164
|
+
}
|
|
165
|
+
if (s.currentTurn !== undefined && s.currentTurn !== null) {
|
|
166
|
+
if (typeof s.currentTurn === 'object') {
|
|
167
|
+
const ct = s.currentTurn as Record<string, unknown>
|
|
168
|
+
if (ct.status === 'running' || ct.state === 'running' || ct.outcome === 'running' || ct.endedAt === undefined) {
|
|
169
|
+
return true
|
|
170
|
+
}
|
|
171
|
+
} else {
|
|
172
|
+
return true
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// 6. Turns list
|
|
177
|
+
if (Array.isArray(s.turns) && s.turns.length > 0) {
|
|
178
|
+
const lastTurn = s.turns[s.turns.length - 1]
|
|
179
|
+
if (typeof lastTurn === 'object' && lastTurn !== null) {
|
|
180
|
+
const lt = lastTurn as Record<string, unknown>
|
|
181
|
+
if (lt.status === 'running' || lt.state === 'running' || lt.outcome === 'running' || (lt.startedAt !== undefined && lt.endedAt === undefined)) {
|
|
182
|
+
return true
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return false
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Dependencies required by the external session sync service. */
|
|
191
|
+
export interface SessionSyncDeps {
|
|
192
|
+
store: TaskStore
|
|
193
|
+
workspaces: WorkspaceFace
|
|
194
|
+
events: EventsFace
|
|
195
|
+
sessions?: {
|
|
196
|
+
get?: (id: string) => unknown
|
|
197
|
+
list?: () => unknown[]
|
|
198
|
+
}
|
|
199
|
+
now: () => number
|
|
200
|
+
scanIntervalMs?: number
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Default scan interval: 4s. */
|
|
204
|
+
export const DEFAULT_SCAN_INTERVAL_MS = 4000
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Service that synchronizes external workspace sessions into the taskboard.
|
|
208
|
+
*/
|
|
209
|
+
export class ExternalSessionSyncService {
|
|
210
|
+
private readonly unsubscribe: () => void
|
|
211
|
+
private readonly ignoredSessions = new Set<string>()
|
|
212
|
+
private scanTimer?: NodeJS.Timeout | number
|
|
213
|
+
|
|
214
|
+
constructor(private readonly deps: SessionSyncDeps) {
|
|
215
|
+
this.unsubscribe = deps.events.onSessionEvent((sessionId, event, sessionMeta) => {
|
|
216
|
+
void this.handleSessionEvent(sessionId, event, sessionMeta)
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
const interval = deps.scanIntervalMs ?? DEFAULT_SCAN_INTERVAL_MS
|
|
220
|
+
if (interval > 0) {
|
|
221
|
+
this.scanTimer = setInterval(() => {
|
|
222
|
+
void this.scanActiveSessions()
|
|
223
|
+
}, interval)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Detach listener and clear scanner on teardown. */
|
|
228
|
+
dispose(): void {
|
|
229
|
+
this.unsubscribe()
|
|
230
|
+
if (this.scanTimer !== undefined) {
|
|
231
|
+
clearInterval(this.scanTimer as NodeJS.Timeout)
|
|
232
|
+
this.scanTimer = undefined
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Periodic active scan: checks whether external sessions linked to board tasks
|
|
238
|
+
* are actively working, ensuring tasks in `in_review` / `todo` / `backlog`
|
|
239
|
+
* automatically pull back to `in_progress`.
|
|
240
|
+
*/
|
|
241
|
+
async scanActiveSessions(): Promise<void> {
|
|
242
|
+
const snapshot = this.deps.store.snapshot()
|
|
243
|
+
if (!defaultSyncExternalSessionsOf(snapshot.settings)) return
|
|
244
|
+
if (this.deps.sessions === undefined) return
|
|
245
|
+
|
|
246
|
+
const now = this.deps.now()
|
|
247
|
+
const tasks = snapshot.tasks.filter(t => t.trashedAt === undefined)
|
|
248
|
+
|
|
249
|
+
for (const task of tasks) {
|
|
250
|
+
const sessionId = task.claimedBy ?? task.executions[task.executions.length - 1]?.sessionId
|
|
251
|
+
if (sessionId === undefined || typeof sessionId !== 'string') continue
|
|
252
|
+
if (this.ignoredSessions.has(sessionId) || sessionId.startsWith('session-taskboard-')) continue
|
|
253
|
+
|
|
254
|
+
let session: unknown
|
|
255
|
+
try {
|
|
256
|
+
session = this.deps.sessions.get?.(sessionId)
|
|
257
|
+
} catch { /* ignore */ }
|
|
258
|
+
|
|
259
|
+
if (session === undefined && typeof this.deps.sessions.list === 'function') {
|
|
260
|
+
try {
|
|
261
|
+
const list = this.deps.sessions.list()
|
|
262
|
+
session = list?.find(s => (s as { id?: string })?.id === sessionId)
|
|
263
|
+
} catch { /* ignore */ }
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (session === undefined || session === null) continue
|
|
267
|
+
if (isSubagentSession(sessionId, session)) {
|
|
268
|
+
this.ignoredSessions.add(sessionId)
|
|
269
|
+
continue
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const isWorking = isSessionActiveWorking(session)
|
|
273
|
+
if (isWorking) {
|
|
274
|
+
if (task.status !== 'in_progress') {
|
|
275
|
+
await this.deps.store.mutate('task-updated', (ledger) => {
|
|
276
|
+
const current = ledger.tasks.find(t => t.id === task.id)
|
|
277
|
+
if (current === undefined || current.trashedAt !== undefined) return undefined
|
|
278
|
+
current.status = 'in_progress'
|
|
279
|
+
current.claimedBy = sessionId
|
|
280
|
+
current.claimedAt = current.claimedAt ?? now
|
|
281
|
+
current.updatedAt = now
|
|
282
|
+
current.updatedBy = { kind: 'agent', sessionId }
|
|
283
|
+
const hasRunning = current.executions.some(e => e.sessionId === sessionId && e.outcome === 'running')
|
|
284
|
+
if (!hasRunning) {
|
|
285
|
+
current.executions.push({
|
|
286
|
+
id: newExecutionId(),
|
|
287
|
+
sessionId,
|
|
288
|
+
trigger: 'manual',
|
|
289
|
+
startedAt: now,
|
|
290
|
+
outcome: 'running',
|
|
291
|
+
isolation: 'none',
|
|
292
|
+
})
|
|
293
|
+
}
|
|
294
|
+
return [current]
|
|
295
|
+
})
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private async handleSessionEvent(
|
|
302
|
+
sessionId: string,
|
|
303
|
+
event: { type: string; data?: unknown },
|
|
304
|
+
sessionMeta?: {
|
|
305
|
+
header?: { cwd?: string; origin?: string; parentSession?: string; parentSessionId?: string; delegationDepth?: number }
|
|
306
|
+
meta?: { origin?: string; parentSession?: string; delegationDepth?: number }
|
|
307
|
+
options?: { subagentDepth?: number }
|
|
308
|
+
},
|
|
309
|
+
): Promise<void> {
|
|
310
|
+
// 1. Check if already ignored
|
|
311
|
+
if (this.ignoredSessions.has(sessionId)) return
|
|
312
|
+
|
|
313
|
+
// 2. Ignore taskboard's internal execution sessions
|
|
314
|
+
if (sessionId.startsWith('session-taskboard-')) {
|
|
315
|
+
this.ignoredSessions.add(sessionId)
|
|
316
|
+
return
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// 3. Ignore subagent sessions (delegated children)
|
|
320
|
+
if (isSubagentSession(sessionId, sessionMeta, event)) {
|
|
321
|
+
this.ignoredSessions.add(sessionId)
|
|
322
|
+
// If a task was previously created for this subagent before detection, clean it up
|
|
323
|
+
await this.deps.store.mutate('task-deleted', (ledger) => {
|
|
324
|
+
const idx = ledger.tasks.findIndex(
|
|
325
|
+
t => t.claimedBy === sessionId && t.createdBy.kind === 'agent' && t.createdBy.sessionId === sessionId,
|
|
326
|
+
)
|
|
327
|
+
if (idx >= 0) {
|
|
328
|
+
ledger.tasks.splice(idx, 1)
|
|
329
|
+
return []
|
|
330
|
+
}
|
|
331
|
+
return undefined
|
|
332
|
+
})
|
|
333
|
+
return
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// 4. Check if external session sync is enabled in board settings
|
|
337
|
+
const snapshot = this.deps.store.snapshot()
|
|
338
|
+
if (!defaultSyncExternalSessionsOf(snapshot.settings)) return
|
|
339
|
+
|
|
340
|
+
const now = this.deps.now()
|
|
341
|
+
|
|
342
|
+
if (event.type === 'turn/start') {
|
|
343
|
+
await this.handleTurnStart(sessionId, sessionMeta?.header?.cwd, now)
|
|
344
|
+
return
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (event.type === 'user/message') {
|
|
348
|
+
await this.handleUserMessage(sessionId, event.data, now)
|
|
349
|
+
return
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (
|
|
353
|
+
event.type === 'turn/step'
|
|
354
|
+
|| event.type === 'turn/progress'
|
|
355
|
+
|| event.type === 'agent/step'
|
|
356
|
+
|| event.type === 'agent/thought'
|
|
357
|
+
|| event.type === 'agent/turn/start'
|
|
358
|
+
) {
|
|
359
|
+
await this.ensureSessionInProgress(sessionId, now)
|
|
360
|
+
return
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (event.type === 'session/title') {
|
|
364
|
+
await this.handleSessionTitle(sessionId, event.data, now)
|
|
365
|
+
return
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (event.type === 'turn/end') {
|
|
369
|
+
await this.handleTurnEnd(sessionId, event.data, now)
|
|
370
|
+
return
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
private async ensureSessionInProgress(sessionId: string, now: number): Promise<void> {
|
|
375
|
+
await this.deps.store.mutate('task-updated', (ledger) => {
|
|
376
|
+
const task = ledger.tasks.find(
|
|
377
|
+
t => t.claimedBy === sessionId || t.executions.some(e => e.sessionId === sessionId),
|
|
378
|
+
)
|
|
379
|
+
if (task === undefined || task.trashedAt !== undefined) return undefined
|
|
380
|
+
|
|
381
|
+
let changed = false
|
|
382
|
+
if (task.status !== 'in_progress') {
|
|
383
|
+
task.status = 'in_progress'
|
|
384
|
+
task.claimedBy = sessionId
|
|
385
|
+
task.claimedAt = task.claimedAt ?? now
|
|
386
|
+
changed = true
|
|
387
|
+
}
|
|
388
|
+
const hasRunning = task.executions.some(e => e.sessionId === sessionId && e.outcome === 'running')
|
|
389
|
+
if (!hasRunning) {
|
|
390
|
+
task.executions.push({
|
|
391
|
+
id: newExecutionId(),
|
|
392
|
+
sessionId,
|
|
393
|
+
trigger: 'manual',
|
|
394
|
+
startedAt: now,
|
|
395
|
+
outcome: 'running',
|
|
396
|
+
isolation: 'none',
|
|
397
|
+
})
|
|
398
|
+
changed = true
|
|
399
|
+
}
|
|
400
|
+
if (changed) {
|
|
401
|
+
task.updatedAt = now
|
|
402
|
+
task.updatedBy = { kind: 'agent', sessionId }
|
|
403
|
+
return [task]
|
|
404
|
+
}
|
|
405
|
+
return undefined
|
|
406
|
+
})
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
private async handleTurnStart(sessionId: string, cwd: string | undefined, now: number): Promise<void> {
|
|
410
|
+
// Resolve workspace
|
|
411
|
+
let wsId: string | undefined
|
|
412
|
+
if (cwd !== undefined && cwd.length > 0) {
|
|
413
|
+
const resolved = await this.deps.workspaces.resolveByPath(cwd)
|
|
414
|
+
wsId = resolved?.id
|
|
415
|
+
}
|
|
416
|
+
if (wsId === undefined) {
|
|
417
|
+
wsId = this.deps.workspaces.list()[0]?.id ?? 'default'
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
await this.deps.store.mutate('task-created', (ledger) => {
|
|
421
|
+
// Find existing task linked to this session
|
|
422
|
+
const existing = ledger.tasks.find(
|
|
423
|
+
t => t.claimedBy === sessionId || t.executions.some(e => e.sessionId === sessionId),
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
if (existing !== undefined) {
|
|
427
|
+
if (existing.trashedAt !== undefined) return undefined
|
|
428
|
+
// If already in_progress and holding claim, ensure running execution
|
|
429
|
+
if (existing.status === 'in_progress' && existing.claimedBy === sessionId) {
|
|
430
|
+
const hasRunning = existing.executions.some(e => e.sessionId === sessionId && e.outcome === 'running')
|
|
431
|
+
if (!hasRunning) {
|
|
432
|
+
existing.executions.push({
|
|
433
|
+
id: newExecutionId(),
|
|
434
|
+
sessionId,
|
|
435
|
+
trigger: 'manual',
|
|
436
|
+
startedAt: now,
|
|
437
|
+
outcome: 'running',
|
|
438
|
+
isolation: 'none',
|
|
439
|
+
})
|
|
440
|
+
existing.updatedAt = now
|
|
441
|
+
existing.updatedBy = { kind: 'agent', sessionId }
|
|
442
|
+
return [existing]
|
|
443
|
+
}
|
|
444
|
+
return undefined
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Resumed or continued turn (e.g. from in_review or todo)
|
|
448
|
+
existing.status = 'in_progress'
|
|
449
|
+
existing.claimedBy = sessionId
|
|
450
|
+
existing.claimedAt = now
|
|
451
|
+
existing.updatedAt = now
|
|
452
|
+
existing.updatedBy = { kind: 'agent', sessionId }
|
|
453
|
+
existing.executions.push({
|
|
454
|
+
id: newExecutionId(),
|
|
455
|
+
sessionId,
|
|
456
|
+
trigger: 'manual',
|
|
457
|
+
startedAt: now,
|
|
458
|
+
outcome: 'running',
|
|
459
|
+
isolation: 'none',
|
|
460
|
+
})
|
|
461
|
+
return [existing]
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Create new task for this external session
|
|
465
|
+
const shortId = sessionId.replace(/^session-/, '').slice(0, 8)
|
|
466
|
+
const newTask: TaskRecord = {
|
|
467
|
+
id: newTaskId(),
|
|
468
|
+
title: `会话 ${shortId}`,
|
|
469
|
+
description: '',
|
|
470
|
+
prompt: '',
|
|
471
|
+
workspaceId: wsId,
|
|
472
|
+
urgency: 'normal',
|
|
473
|
+
status: 'in_progress',
|
|
474
|
+
blocked: false,
|
|
475
|
+
execution: { mode: 'claim' },
|
|
476
|
+
isolation: 'none',
|
|
477
|
+
claimedBy: sessionId,
|
|
478
|
+
claimedAt: now,
|
|
479
|
+
version: 1,
|
|
480
|
+
createdAt: now,
|
|
481
|
+
updatedAt: now,
|
|
482
|
+
createdBy: { kind: 'agent', sessionId },
|
|
483
|
+
updatedBy: { kind: 'agent', sessionId },
|
|
484
|
+
comments: [],
|
|
485
|
+
executions: [
|
|
486
|
+
{
|
|
487
|
+
id: newExecutionId(),
|
|
488
|
+
sessionId,
|
|
489
|
+
trigger: 'manual',
|
|
490
|
+
startedAt: now,
|
|
491
|
+
outcome: 'running',
|
|
492
|
+
isolation: 'none',
|
|
493
|
+
},
|
|
494
|
+
],
|
|
495
|
+
}
|
|
496
|
+
ledger.tasks.push(newTask)
|
|
497
|
+
return [newTask]
|
|
498
|
+
})
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
private async handleUserMessage(sessionId: string, msgData: unknown, now: number): Promise<void> {
|
|
502
|
+
const text = extractUserMessageText(msgData)
|
|
503
|
+
|
|
504
|
+
await this.deps.store.mutate('task-updated', (ledger) => {
|
|
505
|
+
const task = ledger.tasks.find(
|
|
506
|
+
t => t.claimedBy === sessionId || t.executions.some(e => e.sessionId === sessionId),
|
|
507
|
+
)
|
|
508
|
+
if (task === undefined || task.trashedAt !== undefined) return undefined
|
|
509
|
+
|
|
510
|
+
let changed = false
|
|
511
|
+
if (text.trim().length > 0) {
|
|
512
|
+
// If title is default placeholder "会话 ...", replace with prompt summary
|
|
513
|
+
if (task.title.startsWith('会话 ') && task.title.length <= 16) {
|
|
514
|
+
const derived = titleFromText(text)
|
|
515
|
+
if (derived.length > 0) {
|
|
516
|
+
task.title = normalizeTitle(derived)
|
|
517
|
+
changed = true
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
// If description is empty, record initial prompt
|
|
521
|
+
if (task.description.length === 0) {
|
|
522
|
+
task.description = text.slice(0, 2000)
|
|
523
|
+
changed = true
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// If task was in in_review, todo, or backlog, user message resumes work -> move to in_progress
|
|
528
|
+
if (task.status !== 'in_progress') {
|
|
529
|
+
task.status = 'in_progress'
|
|
530
|
+
task.claimedBy = sessionId
|
|
531
|
+
task.claimedAt = now
|
|
532
|
+
changed = true
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Ensure running execution exists
|
|
536
|
+
const hasRunning = task.executions.some(e => e.sessionId === sessionId && e.outcome === 'running')
|
|
537
|
+
if (!hasRunning) {
|
|
538
|
+
task.executions.push({
|
|
539
|
+
id: newExecutionId(),
|
|
540
|
+
sessionId,
|
|
541
|
+
trigger: 'manual',
|
|
542
|
+
startedAt: now,
|
|
543
|
+
outcome: 'running',
|
|
544
|
+
isolation: 'none',
|
|
545
|
+
})
|
|
546
|
+
changed = true
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if (changed) {
|
|
550
|
+
task.updatedAt = now
|
|
551
|
+
task.updatedBy = { kind: 'user' }
|
|
552
|
+
return [task]
|
|
553
|
+
}
|
|
554
|
+
return undefined
|
|
555
|
+
})
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
private async handleSessionTitle(sessionId: string, titleData: unknown, now: number): Promise<void> {
|
|
559
|
+
const rawTitle = typeof titleData === 'object' && titleData !== null && 'title' in titleData && typeof (titleData as { title: unknown }).title === 'string'
|
|
560
|
+
? (titleData as { title: string }).title
|
|
561
|
+
: typeof titleData === 'string'
|
|
562
|
+
? titleData
|
|
563
|
+
: ''
|
|
564
|
+
if (rawTitle.trim().length === 0) return
|
|
565
|
+
|
|
566
|
+
await this.deps.store.mutate('task-updated', (ledger) => {
|
|
567
|
+
const task = ledger.tasks.find(
|
|
568
|
+
t => t.claimedBy === sessionId || t.executions.some(e => e.sessionId === sessionId),
|
|
569
|
+
)
|
|
570
|
+
if (task === undefined || task.trashedAt !== undefined) return undefined
|
|
571
|
+
task.title = normalizeTitle(rawTitle)
|
|
572
|
+
task.updatedAt = now
|
|
573
|
+
task.updatedBy = { kind: 'user' }
|
|
574
|
+
return [task]
|
|
575
|
+
})
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
private async handleTurnEnd(sessionId: string, endData: unknown, now: number): Promise<void> {
|
|
579
|
+
const reason = typeof endData === 'object' && endData !== null && 'reason' in endData
|
|
580
|
+
? (endData as { reason: unknown }).reason
|
|
581
|
+
: endData
|
|
582
|
+
|
|
583
|
+
// Check if error or failure
|
|
584
|
+
let isFailure = false
|
|
585
|
+
let errorMessage = ''
|
|
586
|
+
if (typeof reason === 'object' && reason !== null) {
|
|
587
|
+
const r = reason as Record<string, unknown>
|
|
588
|
+
if (r.kind === 'error' || r.kind === 'failure') {
|
|
589
|
+
isFailure = true
|
|
590
|
+
errorMessage = typeof r.error === 'string' ? r.error : typeof r.message === 'string' ? r.message : 'turn error'
|
|
591
|
+
} else if (r.kind === 'cancel') {
|
|
592
|
+
isFailure = true
|
|
593
|
+
errorMessage = 'cancelled'
|
|
594
|
+
}
|
|
595
|
+
} else if (typeof reason === 'string' && (reason.includes('error') || reason.includes('fail'))) {
|
|
596
|
+
isFailure = true
|
|
597
|
+
errorMessage = reason
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
await this.deps.store.mutate('execution-recorded', (ledger) => {
|
|
601
|
+
const task = ledger.tasks.find(
|
|
602
|
+
t => t.claimedBy === sessionId || t.executions.some(e => e.sessionId === sessionId),
|
|
603
|
+
)
|
|
604
|
+
if (task === undefined || task.trashedAt !== undefined) return undefined
|
|
605
|
+
|
|
606
|
+
// Settle running execution
|
|
607
|
+
for (const exec of task.executions) {
|
|
608
|
+
if (exec.sessionId === sessionId && exec.outcome === 'running') {
|
|
609
|
+
exec.endedAt = now
|
|
610
|
+
if (isFailure) {
|
|
611
|
+
exec.outcome = 'failed'
|
|
612
|
+
exec.error = errorMessage.slice(0, 500)
|
|
613
|
+
} else {
|
|
614
|
+
exec.outcome = 'succeeded'
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
delete task.claimedBy
|
|
620
|
+
delete task.claimedAt
|
|
621
|
+
task.updatedAt = now
|
|
622
|
+
task.updatedBy = { kind: 'agent', sessionId }
|
|
623
|
+
|
|
624
|
+
if (isFailure) {
|
|
625
|
+
// Failed session hands back to todo with comment
|
|
626
|
+
if (task.status === 'in_progress') {
|
|
627
|
+
task.status = 'todo'
|
|
628
|
+
task.comments.push({
|
|
629
|
+
id: newCommentId(),
|
|
630
|
+
body: normalizeBody(`[系统] 会话执行异常:${errorMessage.slice(0, 300)};任务已退回待办。`),
|
|
631
|
+
version: 1,
|
|
632
|
+
createdAt: now,
|
|
633
|
+
})
|
|
634
|
+
}
|
|
635
|
+
} else {
|
|
636
|
+
// Successful settlement automatically moves to in_review (待验收)
|
|
637
|
+
if (task.status === 'in_progress') {
|
|
638
|
+
task.status = 'in_review'
|
|
639
|
+
task.comments.push({
|
|
640
|
+
id: newCommentId(),
|
|
641
|
+
body: normalizeBody('[系统] 会话执行完毕,已自动进入待验收。'),
|
|
642
|
+
version: 1,
|
|
643
|
+
createdAt: now,
|
|
644
|
+
})
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
return [task]
|
|
648
|
+
})
|
|
649
|
+
}
|
|
650
|
+
}
|
package/src/host/store.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { mkdir, open, readFile, rename } from 'node:fs/promises'
|
|
|
10
10
|
import { dirname, join } from 'node:path'
|
|
11
11
|
import {
|
|
12
12
|
LEDGER_SCHEMA_VERSION,
|
|
13
|
+
asBoardSettings,
|
|
13
14
|
emptyLedger,
|
|
14
15
|
isPlausibleTaskRecord,
|
|
15
16
|
pruneExecutions,
|
|
@@ -81,7 +82,20 @@ export class TaskStore {
|
|
|
81
82
|
task.claimedAt = task.updatedAt
|
|
82
83
|
}
|
|
83
84
|
}
|
|
84
|
-
|
|
85
|
+
let settings = undefined
|
|
86
|
+
if (parsed.settings !== undefined) {
|
|
87
|
+
try {
|
|
88
|
+
settings = asBoardSettings(parsed.settings)
|
|
89
|
+
} catch {
|
|
90
|
+
console.warn('[dsh-taskboard] dropping invalid board settings on load')
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
this.ledger = {
|
|
94
|
+
schemaVersion: LEDGER_SCHEMA_VERSION,
|
|
95
|
+
revision: parsed.revision,
|
|
96
|
+
tasks,
|
|
97
|
+
...(settings !== undefined ? { settings } : {}),
|
|
98
|
+
}
|
|
85
99
|
}
|
|
86
100
|
} catch (error) {
|
|
87
101
|
const code = (error as NodeJS.ErrnoException).code
|