@linxin666/dsh-client-ui-task-board 0.2.8 → 0.3.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.
@@ -1,425 +0,0 @@
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
- /** One list-row summary the execution service reads (the narrow slice of a SessionSummary). */
19
- export interface ExecutionSessionSummary {
20
- running: boolean
21
- completed?: boolean
22
- /** Empty-log bit: the preset can only be recomposed while the session is blank. */
23
- blank?: boolean
24
- /** The preset the session currently runs (absent on deployments without presets). */
25
- agentPreset?: string
26
- }
27
-
28
- /** The narrow sessions face the service needs. */
29
- export interface SessionsExecutionFace {
30
- list: {
31
- getSnapshot(): {
32
- /** Baseline arrival lifecycle — 'pending' until the host list has loaded. */
33
- phase: 'pending' | 'ready'
34
- byId: Record<string, ExecutionSessionSummary>
35
- }
36
- subscribe(fn: () => void): () => void
37
- }
38
- binding(id: string): { session: SessionDriver } | undefined
39
- /** Record a host-confirmed preset switch so the list label moves immediately. */
40
- noteAgentPreset?(sessionId: string, agentPreset: string): void
41
- }
42
-
43
- /** The narrow agent-preset wire face the service needs (`agentPreset.select`). */
44
- export interface PresetsExecutionFace {
45
- /** Recompose a blank session's agent from a preset. */
46
- select(sessionId: string, agentPreset: string): Promise<{ ok: true } | { ok: false; error: unknown }>
47
- }
48
-
49
- /** Settled result of one agent-preset switch, shared by concurrent waiters. */
50
- type PresetSelectResult = { ok: true } | { ok: false; error: unknown }
51
-
52
- /** The narrow workspaces face the service needs. */
53
- export interface WorkspacesExecutionFace {
54
- list: {
55
- getSnapshot(): {
56
- items: readonly { workspaceId: string }[]
57
- recentWorkspaceId: string | undefined
58
- }
59
- }
60
- connectWorkspace(workspaceId: string): Promise<string>
61
- }
62
-
63
- /** One raw session-history event narrowed to the failure signal reconcile needs. */
64
- export interface ExecutionHistoryEvent {
65
- type: string
66
- data?: unknown
67
- }
68
-
69
- /** Optional raw-history face used to detect failures of never-opened sessions. */
70
- export interface HistoryExecutionFace {
71
- loadTail(sessionId: string): Promise<{ events: readonly ExecutionHistoryEvent[] } | undefined>
72
- }
73
-
74
- /** The behavior verbs the service invokes on an execution session. */
75
- export interface SessionDriver {
76
- rename(title: string): Promise<unknown>
77
- prompt(
78
- content: readonly unknown[],
79
- mode: 'queue',
80
- ): Promise<{ ok: true } | { ok: false; error: unknown }>
81
- /**
82
- * Admit one slash-command line against the session's agent (the
83
- * `/permission <id>` mechanism). `matched` reports whether a command
84
- * claimed the line.
85
- */
86
- command(line: string): Promise<{ ok: true; matched: boolean } | { ok: false; error: unknown }>
87
- getSnapshot(): { running: boolean; lastAgentError: string | null; turnEnds: ReadonlyMap<number, number> }
88
- subscribe(fn: () => void): () => void
89
- }
90
-
91
- /** Everything the service needs from the runtime. */
92
- export interface ExecutionEnvironment {
93
- sessions: SessionsExecutionFace
94
- workspaces: WorkspacesExecutionFace
95
- /** Agent-preset wire face; absent on deployments without preset support. */
96
- presets?: PresetsExecutionFace
97
- /** Raw-history reader for failure detection of never-opened sessions. */
98
- history?: HistoryExecutionFace
99
- }
100
-
101
- /** Outcome events the service emits to the controller. */
102
- export type ExecutionEvent =
103
- | { kind: 'started'; taskId: string; executionId: string; sessionId: string }
104
- | { kind: 'settled'; taskId: string; executionId: string; outcome: 'succeeded' | 'failed' | 'cancelled'; error?: string }
105
-
106
- /** Human copy for a run failure. */
107
- function messageOf(error: unknown): string {
108
- if (error instanceof Error) return error.message
109
- return String(error)
110
- }
111
-
112
- /**
113
- * Whether a rejected preset switch actually means "the session already runs
114
- * this preset" (the host's agent-preset-conflict with a matching
115
- * existingPreset). A blank-session reuse race can produce this even though
116
- * the requested composition is in place.
117
- */
118
- function presetAlreadyRuns(error: unknown, mode: string): boolean {
119
- if (typeof error !== 'object' || error === null) return false
120
- const details = (error as { details?: unknown }).details
121
- if (typeof details !== 'object' || details === null) return false
122
- return (details as { existingPreset?: unknown }).existingPreset === mode
123
- }
124
-
125
- /** Whether a `turn/end` payload closed the turn with an error reason. */
126
- function isErrorTurnEnd(data: unknown): boolean {
127
- if (typeof data !== 'object' || data === null) return false
128
- const reason = (data as { reason?: unknown }).reason
129
- return typeof reason === 'object' && reason !== null
130
- && (reason as { kind?: unknown }).kind === 'error'
131
- }
132
-
133
- /**
134
- * Run one task to completion (or to a settled failure).
135
- *
136
- * @param task - the task being executed.
137
- * @param execution - the freshly opened execution record (id + start time).
138
- * @param onEvent - callback for started/settled events.
139
- * @returns resolves when the run settles (or fails to start); never rejects —
140
- * every failure path is reported as a settled event.
141
- */
142
- export class ExecutionService {
143
- /**
144
- * In-flight preset switches keyed by `sessionId\u0000mode`. The session
145
- * list mirror can lag behind a just-applied switch, so concurrent runs
146
- * against one blank session would otherwise each issue the same select
147
- * RPC (an amplified storm); sharing one in-flight call keeps the wire to
148
- * a single request. The settled entry is forgotten so a later run can
149
- * switch again.
150
- */
151
- private readonly presetSwitches = new Map<string, Promise<PresetSelectResult>>()
152
-
153
- /** @param env - the runtime faces (real or fake). */
154
- constructor(private readonly env: ExecutionEnvironment) {}
155
-
156
- async run(
157
- task: TaskRecord,
158
- execution: ExecutionRecord,
159
- onEvent: (event: ExecutionEvent) => void,
160
- ): Promise<void> {
161
- const settleFailed = (error: string): void => {
162
- onEvent({ kind: 'settled', taskId: task.id, executionId: execution.id, outcome: 'failed', error })
163
- }
164
- try {
165
- const sessionId = await this.connectSession(task.workspaceId)
166
- onEvent({ kind: 'started', taskId: task.id, executionId: execution.id, sessionId })
167
- const driver = this.driverOf(sessionId)
168
- if (driver === undefined) {
169
- settleFailed('execution session is not ready')
170
- return
171
- }
172
- // Task-pinned execution targets are applied BEFORE any prompt: a preset
173
- // can only be recomposed while the session is blank, and the permission
174
- // command must run before the task's turn starts. A rejected target
175
- // fails the run without sending the prompt — running the task under
176
- // different settings than it declared would be worse than not running.
177
- if (!await this.applyMode(task, sessionId, settleFailed)) return
178
- if (!await this.applyPermission(driver, task, settleFailed)) return
179
- // Best-effort rename so the execution is recognizable in the session list.
180
- await driver.rename(task.title).catch(() => { /* rename is cosmetic */ })
181
- // Baseline the turn counter BEFORE the prompt round-trip: a turn that
182
- // completes while prompt is in flight must still advance past this
183
- // baseline, or the watch below would never observe it settle.
184
- const baseline = driver.getSnapshot().turnEnds.size
185
- const accepted = await this.sendPrompt(driver, task)
186
- if (!accepted.ok) {
187
- settleFailed(messageOf(accepted.error))
188
- return
189
- }
190
- this.watchForSettlement(driver, task.id, execution.id, onEvent, baseline)
191
- } catch (error) {
192
- settleFailed(messageOf(error))
193
- }
194
- }
195
-
196
- /**
197
- * Recompose the execution session's agent from the task-pinned preset.
198
- * No-op when the task pins none or the session already runs it; fails the
199
- * run when the session is no longer blank, the preset face is missing, or
200
- * the wire refuses.
201
- */
202
- private async applyMode(
203
- task: TaskRecord,
204
- sessionId: string,
205
- settleFailed: (error: string) => void,
206
- ): Promise<boolean> {
207
- const mode = task.mode
208
- if (mode === undefined || mode === '') return true
209
- const summary = this.env.sessions.list.getSnapshot().byId[sessionId]
210
- if (summary?.blank === false) {
211
- settleFailed(`cannot switch agent preset to ${mode}: the execution session is not blank`)
212
- return false
213
- }
214
- // Fast path: the list mirror already reports the pinned preset. The
215
- // mirror can lag a just-applied switch, though, so this guard alone is
216
- // not enough under concurrent runs — `switchPreset` below dedupes the
217
- // wire call for the lag window.
218
- if (summary?.agentPreset === mode) return true
219
- const presets = this.env.presets
220
- if (presets === undefined) {
221
- settleFailed(`this deployment does not support agent presets (task asks for ${mode})`)
222
- return false
223
- }
224
- try {
225
- const result = await this.switchPreset(presets, sessionId, mode)
226
- if (!result.ok) {
227
- // A list race can leave the summary without the preset label even
228
- // though the blank session already runs it; the wire answers that
229
- // case with agent-preset-conflict (existingPreset === requested).
230
- // The requested composition is already in place, so count it as
231
- // applied instead of failing the run.
232
- if (presetAlreadyRuns(result.error, mode)) {
233
- this.env.sessions.noteAgentPreset?.(sessionId, mode)
234
- return true
235
- }
236
- settleFailed(`agent preset switch to ${mode} rejected: ${messageOf(result.error)}`)
237
- return false
238
- }
239
- } catch (error) {
240
- settleFailed(`agent preset switch to ${mode} failed: ${messageOf(error)}`)
241
- return false
242
- }
243
- this.env.sessions.noteAgentPreset?.(sessionId, mode)
244
- return true
245
- }
246
-
247
- /**
248
- * One in-flight `select` per (session, preset): concurrent runs against
249
- * the same blank session share the same wire call instead of each issuing
250
- * a duplicate RPC. The entry is removed once the call settles, so a later
251
- * run (after a shared failure, say) issues a fresh switch.
252
- */
253
- private switchPreset(
254
- presets: PresetsExecutionFace,
255
- sessionId: string,
256
- mode: string,
257
- ): Promise<PresetSelectResult> {
258
- const key = `${sessionId}\u0000${mode}`
259
- const inflight = this.presetSwitches.get(key)
260
- if (inflight !== undefined) return inflight
261
- const attempt = presets.select(sessionId, mode).finally(() => {
262
- if (this.presetSwitches.get(key) === attempt) this.presetSwitches.delete(key)
263
- })
264
- this.presetSwitches.set(key, attempt)
265
- return attempt
266
- }
267
-
268
- /**
269
- * Apply the task-pinned permission preset through the `/permission <id>`
270
- * slash command. No-op when the task pins none; fails the run when the
271
- * admission is rejected or no command claimed the line.
272
- */
273
- private async applyPermission(
274
- driver: SessionDriver,
275
- task: TaskRecord,
276
- settleFailed: (error: string) => void,
277
- ): Promise<boolean> {
278
- const permission = task.permission
279
- if (permission === undefined) return true
280
- const line = `/permission ${permission}`
281
- try {
282
- const result = await driver.command(line)
283
- if (!result.ok) {
284
- settleFailed(`permission command rejected: ${messageOf(result.error)}`)
285
- return false
286
- }
287
- if (!result.matched) {
288
- settleFailed(`permission command not recognized: ${line}`)
289
- return false
290
- }
291
- } catch (error) {
292
- settleFailed(`permission command failed: ${messageOf(error)}`)
293
- return false
294
- }
295
- return true
296
- }
297
-
298
- /**
299
- * Inspect a reloaded/background task that was left 'running' and emit a
300
- * settled event when its session already finished.
301
- *
302
- * A session that was never opened keeps a cold conversation snapshot (the
303
- * runtime only maintains the window for the staged/current session), so the
304
- * settled outcome is decided by the strongest available signal, in order:
305
- * 1. the list summary — missing session → cancelled; still running → pending;
306
- * 2. a warm conversation snapshot → `lastAgentError` decides failed/succeeded;
307
- * 3. the raw history tail (when a history face is wired) — a `turn/end`
308
- * error reason proves failure;
309
- * 4. otherwise a finished session counts as succeeded.
310
- *
311
- * @param task - a task whose latest execution has no endedAt.
312
- * @returns a settled event when the session state proves completion, else undefined.
313
- */
314
- async reconcile(task: TaskRecord): Promise<ExecutionEvent | undefined> {
315
- const execution = task.executions[task.executions.length - 1]
316
- if (execution === undefined || execution.sessionId === undefined || execution.endedAt !== undefined) return undefined
317
- const list = this.env.sessions.list.getSnapshot()
318
- // The host list baseline has not arrived yet (page load): a session "not
319
- // found" now would be a false cancel. Wait for a later list change.
320
- if (list.phase !== 'ready') return undefined
321
- const summary = list.byId[execution.sessionId]
322
- if (summary === undefined) {
323
- return { kind: 'settled', taskId: task.id, executionId: execution.id, outcome: 'cancelled', error: 'execution session no longer exists' }
324
- }
325
- if (summary.running) return undefined
326
- const driver = this.driverOf(execution.sessionId)
327
- if (driver !== undefined) {
328
- const snapshot = driver.getSnapshot()
329
- if (snapshot.turnEnds.size > 0) {
330
- const outcome = snapshot.lastAgentError !== null ? 'failed' : 'succeeded'
331
- return {
332
- kind: 'settled', taskId: task.id, executionId: execution.id, outcome,
333
- error: snapshot.lastAgentError ?? undefined,
334
- }
335
- }
336
- }
337
- const failed = await this.historyShowsFailure(execution.sessionId)
338
- if (failed) {
339
- return { kind: 'settled', taskId: task.id, executionId: execution.id, outcome: 'failed', error: 'agent turn failed' }
340
- }
341
- return { kind: 'settled', taskId: task.id, executionId: execution.id, outcome: 'succeeded' }
342
- }
343
-
344
- /** Best-effort failure probe over the raw history tail (false when unavailable). */
345
- private async historyShowsFailure(sessionId: string): Promise<boolean> {
346
- const history = this.env.history
347
- if (history === undefined) return false
348
- try {
349
- const tail = await history.loadTail(sessionId)
350
- if (tail === undefined) return false
351
- return tail.events.some(event => event.type === 'turn/end' && isErrorTurnEnd(event.data))
352
- } catch (error) {
353
- // A failed history read must not block settlement; fall back to success.
354
- console.error('[dsh-task-board] history failure probe failed', error)
355
- return false
356
- }
357
- }
358
-
359
- private async connectSession(taskWorkspaceId: string | undefined): Promise<string> {
360
- const workspace = this.env.workspaces.list.getSnapshot()
361
- if (taskWorkspaceId !== undefined && taskWorkspaceId !== '') {
362
- // A task-pinned workspace must exist in the list: connecting an
363
- // unknown id would only defer the failure into the wire.
364
- if (!workspace.items.some(item => item.workspaceId === taskWorkspaceId)) {
365
- throw new Error(`task workspace is not available: ${taskWorkspaceId}`)
366
- }
367
- return this.env.workspaces.connectWorkspace(taskWorkspaceId)
368
- }
369
- const workspaceId = workspace.recentWorkspaceId ?? workspace.items[0]?.workspaceId
370
- if (workspaceId === undefined) {
371
- throw new Error('no workspace available to run the task in')
372
- }
373
- return this.env.workspaces.connectWorkspace(workspaceId)
374
- }
375
-
376
- private driverOf(sessionId: string): SessionDriver | undefined {
377
- return this.env.sessions.binding(sessionId)?.session
378
- }
379
-
380
- private async sendPrompt(
381
- driver: SessionDriver,
382
- task: TaskRecord,
383
- ): Promise<{ ok: true } | { ok: false; error: unknown }> {
384
- const text = task.prompt.trim() !== '' ? task.prompt : task.title
385
- try {
386
- const result = await driver.prompt([{ type: 'text', text }], 'queue')
387
- return result
388
- } catch (error) {
389
- return { ok: false, error }
390
- }
391
- }
392
-
393
- /**
394
- * Subscribe to the execution session and settle the run once the accepted
395
- * turn completes (turn counter advanced past the acceptance baseline and
396
- * the session is no longer running). Never settles while the session is
397
- * still running; unsubscribes on settle.
398
- */
399
- private watchForSettlement(
400
- driver: SessionDriver,
401
- taskId: string,
402
- executionId: string,
403
- onEvent: (event: ExecutionEvent) => void,
404
- baseline: number,
405
- ): void {
406
- let settled = false
407
- let unsubscribe: () => void = () => {}
408
- const check = (): void => {
409
- if (settled) return
410
- const snapshot = driver.getSnapshot()
411
- if (snapshot.running || snapshot.turnEnds.size <= baseline) return
412
- settled = true
413
- unsubscribe()
414
- onEvent({
415
- kind: 'settled', taskId, executionId,
416
- outcome: snapshot.lastAgentError !== null ? 'failed' : 'succeeded',
417
- error: snapshot.lastAgentError ?? undefined,
418
- })
419
- }
420
- unsubscribe = driver.subscribe(check)
421
- // A turn can complete during the prompt round-trip (before subscribe):
422
- // re-check immediately so a fast turn is never missed.
423
- check()
424
- }
425
- }