@linxin666/dsh-client-ui-task-board 0.2.8 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -29
- package/README.i18n.yaml +2 -2
- package/README.md +1 -0
- package/README.zh.md +1 -0
- package/lib/client.js +18 -159
- package/lib/client.js.map +1 -1
- package/lib/index.js +123 -46
- package/lib/types/core/controller.d.ts +10 -31
- package/lib/types/core/tasks.d.ts +17 -1
- package/lib/types/host-ledger.d.ts +28 -0
- package/lib/types/host-service.d.ts +0 -1
- package/lib/types/http.d.ts +42 -0
- package/package.json +1 -1
- package/src/client/board/TaskBoard.tsx +2 -0
- package/src/client/board.module.css +185 -13
- package/src/client/sidebar-entry.ts +2 -2
- package/src/core/controller.ts +16 -152
- package/src/core/tasks.ts +31 -2
- package/src/host-ledger.ts +69 -3
- package/src/host-routes.ts +10 -14
- package/src/host-service.ts +32 -28
- package/src/http.ts +105 -0
- package/lib/types/core/execution.d.ts +0 -197
- package/src/core/execution.ts +0 -425
|
@@ -51,12 +51,13 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
|
|
|
51
51
|
/* --- sidebar entry row ------------------------------------------------------- */
|
|
52
52
|
|
|
53
53
|
.entry {
|
|
54
|
+
box-sizing: border-box;
|
|
54
55
|
display: flex;
|
|
55
56
|
align-items: center;
|
|
56
57
|
gap: 8px;
|
|
57
58
|
width: 100%;
|
|
58
|
-
height:
|
|
59
|
-
padding: 0
|
|
59
|
+
height: 36px;
|
|
60
|
+
padding: 0 10px;
|
|
60
61
|
background: transparent;
|
|
61
62
|
border: none;
|
|
62
63
|
border-radius: 8px;
|
|
@@ -67,12 +68,12 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
|
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
.entry:hover {
|
|
70
|
-
background: var(--dsw-
|
|
71
|
+
background: var(--dsw-alias-interactive-bg-hover);
|
|
71
72
|
color: var(--dsw-alias-label-primary);
|
|
72
73
|
}
|
|
73
74
|
|
|
74
75
|
.entry[data-active] {
|
|
75
|
-
background: var(--dsw-
|
|
76
|
+
background: var(--dsw-alias-interactive-bg-active);
|
|
76
77
|
color: var(--dsw-alias-label-primary);
|
|
77
78
|
font-weight: 600;
|
|
78
79
|
}
|
|
@@ -81,9 +82,17 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
|
|
|
81
82
|
display: inline-flex;
|
|
82
83
|
align-items: center;
|
|
83
84
|
justify-content: center;
|
|
85
|
+
width: 24px;
|
|
86
|
+
height: 24px;
|
|
84
87
|
flex: none;
|
|
85
88
|
}
|
|
86
89
|
|
|
90
|
+
.entryIcon svg {
|
|
91
|
+
display: block;
|
|
92
|
+
width: 18px;
|
|
93
|
+
height: 18px;
|
|
94
|
+
}
|
|
95
|
+
|
|
87
96
|
.entryLabel {
|
|
88
97
|
overflow: hidden;
|
|
89
98
|
text-overflow: ellipsis;
|
|
@@ -93,7 +102,10 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
|
|
|
93
102
|
[data-dsh-frame][data-sidebar-collapsed] .entry {
|
|
94
103
|
justify-content: center;
|
|
95
104
|
padding: 0;
|
|
96
|
-
width:
|
|
105
|
+
width: 36px;
|
|
106
|
+
height: 36px;
|
|
107
|
+
margin: 0 auto 12px;
|
|
108
|
+
border-radius: 50%;
|
|
97
109
|
}
|
|
98
110
|
|
|
99
111
|
[data-dsh-frame][data-sidebar-collapsed] .entryLabel {
|
|
@@ -809,31 +821,191 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
|
|
|
809
821
|
|
|
810
822
|
/* --- constrained board containers ------------------------------------------ */
|
|
811
823
|
|
|
812
|
-
@container task-board-view (max-width:
|
|
824
|
+
@container task-board-view (max-width: 768px) {
|
|
813
825
|
.board {
|
|
814
826
|
gap: 10px;
|
|
815
827
|
padding: 10px;
|
|
816
828
|
}
|
|
817
829
|
|
|
830
|
+
.boardHeader {
|
|
831
|
+
align-items: center;
|
|
832
|
+
flex-wrap: wrap;
|
|
833
|
+
gap: 8px;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
.backButton {
|
|
837
|
+
flex: 0 0 auto;
|
|
838
|
+
order: 1;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
.boardTitle {
|
|
842
|
+
flex: 1 1 auto;
|
|
843
|
+
order: 2;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
.boardHeader > .detailMeta {
|
|
847
|
+
flex: 1 0 100%;
|
|
848
|
+
margin-left: 0;
|
|
849
|
+
order: 3;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
.search {
|
|
853
|
+
flex: 1 0 100%;
|
|
854
|
+
min-width: 0;
|
|
855
|
+
order: 4;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
.boardHeader > button:not(.backButton) {
|
|
859
|
+
flex: 1 1 0;
|
|
860
|
+
min-width: 0;
|
|
861
|
+
order: 5;
|
|
862
|
+
}
|
|
863
|
+
|
|
818
864
|
.columns {
|
|
865
|
+
grid-auto-columns: 86cqw;
|
|
819
866
|
gap: 10px;
|
|
867
|
+
padding-inline: 2px 14cqw;
|
|
868
|
+
scroll-padding-inline: 2px;
|
|
869
|
+
scroll-snap-type: inline mandatory;
|
|
870
|
+
scrollbar-width: none;
|
|
871
|
+
-webkit-overflow-scrolling: touch;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
.columns::-webkit-scrollbar {
|
|
875
|
+
display: none;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
.column {
|
|
879
|
+
scroll-snap-align: start;
|
|
880
|
+
scroll-snap-stop: always;
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
@container task-board-view (max-width: 720px) {
|
|
885
|
+
.boardHeader > .detailMeta {
|
|
886
|
+
overflow: hidden;
|
|
887
|
+
text-overflow: ellipsis;
|
|
888
|
+
white-space: nowrap;
|
|
820
889
|
}
|
|
821
890
|
}
|
|
822
891
|
|
|
823
892
|
@container task-board-view (max-width: 600px) {
|
|
824
|
-
.
|
|
893
|
+
.board {
|
|
894
|
+
padding-inline: 8px;
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/* Viewport-specific mobile treatment: overlays occupy the dynamic viewport,
|
|
899
|
+
preserve notches/home indicators, and keep their action rows reachable. */
|
|
900
|
+
@media (max-width: 768px) {
|
|
901
|
+
[data-dsh-taskboard-view] {
|
|
902
|
+
height: 100vh;
|
|
903
|
+
height: 100dvh;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
.entry,
|
|
907
|
+
.card,
|
|
908
|
+
.primaryButton,
|
|
909
|
+
.ghostButton,
|
|
910
|
+
.dangerButton,
|
|
911
|
+
.iconButton,
|
|
912
|
+
.linkButton,
|
|
913
|
+
.search,
|
|
914
|
+
.input,
|
|
915
|
+
.select,
|
|
916
|
+
.schedulePreset,
|
|
917
|
+
.scheduleToggle {
|
|
918
|
+
min-height: 44px;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
.search,
|
|
922
|
+
.input,
|
|
923
|
+
.select,
|
|
924
|
+
.schedulePreset {
|
|
925
|
+
box-sizing: border-box;
|
|
926
|
+
font-size: 16px;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
.modalBackdrop {
|
|
930
|
+
align-items: stretch;
|
|
931
|
+
justify-content: stretch;
|
|
932
|
+
width: 100vw;
|
|
933
|
+
height: 100vh;
|
|
934
|
+
height: 100dvh;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
.modal,
|
|
938
|
+
.detail {
|
|
939
|
+
box-sizing: border-box;
|
|
940
|
+
width: 100vw;
|
|
941
|
+
height: 100vh;
|
|
942
|
+
height: 100dvh;
|
|
943
|
+
max-height: none;
|
|
944
|
+
border: 0;
|
|
945
|
+
border-radius: 0;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
.modal {
|
|
949
|
+
padding-top: max(16px, env(safe-area-inset-top));
|
|
950
|
+
padding-right: max(16px, env(safe-area-inset-right));
|
|
951
|
+
padding-bottom: max(16px, env(safe-area-inset-bottom));
|
|
952
|
+
padding-left: max(16px, env(safe-area-inset-left));
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
.modalFooter {
|
|
956
|
+
position: sticky;
|
|
957
|
+
bottom: 0;
|
|
958
|
+
z-index: 1;
|
|
825
959
|
flex-wrap: wrap;
|
|
826
|
-
|
|
960
|
+
padding-top: 8px;
|
|
961
|
+
background: var(--dsw-alias-bg-base);
|
|
827
962
|
}
|
|
828
963
|
|
|
829
|
-
.
|
|
830
|
-
flex: 1 1
|
|
964
|
+
.modalFooter > button {
|
|
965
|
+
flex: 1 1 120px;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
.detailHeader {
|
|
969
|
+
flex-wrap: wrap;
|
|
970
|
+
padding-top: max(12px, env(safe-area-inset-top));
|
|
971
|
+
padding-right: max(14px, env(safe-area-inset-right));
|
|
972
|
+
padding-left: max(14px, env(safe-area-inset-left));
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
.detailTitle {
|
|
831
976
|
min-width: 0;
|
|
832
977
|
}
|
|
833
978
|
|
|
834
|
-
.
|
|
835
|
-
|
|
836
|
-
|
|
979
|
+
.detailBody {
|
|
980
|
+
overscroll-behavior-y: contain;
|
|
981
|
+
padding-right: max(14px, env(safe-area-inset-right));
|
|
982
|
+
padding-left: max(14px, env(safe-area-inset-left));
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
.detailFooter {
|
|
986
|
+
flex-wrap: wrap;
|
|
987
|
+
padding-right: max(14px, env(safe-area-inset-right));
|
|
988
|
+
padding-bottom: max(12px, env(safe-area-inset-bottom));
|
|
989
|
+
padding-left: max(14px, env(safe-area-inset-left));
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
.detailFooter > button {
|
|
993
|
+
flex: 1 1 96px;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
.detailFooter > .detailMeta {
|
|
997
|
+
flex: 1 0 100%;
|
|
998
|
+
margin-left: 0;
|
|
999
|
+
text-align: end;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
.scheduleRow {
|
|
1003
|
+
align-items: stretch;
|
|
1004
|
+
flex-direction: column;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
.schedulePreset {
|
|
1008
|
+
width: 100%;
|
|
837
1009
|
}
|
|
838
1010
|
}
|
|
839
1011
|
|
|
@@ -19,8 +19,8 @@ import { mountSidebarEntry as mountSharedSidebarEntry } from './sidebar-entry-co
|
|
|
19
19
|
/** Stable data attribute identifying the injected entry row. */
|
|
20
20
|
export const ENTRY_SELECTOR = '[data-dsh-taskboard-entry]'
|
|
21
21
|
|
|
22
|
-
/** Inline icon
|
|
23
|
-
const ICON = '<svg viewBox="0 0 16 16" width="
|
|
22
|
+
/** Inline icon normalized to the shell's 18px navigation glyph size. */
|
|
23
|
+
const ICON = '<svg viewBox="0 0 16 16" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="2.5" width="12" height="11" rx="1.5"/><path d="M2 6.5h12M6.5 6.5v7"/></svg>'
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* Mount the sidebar entry, waiting for the shell to render and self-healing
|
package/src/core/controller.ts
CHANGED
|
@@ -2,22 +2,17 @@
|
|
|
2
2
|
* Board controller: the single owner of task-ledger state and view state.
|
|
3
3
|
*
|
|
4
4
|
* In production it projects the Host ledger and submits confirmed actions;
|
|
5
|
-
* the legacy store
|
|
6
|
-
*
|
|
5
|
+
* the legacy store seam remains for v1 migration tests. It also closes the
|
|
6
|
+
* board whenever the user navigates to a session.
|
|
7
7
|
* Framework-free (structural runtime faces) so the whole orchestration is
|
|
8
8
|
* unit-testable with fakes.
|
|
9
9
|
*
|
|
10
10
|
* The per use-case domain transitions (create/update/delete/schedule) live in
|
|
11
11
|
* dedicated modules under core/use-cases and are applied here; the controller
|
|
12
|
-
* owns only the orchestration seam (state, persistence, notify,
|
|
13
|
-
* navigation, reconciliation).
|
|
12
|
+
* owns only the orchestration seam (state, persistence, notify, navigation).
|
|
14
13
|
*/
|
|
15
|
-
import type { ExecutionEvent, ExecutionService } from './execution.ts'
|
|
16
14
|
import type { TaskStore } from './store.ts'
|
|
17
|
-
import {
|
|
18
|
-
settleExecution, startExecution, withStatus,
|
|
19
|
-
type NewTaskInput, type TaskRecord, type TaskStatus,
|
|
20
|
-
} from './tasks.ts'
|
|
15
|
+
import { withStatus, type NewTaskInput, type TaskRecord, type TaskStatus } from './tasks.ts'
|
|
21
16
|
import { applyArchiveTask, applyRestoreTask } from './use-cases/task-archive.ts'
|
|
22
17
|
import { applyCreateTask } from './use-cases/task-create.ts'
|
|
23
18
|
import { applyDeleteTask } from './use-cases/task-delete.ts'
|
|
@@ -45,15 +40,11 @@ export interface SessionsControllerFace {
|
|
|
45
40
|
/** Controller dependencies (all swappable in tests). */
|
|
46
41
|
export interface ControllerDeps {
|
|
47
42
|
store: TaskStore
|
|
48
|
-
/** Legacy browser execution seam used only by isolated v1 tests. */
|
|
49
|
-
exec?: ExecutionService
|
|
50
43
|
sessions: SessionsControllerFace
|
|
51
44
|
/** Clock; defaults to Date.now. */
|
|
52
45
|
now?: () => number
|
|
53
46
|
/** Id minting; defaults to a random-uuid. */
|
|
54
47
|
uuid?: () => string
|
|
55
|
-
/** Debounce (ms) for session-list-changed reconciles; defaults to 350. */
|
|
56
|
-
reconcileDebounceMs?: number
|
|
57
48
|
/** Host-authoritative transport; absent keeps the legacy in-memory test path. */
|
|
58
49
|
transport?: TaskBoardTransport
|
|
59
50
|
}
|
|
@@ -147,7 +138,7 @@ export class BoardController {
|
|
|
147
138
|
private remoteSubscribed = false
|
|
148
139
|
private remoteInitialization: Promise<boolean> | undefined
|
|
149
140
|
|
|
150
|
-
/** @param deps - store
|
|
141
|
+
/** @param deps - store and the sessions navigation face. */
|
|
151
142
|
constructor(private readonly deps: ControllerDeps) {
|
|
152
143
|
this.now = deps.now ?? (() => Date.now())
|
|
153
144
|
this.uuid = deps.uuid ?? randomUuid
|
|
@@ -158,11 +149,7 @@ export class BoardController {
|
|
|
158
149
|
/** Load the persisted ledger and start the navigation/status subscriptions. */
|
|
159
150
|
start(): void {
|
|
160
151
|
this.tasks = this.deps.store.load()
|
|
161
|
-
if (this.deps.transport !== undefined)
|
|
162
|
-
void this.initializeRemote()
|
|
163
|
-
} else {
|
|
164
|
-
void this.reconcileRunningTasks()
|
|
165
|
-
}
|
|
152
|
+
if (this.deps.transport !== undefined) void this.initializeRemote()
|
|
166
153
|
// A sibling tab may have edited or deleted the ledger (same origin,
|
|
167
154
|
// storage events). Reload on external change so a task deleted in
|
|
168
155
|
// another tab stops firing here — and is never written back by this
|
|
@@ -182,8 +169,6 @@ export class BoardController {
|
|
|
182
169
|
dispose(): void {
|
|
183
170
|
for (const dispose of this.disposers.splice(0)) dispose()
|
|
184
171
|
this.listeners.clear()
|
|
185
|
-
if (this.reconcileTimer !== undefined) clearTimeout(this.reconcileTimer)
|
|
186
|
-
this.reconcileTimer = undefined
|
|
187
172
|
}
|
|
188
173
|
|
|
189
174
|
// --- snapshot / subscription ------------------------------------------------
|
|
@@ -413,68 +398,30 @@ export class BoardController {
|
|
|
413
398
|
// --- execution ---------------------------------------------------------------
|
|
414
399
|
|
|
415
400
|
/**
|
|
416
|
-
*
|
|
417
|
-
*
|
|
418
|
-
* already running is ignored
|
|
401
|
+
* Request a Host execution for a task: the Host ledger owns the running
|
|
402
|
+
* transition, the execution record, and the settlement. A second call
|
|
403
|
+
* while the task is already running is ignored; without a Host transport
|
|
404
|
+
* the run is refused (returns false).
|
|
419
405
|
*/
|
|
420
406
|
async runTask(id: string): Promise<boolean> {
|
|
421
407
|
const task = this.tasks.find(candidate => candidate.id === id)
|
|
422
408
|
if (task === undefined || task.archivedAt !== undefined || task.status === 'running') return false
|
|
423
|
-
if (this.deps.transport
|
|
424
|
-
|
|
425
|
-
}
|
|
426
|
-
const { task: next, execution } = startExecution(task, this.now(), this.uuid())
|
|
427
|
-
this.tasks = this.tasks.map(candidate => candidate.id === id ? next : candidate)
|
|
428
|
-
this.persistAndNotify()
|
|
429
|
-
// This page owns the settlement of its own launches: the live watch
|
|
430
|
-
// (ExecutionService.run) settles on the turn boundary, and list
|
|
431
|
-
// reconciliation must not pre-empt it with a session that has not
|
|
432
|
-
// started a turn yet (its list row is idle, not completed).
|
|
433
|
-
this.activeExecutionIds.add(execution.id)
|
|
434
|
-
if (this.deps.exec === undefined) throw new Error('legacy execution service is unavailable')
|
|
435
|
-
await this.deps.exec.run(next, execution, (event) => { this.handleExecutionEvent(event) })
|
|
436
|
-
return true
|
|
409
|
+
if (this.deps.transport === undefined) return false
|
|
410
|
+
return await this.commitRemote({ kind: 'run', taskId: id }, id)
|
|
437
411
|
}
|
|
438
412
|
|
|
439
|
-
/** Re-run a settled task
|
|
413
|
+
/** Re-run a settled task through the Host (the Host replans and executes). */
|
|
440
414
|
async rerunTask(id: string): Promise<void> {
|
|
441
415
|
const task = this.tasks.find(candidate => candidate.id === id)
|
|
442
416
|
if (task === undefined || task.archivedAt !== undefined) return
|
|
443
|
-
if (this.deps.transport
|
|
444
|
-
|
|
445
|
-
return
|
|
446
|
-
}
|
|
447
|
-
if (task.status !== 'running') {
|
|
448
|
-
this.tasks = this.tasks.map(candidate => candidate.id === id ? withStatus(candidate, 'todo', this.now()) : candidate)
|
|
449
|
-
this.persistAndNotify()
|
|
450
|
-
}
|
|
451
|
-
await this.runTask(id)
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
private handleExecutionEvent(event: ExecutionEvent): void {
|
|
455
|
-
if (event.kind === 'started') {
|
|
456
|
-
this.tasks = this.tasks.map(task => task.id === event.taskId
|
|
457
|
-
? attachSessionId(task, event.executionId, event.sessionId, this.now())
|
|
458
|
-
: task)
|
|
459
|
-
this.persistAndNotify()
|
|
460
|
-
return
|
|
461
|
-
}
|
|
462
|
-
this.activeExecutionIds.delete(event.executionId)
|
|
463
|
-
this.tasks = this.tasks.map(task => task.id === event.taskId
|
|
464
|
-
? settleExecution(task, event.executionId, event.outcome, this.now(), event.error)
|
|
465
|
-
: task)
|
|
466
|
-
this.persistAndNotify()
|
|
417
|
+
if (this.deps.transport === undefined) return
|
|
418
|
+
await this.commitRemote({ kind: 'rerun', taskId: id }, id)
|
|
467
419
|
}
|
|
468
420
|
|
|
469
421
|
// --- internals ---------------------------------------------------------------
|
|
470
422
|
|
|
471
|
-
/**
|
|
423
|
+
/** Close the board when the user navigates to another session. */
|
|
472
424
|
private onSessionsChanged(): void {
|
|
473
|
-
// Background/leftover executions settle through the session list (their
|
|
474
|
-
// conversation snapshots stay cold until opened). Coalesce the burst of
|
|
475
|
-
// list notifications into one reconcile pass instead of fanning out a
|
|
476
|
-
// history read per notification; see scheduleReconcile.
|
|
477
|
-
if (this.deps.transport === undefined) this.scheduleReconcile()
|
|
478
425
|
if (!this.boardOpen) return
|
|
479
426
|
const current = currentOf(this.deps.sessions)
|
|
480
427
|
if (current !== this.lastCurrent) this.closeBoard()
|
|
@@ -483,74 +430,6 @@ export class BoardController {
|
|
|
483
430
|
|
|
484
431
|
private lastCurrent: string | undefined = undefined
|
|
485
432
|
|
|
486
|
-
/** Execution ids launched on this page; they settle via their live watch, never list reconciliation. */
|
|
487
|
-
private readonly activeExecutionIds = new Set<string>()
|
|
488
|
-
|
|
489
|
-
/** Debounce timer for {@link reconcileRunningTasks}. */
|
|
490
|
-
private reconcileTimer: ReturnType<typeof setTimeout> | undefined = undefined
|
|
491
|
-
|
|
492
|
-
/** Whether a reconcile pass is underway (single-flight guard). */
|
|
493
|
-
private reconcileInFlight = false
|
|
494
|
-
|
|
495
|
-
/**
|
|
496
|
-
* Debounce + single-flight trigger for the running-task reconciliation.
|
|
497
|
-
* Session-list notifications arrive in bursts (one per session status
|
|
498
|
-
* change); both guards together keep a burst from reading the history API
|
|
499
|
-
* once per running task.
|
|
500
|
-
*/
|
|
501
|
-
private scheduleReconcile(): void {
|
|
502
|
-
if (this.reconcileTimer !== undefined) return
|
|
503
|
-
this.reconcileTimer = setTimeout(() => {
|
|
504
|
-
this.reconcileTimer = undefined
|
|
505
|
-
void this.reconcileRunningTasks()
|
|
506
|
-
}, this.deps.reconcileDebounceMs ?? 350)
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
/** Settle tasks left 'running' whose sessions already finished. */
|
|
510
|
-
private async reconcileRunningTasks(): Promise<void> {
|
|
511
|
-
if (this.deps.exec === undefined) return
|
|
512
|
-
if (this.reconcileInFlight) {
|
|
513
|
-
// A session change arrived while a pass was in flight. The in-flight
|
|
514
|
-
// pass already captured the task list it iterates and will not revisit
|
|
515
|
-
// this task, so dropping the notification would leave it stuck
|
|
516
|
-
// 'running'. Re-arm the debounce so the change is reconciled once the
|
|
517
|
-
// current pass settles.
|
|
518
|
-
this.scheduleReconcile()
|
|
519
|
-
return
|
|
520
|
-
}
|
|
521
|
-
this.reconcileInFlight = true
|
|
522
|
-
try {
|
|
523
|
-
type Settled = Extract<ExecutionEvent, { kind: 'settled' }>
|
|
524
|
-
const events: Array<{ taskId: string; event: Settled }> = []
|
|
525
|
-
for (const task of this.tasks) {
|
|
526
|
-
if (task.status !== 'running') continue
|
|
527
|
-
const execution = task.executions[task.executions.length - 1]
|
|
528
|
-
// Runs launched on this page settle through their live watch (turn
|
|
529
|
-
// boundary); reconciliation exists for background/leftover runs.
|
|
530
|
-
if (execution !== undefined && this.activeExecutionIds.has(execution.id)) continue
|
|
531
|
-
const event = await this.deps.exec.reconcile(task)
|
|
532
|
-
if (event !== undefined && event.kind === 'settled') events.push({ taskId: task.id, event })
|
|
533
|
-
}
|
|
534
|
-
if (events.length === 0) return
|
|
535
|
-
let changed = false
|
|
536
|
-
for (const { taskId, event } of events) {
|
|
537
|
-
// The reconcile call above awaited: a sibling tab may have rewritten
|
|
538
|
-
// the ledger (storage event reload) meanwhile. Re-read the freshest
|
|
539
|
-
// record now so the stale task captured before the await can never
|
|
540
|
-
// overwrite fields the sibling wrote.
|
|
541
|
-
const task = this.tasks.find(candidate => candidate.id === taskId)
|
|
542
|
-
if (task === undefined) continue
|
|
543
|
-
const next = settleExecution(task, event.executionId, event.outcome, this.now(), event.error)
|
|
544
|
-
if (next === task) continue
|
|
545
|
-
this.tasks = this.tasks.map(candidate => candidate.id === taskId ? next : candidate)
|
|
546
|
-
changed = true
|
|
547
|
-
}
|
|
548
|
-
if (changed) this.persistAndNotify()
|
|
549
|
-
} finally {
|
|
550
|
-
this.reconcileInFlight = false
|
|
551
|
-
}
|
|
552
|
-
}
|
|
553
|
-
|
|
554
433
|
private persistAndNotify(): void {
|
|
555
434
|
if (this.deps.transport === undefined) this.deps.store.save(this.tasks)
|
|
556
435
|
this.notify()
|
|
@@ -676,18 +555,3 @@ export class BoardController {
|
|
|
676
555
|
for (const fn of [...this.listeners]) fn()
|
|
677
556
|
}
|
|
678
557
|
}
|
|
679
|
-
|
|
680
|
-
/** Record which session ran an execution (once the execution service reports it). */
|
|
681
|
-
function attachSessionId(
|
|
682
|
-
task: TaskRecord,
|
|
683
|
-
executionId: string,
|
|
684
|
-
sessionId: string,
|
|
685
|
-
now: number,
|
|
686
|
-
): TaskRecord {
|
|
687
|
-
return {
|
|
688
|
-
...task,
|
|
689
|
-
updatedAt: now,
|
|
690
|
-
executions: task.executions.map(execution =>
|
|
691
|
-
execution.id === executionId ? { ...execution, sessionId } : execution),
|
|
692
|
-
}
|
|
693
|
-
}
|
package/src/core/tasks.ts
CHANGED
|
@@ -28,6 +28,27 @@ export interface ExecutionRecord {
|
|
|
28
28
|
error: string | undefined
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Maximum number of execution records retained per task. Older settled runs
|
|
33
|
+
* are trimmed when a new execution starts so per-action ledger cost stays
|
|
34
|
+
* bounded regardless of how often a task ran before.
|
|
35
|
+
*/
|
|
36
|
+
export const EXECUTION_HISTORY_LIMIT = 20
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Trim an execution list to at most {@link EXECUTION_HISTORY_LIMIT} records,
|
|
40
|
+
* most recent last. A running (unsettled) execution is never trimmed: the
|
|
41
|
+
* Host monitor and restart recovery depend on the active record, and a task
|
|
42
|
+
* cannot start a new run while one is still open.
|
|
43
|
+
*/
|
|
44
|
+
export function retainRecentExecutions(executions: readonly ExecutionRecord[]): ExecutionRecord[] {
|
|
45
|
+
if (executions.length <= EXECUTION_HISTORY_LIMIT) return [...executions]
|
|
46
|
+
const open = executions.filter(execution => execution.endedAt === undefined)
|
|
47
|
+
const settled = executions.filter(execution => execution.endedAt !== undefined)
|
|
48
|
+
const keepSettled = Math.max(EXECUTION_HISTORY_LIMIT - open.length, 0)
|
|
49
|
+
return [...settled.slice(Math.max(settled.length - keepSettled, 0)), ...open]
|
|
50
|
+
}
|
|
51
|
+
|
|
31
52
|
/**
|
|
32
53
|
* A scheduled-run rule attached to a task. The Host scheduler triggers the
|
|
33
54
|
* task when `nextRunAt` is due and persists the rule in the Host ledger.
|
|
@@ -59,7 +80,10 @@ export interface TaskRecord {
|
|
|
59
80
|
createdAt: number
|
|
60
81
|
/** Last mutation instant (ms epoch). */
|
|
61
82
|
updatedAt: number
|
|
62
|
-
/**
|
|
83
|
+
/**
|
|
84
|
+
* Execution history retained on the task, most recent last: the latest
|
|
85
|
+
* {@link EXECUTION_HISTORY_LIMIT} attempts, oldest trimmed on append.
|
|
86
|
+
*/
|
|
63
87
|
executions: ExecutionRecord[]
|
|
64
88
|
/** Optional scheduled-run rule (absent on tasks without a schedule). */
|
|
65
89
|
schedule?: ScheduleRule
|
|
@@ -221,7 +245,12 @@ export function startExecution(
|
|
|
221
245
|
error: undefined,
|
|
222
246
|
}
|
|
223
247
|
return {
|
|
224
|
-
task: {
|
|
248
|
+
task: {
|
|
249
|
+
...task,
|
|
250
|
+
status: 'running',
|
|
251
|
+
updatedAt: now,
|
|
252
|
+
executions: retainRecentExecutions([...task.executions, execution]),
|
|
253
|
+
},
|
|
225
254
|
execution,
|
|
226
255
|
}
|
|
227
256
|
}
|
package/src/host-ledger.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { dirname, join } from 'node:path'
|
|
|
5
5
|
import { dshHome } from './dsh-home.ts'
|
|
6
6
|
import { isValidCron, nextRunAtMs } from './core/schedule.ts'
|
|
7
7
|
import { parseLedger } from './core/store.ts'
|
|
8
|
-
import { canMoveManually, settleExecution, startExecution, withStatus, type ExecutionRecord, type TaskRecord } from './core/tasks.ts'
|
|
8
|
+
import { canMoveManually, retainRecentExecutions, settleExecution, startExecution, withStatus, type ExecutionRecord, type TaskRecord } from './core/tasks.ts'
|
|
9
9
|
import { applyArchiveTask, applyRestoreTask } from './core/use-cases/task-archive.ts'
|
|
10
10
|
import { applyCreateTask } from './core/use-cases/task-create.ts'
|
|
11
11
|
import { applyDeleteTask } from './core/use-cases/task-delete.ts'
|
|
@@ -41,6 +41,27 @@ export interface OpenedRun {
|
|
|
41
41
|
execution: ExecutionRecord
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/** Minimal value copy used by the Host session monitor. */
|
|
45
|
+
export interface OpenExecutionReference {
|
|
46
|
+
readonly taskId: string
|
|
47
|
+
readonly executionId: string
|
|
48
|
+
readonly sessionId: string | undefined
|
|
49
|
+
readonly startedAt: number
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Minimal value copy used by the Host scheduler. */
|
|
53
|
+
export interface DueScheduleReference {
|
|
54
|
+
readonly taskId: string
|
|
55
|
+
readonly cron: string
|
|
56
|
+
readonly nextRunAt: number
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Derived runtime data for one session-poll pass. */
|
|
60
|
+
export interface LedgerRuntimeView {
|
|
61
|
+
readonly armedSchedules: number
|
|
62
|
+
readonly openExecutions: readonly OpenExecutionReference[]
|
|
63
|
+
}
|
|
64
|
+
|
|
44
65
|
const MAX_REQUEST_CACHE = 256
|
|
45
66
|
|
|
46
67
|
interface CachedRequest {
|
|
@@ -211,7 +232,8 @@ function mergeTask(a: TaskRecord, b: TaskRecord): TaskRecord {
|
|
|
211
232
|
const previous = byId.get(entry.id)
|
|
212
233
|
byId.set(entry.id, previous === undefined ? entry : betterExecution(previous, entry))
|
|
213
234
|
}
|
|
214
|
-
|
|
235
|
+
const executions = [...byId.values()].sort((x, y) => x.startedAt - y.startedAt)
|
|
236
|
+
return { ...newer, executions: retainRecentExecutions(executions) }
|
|
215
237
|
}
|
|
216
238
|
|
|
217
239
|
function parseHostTasks(values: readonly unknown[]): TaskRecord[] {
|
|
@@ -284,6 +306,50 @@ export class HostTaskLedger {
|
|
|
284
306
|
return { revision, tasks: cloneTasks(this.document.tasks), scheduler }
|
|
285
307
|
}
|
|
286
308
|
|
|
309
|
+
/**
|
|
310
|
+
* Runtime-only projection for the 5 s Host poll. It copies just primitive
|
|
311
|
+
* identifiers and timestamps, never the complete task/execution history or
|
|
312
|
+
* an authoritative mutable object from the ledger.
|
|
313
|
+
*/
|
|
314
|
+
runtimeView(): LedgerRuntimeView {
|
|
315
|
+
let armedSchedules = 0
|
|
316
|
+
const openExecutions: OpenExecutionReference[] = []
|
|
317
|
+
for (const task of this.document.tasks) {
|
|
318
|
+
if (task.archivedAt === undefined && task.schedule?.enabled === true) armedSchedules += 1
|
|
319
|
+
for (const execution of task.executions) {
|
|
320
|
+
if (execution.endedAt !== undefined) continue
|
|
321
|
+
openExecutions.push({
|
|
322
|
+
taskId: task.id,
|
|
323
|
+
executionId: execution.id,
|
|
324
|
+
sessionId: execution.sessionId,
|
|
325
|
+
startedAt: execution.startedAt,
|
|
326
|
+
})
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return { armedSchedules, openExecutions }
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Count armed, non-archived schedules without cloning task histories. */
|
|
333
|
+
armedScheduleCount(): number {
|
|
334
|
+
let count = 0
|
|
335
|
+
for (const task of this.document.tasks) {
|
|
336
|
+
if (task.archivedAt === undefined && task.schedule?.enabled === true) count += 1
|
|
337
|
+
}
|
|
338
|
+
return count
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Return value-only references for schedules due at the supplied Host time. */
|
|
342
|
+
dueSchedules(now: number): DueScheduleReference[] {
|
|
343
|
+
const due: DueScheduleReference[] = []
|
|
344
|
+
for (const task of this.document.tasks) {
|
|
345
|
+
if (task.archivedAt !== undefined) continue
|
|
346
|
+
const schedule = task.schedule
|
|
347
|
+
if (schedule === undefined || !schedule.enabled || schedule.nextRunAt === undefined || schedule.nextRunAt > now) continue
|
|
348
|
+
due.push({ taskId: task.id, cron: schedule.cron, nextRunAt: schedule.nextRunAt })
|
|
349
|
+
}
|
|
350
|
+
return due
|
|
351
|
+
}
|
|
352
|
+
|
|
287
353
|
subscribe(listener: () => void): () => void {
|
|
288
354
|
this.listeners.add(listener)
|
|
289
355
|
return () => { this.listeners.delete(listener) }
|
|
@@ -509,7 +575,7 @@ export class HostTaskLedger {
|
|
|
509
575
|
try {
|
|
510
576
|
const parsed = JSON.parse(readFileSync(this.file, 'utf8')) as Partial<LedgerDocument>
|
|
511
577
|
if (parsed.schemaVersion !== TASK_BOARD_SCHEMA_VERSION || !Array.isArray(parsed.tasks)) throw new Error('unsupported ledger schema')
|
|
512
|
-
const tasks = parseHostTasks(parsed.tasks)
|
|
578
|
+
const tasks = parseHostTasks(parsed.tasks).map(task => ({ ...task, executions: retainRecentExecutions(task.executions) }))
|
|
513
579
|
const invalidScheduleIds = (parsed.tasks as unknown[]).flatMap(value => {
|
|
514
580
|
if (typeof value !== 'object' || value === null) return []
|
|
515
581
|
const row = value as { id?: unknown; schedule?: unknown }
|