@linxin666/dsh-client-ui-task-board 0.4.3 → 0.4.5
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 +56 -18
- package/README.zh.md +56 -18
- package/lib/client.js +6242 -958
- package/lib/client.js.map +1 -1
- package/lib/index.js +3475 -234
- package/lib/types/client/PluginSettingsCard.d.ts +7 -0
- package/lib/types/client/TaskBoardSettingsCard.d.ts +19 -3
- package/lib/types/client/board/ClampedMarkdown.d.ts +3 -0
- package/lib/types/client/board/ConfirmDialog.d.ts +4 -1
- package/lib/types/client/board/EditTaskModal.d.ts +5 -2
- package/lib/types/client/board/LinkSubtaskModal.d.ts +5 -2
- package/lib/types/client/board/NewTaskModal.d.ts +4 -1
- package/lib/types/client/board/TagManagerModal.d.ts +26 -0
- package/lib/types/client/board/TaskBoard.d.ts +10 -1
- package/lib/types/client/board/TaskCard.d.ts +14 -1
- package/lib/types/client/board/TaskDetail.d.ts +3 -1
- package/lib/types/client/board/TaskForm.d.ts +23 -2
- package/lib/types/client/board/VerificationReport.d.ts +23 -0
- package/lib/types/client/board/card-view.d.ts +113 -0
- package/lib/types/client/board/icons.d.ts +32 -0
- package/lib/types/client/board/overlay.d.ts +69 -0
- package/lib/types/client/board/preset-label.d.ts +22 -0
- package/lib/types/client/board/status-key.d.ts +15 -0
- package/lib/types/client/board/task-markdown.d.ts +19 -0
- package/lib/types/client/board-card-seat.d.ts +34 -0
- package/lib/types/client/host-api.d.ts +24 -1
- package/lib/types/client/index.d.ts +19 -0
- package/lib/types/client/locales.d.ts +141 -3
- package/lib/types/client/native-panel.d.ts +6 -20
- package/lib/types/client/schedule-zone.d.ts +47 -0
- package/lib/types/client/seats.d.ts +18 -0
- package/lib/types/client/service.d.ts +32 -0
- package/lib/types/client/settings-form.d.ts +16 -0
- package/lib/types/core/controller.d.ts +38 -1
- package/lib/types/core/degraded-reason.d.ts +46 -0
- package/lib/types/core/extension.d.ts +283 -0
- package/lib/types/core/handover.d.ts +13 -1
- package/lib/types/core/issue-form.d.ts +16 -0
- package/lib/types/core/package-name.d.ts +9 -0
- package/lib/types/core/schedule.d.ts +95 -20
- package/lib/types/core/subtask.d.ts +30 -11
- package/lib/types/core/tasks.d.ts +110 -5
- package/lib/types/core/use-cases/task-create.d.ts +2 -1
- package/lib/types/core/use-cases/task-schedule.d.ts +8 -4
- package/lib/types/core/use-cases/task-tag.d.ts +48 -0
- package/lib/types/core/use-cases/task-update.d.ts +5 -3
- package/lib/types/core/verification.d.ts +386 -0
- package/lib/types/core/workspace-target.d.ts +41 -0
- package/lib/types/host/agent-tools.d.ts +1 -1
- package/lib/types/host/extension-registry.d.ts +111 -0
- package/lib/types/host/llm-dispatch.d.ts +63 -0
- package/lib/types/host/verification-gate.d.ts +71 -0
- package/lib/types/host/verification-runner.d.ts +108 -0
- package/lib/types/host/workspace-evidence.d.ts +53 -0
- package/lib/types/host-ledger.d.ts +107 -11
- package/lib/types/host-runner.d.ts +99 -4
- package/lib/types/host-service.d.ts +114 -2
- package/lib/types/index.d.ts +80 -26
- package/lib/types/protocol.d.ts +53 -3
- package/lib/types/tool-surface.d.ts +71 -0
- package/package.json +27 -26
- package/src/client/PluginSettingsCard.tsx +9 -0
- package/src/client/TaskBoardSettingsCard.tsx +304 -65
- package/src/client/board/ClampedMarkdown.tsx +69 -0
- package/src/client/board/ConfirmDialog.tsx +7 -3
- package/src/client/board/EditTaskModal.tsx +5 -2
- package/src/client/board/LinkSubtaskModal.tsx +30 -15
- package/src/client/board/NewTaskModal.tsx +288 -151
- package/src/client/board/TagManagerModal.tsx +235 -0
- package/src/client/board/TaskBoard.tsx +259 -103
- package/src/client/board/TaskCard.tsx +238 -86
- package/src/client/board/TaskDetail.tsx +154 -59
- package/src/client/board/TaskForm.tsx +61 -6
- package/src/client/board/VerificationReport.tsx +203 -0
- package/src/client/board/card-view.ts +220 -0
- package/src/client/board/icons.tsx +89 -0
- package/src/client/board/overlay.tsx +189 -0
- package/src/client/board/preset-label.ts +44 -0
- package/src/client/board/status-key.ts +27 -0
- package/src/client/board/task-markdown.module.css +137 -0
- package/src/client/board/task-markdown.tsx +106 -0
- package/src/client/board-card-seat.ts +102 -0
- package/src/client/board-settings.module.css +66 -0
- package/src/client/board.module.css +602 -68
- package/src/client/host-api.ts +109 -6
- package/src/client/index.ts +48 -6
- package/src/client/locales.ts +290 -12
- package/src/client/native-panel.tsx +18 -2
- package/src/client/schedule-zone.ts +135 -0
- package/src/client/seats.tsx +58 -0
- package/src/client/service.ts +94 -0
- package/src/client/settings-card.module.css +31 -5
- package/src/client/settings-form.ts +27 -1
- package/src/core/controller.ts +101 -5
- package/src/core/degraded-reason.ts +81 -0
- package/src/core/extension.ts +376 -0
- package/src/core/freeze-snapshot.ts +2 -2
- package/src/core/handover.ts +16 -1
- package/src/core/issue-form.ts +19 -0
- package/src/core/package-name.ts +9 -0
- package/src/core/schedule.ts +211 -55
- package/src/core/store.ts +26 -1
- package/src/core/subtask.ts +77 -22
- package/src/core/tasks.ts +138 -10
- package/src/core/use-cases/task-create.ts +14 -2
- package/src/core/use-cases/task-parent.ts +3 -2
- package/src/core/use-cases/task-schedule.ts +17 -6
- package/src/core/use-cases/task-tag.ts +110 -0
- package/src/core/use-cases/task-update.ts +13 -4
- package/src/core/verification.ts +826 -0
- package/src/core/workspace-target.ts +59 -0
- package/src/host/agent-tools.ts +58 -24
- package/src/host/extension-registry.ts +509 -0
- package/src/host/llm-dispatch.ts +87 -0
- package/src/host/verification-gate.ts +331 -0
- package/src/host/verification-runner.ts +535 -0
- package/src/host/workspace-evidence.ts +176 -0
- package/src/host-ai.ts +15 -9
- package/src/host-ledger.ts +342 -51
- package/src/host-routes.ts +15 -2
- package/src/host-runner.ts +219 -20
- package/src/host-service.ts +346 -26
- package/src/index.ts +204 -16
- package/src/protocol.ts +122 -9
- package/src/tool-surface.ts +95 -0
- package/lib/types/host/run-guarded.d.ts +0 -39
- package/src/host/run-guarded.ts +0 -76
package/src/core/schedule.ts
CHANGED
|
@@ -1,15 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* runs. Framework-free and dependency-free so the scheduler
|
|
4
|
-
* share one tiny pure module.
|
|
2
|
+
* Zone-aware 5-field cron parsing and next-run computation for scheduled task
|
|
3
|
+
* runs. Framework-free and dependency-free (Intl only) so the Host scheduler
|
|
4
|
+
* and the browser next-run preview share one tiny pure module.
|
|
5
5
|
*
|
|
6
6
|
* Grammar: five whitespace-separated fields, 分 时 日 月 周. Every field
|
|
7
7
|
* supports the wildcard, step (wildcard or range + "/n"), single value,
|
|
8
8
|
* inclusive range a-b, and comma lists mixing any of those. Ranges: minutes
|
|
9
9
|
* 0-59, hours 0-23, days 1-31, months 1-12, weekdays 0-7 (0 and 7 both mean
|
|
10
|
-
* Sunday).
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* Sunday). Invalid expressions parse to null and are rejected by the UI and
|
|
11
|
+
* the Host.
|
|
12
|
+
*
|
|
13
|
+
* Day semantics follow Vixie cron: a field is *starred* when its field text
|
|
14
|
+
* starts with `*`. When day-of-month and day-of-week are both restricted the
|
|
15
|
+
* fields combine with OR; otherwise they combine with AND. The distinction
|
|
16
|
+
* matters for a stepped star such as `*/2`, which still restricts the dates it
|
|
17
|
+
* matches, so `0 0 */2 * 1` means "the Mondays that fall on an even day of
|
|
18
|
+
* month" rather than "every even day, or every Monday".
|
|
19
|
+
*
|
|
20
|
+
* Time zone: every candidate wall clock is interpreted in an explicit IANA
|
|
21
|
+
* zone (the schedule's stored zone, or the Host zone when none is given).
|
|
22
|
+
* A wall clock that does not exist (spring-forward gap) is skipped, and an
|
|
23
|
+
* ambiguous wall clock (fall-back overlap) resolves to the earlier instant, so
|
|
24
|
+
* a repeated local minute fires exactly once. The Host-scheduler semantics are
|
|
25
|
+
* byte-identical to `@deepseek-ai/dsh-schedule`'s resolver, which was verified
|
|
26
|
+
* against it across twelve IANA zones (including 30-minute and 45-minute DST
|
|
27
|
+
* offsets and a date-line jump) and 300k decision instants.
|
|
28
|
+
*
|
|
29
|
+
* That upstream is on the experimental track and is NOT a dependency here (no
|
|
30
|
+
* import, no inject, no peer range), so a change to it fails no test. Re-run the
|
|
31
|
+
* differential check on any `dsh-schedule` release or cohort bump that moves it,
|
|
32
|
+
* and keep this comment honest; the re-verification trigger is recorded in
|
|
33
|
+
* [the owning Agent Note](../../../../.agents/notes/implemented/feature/2026-09-29-task-board-schedule-time-zone.md).
|
|
13
34
|
*/
|
|
14
35
|
|
|
15
36
|
/** The parsed match sets of one cron expression. */
|
|
@@ -20,10 +41,10 @@ export interface CronSchedule {
|
|
|
20
41
|
months: ReadonlySet<number>
|
|
21
42
|
/** Weekdays 0-6, 0 = Sunday (input 7 normalized to 0). */
|
|
22
43
|
weekdays: ReadonlySet<number>
|
|
23
|
-
/** Whether the day-of-month field
|
|
24
|
-
|
|
25
|
-
/** Whether the weekday field
|
|
26
|
-
|
|
44
|
+
/** Whether the day-of-month field text starts with `*` (Vixie starred). */
|
|
45
|
+
dayStarred: boolean
|
|
46
|
+
/** Whether the weekday field text starts with `*` (Vixie starred). */
|
|
47
|
+
weekdayStarred: boolean
|
|
27
48
|
}
|
|
28
49
|
|
|
29
50
|
/** Inclusive ranges per field, in cron order. */
|
|
@@ -35,6 +56,141 @@ const FIELD_RANGES: ReadonlyArray<readonly [number, number]> = [
|
|
|
35
56
|
[0, 7], // weekdays (7 = Sunday, normalized below)
|
|
36
57
|
]
|
|
37
58
|
|
|
59
|
+
/** Fallback zone when the process reports no usable IANA zone. */
|
|
60
|
+
export const HOST_TIME_ZONE_FALLBACK = 'UTC'
|
|
61
|
+
|
|
62
|
+
/** IANA `Area/Location`, plus the bare `UTC` the platform reports. */
|
|
63
|
+
const IANA_ZONE = /^[A-Za-z][A-Za-z0-9_+.-]*(?:\/[A-Za-z0-9_+.-]+)+$/
|
|
64
|
+
|
|
65
|
+
/** One resolved wall-clock reading in some zone. */
|
|
66
|
+
export interface ZonedParts {
|
|
67
|
+
year: number
|
|
68
|
+
month: number
|
|
69
|
+
day: number
|
|
70
|
+
hour: number
|
|
71
|
+
minute: number
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Cached formatters: constructing an Intl formatter dominates the cost of a resolve. */
|
|
75
|
+
const FORMATTERS = new Map<string, Intl.DateTimeFormat>()
|
|
76
|
+
|
|
77
|
+
function zoneFormatter(timeZone: string): Intl.DateTimeFormat {
|
|
78
|
+
let formatter = FORMATTERS.get(timeZone)
|
|
79
|
+
if (formatter === undefined) {
|
|
80
|
+
formatter = new Intl.DateTimeFormat('en-US', {
|
|
81
|
+
timeZone,
|
|
82
|
+
hourCycle: 'h23',
|
|
83
|
+
year: 'numeric',
|
|
84
|
+
month: '2-digit',
|
|
85
|
+
day: '2-digit',
|
|
86
|
+
hour: '2-digit',
|
|
87
|
+
minute: '2-digit',
|
|
88
|
+
})
|
|
89
|
+
FORMATTERS.set(timeZone, formatter)
|
|
90
|
+
}
|
|
91
|
+
return formatter
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The wall-clock reading of one instant in an explicit zone. Reading through
|
|
96
|
+
* Intl (rather than the process `Date` accessors) is what makes the result
|
|
97
|
+
* independent of the Host's own TZ.
|
|
98
|
+
* @param timeZone - explicit IANA zone.
|
|
99
|
+
* @param epochMs - instant to read.
|
|
100
|
+
* @returns the zone-local calendar and clock fields.
|
|
101
|
+
*/
|
|
102
|
+
export function partsInZone(timeZone: string, epochMs: number): ZonedParts {
|
|
103
|
+
const fields: Record<string, string> = {}
|
|
104
|
+
for (const part of zoneFormatter(timeZone).formatToParts(new Date(epochMs))) {
|
|
105
|
+
if (part.type !== 'literal') fields[part.type] = part.value
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
year: Number(fields.year),
|
|
109
|
+
month: Number(fields.month),
|
|
110
|
+
day: Number(fields.day),
|
|
111
|
+
hour: Number(fields.hour),
|
|
112
|
+
minute: Number(fields.minute),
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The zone's UTC offset in effect at one instant, in milliseconds. */
|
|
117
|
+
function offsetAt(timeZone: string, epochMs: number): number {
|
|
118
|
+
const parts = partsInZone(timeZone, epochMs)
|
|
119
|
+
return Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute) - epochMs
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Resolve a wall clock in an explicit zone to its instant.
|
|
124
|
+
*
|
|
125
|
+
* The offset is probed a day either side of the naive guess so both DST
|
|
126
|
+
* transitions are covered, then every candidate offset whose inverse maps back
|
|
127
|
+
* to the requested wall clock is kept. No survivor means the wall clock does
|
|
128
|
+
* not exist (a spring-forward gap); several means it is ambiguous (a fall-back
|
|
129
|
+
* overlap), and the earliest instant wins so the repeated clock is taken once.
|
|
130
|
+
* @param timeZone - explicit IANA zone.
|
|
131
|
+
* @param year - full calendar year.
|
|
132
|
+
* @param month - 1-12.
|
|
133
|
+
* @param day - day of month.
|
|
134
|
+
* @param hour - 0-23.
|
|
135
|
+
* @param minute - 0-59.
|
|
136
|
+
* @returns the instant in ms epoch, or undefined when the wall clock is skipped.
|
|
137
|
+
*/
|
|
138
|
+
export function zonedEpoch(
|
|
139
|
+
timeZone: string,
|
|
140
|
+
year: number,
|
|
141
|
+
month: number,
|
|
142
|
+
day: number,
|
|
143
|
+
hour: number,
|
|
144
|
+
minute: number,
|
|
145
|
+
): number | undefined {
|
|
146
|
+
const naive = Date.UTC(year, month - 1, day, hour, minute)
|
|
147
|
+
const offsets = new Set([
|
|
148
|
+
offsetAt(timeZone, naive - 86_400_000),
|
|
149
|
+
offsetAt(timeZone, naive),
|
|
150
|
+
offsetAt(timeZone, naive + 86_400_000),
|
|
151
|
+
])
|
|
152
|
+
let earliest: number | undefined
|
|
153
|
+
for (const offset of offsets) {
|
|
154
|
+
const candidate = naive - offset
|
|
155
|
+
const parts = partsInZone(timeZone, candidate)
|
|
156
|
+
if (parts.year !== year || parts.month !== month || parts.day !== day) continue
|
|
157
|
+
if (parts.hour !== hour || parts.minute !== minute) continue
|
|
158
|
+
if (earliest === undefined || candidate < earliest) earliest = candidate
|
|
159
|
+
}
|
|
160
|
+
return earliest
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The Host process's IANA zone, used for schedules that store no zone of their
|
|
165
|
+
* own (a ledger written before zones were persisted).
|
|
166
|
+
* @returns a zone name Intl accepts, never empty.
|
|
167
|
+
*/
|
|
168
|
+
export function resolveHostTimeZone(): string {
|
|
169
|
+
try {
|
|
170
|
+
const zone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
|
171
|
+
return typeof zone === 'string' && zone !== '' ? zone : HOST_TIME_ZONE_FALLBACK
|
|
172
|
+
} catch {
|
|
173
|
+
return HOST_TIME_ZONE_FALLBACK
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Whether a value names a zone this runtime can actually resolve. A stored
|
|
179
|
+
* zone is trusted only after it round-trips through Intl, so a typo or a zone
|
|
180
|
+
* that this ICU build does not know can never arm a schedule.
|
|
181
|
+
* @param value - candidate zone name.
|
|
182
|
+
* @returns true when the name is usable.
|
|
183
|
+
*/
|
|
184
|
+
export function isValidTimeZone(value: string): boolean {
|
|
185
|
+
if (value.trim() !== value || value === '') return false
|
|
186
|
+
if (value !== 'UTC' && !IANA_ZONE.test(value)) return false
|
|
187
|
+
try {
|
|
188
|
+
return new Intl.DateTimeFormat('en-US', { timeZone: value }).resolvedOptions().timeZone !== undefined
|
|
189
|
+
} catch {
|
|
190
|
+
return false
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
38
194
|
/**
|
|
39
195
|
* Parse a 5-field cron expression.
|
|
40
196
|
* @returns the match sets, or null when the expression is invalid.
|
|
@@ -57,11 +213,11 @@ export function parseCron(expr: string): CronSchedule | null {
|
|
|
57
213
|
days: sets[2],
|
|
58
214
|
months: sets[3],
|
|
59
215
|
weekdays,
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
|
|
216
|
+
// Vixie selects the day branch from the field TEXT: only a field starting
|
|
217
|
+
// with '*' is starred. A written-out range such as '1-31' is restricted and
|
|
218
|
+
// still participates in the OR/AND choice.
|
|
219
|
+
dayStarred: fields[2].startsWith('*'),
|
|
220
|
+
weekdayStarred: fields[4].startsWith('*'),
|
|
65
221
|
}
|
|
66
222
|
}
|
|
67
223
|
|
|
@@ -71,57 +227,61 @@ export function isValidCron(expr: string): boolean {
|
|
|
71
227
|
}
|
|
72
228
|
|
|
73
229
|
/**
|
|
74
|
-
* Compute the next matching instant after `fromMs`
|
|
75
|
-
*
|
|
76
|
-
* of the matching minute's start, or undefined
|
|
77
|
-
* can never match (for example `0 0 30 2 *`).
|
|
78
|
-
* a full leap cycle, so a valid February 29
|
|
79
|
-
* every non-leap year.
|
|
230
|
+
* Compute the next matching instant after `fromMs` strictly greater than it,
|
|
231
|
+
* interpreting the expression's wall clock in `timeZone` (the Host zone when
|
|
232
|
+
* omitted). Returns the ms epoch of the matching minute's start, or undefined
|
|
233
|
+
* when the calendar constraint can never match (for example `0 0 30 2 *`).
|
|
234
|
+
* The five-year horizon includes a full leap cycle, so a valid February 29
|
|
235
|
+
* schedule remains reachable from every non-leap year.
|
|
80
236
|
*
|
|
81
237
|
* Walks candidate year/month/day/hour/minute values straight from the parsed
|
|
82
238
|
* field sets instead of scanning every minute: a sparse expression such as
|
|
83
239
|
* `0 0 29 2 *` used to iterate ~1.5M wall-clock minutes before reaching the
|
|
84
|
-
* next leap day.
|
|
85
|
-
*
|
|
86
|
-
*
|
|
240
|
+
* next leap day. A candidate wall clock that the zone skips (spring-forward
|
|
241
|
+
* gap) is passed over, which is what makes the gap semantics fall out of the
|
|
242
|
+
* candidate walk rather than needing a separate rule.
|
|
243
|
+
* @param expr - 5-field cron expression.
|
|
244
|
+
* @param fromMs - exclusive lower bound, ms epoch.
|
|
245
|
+
* @param timeZone - explicit IANA zone; defaults to the Host zone.
|
|
246
|
+
* @returns the next trigger instant, or undefined when unreachable.
|
|
87
247
|
*/
|
|
88
|
-
export function nextRunAtMs(expr: string, fromMs: number): number | undefined {
|
|
248
|
+
export function nextRunAtMs(expr: string, fromMs: number, timeZone?: string): number | undefined {
|
|
89
249
|
const schedule = parseCron(expr)
|
|
90
250
|
if (schedule === null) return undefined
|
|
91
251
|
if (!hasPossibleCalendarDay(schedule)) return undefined
|
|
92
|
-
const
|
|
252
|
+
const zone = timeZone ?? resolveHostTimeZone()
|
|
253
|
+
const start = partsInZone(zone, fromMs)
|
|
93
254
|
const limitMs = fromMs + 5 * 366 * 24 * 60 * 60 * 1000
|
|
94
255
|
|
|
95
256
|
const sortedMinutes = [...schedule.minutes].sort((a, b) => a - b)
|
|
96
257
|
const sortedHours = [...schedule.hours].sort((a, b) => a - b)
|
|
97
258
|
const sortedMonths = [...schedule.months].sort((a, b) => a - b)
|
|
98
259
|
|
|
99
|
-
let year =
|
|
100
|
-
let month =
|
|
101
|
-
let day =
|
|
102
|
-
let hour =
|
|
103
|
-
// Strictly after fromMs: the
|
|
104
|
-
let minute =
|
|
260
|
+
let year = start.year
|
|
261
|
+
let month = start.month
|
|
262
|
+
let day = start.day
|
|
263
|
+
let hour = start.hour
|
|
264
|
+
// Strictly after fromMs: the walk starts from the next minute.
|
|
265
|
+
let minute = start.minute + 1
|
|
105
266
|
|
|
106
|
-
while (
|
|
267
|
+
while (Date.UTC(year, month - 1, 1) <= limitMs + 86_400_000) {
|
|
107
268
|
for (const candidateMonth of sortedMonths) {
|
|
108
269
|
if (candidateMonth < month) continue
|
|
109
|
-
const daysInMonth = new Date(year, candidateMonth, 0).
|
|
270
|
+
const daysInMonth = new Date(Date.UTC(year, candidateMonth, 0)).getUTCDate()
|
|
110
271
|
const dayStart = candidateMonth === month ? day : 1
|
|
111
272
|
for (let candidateDay = dayStart; candidateDay <= daysInMonth; candidateDay += 1) {
|
|
112
|
-
|
|
113
|
-
if (!dayCandidate(schedule, dayProbe)) continue
|
|
273
|
+
if (!dayCandidate(schedule, year, candidateMonth, candidateDay)) continue
|
|
114
274
|
const hourStart = candidateMonth === month && candidateDay === day ? hour : 0
|
|
115
275
|
for (const candidateHour of sortedHours) {
|
|
116
276
|
if (candidateHour < hourStart) continue
|
|
117
277
|
const minuteStart = candidateMonth === month && candidateDay === day && candidateHour === hour ? minute : 0
|
|
118
278
|
for (const candidateMinute of sortedMinutes) {
|
|
119
279
|
if (candidateMinute < minuteStart) continue
|
|
120
|
-
const
|
|
121
|
-
|
|
280
|
+
const time = zonedEpoch(zone, year, candidateMonth, candidateDay, candidateHour, candidateMinute)
|
|
281
|
+
if (time === undefined) continue
|
|
122
282
|
if (time <= fromMs) continue
|
|
123
283
|
if (time > limitMs) return undefined
|
|
124
|
-
|
|
284
|
+
return time
|
|
125
285
|
}
|
|
126
286
|
}
|
|
127
287
|
}
|
|
@@ -135,18 +295,22 @@ export function nextRunAtMs(expr: string, fromMs: number): number | undefined {
|
|
|
135
295
|
return undefined
|
|
136
296
|
}
|
|
137
297
|
|
|
138
|
-
/**
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
298
|
+
/**
|
|
299
|
+
* The Vixie day gate shared by the candidate walk. Both fields restricted is
|
|
300
|
+
* OR; any other combination is AND, because a starred field matches every
|
|
301
|
+
* value and so cannot widen the result.
|
|
302
|
+
*/
|
|
303
|
+
function dayCandidate(schedule: CronSchedule, year: number, month: number, day: number): boolean {
|
|
304
|
+
const dayMatches = schedule.days.has(day)
|
|
305
|
+
// Day of week is a property of the calendar date, so it is zone-independent.
|
|
306
|
+
const weekdayMatches = schedule.weekdays.has(new Date(Date.UTC(year, month - 1, day)).getUTCDay())
|
|
307
|
+
if (!schedule.dayStarred && !schedule.weekdayStarred) return dayMatches || weekdayMatches
|
|
308
|
+
return dayMatches && weekdayMatches
|
|
145
309
|
}
|
|
146
310
|
|
|
147
311
|
/** Reject impossible month/day pairs without spending the multi-year scan. */
|
|
148
312
|
function hasPossibleCalendarDay(schedule: CronSchedule): boolean {
|
|
149
|
-
if (schedule.
|
|
313
|
+
if (schedule.dayStarred || !schedule.weekdayStarred) return true
|
|
150
314
|
const maximumDay = new Map<number, number>([
|
|
151
315
|
[1, 31], [2, 29], [3, 31], [4, 30], [5, 31], [6, 30],
|
|
152
316
|
[7, 31], [8, 31], [9, 30], [10, 31], [11, 30], [12, 31],
|
|
@@ -194,14 +358,6 @@ function parseField(field: string, min: number, max: number, out: Set<number>):
|
|
|
194
358
|
return true
|
|
195
359
|
}
|
|
196
360
|
|
|
197
|
-
/** Day/weekday OR semantics: a restricted day field alone gates, and vice versa. */
|
|
198
|
-
function matches(schedule: CronSchedule, date: Date): boolean {
|
|
199
|
-
if (!schedule.minutes.has(date.getMinutes())) return false
|
|
200
|
-
if (!schedule.hours.has(date.getHours())) return false
|
|
201
|
-
if (!schedule.months.has(date.getMonth() + 1)) return false
|
|
202
|
-
return dayCandidate(schedule, date)
|
|
203
|
-
}
|
|
204
|
-
|
|
205
361
|
function isDigits(value: string): boolean {
|
|
206
362
|
return /^\d+$/.test(value)
|
|
207
363
|
}
|
package/src/core/store.ts
CHANGED
|
@@ -9,12 +9,14 @@
|
|
|
9
9
|
* channel later); tests run against the in-memory backend and a jsdom
|
|
10
10
|
* localStorage backend.
|
|
11
11
|
*/
|
|
12
|
-
import { isValidCron } from './schedule.ts'
|
|
12
|
+
import { isValidCron, isValidTimeZone } from './schedule.ts'
|
|
13
13
|
import { isTaskPermission, isTaskStatus, normalizeTags, normalizeTargetId, type ScheduleRule, type TaskFreeze, type TaskRecord, type TaskPermission, type TaskStatus } from './tasks.ts'
|
|
14
14
|
import { isExecutionOutcome } from './subtask.ts'
|
|
15
15
|
import type { TaskHandover } from './handover.ts'
|
|
16
16
|
import { sanitizeFreezeSnapshot } from './freeze-snapshot.ts'
|
|
17
17
|
import { sanitizeHandover } from './handover.ts'
|
|
18
|
+
import { normalizeTaskIntegrations } from './extension.ts'
|
|
19
|
+
import { normalizeVerification } from './verification.ts'
|
|
18
20
|
|
|
19
21
|
/** Persistence seam for the task ledger. */
|
|
20
22
|
export interface TaskStore {
|
|
@@ -67,7 +69,11 @@ function isTaskRecordShape(value: unknown): value is Omit<TaskRecord, 'status'>
|
|
|
67
69
|
if (record.workspaceId !== undefined && typeof record.workspaceId !== 'string') return false
|
|
68
70
|
if (record.mode !== undefined && typeof record.mode !== 'string') return false
|
|
69
71
|
if (record.permission !== undefined && typeof record.permission !== 'string') return false
|
|
72
|
+
if (record.integrations !== undefined && (typeof record.integrations !== 'object' || record.integrations === null || Array.isArray(record.integrations))) return false
|
|
73
|
+
if (record.hidden !== undefined && typeof record.hidden !== 'boolean') return false
|
|
70
74
|
if (record.reuseSession !== undefined && typeof record.reuseSession !== 'boolean') return false
|
|
75
|
+
if (record.goalRun !== undefined && typeof record.goalRun !== 'boolean') return false
|
|
76
|
+
if (record.skipVerification !== undefined && typeof record.skipVerification !== 'boolean') return false
|
|
71
77
|
if (!Array.isArray(record.executions)) return false
|
|
72
78
|
for (const execution of record.executions) {
|
|
73
79
|
if (typeof execution !== 'object' || execution === null) return false
|
|
@@ -81,6 +87,9 @@ function isTaskRecordShape(value: unknown): value is Omit<TaskRecord, 'status'>
|
|
|
81
87
|
if (entry.initiatedBy !== undefined && typeof entry.initiatedBy !== 'string') return false
|
|
82
88
|
if (entry.frozenBy !== undefined && typeof entry.frozenBy !== 'string') return false
|
|
83
89
|
if (entry.frozenAt !== undefined && typeof entry.frozenAt !== 'number') return false
|
|
90
|
+
// The acceptance block is repaired like every other optional field: an
|
|
91
|
+
// unusable one is dropped, which can only make the board MORE strict.
|
|
92
|
+
if (entry.verification !== undefined && normalizeVerification(entry.verification) === undefined) return false
|
|
84
93
|
}
|
|
85
94
|
return true
|
|
86
95
|
}
|
|
@@ -108,9 +117,14 @@ function normalizeSchedule(schedule: unknown): ScheduleRule | undefined {
|
|
|
108
117
|
// schedule instead of being dropped for later repair.
|
|
109
118
|
if (typeof rule.cron !== 'string') return undefined
|
|
110
119
|
if (rule.cron.trim() === '' || !isValidCron(rule.cron)) return undefined
|
|
120
|
+
// A stored zone survives only when this runtime can resolve it; an unusable
|
|
121
|
+
// name is dropped (the rule then follows the Host zone) instead of being
|
|
122
|
+
// kept to fail every later resolve.
|
|
123
|
+
const timeZone = typeof rule.timeZone === 'string' && isValidTimeZone(rule.timeZone) ? rule.timeZone : undefined
|
|
111
124
|
return {
|
|
112
125
|
enabled: rule.enabled === true,
|
|
113
126
|
cron: rule.cron,
|
|
127
|
+
...(timeZone === undefined ? {} : { timeZone }),
|
|
114
128
|
nextRunAt: typeof rule.nextRunAt === 'number' ? rule.nextRunAt : undefined,
|
|
115
129
|
lastTriggeredAt: typeof rule.lastTriggeredAt === 'number' ? rule.lastTriggeredAt : undefined,
|
|
116
130
|
}
|
|
@@ -193,6 +207,9 @@ export function parseLedger(raw: string | null): TaskRecord[] {
|
|
|
193
207
|
runGroupId: normalizeTargetId(execution.runGroupId),
|
|
194
208
|
ownResult: isExecutionOutcome(execution.ownResult) ? execution.ownResult : undefined,
|
|
195
209
|
ownError: typeof execution.ownError === 'string' ? execution.ownError : undefined,
|
|
210
|
+
// A malformed acceptance block is dropped rather than dropping the
|
|
211
|
+
// execution record: the board then treats that run as unverified.
|
|
212
|
+
verification: normalizeVerification(execution.verification),
|
|
196
213
|
}))
|
|
197
214
|
// Execution targets are normalized like the schedule: blank strings
|
|
198
215
|
// clear the pin and unknown permission strings from a future version
|
|
@@ -202,6 +219,12 @@ export function parseLedger(raw: string | null): TaskRecord[] {
|
|
|
202
219
|
task.archivedAt = typeof row.archivedAt === 'number' && Number.isFinite(row.archivedAt) ? row.archivedAt : undefined
|
|
203
220
|
task.permission = isTaskPermission(row.permission) ? row.permission as TaskPermission : undefined
|
|
204
221
|
task.reuseSession = row.reuseSession === true ? true : undefined
|
|
222
|
+
// The goal opt-in is stored only when it is OFF: absent means the default
|
|
223
|
+
// (start each run with /goal), so a hand-edited true normalizes back to it.
|
|
224
|
+
task.goalRun = row.goalRun === false ? false : undefined
|
|
225
|
+
// The acceptance opt-out is stored only when it is ON: absent inherits the
|
|
226
|
+
// board-wide switch, so a hand-edited false normalizes back to inherit.
|
|
227
|
+
task.skipVerification = row.skipVerification === true ? true : undefined
|
|
205
228
|
task.freeze = normalizeFreeze(row.freeze)
|
|
206
229
|
task.handover = normalizeHandover(row.handover)
|
|
207
230
|
// Tags are repaired field by field like the schedule: a malformed entry is
|
|
@@ -209,6 +232,8 @@ export function parseLedger(raw: string | null): TaskRecord[] {
|
|
|
209
232
|
// dropping the task row.
|
|
210
233
|
task.tags = normalizeTags(row.tags)
|
|
211
234
|
task.permissionConfirmedAt = typeof row.permissionConfirmedAt === 'number' && Number.isFinite(row.permissionConfirmedAt) ? row.permissionConfirmedAt : undefined
|
|
235
|
+
task.integrations = normalizeTaskIntegrations(row.integrations)
|
|
236
|
+
task.hidden = row.hidden === true ? true : undefined
|
|
212
237
|
tasks.push(task)
|
|
213
238
|
}
|
|
214
239
|
return tasks
|
package/src/core/subtask.ts
CHANGED
|
@@ -36,6 +36,31 @@ export function isExecutionOutcome(value: unknown): value is ExecutionOutcome {
|
|
|
36
36
|
return value === 'succeeded' || value === 'failed' || value === 'cancelled'
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Reusable lineage index over ONE task list: the id lookup and the direct
|
|
41
|
+
* children per parent. A caller that runs several lineage queries over the same
|
|
42
|
+
* ledger (the link-subtask candidate gate runs one per task) builds this once
|
|
43
|
+
* instead of rescanning the list per query.
|
|
44
|
+
*/
|
|
45
|
+
export interface LineageIndex {
|
|
46
|
+
readonly byId: ReadonlyMap<string, TaskRecord>
|
|
47
|
+
readonly childrenByParent: ReadonlyMap<string, readonly TaskRecord[]>
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Build the reusable index for a task list (ledger order preserved). */
|
|
51
|
+
export function buildLineageIndex(tasks: readonly TaskRecord[]): LineageIndex {
|
|
52
|
+
const byId = new Map<string, TaskRecord>()
|
|
53
|
+
const childrenByParent = new Map<string, TaskRecord[]>()
|
|
54
|
+
for (const task of tasks) {
|
|
55
|
+
byId.set(task.id, task)
|
|
56
|
+
if (task.parentId === undefined) continue
|
|
57
|
+
const siblings = childrenByParent.get(task.parentId)
|
|
58
|
+
if (siblings === undefined) childrenByParent.set(task.parentId, [task])
|
|
59
|
+
else siblings.push(task)
|
|
60
|
+
}
|
|
61
|
+
return { byId, childrenByParent }
|
|
62
|
+
}
|
|
63
|
+
|
|
39
64
|
function indexOf(tasks: readonly TaskRecord[]): Map<string, TaskRecord> {
|
|
40
65
|
return new Map(tasks.map(task => [task.id, task]))
|
|
41
66
|
}
|
|
@@ -46,8 +71,8 @@ function indexOf(tasks: readonly TaskRecord[]): Map<string, TaskRecord> {
|
|
|
46
71
|
* under a larger limit survives that limit being lowered, and restore and
|
|
47
72
|
* inheritance must still see all of it.
|
|
48
73
|
*/
|
|
49
|
-
export function ancestorChain(tasks: readonly TaskRecord[], task: TaskRecord): TaskRecord[] {
|
|
50
|
-
const index = indexOf(tasks)
|
|
74
|
+
export function ancestorChain(tasks: readonly TaskRecord[], task: TaskRecord, lineage?: LineageIndex): TaskRecord[] {
|
|
75
|
+
const index = lineage?.byId ?? indexOf(tasks)
|
|
51
76
|
const chain: TaskRecord[] = []
|
|
52
77
|
const seen = new Set<string>([task.id])
|
|
53
78
|
let current = task.parentId === undefined ? undefined : index.get(task.parentId)
|
|
@@ -61,13 +86,14 @@ export function ancestorChain(tasks: readonly TaskRecord[], task: TaskRecord): T
|
|
|
61
86
|
}
|
|
62
87
|
|
|
63
88
|
/** Root-to-task depth: a root task is 0, its subtask 1, and so on. */
|
|
64
|
-
export function taskDepth(tasks: readonly TaskRecord[], id: string): number {
|
|
65
|
-
const task = indexOf(tasks).get(id)
|
|
66
|
-
return task === undefined ? 0 : ancestorChain(tasks, task).length
|
|
89
|
+
export function taskDepth(tasks: readonly TaskRecord[], id: string, lineage?: LineageIndex): number {
|
|
90
|
+
const task = (lineage?.byId ?? indexOf(tasks)).get(id)
|
|
91
|
+
return task === undefined ? 0 : ancestorChain(tasks, task, lineage).length
|
|
67
92
|
}
|
|
68
93
|
|
|
69
94
|
/** Direct subtasks of a task, in ledger order. */
|
|
70
|
-
export function directSubtasks(tasks: readonly TaskRecord[], id: string): TaskRecord[] {
|
|
95
|
+
export function directSubtasks(tasks: readonly TaskRecord[], id: string, lineage?: LineageIndex): TaskRecord[] {
|
|
96
|
+
if (lineage !== undefined) return [...(lineage.childrenByParent.get(id) ?? [])]
|
|
71
97
|
return tasks.filter(task => task.parentId === id)
|
|
72
98
|
}
|
|
73
99
|
|
|
@@ -75,15 +101,18 @@ export function directSubtasks(tasks: readonly TaskRecord[], id: string): TaskRe
|
|
|
75
101
|
* Descendants of a task in breadth-first order, bounded by maxSubtaskDepth
|
|
76
102
|
* total depth. The visited set keeps a malformed ledger from looping.
|
|
77
103
|
*/
|
|
78
|
-
export function descendantTasks(tasks: readonly TaskRecord[], id: string, maxSubtaskDepth: number): TaskRecord[] {
|
|
104
|
+
export function descendantTasks(tasks: readonly TaskRecord[], id: string, maxSubtaskDepth: number, lineage?: LineageIndex): TaskRecord[] {
|
|
79
105
|
const visited = new Set<string>([id])
|
|
80
106
|
const found: TaskRecord[] = []
|
|
81
107
|
let frontier = [id]
|
|
82
108
|
for (let depth = 1; depth <= maxSubtaskDepth && frontier.length > 0; depth += 1) {
|
|
83
109
|
const next: string[] = []
|
|
84
110
|
for (const parentId of frontier) {
|
|
85
|
-
|
|
86
|
-
|
|
111
|
+
const children = lineage === undefined
|
|
112
|
+
? tasks.filter(task => task.parentId === parentId)
|
|
113
|
+
: lineage.childrenByParent.get(parentId) ?? []
|
|
114
|
+
for (const task of children) {
|
|
115
|
+
if (visited.has(task.id)) continue
|
|
87
116
|
visited.add(task.id)
|
|
88
117
|
found.push(task)
|
|
89
118
|
next.push(task.id)
|
|
@@ -99,13 +128,13 @@ export function descendantTasks(tasks: readonly TaskRecord[], id: string, maxSub
|
|
|
99
128
|
* The visited set makes the walk terminate on a cyclic hand-edited ledger
|
|
100
129
|
* instead of recursing until the stack overflows.
|
|
101
130
|
*/
|
|
102
|
-
export function subtreeHeight(tasks: readonly TaskRecord[], id: string, visited: ReadonlySet<string> = new Set<string>()): number {
|
|
131
|
+
export function subtreeHeight(tasks: readonly TaskRecord[], id: string, visited: ReadonlySet<string> = new Set<string>(), lineage?: LineageIndex): number {
|
|
103
132
|
if (visited.has(id)) return 0
|
|
104
133
|
const seen = new Set([...visited, id])
|
|
105
|
-
const children = directSubtasks(tasks, id)
|
|
134
|
+
const children = directSubtasks(tasks, id, lineage)
|
|
106
135
|
if (children.length === 0) return 0
|
|
107
136
|
let height = 0
|
|
108
|
-
for (const child of children) height = Math.max(height, 1 + subtreeHeight(tasks, child.id, seen))
|
|
137
|
+
for (const child of children) height = Math.max(height, 1 + subtreeHeight(tasks, child.id, seen, lineage))
|
|
109
138
|
return Math.min(height, SUBTASK_DEPTH_MAX)
|
|
110
139
|
}
|
|
111
140
|
|
|
@@ -137,16 +166,17 @@ export function checkParentLink(
|
|
|
137
166
|
childId: string,
|
|
138
167
|
parentId: string | null,
|
|
139
168
|
maxSubtaskDepth: number,
|
|
169
|
+
lineage?: LineageIndex,
|
|
140
170
|
): ParentLinkCheck {
|
|
141
|
-
const child = tasks.find(task => task.id === childId)
|
|
171
|
+
const child = lineage === undefined ? tasks.find(task => task.id === childId) : lineage.byId.get(childId)
|
|
142
172
|
if (child === undefined) return { ok: false, reason: 'unknown-task' }
|
|
143
173
|
if (parentId === null || parentId === '') return { ok: true }
|
|
144
174
|
if (parentId === childId) return { ok: false, reason: 'self-parent' }
|
|
145
|
-
const parent = tasks.find(task => task.id === parentId)
|
|
175
|
+
const parent = lineage === undefined ? tasks.find(task => task.id === parentId) : lineage.byId.get(parentId)
|
|
146
176
|
if (parent === undefined) return { ok: false, reason: 'unknown-parent' }
|
|
147
177
|
if (parent.archivedAt !== undefined) return { ok: false, reason: 'archived-parent' }
|
|
148
|
-
if (descendantTasks(tasks, childId, SUBTASK_DEPTH_MAX).some(task => task.id === parentId)) return { ok: false, reason: 'cycle' }
|
|
149
|
-
const depth = taskDepth(tasks, parentId) + 1 + subtreeHeight(tasks, childId)
|
|
178
|
+
if (descendantTasks(tasks, childId, SUBTASK_DEPTH_MAX, lineage).some(task => task.id === parentId)) return { ok: false, reason: 'cycle' }
|
|
179
|
+
const depth = taskDepth(tasks, parentId, lineage) + 1 + subtreeHeight(tasks, childId, new Set<string>(), lineage)
|
|
150
180
|
if (depth > maxSubtaskDepth) return { ok: false, reason: 'depth-exceeded' }
|
|
151
181
|
return { ok: true }
|
|
152
182
|
}
|
|
@@ -241,18 +271,43 @@ export function effectiveTaskPermission(task: TaskRecord): TaskRecord['permissio
|
|
|
241
271
|
*/
|
|
242
272
|
/**
|
|
243
273
|
* The immutable teammate name for one team-mode member. Teammate names are
|
|
244
|
-
* lower-kebab-case and must stay unique for the lifetime of the Team
|
|
245
|
-
* run-group token
|
|
246
|
-
*
|
|
247
|
-
*
|
|
274
|
+
* lower-kebab-case and must stay unique for the lifetime of the Team: the
|
|
275
|
+
* run-group token separates two runs of the same tree, and the member
|
|
276
|
+
* discriminator separates two members of one run that reduce to the same slug.
|
|
277
|
+
*
|
|
278
|
+
* The discriminator is not cosmetic. A title with no ASCII word characters (a
|
|
279
|
+
* CJK title, for example) carries no slug at all, so every such title of one
|
|
280
|
+
* run group collides on the generic prefix; Agent Teams answers every member
|
|
281
|
+
* after the first with `teammate name "..." was already used in this Team`,
|
|
282
|
+
* and those subtasks never start. Two ASCII titles that share a slug
|
|
283
|
+
* (`AI 强度预测` next to `AI 配比优化`) collide the same way.
|
|
248
284
|
* @param title - the member task's title.
|
|
249
285
|
* @param token - the run group id (the task id when no group is recorded).
|
|
286
|
+
* @param memberId - the member task's id, which makes the name unique in the run.
|
|
250
287
|
* @returns the teammate name handed to the Agent Teams service.
|
|
251
288
|
*/
|
|
252
|
-
export function teammateName(title: string, token: string): string {
|
|
289
|
+
export function teammateName(title: string, token: string, memberId?: string): string {
|
|
253
290
|
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 32).replace(/-+$/g, '')
|
|
254
291
|
const suffix = token.replace(/[^a-z0-9]/gi, '').slice(0, 8).toLowerCase()
|
|
255
|
-
|
|
292
|
+
const member = memberId === undefined || memberId === '' ? undefined : stableTag(memberId)
|
|
293
|
+
return [slug === '' ? 'subtask' : slug, member, suffix === '' ? 'run' : suffix]
|
|
294
|
+
.filter((part): part is string => part !== undefined)
|
|
295
|
+
.join('-')
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* A stable 4-hex tag for one member id. The name is derived twice for the same
|
|
300
|
+
* member — once for the Lead's prompt and once for the spawn request — so the
|
|
301
|
+
* tag must be a pure function of the id, never of a clock or a counter. FNV-1a
|
|
302
|
+
* keeps it dependency-free in `core/`, which both halves compile.
|
|
303
|
+
*/
|
|
304
|
+
function stableTag(value: string): string {
|
|
305
|
+
let hash = 0x811c9dc5
|
|
306
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
307
|
+
hash ^= value.charCodeAt(index)
|
|
308
|
+
hash = Math.imul(hash, 0x01000193) >>> 0
|
|
309
|
+
}
|
|
310
|
+
return hash.toString(16).padStart(8, '0').slice(0, 4)
|
|
256
311
|
}
|
|
257
312
|
|
|
258
313
|
export function resolveExecutionTargets(
|