dsh-taskboard 0.1.0
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/LICENSE +201 -0
- package/README.md +169 -0
- package/cordis.patch.yml +12 -0
- package/lib/client.js +2085 -0
- package/lib/host/execution.js +189 -0
- package/lib/host/execution.js.map +1 -0
- package/lib/host/protocol-text.js +37 -0
- package/lib/host/protocol-text.js.map +1 -0
- package/lib/host/routes.js +369 -0
- package/lib/host/routes.js.map +1 -0
- package/lib/host/scheduler.js +91 -0
- package/lib/host/scheduler.js.map +1 -0
- package/lib/host/sdk.js +145 -0
- package/lib/host/sdk.js.map +1 -0
- package/lib/host/store.js +112 -0
- package/lib/host/store.js.map +1 -0
- package/lib/host/tools.js +620 -0
- package/lib/host/tools.js.map +1 -0
- package/lib/index.js +91 -0
- package/lib/index.js.map +1 -0
- package/lib/invariant.js +22 -0
- package/lib/invariant.js.map +1 -0
- package/lib/shared/api.js +9 -0
- package/lib/shared/api.js.map +1 -0
- package/lib/shared/protocol.js +279 -0
- package/lib/shared/protocol.js.map +1 -0
- package/package.json +74 -0
- package/src/client/api.ts +90 -0
- package/src/client/board/NewTaskModal.tsx +8 -0
- package/src/client/board/TaskBoard.tsx +184 -0
- package/src/client/board/TaskCard.tsx +61 -0
- package/src/client/board/TaskDetail.tsx +210 -0
- package/src/client/board/TaskFormModal.tsx +257 -0
- package/src/client/board-mount.tsx +92 -0
- package/src/client/controller.ts +241 -0
- package/src/client/index.ts +87 -0
- package/src/client/sidebar-entry.ts +165 -0
- package/src/client/styles.ts +391 -0
- package/src/host/execution.ts +244 -0
- package/src/host/protocol-text.ts +37 -0
- package/src/host/routes.ts +387 -0
- package/src/host/scheduler.ts +107 -0
- package/src/host/sdk.ts +200 -0
- package/src/host/store.ts +139 -0
- package/src/host/tools.ts +631 -0
- package/src/index.ts +124 -0
- package/src/invariant.ts +22 -0
- package/src/shared/api.ts +98 -0
- package/src/shared/protocol.ts +475 -0
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task domain model, state machine, urgency classes, and cron math — the
|
|
3
|
+
* framework-free core shared verbatim by the host half (tools, store, routes,
|
|
4
|
+
* scheduler) and, from P2 on, the browser half (board view).
|
|
5
|
+
*
|
|
6
|
+
* Everything here is a pure function over plain data: no imports beyond the
|
|
7
|
+
* standard library, no I/O, no globals. Tests drive it directly.
|
|
8
|
+
*
|
|
9
|
+
* @module dsh-taskboard/shared/protocol
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Status vocabulary
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Task lifecycle states. Main board columns render `backlog → todo →
|
|
18
|
+
* in_progress → in_review → done`; `canceled` and `archived` are secondary
|
|
19
|
+
* states collected under an "other tasks" tab. `blocked` is NOT a status —
|
|
20
|
+
* it is a horizontal marker any non-terminal state may carry.
|
|
21
|
+
*/
|
|
22
|
+
export type TaskStatus =
|
|
23
|
+
| 'backlog'
|
|
24
|
+
| 'todo'
|
|
25
|
+
| 'in_progress'
|
|
26
|
+
| 'in_review'
|
|
27
|
+
| 'done'
|
|
28
|
+
| 'canceled'
|
|
29
|
+
| 'archived'
|
|
30
|
+
|
|
31
|
+
/** Statuses shown as the five main board columns, in order. */
|
|
32
|
+
export const MAIN_STATUSES: readonly TaskStatus[] = [
|
|
33
|
+
'backlog',
|
|
34
|
+
'todo',
|
|
35
|
+
'in_progress',
|
|
36
|
+
'in_review',
|
|
37
|
+
'done',
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
/** Statuses collected under the secondary tab. */
|
|
41
|
+
export const SECONDARY_STATUSES: readonly TaskStatus[] = ['canceled', 'archived']
|
|
42
|
+
|
|
43
|
+
/** Every valid status, main first. */
|
|
44
|
+
export const ALL_STATUSES: readonly TaskStatus[] = [...MAIN_STATUSES, ...SECONDARY_STATUSES]
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Legal forward/sideways transitions. Anything not listed is rejected with
|
|
48
|
+
* `invalid_transition`. `archived` is terminal.
|
|
49
|
+
*/
|
|
50
|
+
const TRANSITIONS: Readonly<Record<TaskStatus, readonly TaskStatus[]>> = {
|
|
51
|
+
backlog: ['todo', 'canceled'],
|
|
52
|
+
todo: ['in_progress', 'backlog', 'canceled'],
|
|
53
|
+
in_progress: ['in_review', 'todo', 'canceled'],
|
|
54
|
+
in_review: ['in_progress', 'todo', 'done', 'canceled'],
|
|
55
|
+
done: ['archived'],
|
|
56
|
+
canceled: ['archived', 'todo'],
|
|
57
|
+
archived: [],
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Whether a status move is legal per the state machine.
|
|
62
|
+
* @param from - current status.
|
|
63
|
+
* @param to - requested status.
|
|
64
|
+
* @returns true when the transition is allowed.
|
|
65
|
+
*/
|
|
66
|
+
export function canTransition(from: TaskStatus, to: TaskStatus): boolean {
|
|
67
|
+
return TRANSITIONS[from].includes(to)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The claim move: the one transition that transfers ownership of a task to
|
|
72
|
+
* the calling session. Guarded by the project (workspace) boundary in the
|
|
73
|
+
* tool layer.
|
|
74
|
+
*/
|
|
75
|
+
export function isClaim(from: TaskStatus, to: TaskStatus): boolean {
|
|
76
|
+
return from === 'todo' && to === 'in_progress'
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Statuses a `done` move may depart from (user confirmation only). */
|
|
80
|
+
export function canCompleteFrom(from: TaskStatus): boolean {
|
|
81
|
+
return from === 'in_review'
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Urgency
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
/** Urgency classes with fixed UI colors. */
|
|
89
|
+
export type Urgency = 'urgent' | 'normal' | 'relaxed'
|
|
90
|
+
|
|
91
|
+
/** All valid urgency values. */
|
|
92
|
+
export const URGENCIES: readonly Urgency[] = ['urgent', 'normal', 'relaxed']
|
|
93
|
+
|
|
94
|
+
/** CSS color token per urgency: red / purple / blue. */
|
|
95
|
+
export const URGENCY_COLOR: Readonly<Record<Urgency, string>> = {
|
|
96
|
+
urgent: '#e5484d',
|
|
97
|
+
normal: '#8e4ec6',
|
|
98
|
+
relaxed: '#3e63dd',
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// Execution
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
/** How a task may run. */
|
|
106
|
+
export type ExecutionMode = 'claim' | 'scheduled'
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Per-task execution configuration. `claim` tasks wait for an in-project
|
|
110
|
+
* session to claim them; `scheduled` tasks run on the host cron scheduler.
|
|
111
|
+
*/
|
|
112
|
+
export interface ExecutionConfig {
|
|
113
|
+
mode: ExecutionMode
|
|
114
|
+
/** Five-field cron expression (minute hour day month weekday); required for `scheduled`. */
|
|
115
|
+
cron?: string
|
|
116
|
+
/** Next due time (epoch ms); maintained by the host scheduler. */
|
|
117
|
+
nextRunAt?: number
|
|
118
|
+
/** Last time the scheduler triggered this task (epoch ms). */
|
|
119
|
+
lastTriggeredAt?: number
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Parse a five-field cron expression. Supported field syntax: star, star/step
|
|
124
|
+
* (`* / n` without spaces), a single number, an `a-b` range, and comma lists
|
|
125
|
+
* of those. Day-of-week accepts both 0 and 7 as Sunday (normalized to 0).
|
|
126
|
+
*
|
|
127
|
+
* @param expr - the expression to parse.
|
|
128
|
+
* @returns the match sets per field, or null when invalid.
|
|
129
|
+
*/
|
|
130
|
+
export function parseCron(expr: string): CronMatch | null {
|
|
131
|
+
const fields = expr.trim().split(/\s+/)
|
|
132
|
+
if (fields.length !== 5) return null
|
|
133
|
+
const ranges: ReadonlyArray<readonly [number, number]> = [
|
|
134
|
+
[0, 59],
|
|
135
|
+
[0, 23],
|
|
136
|
+
[1, 31],
|
|
137
|
+
[1, 12],
|
|
138
|
+
[0, 7],
|
|
139
|
+
]
|
|
140
|
+
const sets: Array<Set<number>> = []
|
|
141
|
+
for (let i = 0; i < 5; i++) {
|
|
142
|
+
const [min, max] = ranges[i]!
|
|
143
|
+
const set = new Set<number>()
|
|
144
|
+
if (!parseCronField(fields[i]!, min, max, set)) return null
|
|
145
|
+
sets.push(set)
|
|
146
|
+
}
|
|
147
|
+
const weekdays = new Set<number>()
|
|
148
|
+
for (const day of sets[4]!) weekdays.add(day === 7 ? 0 : day)
|
|
149
|
+
return { minutes: sets[0]!, hours: sets[1]!, days: sets[2]!, months: sets[3]!, weekdays }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Parsed cron field match sets. */
|
|
153
|
+
export type CronMatch = {
|
|
154
|
+
minutes: ReadonlySet<number>
|
|
155
|
+
hours: ReadonlySet<number>
|
|
156
|
+
days: ReadonlySet<number>
|
|
157
|
+
months: ReadonlySet<number>
|
|
158
|
+
weekdays: ReadonlySet<number>
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Parse one cron field into a match set; false on any syntax error. */
|
|
162
|
+
function parseCronField(field: string, min: number, max: number, out: Set<number>): boolean {
|
|
163
|
+
for (const part of field.split(',')) {
|
|
164
|
+
const [range, stepRaw] = part.split('/')
|
|
165
|
+
const step = stepRaw === undefined ? 1 : Number.parseInt(stepRaw, 10)
|
|
166
|
+
if (!Number.isInteger(step) || step < 1) return false
|
|
167
|
+
let lo: number
|
|
168
|
+
let hi: number
|
|
169
|
+
if (range === undefined || range === '') return false
|
|
170
|
+
if (range === '*') {
|
|
171
|
+
lo = min
|
|
172
|
+
hi = max
|
|
173
|
+
} else if (range.includes('-')) {
|
|
174
|
+
const [a, b] = range.split('-')
|
|
175
|
+
lo = Number.parseInt(a ?? '', 10)
|
|
176
|
+
hi = Number.parseInt(b ?? '', 10)
|
|
177
|
+
if (!Number.isInteger(lo) || !Number.isInteger(hi)) return false
|
|
178
|
+
} else {
|
|
179
|
+
lo = Number.parseInt(range, 10)
|
|
180
|
+
if (!Number.isInteger(lo)) return false
|
|
181
|
+
hi = stepRaw === undefined ? lo : max
|
|
182
|
+
}
|
|
183
|
+
if (lo < min || hi > max || lo > hi) return false
|
|
184
|
+
for (let v = lo; v <= hi; v += step) out.add(v)
|
|
185
|
+
}
|
|
186
|
+
return out.size > 0
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The next time at or after `from` matching the cron sets (local time),
|
|
191
|
+
* or null when no match exists within four years (e.g. Feb 30).
|
|
192
|
+
* @param match - parsed cron sets.
|
|
193
|
+
* @param from - epoch ms start point (inclusive match candidate).
|
|
194
|
+
* @returns the next match's epoch ms, or null.
|
|
195
|
+
*/
|
|
196
|
+
export function nextCronTime(match: CronMatch, from: number): number | null {
|
|
197
|
+
// Walk minute by minute from the next whole minute, capped at ~4 years.
|
|
198
|
+
const start = new Date(from)
|
|
199
|
+
start.setSeconds(0, 0)
|
|
200
|
+
start.setMinutes(start.getMinutes() + 1)
|
|
201
|
+
const cap = from + 4 * 366 * 24 * 60 * 60 * 1000
|
|
202
|
+
let t = start.getTime()
|
|
203
|
+
while (t <= cap) {
|
|
204
|
+
const d = new Date(t)
|
|
205
|
+
if (
|
|
206
|
+
match.months.has(d.getMonth() + 1)
|
|
207
|
+
&& match.days.has(d.getDate())
|
|
208
|
+
&& match.weekdays.has(d.getDay())
|
|
209
|
+
&& match.hours.has(d.getHours())
|
|
210
|
+
&& match.minutes.has(d.getMinutes())
|
|
211
|
+
) {
|
|
212
|
+
return t
|
|
213
|
+
}
|
|
214
|
+
t += 60_000
|
|
215
|
+
}
|
|
216
|
+
return null
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
// Records
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
/** Who performed a write. */
|
|
224
|
+
export type Actor =
|
|
225
|
+
| { kind: 'user' }
|
|
226
|
+
| { kind: 'agent'; sessionId: string }
|
|
227
|
+
|
|
228
|
+
/** A progress/report comment on a task. */
|
|
229
|
+
export type CommentRecord = {
|
|
230
|
+
id: string
|
|
231
|
+
/** Comment body (plain text; UI renders as pre-wrapped). */
|
|
232
|
+
body: string
|
|
233
|
+
/** Optimistic-concurrency version of this comment. */
|
|
234
|
+
version: number
|
|
235
|
+
createdAt: number
|
|
236
|
+
/** The session that wrote this comment; absent for user-written ones. */
|
|
237
|
+
threadId?: string
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** One execution attempt of a task. */
|
|
241
|
+
export type ExecutionRecord = {
|
|
242
|
+
id: string
|
|
243
|
+
/** The session this execution ran in; set once the session is really started. */
|
|
244
|
+
sessionId?: string
|
|
245
|
+
/** Trigger: manual button or the host scheduler. */
|
|
246
|
+
trigger: 'manual' | 'scheduled'
|
|
247
|
+
startedAt?: number
|
|
248
|
+
endedAt?: number
|
|
249
|
+
outcome: 'running' | 'succeeded' | 'failed' | 'cancelled'
|
|
250
|
+
error?: string
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** The per-model override a task may carry; absent = session default model. */
|
|
254
|
+
export type TaskModel = {
|
|
255
|
+
provider: string
|
|
256
|
+
model: string
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** One task on the board. */
|
|
260
|
+
export type TaskRecord = {
|
|
261
|
+
id: string
|
|
262
|
+
title: string
|
|
263
|
+
description: string
|
|
264
|
+
/** The prompt sent to a fresh session on execution; falls back to title+description. */
|
|
265
|
+
prompt: string
|
|
266
|
+
/** Owning project: a DSH workspace id. */
|
|
267
|
+
workspaceId: string
|
|
268
|
+
urgency: Urgency
|
|
269
|
+
status: TaskStatus
|
|
270
|
+
/** Horizontal marker: work cannot continue right now (any non-terminal status). */
|
|
271
|
+
blocked: boolean
|
|
272
|
+
execution: ExecutionConfig
|
|
273
|
+
model?: TaskModel
|
|
274
|
+
version: number
|
|
275
|
+
createdAt: number
|
|
276
|
+
updatedAt: number
|
|
277
|
+
createdBy: Actor
|
|
278
|
+
updatedBy: Actor
|
|
279
|
+
comments: CommentRecord[]
|
|
280
|
+
executions: ExecutionRecord[]
|
|
281
|
+
/** Soft-delete marker set by agent `taskboard_delete`; user confirms the purge. */
|
|
282
|
+
trashedAt?: number
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** The whole durable ledger. */
|
|
286
|
+
export type TaskLedger = {
|
|
287
|
+
schemaVersion: number
|
|
288
|
+
/** Global monotonic revision; every mutation bumps it. */
|
|
289
|
+
revision: number
|
|
290
|
+
tasks: TaskRecord[]
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Current ledger format version. */
|
|
294
|
+
export const LEDGER_SCHEMA_VERSION = 1
|
|
295
|
+
|
|
296
|
+
/** An empty ledger. */
|
|
297
|
+
export function emptyLedger(): TaskLedger {
|
|
298
|
+
return { schemaVersion: LEDGER_SCHEMA_VERSION, revision: 0, tasks: [] }
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
// ids
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
/** Random base36 suffix. */
|
|
306
|
+
function suffix(): string {
|
|
307
|
+
return Math.random().toString(36).slice(2, 8)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Mint a task id. */
|
|
311
|
+
export function newTaskId(): string {
|
|
312
|
+
return `t-${Date.now().toString(36)}-${suffix()}`
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Mint a comment id. */
|
|
316
|
+
export function newCommentId(): string {
|
|
317
|
+
return `c-${Date.now().toString(36)}-${suffix()}`
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Mint an execution id. */
|
|
321
|
+
export function newExecutionId(): string {
|
|
322
|
+
return `e-${Date.now().toString(36)}-${suffix()}`
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ---------------------------------------------------------------------------
|
|
326
|
+
// validation helpers (input shaping for tools and routes)
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Validate and normalize a title: trimmed, 1..200 chars.
|
|
331
|
+
* @param raw - the raw input.
|
|
332
|
+
* @returns the normalized title.
|
|
333
|
+
* @throws when empty or too long.
|
|
334
|
+
*/
|
|
335
|
+
export function normalizeTitle(raw: string): string {
|
|
336
|
+
const t = raw.trim()
|
|
337
|
+
if (t.length === 0 || t.length > 200) {
|
|
338
|
+
throw new Error('title must be 1..200 characters')
|
|
339
|
+
}
|
|
340
|
+
return t
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Validate a task prompt: trimmed, at most 8000 chars; empty becomes ''.
|
|
345
|
+
* @param raw - the raw input.
|
|
346
|
+
*/
|
|
347
|
+
export function normalizePrompt(raw: string | undefined): string {
|
|
348
|
+
const t = (raw ?? '').trim()
|
|
349
|
+
if (t.length > 8000) throw new Error('prompt must be at most 8000 characters')
|
|
350
|
+
return t
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Validate and normalize a comment body: trimmed, 1..4000 chars.
|
|
355
|
+
* @param raw - the raw input.
|
|
356
|
+
*/
|
|
357
|
+
export function normalizeBody(raw: string): string {
|
|
358
|
+
const t = raw.trim()
|
|
359
|
+
if (t.length === 0 || t.length > 4000) {
|
|
360
|
+
throw new Error('comment body must be 1..4000 characters')
|
|
361
|
+
}
|
|
362
|
+
return t
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Validate an urgency value.
|
|
367
|
+
* @param raw - the raw input.
|
|
368
|
+
*/
|
|
369
|
+
export function asUrgency(raw: string): Urgency {
|
|
370
|
+
if (!URGENCIES.includes(raw as Urgency)) {
|
|
371
|
+
throw new Error(`urgency must be one of: ${URGENCIES.join(', ')}`)
|
|
372
|
+
}
|
|
373
|
+
return raw as Urgency
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Validate a status value.
|
|
378
|
+
* @param raw - the raw input.
|
|
379
|
+
*/
|
|
380
|
+
export function asStatus(raw: string): TaskStatus {
|
|
381
|
+
if (!ALL_STATUSES.includes(raw as TaskStatus)) {
|
|
382
|
+
throw new Error(`status must be one of: ${ALL_STATUSES.join(', ')}`)
|
|
383
|
+
}
|
|
384
|
+
return raw as TaskStatus
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Validate an execution config request from raw tool/route input.
|
|
389
|
+
* `scheduled` requires a valid cron; computes the first `nextRunAt` from
|
|
390
|
+
* `now`.
|
|
391
|
+
* @param raw - raw execution input ({@link ExecutionConfig} fields, untyped).
|
|
392
|
+
* @param now - current epoch ms.
|
|
393
|
+
* @returns the normalized config.
|
|
394
|
+
*/
|
|
395
|
+
export function normalizeExecution(
|
|
396
|
+
raw: { mode?: string; cron?: string },
|
|
397
|
+
now: number,
|
|
398
|
+
): ExecutionConfig {
|
|
399
|
+
const mode = raw.mode ?? 'claim'
|
|
400
|
+
if (mode !== 'claim' && mode !== 'scheduled') {
|
|
401
|
+
throw new Error("execution.mode must be 'claim' or 'scheduled'")
|
|
402
|
+
}
|
|
403
|
+
if (mode === 'claim') return { mode }
|
|
404
|
+
const cron = (raw.cron ?? '').trim()
|
|
405
|
+
const match = parseCron(cron)
|
|
406
|
+
if (match === null) throw new Error('execution.cron is not a valid 5-field cron expression')
|
|
407
|
+
const next = nextCronTime(match, now)
|
|
408
|
+
if (next === null) throw new Error('execution.cron never matches within 4 years')
|
|
409
|
+
return { mode, cron, nextRunAt: next }
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* The effective prompt of a task: explicit prompt, or title+description.
|
|
414
|
+
* @param task - the task.
|
|
415
|
+
*/
|
|
416
|
+
export function effectivePrompt(task: TaskRecord): string {
|
|
417
|
+
if (task.prompt.length > 0) return task.prompt
|
|
418
|
+
const head = task.title
|
|
419
|
+
return task.description.length > 0 ? `${head}\n\n${task.description}` : head
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Whether the task is currently claimed by a session (running state).
|
|
424
|
+
* @param task - the task.
|
|
425
|
+
*/
|
|
426
|
+
export function isClaimedBy(task: TaskRecord): string | undefined {
|
|
427
|
+
return task.status === 'in_progress' && task.updatedBy.kind === 'agent'
|
|
428
|
+
? task.updatedBy.sessionId
|
|
429
|
+
: undefined
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Compact list-projection of a task (token-friendly for `taskboard_list`).
|
|
434
|
+
* @param task - the task.
|
|
435
|
+
*/
|
|
436
|
+
export type TaskSummary = {
|
|
437
|
+
id: string
|
|
438
|
+
title: string
|
|
439
|
+
workspaceId: string
|
|
440
|
+
urgency: Urgency
|
|
441
|
+
status: TaskStatus
|
|
442
|
+
blocked: boolean
|
|
443
|
+
executionMode: ExecutionMode
|
|
444
|
+
nextRunAt?: number
|
|
445
|
+
model?: TaskModel
|
|
446
|
+
version: number
|
|
447
|
+
claimOwner?: string
|
|
448
|
+
commentCount: number
|
|
449
|
+
lastExecutionOutcome?: ExecutionRecord['outcome']
|
|
450
|
+
trashed: boolean
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Build the compact summary of a task.
|
|
455
|
+
* @param task - the task.
|
|
456
|
+
*/
|
|
457
|
+
export function summarize(task: TaskRecord): TaskSummary {
|
|
458
|
+
const last = task.executions.length > 0 ? task.executions[task.executions.length - 1] : undefined
|
|
459
|
+
return {
|
|
460
|
+
id: task.id,
|
|
461
|
+
title: task.title,
|
|
462
|
+
workspaceId: task.workspaceId,
|
|
463
|
+
urgency: task.urgency,
|
|
464
|
+
status: task.status,
|
|
465
|
+
blocked: task.blocked,
|
|
466
|
+
executionMode: task.execution.mode,
|
|
467
|
+
nextRunAt: task.execution.nextRunAt,
|
|
468
|
+
model: task.model,
|
|
469
|
+
version: task.version,
|
|
470
|
+
claimOwner: isClaimedBy(task),
|
|
471
|
+
commentCount: task.comments.length,
|
|
472
|
+
lastExecutionOutcome: last?.outcome,
|
|
473
|
+
trashed: task.trashedAt !== undefined,
|
|
474
|
+
}
|
|
475
|
+
}
|