@linxin666/dsh-client-ui-task-board 0.1.1
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/LICENSE +29 -0
- package/README.md +211 -0
- package/cordis.patch.yml +12 -0
- package/lib/client.js +2592 -0
- package/lib/index.js +54 -0
- package/lib/invariant.js +6 -0
- package/lib/types/client/PluginSettingsCard.d.ts +77 -0
- package/lib/types/client/TaskBoardSettingsCard.d.ts +51 -0
- package/lib/types/client/board/ConfirmDialog.d.ts +12 -0
- package/lib/types/client/board/NewTaskModal.d.ts +6 -0
- package/lib/types/client/board/TaskBoard.d.ts +5 -0
- package/lib/types/client/board/TaskCard.d.ts +12 -0
- package/lib/types/client/board/TaskDetail.d.ts +7 -0
- package/lib/types/client/board-mount.d.ts +10 -0
- package/lib/types/client/index.d.ts +43 -0
- package/lib/types/client/locales.d.ts +105 -0
- package/lib/types/client/settings-form.d.ts +118 -0
- package/lib/types/client/sidebar-entry.d.ts +25 -0
- package/lib/types/core/controller.d.ts +129 -0
- package/lib/types/core/execution.d.ts +136 -0
- package/lib/types/core/schedule.d.ts +40 -0
- package/lib/types/core/scheduler.d.ts +49 -0
- package/lib/types/core/store.d.ts +36 -0
- package/lib/types/core/tasks.d.ts +111 -0
- package/lib/types/index.d.ts +44 -0
- package/lib/types/invariant.d.ts +3 -0
- package/package.json +85 -0
- package/src/client/PluginSettingsCard.tsx +207 -0
- package/src/client/TaskBoardSettingsCard.tsx +124 -0
- package/src/client/board/ConfirmDialog.tsx +40 -0
- package/src/client/board/NewTaskModal.tsx +82 -0
- package/src/client/board/TaskBoard.tsx +107 -0
- package/src/client/board/TaskCard.tsx +62 -0
- package/src/client/board/TaskDetail.tsx +286 -0
- package/src/client/board-mount.tsx +74 -0
- package/src/client/board.module.css +849 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/index.ts +167 -0
- package/src/client/locales.ts +211 -0
- package/src/client/settings-card.module.css +261 -0
- package/src/client/settings-form.ts +294 -0
- package/src/client/sidebar-entry.ts +118 -0
- package/src/core/controller.ts +379 -0
- package/src/core/execution.ts +259 -0
- package/src/core/schedule.ts +140 -0
- package/src/core/scheduler.ts +104 -0
- package/src/core/store.ts +183 -0
- package/src/core/tasks.ts +204 -0
- package/src/index.ts +94 -0
- package/src/invariant.ts +4 -0
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Board controller: the single owner of task-ledger state and view state.
|
|
3
|
+
*
|
|
4
|
+
* It keeps the ledger in memory, persists every mutation through the
|
|
5
|
+
* {@link TaskStore}, drives real executions through the
|
|
6
|
+
* {@link ExecutionService}, and closes the board view whenever the user
|
|
7
|
+
* navigates to a session (the sessions-list `current` selection changes).
|
|
8
|
+
* Framework-free (structural runtime faces) so the whole orchestration is
|
|
9
|
+
* unit-testable with fakes.
|
|
10
|
+
*/
|
|
11
|
+
import { ExecutionService, type ExecutionEvent } from './execution.ts'
|
|
12
|
+
import { isValidCron, nextRunAtMs } from './schedule.ts'
|
|
13
|
+
import type { TaskStore } from './store.ts'
|
|
14
|
+
import {
|
|
15
|
+
createTask, settleExecution, startExecution, withSchedule, withStatus,
|
|
16
|
+
type NewTaskInput, type TaskRecord, type TaskStatus,
|
|
17
|
+
} from './tasks.ts'
|
|
18
|
+
|
|
19
|
+
/** The sessions face the controller needs for navigation awareness. */
|
|
20
|
+
export interface SessionsControllerFace {
|
|
21
|
+
list: {
|
|
22
|
+
getSnapshot(): { current: string | undefined }
|
|
23
|
+
subscribe(fn: () => void): () => void
|
|
24
|
+
}
|
|
25
|
+
/** Select a session as current (navigates the conversation view). */
|
|
26
|
+
open(id: string): void
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Controller dependencies (all swappable in tests). */
|
|
30
|
+
export interface ControllerDeps {
|
|
31
|
+
store: TaskStore
|
|
32
|
+
exec: ExecutionService
|
|
33
|
+
sessions: SessionsControllerFace
|
|
34
|
+
/** Clock; defaults to Date.now. */
|
|
35
|
+
now?: () => number
|
|
36
|
+
/** Id minting; defaults to a random-uuid. */
|
|
37
|
+
uuid?: () => string
|
|
38
|
+
/** Debounce (ms) for session-list-changed reconciles; defaults to 350. */
|
|
39
|
+
reconcileDebounceMs?: number
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Immutable controller snapshot for UI subscriptions. */
|
|
43
|
+
export interface ControllerSnapshot {
|
|
44
|
+
tasks: readonly TaskRecord[]
|
|
45
|
+
boardOpen: boolean
|
|
46
|
+
selectedTaskId: string | undefined
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The selected task (resolved from the ledger), or undefined. */
|
|
50
|
+
export function selectedTaskOf(snapshot: ControllerSnapshot): TaskRecord | undefined {
|
|
51
|
+
if (snapshot.selectedTaskId === undefined) return undefined
|
|
52
|
+
return snapshot.tasks.find(task => task.id === snapshot.selectedTaskId)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function randomUuid(): string {
|
|
56
|
+
const bytes = globalThis.crypto?.getRandomValues(new Uint8Array(16))
|
|
57
|
+
if (bytes === undefined) {
|
|
58
|
+
// Non-secure fallback (tests, odd environments).
|
|
59
|
+
return `t-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
|
|
60
|
+
}
|
|
61
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x40
|
|
62
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80
|
|
63
|
+
const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('')
|
|
64
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Read the current selection off a session-list snapshot (structural). */
|
|
68
|
+
function currentOf(sessions: SessionsControllerFace): string | undefined {
|
|
69
|
+
return sessions.list.getSnapshot().current
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Board controller (see module doc). All mutations bump the snapshot and
|
|
74
|
+
* persist through the store; UI and DOM mounts subscribe and re-render.
|
|
75
|
+
*/
|
|
76
|
+
export class BoardController {
|
|
77
|
+
private tasks: TaskRecord[] = []
|
|
78
|
+
private boardOpen = false
|
|
79
|
+
private selectedTaskId: string | undefined
|
|
80
|
+
private listeners = new Set<() => void>()
|
|
81
|
+
private disposers: Array<() => void> = []
|
|
82
|
+
private readonly now: () => number
|
|
83
|
+
private readonly uuid: () => string
|
|
84
|
+
|
|
85
|
+
/** @param deps - store, execution service, and the sessions navigation face. */
|
|
86
|
+
constructor(private readonly deps: ControllerDeps) {
|
|
87
|
+
this.now = deps.now ?? (() => Date.now())
|
|
88
|
+
this.uuid = deps.uuid ?? randomUuid
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// --- lifecycle -------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
/** Load the persisted ledger and start the navigation/status subscriptions. */
|
|
94
|
+
start(): void {
|
|
95
|
+
this.tasks = this.deps.store.load()
|
|
96
|
+
void this.reconcileRunningTasks()
|
|
97
|
+
this.disposers.push(this.deps.sessions.list.subscribe(() => {
|
|
98
|
+
this.onSessionsChanged()
|
|
99
|
+
}))
|
|
100
|
+
this.notify()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Stop all subscriptions and drop retained state (idempotent). */
|
|
104
|
+
dispose(): void {
|
|
105
|
+
for (const dispose of this.disposers.splice(0)) dispose()
|
|
106
|
+
this.listeners.clear()
|
|
107
|
+
if (this.reconcileTimer !== undefined) clearTimeout(this.reconcileTimer)
|
|
108
|
+
this.reconcileTimer = undefined
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// --- snapshot / subscription ------------------------------------------------
|
|
112
|
+
|
|
113
|
+
getSnapshot(): ControllerSnapshot {
|
|
114
|
+
return {
|
|
115
|
+
tasks: this.tasks,
|
|
116
|
+
boardOpen: this.boardOpen,
|
|
117
|
+
selectedTaskId: this.selectedTaskId,
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
subscribe(fn: () => void): () => void {
|
|
122
|
+
this.listeners.add(fn)
|
|
123
|
+
return () => { this.listeners.delete(fn) }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// --- view state -------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
openBoard(): void {
|
|
129
|
+
if (this.boardOpen) return
|
|
130
|
+
// Baseline the selection the board opened against: the board stays open
|
|
131
|
+
// until the user navigates (selection changes), never on mere status
|
|
132
|
+
// updates of the already-selected session.
|
|
133
|
+
this.lastCurrent = currentOf(this.deps.sessions)
|
|
134
|
+
this.boardOpen = true
|
|
135
|
+
this.notify()
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
closeBoard(): void {
|
|
139
|
+
if (!this.boardOpen) return
|
|
140
|
+
this.boardOpen = false
|
|
141
|
+
this.notify()
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
toggleBoard(): void {
|
|
145
|
+
if (this.boardOpen) this.closeBoard()
|
|
146
|
+
else this.openBoard()
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
openTask(id: string): void {
|
|
150
|
+
if (this.tasks.some(task => task.id === id)) {
|
|
151
|
+
this.selectedTaskId = id
|
|
152
|
+
this.notify()
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
closeTask(): void {
|
|
157
|
+
if (this.selectedTaskId === undefined) return
|
|
158
|
+
this.selectedTaskId = undefined
|
|
159
|
+
this.notify()
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// --- task mutations ---------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
createTask(input: NewTaskInput): TaskRecord | undefined {
|
|
165
|
+
const title = input.title.trim()
|
|
166
|
+
if (title === '') return undefined
|
|
167
|
+
const task = createTask(input, this.now(), this.uuid())
|
|
168
|
+
this.tasks = [...this.tasks, task]
|
|
169
|
+
this.persistAndNotify()
|
|
170
|
+
return task
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
updateTask(id: string, patch: Partial<Pick<TaskRecord, 'title' | 'description' | 'prompt'>>): void {
|
|
174
|
+
this.tasks = this.tasks.map(task => task.id === id
|
|
175
|
+
? { ...task, ...patch, updatedAt: this.now() }
|
|
176
|
+
: task)
|
|
177
|
+
this.persistAndNotify()
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
moveTask(id: string, status: TaskStatus): void {
|
|
181
|
+
this.tasks = this.tasks.map(task => task.id === id ? withStatus(task, status, this.now()) : task)
|
|
182
|
+
this.persistAndNotify()
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
deleteTask(id: string): void {
|
|
186
|
+
this.tasks = this.tasks.filter(task => task.id !== id)
|
|
187
|
+
if (this.selectedTaskId === id) this.selectedTaskId = undefined
|
|
188
|
+
this.persistAndNotify()
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// --- scheduling ---------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Update a task's schedule rule. A blank or invalid cron expression is
|
|
195
|
+
* rejected (returns false, state untouched). When the rule ends up enabled
|
|
196
|
+
* the next run instant is computed immediately; a disabled rule carries no
|
|
197
|
+
* next-run instant.
|
|
198
|
+
* @param id - the task to schedule.
|
|
199
|
+
* @param patch - fields to change (absent fields keep their current value).
|
|
200
|
+
* @returns true when applied, false when rejected (invalid cron / unknown task).
|
|
201
|
+
*/
|
|
202
|
+
setSchedule(id: string, patch: { enabled?: boolean; cron?: string }): boolean {
|
|
203
|
+
const task = this.tasks.find(candidate => candidate.id === id)
|
|
204
|
+
if (task === undefined) return false
|
|
205
|
+
const current = task.schedule
|
|
206
|
+
const cron = (patch.cron ?? current?.cron ?? '').trim()
|
|
207
|
+
if (cron === '' || !isValidCron(cron)) return false
|
|
208
|
+
const enabled = patch.enabled ?? current?.enabled ?? false
|
|
209
|
+
const nextRunAt = enabled ? nextRunAtMs(cron, this.now()) : undefined
|
|
210
|
+
this.tasks = this.tasks.map(candidate =>
|
|
211
|
+
candidate.id === id ? withSchedule(candidate, { enabled, cron, nextRunAt }, this.now()) : candidate)
|
|
212
|
+
this.persistAndNotify()
|
|
213
|
+
return true
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Roll a task's schedule forward (scheduler callback): persist the next due
|
|
218
|
+
* instant and the trigger instant of this run. No-op when the task has no
|
|
219
|
+
* schedule rule (it was deleted mid-tick, for example).
|
|
220
|
+
*/
|
|
221
|
+
applyScheduleNextRun(id: string, nextRunAt: number | undefined, lastTriggeredAt: number | undefined): void {
|
|
222
|
+
this.tasks = this.tasks.map(task =>
|
|
223
|
+
task.id === id && task.schedule !== undefined
|
|
224
|
+
? withSchedule(task, { nextRunAt, lastTriggeredAt }, this.now())
|
|
225
|
+
: task)
|
|
226
|
+
this.persistAndNotify()
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Jump to an execution's session transcript. Selecting the session changes
|
|
231
|
+
* `current`, which closes the board (the conversation view takes over).
|
|
232
|
+
* @param sessionId - the execution session to open.
|
|
233
|
+
*/
|
|
234
|
+
openSession(sessionId: string): void {
|
|
235
|
+
this.deps.sessions.open(sessionId)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// --- execution ---------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Execute a task for real: move it to 'running', open an execution record,
|
|
242
|
+
* and hand off to the ExecutionService. A second call while the task is
|
|
243
|
+
* already running is ignored.
|
|
244
|
+
*/
|
|
245
|
+
async runTask(id: string): Promise<boolean> {
|
|
246
|
+
const task = this.tasks.find(candidate => candidate.id === id)
|
|
247
|
+
if (task === undefined || task.status === 'running') return false
|
|
248
|
+
const { task: next, execution } = startExecution(task, this.now(), this.uuid())
|
|
249
|
+
this.tasks = this.tasks.map(candidate => candidate.id === id ? next : candidate)
|
|
250
|
+
this.persistAndNotify()
|
|
251
|
+
// This page owns the settlement of its own launches: the live watch
|
|
252
|
+
// (ExecutionService.run) settles on the turn boundary, and list
|
|
253
|
+
// reconciliation must not pre-empt it with a session that has not
|
|
254
|
+
// started a turn yet (its list row is idle, not completed).
|
|
255
|
+
this.activeExecutionIds.add(execution.id)
|
|
256
|
+
await this.deps.exec.run(next, execution, (event) => { this.handleExecutionEvent(event) })
|
|
257
|
+
return true
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Re-run a settled task: move it back to 'todo' first, then execute. */
|
|
261
|
+
async rerunTask(id: string): Promise<void> {
|
|
262
|
+
const task = this.tasks.find(candidate => candidate.id === id)
|
|
263
|
+
if (task === undefined) return
|
|
264
|
+
if (task.status !== 'running') {
|
|
265
|
+
this.tasks = this.tasks.map(candidate => candidate.id === id ? withStatus(candidate, 'todo', this.now()) : candidate)
|
|
266
|
+
this.persistAndNotify()
|
|
267
|
+
}
|
|
268
|
+
await this.runTask(id)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private handleExecutionEvent(event: ExecutionEvent): void {
|
|
272
|
+
if (event.kind === 'started') {
|
|
273
|
+
this.tasks = this.tasks.map(task => task.id === event.taskId
|
|
274
|
+
? attachSessionId(task, event.executionId, event.sessionId, this.now())
|
|
275
|
+
: task)
|
|
276
|
+
this.persistAndNotify()
|
|
277
|
+
return
|
|
278
|
+
}
|
|
279
|
+
this.activeExecutionIds.delete(event.executionId)
|
|
280
|
+
this.tasks = this.tasks.map(task => task.id === event.taskId
|
|
281
|
+
? settleExecution(task, event.executionId, event.outcome, this.now(), event.error)
|
|
282
|
+
: task)
|
|
283
|
+
this.persistAndNotify()
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// --- internals ---------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
/** Reconcile running tasks and close the board when the user navigates. */
|
|
289
|
+
private onSessionsChanged(): void {
|
|
290
|
+
// Background/leftover executions settle through the session list (their
|
|
291
|
+
// conversation snapshots stay cold until opened). Coalesce the burst of
|
|
292
|
+
// list notifications into one reconcile pass instead of fanning out a
|
|
293
|
+
// history read per notification; see scheduleReconcile.
|
|
294
|
+
this.scheduleReconcile()
|
|
295
|
+
if (!this.boardOpen) return
|
|
296
|
+
const current = currentOf(this.deps.sessions)
|
|
297
|
+
if (current !== this.lastCurrent) this.closeBoard()
|
|
298
|
+
this.lastCurrent = current
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private lastCurrent: string | undefined = undefined
|
|
302
|
+
|
|
303
|
+
/** Execution ids launched on this page; they settle via their live watch, never list reconciliation. */
|
|
304
|
+
private readonly activeExecutionIds = new Set<string>()
|
|
305
|
+
|
|
306
|
+
/** Debounce timer for {@link reconcileRunningTasks}. */
|
|
307
|
+
private reconcileTimer: ReturnType<typeof setTimeout> | undefined = undefined
|
|
308
|
+
|
|
309
|
+
/** Whether a reconcile pass is underway (single-flight guard). */
|
|
310
|
+
private reconcileInFlight = false
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Debounce + single-flight trigger for the running-task reconciliation.
|
|
314
|
+
* Session-list notifications arrive in bursts (one per session status
|
|
315
|
+
* change); both guards together keep a burst from reading the history API
|
|
316
|
+
* once per running task.
|
|
317
|
+
*/
|
|
318
|
+
private scheduleReconcile(): void {
|
|
319
|
+
if (this.reconcileTimer !== undefined) return
|
|
320
|
+
this.reconcileTimer = setTimeout(() => {
|
|
321
|
+
this.reconcileTimer = undefined
|
|
322
|
+
void this.reconcileRunningTasks()
|
|
323
|
+
}, this.deps.reconcileDebounceMs ?? 350)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Settle tasks left 'running' whose sessions already finished. */
|
|
327
|
+
private async reconcileRunningTasks(): Promise<void> {
|
|
328
|
+
if (this.reconcileInFlight) return
|
|
329
|
+
this.reconcileInFlight = true
|
|
330
|
+
try {
|
|
331
|
+
type Settled = Extract<ExecutionEvent, { kind: 'settled' }>
|
|
332
|
+
const events: Array<{ task: TaskRecord; event: Settled }> = []
|
|
333
|
+
for (const task of this.tasks) {
|
|
334
|
+
if (task.status !== 'running') continue
|
|
335
|
+
const execution = task.executions[task.executions.length - 1]
|
|
336
|
+
// Runs launched on this page settle through their live watch (turn
|
|
337
|
+
// boundary); reconciliation exists for background/leftover runs.
|
|
338
|
+
if (execution !== undefined && this.activeExecutionIds.has(execution.id)) continue
|
|
339
|
+
const event = await this.deps.exec.reconcile(task)
|
|
340
|
+
if (event !== undefined && event.kind === 'settled') events.push({ task, event })
|
|
341
|
+
}
|
|
342
|
+
if (events.length === 0) return
|
|
343
|
+
let changed = false
|
|
344
|
+
for (const { task, event } of events) {
|
|
345
|
+
const next = settleExecution(task, event.executionId, event.outcome, this.now(), event.error)
|
|
346
|
+
if (next === task) continue
|
|
347
|
+
this.tasks = this.tasks.map(candidate => candidate.id === task.id ? next : candidate)
|
|
348
|
+
changed = true
|
|
349
|
+
}
|
|
350
|
+
if (changed) this.persistAndNotify()
|
|
351
|
+
} finally {
|
|
352
|
+
this.reconcileInFlight = false
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
private persistAndNotify(): void {
|
|
357
|
+
this.deps.store.save(this.tasks)
|
|
358
|
+
this.notify()
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
private notify(): void {
|
|
362
|
+
for (const fn of [...this.listeners]) fn()
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Record which session ran an execution (once the execution service reports it). */
|
|
367
|
+
function attachSessionId(
|
|
368
|
+
task: TaskRecord,
|
|
369
|
+
executionId: string,
|
|
370
|
+
sessionId: string,
|
|
371
|
+
now: number,
|
|
372
|
+
): TaskRecord {
|
|
373
|
+
return {
|
|
374
|
+
...task,
|
|
375
|
+
updatedAt: now,
|
|
376
|
+
executions: task.executions.map(execution =>
|
|
377
|
+
execution.id === executionId ? { ...execution, sessionId } : execution),
|
|
378
|
+
}
|
|
379
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Execution service: runs a task through dsh's real session machinery.
|
|
3
|
+
*
|
|
4
|
+
* The board's "run" button must make dsh actually work, not fake a status:
|
|
5
|
+
* the service connects a real session (workspace blank-session reuse or
|
|
6
|
+
* `session.create` on the host via the workspaces service), renames it to
|
|
7
|
+
* the task title, sends the task prompt with `session.prompt`, and then
|
|
8
|
+
* watches the session's conversation snapshot until its turn settles. The
|
|
9
|
+
* task board controller consumes {@link ExecutionEvent}s to move the card
|
|
10
|
+
* running → done/failed and to keep the execution record.
|
|
11
|
+
*
|
|
12
|
+
* Deliberately framework-free: the runtime faces are declared structurally
|
|
13
|
+
* (a narrow slice of the real `ctx.sessions` / `ctx.workspaces` contracts)
|
|
14
|
+
* so tests drive it with plain fakes.
|
|
15
|
+
*/
|
|
16
|
+
import type { ExecutionRecord, TaskRecord } from './tasks.ts'
|
|
17
|
+
|
|
18
|
+
/** The narrow sessions face the service needs. */
|
|
19
|
+
export interface SessionsExecutionFace {
|
|
20
|
+
list: {
|
|
21
|
+
getSnapshot(): {
|
|
22
|
+
/** Baseline arrival lifecycle — 'pending' until the host list has loaded. */
|
|
23
|
+
phase: 'pending' | 'ready'
|
|
24
|
+
byId: Record<string, { running: boolean; completed?: boolean }>
|
|
25
|
+
}
|
|
26
|
+
subscribe(fn: () => void): () => void
|
|
27
|
+
}
|
|
28
|
+
binding(id: string): { session: SessionDriver } | undefined
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The narrow workspaces face the service needs. */
|
|
32
|
+
export interface WorkspacesExecutionFace {
|
|
33
|
+
list: {
|
|
34
|
+
getSnapshot(): {
|
|
35
|
+
items: readonly { workspaceId: string }[]
|
|
36
|
+
recentWorkspaceId: string | undefined
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
connectWorkspace(workspaceId: string): Promise<string>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** One raw session-history event narrowed to the failure signal reconcile needs. */
|
|
43
|
+
export interface ExecutionHistoryEvent {
|
|
44
|
+
type: string
|
|
45
|
+
data?: unknown
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Optional raw-history face used to detect failures of never-opened sessions. */
|
|
49
|
+
export interface HistoryExecutionFace {
|
|
50
|
+
loadTail(sessionId: string): Promise<{ events: readonly ExecutionHistoryEvent[] } | undefined>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The behavior verbs the service invokes on an execution session. */
|
|
54
|
+
export interface SessionDriver {
|
|
55
|
+
rename(title: string): Promise<unknown>
|
|
56
|
+
prompt(
|
|
57
|
+
content: readonly unknown[],
|
|
58
|
+
mode: 'queue',
|
|
59
|
+
): Promise<{ ok: true } | { ok: false; error: unknown }>
|
|
60
|
+
getSnapshot(): { running: boolean; lastAgentError: string | null; turnEnds: ReadonlyMap<number, number> }
|
|
61
|
+
subscribe(fn: () => void): () => void
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Everything the service needs from the runtime. */
|
|
65
|
+
export interface ExecutionEnvironment {
|
|
66
|
+
sessions: SessionsExecutionFace
|
|
67
|
+
workspaces: WorkspacesExecutionFace
|
|
68
|
+
/** Raw-history reader for failure detection of never-opened sessions. */
|
|
69
|
+
history?: HistoryExecutionFace
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Outcome events the service emits to the controller. */
|
|
73
|
+
export type ExecutionEvent =
|
|
74
|
+
| { kind: 'started'; taskId: string; executionId: string; sessionId: string }
|
|
75
|
+
| { kind: 'settled'; taskId: string; executionId: string; outcome: 'succeeded' | 'failed' | 'cancelled'; error?: string }
|
|
76
|
+
|
|
77
|
+
/** Human copy for a run failure. */
|
|
78
|
+
function messageOf(error: unknown): string {
|
|
79
|
+
if (error instanceof Error) return error.message
|
|
80
|
+
return String(error)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Whether a `turn/end` payload closed the turn with an error reason. */
|
|
84
|
+
function isErrorTurnEnd(data: unknown): boolean {
|
|
85
|
+
if (typeof data !== 'object' || data === null) return false
|
|
86
|
+
const reason = (data as { reason?: unknown }).reason
|
|
87
|
+
return typeof reason === 'object' && reason !== null
|
|
88
|
+
&& (reason as { kind?: unknown }).kind === 'error'
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Run one task to completion (or to a settled failure).
|
|
93
|
+
*
|
|
94
|
+
* @param task - the task being executed.
|
|
95
|
+
* @param execution - the freshly opened execution record (id + start time).
|
|
96
|
+
* @param onEvent - callback for started/settled events.
|
|
97
|
+
* @returns resolves when the run settles (or fails to start); never rejects —
|
|
98
|
+
* every failure path is reported as a settled event.
|
|
99
|
+
*/
|
|
100
|
+
export class ExecutionService {
|
|
101
|
+
/** @param env - the runtime faces (real or fake). */
|
|
102
|
+
constructor(private readonly env: ExecutionEnvironment) {}
|
|
103
|
+
|
|
104
|
+
async run(
|
|
105
|
+
task: TaskRecord,
|
|
106
|
+
execution: ExecutionRecord,
|
|
107
|
+
onEvent: (event: ExecutionEvent) => void,
|
|
108
|
+
): Promise<void> {
|
|
109
|
+
try {
|
|
110
|
+
const sessionId = await this.connectSession()
|
|
111
|
+
onEvent({ kind: 'started', taskId: task.id, executionId: execution.id, sessionId })
|
|
112
|
+
const driver = this.driverOf(sessionId)
|
|
113
|
+
if (driver === undefined) {
|
|
114
|
+
onEvent({ kind: 'settled', taskId: task.id, executionId: execution.id, outcome: 'failed', error: 'execution session is not ready' })
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
// Best-effort rename so the execution is recognizable in the session list.
|
|
118
|
+
await driver.rename(task.title).catch(() => { /* rename is cosmetic */ })
|
|
119
|
+
// Baseline the turn counter BEFORE the prompt round-trip: a turn that
|
|
120
|
+
// completes while prompt is in flight must still advance past this
|
|
121
|
+
// baseline, or the watch below would never observe it settle.
|
|
122
|
+
const baseline = driver.getSnapshot().turnEnds.size
|
|
123
|
+
const accepted = await this.sendPrompt(driver, task)
|
|
124
|
+
if (!accepted.ok) {
|
|
125
|
+
onEvent({
|
|
126
|
+
kind: 'settled', taskId: task.id, executionId: execution.id, outcome: 'failed',
|
|
127
|
+
error: messageOf(accepted.error),
|
|
128
|
+
})
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
this.watchForSettlement(driver, task.id, execution.id, onEvent, baseline)
|
|
132
|
+
} catch (error) {
|
|
133
|
+
onEvent({
|
|
134
|
+
kind: 'settled', taskId: task.id, executionId: execution.id, outcome: 'failed',
|
|
135
|
+
error: messageOf(error),
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Inspect a reloaded/background task that was left 'running' and emit a
|
|
142
|
+
* settled event when its session already finished.
|
|
143
|
+
*
|
|
144
|
+
* A session that was never opened keeps a cold conversation snapshot (the
|
|
145
|
+
* runtime only maintains the window for the staged/current session), so the
|
|
146
|
+
* settled outcome is decided by the strongest available signal, in order:
|
|
147
|
+
* 1. the list summary — missing session → cancelled; still running → pending;
|
|
148
|
+
* 2. a warm conversation snapshot → `lastAgentError` decides failed/succeeded;
|
|
149
|
+
* 3. the raw history tail (when a history face is wired) — a `turn/end`
|
|
150
|
+
* error reason proves failure;
|
|
151
|
+
* 4. otherwise a finished session counts as succeeded.
|
|
152
|
+
*
|
|
153
|
+
* @param task - a task whose latest execution has no endedAt.
|
|
154
|
+
* @returns a settled event when the session state proves completion, else undefined.
|
|
155
|
+
*/
|
|
156
|
+
async reconcile(task: TaskRecord): Promise<ExecutionEvent | undefined> {
|
|
157
|
+
const execution = task.executions[task.executions.length - 1]
|
|
158
|
+
if (execution === undefined || execution.sessionId === undefined || execution.endedAt !== undefined) return undefined
|
|
159
|
+
const list = this.env.sessions.list.getSnapshot()
|
|
160
|
+
// The host list baseline has not arrived yet (page load): a session "not
|
|
161
|
+
// found" now would be a false cancel. Wait for a later list change.
|
|
162
|
+
if (list.phase !== 'ready') return undefined
|
|
163
|
+
const summary = list.byId[execution.sessionId]
|
|
164
|
+
if (summary === undefined) {
|
|
165
|
+
return { kind: 'settled', taskId: task.id, executionId: execution.id, outcome: 'cancelled', error: 'execution session no longer exists' }
|
|
166
|
+
}
|
|
167
|
+
if (summary.running) return undefined
|
|
168
|
+
const driver = this.driverOf(execution.sessionId)
|
|
169
|
+
if (driver !== undefined) {
|
|
170
|
+
const snapshot = driver.getSnapshot()
|
|
171
|
+
if (snapshot.turnEnds.size > 0) {
|
|
172
|
+
const outcome = snapshot.lastAgentError !== null ? 'failed' : 'succeeded'
|
|
173
|
+
return {
|
|
174
|
+
kind: 'settled', taskId: task.id, executionId: execution.id, outcome,
|
|
175
|
+
error: snapshot.lastAgentError ?? undefined,
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const failed = await this.historyShowsFailure(execution.sessionId)
|
|
180
|
+
if (failed) {
|
|
181
|
+
return { kind: 'settled', taskId: task.id, executionId: execution.id, outcome: 'failed', error: 'agent turn failed' }
|
|
182
|
+
}
|
|
183
|
+
return { kind: 'settled', taskId: task.id, executionId: execution.id, outcome: 'succeeded' }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Best-effort failure probe over the raw history tail (false when unavailable). */
|
|
187
|
+
private async historyShowsFailure(sessionId: string): Promise<boolean> {
|
|
188
|
+
const history = this.env.history
|
|
189
|
+
if (history === undefined) return false
|
|
190
|
+
try {
|
|
191
|
+
const tail = await history.loadTail(sessionId)
|
|
192
|
+
if (tail === undefined) return false
|
|
193
|
+
return tail.events.some(event => event.type === 'turn/end' && isErrorTurnEnd(event.data))
|
|
194
|
+
} catch (error) {
|
|
195
|
+
// A failed history read must not block settlement; fall back to success.
|
|
196
|
+
console.error('[dsh-task-board] history failure probe failed', error)
|
|
197
|
+
return false
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private async connectSession(): Promise<string> {
|
|
202
|
+
const workspace = this.env.workspaces.list.getSnapshot()
|
|
203
|
+
const workspaceId = workspace.recentWorkspaceId ?? workspace.items[0]?.workspaceId
|
|
204
|
+
if (workspaceId === undefined) {
|
|
205
|
+
throw new Error('no workspace available to run the task in')
|
|
206
|
+
}
|
|
207
|
+
return this.env.workspaces.connectWorkspace(workspaceId)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private driverOf(sessionId: string): SessionDriver | undefined {
|
|
211
|
+
return this.env.sessions.binding(sessionId)?.session
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private async sendPrompt(
|
|
215
|
+
driver: SessionDriver,
|
|
216
|
+
task: TaskRecord,
|
|
217
|
+
): Promise<{ ok: true } | { ok: false; error: unknown }> {
|
|
218
|
+
const text = task.prompt.trim() !== '' ? task.prompt : task.title
|
|
219
|
+
try {
|
|
220
|
+
const result = await driver.prompt([{ type: 'text', text }], 'queue')
|
|
221
|
+
return result
|
|
222
|
+
} catch (error) {
|
|
223
|
+
return { ok: false, error }
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Subscribe to the execution session and settle the run once the accepted
|
|
229
|
+
* turn completes (turn counter advanced past the acceptance baseline and
|
|
230
|
+
* the session is no longer running). Never settles while the session is
|
|
231
|
+
* still running; unsubscribes on settle.
|
|
232
|
+
*/
|
|
233
|
+
private watchForSettlement(
|
|
234
|
+
driver: SessionDriver,
|
|
235
|
+
taskId: string,
|
|
236
|
+
executionId: string,
|
|
237
|
+
onEvent: (event: ExecutionEvent) => void,
|
|
238
|
+
baseline: number,
|
|
239
|
+
): void {
|
|
240
|
+
let settled = false
|
|
241
|
+
let unsubscribe: () => void = () => {}
|
|
242
|
+
const check = (): void => {
|
|
243
|
+
if (settled) return
|
|
244
|
+
const snapshot = driver.getSnapshot()
|
|
245
|
+
if (snapshot.running || snapshot.turnEnds.size <= baseline) return
|
|
246
|
+
settled = true
|
|
247
|
+
unsubscribe()
|
|
248
|
+
onEvent({
|
|
249
|
+
kind: 'settled', taskId, executionId,
|
|
250
|
+
outcome: snapshot.lastAgentError !== null ? 'failed' : 'succeeded',
|
|
251
|
+
error: snapshot.lastAgentError ?? undefined,
|
|
252
|
+
})
|
|
253
|
+
}
|
|
254
|
+
unsubscribe = driver.subscribe(check)
|
|
255
|
+
// A turn can complete during the prompt round-trip (before subscribe):
|
|
256
|
+
// re-check immediately so a fast turn is never missed.
|
|
257
|
+
check()
|
|
258
|
+
}
|
|
259
|
+
}
|