@linxin666/dsh-client-ui-task-board 0.1.20 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.i18n.yaml +2 -2
- package/README.md +82 -104
- package/README.zh.md +82 -104
- package/lib/client.js +574 -581
- package/lib/client.js.map +1 -1
- package/lib/index.js +2144 -5
- package/lib/types/client/PluginSettingsCard.d.ts +7 -1
- package/lib/types/client/TaskBoardSettingsCard.d.ts +7 -4
- package/lib/types/client/board/TaskCard.d.ts +5 -2
- package/lib/types/client/host-api.d.ts +18 -0
- package/lib/types/client/locales.d.ts +10 -0
- package/lib/types/core/controller.d.ts +40 -15
- package/lib/types/core/schedule.d.ts +4 -2
- package/lib/types/core/tasks.d.ts +6 -6
- package/lib/types/core/use-cases/task-archive.d.ts +5 -4
- package/lib/types/core/use-cases/task-schedule.d.ts +3 -3
- package/lib/types/dsh-home.d.ts +16 -0
- package/lib/types/host-ledger.d.ts +42 -0
- package/lib/types/host-routes.d.ts +24 -0
- package/lib/types/host-runner.d.ts +30 -0
- package/lib/types/host-service.d.ts +41 -0
- package/lib/types/index.d.ts +17 -9
- package/lib/types/loopback.d.ts +24 -0
- package/lib/types/power-inhibitor.d.ts +73 -0
- package/lib/types/protocol.d.ts +72 -0
- package/package.json +12 -9
- package/src/client/PluginSettingsCard.tsx +10 -0
- package/src/client/TaskBoardSettingsCard.tsx +51 -4
- package/src/client/board/NewTaskModal.tsx +9 -6
- package/src/client/board/TaskBoard.tsx +21 -4
- package/src/client/board/TaskCard.tsx +24 -8
- package/src/client/board/TaskDetail.tsx +83 -50
- package/src/client/host-api.ts +95 -0
- package/src/client/index.ts +9 -78
- package/src/client/locales.ts +24 -4
- package/src/core/controller.ts +205 -24
- package/src/core/schedule.ts +20 -3
- package/src/core/store.ts +4 -8
- package/src/core/tasks.ts +6 -6
- package/src/core/use-cases/task-archive.ts +14 -5
- package/src/core/use-cases/task-schedule.ts +6 -5
- package/src/dsh-home.ts +36 -0
- package/src/host-ledger.ts +478 -0
- package/src/host-routes.ts +188 -0
- package/src/host-runner.ts +134 -0
- package/src/host-service.ts +209 -0
- package/src/index.ts +54 -11
- package/src/loopback.ts +63 -0
- package/src/power-inhibitor.ts +321 -0
- package/src/protocol.ts +213 -0
- package/lib/types/core/scheduler.d.ts +0 -70
- package/src/core/scheduler.ts +0 -161
package/src/core/controller.ts
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Board controller: the single owner of task-ledger state and view state.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* navigates to a session (the sessions-list `current` selection changes).
|
|
4
|
+
* In production it projects the Host ledger and submits confirmed actions;
|
|
5
|
+
* the legacy store/execution seams remain for v1 migration tests. It also
|
|
6
|
+
* closes the board whenever the user navigates to a session.
|
|
8
7
|
* Framework-free (structural runtime faces) so the whole orchestration is
|
|
9
8
|
* unit-testable with fakes.
|
|
10
9
|
*
|
|
@@ -13,7 +12,7 @@
|
|
|
13
12
|
* owns only the orchestration seam (state, persistence, notify, execution,
|
|
14
13
|
* navigation, reconciliation).
|
|
15
14
|
*/
|
|
16
|
-
import {
|
|
15
|
+
import type { ExecutionEvent, ExecutionService } from './execution.ts'
|
|
17
16
|
import type { TaskStore } from './store.ts'
|
|
18
17
|
import {
|
|
19
18
|
settleExecution, startExecution, withStatus,
|
|
@@ -24,6 +23,14 @@ import { applyCreateTask } from './use-cases/task-create.ts'
|
|
|
24
23
|
import { applyDeleteTask } from './use-cases/task-delete.ts'
|
|
25
24
|
import { applyScheduleNextRun as applyScheduleRollForward, applySetSchedule } from './use-cases/task-schedule.ts'
|
|
26
25
|
import { applyUpdateTask, type TaskUpdatePatch } from './use-cases/task-update.ts'
|
|
26
|
+
import type { TaskBoardAction, TaskBoardSnapshot } from '../protocol.ts'
|
|
27
|
+
|
|
28
|
+
export interface TaskBoardTransport {
|
|
29
|
+
bootstrap(legacy: readonly TaskRecord[]): Promise<TaskBoardSnapshot>
|
|
30
|
+
state(): Promise<TaskBoardSnapshot>
|
|
31
|
+
action(action: TaskBoardAction): Promise<TaskBoardSnapshot>
|
|
32
|
+
subscribe(listener: () => void): () => void
|
|
33
|
+
}
|
|
27
34
|
|
|
28
35
|
/** The sessions face the controller needs for navigation awareness. */
|
|
29
36
|
export interface SessionsControllerFace {
|
|
@@ -38,7 +45,8 @@ export interface SessionsControllerFace {
|
|
|
38
45
|
/** Controller dependencies (all swappable in tests). */
|
|
39
46
|
export interface ControllerDeps {
|
|
40
47
|
store: TaskStore
|
|
41
|
-
|
|
48
|
+
/** Legacy browser execution seam used only by isolated v1 tests. */
|
|
49
|
+
exec?: ExecutionService
|
|
42
50
|
sessions: SessionsControllerFace
|
|
43
51
|
/** Clock; defaults to Date.now. */
|
|
44
52
|
now?: () => number
|
|
@@ -46,6 +54,8 @@ export interface ControllerDeps {
|
|
|
46
54
|
uuid?: () => string
|
|
47
55
|
/** Debounce (ms) for session-list-changed reconciles; defaults to 350. */
|
|
48
56
|
reconcileDebounceMs?: number
|
|
57
|
+
/** Host-authoritative transport; absent keeps the legacy in-memory test path. */
|
|
58
|
+
transport?: TaskBoardTransport
|
|
49
59
|
}
|
|
50
60
|
|
|
51
61
|
/** One workspace option the execution-target pickers offer. */
|
|
@@ -80,6 +90,9 @@ export interface ControllerSnapshot {
|
|
|
80
90
|
selectedTaskId: string | undefined
|
|
81
91
|
/** Picker option sets (workspace list + agent-preset roster). */
|
|
82
92
|
executionOptions: ExecutionOptionsSnapshot
|
|
93
|
+
pendingTaskIds: readonly string[]
|
|
94
|
+
transportError?: string
|
|
95
|
+
host?: Pick<TaskBoardSnapshot, 'revision' | 'scheduler' | 'power'>
|
|
83
96
|
}
|
|
84
97
|
|
|
85
98
|
/** The selected task (resolved from the ledger), or undefined. */
|
|
@@ -109,6 +122,10 @@ function currentOf(sessions: SessionsControllerFace): string | undefined {
|
|
|
109
122
|
return sessions.list.getSnapshot().current
|
|
110
123
|
}
|
|
111
124
|
|
|
125
|
+
function messageOf(error: unknown): string {
|
|
126
|
+
return error instanceof Error ? error.message : String(error)
|
|
127
|
+
}
|
|
128
|
+
|
|
112
129
|
/**
|
|
113
130
|
* Board controller (see module doc). All mutations bump the snapshot and
|
|
114
131
|
* persist through the store; UI and DOM mounts subscribe and re-render.
|
|
@@ -123,6 +140,12 @@ export class BoardController {
|
|
|
123
140
|
private disposers: Array<() => void> = []
|
|
124
141
|
private readonly now: () => number
|
|
125
142
|
private readonly uuid: () => string
|
|
143
|
+
private readonly pendingTaskIds = new Set<string>()
|
|
144
|
+
private readonly taskQueues = new Map<string, Promise<void>>()
|
|
145
|
+
private transportError: string | undefined
|
|
146
|
+
private hostState: Pick<TaskBoardSnapshot, 'revision' | 'scheduler' | 'power'> | undefined
|
|
147
|
+
private remoteSubscribed = false
|
|
148
|
+
private remoteInitialization: Promise<boolean> | undefined
|
|
126
149
|
|
|
127
150
|
/** @param deps - store, execution service, and the sessions navigation face. */
|
|
128
151
|
constructor(private readonly deps: ControllerDeps) {
|
|
@@ -135,15 +158,19 @@ export class BoardController {
|
|
|
135
158
|
/** Load the persisted ledger and start the navigation/status subscriptions. */
|
|
136
159
|
start(): void {
|
|
137
160
|
this.tasks = this.deps.store.load()
|
|
138
|
-
|
|
161
|
+
if (this.deps.transport !== undefined) {
|
|
162
|
+
void this.initializeRemote()
|
|
163
|
+
} else {
|
|
164
|
+
void this.reconcileRunningTasks()
|
|
165
|
+
}
|
|
139
166
|
// A sibling tab may have edited or deleted the ledger (same origin,
|
|
140
167
|
// storage events). Reload on external change so a task deleted in
|
|
141
168
|
// another tab stops firing here — and is never written back by this
|
|
142
169
|
// tab's stale copy (scheduler roll-forward, execution settlement).
|
|
143
|
-
const unsubscribeExternal = this.deps.store.subscribeExternal?.(() => {
|
|
170
|
+
const unsubscribeExternal = this.deps.transport === undefined ? this.deps.store.subscribeExternal?.(() => {
|
|
144
171
|
this.tasks = this.deps.store.load()
|
|
145
172
|
this.notify()
|
|
146
|
-
})
|
|
173
|
+
}) : undefined
|
|
147
174
|
if (unsubscribeExternal !== undefined) this.disposers.push(unsubscribeExternal)
|
|
148
175
|
this.disposers.push(this.deps.sessions.list.subscribe(() => {
|
|
149
176
|
this.onSessionsChanged()
|
|
@@ -168,6 +195,9 @@ export class BoardController {
|
|
|
168
195
|
archiveView: this.archiveView,
|
|
169
196
|
selectedTaskId: this.selectedTaskId,
|
|
170
197
|
executionOptions: this.executionOptions,
|
|
198
|
+
pendingTaskIds: [...this.pendingTaskIds],
|
|
199
|
+
...(this.transportError === undefined ? {} : { transportError: this.transportError }),
|
|
200
|
+
...(this.hostState === undefined ? {} : { host: this.hostState }),
|
|
171
201
|
}
|
|
172
202
|
}
|
|
173
203
|
|
|
@@ -176,6 +206,16 @@ export class BoardController {
|
|
|
176
206
|
return () => { this.listeners.delete(fn) }
|
|
177
207
|
}
|
|
178
208
|
|
|
209
|
+
/** Whether production mutations are confirmed by the Host transport. */
|
|
210
|
+
isHostBacked(): boolean {
|
|
211
|
+
return this.deps.transport !== undefined
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Retry initial migration/state synchronization after an explicit Host error. */
|
|
215
|
+
async retryHostSync(): Promise<boolean> {
|
|
216
|
+
return await this.initializeRemote()
|
|
217
|
+
}
|
|
218
|
+
|
|
179
219
|
// --- view state -------------------------------------------------------------
|
|
180
220
|
|
|
181
221
|
openBoard(): void {
|
|
@@ -229,14 +269,30 @@ export class BoardController {
|
|
|
229
269
|
// --- task mutations (use-case transitions in core/use-cases) -----------------
|
|
230
270
|
|
|
231
271
|
createTask(input: NewTaskInput): TaskRecord | undefined {
|
|
232
|
-
const
|
|
272
|
+
const id = this.uuid()
|
|
273
|
+
const { task, tasks } = applyCreateTask(this.tasks, input, this.now(), id)
|
|
233
274
|
if (task === undefined) return undefined
|
|
234
275
|
this.tasks = [...tasks]
|
|
235
276
|
this.persistAndNotify()
|
|
236
277
|
return task
|
|
237
278
|
}
|
|
238
279
|
|
|
280
|
+
/** Create through the Host and expose the task only after confirmation. */
|
|
281
|
+
async createTaskConfirmed(input: NewTaskInput): Promise<TaskRecord | undefined> {
|
|
282
|
+
if (this.deps.transport === undefined) return this.createTask(input)
|
|
283
|
+
const id = this.uuid()
|
|
284
|
+
const preview = applyCreateTask(this.tasks, input, this.now(), id).task
|
|
285
|
+
if (preview === undefined) return undefined
|
|
286
|
+
return await this.commitRemote({ kind: 'create', id, input }, id)
|
|
287
|
+
? this.tasks.find(task => task.id === id)
|
|
288
|
+
: undefined
|
|
289
|
+
}
|
|
290
|
+
|
|
239
291
|
updateTask(id: string, patch: TaskUpdatePatch): void {
|
|
292
|
+
if (this.deps.transport !== undefined) {
|
|
293
|
+
void this.commitRemote({ kind: 'update', taskId: id, patch }, id)
|
|
294
|
+
return
|
|
295
|
+
}
|
|
240
296
|
this.tasks = [...applyUpdateTask(this.tasks, id, patch, this.now())]
|
|
241
297
|
this.persistAndNotify()
|
|
242
298
|
}
|
|
@@ -251,11 +307,19 @@ export class BoardController {
|
|
|
251
307
|
}
|
|
252
308
|
|
|
253
309
|
moveTask(id: string, status: TaskStatus): void {
|
|
310
|
+
if (this.deps.transport !== undefined) {
|
|
311
|
+
void this.commitRemote({ kind: 'move', taskId: id, status }, id)
|
|
312
|
+
return
|
|
313
|
+
}
|
|
254
314
|
this.tasks = this.tasks.map(task => task.id === id ? withStatus(task, status, this.now()) : task)
|
|
255
315
|
this.persistAndNotify()
|
|
256
316
|
}
|
|
257
317
|
|
|
258
318
|
deleteTask(id: string): void {
|
|
319
|
+
if (this.deps.transport !== undefined) {
|
|
320
|
+
void this.commitRemote({ kind: 'delete', taskId: id }, id)
|
|
321
|
+
return
|
|
322
|
+
}
|
|
259
323
|
const { tasks, selectionCleared } = applyDeleteTask(this.tasks, this.selectedTaskId, id)
|
|
260
324
|
this.tasks = [...tasks]
|
|
261
325
|
if (selectionCleared) this.selectedTaskId = undefined
|
|
@@ -271,6 +335,10 @@ export class BoardController {
|
|
|
271
335
|
archiveTask(id: string): boolean {
|
|
272
336
|
const { tasks, archived } = applyArchiveTask(this.tasks, id, this.now())
|
|
273
337
|
if (!archived) return false
|
|
338
|
+
if (this.deps.transport !== undefined) {
|
|
339
|
+
void this.commitRemote({ kind: 'archive', taskId: id }, id)
|
|
340
|
+
return true
|
|
341
|
+
}
|
|
274
342
|
this.tasks = [...tasks]
|
|
275
343
|
this.persistAndNotify()
|
|
276
344
|
return true
|
|
@@ -280,7 +348,14 @@ export class BoardController {
|
|
|
280
348
|
restoreTask(id: string): boolean {
|
|
281
349
|
const { tasks, archived } = applyRestoreTask(this.tasks, id, this.now())
|
|
282
350
|
if (!archived) return false
|
|
351
|
+
if (this.deps.transport !== undefined) {
|
|
352
|
+
void this.commitRemote({ kind: 'restore', taskId: id }, id).then(restored => {
|
|
353
|
+
if (restored && this.selectedTaskId === id) this.closeTask()
|
|
354
|
+
})
|
|
355
|
+
return true
|
|
356
|
+
}
|
|
283
357
|
this.tasks = [...tasks]
|
|
358
|
+
if (this.selectedTaskId === id) this.selectedTaskId = undefined
|
|
284
359
|
this.persistAndNotify()
|
|
285
360
|
return true
|
|
286
361
|
}
|
|
@@ -299,15 +374,18 @@ export class BoardController {
|
|
|
299
374
|
setSchedule(id: string, patch: { enabled?: boolean; cron?: string }): boolean {
|
|
300
375
|
const { tasks, applied } = applySetSchedule(this.tasks, id, patch, this.now())
|
|
301
376
|
if (!applied) return false
|
|
377
|
+
if (this.deps.transport !== undefined) {
|
|
378
|
+
void this.commitRemote({ kind: 'set-schedule', taskId: id, patch }, id)
|
|
379
|
+
return true
|
|
380
|
+
}
|
|
302
381
|
this.tasks = [...tasks]
|
|
303
382
|
this.persistAndNotify()
|
|
304
383
|
return true
|
|
305
384
|
}
|
|
306
385
|
|
|
307
386
|
/**
|
|
308
|
-
*
|
|
309
|
-
*
|
|
310
|
-
* schedule rule (it was deleted mid-tick, for example).
|
|
387
|
+
* Legacy pure-controller seam retained for migration-focused tests. The
|
|
388
|
+
* production browser never rolls schedules; the Host ledger owns them.
|
|
311
389
|
*/
|
|
312
390
|
applyScheduleNextRun(id: string, nextRunAt: number | undefined, lastTriggeredAt: number | undefined): void {
|
|
313
391
|
const next = applyScheduleRollForward(this.tasks, id, nextRunAt, lastTriggeredAt, this.now())
|
|
@@ -316,12 +394,8 @@ export class BoardController {
|
|
|
316
394
|
}
|
|
317
395
|
|
|
318
396
|
/**
|
|
319
|
-
* Reload the
|
|
320
|
-
*
|
|
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.
|
|
397
|
+
* Reload the legacy v1 store without notifying subscribers. Production v2
|
|
398
|
+
* reads Host snapshots instead; this remains only for isolated legacy tests.
|
|
325
399
|
*/
|
|
326
400
|
reloadFromStore(): void {
|
|
327
401
|
this.tasks = this.deps.store.load()
|
|
@@ -345,7 +419,10 @@ export class BoardController {
|
|
|
345
419
|
*/
|
|
346
420
|
async runTask(id: string): Promise<boolean> {
|
|
347
421
|
const task = this.tasks.find(candidate => candidate.id === id)
|
|
348
|
-
if (task === undefined || task.status === 'running') return false
|
|
422
|
+
if (task === undefined || task.archivedAt !== undefined || task.status === 'running') return false
|
|
423
|
+
if (this.deps.transport !== undefined) {
|
|
424
|
+
return await this.commitRemote({ kind: 'run', taskId: id }, id)
|
|
425
|
+
}
|
|
349
426
|
const { task: next, execution } = startExecution(task, this.now(), this.uuid())
|
|
350
427
|
this.tasks = this.tasks.map(candidate => candidate.id === id ? next : candidate)
|
|
351
428
|
this.persistAndNotify()
|
|
@@ -354,6 +431,7 @@ export class BoardController {
|
|
|
354
431
|
// reconciliation must not pre-empt it with a session that has not
|
|
355
432
|
// started a turn yet (its list row is idle, not completed).
|
|
356
433
|
this.activeExecutionIds.add(execution.id)
|
|
434
|
+
if (this.deps.exec === undefined) throw new Error('legacy execution service is unavailable')
|
|
357
435
|
await this.deps.exec.run(next, execution, (event) => { this.handleExecutionEvent(event) })
|
|
358
436
|
return true
|
|
359
437
|
}
|
|
@@ -361,7 +439,11 @@ export class BoardController {
|
|
|
361
439
|
/** Re-run a settled task: move it back to 'todo' first, then execute. */
|
|
362
440
|
async rerunTask(id: string): Promise<void> {
|
|
363
441
|
const task = this.tasks.find(candidate => candidate.id === id)
|
|
364
|
-
if (task === undefined) return
|
|
442
|
+
if (task === undefined || task.archivedAt !== undefined) return
|
|
443
|
+
if (this.deps.transport !== undefined) {
|
|
444
|
+
await this.commitRemote({ kind: 'rerun', taskId: id }, id)
|
|
445
|
+
return
|
|
446
|
+
}
|
|
365
447
|
if (task.status !== 'running') {
|
|
366
448
|
this.tasks = this.tasks.map(candidate => candidate.id === id ? withStatus(candidate, 'todo', this.now()) : candidate)
|
|
367
449
|
this.persistAndNotify()
|
|
@@ -392,7 +474,7 @@ export class BoardController {
|
|
|
392
474
|
// conversation snapshots stay cold until opened). Coalesce the burst of
|
|
393
475
|
// list notifications into one reconcile pass instead of fanning out a
|
|
394
476
|
// history read per notification; see scheduleReconcile.
|
|
395
|
-
this.scheduleReconcile()
|
|
477
|
+
if (this.deps.transport === undefined) this.scheduleReconcile()
|
|
396
478
|
if (!this.boardOpen) return
|
|
397
479
|
const current = currentOf(this.deps.sessions)
|
|
398
480
|
if (current !== this.lastCurrent) this.closeBoard()
|
|
@@ -426,6 +508,7 @@ export class BoardController {
|
|
|
426
508
|
|
|
427
509
|
/** Settle tasks left 'running' whose sessions already finished. */
|
|
428
510
|
private async reconcileRunningTasks(): Promise<void> {
|
|
511
|
+
if (this.deps.exec === undefined) return
|
|
429
512
|
if (this.reconcileInFlight) return
|
|
430
513
|
this.reconcileInFlight = true
|
|
431
514
|
try {
|
|
@@ -461,8 +544,107 @@ export class BoardController {
|
|
|
461
544
|
}
|
|
462
545
|
|
|
463
546
|
private persistAndNotify(): void {
|
|
464
|
-
this.deps.store.save(this.tasks)
|
|
547
|
+
if (this.deps.transport === undefined) this.deps.store.save(this.tasks)
|
|
548
|
+
this.notify()
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
private async commitRemote(action: TaskBoardAction, taskId?: string): Promise<boolean> {
|
|
552
|
+
const transport = this.deps.transport
|
|
553
|
+
if (transport === undefined) return true
|
|
554
|
+
if (taskId === undefined) return await this.performRemote(action)
|
|
555
|
+
const previous = this.taskQueues.get(taskId) ?? Promise.resolve()
|
|
556
|
+
const operation = previous.catch(() => {}).then(async () => await this.performRemote(action))
|
|
557
|
+
const tail = operation.then(() => {}, () => {})
|
|
558
|
+
this.taskQueues.set(taskId, tail)
|
|
559
|
+
this.pendingTaskIds.add(taskId)
|
|
560
|
+
this.notify()
|
|
561
|
+
try {
|
|
562
|
+
return await operation
|
|
563
|
+
} finally {
|
|
564
|
+
if (this.taskQueues.get(taskId) === tail) {
|
|
565
|
+
this.taskQueues.delete(taskId)
|
|
566
|
+
this.pendingTaskIds.delete(taskId)
|
|
567
|
+
this.notify()
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
private async performRemote(action: TaskBoardAction): Promise<boolean> {
|
|
573
|
+
const transport = this.deps.transport
|
|
574
|
+
if (transport === undefined) return true
|
|
575
|
+
this.transportError = undefined
|
|
576
|
+
this.notify()
|
|
577
|
+
try {
|
|
578
|
+
const accepted = this.acceptRemote(await transport.action(action))
|
|
579
|
+
return accepted || await this.refreshRemote()
|
|
580
|
+
} catch (error) {
|
|
581
|
+
await this.refreshRemote(messageOf(error))
|
|
582
|
+
return false
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
private async initializeRemote(): Promise<boolean> {
|
|
587
|
+
if (this.remoteInitialization !== undefined) return await this.remoteInitialization
|
|
588
|
+
const initialization = this.doInitializeRemote()
|
|
589
|
+
this.remoteInitialization = initialization
|
|
590
|
+
try {
|
|
591
|
+
return await initialization
|
|
592
|
+
} finally {
|
|
593
|
+
if (this.remoteInitialization === initialization) this.remoteInitialization = undefined
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
private async doInitializeRemote(): Promise<boolean> {
|
|
598
|
+
const transport = this.deps.transport
|
|
599
|
+
if (transport === undefined) return true
|
|
600
|
+
try {
|
|
601
|
+
this.acceptRemote(await transport.bootstrap(this.tasks))
|
|
602
|
+
if (!this.remoteSubscribed) {
|
|
603
|
+
this.remoteSubscribed = true
|
|
604
|
+
this.disposers.push(transport.subscribe(() => { void this.refreshRemote() }))
|
|
605
|
+
}
|
|
606
|
+
return true
|
|
607
|
+
} catch (error) {
|
|
608
|
+
this.transportError = messageOf(error)
|
|
609
|
+
this.notify()
|
|
610
|
+
return false
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
private async refreshRemote(preserveError?: string): Promise<boolean> {
|
|
615
|
+
const transport = this.deps.transport
|
|
616
|
+
if (transport === undefined) return true
|
|
617
|
+
try {
|
|
618
|
+
this.acceptRemote(await transport.state())
|
|
619
|
+
if (preserveError !== undefined) {
|
|
620
|
+
this.transportError = preserveError
|
|
621
|
+
this.notify()
|
|
622
|
+
}
|
|
623
|
+
return true
|
|
624
|
+
} catch (error) {
|
|
625
|
+
this.transportError = preserveError ?? messageOf(error)
|
|
626
|
+
this.notify()
|
|
627
|
+
return false
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
private acceptRemote(snapshot: TaskBoardSnapshot): boolean {
|
|
632
|
+
const currentLedgerId = this.hostState?.scheduler.ledgerId
|
|
633
|
+
const nextLedgerId = snapshot.scheduler.ledgerId
|
|
634
|
+
const sameGeneration = currentLedgerId === nextLedgerId
|
|
635
|
+
if (sameGeneration && this.hostState !== undefined && snapshot.revision < this.hostState.revision) return false
|
|
636
|
+
this.tasks = [...snapshot.tasks]
|
|
637
|
+
this.hostState = { revision: snapshot.revision, scheduler: snapshot.scheduler, power: snapshot.power }
|
|
638
|
+
this.transportError = undefined
|
|
639
|
+
if (this.selectedTaskId !== undefined && !this.tasks.some(task => task.id === this.selectedTaskId)) {
|
|
640
|
+
this.selectedTaskId = undefined
|
|
641
|
+
}
|
|
642
|
+
if (!this.archiveView && this.selectedTaskId !== undefined
|
|
643
|
+
&& this.tasks.find(task => task.id === this.selectedTaskId)?.archivedAt !== undefined) {
|
|
644
|
+
this.selectedTaskId = undefined
|
|
645
|
+
}
|
|
465
646
|
this.notify()
|
|
647
|
+
return true
|
|
466
648
|
}
|
|
467
649
|
|
|
468
650
|
private notify(): void {
|
|
@@ -484,4 +666,3 @@ function attachSessionId(
|
|
|
484
666
|
execution.id === executionId ? { ...execution, sessionId } : execution),
|
|
485
667
|
}
|
|
486
668
|
}
|
|
487
|
-
|
package/src/core/schedule.ts
CHANGED
|
@@ -73,16 +73,19 @@ export function isValidCron(expr: string): boolean {
|
|
|
73
73
|
/**
|
|
74
74
|
* Compute the next matching instant after `fromMs` (ms epoch), in local time,
|
|
75
75
|
* at minute granularity, strictly greater than `fromMs`. Returns the ms epoch
|
|
76
|
-
* of the matching minute's start, or undefined when
|
|
77
|
-
*
|
|
76
|
+
* of the matching minute's start, or undefined when the calendar constraint
|
|
77
|
+
* can never match (for example `0 0 30 2 *`). The five-year horizon includes
|
|
78
|
+
* a full leap cycle, so a valid February 29 schedule remains reachable from
|
|
79
|
+
* every non-leap year.
|
|
78
80
|
*/
|
|
79
81
|
export function nextRunAtMs(expr: string, fromMs: number): number | undefined {
|
|
80
82
|
const schedule = parseCron(expr)
|
|
81
83
|
if (schedule === null) return undefined
|
|
84
|
+
if (!hasPossibleCalendarDay(schedule)) return undefined
|
|
82
85
|
const from = new Date(fromMs)
|
|
83
86
|
// Scan from the next minute; Date rolls overflow (Feb 30 → Mar 2) for free.
|
|
84
87
|
const scan = new Date(from.getFullYear(), from.getMonth(), from.getDate(), from.getHours(), from.getMinutes() + 1, 0, 0)
|
|
85
|
-
const limitMs = fromMs + 366 * 24 * 60 * 60 * 1000
|
|
88
|
+
const limitMs = fromMs + 5 * 366 * 24 * 60 * 60 * 1000
|
|
86
89
|
while (scan.getTime() <= limitMs) {
|
|
87
90
|
if (matches(schedule, scan)) return scan.getTime()
|
|
88
91
|
scan.setMinutes(scan.getMinutes() + 1)
|
|
@@ -90,6 +93,20 @@ export function nextRunAtMs(expr: string, fromMs: number): number | undefined {
|
|
|
90
93
|
return undefined
|
|
91
94
|
}
|
|
92
95
|
|
|
96
|
+
/** Reject impossible month/day pairs without spending the multi-year scan. */
|
|
97
|
+
function hasPossibleCalendarDay(schedule: CronSchedule): boolean {
|
|
98
|
+
if (schedule.dayWildcard || !schedule.weekdayWildcard) return true
|
|
99
|
+
const maximumDay = new Map<number, number>([
|
|
100
|
+
[1, 31], [2, 29], [3, 31], [4, 30], [5, 31], [6, 30],
|
|
101
|
+
[7, 31], [8, 31], [9, 30], [10, 31], [11, 30], [12, 31],
|
|
102
|
+
])
|
|
103
|
+
for (const month of schedule.months) {
|
|
104
|
+
const maximum = maximumDay.get(month) ?? 0
|
|
105
|
+
if ([...schedule.days].some(day => day <= maximum)) return true
|
|
106
|
+
}
|
|
107
|
+
return false
|
|
108
|
+
}
|
|
109
|
+
|
|
93
110
|
/** Parse one comma-list field into the match set. */
|
|
94
111
|
function parseField(field: string, min: number, max: number, out: Set<number>): boolean {
|
|
95
112
|
if (field === '*') {
|
package/src/core/store.ts
CHANGED
|
@@ -1,13 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Legacy v1 browser persistence and the store seam used by pure client tests.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* localStorage under a versioned key — the same persistence mechanism dsh's
|
|
8
|
-
* own client snapshot stores use (`createSnapshotStore` persist). Data
|
|
9
|
-
* survives page refreshes and dsh restarts (same origin), and survives
|
|
10
|
-
* plugin uninstall (the key is simply left in place).
|
|
4
|
+
* Production v2 state is Host-authoritative. This backend is retained only to
|
|
5
|
+
* read `dsh.taskBoard.v1` for one-time import; the old value is never removed,
|
|
6
|
+
* so it remains a read-only rollback copy after migration.
|
|
11
7
|
*
|
|
12
8
|
* The seam keeps the backend swappable (e.g. an IndexedDB or a host-file
|
|
13
9
|
* channel later); tests run against the in-memory backend and a jsdom
|
package/src/core/tasks.ts
CHANGED
|
@@ -29,9 +29,8 @@ export interface ExecutionRecord {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
|
-
* A scheduled-run rule attached to a task. The
|
|
33
|
-
*
|
|
34
|
-
* persisted with the task (localStorage), so scheduling survives refreshes.
|
|
32
|
+
* A scheduled-run rule attached to a task. The Host scheduler triggers the
|
|
33
|
+
* task when `nextRunAt` is due and persists the rule in the Host ledger.
|
|
35
34
|
*/
|
|
36
35
|
export interface ScheduleRule {
|
|
37
36
|
/** Whether the schedule is armed. */
|
|
@@ -81,7 +80,8 @@ export interface TaskRecord {
|
|
|
81
80
|
permission?: TaskPermission
|
|
82
81
|
/**
|
|
83
82
|
* When the task was archived (ms epoch). Archived tasks keep their status
|
|
84
|
-
* and execution history
|
|
83
|
+
* and execution history, leave the main board, and cannot run until restored;
|
|
84
|
+
* absent means on-board.
|
|
85
85
|
*/
|
|
86
86
|
archivedAt?: number
|
|
87
87
|
}
|
|
@@ -146,8 +146,8 @@ export function isTaskStatus(value: unknown): value is TaskStatus {
|
|
|
146
146
|
}
|
|
147
147
|
|
|
148
148
|
/** Whether a manual move target is allowed from the given status. */
|
|
149
|
-
export function canMoveManually(
|
|
150
|
-
return (MANUAL_STATUSES as readonly TaskStatus[]).includes(to)
|
|
149
|
+
export function canMoveManually(from: TaskStatus, to: TaskStatus): boolean {
|
|
150
|
+
return from !== 'running' && (MANUAL_STATUSES as readonly TaskStatus[]).includes(to)
|
|
151
151
|
}
|
|
152
152
|
|
|
153
153
|
/** Normalize one optional execution-target string: trim; blank collapses to undefined. */
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Archive/restore task use case: move a settled (done/failed) task off the
|
|
3
|
-
* main board and back.
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* main board and back. The task keeps its status, execution history, and
|
|
4
|
+
* transcript references, while archiving disarms any schedule so it cannot
|
|
5
|
+
* create more execution records until the user restores and re-enables it.
|
|
6
6
|
*/
|
|
7
7
|
import type { TaskRecord } from '../tasks.ts'
|
|
8
8
|
import { ARCHIVABLE_STATUSES } from '../tasks.ts'
|
|
@@ -18,7 +18,8 @@ export interface ArchiveTaskResult {
|
|
|
18
18
|
/**
|
|
19
19
|
* Archive one task: only settled statuses (done/failed) can be archived;
|
|
20
20
|
* a running or not-yet-settled task stays on the board (its runner still
|
|
21
|
-
* owns its lifecycle).
|
|
21
|
+
* owns its lifecycle). Archiving disarms a schedule; already-archived tasks
|
|
22
|
+
* are a no-op.
|
|
22
23
|
*/
|
|
23
24
|
export function applyArchiveTask(
|
|
24
25
|
tasks: readonly TaskRecord[],
|
|
@@ -30,7 +31,15 @@ export function applyArchiveTask(
|
|
|
30
31
|
if (task.id !== id || task.archivedAt !== undefined) return task
|
|
31
32
|
if (!(ARCHIVABLE_STATUSES as readonly string[]).includes(task.status)) return task
|
|
32
33
|
applied = true
|
|
33
|
-
|
|
34
|
+
const schedule = task.schedule === undefined
|
|
35
|
+
? undefined
|
|
36
|
+
: { ...task.schedule, enabled: false, nextRunAt: undefined }
|
|
37
|
+
return {
|
|
38
|
+
...task,
|
|
39
|
+
...(schedule === undefined ? {} : { schedule }),
|
|
40
|
+
archivedAt: now,
|
|
41
|
+
updatedAt: now,
|
|
42
|
+
}
|
|
34
43
|
})
|
|
35
44
|
return { tasks: next, archived: applied }
|
|
36
45
|
}
|
|
@@ -22,9 +22,9 @@ export interface SetScheduleResult {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
|
-
* Set
|
|
26
|
-
* untouched); an enabled rule computes the
|
|
27
|
-
*
|
|
25
|
+
* Set an on-board task's schedule rule. A blank or invalid cron, or an
|
|
26
|
+
* archived task, is rejected (state untouched); an enabled rule computes the
|
|
27
|
+
* next run instant immediately, a disabled one carries no next-run instant.
|
|
28
28
|
* @param tasks - current ledger.
|
|
29
29
|
* @param id - the task to schedule.
|
|
30
30
|
* @param patch - rule fields to change (absent fields keep their current value).
|
|
@@ -37,12 +37,13 @@ export function applySetSchedule(
|
|
|
37
37
|
now: number,
|
|
38
38
|
): SetScheduleResult {
|
|
39
39
|
const task = tasks.find(candidate => candidate.id === id)
|
|
40
|
-
if (task === undefined) return { tasks, applied: false }
|
|
40
|
+
if (task === undefined || task.archivedAt !== undefined) return { tasks, applied: false }
|
|
41
41
|
const current = task.schedule
|
|
42
42
|
const cron = (patch.cron ?? current?.cron ?? '').trim()
|
|
43
43
|
if (cron === '' || !isValidCron(cron)) return { tasks, applied: false }
|
|
44
44
|
const enabled = patch.enabled ?? current?.enabled ?? false
|
|
45
45
|
const nextRunAt = enabled ? nextRunAtMs(cron, now) : undefined
|
|
46
|
+
if (enabled && nextRunAt === undefined) return { tasks, applied: false }
|
|
46
47
|
return {
|
|
47
48
|
tasks: tasks.map(candidate =>
|
|
48
49
|
candidate.id === id ? withSchedule(candidate, { enabled, cron, nextRunAt }, now) : candidate),
|
|
@@ -68,7 +69,7 @@ export function applyScheduleNextRun(
|
|
|
68
69
|
now: number,
|
|
69
70
|
): readonly TaskRecord[] {
|
|
70
71
|
return tasks.map(task =>
|
|
71
|
-
task.id === id && task.schedule !== undefined
|
|
72
|
+
task.id === id && task.archivedAt === undefined && task.schedule !== undefined
|
|
72
73
|
? withSchedule(task, { nextRunAt, lastTriggeredAt }, now)
|
|
73
74
|
: task)
|
|
74
75
|
}
|
package/src/dsh-home.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Generated by scripts/sync-shared.mjs from shared/host/dsh-home.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
|
2
|
+
/**
|
|
3
|
+
* DSH_HOME resolution shared by the plugin family's Host halves: the
|
|
4
|
+
* environment override wins, the platform home fallback follows. Mirrors
|
|
5
|
+
* what dsh-pet and dsh-liangshen each used to implement locally.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { homedir } from 'node:os'
|
|
9
|
+
import { isAbsolute, join } from 'node:path'
|
|
10
|
+
|
|
11
|
+
/** Expand a leading ~ (or ~user) in a path, platform-style. */
|
|
12
|
+
export function expandHome(path: string, home: string = homedir()): string {
|
|
13
|
+
if (path === '~') return home
|
|
14
|
+
if (path.startsWith('~/') || path.startsWith('~\\')) return join(home, path.slice(2))
|
|
15
|
+
return path
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Resolve the DSH home directory.
|
|
20
|
+
* @param env - process environment to read DSH_HOME from.
|
|
21
|
+
* @param home - platform home directory fallback (test seam).
|
|
22
|
+
* @returns the absolute DSH home path.
|
|
23
|
+
*/
|
|
24
|
+
export function resolveDshHome(env: NodeJS.ProcessEnv = process.env, home: string = homedir()): string {
|
|
25
|
+
const raw = env.DSH_HOME
|
|
26
|
+
if (raw !== undefined && raw.trim() !== '') {
|
|
27
|
+
const expanded = expandHome(raw.trim(), home)
|
|
28
|
+
return isAbsolute(expanded) ? expanded : join(process.cwd(), expanded)
|
|
29
|
+
}
|
|
30
|
+
return join(home, '.dsh')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Resolve the DSH home directory from the live environment. */
|
|
34
|
+
export function dshHome(): string {
|
|
35
|
+
return resolveDshHome()
|
|
36
|
+
}
|