@linxin666/dsh-client-ui-task-board 0.1.16 → 0.1.18
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.i18n.yaml +2 -2
- package/README.md +11 -7
- package/README.zh.md +11 -7
- package/lib/client.js +821 -145
- package/lib/client.js.map +1 -1
- package/lib/index.js +1 -1
- package/lib/types/client/PluginSettingsCard.d.ts +11 -0
- package/lib/types/client/locales.d.ts +21 -0
- package/lib/types/client/schedule-presets.d.ts +10 -0
- package/lib/types/core/controller.d.ts +57 -1
- package/lib/types/core/execution.d.ts +65 -4
- package/lib/types/core/scheduler.d.ts +7 -0
- package/lib/types/core/store.d.ts +1 -1
- package/lib/types/core/tasks.d.ts +43 -0
- package/lib/types/core/use-cases/task-archive.d.ts +22 -0
- package/lib/types/core/use-cases/task-create.d.ts +1 -6
- package/lib/types/core/use-cases/task-update.d.ts +9 -4
- package/lib/types/index.d.ts +1 -1
- package/package.json +1 -1
- package/src/client/PluginSettingsCard.tsx +50 -47
- package/src/client/board/NewTaskModal.tsx +136 -3
- package/src/client/board/TaskBoard.tsx +48 -19
- package/src/client/board/TaskDetail.tsx +97 -8
- package/src/client/board.module.css +16 -0
- package/src/client/index.ts +71 -2
- package/src/client/locales.ts +42 -0
- package/src/client/schedule-presets.ts +13 -0
- package/src/client/settings-card.module.css +9 -0
- package/src/core/controller.ts +92 -2
- package/src/core/execution.ts +179 -12
- package/src/core/scheduler.ts +15 -0
- package/src/core/store.ts +16 -2
- package/src/core/tasks.ts +56 -0
- package/src/core/use-cases/task-archive.ts +48 -0
- package/src/core/use-cases/task-create.ts +12 -3
- package/src/core/use-cases/task-update.ts +34 -5
- package/src/index.ts +1 -1
package/src/core/controller.ts
CHANGED
|
@@ -19,10 +19,11 @@ import {
|
|
|
19
19
|
settleExecution, startExecution, withStatus,
|
|
20
20
|
type NewTaskInput, type TaskRecord, type TaskStatus,
|
|
21
21
|
} from './tasks.ts'
|
|
22
|
+
import { applyArchiveTask, applyRestoreTask } from './use-cases/task-archive.ts'
|
|
22
23
|
import { applyCreateTask } from './use-cases/task-create.ts'
|
|
23
24
|
import { applyDeleteTask } from './use-cases/task-delete.ts'
|
|
24
25
|
import { applyScheduleNextRun as applyScheduleRollForward, applySetSchedule } from './use-cases/task-schedule.ts'
|
|
25
|
-
import { applyUpdateTask } from './use-cases/task-update.ts'
|
|
26
|
+
import { applyUpdateTask, type TaskUpdatePatch } from './use-cases/task-update.ts'
|
|
26
27
|
|
|
27
28
|
/** The sessions face the controller needs for navigation awareness. */
|
|
28
29
|
export interface SessionsControllerFace {
|
|
@@ -47,11 +48,38 @@ export interface ControllerDeps {
|
|
|
47
48
|
reconcileDebounceMs?: number
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
/** One workspace option the execution-target pickers offer. */
|
|
52
|
+
export interface ExecutionWorkspaceOption {
|
|
53
|
+
workspaceId: string
|
|
54
|
+
/** Display label (workspace title; the wiring falls back to the path). */
|
|
55
|
+
title: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** One agent-preset option the execution-target pickers offer. */
|
|
59
|
+
export interface ExecutionPresetOption {
|
|
60
|
+
id: string
|
|
61
|
+
name?: string
|
|
62
|
+
description?: string
|
|
63
|
+
/** Why this preset cannot compose a session; the pickers disable it. */
|
|
64
|
+
broken?: string
|
|
65
|
+
isDefault: boolean
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The execution-target option sets the UI feeds into the controller. */
|
|
69
|
+
export interface ExecutionOptionsSnapshot {
|
|
70
|
+
workspaces: readonly ExecutionWorkspaceOption[]
|
|
71
|
+
presets: readonly ExecutionPresetOption[]
|
|
72
|
+
}
|
|
73
|
+
|
|
50
74
|
/** Immutable controller snapshot for UI subscriptions. */
|
|
51
75
|
export interface ControllerSnapshot {
|
|
52
76
|
tasks: readonly TaskRecord[]
|
|
53
77
|
boardOpen: boolean
|
|
78
|
+
/** True when the board shows the archive view instead of the columns. */
|
|
79
|
+
archiveView: boolean
|
|
54
80
|
selectedTaskId: string | undefined
|
|
81
|
+
/** Picker option sets (workspace list + agent-preset roster). */
|
|
82
|
+
executionOptions: ExecutionOptionsSnapshot
|
|
55
83
|
}
|
|
56
84
|
|
|
57
85
|
/** The selected task (resolved from the ledger), or undefined. */
|
|
@@ -84,7 +112,9 @@ function currentOf(sessions: SessionsControllerFace): string | undefined {
|
|
|
84
112
|
export class BoardController {
|
|
85
113
|
private tasks: TaskRecord[] = []
|
|
86
114
|
private boardOpen = false
|
|
115
|
+
private archiveView = false
|
|
87
116
|
private selectedTaskId: string | undefined
|
|
117
|
+
private executionOptions: ExecutionOptionsSnapshot = { workspaces: [], presets: [] }
|
|
88
118
|
private listeners = new Set<() => void>()
|
|
89
119
|
private disposers: Array<() => void> = []
|
|
90
120
|
private readonly now: () => number
|
|
@@ -131,7 +161,9 @@ export class BoardController {
|
|
|
131
161
|
return {
|
|
132
162
|
tasks: this.tasks,
|
|
133
163
|
boardOpen: this.boardOpen,
|
|
164
|
+
archiveView: this.archiveView,
|
|
134
165
|
selectedTaskId: this.selectedTaskId,
|
|
166
|
+
executionOptions: this.executionOptions,
|
|
135
167
|
}
|
|
136
168
|
}
|
|
137
169
|
|
|
@@ -163,6 +195,20 @@ export class BoardController {
|
|
|
163
195
|
else this.openBoard()
|
|
164
196
|
}
|
|
165
197
|
|
|
198
|
+
/**
|
|
199
|
+
* Switch between the kanban columns and the archive view. Leaving the
|
|
200
|
+
* archive view with an archived task still selected closes the selection —
|
|
201
|
+
* the detail overlay must not linger over a task that is off-board.
|
|
202
|
+
*/
|
|
203
|
+
toggleArchiveView(): void {
|
|
204
|
+
this.archiveView = !this.archiveView
|
|
205
|
+
if (!this.archiveView && this.selectedTaskId !== undefined) {
|
|
206
|
+
const selected = this.tasks.find(task => task.id === this.selectedTaskId)
|
|
207
|
+
if (selected?.archivedAt !== undefined) this.selectedTaskId = undefined
|
|
208
|
+
}
|
|
209
|
+
this.notify()
|
|
210
|
+
}
|
|
211
|
+
|
|
166
212
|
openTask(id: string): void {
|
|
167
213
|
if (this.tasks.some(task => task.id === id)) {
|
|
168
214
|
this.selectedTaskId = id
|
|
@@ -186,11 +232,20 @@ export class BoardController {
|
|
|
186
232
|
return task
|
|
187
233
|
}
|
|
188
234
|
|
|
189
|
-
updateTask(id: string, patch:
|
|
235
|
+
updateTask(id: string, patch: TaskUpdatePatch): void {
|
|
190
236
|
this.tasks = [...applyUpdateTask(this.tasks, id, patch, this.now())]
|
|
191
237
|
this.persistAndNotify()
|
|
192
238
|
}
|
|
193
239
|
|
|
240
|
+
/**
|
|
241
|
+
* Replace (a part of) the picker option sets the UI feeds (workspace list
|
|
242
|
+
* and agent-preset roster come from the runtime, not the ledger).
|
|
243
|
+
*/
|
|
244
|
+
setExecutionOptions(patch: Partial<ExecutionOptionsSnapshot>): void {
|
|
245
|
+
this.executionOptions = { ...this.executionOptions, ...patch }
|
|
246
|
+
this.notify()
|
|
247
|
+
}
|
|
248
|
+
|
|
194
249
|
moveTask(id: string, status: TaskStatus): void {
|
|
195
250
|
this.tasks = this.tasks.map(task => task.id === id ? withStatus(task, status, this.now()) : task)
|
|
196
251
|
this.persistAndNotify()
|
|
@@ -203,6 +258,29 @@ export class BoardController {
|
|
|
203
258
|
this.persistAndNotify()
|
|
204
259
|
}
|
|
205
260
|
|
|
261
|
+
/**
|
|
262
|
+
* Archive a settled task (done/failed). Running or on-board-unsettled
|
|
263
|
+
* tasks are refused so the runner keeps exclusive ownership of their
|
|
264
|
+
* lifecycle.
|
|
265
|
+
* @returns true when applied.
|
|
266
|
+
*/
|
|
267
|
+
archiveTask(id: string): boolean {
|
|
268
|
+
const { tasks, archived } = applyArchiveTask(this.tasks, id, this.now())
|
|
269
|
+
if (!archived) return false
|
|
270
|
+
this.tasks = [...tasks]
|
|
271
|
+
this.persistAndNotify()
|
|
272
|
+
return true
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Restore an archived task back onto the board (same status column). */
|
|
276
|
+
restoreTask(id: string): boolean {
|
|
277
|
+
const { tasks, archived } = applyRestoreTask(this.tasks, id, this.now())
|
|
278
|
+
if (!archived) return false
|
|
279
|
+
this.tasks = [...tasks]
|
|
280
|
+
this.persistAndNotify()
|
|
281
|
+
return true
|
|
282
|
+
}
|
|
283
|
+
|
|
206
284
|
// --- scheduling ---------------------------------------------------------------
|
|
207
285
|
|
|
208
286
|
/**
|
|
@@ -233,6 +311,18 @@ export class BoardController {
|
|
|
233
311
|
this.persistAndNotify()
|
|
234
312
|
}
|
|
235
313
|
|
|
314
|
+
/**
|
|
315
|
+
* Reload the ledger from the persisted store without notifying subscribers.
|
|
316
|
+
* The scheduler calls this before every tick so a task deleted in another
|
|
317
|
+
* tab (or a stale in-memory copy) can never be fired from this tab: the
|
|
318
|
+
* fire decision and the subsequent roll-forward both run on the freshest
|
|
319
|
+
* persisted truth. Deliberately silent — same-origin external changes still
|
|
320
|
+
* reach subscribers through the storage-event subscription.
|
|
321
|
+
*/
|
|
322
|
+
reloadFromStore(): void {
|
|
323
|
+
this.tasks = this.deps.store.load()
|
|
324
|
+
}
|
|
325
|
+
|
|
236
326
|
/**
|
|
237
327
|
* Jump to an execution's session transcript. Selecting the session changes
|
|
238
328
|
* `current`, which closes the board (the conversation view takes over).
|
package/src/core/execution.ts
CHANGED
|
@@ -15,19 +15,40 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import type { ExecutionRecord, TaskRecord } from './tasks.ts'
|
|
17
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
|
+
|
|
18
28
|
/** The narrow sessions face the service needs. */
|
|
19
29
|
export interface SessionsExecutionFace {
|
|
20
30
|
list: {
|
|
21
31
|
getSnapshot(): {
|
|
22
32
|
/** Baseline arrival lifecycle — 'pending' until the host list has loaded. */
|
|
23
33
|
phase: 'pending' | 'ready'
|
|
24
|
-
byId: Record<string,
|
|
34
|
+
byId: Record<string, ExecutionSessionSummary>
|
|
25
35
|
}
|
|
26
36
|
subscribe(fn: () => void): () => void
|
|
27
37
|
}
|
|
28
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 }>
|
|
29
47
|
}
|
|
30
48
|
|
|
49
|
+
/** Settled result of one agent-preset switch, shared by concurrent waiters. */
|
|
50
|
+
type PresetSelectResult = { ok: true } | { ok: false; error: unknown }
|
|
51
|
+
|
|
31
52
|
/** The narrow workspaces face the service needs. */
|
|
32
53
|
export interface WorkspacesExecutionFace {
|
|
33
54
|
list: {
|
|
@@ -57,6 +78,12 @@ export interface SessionDriver {
|
|
|
57
78
|
content: readonly unknown[],
|
|
58
79
|
mode: 'queue',
|
|
59
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 }>
|
|
60
87
|
getSnapshot(): { running: boolean; lastAgentError: string | null; turnEnds: ReadonlyMap<number, number> }
|
|
61
88
|
subscribe(fn: () => void): () => void
|
|
62
89
|
}
|
|
@@ -65,6 +92,8 @@ export interface SessionDriver {
|
|
|
65
92
|
export interface ExecutionEnvironment {
|
|
66
93
|
sessions: SessionsExecutionFace
|
|
67
94
|
workspaces: WorkspacesExecutionFace
|
|
95
|
+
/** Agent-preset wire face; absent on deployments without preset support. */
|
|
96
|
+
presets?: PresetsExecutionFace
|
|
68
97
|
/** Raw-history reader for failure detection of never-opened sessions. */
|
|
69
98
|
history?: HistoryExecutionFace
|
|
70
99
|
}
|
|
@@ -80,6 +109,19 @@ function messageOf(error: unknown): string {
|
|
|
80
109
|
return String(error)
|
|
81
110
|
}
|
|
82
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
|
+
|
|
83
125
|
/** Whether a `turn/end` payload closed the turn with an error reason. */
|
|
84
126
|
function isErrorTurnEnd(data: unknown): boolean {
|
|
85
127
|
if (typeof data !== 'object' || data === null) return false
|
|
@@ -98,6 +140,16 @@ function isErrorTurnEnd(data: unknown): boolean {
|
|
|
98
140
|
* every failure path is reported as a settled event.
|
|
99
141
|
*/
|
|
100
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
|
+
|
|
101
153
|
/** @param env - the runtime faces (real or fake). */
|
|
102
154
|
constructor(private readonly env: ExecutionEnvironment) {}
|
|
103
155
|
|
|
@@ -106,14 +158,24 @@ export class ExecutionService {
|
|
|
106
158
|
execution: ExecutionRecord,
|
|
107
159
|
onEvent: (event: ExecutionEvent) => void,
|
|
108
160
|
): Promise<void> {
|
|
161
|
+
const settleFailed = (error: string): void => {
|
|
162
|
+
onEvent({ kind: 'settled', taskId: task.id, executionId: execution.id, outcome: 'failed', error })
|
|
163
|
+
}
|
|
109
164
|
try {
|
|
110
|
-
const sessionId = await this.connectSession()
|
|
165
|
+
const sessionId = await this.connectSession(task.workspaceId)
|
|
111
166
|
onEvent({ kind: 'started', taskId: task.id, executionId: execution.id, sessionId })
|
|
112
167
|
const driver = this.driverOf(sessionId)
|
|
113
168
|
if (driver === undefined) {
|
|
114
|
-
|
|
169
|
+
settleFailed('execution session is not ready')
|
|
115
170
|
return
|
|
116
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(driver, task, sessionId, settleFailed)) return
|
|
178
|
+
if (!await this.applyPermission(driver, task, settleFailed)) return
|
|
117
179
|
// Best-effort rename so the execution is recognizable in the session list.
|
|
118
180
|
await driver.rename(task.title).catch(() => { /* rename is cosmetic */ })
|
|
119
181
|
// Baseline the turn counter BEFORE the prompt round-trip: a turn that
|
|
@@ -122,21 +184,118 @@ export class ExecutionService {
|
|
|
122
184
|
const baseline = driver.getSnapshot().turnEnds.size
|
|
123
185
|
const accepted = await this.sendPrompt(driver, task)
|
|
124
186
|
if (!accepted.ok) {
|
|
125
|
-
|
|
126
|
-
kind: 'settled', taskId: task.id, executionId: execution.id, outcome: 'failed',
|
|
127
|
-
error: messageOf(accepted.error),
|
|
128
|
-
})
|
|
187
|
+
settleFailed(messageOf(accepted.error))
|
|
129
188
|
return
|
|
130
189
|
}
|
|
131
190
|
this.watchForSettlement(driver, task.id, execution.id, onEvent, baseline)
|
|
132
191
|
} catch (error) {
|
|
133
|
-
|
|
134
|
-
kind: 'settled', taskId: task.id, executionId: execution.id, outcome: 'failed',
|
|
135
|
-
error: messageOf(error),
|
|
136
|
-
})
|
|
192
|
+
settleFailed(messageOf(error))
|
|
137
193
|
}
|
|
138
194
|
}
|
|
139
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
|
+
driver: SessionDriver,
|
|
204
|
+
task: TaskRecord,
|
|
205
|
+
sessionId: string,
|
|
206
|
+
settleFailed: (error: string) => void,
|
|
207
|
+
): Promise<boolean> {
|
|
208
|
+
const mode = task.mode
|
|
209
|
+
if (mode === undefined || mode === '') return true
|
|
210
|
+
const summary = this.env.sessions.list.getSnapshot().byId[sessionId]
|
|
211
|
+
if (summary?.blank === false) {
|
|
212
|
+
settleFailed(`cannot switch agent preset to ${mode}: the execution session is not blank`)
|
|
213
|
+
return false
|
|
214
|
+
}
|
|
215
|
+
// Fast path: the list mirror already reports the pinned preset. The
|
|
216
|
+
// mirror can lag a just-applied switch, though, so this guard alone is
|
|
217
|
+
// not enough under concurrent runs — `switchPreset` below dedupes the
|
|
218
|
+
// wire call for the lag window.
|
|
219
|
+
if (summary?.agentPreset === mode) return true
|
|
220
|
+
const presets = this.env.presets
|
|
221
|
+
if (presets === undefined) {
|
|
222
|
+
settleFailed(`this deployment does not support agent presets (task asks for ${mode})`)
|
|
223
|
+
return false
|
|
224
|
+
}
|
|
225
|
+
try {
|
|
226
|
+
const result = await this.switchPreset(presets, sessionId, mode)
|
|
227
|
+
if (!result.ok) {
|
|
228
|
+
// A list race can leave the summary without the preset label even
|
|
229
|
+
// though the blank session already runs it; the wire answers that
|
|
230
|
+
// case with agent-preset-conflict (existingPreset === requested).
|
|
231
|
+
// The requested composition is already in place, so count it as
|
|
232
|
+
// applied instead of failing the run.
|
|
233
|
+
if (presetAlreadyRuns(result.error, mode)) {
|
|
234
|
+
this.env.sessions.noteAgentPreset?.(sessionId, mode)
|
|
235
|
+
return true
|
|
236
|
+
}
|
|
237
|
+
settleFailed(`agent preset switch to ${mode} rejected: ${messageOf(result.error)}`)
|
|
238
|
+
return false
|
|
239
|
+
}
|
|
240
|
+
} catch (error) {
|
|
241
|
+
settleFailed(`agent preset switch to ${mode} failed: ${messageOf(error)}`)
|
|
242
|
+
return false
|
|
243
|
+
}
|
|
244
|
+
this.env.sessions.noteAgentPreset?.(sessionId, mode)
|
|
245
|
+
return true
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* One in-flight `select` per (session, preset): concurrent runs against
|
|
250
|
+
* the same blank session share the same wire call instead of each issuing
|
|
251
|
+
* a duplicate RPC. The entry is removed once the call settles, so a later
|
|
252
|
+
* run (after a shared failure, say) issues a fresh switch.
|
|
253
|
+
*/
|
|
254
|
+
private switchPreset(
|
|
255
|
+
presets: PresetsExecutionFace,
|
|
256
|
+
sessionId: string,
|
|
257
|
+
mode: string,
|
|
258
|
+
): Promise<PresetSelectResult> {
|
|
259
|
+
const key = `${sessionId}\u0000${mode}`
|
|
260
|
+
const inflight = this.presetSwitches.get(key)
|
|
261
|
+
if (inflight !== undefined) return inflight
|
|
262
|
+
const attempt = presets.select(sessionId, mode).finally(() => {
|
|
263
|
+
if (this.presetSwitches.get(key) === attempt) this.presetSwitches.delete(key)
|
|
264
|
+
})
|
|
265
|
+
this.presetSwitches.set(key, attempt)
|
|
266
|
+
return attempt
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Apply the task-pinned permission preset through the `/permission <id>`
|
|
271
|
+
* slash command. No-op when the task pins none; fails the run when the
|
|
272
|
+
* admission is rejected or no command claimed the line.
|
|
273
|
+
*/
|
|
274
|
+
private async applyPermission(
|
|
275
|
+
driver: SessionDriver,
|
|
276
|
+
task: TaskRecord,
|
|
277
|
+
settleFailed: (error: string) => void,
|
|
278
|
+
): Promise<boolean> {
|
|
279
|
+
const permission = task.permission
|
|
280
|
+
if (permission === undefined) return true
|
|
281
|
+
const line = `/permission ${permission}`
|
|
282
|
+
try {
|
|
283
|
+
const result = await driver.command(line)
|
|
284
|
+
if (!result.ok) {
|
|
285
|
+
settleFailed(`permission command rejected: ${messageOf(result.error)}`)
|
|
286
|
+
return false
|
|
287
|
+
}
|
|
288
|
+
if (!result.matched) {
|
|
289
|
+
settleFailed(`permission command not recognized: ${line}`)
|
|
290
|
+
return false
|
|
291
|
+
}
|
|
292
|
+
} catch (error) {
|
|
293
|
+
settleFailed(`permission command failed: ${messageOf(error)}`)
|
|
294
|
+
return false
|
|
295
|
+
}
|
|
296
|
+
return true
|
|
297
|
+
}
|
|
298
|
+
|
|
140
299
|
/**
|
|
141
300
|
* Inspect a reloaded/background task that was left 'running' and emit a
|
|
142
301
|
* settled event when its session already finished.
|
|
@@ -198,8 +357,16 @@ export class ExecutionService {
|
|
|
198
357
|
}
|
|
199
358
|
}
|
|
200
359
|
|
|
201
|
-
private async connectSession(): Promise<string> {
|
|
360
|
+
private async connectSession(taskWorkspaceId: string | undefined): Promise<string> {
|
|
202
361
|
const workspace = this.env.workspaces.list.getSnapshot()
|
|
362
|
+
if (taskWorkspaceId !== undefined && taskWorkspaceId !== '') {
|
|
363
|
+
// A task-pinned workspace must exist in the list: connecting an
|
|
364
|
+
// unknown id would only defer the failure into the wire.
|
|
365
|
+
if (!workspace.items.some(item => item.workspaceId === taskWorkspaceId)) {
|
|
366
|
+
throw new Error(`task workspace is not available: ${taskWorkspaceId}`)
|
|
367
|
+
}
|
|
368
|
+
return this.env.workspaces.connectWorkspace(taskWorkspaceId)
|
|
369
|
+
}
|
|
203
370
|
const workspaceId = workspace.recentWorkspaceId ?? workspace.items[0]?.workspaceId
|
|
204
371
|
if (workspaceId === undefined) {
|
|
205
372
|
throw new Error('no workspace available to run the task in')
|
package/src/core/scheduler.ts
CHANGED
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
* a task still running at its due instant is skipped by the controller's
|
|
10
10
|
* runTask guard and simply waits for the next cron match.
|
|
11
11
|
*
|
|
12
|
+
* A deleted task can never fire again from a stale in-memory copy: every
|
|
13
|
+
* tick first re-reads the persisted ledger through the optional `refresh`
|
|
14
|
+
* hook (the controller reloads its store), so the fire decision and the
|
|
15
|
+
* subsequent roll-forward both run on the freshest persisted truth.
|
|
16
|
+
*
|
|
12
17
|
* The ticker is controlled: `start` arms a single interval guarded by an
|
|
13
18
|
* idempotence check (a second start while running is a no-op, so wiring can
|
|
14
19
|
* never leak a duplicate timer), and `stop`/`dispose` clear the timer and
|
|
@@ -25,6 +30,13 @@ import type { TaskRecord } from './tasks.ts'
|
|
|
25
30
|
export interface SchedulerDeps {
|
|
26
31
|
/** Read the current task ledger (the controller snapshot). */
|
|
27
32
|
tasks(): readonly TaskRecord[]
|
|
33
|
+
/**
|
|
34
|
+
* Re-sync the task ledger from its persisted source before the tick decides
|
|
35
|
+
* what is due. The wiring passes the controller's store reload so a task
|
|
36
|
+
* deleted elsewhere can never be fired from a stale in-memory copy.
|
|
37
|
+
* Optional: absent in pure in-memory harnesses.
|
|
38
|
+
*/
|
|
39
|
+
refresh?: () => void
|
|
28
40
|
/** Clock; defaults to Date.now in the controller wiring. */
|
|
29
41
|
now(): number
|
|
30
42
|
/** Trigger one task's real execution (the controller's runTask); resolves
|
|
@@ -112,6 +124,9 @@ export class SchedulerService {
|
|
|
112
124
|
async tick(): Promise<void> {
|
|
113
125
|
if (this.disposed) return
|
|
114
126
|
if (this.deps.ready !== undefined && !this.deps.ready()) return
|
|
127
|
+
// Freshest persisted truth before any fire decision: a task deleted in
|
|
128
|
+
// another tab (or a stale in-memory copy) must never be triggered.
|
|
129
|
+
this.deps.refresh?.()
|
|
115
130
|
const now = this.deps.now()
|
|
116
131
|
for (const task of this.deps.tasks()) {
|
|
117
132
|
const schedule = task.schedule
|
package/src/core/store.ts
CHANGED
|
@@ -14,8 +14,7 @@
|
|
|
14
14
|
* localStorage backend.
|
|
15
15
|
*/
|
|
16
16
|
import { isValidCron } from './schedule.ts'
|
|
17
|
-
import type
|
|
18
|
-
import { isTaskStatus } from './tasks.ts'
|
|
17
|
+
import { isTaskPermission, isTaskStatus, type ScheduleRule, type TaskRecord, type TaskPermission, type TaskStatus } from './tasks.ts'
|
|
19
18
|
|
|
20
19
|
/** Persistence seam for the task ledger. */
|
|
21
20
|
export interface TaskStore {
|
|
@@ -64,6 +63,9 @@ function isTaskRecordShape(value: unknown): value is Omit<TaskRecord, 'status'>
|
|
|
64
63
|
if (typeof record.prompt !== 'string') return false
|
|
65
64
|
if (typeof record.createdAt !== 'number') return false
|
|
66
65
|
if (typeof record.updatedAt !== 'number') return false
|
|
66
|
+
if (record.workspaceId !== undefined && typeof record.workspaceId !== 'string') return false
|
|
67
|
+
if (record.mode !== undefined && typeof record.mode !== 'string') return false
|
|
68
|
+
if (record.permission !== undefined && typeof record.permission !== 'string') return false
|
|
67
69
|
if (!Array.isArray(record.executions)) return false
|
|
68
70
|
for (const execution of record.executions) {
|
|
69
71
|
if (typeof execution !== 'object' || execution === null) return false
|
|
@@ -78,6 +80,11 @@ function isTaskRecordShape(value: unknown): value is Omit<TaskRecord, 'status'>
|
|
|
78
80
|
return true
|
|
79
81
|
}
|
|
80
82
|
|
|
83
|
+
/** Collapse a blank persisted target string to undefined (clears the pin). */
|
|
84
|
+
function normalizeTargetId(value: string | undefined): string | undefined {
|
|
85
|
+
return value !== undefined && value.trim() === '' ? undefined : value
|
|
86
|
+
}
|
|
87
|
+
|
|
81
88
|
/** A task record is structurally valid if it round-trips through the UI. */
|
|
82
89
|
export function isTaskRecord(value: unknown): value is TaskRecord {
|
|
83
90
|
return isTaskRecordShape(value) && isTaskStatus(value.status)
|
|
@@ -136,6 +143,13 @@ export function parseLedger(raw: string | null): TaskRecord[] {
|
|
|
136
143
|
// clear a malformed persisted rule rather than leave it in the row.
|
|
137
144
|
const task: TaskRecord = { ...row, status: normalizeStatus(row.status) }
|
|
138
145
|
task.schedule = normalizeSchedule(row.schedule)
|
|
146
|
+
// Execution targets are normalized like the schedule: blank strings
|
|
147
|
+
// clear the pin and unknown permission strings from a future version
|
|
148
|
+
// fall back to the session default instead of dropping the row.
|
|
149
|
+
task.workspaceId = normalizeTargetId(row.workspaceId)
|
|
150
|
+
task.mode = normalizeTargetId(row.mode)
|
|
151
|
+
task.archivedAt = typeof row.archivedAt === 'number' && Number.isFinite(row.archivedAt) ? row.archivedAt : undefined
|
|
152
|
+
task.permission = isTaskPermission(row.permission) ? row.permission as TaskPermission : undefined
|
|
139
153
|
tasks.push(task)
|
|
140
154
|
}
|
|
141
155
|
return tasks
|
package/src/core/tasks.ts
CHANGED
|
@@ -64,6 +64,41 @@ export interface TaskRecord {
|
|
|
64
64
|
executions: ExecutionRecord[]
|
|
65
65
|
/** Optional scheduled-run rule (absent on tasks without a schedule). */
|
|
66
66
|
schedule?: ScheduleRule
|
|
67
|
+
/**
|
|
68
|
+
* Workspace the execution must run in (a workspace-list id); absent means
|
|
69
|
+
* the recent-workspace fallback at execution time.
|
|
70
|
+
*/
|
|
71
|
+
workspaceId?: string
|
|
72
|
+
/**
|
|
73
|
+
* Agent preset the execution session must be composed from (an
|
|
74
|
+
* `agentPreset.list` id); absent means the deployment default.
|
|
75
|
+
*/
|
|
76
|
+
mode?: string
|
|
77
|
+
/**
|
|
78
|
+
* Permission preset applied to the execution session through the
|
|
79
|
+
* `/permission <id>` slash command; absent leaves the session default.
|
|
80
|
+
*/
|
|
81
|
+
permission?: TaskPermission
|
|
82
|
+
/**
|
|
83
|
+
* When the task was archived (ms epoch). Archived tasks keep their status
|
|
84
|
+
* and execution history but leave the main board; absent means on-board.
|
|
85
|
+
*/
|
|
86
|
+
archivedAt?: number
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Statuses a settled task may be archived from. */
|
|
90
|
+
export const ARCHIVABLE_STATUSES: readonly TaskStatus[] = ['done', 'failed']
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
/** Permission presets a task may pin on its execution session (the `/permission <id>` ids). */
|
|
94
|
+
export const TASK_PERMISSIONS = ['read-only', 'workspace-write', 'danger-full-access'] as const
|
|
95
|
+
|
|
96
|
+
/** One permission preset id. */
|
|
97
|
+
export type TaskPermission = typeof TASK_PERMISSIONS[number]
|
|
98
|
+
|
|
99
|
+
/** Whether an unknown value is a known permission preset id. */
|
|
100
|
+
export function isTaskPermission(value: unknown): value is TaskPermission {
|
|
101
|
+
return typeof value === 'string' && (TASK_PERMISSIONS as readonly string[]).includes(value)
|
|
67
102
|
}
|
|
68
103
|
|
|
69
104
|
/** Input for creating a task. */
|
|
@@ -71,6 +106,18 @@ export interface NewTaskInput {
|
|
|
71
106
|
title: string
|
|
72
107
|
description: string
|
|
73
108
|
prompt: string
|
|
109
|
+
/** Workspace the execution must run in; empty/absent = the recent workspace. */
|
|
110
|
+
workspaceId?: string
|
|
111
|
+
/** Agent preset the execution session must be composed from; empty/absent = deployment default. */
|
|
112
|
+
mode?: string
|
|
113
|
+
/** Permission preset applied to the execution session; absent = session default. */
|
|
114
|
+
permission?: TaskPermission
|
|
115
|
+
/**
|
|
116
|
+
* Optional scheduled-run rule requested at creation time (the new-task
|
|
117
|
+
* dialog): an enable flag plus a 5-field cron expression. The create use
|
|
118
|
+
* case arms it only when enabled and the expression is valid.
|
|
119
|
+
*/
|
|
120
|
+
schedule?: { enabled: boolean; cron: string }
|
|
74
121
|
}
|
|
75
122
|
|
|
76
123
|
/** The five kanban columns, in display order. */
|
|
@@ -103,6 +150,12 @@ export function canMoveManually(_from: TaskStatus, to: TaskStatus): boolean {
|
|
|
103
150
|
return (MANUAL_STATUSES as readonly TaskStatus[]).includes(to)
|
|
104
151
|
}
|
|
105
152
|
|
|
153
|
+
/** Normalize one optional execution-target string: trim; blank collapses to undefined. */
|
|
154
|
+
function normalizeTargetId(value: string | undefined): string | undefined {
|
|
155
|
+
const trimmed = value?.trim()
|
|
156
|
+
return trimmed === undefined || trimmed === '' ? undefined : trimmed
|
|
157
|
+
}
|
|
158
|
+
|
|
106
159
|
/** Create a task from user input. */
|
|
107
160
|
export function createTask(input: NewTaskInput, now: number, id: string): TaskRecord {
|
|
108
161
|
return {
|
|
@@ -114,6 +167,9 @@ export function createTask(input: NewTaskInput, now: number, id: string): TaskRe
|
|
|
114
167
|
createdAt: now,
|
|
115
168
|
updatedAt: now,
|
|
116
169
|
executions: [],
|
|
170
|
+
workspaceId: normalizeTargetId(input.workspaceId),
|
|
171
|
+
mode: normalizeTargetId(input.mode),
|
|
172
|
+
permission: isTaskPermission(input.permission) ? input.permission : undefined,
|
|
117
173
|
}
|
|
118
174
|
}
|
|
119
175
|
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Archive/restore task use case: move a settled (done/failed) task off the
|
|
3
|
+
* main board and back. Purely a marker transition — the task keeps its
|
|
4
|
+
* status, execution history and transcript references, so archiving never
|
|
5
|
+
* loses traceability and restoring lands it in the exact column it left.
|
|
6
|
+
*/
|
|
7
|
+
import type { TaskRecord } from '../tasks.ts'
|
|
8
|
+
import { ARCHIVABLE_STATUSES } from '../tasks.ts'
|
|
9
|
+
|
|
10
|
+
/** Result of an archive transition. */
|
|
11
|
+
export interface ArchiveTaskResult {
|
|
12
|
+
/** The next ledger. */
|
|
13
|
+
tasks: readonly TaskRecord[]
|
|
14
|
+
/** Whether the archive was applied (false = unknown task / not archivable). */
|
|
15
|
+
archived: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Archive one task: only settled statuses (done/failed) can be archived;
|
|
20
|
+
* a running or not-yet-settled task stays on the board (its runner still
|
|
21
|
+
* owns its lifecycle). Already-archived tasks are a no-op.
|
|
22
|
+
*/
|
|
23
|
+
export function applyArchiveTask(
|
|
24
|
+
tasks: readonly TaskRecord[],
|
|
25
|
+
id: string,
|
|
26
|
+
now: number,
|
|
27
|
+
): ArchiveTaskResult {
|
|
28
|
+
let applied = false
|
|
29
|
+
const next = tasks.map(task => {
|
|
30
|
+
if (task.id !== id || task.archivedAt !== undefined) return task
|
|
31
|
+
if (!(ARCHIVABLE_STATUSES as readonly string[]).includes(task.status)) return task
|
|
32
|
+
applied = true
|
|
33
|
+
return { ...task, archivedAt: now, updatedAt: now }
|
|
34
|
+
})
|
|
35
|
+
return { tasks: next, archived: applied }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Restore one task back onto the main board (clears the archive marker). */
|
|
39
|
+
export function applyRestoreTask(tasks: readonly TaskRecord[], id: string, now: number): ArchiveTaskResult {
|
|
40
|
+
let applied = false
|
|
41
|
+
const next = tasks.map(task => {
|
|
42
|
+
if (task.id !== id || task.archivedAt === undefined) return task
|
|
43
|
+
applied = true
|
|
44
|
+
const { archivedAt: _archived, ...rest } = task
|
|
45
|
+
return { ...rest, updatedAt: now }
|
|
46
|
+
})
|
|
47
|
+
return { tasks: next, archived: applied }
|
|
48
|
+
}
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* title. Pure ledger transition (no persistence or notify — the controller
|
|
4
4
|
* orchestrates those), so it is unit-testable without any runtime face.
|
|
5
5
|
*/
|
|
6
|
-
import {
|
|
6
|
+
import { isValidCron, nextRunAtMs } from '../schedule.ts'
|
|
7
|
+
import { createTask, withSchedule, type NewTaskInput, type TaskRecord } from '../tasks.ts'
|
|
7
8
|
|
|
8
9
|
/** Result of a create transition: the new task (when accepted) + the next ledger. */
|
|
9
10
|
export interface CreateTaskResult {
|
|
@@ -17,7 +18,7 @@ export interface CreateTaskResult {
|
|
|
17
18
|
* Apply a create against the current ledger. Returns the new task and the
|
|
18
19
|
* appended ledger, or the unchanged ledger when the title is blank.
|
|
19
20
|
* @param tasks - current ledger.
|
|
20
|
-
* @param input - raw user input (title/description/prompt).
|
|
21
|
+
* @param input - raw user input (title/description/prompt + optional schedule).
|
|
21
22
|
* @param now - clock instant (ms epoch).
|
|
22
23
|
* @param id - minted task id.
|
|
23
24
|
*/
|
|
@@ -28,6 +29,14 @@ export function applyCreateTask(
|
|
|
28
29
|
id: string,
|
|
29
30
|
): CreateTaskResult {
|
|
30
31
|
if (input.title.trim() === '') return { task: undefined, tasks }
|
|
31
|
-
|
|
32
|
+
let task = createTask(input, now, id)
|
|
33
|
+
// Arm the requested schedule (new-task dialog): only an enabled rule with
|
|
34
|
+
// a valid cron is applied; blank, invalid, or disabled requests leave the
|
|
35
|
+
// task unscheduled.
|
|
36
|
+
const requested = input.schedule
|
|
37
|
+
if (requested?.enabled === true && requested.cron.trim() !== '' && isValidCron(requested.cron)) {
|
|
38
|
+
const cron = requested.cron.trim()
|
|
39
|
+
task = withSchedule(task, { enabled: true, cron, nextRunAt: nextRunAtMs(cron, now) }, now)
|
|
40
|
+
}
|
|
32
41
|
return { task, tasks: [...tasks, task] }
|
|
33
42
|
}
|