@linxin666/dsh-client-ui-task-board 0.4.4 → 0.4.6

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 (120) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +59 -20
  3. package/README.zh.md +59 -20
  4. package/lib/client.js +6208 -624
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +4774 -493
  7. package/lib/types/client/PluginSettingsCard.d.ts +7 -0
  8. package/lib/types/client/TaskBoardSettingsCard.d.ts +31 -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 +4 -1
  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/status-key.d.ts +15 -0
  24. package/lib/types/client/board/task-markdown.d.ts +19 -0
  25. package/lib/types/client/index.d.ts +23 -5
  26. package/lib/types/client/locales.d.ts +168 -1
  27. package/lib/types/client/native-panel.d.ts +6 -20
  28. package/lib/types/client/plugin-card-seat.d.ts +5 -0
  29. package/lib/types/client/schedule-zone.d.ts +50 -0
  30. package/lib/types/client/seats.d.ts +18 -0
  31. package/lib/types/client/service.d.ts +32 -0
  32. package/lib/types/client/settings-form.d.ts +16 -0
  33. package/lib/types/core/controller.d.ts +46 -11
  34. package/lib/types/core/extension.d.ts +283 -0
  35. package/lib/types/core/handover.d.ts +13 -1
  36. package/lib/types/core/issue-form.d.ts +16 -0
  37. package/lib/types/core/poll-cadence.d.ts +31 -0
  38. package/lib/types/core/tasks.d.ts +228 -17
  39. package/lib/types/core/use-cases/task-schedule.d.ts +68 -10
  40. package/lib/types/core/use-cases/task-tag.d.ts +48 -0
  41. package/lib/types/core/use-cases/task-update.d.ts +5 -3
  42. package/lib/types/core/verification-budget.d.ts +52 -0
  43. package/lib/types/core/verification.d.ts +753 -0
  44. package/lib/types/core/workspace-target.d.ts +41 -0
  45. package/lib/types/host/agent-tools.d.ts +1 -1
  46. package/lib/types/host/extension-registry.d.ts +111 -0
  47. package/lib/types/host/llm-dispatch.d.ts +63 -0
  48. package/lib/types/host/verification-gate.d.ts +95 -0
  49. package/lib/types/host/verification-runner.d.ts +139 -0
  50. package/lib/types/host/workspace-evidence.d.ts +53 -0
  51. package/lib/types/host-ledger.d.ts +133 -14
  52. package/lib/types/host-runner.d.ts +55 -10
  53. package/lib/types/host-service.d.ts +205 -11
  54. package/lib/types/index.d.ts +134 -26
  55. package/lib/types/protocol.d.ts +92 -10
  56. package/lib/types/tool-surface.d.ts +71 -0
  57. package/package.json +28 -31
  58. package/src/client/PluginSettingsCard.tsx +9 -0
  59. package/src/client/TaskBoardSettingsCard.tsx +378 -66
  60. package/src/client/board/ClampedMarkdown.tsx +69 -0
  61. package/src/client/board/ConfirmDialog.tsx +7 -3
  62. package/src/client/board/EditTaskModal.tsx +5 -2
  63. package/src/client/board/LinkSubtaskModal.tsx +12 -7
  64. package/src/client/board/NewTaskModal.tsx +168 -21
  65. package/src/client/board/TagManagerModal.tsx +235 -0
  66. package/src/client/board/TaskBoard.tsx +259 -103
  67. package/src/client/board/TaskCard.tsx +214 -81
  68. package/src/client/board/TaskDetail.tsx +289 -43
  69. package/src/client/board/TaskForm.tsx +13 -3
  70. package/src/client/board/VerificationReport.tsx +322 -0
  71. package/src/client/board/card-view.ts +220 -0
  72. package/src/client/board/icons.tsx +89 -0
  73. package/src/client/board/overlay.tsx +189 -0
  74. package/src/client/board/status-key.ts +30 -0
  75. package/src/client/board/task-markdown.module.css +137 -0
  76. package/src/client/board/task-markdown.tsx +106 -0
  77. package/src/client/board-settings.module.css +66 -0
  78. package/src/client/board.module.css +488 -63
  79. package/src/client/index.ts +82 -51
  80. package/src/client/locales.ts +343 -6
  81. package/src/client/native-panel.tsx +18 -2
  82. package/src/client/plugin-card-seat.ts +7 -0
  83. package/src/client/schedule-zone.ts +100 -2
  84. package/src/client/seats.tsx +58 -0
  85. package/src/client/service.ts +94 -0
  86. package/src/client/settings-card.module.css +31 -5
  87. package/src/client/settings-form.ts +27 -1
  88. package/src/core/controller.ts +105 -10
  89. package/src/core/extension.ts +376 -0
  90. package/src/core/freeze-snapshot.ts +2 -2
  91. package/src/core/handover.ts +16 -1
  92. package/src/core/issue-form.ts +19 -0
  93. package/src/core/poll-cadence.ts +41 -0
  94. package/src/core/store.ts +80 -15
  95. package/src/core/tasks.ts +293 -45
  96. package/src/core/use-cases/task-create.ts +30 -9
  97. package/src/core/use-cases/task-parent.ts +3 -2
  98. package/src/core/use-cases/task-schedule.ts +190 -25
  99. package/src/core/use-cases/task-tag.ts +110 -0
  100. package/src/core/use-cases/task-update.ts +13 -7
  101. package/src/core/verification-budget.ts +115 -0
  102. package/src/core/verification.ts +1594 -0
  103. package/src/core/workspace-target.ts +59 -0
  104. package/src/host/agent-tools.ts +125 -38
  105. package/src/host/extension-registry.ts +509 -0
  106. package/src/host/llm-dispatch.ts +87 -0
  107. package/src/host/verification-gate.ts +487 -0
  108. package/src/host/verification-runner.ts +702 -0
  109. package/src/host/workspace-evidence.ts +176 -0
  110. package/src/host-ai.ts +15 -9
  111. package/src/host-ledger.ts +468 -71
  112. package/src/host-routes.ts +15 -2
  113. package/src/host-runner.ts +106 -24
  114. package/src/host-service.ts +546 -40
  115. package/src/index.ts +305 -21
  116. package/src/protocol.ts +226 -28
  117. package/src/tool-surface.ts +95 -0
  118. package/lib/invariant.js +0 -6
  119. package/lib/types/invariant.d.ts +0 -3
  120. package/src/invariant.ts +0 -4
