@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
package/src/core/tasks.ts
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
* unit-testable in isolation.
|
|
6
6
|
*/
|
|
7
7
|
import type { FreezeSnapshot } from './freeze-snapshot.ts'
|
|
8
|
+
import type { ExecutionVerification } from './verification.ts'
|
|
8
9
|
import type { TaskHandover, TaskHandoverInput } from './handover.ts'
|
|
10
|
+
import { normalizeTaskIntegrations, type TaskBoardExtensionPayload } from './extension.ts'
|
|
9
11
|
|
|
10
12
|
/** Task lifecycle status, one per kanban column. */
|
|
11
13
|
export type TaskStatus = 'backlog' | 'todo' | 'running' | 'done' | 'failed'
|
|
@@ -35,8 +37,23 @@ export interface ExecutionRecord {
|
|
|
35
37
|
* Session id of the DSH session that issued the run/rerun action (issue #6
|
|
36
38
|
* audit origin). Client-asserted, not a trust boundary; absent when the run
|
|
37
39
|
* was triggered by cron (source unknown).
|
|
40
|
+
*
|
|
41
|
+
* On an EXTERNAL record (issue #1826) this names the agent that completed the
|
|
42
|
+
* work outside the Host instead, which is what makes the history readable.
|
|
38
43
|
*/
|
|
39
44
|
initiatedBy?: string
|
|
45
|
+
/**
|
|
46
|
+
* True when this outcome was recorded from outside the Host by
|
|
47
|
+
* `record-external-outcome` rather than observed from a DSH session
|
|
48
|
+
* (issue #1826).
|
|
49
|
+
*
|
|
50
|
+
* The board never manufactures one of these itself: every Host-side path
|
|
51
|
+
* (`run`, `settle`, cascade folding, restart recovery) writes an execution
|
|
52
|
+
* without it, so `external` is the single field separating work this Host ran
|
|
53
|
+
* from work an outside agent reported. Such a record never carries a
|
|
54
|
+
* `sessionId` and never claims a verdict the Host observed.
|
|
55
|
+
*/
|
|
56
|
+
external?: boolean
|
|
40
57
|
/** Freeze instant captured from the card snapshot when the run opened. */
|
|
41
58
|
frozenAt?: number
|
|
42
59
|
/** Freeze source session captured from the card snapshot when the run opened. */
|
|
@@ -55,6 +72,14 @@ export interface ExecutionRecord {
|
|
|
55
72
|
ownResult?: ExecutionOutcome
|
|
56
73
|
/** Human failure text that arrived with {@link ownResult}. */
|
|
57
74
|
ownError?: string
|
|
75
|
+
/**
|
|
76
|
+
* Goal acceptance state of this execution (task-board goal verification).
|
|
77
|
+
* Fixed when the execution starts and never rewritten from later settings:
|
|
78
|
+
* the contract is frozen here, the attempts accrue here, and the settlement
|
|
79
|
+
* rule reads the pass record here. Absent on an execution that started
|
|
80
|
+
* before the feature existed, which keeps it on the historical verdict.
|
|
81
|
+
*/
|
|
82
|
+
verification?: ExecutionVerification
|
|
58
83
|
}
|
|
59
84
|
|
|
60
85
|
/**
|
|
@@ -78,17 +103,74 @@ export function retainRecentExecutions(executions: readonly ExecutionRecord[]):
|
|
|
78
103
|
return [...settled.slice(Math.max(settled.length - keepSettled, 0)), ...open]
|
|
79
104
|
}
|
|
80
105
|
|
|
106
|
+
/**
|
|
107
|
+
* How a rule produces its triggers: a recurring cron rule, or one single
|
|
108
|
+
* planned instant. The distinction is stored rather than inferred so a
|
|
109
|
+
* one-shot is never represented as a date-restricted cron expression.
|
|
110
|
+
*/
|
|
111
|
+
export type ScheduleMode = 'cron' | 'once'
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Why a due occurrence produced no run, or why a rule stopped on its own.
|
|
115
|
+
*
|
|
116
|
+
* These are stable codes, not display text: the board renders them through its
|
|
117
|
+
* locale dictionary, so the ledger never carries a translated string.
|
|
118
|
+
*/
|
|
119
|
+
export type ScheduleStopReason =
|
|
120
|
+
/** The single planned instant produced its run. */
|
|
121
|
+
| 'fired'
|
|
122
|
+
/** The recurring rule reached its run budget. */
|
|
123
|
+
| 'limit'
|
|
124
|
+
/** The plan has no reachable instant any more (an impossible cron). */
|
|
125
|
+
| 'no-target'
|
|
126
|
+
/** The instant passed while the board was not running through it. */
|
|
127
|
+
| 'missed'
|
|
128
|
+
/** The task already had an open execution, so this occurrence was skipped. */
|
|
129
|
+
| 'busy'
|
|
130
|
+
/** An above-default permission was still unconfirmed, so this occurrence was skipped. */
|
|
131
|
+
| 'permission'
|
|
132
|
+
/**
|
|
133
|
+
* A scheduled launch failed before any session existed, so the occurrence
|
|
134
|
+
* created no execution and its run was refunded rather than spent.
|
|
135
|
+
*/
|
|
136
|
+
| 'launch-failed'
|
|
137
|
+
|
|
138
|
+
/** Every stop reason this build understands; the repair and wire gates share it. */
|
|
139
|
+
export const SCHEDULE_STOP_REASONS: readonly ScheduleStopReason[] = [
|
|
140
|
+
'fired', 'limit', 'no-target', 'missed', 'busy', 'permission', 'launch-failed',
|
|
141
|
+
]
|
|
142
|
+
|
|
81
143
|
/**
|
|
82
144
|
* A scheduled-run rule attached to a task. The Host scheduler triggers the
|
|
83
145
|
* task when `nextRunAt` is due and persists the rule in the Host ledger.
|
|
146
|
+
*
|
|
147
|
+
* A rule is either `cron` (the historical shape: a 5-field expression with an
|
|
148
|
+
* explicit IANA zone, optionally capped by `maxRuns`) or `once` (one planned
|
|
149
|
+
* instant, `at`, with an implicit budget of one run). Both modes count the
|
|
150
|
+
* executions the scheduler actually started in `runCount`; a rule that reaches
|
|
151
|
+
* its budget stops itself in the ledger (`enabled: false` + `endedReason`)
|
|
152
|
+
* instead of relying on the execution agent to disable it.
|
|
84
153
|
*/
|
|
85
154
|
export interface ScheduleRule {
|
|
86
155
|
/** Whether the schedule is armed. */
|
|
87
156
|
enabled: boolean
|
|
88
|
-
/** 5-field cron expression: `分 时 日 月 周`. */
|
|
89
|
-
cron: string
|
|
90
157
|
/**
|
|
91
|
-
*
|
|
158
|
+
* Which kind of plan this rule carries. A rule loaded from a ledger written
|
|
159
|
+
* before the field existed normalizes to `cron` (its `cron` is the plan).
|
|
160
|
+
*/
|
|
161
|
+
mode: ScheduleMode
|
|
162
|
+
/**
|
|
163
|
+
* 5-field cron expression: `分 时 日 月 周`. Present on a `cron` rule and
|
|
164
|
+
* absent on a `once` rule.
|
|
165
|
+
*/
|
|
166
|
+
cron?: string
|
|
167
|
+
/**
|
|
168
|
+
* The instant a `once` rule executes (ms epoch). Kept after the run so the
|
|
169
|
+
* detail view can still show the planned time once the rule has ended.
|
|
170
|
+
*/
|
|
171
|
+
at?: number
|
|
172
|
+
/**
|
|
173
|
+
* IANA zone the wall clock is read in (issue #1722). Absent means the
|
|
92
174
|
* Host process zone, which is what a rule written before zones were
|
|
93
175
|
* persisted keeps using; the Host stamps its own zone at ledger migration so
|
|
94
176
|
* a later `TZ` change cannot silently move an existing rule.
|
|
@@ -98,6 +180,62 @@ export interface ScheduleRule {
|
|
|
98
180
|
nextRunAt: number | undefined
|
|
99
181
|
/** Instant of the latest scheduled trigger (ms epoch). */
|
|
100
182
|
lastTriggeredAt: number | undefined
|
|
183
|
+
/**
|
|
184
|
+
* Scheduled executions the Host has created under this rule. This is the
|
|
185
|
+
* run budget's counter: it counts executions that were successfully opened
|
|
186
|
+
* (whatever their later outcome), never completions, and a manual run never
|
|
187
|
+
* touches it. A skipped occurrence (busy / permission / missed / no target)
|
|
188
|
+
* creates no execution and therefore consumes nothing.
|
|
189
|
+
*/
|
|
190
|
+
runCount: number
|
|
191
|
+
/**
|
|
192
|
+
* Maximum scheduled executions for a `cron` rule; absent means unlimited
|
|
193
|
+
* (the historical behavior). A `once` rule has an implicit budget of one and
|
|
194
|
+
* stores no `maxRuns`.
|
|
195
|
+
*/
|
|
196
|
+
maxRuns?: number
|
|
197
|
+
/** Instant the rule stopped itself (ms epoch); absent while it may still fire. */
|
|
198
|
+
endedAt?: number
|
|
199
|
+
/** Why the rule stopped itself; present with `endedAt`. */
|
|
200
|
+
endedReason?: ScheduleStopReason
|
|
201
|
+
/** Instant of the latest occurrence that produced no run. */
|
|
202
|
+
skippedAt?: number
|
|
203
|
+
/** Why that occurrence produced no run; present with `skippedAt`. */
|
|
204
|
+
skippedReason?: ScheduleStopReason
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The rule's total run budget: one for a one-shot, the stored cap for a
|
|
209
|
+
* recurring rule, and undefined when the rule is unlimited.
|
|
210
|
+
* @param rule - the schedule rule.
|
|
211
|
+
* @returns the budget, or undefined for an uncapped rule.
|
|
212
|
+
*/
|
|
213
|
+
export function scheduleRunBudget(rule: ScheduleRule): number | undefined {
|
|
214
|
+
if (rule.mode === 'once') return 1
|
|
215
|
+
return rule.maxRuns
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Whether a rule has no runs left. The scheduler refuses to open another
|
|
220
|
+
* execution for an exhausted rule, which is what makes the budget terminal
|
|
221
|
+
* regardless of restarts or repeated timer fires.
|
|
222
|
+
* @param rule - the schedule rule.
|
|
223
|
+
* @returns true when the run budget is spent.
|
|
224
|
+
*/
|
|
225
|
+
export function scheduleExhausted(rule: ScheduleRule): boolean {
|
|
226
|
+
const budget = scheduleRunBudget(rule)
|
|
227
|
+
return budget !== undefined && rule.runCount >= budget
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The instant a rule plans to fire next, for display, whatever its mode: the
|
|
232
|
+
* planned `at` of a one-shot, or the stored `nextRunAt` of a recurring rule.
|
|
233
|
+
* @param rule - the schedule rule.
|
|
234
|
+
* @returns the planned instant, or undefined when the rule carries none.
|
|
235
|
+
*/
|
|
236
|
+
export function schedulePlannedAt(rule: ScheduleRule): number | undefined {
|
|
237
|
+
if (rule.mode === 'once') return rule.at
|
|
238
|
+
return rule.nextRunAt
|
|
101
239
|
}
|
|
102
240
|
|
|
103
241
|
/**
|
|
@@ -290,11 +428,27 @@ export interface TaskRecord {
|
|
|
290
428
|
* armed with the composed execution prompt as its objective. The session then
|
|
291
429
|
* keeps working automatic continuation rounds until the agent marks the goal
|
|
292
430
|
* complete, and this board settles the execution on the goal's own end
|
|
293
|
-
* instead of at the first turn end. Absent means
|
|
294
|
-
* by default, so only an explicit `
|
|
295
|
-
*
|
|
431
|
+
* instead of at the first turn end. Absent means OFF — the option is
|
|
432
|
+
* unchecked by default, so only an explicit `true` joins the goal run and
|
|
433
|
+
* every card that never touched it (including every card written before the
|
|
434
|
+
* field existed) runs one plain turn. A session whose `/goal` command is
|
|
435
|
+
* refused still runs the prompt.
|
|
296
436
|
*/
|
|
297
437
|
goalRun?: boolean
|
|
438
|
+
/**
|
|
439
|
+
* Run this task's goal executions WITHOUT the board's acceptance gate.
|
|
440
|
+
*
|
|
441
|
+
* Absent means INHERIT: the execution's applicability is decided by the
|
|
442
|
+
* board-wide `goalVerification` switch frozen at start, exactly as a card
|
|
443
|
+
* that never touched this option behaves. Only an explicit `true` opts the
|
|
444
|
+
* card out, and the execution records `applicability: 'disabled'` so the
|
|
445
|
+
* report says plainly that nothing certified this run.
|
|
446
|
+
*
|
|
447
|
+
* The opt-out is per execution, not per run: flipping it never rewrites the
|
|
448
|
+
* verdict rule of an execution already open, and a rerun or a scheduled
|
|
449
|
+
* occurrence reads the card's current value when it freezes its contract.
|
|
450
|
+
*/
|
|
451
|
+
skipVerification?: boolean
|
|
298
452
|
/**
|
|
299
453
|
* Frozen context snapshot for a continuation card; absent on plain tasks.
|
|
300
454
|
* Sanitized before it enters the ledger (redaction, slash-command taint,
|
|
@@ -326,6 +480,20 @@ export interface TaskRecord {
|
|
|
326
480
|
* absent means on-board.
|
|
327
481
|
*/
|
|
328
482
|
archivedAt?: number
|
|
483
|
+
/**
|
|
484
|
+
* Opaque external-provider metadata, keyed by extension id; optional and
|
|
485
|
+
* additive so existing tasks require no ledger migration. The board stores
|
|
486
|
+
* and forwards it without interpreting the keys — each provider owns the
|
|
487
|
+
* shape and validation of its own entry.
|
|
488
|
+
*/
|
|
489
|
+
integrations?: Record<string, TaskBoardExtensionPayload>
|
|
490
|
+
/**
|
|
491
|
+
* Whether the card is held off the board: it exists in the ledger (and its
|
|
492
|
+
* execution history is intact) but no column ever renders it. Providers
|
|
493
|
+
* create hidden cards for work they materialize but do not want displayed;
|
|
494
|
+
* absent means on-board.
|
|
495
|
+
*/
|
|
496
|
+
hidden?: boolean
|
|
329
497
|
}
|
|
330
498
|
|
|
331
499
|
/**
|
|
@@ -359,7 +527,7 @@ export interface NewTaskInput {
|
|
|
359
527
|
* inherits the parent's unset execution targets.
|
|
360
528
|
*/
|
|
361
529
|
parentId?: string
|
|
362
|
-
/** Workspace the execution must run in; empty/absent = the recent workspace. */
|
|
530
|
+
/** Workspace the execution must run in; empty/absent = the creating session's workspace for a root task, else the recent workspace at execution time. */
|
|
363
531
|
workspaceId?: string
|
|
364
532
|
/** Agent preset the execution session must be composed from; empty/absent = deployment default. */
|
|
365
533
|
mode?: string
|
|
@@ -372,17 +540,32 @@ export interface NewTaskInput {
|
|
|
372
540
|
/** Run the subtree as an Agent Team (Team Lead session plus one teammate per direct subtask). */
|
|
373
541
|
teamRun?: boolean
|
|
374
542
|
/**
|
|
375
|
-
* Start the execution with dsh's built-in `/goal`. Absent
|
|
376
|
-
*
|
|
543
|
+
* Start the execution with dsh's built-in `/goal`. Absent means the default
|
|
544
|
+
* (one plain turn); only an explicit true joins the goal run.
|
|
377
545
|
*/
|
|
378
546
|
goalRun?: boolean
|
|
379
547
|
/**
|
|
380
|
-
*
|
|
381
|
-
*
|
|
382
|
-
|
|
383
|
-
|
|
548
|
+
* Run this task's goal executions without the acceptance gate; absent
|
|
549
|
+
* inherits the board-wide `goalVerification` switch.
|
|
550
|
+
*/
|
|
551
|
+
skipVerification?: boolean
|
|
552
|
+
/**
|
|
553
|
+
* Optional scheduling rule requested at creation time (the new-task
|
|
554
|
+
* dialog): an enable flag, the kind of plan (a recurring 5-field cron
|
|
555
|
+
* expression, or one planned instant for a one-shot), an optional run cap,
|
|
556
|
+
* and the IANA zone its wall clock is read in (absent means the Host zone).
|
|
557
|
+
* The create use case arms it only when enabled and the plan is valid.
|
|
384
558
|
*/
|
|
385
|
-
schedule?: {
|
|
559
|
+
schedule?: {
|
|
560
|
+
enabled: boolean
|
|
561
|
+
mode?: ScheduleMode
|
|
562
|
+
cron?: string
|
|
563
|
+
/** Planned instant of a one-shot rule (ms epoch). */
|
|
564
|
+
at?: number
|
|
565
|
+
timeZone?: string
|
|
566
|
+
/** Run cap of a recurring rule; absent means unlimited. */
|
|
567
|
+
maxRuns?: number
|
|
568
|
+
}
|
|
386
569
|
/**
|
|
387
570
|
* Optional frozen context snapshot (goal/progress/next, sanitized by the
|
|
388
571
|
* protocol gate) turning the new task into a continuation card.
|
|
@@ -398,6 +581,12 @@ export interface NewTaskInput {
|
|
|
398
581
|
* ahead of the execution prompt; a bare name changes nothing at run time.
|
|
399
582
|
*/
|
|
400
583
|
tags?: TaskTag[]
|
|
584
|
+
/** Opaque external-provider metadata keyed by extension id. */
|
|
585
|
+
integrations?: Record<string, TaskBoardExtensionPayload>
|
|
586
|
+
/** Column the created card opens in; absent means the board default (todo). */
|
|
587
|
+
status?: TaskStatus
|
|
588
|
+
/** Create the card off-board (held in the ledger, never rendered). */
|
|
589
|
+
hidden?: boolean
|
|
401
590
|
}
|
|
402
591
|
|
|
403
592
|
/** The five kanban columns, in display order. */
|
|
@@ -409,8 +598,20 @@ export const COLUMNS: readonly { status: TaskStatus; label: string }[] = [
|
|
|
409
598
|
{ status: 'failed', label: '已失败' },
|
|
410
599
|
]
|
|
411
600
|
|
|
412
|
-
/**
|
|
413
|
-
|
|
601
|
+
/**
|
|
602
|
+
* Statuses a card may be moved to by hand: every column. The board UI and the
|
|
603
|
+
* agent tool surface share this one list, so no column can be manual on one
|
|
604
|
+
* surface and locked on the other.
|
|
605
|
+
*
|
|
606
|
+
* The column is a board statement, not a claim about a run. A hand-written
|
|
607
|
+
* `done`/`failed` declares that the work finished (or failed) outside a
|
|
608
|
+
* Host-run execution — human hands, an external system — and a hand-written
|
|
609
|
+
* `running` says the work is under way without a tracked session. None of them
|
|
610
|
+
* creates an {@link ExecutionRecord}, which is what keeps a declaration
|
|
611
|
+
* distinguishable from a recorded outcome in the card's execution history; the
|
|
612
|
+
* next settled run overwrites the column with the real one.
|
|
613
|
+
*/
|
|
614
|
+
export const MANUAL_STATUSES: readonly TaskStatus[] = ['backlog', 'todo', 'running', 'done', 'failed']
|
|
414
615
|
|
|
415
616
|
/** Statuses the runner may move a card to from 'running'. */
|
|
416
617
|
export const RUNNER_SETTLE_STATUSES: readonly TaskStatus[] = ['done', 'failed']
|
|
@@ -425,9 +626,35 @@ export function isTaskStatus(value: unknown): value is TaskStatus {
|
|
|
425
626
|
return typeof value === 'string' && (ALL_STATUSES as readonly string[]).includes(value)
|
|
426
627
|
}
|
|
427
628
|
|
|
428
|
-
/**
|
|
629
|
+
/**
|
|
630
|
+
* Whether the manual move `from` -> `to` is a legal *column* change: any
|
|
631
|
+
* column, and never the one the card already shows.
|
|
632
|
+
*
|
|
633
|
+
* Whether the card may be moved at all is a separate question. The lock an
|
|
634
|
+
* executing card carries belongs to its open execution, never to the column
|
|
635
|
+
* text, so it is {@link canMoveTask} — not this predicate — that expresses it.
|
|
636
|
+
*/
|
|
429
637
|
export function canMoveManually(from: TaskStatus, to: TaskStatus): boolean {
|
|
430
|
-
return from !==
|
|
638
|
+
return from !== to && (MANUAL_STATUSES as readonly TaskStatus[]).includes(to)
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Whether the board may move `task` to `to` by hand: the card is on-board,
|
|
643
|
+
* holds no open execution, and the target is a legal column change. The ledger,
|
|
644
|
+
* the detail view's status buttons and the board's drop handler all decide
|
|
645
|
+
* through this one predicate, so no two surfaces can disagree about a card.
|
|
646
|
+
*/
|
|
647
|
+
export function canMoveTask(task: TaskRecord, to: TaskStatus): boolean {
|
|
648
|
+
return task.archivedAt === undefined && !hasOpenExecution(task) && canMoveManually(task.status, to)
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Whether the task still has an execution the runner has not settled. This —
|
|
653
|
+
* not the `running` column — is what "the runner owns this card" means: a card
|
|
654
|
+
* parked in `running` by hand has no session and stays fully movable.
|
|
655
|
+
*/
|
|
656
|
+
export function hasOpenExecution(task: TaskRecord): boolean {
|
|
657
|
+
return task.executions.some(execution => execution.endedAt === undefined)
|
|
431
658
|
}
|
|
432
659
|
|
|
433
660
|
/** Normalize one optional execution-target string: trim; blank collapses to undefined. */
|
|
@@ -457,13 +684,14 @@ export function freezeOf(
|
|
|
457
684
|
/** Create a task from user input. */
|
|
458
685
|
export function createTask(input: NewTaskInput, now: number, id: string): TaskRecord {
|
|
459
686
|
const tags = normalizeTags(input.tags)
|
|
687
|
+
const integrations = normalizeTaskIntegrations(input.integrations)
|
|
460
688
|
return {
|
|
461
689
|
id,
|
|
462
690
|
title: input.title.trim(),
|
|
463
691
|
description: input.description.trim(),
|
|
464
692
|
prompt: input.prompt.trim(),
|
|
465
693
|
parentId: normalizeTargetId(input.parentId),
|
|
466
|
-
status: 'todo',
|
|
694
|
+
status: input.status ?? 'todo',
|
|
467
695
|
createdAt: now,
|
|
468
696
|
updatedAt: now,
|
|
469
697
|
executions: [],
|
|
@@ -473,13 +701,19 @@ export function createTask(input: NewTaskInput, now: number, id: string): TaskRe
|
|
|
473
701
|
model: normalizeTargetId(input.model),
|
|
474
702
|
reuseSession: input.reuseSession === true ? true : undefined,
|
|
475
703
|
teamRun: input.teamRun === true ? true : undefined,
|
|
476
|
-
// Default
|
|
704
|
+
// Default OFF: only an explicit true is stored, so a card that never
|
|
477
705
|
// touched the option (and every card written before the field existed)
|
|
478
|
-
// keeps
|
|
479
|
-
goalRun: input.goalRun ===
|
|
706
|
+
// keeps running one plain turn.
|
|
707
|
+
goalRun: input.goalRun === true ? true : undefined,
|
|
708
|
+
// Default OFF: only an explicit true is stored, so a card that never
|
|
709
|
+
// touched the option inherits the board-wide acceptance switch exactly as
|
|
710
|
+
// every card written before the field existed does.
|
|
711
|
+
skipVerification: input.skipVerification === true ? true : undefined,
|
|
480
712
|
...(input.freeze === undefined ? {} : { freeze: freezeOf(input.freeze, now) }),
|
|
481
713
|
...(input.handover === undefined ? {} : { handover: { ...input.handover, bundledAt: now } }),
|
|
482
714
|
...(tags === undefined ? {} : { tags }),
|
|
715
|
+
...(integrations === undefined ? {} : { integrations }),
|
|
716
|
+
...(input.hidden === true ? { hidden: true } : {}),
|
|
483
717
|
}
|
|
484
718
|
}
|
|
485
719
|
|
|
@@ -488,35 +722,38 @@ export function withStatus(task: TaskRecord, status: TaskStatus, now: number): T
|
|
|
488
722
|
return { ...task, status, updatedAt: now }
|
|
489
723
|
}
|
|
490
724
|
|
|
725
|
+
/** Every field a schedule patch may carry; the rest of the rule is carried over. */
|
|
726
|
+
const SCHEDULE_PATCH_KEYS = [
|
|
727
|
+
'enabled', 'mode', 'cron', 'at', 'timeZone', 'nextRunAt', 'lastTriggeredAt',
|
|
728
|
+
'runCount', 'maxRuns', 'endedAt', 'endedReason', 'skippedAt', 'skippedReason',
|
|
729
|
+
] as const satisfies ReadonlyArray<keyof ScheduleRule>
|
|
730
|
+
|
|
491
731
|
/**
|
|
492
732
|
* Merge a schedule patch into a task's schedule rule (creating it when
|
|
493
733
|
* absent), with a fresh updatedAt. Keys present in the patch overwrite the
|
|
494
734
|
* current value — including explicit `undefined`, which clears a field (used
|
|
495
|
-
* to disarm `nextRunAt`); absent keys keep
|
|
735
|
+
* to disarm `nextRunAt` or to drop an ended/skip record); absent keys keep
|
|
736
|
+
* their current value.
|
|
496
737
|
*/
|
|
497
738
|
export function withSchedule(
|
|
498
739
|
task: TaskRecord,
|
|
499
740
|
patch: Partial<ScheduleRule>,
|
|
500
741
|
now: number,
|
|
501
742
|
): TaskRecord {
|
|
502
|
-
const
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
if ('cron' in patch) schedule.cron = patch.cron ?? ''
|
|
512
|
-
// An explicit `undefined` drops the stored zone (back to the Host zone);
|
|
513
|
-
// an absent key keeps it.
|
|
514
|
-
if ('timeZone' in patch) {
|
|
515
|
-
if (patch.timeZone === undefined) delete schedule.timeZone
|
|
516
|
-
else schedule.timeZone = patch.timeZone
|
|
743
|
+
const schedule: ScheduleRule = task.schedule === undefined
|
|
744
|
+
? { enabled: false, mode: 'cron', nextRunAt: undefined, lastTriggeredAt: undefined, runCount: 0 }
|
|
745
|
+
: { ...task.schedule }
|
|
746
|
+
const fields = schedule as unknown as Record<string, unknown>
|
|
747
|
+
const next = patch as unknown as Record<string, unknown>
|
|
748
|
+
for (const key of SCHEDULE_PATCH_KEYS) {
|
|
749
|
+
if (!(key in patch)) continue
|
|
750
|
+
if (next[key] === undefined) delete fields[key]
|
|
751
|
+
else fields[key] = next[key]
|
|
517
752
|
}
|
|
518
|
-
|
|
519
|
-
if (
|
|
753
|
+
// The three non-optional fields can never be cleared into an unusable rule.
|
|
754
|
+
if (schedule.enabled !== true) schedule.enabled = false
|
|
755
|
+
if (schedule.mode !== 'once') schedule.mode = 'cron'
|
|
756
|
+
if (!Number.isFinite(schedule.runCount) || schedule.runCount < 0) schedule.runCount = 0
|
|
520
757
|
return { ...task, updatedAt: now, schedule }
|
|
521
758
|
}
|
|
522
759
|
|
|
@@ -560,6 +797,21 @@ export function startExecution(
|
|
|
560
797
|
}
|
|
561
798
|
}
|
|
562
799
|
|
|
800
|
+
/**
|
|
801
|
+
* The column a settled outcome puts a card in. The ONE derivation shared by
|
|
802
|
+
* every settlement path (issue #1826): a Host-run execution and an outcome an
|
|
803
|
+
* outside agent reported both land in the same column from the same rule, so
|
|
804
|
+
* the column never becomes a second, independent piece of state.
|
|
805
|
+
@param task - the card being settled.
|
|
806
|
+
@param outcome - the settled outcome.
|
|
807
|
+
@returns the status the card shows afterwards.
|
|
808
|
+
*/
|
|
809
|
+
export function settledStatus(task: TaskRecord, outcome: ExecutionOutcome): TaskStatus {
|
|
810
|
+
if (outcome === 'succeeded') return task.schedule?.enabled === true ? 'todo' : 'done'
|
|
811
|
+
if (outcome === 'failed') return 'failed'
|
|
812
|
+
return task.status === 'running' ? 'todo' : task.status
|
|
813
|
+
}
|
|
814
|
+
|
|
563
815
|
/**
|
|
564
816
|
* Settle a running execution: record the outcome and move the task into the
|
|
565
817
|
* matching column. No-op (returns the input task) when the execution is not
|
|
@@ -579,11 +831,7 @@ export function settleExecution(
|
|
|
579
831
|
const settled: ExecutionRecord = { ...execution, endedAt: now, result: outcome, error }
|
|
580
832
|
const executions = [...task.executions]
|
|
581
833
|
executions[index] = settled
|
|
582
|
-
|
|
583
|
-
? (task.schedule?.enabled ? 'todo' : 'done')
|
|
584
|
-
: outcome === 'failed' ? 'failed'
|
|
585
|
-
: task.status === 'running' ? 'todo' : task.status
|
|
586
|
-
return { ...task, status, updatedAt: now, executions }
|
|
834
|
+
return { ...task, status: settledStatus(task, outcome), updatedAt: now, executions }
|
|
587
835
|
}
|
|
588
836
|
|
|
589
837
|
/** A settled-execution summary string for the detail view. */
|
|
@@ -67,22 +67,43 @@ export function applyCreateTask(
|
|
|
67
67
|
model: input.model ?? parent.model,
|
|
68
68
|
}
|
|
69
69
|
let task = createTask(effective, now, id)
|
|
70
|
-
// Arm the requested schedule (new-task dialog)
|
|
71
|
-
// a
|
|
72
|
-
//
|
|
70
|
+
// Arm the requested schedule (new-task dialog). The Host wire gate already
|
|
71
|
+
// rejects a malformed enabled plan; this arm re-checks it so a direct caller
|
|
72
|
+
// (a test or an embedder) cannot persist an unusable rule either.
|
|
73
73
|
const requested = input.schedule
|
|
74
|
-
if (requested?.enabled === true
|
|
75
|
-
const cron = requested.cron.trim()
|
|
74
|
+
if (requested?.enabled === true) {
|
|
76
75
|
const requestedZone = requested.timeZone
|
|
77
76
|
// An unusable zone is refused rather than silently reinterpreted as the
|
|
78
77
|
// Host zone: the caller asked for a specific wall clock.
|
|
79
78
|
if (requestedZone !== undefined && !isValidTimeZone(requestedZone)) {
|
|
80
79
|
return { task: undefined, tasks, error: 'invalid schedule time zone' }
|
|
81
80
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
81
|
+
if (requested.mode === 'once') {
|
|
82
|
+
const at = requested.at
|
|
83
|
+
if (at === undefined || !Number.isInteger(at) || at <= now) {
|
|
84
|
+
return { task: undefined, tasks, error: 'invalid schedule instant' }
|
|
85
|
+
}
|
|
86
|
+
task = withSchedule(task, {
|
|
87
|
+
enabled: true,
|
|
88
|
+
mode: 'once',
|
|
89
|
+
at,
|
|
90
|
+
timeZone: requestedZone,
|
|
91
|
+
nextRunAt: at,
|
|
92
|
+
runCount: 0,
|
|
93
|
+
}, now)
|
|
94
|
+
} else {
|
|
95
|
+
const cron = (requested.cron ?? '').trim()
|
|
96
|
+
if (cron !== '' && isValidCron(cron)) {
|
|
97
|
+
const maxRuns = requested.maxRuns
|
|
98
|
+
if (maxRuns !== undefined && (!Number.isInteger(maxRuns) || maxRuns < 1)) {
|
|
99
|
+
return { task: undefined, tasks, error: 'invalid schedule run cap' }
|
|
100
|
+
}
|
|
101
|
+
const zone = requestedZone ?? hostTimeZone
|
|
102
|
+
const nextRunAt = nextRunAtMs(cron, now, zone)
|
|
103
|
+
if (nextRunAt !== undefined) {
|
|
104
|
+
task = withSchedule(task, { enabled: true, mode: 'cron', cron, maxRuns, timeZone: requestedZone, nextRunAt, runCount: 0 }, now)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
86
107
|
}
|
|
87
108
|
}
|
|
88
109
|
return { task, tasks: [...tasks, task] }
|
|
@@ -49,8 +49,9 @@ export function applySetParent(
|
|
|
49
49
|
if (task.archivedAt !== undefined) return { tasks, applied: false, error: 'archived task is read-only' }
|
|
50
50
|
// A task with an open execution may be a participant of a running cascade.
|
|
51
51
|
// Moving it would break that group's parent walk and leave the parent card
|
|
52
|
-
// in the running column forever, so the link is frozen until it settles.
|
|
53
|
-
|
|
52
|
+
// in the running column forever, so the link is frozen until it settles. The
|
|
53
|
+
// lock is the open execution: a card parked in 'running' by hand links freely.
|
|
54
|
+
if (task.executions.some(execution => execution.endedAt === undefined)) {
|
|
54
55
|
return { tasks, applied: false, error: 'running task cannot be re-parented' }
|
|
55
56
|
}
|
|
56
57
|
const check = checkParentLink(tasks, id, parentId, normalizeSubtaskDepth(maxSubtaskDepth))
|