@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.
Files changed (128) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +56 -18
  3. package/README.zh.md +56 -18
  4. package/lib/client.js +6242 -958
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +3475 -234
  7. package/lib/types/client/PluginSettingsCard.d.ts +7 -0
  8. package/lib/types/client/TaskBoardSettingsCard.d.ts +19 -3
  9. package/lib/types/client/board/ClampedMarkdown.d.ts +3 -0
  10. package/lib/types/client/board/ConfirmDialog.d.ts +4 -1
  11. package/lib/types/client/board/EditTaskModal.d.ts +5 -2
  12. package/lib/types/client/board/LinkSubtaskModal.d.ts +5 -2
  13. package/lib/types/client/board/NewTaskModal.d.ts +4 -1
  14. package/lib/types/client/board/TagManagerModal.d.ts +26 -0
  15. package/lib/types/client/board/TaskBoard.d.ts +10 -1
  16. package/lib/types/client/board/TaskCard.d.ts +14 -1
  17. package/lib/types/client/board/TaskDetail.d.ts +3 -1
  18. package/lib/types/client/board/TaskForm.d.ts +23 -2
  19. package/lib/types/client/board/VerificationReport.d.ts +23 -0
  20. package/lib/types/client/board/card-view.d.ts +113 -0
  21. package/lib/types/client/board/icons.d.ts +32 -0
  22. package/lib/types/client/board/overlay.d.ts +69 -0
  23. package/lib/types/client/board/preset-label.d.ts +22 -0
  24. package/lib/types/client/board/status-key.d.ts +15 -0
  25. package/lib/types/client/board/task-markdown.d.ts +19 -0
  26. package/lib/types/client/board-card-seat.d.ts +34 -0
  27. package/lib/types/client/host-api.d.ts +24 -1
  28. package/lib/types/client/index.d.ts +19 -0
  29. package/lib/types/client/locales.d.ts +141 -3
  30. package/lib/types/client/native-panel.d.ts +6 -20
  31. package/lib/types/client/schedule-zone.d.ts +47 -0
  32. package/lib/types/client/seats.d.ts +18 -0
  33. package/lib/types/client/service.d.ts +32 -0
  34. package/lib/types/client/settings-form.d.ts +16 -0
  35. package/lib/types/core/controller.d.ts +38 -1
  36. package/lib/types/core/degraded-reason.d.ts +46 -0
  37. package/lib/types/core/extension.d.ts +283 -0
  38. package/lib/types/core/handover.d.ts +13 -1
  39. package/lib/types/core/issue-form.d.ts +16 -0
  40. package/lib/types/core/package-name.d.ts +9 -0
  41. package/lib/types/core/schedule.d.ts +95 -20
  42. package/lib/types/core/subtask.d.ts +30 -11
  43. package/lib/types/core/tasks.d.ts +110 -5
  44. package/lib/types/core/use-cases/task-create.d.ts +2 -1
  45. package/lib/types/core/use-cases/task-schedule.d.ts +8 -4
  46. package/lib/types/core/use-cases/task-tag.d.ts +48 -0
  47. package/lib/types/core/use-cases/task-update.d.ts +5 -3
  48. package/lib/types/core/verification.d.ts +386 -0
  49. package/lib/types/core/workspace-target.d.ts +41 -0
  50. package/lib/types/host/agent-tools.d.ts +1 -1
  51. package/lib/types/host/extension-registry.d.ts +111 -0
  52. package/lib/types/host/llm-dispatch.d.ts +63 -0
  53. package/lib/types/host/verification-gate.d.ts +71 -0
  54. package/lib/types/host/verification-runner.d.ts +108 -0
  55. package/lib/types/host/workspace-evidence.d.ts +53 -0
  56. package/lib/types/host-ledger.d.ts +107 -11
  57. package/lib/types/host-runner.d.ts +99 -4
  58. package/lib/types/host-service.d.ts +114 -2
  59. package/lib/types/index.d.ts +80 -26
  60. package/lib/types/protocol.d.ts +53 -3
  61. package/lib/types/tool-surface.d.ts +71 -0
  62. package/package.json +27 -26
  63. package/src/client/PluginSettingsCard.tsx +9 -0
  64. package/src/client/TaskBoardSettingsCard.tsx +304 -65
  65. package/src/client/board/ClampedMarkdown.tsx +69 -0
  66. package/src/client/board/ConfirmDialog.tsx +7 -3
  67. package/src/client/board/EditTaskModal.tsx +5 -2
  68. package/src/client/board/LinkSubtaskModal.tsx +30 -15
  69. package/src/client/board/NewTaskModal.tsx +288 -151
  70. package/src/client/board/TagManagerModal.tsx +235 -0
  71. package/src/client/board/TaskBoard.tsx +259 -103
  72. package/src/client/board/TaskCard.tsx +238 -86
  73. package/src/client/board/TaskDetail.tsx +154 -59
  74. package/src/client/board/TaskForm.tsx +61 -6
  75. package/src/client/board/VerificationReport.tsx +203 -0
  76. package/src/client/board/card-view.ts +220 -0
  77. package/src/client/board/icons.tsx +89 -0
  78. package/src/client/board/overlay.tsx +189 -0
  79. package/src/client/board/preset-label.ts +44 -0
  80. package/src/client/board/status-key.ts +27 -0
  81. package/src/client/board/task-markdown.module.css +137 -0
  82. package/src/client/board/task-markdown.tsx +106 -0
  83. package/src/client/board-card-seat.ts +102 -0
  84. package/src/client/board-settings.module.css +66 -0
  85. package/src/client/board.module.css +602 -68
  86. package/src/client/host-api.ts +109 -6
  87. package/src/client/index.ts +48 -6
  88. package/src/client/locales.ts +290 -12
  89. package/src/client/native-panel.tsx +18 -2
  90. package/src/client/schedule-zone.ts +135 -0
  91. package/src/client/seats.tsx +58 -0
  92. package/src/client/service.ts +94 -0
  93. package/src/client/settings-card.module.css +31 -5
  94. package/src/client/settings-form.ts +27 -1
  95. package/src/core/controller.ts +101 -5
  96. package/src/core/degraded-reason.ts +81 -0
  97. package/src/core/extension.ts +376 -0
  98. package/src/core/freeze-snapshot.ts +2 -2
  99. package/src/core/handover.ts +16 -1
  100. package/src/core/issue-form.ts +19 -0
  101. package/src/core/package-name.ts +9 -0
  102. package/src/core/schedule.ts +211 -55
  103. package/src/core/store.ts +26 -1
  104. package/src/core/subtask.ts +77 -22
  105. package/src/core/tasks.ts +138 -10
  106. package/src/core/use-cases/task-create.ts +14 -2
  107. package/src/core/use-cases/task-parent.ts +3 -2
  108. package/src/core/use-cases/task-schedule.ts +17 -6
  109. package/src/core/use-cases/task-tag.ts +110 -0
  110. package/src/core/use-cases/task-update.ts +13 -4
  111. package/src/core/verification.ts +826 -0
  112. package/src/core/workspace-target.ts +59 -0
  113. package/src/host/agent-tools.ts +58 -24
  114. package/src/host/extension-registry.ts +509 -0
  115. package/src/host/llm-dispatch.ts +87 -0
  116. package/src/host/verification-gate.ts +331 -0
  117. package/src/host/verification-runner.ts +535 -0
  118. package/src/host/workspace-evidence.ts +176 -0
  119. package/src/host-ai.ts +15 -9
  120. package/src/host-ledger.ts +342 -51
  121. package/src/host-routes.ts +15 -2
  122. package/src/host-runner.ts +219 -20
  123. package/src/host-service.ts +346 -26
  124. package/src/index.ts +204 -16
  125. package/src/protocol.ts +122 -9
  126. package/src/tool-surface.ts +95 -0
  127. package/lib/types/host/run-guarded.d.ts +0 -39
  128. package/src/host/run-guarded.ts +0 -76
