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.
Files changed (49) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +169 -0
  3. package/cordis.patch.yml +12 -0
  4. package/lib/client.js +2085 -0
  5. package/lib/host/execution.js +189 -0
  6. package/lib/host/execution.js.map +1 -0
  7. package/lib/host/protocol-text.js +37 -0
  8. package/lib/host/protocol-text.js.map +1 -0
  9. package/lib/host/routes.js +369 -0
  10. package/lib/host/routes.js.map +1 -0
  11. package/lib/host/scheduler.js +91 -0
  12. package/lib/host/scheduler.js.map +1 -0
  13. package/lib/host/sdk.js +145 -0
  14. package/lib/host/sdk.js.map +1 -0
  15. package/lib/host/store.js +112 -0
  16. package/lib/host/store.js.map +1 -0
  17. package/lib/host/tools.js +620 -0
  18. package/lib/host/tools.js.map +1 -0
  19. package/lib/index.js +91 -0
  20. package/lib/index.js.map +1 -0
  21. package/lib/invariant.js +22 -0
  22. package/lib/invariant.js.map +1 -0
  23. package/lib/shared/api.js +9 -0
  24. package/lib/shared/api.js.map +1 -0
  25. package/lib/shared/protocol.js +279 -0
  26. package/lib/shared/protocol.js.map +1 -0
  27. package/package.json +74 -0
  28. package/src/client/api.ts +90 -0
  29. package/src/client/board/NewTaskModal.tsx +8 -0
  30. package/src/client/board/TaskBoard.tsx +184 -0
  31. package/src/client/board/TaskCard.tsx +61 -0
  32. package/src/client/board/TaskDetail.tsx +210 -0
  33. package/src/client/board/TaskFormModal.tsx +257 -0
  34. package/src/client/board-mount.tsx +92 -0
  35. package/src/client/controller.ts +241 -0
  36. package/src/client/index.ts +87 -0
  37. package/src/client/sidebar-entry.ts +165 -0
  38. package/src/client/styles.ts +391 -0
  39. package/src/host/execution.ts +244 -0
  40. package/src/host/protocol-text.ts +37 -0
  41. package/src/host/routes.ts +387 -0
  42. package/src/host/scheduler.ts +107 -0
  43. package/src/host/sdk.ts +200 -0
  44. package/src/host/store.ts +139 -0
  45. package/src/host/tools.ts +631 -0
  46. package/src/index.ts +124 -0
  47. package/src/invariant.ts +22 -0
  48. package/src/shared/api.ts +98 -0
  49. 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
+ }