@linxin666/dsh-client-ui-task-board 0.1.17 → 0.1.19
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 +3 -3
- package/README.zh.md +3 -3
- package/lib/client.js +430 -134
- package/lib/client.js.map +1 -1
- package/lib/types/client/PluginSettingsCard.d.ts +11 -0
- package/lib/types/client/TaskBoardSettingsCard.d.ts +5 -0
- package/lib/types/client/locales.d.ts +7 -0
- package/lib/types/client/schedule-presets.d.ts +10 -0
- package/lib/types/client/settings-form.d.ts +8 -0
- package/lib/types/core/controller.d.ts +27 -0
- package/lib/types/core/execution.d.ts +16 -0
- package/lib/types/core/scheduler.d.ts +7 -0
- package/lib/types/core/tasks.d.ts +16 -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/package.json +1 -1
- package/src/client/PluginSettingsCard.tsx +50 -47
- package/src/client/TaskBoardSettingsCard.tsx +8 -0
- package/src/client/board/NewTaskModal.tsx +68 -0
- package/src/client/board/TaskBoard.tsx +56 -25
- package/src/client/board/TaskDetail.tsx +27 -8
- package/src/client/board.module.css +68 -1
- package/src/client/index.ts +16 -7
- package/src/client/locales.ts +15 -1
- package/src/client/schedule-presets.ts +13 -0
- package/src/client/settings-card.module.css +9 -0
- package/src/client/settings-form.ts +15 -1
- package/src/core/controller.ts +58 -0
- package/src/core/execution.ts +41 -2
- package/src/core/scheduler.ts +15 -0
- package/src/core/store.ts +1 -0
- package/src/core/tasks.ts +15 -0
- package/src/core/use-cases/task-archive.ts +48 -0
- package/src/core/use-cases/task-create.ts +12 -3
|
@@ -9,6 +9,7 @@ import type { BoardController } from '../../core/controller.ts'
|
|
|
9
9
|
import { isValidCron } from '../../core/schedule.ts'
|
|
10
10
|
import { MANUAL_STATUSES, TASK_PERMISSIONS, type ExecutionRecord, type TaskPermission, type TaskRecord, type TaskStatus } from '../../core/tasks.ts'
|
|
11
11
|
import { t, type TaskBoardKey } from '../locales.ts'
|
|
12
|
+
import { SCHEDULE_PRESETS } from '../schedule-presets.ts'
|
|
12
13
|
import css from '../board.module.css'
|
|
13
14
|
import { ConfirmDialog } from './ConfirmDialog.tsx'
|
|
14
15
|
import { formatTime } from './TaskCard.tsx'
|
|
@@ -58,14 +59,6 @@ function ExecutionRow({ execution, onOpen }: { execution: ExecutionRecord; onOpe
|
|
|
58
59
|
)
|
|
59
60
|
}
|
|
60
61
|
|
|
61
|
-
/** Common scheduled-run presets (cron → locale label). */
|
|
62
|
-
const SCHEDULE_PRESETS: ReadonlyArray<{ cron: string; label: TaskBoardKey }> = [
|
|
63
|
-
{ cron: '0 9 * * *', label: 'detail.schedule.preset.daily9' },
|
|
64
|
-
{ cron: '0 * * * *', label: 'detail.schedule.preset.hourly' },
|
|
65
|
-
{ cron: '*/10 * * * *', label: 'detail.schedule.preset.tenMin' },
|
|
66
|
-
{ cron: '0 9 * * 1', label: 'detail.schedule.preset.weeklyMon9' },
|
|
67
|
-
]
|
|
68
|
-
|
|
69
62
|
/** The execution-target editor: workspace / mode / permission pickers. */
|
|
70
63
|
function ExecutionSettingsSection({ controller, task }: { controller: BoardController; task: TaskRecord }) {
|
|
71
64
|
const [options, setOptions] = useState(controller.getSnapshot().executionOptions)
|
|
@@ -324,6 +317,31 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
|
|
|
324
317
|
>
|
|
325
318
|
{current.executions.length === 0 ? t('detail.run') : t('detail.rerun')}
|
|
326
319
|
</button>
|
|
320
|
+
{current.archivedAt !== undefined ? (
|
|
321
|
+
<button
|
|
322
|
+
type="button"
|
|
323
|
+
className={css.primaryButton}
|
|
324
|
+
onClick={() => {
|
|
325
|
+
controller.restoreTask(current.id)
|
|
326
|
+
controller.closeTask()
|
|
327
|
+
}}
|
|
328
|
+
>
|
|
329
|
+
{t('detail.restore')}
|
|
330
|
+
</button>
|
|
331
|
+
) : (
|
|
332
|
+
(current.status === 'done' || current.status === 'failed') && (
|
|
333
|
+
<button
|
|
334
|
+
type="button"
|
|
335
|
+
className={css.ghostButton}
|
|
336
|
+
onClick={() => {
|
|
337
|
+
controller.archiveTask(current.id)
|
|
338
|
+
controller.closeTask()
|
|
339
|
+
}}
|
|
340
|
+
>
|
|
341
|
+
{t('detail.archive')}
|
|
342
|
+
</button>
|
|
343
|
+
)
|
|
344
|
+
)}
|
|
327
345
|
<button
|
|
328
346
|
type="button"
|
|
329
347
|
className={css.dangerButton}
|
|
@@ -333,6 +351,7 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
|
|
|
333
351
|
</button>
|
|
334
352
|
<span className={css.detailMeta}>
|
|
335
353
|
{t('board.created')} {formatTime(current.createdAt)}
|
|
354
|
+
{current.archivedAt !== undefined && ` · ${t('detail.archivedAt', { time: formatTime(current.archivedAt) })}`}
|
|
336
355
|
</span>
|
|
337
356
|
</footer>
|
|
338
357
|
</div>
|
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
inset: 0;
|
|
19
19
|
display: none;
|
|
20
20
|
z-index: 60;
|
|
21
|
+
container-name: task-board-view;
|
|
22
|
+
container-type: inline-size;
|
|
21
23
|
/* Opaque backdrop: the conversation subtree stays mounted under the view.
|
|
22
24
|
Even if the hide rule below were defeated by a stronger host inline
|
|
23
25
|
style, this background keeps sticky code-block banners (host z-index 6)
|
|
@@ -103,6 +105,7 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
|
|
|
103
105
|
.board {
|
|
104
106
|
display: flex;
|
|
105
107
|
flex-direction: column;
|
|
108
|
+
box-sizing: border-box;
|
|
106
109
|
height: 100%;
|
|
107
110
|
min-width: 0;
|
|
108
111
|
min-height: 0;
|
|
@@ -128,6 +131,12 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
|
|
|
128
131
|
white-space: nowrap;
|
|
129
132
|
}
|
|
130
133
|
|
|
134
|
+
.backButton {
|
|
135
|
+
display: inline-flex;
|
|
136
|
+
align-items: center;
|
|
137
|
+
gap: 4px;
|
|
138
|
+
}
|
|
139
|
+
|
|
131
140
|
.search {
|
|
132
141
|
flex: 0 1 260px;
|
|
133
142
|
min-width: 120px;
|
|
@@ -148,10 +157,38 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
|
|
|
148
157
|
|
|
149
158
|
.columns {
|
|
150
159
|
display: grid;
|
|
151
|
-
grid-
|
|
160
|
+
grid-auto-flow: column;
|
|
161
|
+
grid-auto-columns: minmax(220px, 1fr);
|
|
152
162
|
gap: 12px;
|
|
153
163
|
flex: 1;
|
|
154
164
|
min-height: 0;
|
|
165
|
+
overflow-x: auto;
|
|
166
|
+
overflow-y: hidden;
|
|
167
|
+
overscroll-behavior-inline: contain;
|
|
168
|
+
padding-bottom: 6px;
|
|
169
|
+
scrollbar-color: var(--dsw-alias-border-l3) var(--dsw-alias-interactive-bg-hover);
|
|
170
|
+
scrollbar-width: thin;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.columns::-webkit-scrollbar {
|
|
174
|
+
height: 10px;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.columns::-webkit-scrollbar-track {
|
|
178
|
+
background: var(--dsw-alias-interactive-bg-hover);
|
|
179
|
+
border-radius: 999px;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.columns::-webkit-scrollbar-thumb {
|
|
183
|
+
background: var(--dsw-alias-border-l3);
|
|
184
|
+
background-clip: content-box;
|
|
185
|
+
border: 2px solid transparent;
|
|
186
|
+
border-radius: 999px;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.columns::-webkit-scrollbar-thumb:hover {
|
|
190
|
+
background: var(--dsw-alias-border-l4);
|
|
191
|
+
background-clip: content-box;
|
|
155
192
|
}
|
|
156
193
|
|
|
157
194
|
.column {
|
|
@@ -770,6 +807,36 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
|
|
|
770
807
|
color: var(--dsw-alias-label-tertiary);
|
|
771
808
|
}
|
|
772
809
|
|
|
810
|
+
/* --- constrained board containers ------------------------------------------ */
|
|
811
|
+
|
|
812
|
+
@container task-board-view (max-width: 720px) {
|
|
813
|
+
.board {
|
|
814
|
+
gap: 10px;
|
|
815
|
+
padding: 10px;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
.columns {
|
|
819
|
+
gap: 10px;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
@container task-board-view (max-width: 600px) {
|
|
824
|
+
.boardHeader {
|
|
825
|
+
flex-wrap: wrap;
|
|
826
|
+
gap: 8px;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
.search {
|
|
830
|
+
flex: 1 1 calc(100% - 72px);
|
|
831
|
+
min-width: 0;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
.boardHeader > button {
|
|
835
|
+
flex: 1 1 0;
|
|
836
|
+
min-width: max-content;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
773
840
|
/* -----------------------------------------------------------------------------
|
|
774
841
|
* Polish pass: complete interaction states (hover / active / focus-visible /
|
|
775
842
|
* disabled), unified 120ms motion, and reduced-motion fallback.
|
package/src/client/index.ts
CHANGED
|
@@ -92,13 +92,19 @@ export function apply(ctx: ClientContext): void {
|
|
|
92
92
|
const binder = ctx.get('webUiSettings') ?? ctx.settingsScope
|
|
93
93
|
const settingsScope = binder.bind<TaskBoardSettings>({ namespace: TASK_BOARD_NS })
|
|
94
94
|
const settingsCard = new TaskBoardSettingsCardController(settingsScope)
|
|
95
|
-
ctx.slots.inject('web-ui.plugin.item', () =>
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
95
|
+
ctx.slots.inject('web-ui.plugin.item', () => {
|
|
96
|
+
const unregister = ctx.slots.register({
|
|
97
|
+
name: 'web-ui.plugin.item',
|
|
98
|
+
id: 'task-board',
|
|
99
|
+
order: 110,
|
|
100
|
+
locale: NS,
|
|
101
|
+
inject: () => settingsCard.inject(),
|
|
102
|
+
}, TaskBoardSettingsCard)
|
|
103
|
+
return () => {
|
|
104
|
+
settingsCard.dispose()
|
|
105
|
+
unregister()
|
|
106
|
+
}
|
|
107
|
+
})
|
|
102
108
|
|
|
103
109
|
// The sidebar entry and board view mount once the settings scope settles;
|
|
104
110
|
// while the scope is still loading, the composition default is unknown, so
|
|
@@ -178,6 +184,9 @@ export function apply(ctx: ClientContext): void {
|
|
|
178
184
|
// not-yet-ready runtime; tab visibility recovery ticks immediately.
|
|
179
185
|
const scheduler = new SchedulerService({
|
|
180
186
|
tasks: () => controller.getSnapshot().tasks,
|
|
187
|
+
// Re-read the persisted ledger before every fire decision: a task
|
|
188
|
+
// deleted in another tab must never fire from a stale in-memory copy.
|
|
189
|
+
refresh: () => controller.reloadFromStore(),
|
|
181
190
|
now: () => Date.now(),
|
|
182
191
|
runTask: id => controller.runTask(id),
|
|
183
192
|
applySchedule: (id, nextRunAt, lastTriggeredAt) =>
|
package/src/client/locales.ts
CHANGED
|
@@ -9,11 +9,15 @@
|
|
|
9
9
|
export const zh = {
|
|
10
10
|
'entry.label': '任务看板',
|
|
11
11
|
'board.title': '任务看板',
|
|
12
|
-
'board.close': '
|
|
12
|
+
'board.close': '返回会话',
|
|
13
13
|
'board.new': '新建任务',
|
|
14
14
|
'board.search': '筛选任务…',
|
|
15
15
|
'board.empty': '这个状态还没有任务',
|
|
16
16
|
'board.filterAll': '全部',
|
|
17
|
+
'board.archive': '归档',
|
|
18
|
+
'board.archiveView': '归档 ({count})',
|
|
19
|
+
'board.backToBoard': '返回看板',
|
|
20
|
+
'archive.empty': '没有已归档的任务',
|
|
17
21
|
'board.status': '状态',
|
|
18
22
|
'board.status.backlog': '待规划',
|
|
19
23
|
'board.status.todo': '待办',
|
|
@@ -41,6 +45,9 @@ export const zh = {
|
|
|
41
45
|
'detail.run': '执行',
|
|
42
46
|
'detail.rerun': '重新执行',
|
|
43
47
|
'detail.delete': '删除',
|
|
48
|
+
'detail.archive': '归档',
|
|
49
|
+
'detail.restore': '恢复',
|
|
50
|
+
'detail.archivedAt': '已归档 · {time}',
|
|
44
51
|
'detail.viewSession': '查看会话',
|
|
45
52
|
'detail.noSession': '暂无会话',
|
|
46
53
|
'detail.executionStarted': '已启动',
|
|
@@ -120,6 +127,10 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
120
127
|
'board.search': 'Filter tasks…',
|
|
121
128
|
'board.empty': 'No tasks in this column',
|
|
122
129
|
'board.filterAll': 'All',
|
|
130
|
+
'board.archive': 'Archive',
|
|
131
|
+
'board.archiveView': 'Archived ({count})',
|
|
132
|
+
'board.backToBoard': 'Back to board',
|
|
133
|
+
'archive.empty': 'No archived tasks',
|
|
123
134
|
'board.status': 'Status',
|
|
124
135
|
'board.status.backlog': 'Backlog',
|
|
125
136
|
'board.status.todo': 'To Do',
|
|
@@ -147,6 +158,9 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
147
158
|
'detail.run': 'Run',
|
|
148
159
|
'detail.rerun': 'Run Again',
|
|
149
160
|
'detail.delete': 'Delete',
|
|
161
|
+
'detail.archive': 'Archive',
|
|
162
|
+
'detail.restore': 'Restore',
|
|
163
|
+
'detail.archivedAt': 'Archived · {time}',
|
|
150
164
|
'detail.viewSession': 'View Session',
|
|
151
165
|
'detail.noSession': 'No session',
|
|
152
166
|
'detail.executionStarted': 'Started',
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared scheduled-run presets for the schedule editors (the new-task dialog
|
|
3
|
+
* and the task detail panel): cron expression → locale label.
|
|
4
|
+
*/
|
|
5
|
+
import type { TaskBoardKey } from './locales.ts'
|
|
6
|
+
|
|
7
|
+
/** Common scheduled-run presets (cron → locale label). */
|
|
8
|
+
export const SCHEDULE_PRESETS: ReadonlyArray<{ cron: string; label: TaskBoardKey }> = [
|
|
9
|
+
{ cron: '0 9 * * *', label: 'detail.schedule.preset.daily9' },
|
|
10
|
+
{ cron: '0 * * * *', label: 'detail.schedule.preset.hourly' },
|
|
11
|
+
{ cron: '*/10 * * * *', label: 'detail.schedule.preset.tenMin' },
|
|
12
|
+
{ cron: '0 9 * * 1', label: 'detail.schedule.preset.weeklyMon9' },
|
|
13
|
+
]
|
|
@@ -234,6 +234,9 @@ export class CardForm<T> {
|
|
|
234
234
|
private readonly specs: Map<string, FieldSpec>
|
|
235
235
|
private readonly staged = new Map<string, StagedEdit>()
|
|
236
236
|
private readonly listeners = new Set<() => void>()
|
|
237
|
+
/** The scope subscription installed in the constructor; released by dispose(). */
|
|
238
|
+
private readonly disposeScope: () => void
|
|
239
|
+
private disposed = false
|
|
237
240
|
private saving = false
|
|
238
241
|
private failed = false
|
|
239
242
|
private failedReason: string | undefined
|
|
@@ -244,7 +247,18 @@ export class CardForm<T> {
|
|
|
244
247
|
specs: FieldSpec[],
|
|
245
248
|
) {
|
|
246
249
|
this.specs = new Map(specs.map(spec => [spec.field, spec]))
|
|
247
|
-
scope.subscribe(() => { this.publish() })
|
|
250
|
+
this.disposeScope = scope.subscribe(() => { this.publish() })
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Release the scope subscription and every bound store listener. The card
|
|
255
|
+
* must call this on teardown; later calls are no-ops.
|
|
256
|
+
*/
|
|
257
|
+
dispose(): void {
|
|
258
|
+
if (this.disposed) return
|
|
259
|
+
this.disposed = true
|
|
260
|
+
this.disposeScope()
|
|
261
|
+
this.listeners.clear()
|
|
248
262
|
}
|
|
249
263
|
|
|
250
264
|
/** Publish a projection of this form, rebuilt whenever the scope or a draft changes. */
|
package/src/core/controller.ts
CHANGED
|
@@ -19,6 +19,7 @@ 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'
|
|
@@ -74,6 +75,8 @@ export interface ExecutionOptionsSnapshot {
|
|
|
74
75
|
export interface ControllerSnapshot {
|
|
75
76
|
tasks: readonly TaskRecord[]
|
|
76
77
|
boardOpen: boolean
|
|
78
|
+
/** True when the board shows the archive view instead of the columns. */
|
|
79
|
+
archiveView: boolean
|
|
77
80
|
selectedTaskId: string | undefined
|
|
78
81
|
/** Picker option sets (workspace list + agent-preset roster). */
|
|
79
82
|
executionOptions: ExecutionOptionsSnapshot
|
|
@@ -86,6 +89,10 @@ export function selectedTaskOf(snapshot: ControllerSnapshot): TaskRecord | undef
|
|
|
86
89
|
}
|
|
87
90
|
|
|
88
91
|
function randomUuid(): string {
|
|
92
|
+
const randomUUID = globalThis.crypto?.randomUUID
|
|
93
|
+
if (randomUUID !== undefined) {
|
|
94
|
+
return randomUUID.call(globalThis.crypto!)
|
|
95
|
+
}
|
|
89
96
|
const bytes = globalThis.crypto?.getRandomValues(new Uint8Array(16))
|
|
90
97
|
if (bytes === undefined) {
|
|
91
98
|
// Non-secure fallback (tests, odd environments).
|
|
@@ -109,6 +116,7 @@ function currentOf(sessions: SessionsControllerFace): string | undefined {
|
|
|
109
116
|
export class BoardController {
|
|
110
117
|
private tasks: TaskRecord[] = []
|
|
111
118
|
private boardOpen = false
|
|
119
|
+
private archiveView = false
|
|
112
120
|
private selectedTaskId: string | undefined
|
|
113
121
|
private executionOptions: ExecutionOptionsSnapshot = { workspaces: [], presets: [] }
|
|
114
122
|
private listeners = new Set<() => void>()
|
|
@@ -157,6 +165,7 @@ export class BoardController {
|
|
|
157
165
|
return {
|
|
158
166
|
tasks: this.tasks,
|
|
159
167
|
boardOpen: this.boardOpen,
|
|
168
|
+
archiveView: this.archiveView,
|
|
160
169
|
selectedTaskId: this.selectedTaskId,
|
|
161
170
|
executionOptions: this.executionOptions,
|
|
162
171
|
}
|
|
@@ -190,6 +199,20 @@ export class BoardController {
|
|
|
190
199
|
else this.openBoard()
|
|
191
200
|
}
|
|
192
201
|
|
|
202
|
+
/**
|
|
203
|
+
* Switch between the kanban columns and the archive view. Leaving the
|
|
204
|
+
* archive view with an archived task still selected closes the selection —
|
|
205
|
+
* the detail overlay must not linger over a task that is off-board.
|
|
206
|
+
*/
|
|
207
|
+
toggleArchiveView(): void {
|
|
208
|
+
this.archiveView = !this.archiveView
|
|
209
|
+
if (!this.archiveView && this.selectedTaskId !== undefined) {
|
|
210
|
+
const selected = this.tasks.find(task => task.id === this.selectedTaskId)
|
|
211
|
+
if (selected?.archivedAt !== undefined) this.selectedTaskId = undefined
|
|
212
|
+
}
|
|
213
|
+
this.notify()
|
|
214
|
+
}
|
|
215
|
+
|
|
193
216
|
openTask(id: string): void {
|
|
194
217
|
if (this.tasks.some(task => task.id === id)) {
|
|
195
218
|
this.selectedTaskId = id
|
|
@@ -239,6 +262,29 @@ export class BoardController {
|
|
|
239
262
|
this.persistAndNotify()
|
|
240
263
|
}
|
|
241
264
|
|
|
265
|
+
/**
|
|
266
|
+
* Archive a settled task (done/failed). Running or on-board-unsettled
|
|
267
|
+
* tasks are refused so the runner keeps exclusive ownership of their
|
|
268
|
+
* lifecycle.
|
|
269
|
+
* @returns true when applied.
|
|
270
|
+
*/
|
|
271
|
+
archiveTask(id: string): boolean {
|
|
272
|
+
const { tasks, archived } = applyArchiveTask(this.tasks, id, this.now())
|
|
273
|
+
if (!archived) return false
|
|
274
|
+
this.tasks = [...tasks]
|
|
275
|
+
this.persistAndNotify()
|
|
276
|
+
return true
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Restore an archived task back onto the board (same status column). */
|
|
280
|
+
restoreTask(id: string): boolean {
|
|
281
|
+
const { tasks, archived } = applyRestoreTask(this.tasks, id, this.now())
|
|
282
|
+
if (!archived) return false
|
|
283
|
+
this.tasks = [...tasks]
|
|
284
|
+
this.persistAndNotify()
|
|
285
|
+
return true
|
|
286
|
+
}
|
|
287
|
+
|
|
242
288
|
// --- scheduling ---------------------------------------------------------------
|
|
243
289
|
|
|
244
290
|
/**
|
|
@@ -269,6 +315,18 @@ export class BoardController {
|
|
|
269
315
|
this.persistAndNotify()
|
|
270
316
|
}
|
|
271
317
|
|
|
318
|
+
/**
|
|
319
|
+
* Reload the ledger from the persisted store without notifying subscribers.
|
|
320
|
+
* The scheduler calls this before every tick so a task deleted in another
|
|
321
|
+
* tab (or a stale in-memory copy) can never be fired from this tab: the
|
|
322
|
+
* fire decision and the subsequent roll-forward both run on the freshest
|
|
323
|
+
* persisted truth. Deliberately silent — same-origin external changes still
|
|
324
|
+
* reach subscribers through the storage-event subscription.
|
|
325
|
+
*/
|
|
326
|
+
reloadFromStore(): void {
|
|
327
|
+
this.tasks = this.deps.store.load()
|
|
328
|
+
}
|
|
329
|
+
|
|
272
330
|
/**
|
|
273
331
|
* Jump to an execution's session transcript. Selecting the session changes
|
|
274
332
|
* `current`, which closes the board (the conversation view takes over).
|
package/src/core/execution.ts
CHANGED
|
@@ -46,6 +46,9 @@ export interface PresetsExecutionFace {
|
|
|
46
46
|
select(sessionId: string, agentPreset: string): Promise<{ ok: true } | { ok: false; error: unknown }>
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/** Settled result of one agent-preset switch, shared by concurrent waiters. */
|
|
50
|
+
type PresetSelectResult = { ok: true } | { ok: false; error: unknown }
|
|
51
|
+
|
|
49
52
|
/** The narrow workspaces face the service needs. */
|
|
50
53
|
export interface WorkspacesExecutionFace {
|
|
51
54
|
list: {
|
|
@@ -137,6 +140,16 @@ function isErrorTurnEnd(data: unknown): boolean {
|
|
|
137
140
|
* every failure path is reported as a settled event.
|
|
138
141
|
*/
|
|
139
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
|
+
|
|
140
153
|
/** @param env - the runtime faces (real or fake). */
|
|
141
154
|
constructor(private readonly env: ExecutionEnvironment) {}
|
|
142
155
|
|
|
@@ -199,13 +212,18 @@ export class ExecutionService {
|
|
|
199
212
|
settleFailed(`cannot switch agent preset to ${mode}: the execution session is not blank`)
|
|
200
213
|
return false
|
|
201
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.
|
|
202
219
|
if (summary?.agentPreset === mode) return true
|
|
203
|
-
|
|
220
|
+
const presets = this.env.presets
|
|
221
|
+
if (presets === undefined) {
|
|
204
222
|
settleFailed(`this deployment does not support agent presets (task asks for ${mode})`)
|
|
205
223
|
return false
|
|
206
224
|
}
|
|
207
225
|
try {
|
|
208
|
-
const result = await this.
|
|
226
|
+
const result = await this.switchPreset(presets, sessionId, mode)
|
|
209
227
|
if (!result.ok) {
|
|
210
228
|
// A list race can leave the summary without the preset label even
|
|
211
229
|
// though the blank session already runs it; the wire answers that
|
|
@@ -227,6 +245,27 @@ export class ExecutionService {
|
|
|
227
245
|
return true
|
|
228
246
|
}
|
|
229
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
|
+
|
|
230
269
|
/**
|
|
231
270
|
* Apply the task-pinned permission preset through the `/permission <id>`
|
|
232
271
|
* slash command. No-op when the task pins none; fails the run when the
|
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
|
@@ -148,6 +148,7 @@ export function parseLedger(raw: string | null): TaskRecord[] {
|
|
|
148
148
|
// fall back to the session default instead of dropping the row.
|
|
149
149
|
task.workspaceId = normalizeTargetId(row.workspaceId)
|
|
150
150
|
task.mode = normalizeTargetId(row.mode)
|
|
151
|
+
task.archivedAt = typeof row.archivedAt === 'number' && Number.isFinite(row.archivedAt) ? row.archivedAt : undefined
|
|
151
152
|
task.permission = isTaskPermission(row.permission) ? row.permission as TaskPermission : undefined
|
|
152
153
|
tasks.push(task)
|
|
153
154
|
}
|
package/src/core/tasks.ts
CHANGED
|
@@ -79,8 +79,17 @@ export interface TaskRecord {
|
|
|
79
79
|
* `/permission <id>` slash command; absent leaves the session default.
|
|
80
80
|
*/
|
|
81
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
|
|
82
87
|
}
|
|
83
88
|
|
|
89
|
+
/** Statuses a settled task may be archived from. */
|
|
90
|
+
export const ARCHIVABLE_STATUSES: readonly TaskStatus[] = ['done', 'failed']
|
|
91
|
+
|
|
92
|
+
|
|
84
93
|
/** Permission presets a task may pin on its execution session (the `/permission <id>` ids). */
|
|
85
94
|
export const TASK_PERMISSIONS = ['read-only', 'workspace-write', 'danger-full-access'] as const
|
|
86
95
|
|
|
@@ -103,6 +112,12 @@ export interface NewTaskInput {
|
|
|
103
112
|
mode?: string
|
|
104
113
|
/** Permission preset applied to the execution session; absent = session default. */
|
|
105
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 }
|
|
106
121
|
}
|
|
107
122
|
|
|
108
123
|
/** The five kanban columns, in display order. */
|
|
@@ -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
|
}
|