@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.
- package/README.i18n.yaml +2 -2
- package/README.md +59 -20
- package/README.zh.md +59 -20
- package/lib/client.js +6208 -624
- package/lib/client.js.map +1 -1
- package/lib/index.js +4774 -493
- package/lib/types/client/PluginSettingsCard.d.ts +7 -0
- package/lib/types/client/TaskBoardSettingsCard.d.ts +31 -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 +4 -1
- 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/status-key.d.ts +15 -0
- package/lib/types/client/board/task-markdown.d.ts +19 -0
- package/lib/types/client/index.d.ts +23 -5
- package/lib/types/client/locales.d.ts +168 -1
- package/lib/types/client/native-panel.d.ts +6 -20
- package/lib/types/client/plugin-card-seat.d.ts +5 -0
- package/lib/types/client/schedule-zone.d.ts +50 -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 +46 -11
- 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/poll-cadence.d.ts +31 -0
- package/lib/types/core/tasks.d.ts +228 -17
- package/lib/types/core/use-cases/task-schedule.d.ts +68 -10
- 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-budget.d.ts +52 -0
- package/lib/types/core/verification.d.ts +753 -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 +95 -0
- package/lib/types/host/verification-runner.d.ts +139 -0
- package/lib/types/host/workspace-evidence.d.ts +53 -0
- package/lib/types/host-ledger.d.ts +133 -14
- package/lib/types/host-runner.d.ts +55 -10
- package/lib/types/host-service.d.ts +205 -11
- package/lib/types/index.d.ts +134 -26
- package/lib/types/protocol.d.ts +92 -10
- package/lib/types/tool-surface.d.ts +71 -0
- package/package.json +28 -31
- package/src/client/PluginSettingsCard.tsx +9 -0
- package/src/client/TaskBoardSettingsCard.tsx +378 -66
- 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 +12 -7
- package/src/client/board/NewTaskModal.tsx +168 -21
- package/src/client/board/TagManagerModal.tsx +235 -0
- package/src/client/board/TaskBoard.tsx +259 -103
- package/src/client/board/TaskCard.tsx +214 -81
- package/src/client/board/TaskDetail.tsx +289 -43
- package/src/client/board/TaskForm.tsx +13 -3
- package/src/client/board/VerificationReport.tsx +322 -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/status-key.ts +30 -0
- package/src/client/board/task-markdown.module.css +137 -0
- package/src/client/board/task-markdown.tsx +106 -0
- package/src/client/board-settings.module.css +66 -0
- package/src/client/board.module.css +488 -63
- package/src/client/index.ts +82 -51
- package/src/client/locales.ts +343 -6
- package/src/client/native-panel.tsx +18 -2
- package/src/client/plugin-card-seat.ts +7 -0
- package/src/client/schedule-zone.ts +100 -2
- 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 +105 -10
- 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/poll-cadence.ts +41 -0
- package/src/core/store.ts +80 -15
- package/src/core/tasks.ts +293 -45
- package/src/core/use-cases/task-create.ts +30 -9
- package/src/core/use-cases/task-parent.ts +3 -2
- package/src/core/use-cases/task-schedule.ts +190 -25
- package/src/core/use-cases/task-tag.ts +110 -0
- package/src/core/use-cases/task-update.ts +13 -7
- package/src/core/verification-budget.ts +115 -0
- package/src/core/verification.ts +1594 -0
- package/src/core/workspace-target.ts +59 -0
- package/src/host/agent-tools.ts +125 -38
- package/src/host/extension-registry.ts +509 -0
- package/src/host/llm-dispatch.ts +87 -0
- package/src/host/verification-gate.ts +487 -0
- package/src/host/verification-runner.ts +702 -0
- package/src/host/workspace-evidence.ts +176 -0
- package/src/host-ai.ts +15 -9
- package/src/host-ledger.ts +468 -71
- package/src/host-routes.ts +15 -2
- package/src/host-runner.ts +106 -24
- package/src/host-service.ts +546 -40
- package/src/index.ts +305 -21
- package/src/protocol.ts +226 -28
- package/src/tool-surface.ts +95 -0
- package/lib/invariant.js +0 -6
- package/lib/types/invariant.d.ts +0 -3
- package/src/invariant.ts +0 -4
|
@@ -1,33 +1,54 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Schedule use
|
|
3
|
-
* Pure ledger transitions (no persistence or notify
|
|
4
|
-
* orchestrates those). Validation and next-run computation
|
|
5
|
-
* the core cron parser (schedule.ts) and the withSchedule
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
|
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
|
|
51
|
-
if (
|
|
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
|
|
54
|
-
const
|
|
55
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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 (
|
|
67
|
-
* due instant and the trigger instant. No-op for tasks without a rule
|
|
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
|
|
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.
|
|
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-
|
|
112
|
-
// card to the default and stores nothing
|
|
113
|
-
if ('goalRun' in patch) next.goalRun = patch.goalRun ===
|
|
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
|
+
}
|