@@ -1,33 +1,54 @@
1
1
  /**
2
- * Schedule use case: arm/disarm a task's cron rule and roll a rule forward.
3
- * Pure ledger transitions (no persistence or notify — the controller
4
- * orchestrates those). Validation and next-run computation live here, sharing
5
- * the core cron parser (schedule.ts) and the withSchedule transition.
2
+ * Schedule use cases: arm/disarm a task's scheduling rule, and record what a
3
+ * due occurrence did to it. Pure ledger transitions (no persistence or notify
4
+ * — the controller orchestrates those). Validation and next-run computation
5
+ * live here, sharing the core cron parser (schedule.ts) and the withSchedule
6
+ * transition.
7
+ *
8
+ * A rule is either a recurring cron rule or a single planned instant, and
9
+ * either kind may be capped by a run budget. The counter (`runCount`) moves
10
+ * only when the Host actually opened an execution; a skipped occurrence
11
+ * (busy, unconfirmed permission, missed instant, unreachable plan) moves the
12
+ * rule without consuming its budget.
6
13
  */
7
14
  import { isValidCron, isValidTimeZone, nextRunAtMs } from '../schedule.ts'
8
- import { withSchedule, type TaskRecord } from '../tasks.ts'
15
+ import { withSchedule, type ScheduleMode, type ScheduleRule, type ScheduleStopReason, type TaskRecord } from '../tasks.ts'
9
16
 
10
17
  /** Fields the schedule use case may change on a rule. */
11
18
  export interface SetSchedulePatch {
12
19
  enabled?: boolean
20
+ /** Which kind of plan to write; an absent key keeps the current mode. */
21
+ mode?: ScheduleMode
22
+ /** 5-field cron expression of a recurring rule. */
13
23
  cron?: string
14
- /** IANA zone for the cron wall clock; `null` clears it back to the Host zone. */
24
+ /** Planned instant of a one-shot rule (ms epoch). */
25
+ at?: number
26
+ /** IANA zone for the wall clock; `null` clears it back to the Host zone. */
15
27
  timeZone?: string | null
28
+ /** Run cap of a recurring rule; `null` clears it back to unlimited. */
29
+ maxRuns?: number | null
16
30
  }
17
31
 
18
32
  /** Result of arming/disarming a rule. */
19
33
  export interface SetScheduleResult {
20
34
  /** The next ledger; unchanged reference when rejected. */
21
35
  tasks: readonly TaskRecord[]
22
- /** Whether the rule was applied (false on unknown task / invalid cron). */
36
+ /** Whether the rule was applied (false on unknown task / invalid plan). */
23
37
  applied: boolean
24
38
  }
