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,107 @@
1
+ /**
2
+ * Host-side cron scheduler: one tick per minute over the ledger's scheduled
3
+ * tasks. A due task (nextRunAt reached, not running, not trashed) first has
4
+ * its next run advanced to the next cron match — then it executes through
5
+ * the same path as the manual button. Missed windows (host was down, tab
6
+ * closed — irrelevant here, this is the host process) simply advance: a
7
+ * nextRunAt more than one window in the past is skipped, not caught up.
8
+ *
9
+ * @module dsh-taskboard/host/scheduler
10
+ */
11
+ import { nextCronTime, parseCron, type TaskLedger } from '../shared/protocol.ts'
12
+ import type { ExecutionService } from './execution.ts'
13
+ import type { TaskStore } from './store.ts'
14
+
15
+ /** Tick cadence. */
16
+ const TICK_MS = 60_000
17
+
18
+ /** A due window older than this is skipped (missed while the host was down). */
19
+ const SKIP_AFTER_MS = 5 * 60_000
20
+
21
+ /** Everything the scheduler needs. */
22
+ export interface SchedulerDeps {
23
+ store: TaskStore
24
+ execution: Pick<ExecutionService, 'run'>
25
+ now: () => number
26
+ /** Timer face (injectable for tests). */
27
+ timers?: {
28
+ setInterval(fn: () => void, ms: number): unknown
29
+ clearInterval(handle: unknown): void
30
+ }
31
+ }
32
+
33
+ /**
34
+ * The cron scheduler.
35
+ */
36
+ export class SchedulerService {
37
+ private handle: unknown
38
+
39
+ /** @param deps - store + execution + clock. */
40
+ constructor(private readonly deps: SchedulerDeps) {}
41
+
42
+ /** Start ticking. */
43
+ start(): void {
44
+ const timers = this.deps.timers ?? {
45
+ setInterval: (fn: () => void, ms: number) => setInterval(fn, ms),
46
+ clearInterval: (handle: unknown) => clearInterval(handle as ReturnType<typeof setInterval>),
47
+ }
48
+ this.handle = timers.setInterval(() => { void this.tick() }, TICK_MS)
49
+ // Catch up promptly on host restart: run one tick soon after start.
50
+ setTimeout(() => { void this.tick() }, 3_000)
51
+ }
52
+
53
+ /** Stop ticking. */
54
+ dispose(): void {
55
+ if (this.handle === undefined) return
56
+ const timers = this.deps.timers ?? { clearInterval: (h: unknown) => clearInterval(h as ReturnType<typeof setInterval>) }
57
+ timers.clearInterval(this.handle)
58
+ this.handle = undefined
59
+ }
60
+
61
+ /** One scheduler pass (exported for tests). */
62
+ async tick(): Promise<void> {
63
+ const now = this.deps.now()
64
+ const ledger: TaskLedger = this.deps.store.snapshot()
65
+ for (const task of ledger.tasks) {
66
+ if (task.execution.mode !== 'scheduled' || task.execution.cron === undefined) continue
67
+ if (task.execution.nextRunAt === undefined) continue
68
+ if (task.status === 'in_progress' || task.trashedAt !== undefined) continue
69
+ if (task.execution.nextRunAt > now) continue
70
+ const missed = now - task.execution.nextRunAt > SKIP_AFTER_MS
71
+
72
+ // Advance the schedule FIRST (idempotent under re-ticks), then run
73
+ // unless the window was missed entirely.
74
+ await this.advance(task.id, now)
75
+ if (missed) continue
76
+ const lastTriggeredAt = task.execution.nextRunAt
77
+ await this.markTriggered(task.id, lastTriggeredAt)
78
+ await this.deps.execution.run(task.id, 'scheduled').catch(error => {
79
+ console.error('[dsh-taskboard] scheduled run failed:', error)
80
+ })
81
+ }
82
+ }
83
+
84
+ /** Recompute and persist the next run for one scheduled task. */
85
+ private async advance(taskId: string, now: number): Promise<void> {
86
+ await this.deps.store.mutate('task-updated', (ledger) => {
87
+ const task = ledger.tasks.find(t => t.id === taskId)
88
+ if (task === undefined || task.execution.cron === undefined) return undefined
89
+ const match = parseCron(task.execution.cron)
90
+ const next = match === null ? undefined : nextCronTime(match, now) ?? undefined
91
+ if (next === undefined) return undefined
92
+ task.execution.nextRunAt = next
93
+ return [task]
94
+ })
95
+ }
96
+
97
+ /** Record the trigger instant on the task. */
98
+ private async markTriggered(taskId: string, at: number | undefined): Promise<void> {
99
+ if (at === undefined) return
100
+ await this.deps.store.mutate('task-updated', (ledger) => {
101
+ const task = ledger.tasks.find(t => t.id === taskId)
102
+ if (task === undefined) return undefined
103
+ task.execution.lastTriggeredAt = at
104
+ return [task]
105
+ })
106
+ }
107
+ }
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Self-contained replacements for the three @deepseek-ai runtime imports the
3
+ * host half used to take from npm-mirror SDK packages (dsh-home-paths,
4
+ * dsh-llm/brand, dsh-tools' defineTool).
5
+ *
6
+ * Why: a published copy must never resolve `@deepseek-ai/dsh-tools` from the
7
+ * profile's node_modules — an npm-mirror dsh-tools there shadows the
8
+ * CLI-internal build for the WHOLE base layer, and the agent loop's private
9
+ * scheduler symbol then misses (`Cannot read properties of undefined
10
+ * (reading 'prepare')` on every tool call). Everything here is a pure,
11
+ * structure-compatible reimplementation of the exact behavior we relied on:
12
+ *
13
+ * - `dshHomePath` mirrors `join(resolve(env.DSH_HOME ?? ~/.dsh), ...segments)`;
14
+ * - `MessageId` is the identity brand the SDK applies at runtime;
15
+ * - `defineTool` compiles our author-facing parameter specs into the same
16
+ * raw JSON-Schema subset the registry expects (object/properties/required/
17
+ * additionalProperties/scalars; the `json` node compiles to an
18
+ * annotation-only schema) and pre-validates model arguments the same way.
19
+ *
20
+ * @module dsh-taskboard/host/sdk
21
+ */
22
+ import { homedir } from 'node:os'
23
+ import { join, resolve } from 'node:path'
24
+
25
+ /** The ledger file's parent: the DSH user home (DSH_HOME overrides). */
26
+ export function dshHomePath(...segments: string[]): string {
27
+ const override = process.env.DSH_HOME
28
+ const home = resolve(override !== undefined && override.length > 0 ? override : join(homedir(), '.dsh'))
29
+ return join(home, ...segments)
30
+ }
31
+
32
+ /** Identity brand — runtime no-op, exactly like the SDK's MessageId(). */
33
+ export function MessageId(id: string): string {
34
+ return id
35
+ }
36
+
37
+ /** Author-facing scalar spec. */
38
+ interface ScalarSpec {
39
+ readonly type: 'string' | 'number' | 'integer' | 'boolean' | 'null'
40
+ readonly description?: string
41
+ readonly enum?: readonly unknown[]
42
+ readonly const?: unknown
43
+ }
44
+
45
+ /** Author-facing object spec (additionalProperties is mandatory). */
46
+ interface ObjectSpec {
47
+ readonly type: 'object'
48
+ readonly additionalProperties: boolean
49
+ readonly description?: string
50
+ readonly properties?: Readonly<Record<string, ValueSpec>>
51
+ }
52
+
53
+ /** Author-facing value spec. */
54
+ type ValueSpec = ScalarSpec | ObjectSpec | { readonly type: 'json' } | { readonly type: 'array'; readonly items?: ValueSpec; readonly description?: string }
55
+
56
+ /** Author-facing parameter entry (a value spec plus top-level required). */
57
+ type ParameterSpec = ValueSpec & { readonly required?: boolean }
58
+
59
+ /** Raw JSON-Schema subset node. */
60
+ type RawSchema = Record<string, unknown>
61
+
62
+ /** Compile one value spec to the raw subset (json → annotation-only). */
63
+ function compileValue(spec: ValueSpec): RawSchema {
64
+ const node: RawSchema = {}
65
+ const description = (spec as { description?: string }).description
66
+ if (typeof description === 'string' && description.length > 0) node.description = description
67
+ const type = (spec as { type?: string }).type
68
+ if (type === undefined || type === 'json') return node
69
+ if (type === 'object') {
70
+ const objectSpec = spec as ObjectSpec
71
+ node.type = 'object'
72
+ node.additionalProperties = objectSpec.additionalProperties
73
+ if (objectSpec.properties !== undefined) node.properties = compilePropertyMap(objectSpec.properties).properties
74
+ return node
75
+ }
76
+ if (type === 'array') {
77
+ node.type = 'array'
78
+ const items = (spec as { items?: ValueSpec }).items
79
+ if (items !== undefined) node.items = compileValue(items)
80
+ return node
81
+ }
82
+ node.type = type
83
+ const enumValues = (spec as ScalarSpec).enum
84
+ if (enumValues !== undefined) node.enum = [...enumValues]
85
+ const constValue = (spec as ScalarSpec).const
86
+ if (constValue !== undefined) node.const = constValue
87
+ return node
88
+ }
89
+
90
+ /** Compile a property map: properties + collected required list. */
91
+ function compilePropertyMap(spec: Readonly<Record<string, ParameterSpec>>): { properties: Record<string, RawSchema>; required?: string[] } {
92
+ const properties: Record<string, RawSchema> = {}
93
+ const required: string[] = []
94
+ for (const [name, entry] of Object.entries(spec)) {
95
+ const { required: isRequired, ...valueSpec } = entry as ParameterSpec & Record<string, unknown>
96
+ properties[name] = compileValue(valueSpec as ValueSpec)
97
+ if (isRequired === true) required.push(name)
98
+ }
99
+ return required.length > 0 ? { properties, required } : { properties }
100
+ }
101
+
102
+ /** Does a JS value match a raw-subset scalar type? */
103
+ function matchesScalarType(value: unknown, type: string): boolean {
104
+ switch (type) {
105
+ case 'string': return typeof value === 'string'
106
+ case 'number': return typeof value === 'number'
107
+ case 'integer': return typeof value === 'number' && Number.isInteger(value)
108
+ case 'boolean': return typeof value === 'boolean'
109
+ case 'null': return value === null
110
+ default: return true
111
+ }
112
+ }
113
+
114
+ /** Validate a value against the compiled subset; returns path-qualified violations. */
115
+ function validateValue(schema: RawSchema, value: unknown, path: string): string[] {
116
+ if (typeof schema.type !== 'string' || schema.type.length === 0) return []
117
+ if (schema.type === 'object') {
118
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return [`${path} must be an object`]
119
+ const violations: string[] = []
120
+ const present = value as Record<string, unknown>
121
+ for (const key of (schema.required as string[] | undefined) ?? []) {
122
+ if (!(key in present)) violations.push(`${path}.${key} is required`)
123
+ }
124
+ if (schema.additionalProperties === false) {
125
+ const known = new Set(Object.keys((schema.properties as Record<string, RawSchema> | undefined) ?? {}))
126
+ for (const key of Object.keys(present)) {
127
+ if (!known.has(key)) violations.push(`${path}.${key} is not a declared property`)
128
+ }
129
+ }
130
+ for (const [key, child] of Object.entries((schema.properties as Record<string, RawSchema> | undefined) ?? {})) {
131
+ if (key in present) violations.push(...validateValue(child, present[key], `${path}.${key}`))
132
+ }
133
+ return violations
134
+ }
135
+ if (schema.type === 'array') {
136
+ if (!Array.isArray(value)) return [`${path} must be an array`]
137
+ const violations: string[] = []
138
+ const items = schema.items as RawSchema | undefined
139
+ if (items !== undefined) {
140
+ value.forEach((item, index) => { violations.push(...validateValue(items, item, `${path}[${index}]`)) })
141
+ }
142
+ return violations
143
+ }
144
+ return matchesScalarType(value, schema.type) ? [] : [`${path} must be ${schema.type}`]
145
+ }
146
+
147
+ /** Options shape we consume (a structural subset of the SDK's defineTool). */
148
+ export interface DefineToolOptions<A, V> {
149
+ readonly name: string
150
+ readonly description: string
151
+ readonly parameters: Readonly<Record<string, ParameterSpec>>
152
+ readonly output: {
153
+ readonly schema: { readonly type: 'json' }
154
+ render(args: A, value: V): Array<{ type: 'text'; text: string }>
155
+ }
156
+ execute(args: A, exec: unknown): Promise<V>
157
+ }
158
+
159
+ /** A registry-ready tool definition (structure-compatible with the SDK's). */
160
+ export interface ToolDefinition<A = unknown, V = unknown> {
161
+ readonly name: string
162
+ readonly description: string
163
+ readonly parameters: RawSchema
164
+ readonly output: {
165
+ readonly schema: RawSchema
166
+ render(args: A, value: V): Array<{ type: 'text'; text: string }>
167
+ }
168
+ execute(args: A, exec: unknown): Promise<V>
169
+ }
170
+
171
+ /**
172
+ * Define a first-party tool: compile the parameter spec, pre-validate
173
+ * arguments (message format matches the SDK's ToolArgsError), and pass
174
+ * through the execution.
175
+ */
176
+ export function defineTool<A extends Record<string, unknown>, V>(options: DefineToolOptions<A, V>): ToolDefinition<A, V> {
177
+ const compiled = compilePropertyMap(options.parameters as Readonly<Record<string, ParameterSpec>>)
178
+ const parameters: RawSchema = { type: 'object', properties: compiled.properties }
179
+ if (compiled.required !== undefined) parameters.required = compiled.required
180
+ const userExecute = options.execute
181
+ return {
182
+ name: options.name,
183
+ description: options.description,
184
+ parameters,
185
+ output: {
186
+ // The SDK compiles the `json` node to an annotation-only schema.
187
+ schema: {},
188
+ render(args, value) {
189
+ return options.output.render(args, value)
190
+ },
191
+ },
192
+ async execute(args, exec) {
193
+ const violations = validateValue(parameters, args, 'arguments')
194
+ if (violations.length > 0) {
195
+ throw new Error(`Error: invalid arguments: ${violations.join('; ')}`)
196
+ }
197
+ return userExecute(args, exec)
198
+ },
199
+ }
200
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Host-side task ledger: one JSON file under the DSH home, mutated through a
3
+ * serial write queue, published as immutable snapshots with a global
4
+ * monotonic revision. Change subscribers (P2: SSE route) observe every
5
+ * committed mutation.
6
+ *
7
+ * @module dsh-taskboard/host/store
8
+ */
9
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
10
+ import { dirname, join } from 'node:path'
11
+ import {
12
+ LEDGER_SCHEMA_VERSION,
13
+ emptyLedger,
14
+ type TaskLedger,
15
+ type TaskRecord,
16
+ } from '../shared/protocol.ts'
17
+
18
+ /** One committed ledger mutation, handed to change subscribers. */
19
+ export interface LedgerChange {
20
+ /** Revision after the mutation. */
21
+ revision: number
22
+ /** The mutated tasks, if any (a comment purge may touch none). */
23
+ tasks: readonly TaskRecord[]
24
+ /** What kind of mutation this was (for SSE event naming later). */
25
+ kind: 'task-created' | 'task-updated' | 'task-moved' | 'task-deleted' | 'comment-added' | 'execution-recorded'
26
+ }
27
+
28
+ /** Options for {@link TaskStore}. */
29
+ export interface TaskStoreOptions {
30
+ /** Absolute ledger file path. */
31
+ file: string
32
+ }
33
+
34
+ /**
35
+ * The durable ledger. All mutations run through {@link mutate}, which:
36
+ * validates the resulting document, bumps the global revision, persists
37
+ * atomically (temp file + rename), and only then notifies subscribers.
38
+ */
39
+ export class TaskStore {
40
+ private readonly file: string
41
+ private ledger: TaskLedger = emptyLedger()
42
+ private readonly subscribers = new Set<(change: LedgerChange) => void>()
43
+ private queue: Promise<unknown> = Promise.resolve()
44
+ private loaded = false
45
+
46
+ /** @param options - file location. */
47
+ constructor(options: TaskStoreOptions) {
48
+ this.file = options.file
49
+ }
50
+
51
+ /** Load (once) from disk; a missing file starts empty; a corrupt file is quarantined, not thrown. */
52
+ async load(): Promise<void> {
53
+ if (this.loaded) return
54
+ try {
55
+ const raw = await readFile(this.file, 'utf8')
56
+ const parsed = JSON.parse(raw) as TaskLedger
57
+ if (typeof parsed.revision === 'number' && Array.isArray(parsed.tasks)) {
58
+ this.ledger = { schemaVersion: LEDGER_SCHEMA_VERSION, revision: parsed.revision, tasks: parsed.tasks }
59
+ }
60
+ } catch (error) {
61
+ const code = (error as NodeJS.ErrnoException).code
62
+ if (code !== 'ENOENT') {
63
+ // Quarantine a corrupt ledger: rename it aside, start fresh. Never
64
+ // take the host down over ledger damage.
65
+ try {
66
+ await rename(this.file, `${this.file}.corrupt-${Date.now()}`)
67
+ } catch { /* best effort */ }
68
+ }
69
+ }
70
+ this.loaded = true
71
+ }
72
+
73
+ /** The current immutable snapshot. */
74
+ snapshot(): TaskLedger {
75
+ return this.ledger
76
+ }
77
+
78
+ /** Find a task by id. */
79
+ get(id: string): TaskRecord | undefined {
80
+ return this.ledger.tasks.find(t => t.id === id)
81
+ }
82
+
83
+ /** Subscribe to committed changes; returns the unsubscribe. */
84
+ subscribe(fn: (change: LedgerChange) => void): () => void {
85
+ this.subscribers.add(fn)
86
+ return () => this.subscribers.delete(fn)
87
+ }
88
+
89
+ /**
90
+ * Run one mutation inside the serial queue. The mutator works on a
91
+ * structured clone; returning `undefined` aborts with no write.
92
+ * @param kind - change kind for subscribers.
93
+ * @param mutator - receives the cloned ledger; mutate tasks in place; return the touched tasks.
94
+ */
95
+ async mutate(
96
+ kind: LedgerChange['kind'],
97
+ mutator: (ledger: TaskLedger) => TaskRecord[] | undefined,
98
+ ): Promise<{ ledger: TaskLedger; changed: readonly TaskRecord[] }> {
99
+ const run = async (): Promise<{ ledger: TaskLedger; changed: readonly TaskRecord[] }> => {
100
+ await this.load()
101
+ const draft: TaskLedger = structuredClone(this.ledger)
102
+ const changed = mutator(draft)
103
+ if (changed === undefined) {
104
+ return { ledger: this.ledger, changed: [] }
105
+ }
106
+ draft.revision += 1
107
+ const json = JSON.stringify(draft)
108
+ await persistAtomic(this.file, json)
109
+ this.ledger = draft
110
+ const change: LedgerChange = { revision: draft.revision, tasks: changed, kind }
111
+ for (const fn of this.subscribers) {
112
+ try {
113
+ fn(change)
114
+ } catch { /* subscriber errors never abort the write */ }
115
+ }
116
+ return { ledger: draft, changed }
117
+ }
118
+ const result = (this.queue = this.queue.then(run, run)) as ReturnType<typeof run>
119
+ return result
120
+ }
121
+
122
+ /** Persist the current ledger now (used after external reconciliation). */
123
+ async flush(kind: LedgerChange['kind'], changed: readonly TaskRecord[]): Promise<void> {
124
+ await this.mutate(kind, (ledger) => {
125
+ // replace tasks wholesale from the live snapshot objects
126
+ const byId = new Map(this.ledger.tasks.map(t => [t.id, t]))
127
+ ledger.tasks = ledger.tasks.map(t => byId.get(t.id) ?? t)
128
+ return [...changed]
129
+ })
130
+ }
131
+ }
132
+
133
+ /** Atomic file persist: write temp, then rename over the target. */
134
+ async function persistAtomic(file: string, contents: string): Promise<void> {
135
+ await mkdir(dirname(file), { recursive: true })
136
+ const temp = join(dirname(file), `.${Math.random().toString(36).slice(2)}.tmp`)
137
+ await writeFile(temp, contents, 'utf8')
138
+ await rename(temp, file)
139
+ }