@@ -1,15 +1,36 @@
1
1
  /**
2
- * Minimal 5-field cron parsing and next-run computation for scheduled task
3
- * runs. Framework-free and dependency-free so the scheduler and controller
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). When both the day and weekday fields are restricted they combine
11
- * with OR semantics (standard cron). Invalid expressions parse to null and
12
- * are rejected by the UI/controller.
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 was the literal '*' (unrestricted). */
24
- dayWildcard: boolean
25
- /** Whether the weekday field was the literal '*' (unrestricted). */
26
- weekdayWildcard: boolean
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
- // Only the literal '*' marks a field unrestricted: an explicit full
61
- // enumeration such as '1-31' is a restricted field and must not collapse
62
- // into the wildcard (it participates in day/weekday OR semantics).
63
- dayWildcard: fields[2] === '*',
64
- weekdayWildcard: fields[4] === '*',
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` (ms epoch), in local time,
75
- * at minute granularity, strictly greater than `fromMs`. Returns the ms epoch
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.
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. Wall-clock field construction + the final `matches` re-check
85
- * preserve the old minute scan's DST semantics exactly (nonexistent spring
86
- * minutes normalize forward and the repeated fall-back hour is never visited).
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 from = new Date(fromMs)
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 = from.getFullYear()
100
- let month = from.getMonth() + 1
101
- let day = from.getDate()
102
- let hour = from.getHours()
103
- // Strictly after fromMs: the old scan started from the next minute.
104
- let minute = from.getMinutes() + 1
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 (new Date(year, month - 1, 1, 0, 0, 0, 0).getTime() <= limitMs) {
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).getDate()
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
- const dayProbe = new Date(year, candidateMonth - 1, candidateDay, 0, 0, 0, 0)
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 candidate = new Date(year, candidateMonth - 1, candidateDay, candidateHour, candidateMinute, 0, 0)
121
- const time = candidate.getTime()
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
- if (matches(schedule, candidate)) return time
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
- /** Day/weekday OR gate shared by {@link matches} and the candidate scan. */
139
- function dayCandidate(schedule: CronSchedule, date: Date): boolean {
140
- const dayMatches = schedule.days.has(date.getDate())
141
- const weekdayMatches = schedule.weekdays.has(date.getDay())
142
- if (schedule.dayWildcard) return weekdayMatches
143
- if (schedule.weekdayWildcard) return dayMatches
144
- return dayMatches || weekdayMatches
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.dayWildcard || !schedule.weekdayWildcard) return true
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
@@ -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
- for (const task of tasks) {
86
- if (task.parentId !== parentId || visited.has(task.id)) continue
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, so the
245
- * run-group token is appended: two runs of the same tree never collide. A title
246
- * with no ASCII word characters (a CJK title, for example) keeps a stable
247
- * generic prefix and is still distinguished by the token.
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
- return `${slug === '' ? 'subtask' : slug}-${suffix === '' ? 'run' : suffix}`
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(