25
39
 
26
40
  /**
27
- * Set an on-board task's schedule rule. A blank or invalid cron, an unknown or
28
- * archived task, or an unusable zone is rejected (state untouched); an enabled
29
- * rule computes the next run instant immediately in the rule's own zone, a
30
- * disabled one carries no next-run instant.
41
+ * Set an on-board task's scheduling rule. An unknown or archived task, an
42
+ * unusable zone, a blank or invalid cron, a one-shot with no usable instant,
43
+ * a one-shot armed in the past, and a run cap that is not a positive integer
44
+ * are all rejected (state untouched). An enabled rule computes its next run
45
+ * instant immediately — a one-shot's next instant IS its planned instant — and
46
+ * a disabled rule carries none.
47
+ *
48
+ * Arming a rule that was not enabled, or switching its kind, starts a fresh
49
+ * budget: the run counter and any previous stop/skip record are cleared. Edits
50
+ * to a live rule (expression, zone, cap) keep the consumed count, so an
51
+ * already-spent budget can never silently reset and overrun.
31
52
  * @param tasks - current ledger.
32
53
  * @param id - the task to schedule.
33
54
  * @param patch - rule fields to change (absent fields keep their current value).
@@ -44,28 +65,72 @@ export function applySetSchedule(
44
65
  const task = tasks.find(candidate => candidate.id === id)
45
66
  if (task === undefined || task.archivedAt !== undefined) return { tasks, applied: false }
46
67
  const current = task.schedule
47
- const cron = (patch.cron ?? current?.cron ?? '').trim()
48
- if (cron === '' || !isValidCron(cron)) return { tasks, applied: false }
68
+ const mode: ScheduleMode = patch.mode ?? current?.mode ?? 'cron'
49
69
  // An explicit null clears the stored zone; an absent key keeps it.
50
- const requestedZone = patch.timeZone === undefined ? current?.timeZone : patch.timeZone ?? undefined
51
- if (requestedZone !== undefined && !isValidTimeZone(requestedZone)) return { tasks, applied: false }
70
+ const timeZone = patch.timeZone === undefined ? current?.timeZone : patch.timeZone ?? undefined
71
+ if (timeZone !== undefined && !isValidTimeZone(timeZone)) return { tasks, applied: false }
52
72
  const enabled = patch.enabled ?? current?.enabled ?? false
53
- const effectiveZone = requestedZone ?? hostTimeZone
54
- const nextRunAt = enabled ? nextRunAtMs(cron, now, effectiveZone) : undefined
55
- if (enabled && nextRunAt === undefined) return { tasks, applied: false }
73
+ const restart = (enabled && current?.enabled !== true) || (current?.mode ?? 'cron') !== mode
74
+ const runCount = restart ? 0 : current?.runCount ?? 0
75
+ const effectiveZone = timeZone ?? hostTimeZone
76
+ const cleared = restart
77
+ ? { endedAt: undefined, endedReason: undefined, skippedAt: undefined, skippedReason: undefined }
78
+ : { endedAt: current?.endedAt, endedReason: current?.endedReason, skippedAt: current?.skippedAt, skippedReason: current?.skippedReason }
79
+
80
+ if (mode === 'once') {
81
+ const at = patch.at ?? current?.at
82
+ if (at === undefined || !Number.isInteger(at)) return { tasks, applied: false }
83
+ // A one-shot is armed for a FUTURE instant: re-arming one at or before
84
+ // "now" would fire on the next tick, which is not what the user chose. A
85
+ // disabled rule may keep a stored past instant as the record of its plan.
86
+ if (enabled && at <= now) return { tasks, applied: false }
87
+ return {
88
+ tasks: tasks.map(candidate => candidate.id !== id ? candidate : withSchedule(candidate, {
89
+ enabled,
90
+ mode: 'once',
91
+ at,
92
+ cron: undefined,
93
+ maxRuns: undefined,
94
+ timeZone: timeZone ?? undefined,
95
+ nextRunAt: enabled ? at : undefined,
96
+ runCount,
97
+ ...cleared,
98
+ }, now)),
99
+ applied: true,
100
+ }
101
+ }
102
+
103
+ const cron = (patch.cron ?? current?.cron ?? '').trim()
104
+ if (cron === '' || !isValidCron(cron)) return { tasks, applied: false }
105
+ // An absent key keeps the stored cap; an explicit null clears it (unlimited).
106
+ const maxRuns = ('maxRuns' in patch ? patch.maxRuns : current?.maxRuns) ?? undefined
107
+ if (maxRuns !== undefined && (!Number.isInteger(maxRuns) || maxRuns < 1)) return { tasks, applied: false }
108
+ const spent = maxRuns !== undefined && runCount >= maxRuns
109
+ const armed = enabled && !spent
110
+ const nextRunAt = armed ? nextRunAtMs(cron, now, effectiveZone) : undefined
111
+ if (armed && nextRunAt === undefined) return { tasks, applied: false }
112
+ // Lowering the cap below what the rule already ran ends it immediately
113
+ // instead of leaving an exhausted rule that would wait for a fire to die.
114
+ const exhausted = enabled && spent
56
115
  return {
57
- tasks: tasks.map(candidate =>
58
- candidate.id === id
59
- ? withSchedule(candidate, { enabled, cron, timeZone: requestedZone ?? undefined, nextRunAt }, now)
60
- : candidate),
116
+ tasks: tasks.map(candidate => candidate.id !== id ? candidate : withSchedule(candidate, {
117
+ enabled: armed,
118
+ mode: 'cron',
119
+ cron,
120
+ at: undefined,
121
+ maxRuns,
122
+ timeZone: timeZone ?? undefined,
123
+ nextRunAt,
124
+ runCount,
125
+ ...(exhausted ? { endedAt: now, endedReason: 'limit' as const } : cleared),
126
+ }, now)),
61
127
  applied: true,
62
128
  }
63
129
  }
64
130
 
65
131
  /**
66
- * Roll a task's schedule rule forward (scheduler callback): persist the next
67
- * due instant and the trigger instant. No-op for tasks without a rule (deleted
68
- * mid-tick, for example).
132
+ * Roll a task's schedule rule forward (legacy pure-controller seam): persist
133
+ * the next due instant and the trigger instant. No-op for tasks without a rule.
69
134
  * @param tasks - current ledger.
70
135
  * @param id - the task to roll forward.
71
136
  * @param nextRunAt - next due instant (may be undefined to clear).
@@ -84,3 +149,103 @@ export function applyScheduleNextRun(
84
149
  ? withSchedule(task, { nextRunAt, lastTriggeredAt }, now)
85
150
  : task)
86
151
  }
152
+
153
+ /**
154
+ * The effect one due occurrence has on its rule, applied by the Host ledger.
155
+ */
156
+ export interface ScheduleProgress {
157
+ /** Executions this occurrence actually opened (0 or 1), added to the counter. */
158
+ consumed: number
159
+ /** Next due instant; absent stops the rule. */
160
+ nextRunAt?: number
161
+ /** Trigger instant recorded as `lastTriggeredAt` (a consumed run only). */
162
+ lastTriggeredAt?: number
163
+ /** Terminal reason when the rule stops itself (disarms it). */
164
+ endedReason?: ScheduleStopReason
165
+ /** Why this occurrence produced no run, when it did not. */
166
+ skippedReason?: ScheduleStopReason
167
+ }
168
+
169
+ /**
170
+ * Refund the one scheduled run an occurrence consumed before its launch failed
171
+ * without ever creating a session. Nothing ran, so the run budget is restored
172
+ * and the rule is left able to serve its next occurrence — a launch failure
173
+ * must never silently spend the user's limited budget.
174
+ *
175
+ * A recurring rule keeps its already-rolled-forward target; a fire that ended
176
+ * the rule at its cap (no target left) is restored and re-armed at the next
177
+ * occurrence, so a failed launch consumes nothing. A one-shot has no later
178
+ * occurrence, so it is left stopped with `launch-failed` and its run returned.
179
+ * A rule the user disabled in the meantime is not resurrected.
180
+ * @param tasks - current ledger.
181
+ * @param id - the task whose occurrence is being refunded.
182
+ * @param now - clock instant (ms epoch).
183
+ * @param hostTimeZone - zone a rule with no stored zone follows.
184
+ * @returns the next ledger.
185
+ */
186
+ export function applyScheduleRefund(
187
+ tasks: readonly TaskRecord[],
188
+ id: string,
189
+ now: number,
190
+ hostTimeZone: string,
191
+ ): readonly TaskRecord[] {
192
+ return tasks.map(task => {
193
+ if (task.id !== id || task.archivedAt !== undefined || task.schedule === undefined) return task
194
+ const rule = task.schedule
195
+ const runCount = Math.max(rule.runCount - 1, 0)
196
+ if (rule.mode === 'once') {
197
+ return withSchedule(task, {
198
+ enabled: false,
199
+ runCount,
200
+ nextRunAt: undefined,
201
+ endedAt: now,
202
+ endedReason: 'launch-failed',
203
+ skippedAt: undefined,
204
+ skippedReason: undefined,
205
+ }, now)
206
+ }
207
+ const refunded = withSchedule(task, {
208
+ runCount,
209
+ skippedAt: now,
210
+ skippedReason: 'launch-failed',
211
+ }, now)
212
+ // The fire already rolled the target forward; only a fire that spent its
213
+ // last run (target cleared, ended at the cap) needs to be restored.
214
+ if (rule.nextRunAt !== undefined || rule.endedReason !== 'limit') return refunded
215
+ const cron = rule.cron ?? ''
216
+ const next = cron === '' ? undefined : nextRunAtMs(cron, now, rule.timeZone ?? hostTimeZone)
217
+ return withSchedule(refunded, next === undefined
218
+ ? { enabled: false, endedAt: now, endedReason: 'no-target' }
219
+ : { enabled: true, nextRunAt: next, endedAt: undefined, endedReason: undefined }, now)
220
+ })
221
+ }
222
+
223
+ /**
224
+ * Record what a due occurrence did to a task's rule: advance the run counter
225
+ * by the executions it opened, set the next target (or stop the rule), and
226
+ * note a terminal reason or a skipped occurrence. Nothing is written for a
227
+ * task that has no rule or has been archived mid-tick.
228
+ * @param tasks - current ledger.
229
+ * @param id - the task whose rule fired.
230
+ * @param progress - the occurrence's effect.
231
+ * @param now - clock instant (ms epoch).
232
+ * @returns the next ledger.
233
+ */
234
+ export function applyScheduleProgress(
235
+ tasks: readonly TaskRecord[],
236
+ id: string,
237
+ progress: ScheduleProgress,
238
+ now: number,
239
+ ): readonly TaskRecord[] {
240
+ return tasks.map(task => {
241
+ if (task.id !== id || task.archivedAt !== undefined || task.schedule === undefined) return task
242
+ const patch: Partial<ScheduleRule> = {
243
+ runCount: task.schedule.runCount + progress.consumed,
244
+ nextRunAt: progress.nextRunAt,
245
+ ...(progress.lastTriggeredAt === undefined ? {} : { lastTriggeredAt: progress.lastTriggeredAt }),
246
+ ...(progress.endedReason === undefined ? {} : { enabled: false, endedAt: now, endedReason: progress.endedReason }),
247
+ ...(progress.skippedReason === undefined ? {} : { skippedAt: now, skippedReason: progress.skippedReason }),
248
+ }
249
+ return withSchedule(task, patch, now)
250
+ })
251
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Label-management use cases (issue: labels could not be cleaned up).
3
+ *
4
+ * A label lives on every task that carries it, so renaming or removing one is a
5
+ * ledger-wide edit, not a task edit: the board's label row is the union of the
6
+ * labels in use, and without these two transitions a label created by an
7
+ * afternoon experiment stayed on the board forever.
8
+ *
9
+ * Both transitions are pure ledger rewrites (no persistence, no notify — the
10
+ * controller and the Host ledger orchestrate those), and both are idempotent:
11
+ * a label that no task carries changes nothing and reports no error for the
12
+ * delete path (the desired end state already holds), while a rename from an
13
+ * unknown label is refused so a stale tab cannot silently do nothing.
14
+ *
15
+ * Renaming onto a name that is already in use MERGES the two: a task carrying
16
+ * both keeps the row it already had (with its execution hint) and loses the
17
+ * duplicate, which is the only reading that cannot lose a hint the user typed.
18
+ */
19
+
20
+ import { TAG_NAME_MAX_LENGTH, collectKnownTags, normalizeTags, type TaskRecord, type TaskTag } from '../tasks.ts'
21
+
22
+ /** Outcome of one ledger-wide label edit. */
23
+ export interface TagEditResult {
24
+ /** The ledger after the edit, or the input unchanged when nothing applied. */
25
+ tasks: readonly TaskRecord[]
26
+ /** Whether any task actually changed (drives whether the caller persists). */
27
+ changed: boolean
28
+ /** Refusal reason; the ledger is unchanged when set. */
29
+ error?: string
30
+ }
31
+
32
+ /** Trim a label name the way creation does. */
33
+ function normalizeName(value: string): string {
34
+ return value.trim()
35
+ }
36
+
37
+ /** Whether any task in the ledger carries this label. */
38
+ function carries(tasks: readonly TaskRecord[], name: string): boolean {
39
+ return collectKnownTags(tasks).some(tag => tag.name === name)
40
+ }
41
+
42
+ /**
43
+ * Rename one label everywhere, merging into an existing label of the target
44
+ * name. Every touched task gets a fresh updatedAt; untouched tasks keep theirs.
45
+ * @param tasks - current ledger.
46
+ * @param from - the label to rename.
47
+ * @param to - the new name (an existing name merges the two labels).
48
+ * @param now - clock instant (ms epoch).
49
+ * @returns the rewritten ledger, or a refusal.
50
+ */
51
+ export function applyRenameTag(
52
+ tasks: readonly TaskRecord[],
53
+ from: string,
54
+ to: string,
55
+ now: number,
56
+ ): TagEditResult {
57
+ const source = normalizeName(from)
58
+ const target = normalizeName(to)
59
+ if (source === '') return { tasks, changed: false, error: 'a label name is required' }
60
+ if (target === '') return { tasks, changed: false, error: 'a label name is required' }
61
+ if (target.length > TAG_NAME_MAX_LENGTH) return { tasks, changed: false, error: 'label name is too long' }
62
+ if (source === target) return { tasks, changed: false }
63
+ if (!carries(tasks, source)) return { tasks, changed: false, error: 'label not found' }
64
+ let changed = false
65
+ const next = tasks.map(task => {
66
+ const tags = task.tags
67
+ if (tags === undefined || !tags.some(tag => tag.name === source)) return task
68
+ changed = true
69
+ // A task that already carried BOTH names keeps the target's own row — the
70
+ // target is the label that survives, so its execution hint stays the one
71
+ // that applies. A task carrying only the source keeps that row's hint under
72
+ // the new name.
73
+ const hasTarget = tags.some(tag => tag.name === target)
74
+ const merged: TaskTag[] = []
75
+ for (const tag of tags) {
76
+ if (hasTarget && tag.name === source) continue
77
+ const name = tag.name === source ? target : tag.name
78
+ if (merged.some(entry => entry.name === name)) continue
79
+ merged.push(name === tag.name ? tag : { ...tag, name })
80
+ }
81
+ return { ...task, tags: normalizeTags(merged), updatedAt: now }
82
+ })
83
+ return { tasks: changed ? next : tasks, changed }
84
+ }
85
+
86
+ /**
87
+ * Remove one label from every task that carries it. Archived cards are included:
88
+ * the board's label row draws from the whole ledger, so a label left on an
89
+ * archived card would come straight back into the filter.
90
+ * @param tasks - current ledger.
91
+ * @param name - the label to remove.
92
+ * @param now - clock instant (ms epoch).
93
+ * @returns the rewritten ledger.
94
+ */
95
+ export function applyDeleteTag(
96
+ tasks: readonly TaskRecord[],
97
+ name: string,
98
+ now: number,
99
+ ): TagEditResult {
100
+ const target = normalizeName(name)
101
+ if (target === '') return { tasks, changed: false, error: 'a label name is required' }
102
+ let changed = false
103
+ const next = tasks.map(task => {
104
+ const tags = task.tags
105
+ if (tags === undefined || !tags.some(tag => tag.name === target)) return task
106
+ changed = true
107
+ return { ...task, tags: normalizeTags(tags.filter(tag => tag.name !== target)), updatedAt: now }
108
+ })
109
+ return { tasks: changed ? next : tasks, changed }
110
+ }
@@ -16,7 +16,7 @@ import type { TaskHandoverInput } from '../handover.ts'
16
16
  * Editable fields on a task (the update patch surface). `freeze` replaces the
17
17
  * continuation-card snapshot (restamping frozenAt); an explicit null clears it.
18
18
  */
19
- export type TaskUpdatePatch = Partial<Pick<TaskRecord, 'title' | 'description' | 'prompt' | 'workspaceId' | 'mode' | 'permission' | 'model' | 'reuseSession' | 'teamRun' | 'goalRun'>> & {
19
+ export type TaskUpdatePatch = Partial<Pick<TaskRecord, 'title' | 'description' | 'prompt' | 'workspaceId' | 'mode' | 'permission' | 'model' | 'reuseSession' | 'teamRun' | 'goalRun' | 'skipVerification'>> & {
20
20
  freeze?: FreezeSnapshot & { redacted?: boolean } | null
21
21
  /** Replaces the handover bundle (restamping bundledAt); an explicit null clears it. */
22
22
  handover?: TaskHandoverInput | null
@@ -43,11 +43,13 @@ export function hasContentPatch(patch: TaskUpdatePatch): boolean {
43
43
 
44
44
  /**
45
45
  * Whether a task's content may still be edited: the task must be on-board
46
- * (not archived) and must never have started executing. Fail-closed: a
47
- * running, settled, or cancelled-before-launch task keeps its content fixed.
46
+ * (not archived) and must never have started executing. Fail-closed: a task
47
+ * with any execution record — running, settled, or cancelled before launch —
48
+ * keeps its content fixed. The run, not the column, is the trigger: a card
49
+ * parked in 'running' by hand has nothing recorded yet and stays editable.
48
50
  */
49
51
  export function canEditTaskContent(task: TaskRecord): boolean {
50
- return task.archivedAt === undefined && task.status !== 'running' && task.executions.length === 0
52
+ return task.archivedAt === undefined && task.executions.length === 0
51
53
  }
52
54
 
53
55
  /** Keep an unknown permission string from entering the ledger. */
@@ -108,9 +110,13 @@ export function applyUpdateTask(
108
110
  if ('reuseSession' in patch) next.reuseSession = patch.reuseSession === true ? true : undefined
109
111
  // Team execution is a boolean opt-in with the same clear-by-false rule.
110
112
  if ('teamRun' in patch) next.teamRun = patch.teamRun === true ? true : undefined
111
- // The /goal opt-in is default-ON: true (or an explicit null) returns the
112
- // card to the default and stores nothing, only false pins a plain turn.
113
- if ('goalRun' in patch) next.goalRun = patch.goalRun === false ? false : undefined
113
+ // The /goal opt-in is default-OFF: true joins the goal run, while false
114
+ // (or an explicit null) returns the card to the default and stores nothing.
115
+ if ('goalRun' in patch) next.goalRun = patch.goalRun === true ? true : undefined
116
+ // The acceptance opt-out is the mirror image: default OFF, so only true
117
+ // pins a card out of the gate; false (or an explicit null) returns it to
118
+ // inheriting the board-wide switch and stores nothing.
119
+ if ('skipVerification' in patch) next.skipVerification = patch.skipVerification === true ? true : undefined
114
120
  if (workspaceId !== undefined || 'workspaceId' in patch) next.workspaceId = workspaceId
115
121
  if (mode !== undefined || 'mode' in patch) next.mode = mode
116
122
  if (permission !== undefined || 'permission' in patch) next.permission = permission
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Goal acceptance time budgets: the one place the judge's per-call ceiling and
3
+ * the acceptance's total ceiling live.
4
+ *
5
+ * Both are user settings (seconds), so the bounds and the seconds to
6
+ * milliseconds conversion live here rather than in either half: the Host
7
+ * clamps a hand-edited profile value to the same range the schema declares, and
8
+ * the gate reads the effective values live, so raising a ceiling takes effect
9
+ * without a reload.
10
+ *
11
+ * The two numbers are one budget, not two independent knobs: one acceptance is
12
+ * {@link ACCEPTANCE_JUDGE_CALLS} serial judge calls (three criteria times two
13
+ * rounds), so a total budget that cannot fit the calls can only end the
14
+ * acceptance early. {@link DEFAULT_VERIFICATION_BUDGET_SECONDS} is therefore
15
+ * derived from the per-call default times the call count plus two retries.
16
+ *
17
+ * @module dsh-task-board/core/verification-budget
18
+ */
19
+ import { CODING_CRITERIA, VERIFICATION_ROUNDS } from './verification.ts'
20
+
21
+ /** Judge calls one acceptance issues: every criterion, every round. */
22
+ export const ACCEPTANCE_JUDGE_CALLS: number = CODING_CRITERIA.length * VERIFICATION_ROUNDS
23
+
24
+ /**
25
+ * Per-judge-call ceiling used when the row names none: two and a half minutes
26
+ * for a reasoning model reading an 80,000 character trace with a 16,384 token
27
+ * output cap. The former 120s constant was tight enough that a slow but healthy
28
+ * route produced a wall of per-call timeouts, and every one of them was charged
29
+ * as an acceptance anomaly against a budget that then failed the card.
30
+ */
31
+ export const DEFAULT_VERIFICATION_CALL_TIMEOUT_SECONDS = 150
32
+
33
+ /** Shortest accepted per-call ceiling: below this no useful judge answers. */
34
+ export const VERIFICATION_CALL_TIMEOUT_MIN_SECONDS = 30
35
+
36
+ /** Longest accepted per-call ceiling, ten minutes. */
37
+ export const VERIFICATION_CALL_TIMEOUT_MAX_SECONDS = 600
38
+
39
+ /** Headroom the default total budget leaves beyond the mandatory calls. */
40
+ const DEFAULT_BUDGET_EXTRA_CALLS = 2
41
+
42
+ /**
43
+ * Total ceiling of one acceptance attempt, covering evidence rendering and
44
+ * every judge call including retries. Derived from the per-call default rather
45
+ * than pinned, because a total budget smaller than
46
+ * {@link ACCEPTANCE_JUDGE_CALLS} times the per-call ceiling guarantees that no
47
+ * acceptance can ever finish.
48
+ */
49
+ export const DEFAULT_VERIFICATION_BUDGET_SECONDS: number =
50
+ DEFAULT_VERIFICATION_CALL_TIMEOUT_SECONDS * (ACCEPTANCE_JUDGE_CALLS + DEFAULT_BUDGET_EXTRA_CALLS)
51
+
52
+ /** Shortest accepted total budget: long enough for at least one judge call. */
53
+ export const VERIFICATION_BUDGET_MIN_SECONDS = 120
54
+
55
+ /** Longest accepted total budget, thirty minutes. */
56
+ export const VERIFICATION_BUDGET_MAX_SECONDS = 1800
57
+
58
+ /** The default total budget in milliseconds, the face the abort timer takes. */
59
+ export const DEFAULT_VERIFICATION_BUDGET_MS = DEFAULT_VERIFICATION_BUDGET_SECONDS * 1_000
60
+
61
+ /**
62
+ * Clamp a configured ceiling into the supported range. A value the schema
63
+ * would refuse (non-finite, below the floor, above the ceiling) still leaves
64
+ * the board judging: an out-of-range setting never disables acceptance.
65
+ * @param value - configured seconds, or undefined for the default.
66
+ * @param fallback - the default to use when nothing usable was configured.
67
+ * @param min - shortest accepted value.
68
+ * @param max - longest accepted value.
69
+ * @returns the effective value in seconds.
70
+ */
71
+ function clampSeconds(value: number | undefined, fallback: number, min: number, max: number): number {
72
+ if (value === undefined || !Number.isFinite(value)) return fallback
73
+ return Math.min(Math.max(Math.trunc(value), min), max)
74
+ }
75
+
76
+ /**
77
+ * The effective per-judge-call ceiling.
78
+ * @param seconds - configured seconds, or undefined for the default.
79
+ * @returns the effective ceiling in seconds.
80
+ */
81
+ export function normalizeVerificationCallTimeoutSeconds(seconds: number | undefined): number {
82
+ return clampSeconds(
83
+ seconds,
84
+ DEFAULT_VERIFICATION_CALL_TIMEOUT_SECONDS,
85
+ VERIFICATION_CALL_TIMEOUT_MIN_SECONDS,
86
+ VERIFICATION_CALL_TIMEOUT_MAX_SECONDS,
87
+ )
88
+ }
89
+
90
+ /**
91
+ * The effective per-judge-call ceiling the timer face takes.
92
+ * @param seconds - configured seconds, or undefined for the default.
93
+ * @returns the effective ceiling in milliseconds.
94
+ */
95
+ export function verificationCallTimeoutMs(seconds: number | undefined): number {
96
+ return normalizeVerificationCallTimeoutSeconds(seconds) * 1_000
97
+ }
98
+
99
+ /**
100
+ * The effective total budget of one acceptance attempt.
101
+ * @param seconds - configured seconds, or undefined for the default.
102
+ * @returns the effective budget in seconds.
103
+ */
104
+ export function normalizeVerificationBudgetSeconds(seconds: number | undefined): number {
105
+ return clampSeconds(seconds, DEFAULT_VERIFICATION_BUDGET_SECONDS, VERIFICATION_BUDGET_MIN_SECONDS, VERIFICATION_BUDGET_MAX_SECONDS)
106
+ }
107
+
108
+ /**
109
+ * The effective total budget the abort face takes.
110
+ * @param seconds - configured seconds, or undefined for the default.
111
+ * @returns the effective budget in milliseconds.
112
+ */
113
+ export function verificationBudgetMs(seconds: number | undefined): number {
114
+ return normalizeVerificationBudgetSeconds(seconds) * 1_000
115
+ }