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,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host execution service: runs a task through dsh's REAL session machinery —
|
|
3
|
+
* a fresh agent+session inside the task's project workspace (creation carries
|
|
4
|
+
* the pinned model when the task has one), the session is attached to the
|
|
5
|
+
* workspace so it appears in the GUI's project session list, the effective
|
|
6
|
+
* prompt is submitted as an ordinary user message, and the turn settlement
|
|
7
|
+
* (turn/end reason) is folded back into the task's execution record.
|
|
8
|
+
*
|
|
9
|
+
* Every execution is a NEW session: clean context, no reuse of previous runs.
|
|
10
|
+
*
|
|
11
|
+
* @module dsh-taskboard/host/execution
|
|
12
|
+
*/
|
|
13
|
+
import { effectivePrompt, newExecutionId, type ExecutionRecord, type TaskRecord } from '../shared/protocol.ts'
|
|
14
|
+
import { MessageId } from './sdk.ts'
|
|
15
|
+
import type { TaskStore } from './store.ts'
|
|
16
|
+
|
|
17
|
+
/** Narrow agents face (the registry's create, structurally). */
|
|
18
|
+
export interface AgentsFace {
|
|
19
|
+
create(options: {
|
|
20
|
+
sessionId: string
|
|
21
|
+
meta?: { cwd?: string }
|
|
22
|
+
agentOptions?: { provider?: string; model?: string }
|
|
23
|
+
}): Promise<{
|
|
24
|
+
agent: {
|
|
25
|
+
id: string
|
|
26
|
+
followup(message: unknown): void
|
|
27
|
+
whenIdle(): Promise<void>
|
|
28
|
+
}
|
|
29
|
+
dispose(): Promise<void>
|
|
30
|
+
}>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Narrow workspaces face for execution. */
|
|
34
|
+
export interface ExecutionWorkspaceFace {
|
|
35
|
+
get(id: string): { id: string; path: string } | undefined
|
|
36
|
+
attach(workspaceId: string, sessionId: string): Promise<void>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Narrow event-bus face for settlement listening. */
|
|
40
|
+
export interface EventsFace {
|
|
41
|
+
onSessionEvent(listener: (sessionId: string, event: { type: string; data?: unknown }) => void): () => void
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Everything the execution service needs. */
|
|
45
|
+
export interface ExecutionDeps {
|
|
46
|
+
store: TaskStore
|
|
47
|
+
agents: AgentsFace
|
|
48
|
+
workspaces: ExecutionWorkspaceFace
|
|
49
|
+
events: EventsFace
|
|
50
|
+
now: () => number
|
|
51
|
+
/** The deployment default model (fills sessions of unpinned tasks). */
|
|
52
|
+
defaultModel?: () => { provider: string; model: string } | undefined
|
|
53
|
+
/** Mint session ids (injectable for tests). */
|
|
54
|
+
mintSessionId?: () => string
|
|
55
|
+
/** Mint message ids (injectable for tests). */
|
|
56
|
+
mintMessageId?: () => string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Outcome of a run request (immediate; the run settles asynchronously). */
|
|
60
|
+
export type RunRequestResult =
|
|
61
|
+
| { ok: true; executionId: string; sessionId: string }
|
|
62
|
+
| { ok: false; error: string }
|
|
63
|
+
|
|
64
|
+
/** Whether a turn/end payload closed with an error reason. */
|
|
65
|
+
function isErrorTurnEnd(data: unknown): { message: string } | undefined {
|
|
66
|
+
if (typeof data !== 'object' || data === null) return undefined
|
|
67
|
+
const reason = (data as { reason?: unknown }).reason
|
|
68
|
+
if (typeof reason !== 'object' || reason === null) return undefined
|
|
69
|
+
const kind = (reason as { kind?: unknown }).kind
|
|
70
|
+
if (kind !== 'error') return undefined
|
|
71
|
+
const error = (reason as { error?: { message?: unknown } }).error
|
|
72
|
+
const detail = JSON.stringify(error) ?? ''
|
|
73
|
+
const message = typeof error?.message === 'string' ? error.message : 'turn failed'
|
|
74
|
+
console.error('[dsh-taskboard] turn error detail:', detail.slice(0, 2000))
|
|
75
|
+
void detail
|
|
76
|
+
return { message }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The execution service.
|
|
81
|
+
*/
|
|
82
|
+
export class ExecutionService {
|
|
83
|
+
/** Execution ids currently settling. */
|
|
84
|
+
private readonly settling = new Map<string, () => void>()
|
|
85
|
+
|
|
86
|
+
/** @param deps - store + agents + workspaces + events + clock. */
|
|
87
|
+
constructor(private readonly deps: ExecutionDeps) {
|
|
88
|
+
deps.events.onSessionEvent((sessionId, event) => {
|
|
89
|
+
if (event.type !== 'turn/end') return
|
|
90
|
+
const failure = isErrorTurnEnd(event.data)
|
|
91
|
+
if (failure !== undefined) this.noteFailure(sessionId, failure.message)
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Record a turn failure against the running execution of that session. */
|
|
96
|
+
private noteFailure(sessionId: string, message: string): void {
|
|
97
|
+
void this.deps.store.mutate('execution-recorded', (ledger) => {
|
|
98
|
+
for (const task of ledger.tasks) {
|
|
99
|
+
for (const execution of task.executions) {
|
|
100
|
+
if (execution.sessionId === sessionId && execution.outcome === 'running') {
|
|
101
|
+
execution.outcome = 'failed'
|
|
102
|
+
execution.error = message.slice(0, 500)
|
|
103
|
+
execution.endedAt = this.deps.now()
|
|
104
|
+
return [task]
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return undefined
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Patch one task's execution record in the ledger. */
|
|
113
|
+
private async patchExecution(executionId: string, patch: Partial<ExecutionRecord>): Promise<void> {
|
|
114
|
+
await this.deps.store.mutate('execution-recorded', (ledger) => {
|
|
115
|
+
for (const task of ledger.tasks) {
|
|
116
|
+
const execution = task.executions.find(e => e.id === executionId)
|
|
117
|
+
if (execution !== undefined) {
|
|
118
|
+
Object.assign(execution, patch)
|
|
119
|
+
return [task]
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return undefined
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Run one task now (manual button or scheduler tick).
|
|
128
|
+
* @param taskId - the task to run.
|
|
129
|
+
* @param trigger - what started it.
|
|
130
|
+
* @returns the immediate result; settlement lands in the ledger.
|
|
131
|
+
*/
|
|
132
|
+
async run(taskId: string, trigger: ExecutionRecord['trigger']): Promise<RunRequestResult> {
|
|
133
|
+
const task = this.deps.store.get(taskId)
|
|
134
|
+
if (task === undefined || task.trashedAt !== undefined) {
|
|
135
|
+
return { ok: false, error: `no task ${taskId}` }
|
|
136
|
+
}
|
|
137
|
+
if (task.status === 'in_progress') {
|
|
138
|
+
return { ok: false, error: 'task is already in progress' }
|
|
139
|
+
}
|
|
140
|
+
const workspace = this.deps.workspaces.get(task.workspaceId)
|
|
141
|
+
if (workspace === undefined) {
|
|
142
|
+
return { ok: false, error: `unknown workspace ${task.workspaceId}` }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const executionId = newExecutionId()
|
|
146
|
+
const sessionId = this.deps.mintSessionId?.() ?? `session-taskboard-${crypto.randomUUID()}`
|
|
147
|
+
|
|
148
|
+
// 1. Open the execution record and move the card to in_progress in one write.
|
|
149
|
+
await this.deps.store.mutate('execution-recorded', (ledger) => {
|
|
150
|
+
const target = ledger.tasks.find(t => t.id === taskId)
|
|
151
|
+
if (target === undefined) return undefined
|
|
152
|
+
target.executions.push({
|
|
153
|
+
id: executionId,
|
|
154
|
+
trigger,
|
|
155
|
+
startedAt: this.deps.now(),
|
|
156
|
+
outcome: 'running',
|
|
157
|
+
})
|
|
158
|
+
target.status = 'in_progress'
|
|
159
|
+
target.updatedAt = this.deps.now()
|
|
160
|
+
target.updatedBy = { kind: 'user' }
|
|
161
|
+
return [target]
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
// 2. Create the fresh agent+session inside the task's project, carrying
|
|
165
|
+
// the pinned model — or the deployment default when unpinned (the
|
|
166
|
+
// persona template renders {{model}}, so the session always needs one).
|
|
167
|
+
let handle: Awaited<ReturnType<AgentsFace['create']>>
|
|
168
|
+
try {
|
|
169
|
+
const model = task.model ?? this.deps.defaultModel?.()
|
|
170
|
+
handle = await this.deps.agents.create({
|
|
171
|
+
sessionId,
|
|
172
|
+
meta: { cwd: workspace.path },
|
|
173
|
+
...(model !== undefined ? { agentOptions: { provider: model.provider, model: model.model } } : {}),
|
|
174
|
+
})
|
|
175
|
+
} catch (error) {
|
|
176
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
177
|
+
await this.patchExecution(executionId, { outcome: 'failed', error: message.slice(0, 500), endedAt: this.deps.now() })
|
|
178
|
+
await this.revertProgress(taskId)
|
|
179
|
+
return { ok: false, error: message }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// 3. Attach the session to the workspace (GUI project session list).
|
|
183
|
+
await this.deps.workspaces.attach(task.workspaceId, sessionId).catch(() => { /* cosmetic */ })
|
|
184
|
+
|
|
185
|
+
// 4. Record the session id (execution is really started now).
|
|
186
|
+
await this.patchExecution(executionId, { sessionId })
|
|
187
|
+
|
|
188
|
+
// 5. Submit the effective prompt as an ordinary user message and settle
|
|
189
|
+
// on quiescence (turn/end errors were already folded by the listener).
|
|
190
|
+
const message = {
|
|
191
|
+
id: this.deps.mintMessageId?.() ?? MessageId(`msg-taskboard-${crypto.randomUUID()}`),
|
|
192
|
+
role: 'user' as const,
|
|
193
|
+
content: [{ type: 'text' as const, text: this.executionPrompt(task) }],
|
|
194
|
+
source: { kind: 'plugin' as const, plugin: 'dsh-taskboard' },
|
|
195
|
+
}
|
|
196
|
+
handle.agent.followup(message)
|
|
197
|
+
|
|
198
|
+
// 6. Settlement watcher.
|
|
199
|
+
const settle = (): void => {
|
|
200
|
+
this.settling.delete(executionId)
|
|
201
|
+
void this.deps.store.mutate('execution-recorded', (ledger) => {
|
|
202
|
+
for (const t of ledger.tasks) {
|
|
203
|
+
const execution = t.executions.find(e => e.id === executionId)
|
|
204
|
+
if (execution !== undefined && execution.outcome === 'running') {
|
|
205
|
+
execution.outcome = 'succeeded'
|
|
206
|
+
execution.endedAt = this.deps.now()
|
|
207
|
+
return [t]
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return undefined
|
|
211
|
+
})
|
|
212
|
+
}
|
|
213
|
+
this.settling.set(executionId, settle)
|
|
214
|
+
void handle.agent.whenIdle().then(settle, () => {
|
|
215
|
+
this.noteFailure(sessionId, 'agent did not reach quiescence')
|
|
216
|
+
settle()
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
return { ok: true, executionId, sessionId }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** The prompt text one execution submits (task context + instructions). */
|
|
223
|
+
private executionPrompt(task: TaskRecord): string {
|
|
224
|
+
const head = `【任务看板执行】${task.title}(任务 ID: ${task.id})`
|
|
225
|
+
const state = '本任务由执行服务启动本会话并已置为 in_progress(你无需再认领,也无需移到 done)。'
|
|
226
|
+
const tail = `完成后请:1) 用 taskboard_get 读取任务 ${task.id} 拿最新 version;`
|
|
227
|
+
+ `2) 用 taskboard_comment_add 留评论(做了什么改动、如何验证、剩余风险);`
|
|
228
|
+
+ `3) 用 taskboard_move 把任务 ${task.id} 移到 in_review(带 ifVersion)。`
|
|
229
|
+
return `${head}\n\n${state}\n\n${effectivePrompt(task)}\n\n${tail}`
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Move a task back out of in_progress after a failed start. */
|
|
233
|
+
private async revertProgress(taskId: string): Promise<void> {
|
|
234
|
+
await this.deps.store.mutate('execution-recorded', (ledger) => {
|
|
235
|
+
const target = ledger.tasks.find(t => t.id === taskId)
|
|
236
|
+
if (target !== undefined && target.status === 'in_progress') {
|
|
237
|
+
target.status = 'todo'
|
|
238
|
+
target.updatedAt = this.deps.now()
|
|
239
|
+
return [target]
|
|
240
|
+
}
|
|
241
|
+
return undefined
|
|
242
|
+
})
|
|
243
|
+
}
|
|
244
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The agent workflow protocol text — the single source the system-prompt
|
|
3
|
+
* section serves. This is a behavioral contract, not a feature ad: claiming
|
|
4
|
+
* discipline, optimistic-version retry rules, review handoff, and the
|
|
5
|
+
* user-only completion gate.
|
|
6
|
+
*
|
|
7
|
+
* The regression test (tests/protocol.spec.ts) locks the discipline
|
|
8
|
+
* sentences, so editing the text without revisiting the test fails loud.
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-taskboard/host/protocol-text
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** The protocol section served to every agent (Chinese UI deployment). */
|
|
14
|
+
export const TASKBOARD_PROTOCOL = [
|
|
15
|
+
'本机已安装 dsh-taskboard 插件(DSH 任务看板):任务挂在项目(DSH workspace)上,',
|
|
16
|
+
'用 taskboard_* 工具读写;人在 Web GUI 看板上实时看到同样数据。能力:查板(list/get)、',
|
|
17
|
+
'建卡(create)、改卡(update)、移卡(move)、评论(comment_add/comments)、删除(delete=仅标记)。',
|
|
18
|
+
'任务带紧急度(urgent红/normal紫/relaxed蓝)、执行方式(claim认领/scheduled定时)与可选指定模型。',
|
|
19
|
+
'工作纪律:',
|
|
20
|
+
'1. 开工先查板:开始工作前先 taskboard_list(按本项目过滤、status=todo),有可认领任务时按纪律认领。',
|
|
21
|
+
'2. 先读后动:动卡前先 taskboard_get 并读评论;评论视为最新需求,若要求等待/暂缓,停下汇报,不改状态。',
|
|
22
|
+
'3. 先认领再干活:把 todo→in_progress(带 ifVersion)成功后,才开始读代码/分析实现;',
|
|
23
|
+
' 认领失败(版本冲突/项目边界不符/已被他人持有)就停止并报告,绝不循环重试或接管他人任务。',
|
|
24
|
+
'4. 版本冲突只重试一次:ifVersion 冲突时重新读卡,仅当状态仍可认领且需求未变时用新版本号重试一次,再失败即停止报告。',
|
|
25
|
+
'5. 验收交接:实现并自验后,评论记录(改动/验证结果/剩余风险),再把 in_progress→in_review。',
|
|
26
|
+
'6. 完成须用户确认:你永远不能把任务移到 done——那是用户的确认动作;blocked=无法继续,canceled=不再继续。',
|
|
27
|
+
'7. backlog=未授权:backlog 任务不算批准执行,被指派也不是授权,除非用户明确要求。',
|
|
28
|
+
'8. 模型与定时只读:任务的 model 与 execution 配置归创建者/用户所有,update 工具不允许你修改这两个字段。',
|
|
29
|
+
'项目边界:只有属于任务所在项目的会话才能认领(todo→in_progress)或执行它。',
|
|
30
|
+
'用户提到「任务看板/看板/认领任务」时即指本插件,请据此协作。',
|
|
31
|
+
].join('\n')
|
|
32
|
+
|
|
33
|
+
/** Section order inside the tool-guidance band (100–199). */
|
|
34
|
+
export const PROTOCOL_SECTION_ORDER = 180
|
|
35
|
+
|
|
36
|
+
/** Registered section name. */
|
|
37
|
+
export const PROTOCOL_SECTION_NAME = 'plugin:dsh-taskboard'
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /dsh-taskboard routes on the shared DSH webserver: a JSON API for the
|
|
3
|
+
* GUI's human operations (create/update/move/comment/delete — actor `user`,
|
|
4
|
+
* the done move IS allowed here) plus an SSE stream mirroring every
|
|
5
|
+
* committed ledger mutation.
|
|
6
|
+
*
|
|
7
|
+
* All domain validation goes through the shared protocol pure functions; the
|
|
8
|
+
* route layer only maps transport to envelope.
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-taskboard/host/routes
|
|
11
|
+
*/
|
|
12
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
13
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
14
|
+
// Type-only: pulls the webServer Context merge (ctx.webServer).
|
|
15
|
+
import type {} from '@deepseek-ai/dsh-host-webserver'
|
|
16
|
+
import {
|
|
17
|
+
asStatus,
|
|
18
|
+
asUrgency,
|
|
19
|
+
canTransition,
|
|
20
|
+
newCommentId,
|
|
21
|
+
newTaskId,
|
|
22
|
+
normalizeBody,
|
|
23
|
+
normalizeExecution,
|
|
24
|
+
normalizePrompt,
|
|
25
|
+
normalizeTitle,
|
|
26
|
+
summarize,
|
|
27
|
+
type TaskRecord,
|
|
28
|
+
} from '../shared/protocol.ts'
|
|
29
|
+
import { ROUTE_PREFIX, SSE_PATH, type ApiFail, type ApiResult } from '../shared/api.ts'
|
|
30
|
+
import type { TaskStore } from './store.ts'
|
|
31
|
+
import type { WorkspaceFace } from './tools.ts'
|
|
32
|
+
|
|
33
|
+
/** Heartbeat cadence for the SSE stream. */
|
|
34
|
+
const HEARTBEAT_MS = 20_000
|
|
35
|
+
|
|
36
|
+
/** The workspaces face routes need (same narrow shape as tools). */
|
|
37
|
+
export type RoutesWorkspaceFace = WorkspaceFace
|
|
38
|
+
|
|
39
|
+
/** Options. */
|
|
40
|
+
export interface TaskboardRoutesOptions {
|
|
41
|
+
store: TaskStore
|
|
42
|
+
workspaces: RoutesWorkspaceFace
|
|
43
|
+
now: () => number
|
|
44
|
+
/** Manual-run hook (the execution service); absent → 501. */
|
|
45
|
+
run?: (taskId: string) => Promise<{ ok: true; executionId: string; sessionId: string } | { ok: false; error: string }>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** JSON-envelope writer. */
|
|
49
|
+
function json(res: ServerResponse, payload: ApiResult<unknown>, status = 200): void {
|
|
50
|
+
const body = JSON.stringify(payload)
|
|
51
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
52
|
+
res.end(body)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Domain failure → envelope + HTTP status. */
|
|
56
|
+
function fail(code: ApiFail['error']['code'], message: string): { res: ApiFail; status: number } {
|
|
57
|
+
const status = code === 'invalid_input' ? 400
|
|
58
|
+
: code === 'not_found' ? 404
|
|
59
|
+
: code === 'version_conflict' ? 409
|
|
60
|
+
: code === 'forbidden' ? 403
|
|
61
|
+
: 500
|
|
62
|
+
return { res: { ok: false, error: { code, message } }, status }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Read one JSON body (null on parse failure). */
|
|
66
|
+
async function readBody(req: IncomingMessage): Promise<Record<string, unknown> | null> {
|
|
67
|
+
const chunks: Buffer[] = []
|
|
68
|
+
for await (const chunk of req) chunks.push(chunk as Buffer)
|
|
69
|
+
if (chunks.length === 0) return {}
|
|
70
|
+
try {
|
|
71
|
+
const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
72
|
+
return typeof parsed === 'object' && parsed !== null ? parsed as Record<string, unknown> : null
|
|
73
|
+
} catch {
|
|
74
|
+
return null
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** String field accessor (null when absent/not a string). */
|
|
79
|
+
function str(body: Record<string, unknown>, key: string): string | null {
|
|
80
|
+
const v = body[key]
|
|
81
|
+
return typeof v === 'string' ? v : null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Number field accessor (undefined when absent; null when present but not a number). */
|
|
85
|
+
function num(body: Record<string, unknown>, key: string): number | undefined | null {
|
|
86
|
+
const v = body[key]
|
|
87
|
+
if (v === undefined) return undefined
|
|
88
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Map a thrown domain error to the envelope. */
|
|
92
|
+
function toFail(error: unknown): { res: ApiFail; status: number } {
|
|
93
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
94
|
+
const code = message.startsWith('Error: ') ? message.slice(7).split(':')[0] : undefined
|
|
95
|
+
const known: ApiFail['error']['code'][] = ['invalid_input', 'not_found', 'version_conflict', 'invalid_transition', 'forbidden', 'internal']
|
|
96
|
+
if (code !== undefined && (known as string[]).includes(code)) {
|
|
97
|
+
return fail(code as ApiFail['error']['code'], message.slice(7 + code.length + 2))
|
|
98
|
+
}
|
|
99
|
+
if (code === 'workspace_mismatch') return fail('forbidden', message.slice(7 + code.length + 2))
|
|
100
|
+
return fail('invalid_input', message)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Register the taskboard routes.
|
|
105
|
+
* @param ctx - context carrying the webServer service.
|
|
106
|
+
* @param options - store + workspaces + clock.
|
|
107
|
+
* @returns the disposer.
|
|
108
|
+
*/
|
|
109
|
+
export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOptions): () => void {
|
|
110
|
+
const { store, workspaces } = options
|
|
111
|
+
const subscribers = new Set<ServerResponse>()
|
|
112
|
+
let heartbeat: NodeJS.Timeout | undefined
|
|
113
|
+
|
|
114
|
+
const broadcast = (change: { revision: number; kind: string; tasks: readonly TaskRecord[] }): void => {
|
|
115
|
+
const frame = `event: change\ndata: ${JSON.stringify({ revision: change.revision, kind: change.kind, tasks: change.tasks.map(summarize) })}\n\n`
|
|
116
|
+
for (const res of subscribers) res.write(frame)
|
|
117
|
+
}
|
|
118
|
+
store.subscribe(broadcast)
|
|
119
|
+
|
|
120
|
+
const taskPath = (id: string, action?: string): RegExp | null => {
|
|
121
|
+
const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
122
|
+
const pattern = action === undefined
|
|
123
|
+
? `^${ROUTE_PREFIX}/tasks/${escaped}$`
|
|
124
|
+
: `^${ROUTE_PREFIX}/tasks/${escaped}/${action}$`
|
|
125
|
+
return new RegExp(pattern)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const handler = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
129
|
+
try {
|
|
130
|
+
const url = new URL(req.url ?? '/', 'http://x')
|
|
131
|
+
const pathname = url.pathname
|
|
132
|
+
|
|
133
|
+
// ---------------------------------------------------------------- GET
|
|
134
|
+
if (req.method === 'GET') {
|
|
135
|
+
if (pathname === `${ROUTE_PREFIX}/state`) {
|
|
136
|
+
await store.load()
|
|
137
|
+
json(res, { ok: true, value: store.snapshot() })
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
if (pathname === `${ROUTE_PREFIX}/workspaces`) {
|
|
141
|
+
json(res, { ok: true, value: workspaces.list() })
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
const taskMatch = pathname.match(new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)$`))
|
|
145
|
+
if (taskMatch !== null) {
|
|
146
|
+
const task = store.get(taskMatch[1]!)
|
|
147
|
+
if (task === undefined) { const f = fail('not_found', 'no such task'); json(res, f.res, f.status); return }
|
|
148
|
+
json(res, { ok: true, value: task })
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
res.writeHead(404)
|
|
152
|
+
res.end()
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (req.method !== 'POST') {
|
|
157
|
+
res.writeHead(405)
|
|
158
|
+
res.end()
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
// CSRF fence: cross-site simple requests cannot set application/json.
|
|
162
|
+
const contentType = req.headers['content-type'] ?? ''
|
|
163
|
+
if (!contentType.toLowerCase().startsWith('application/json')) {
|
|
164
|
+
const f = fail('invalid_input', 'content-type must be application/json')
|
|
165
|
+
json(res, f.res, 415)
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
const body = await readBody(req)
|
|
169
|
+
if (body === null) {
|
|
170
|
+
const f = fail('invalid_input', 'body is not a JSON object')
|
|
171
|
+
json(res, f.res, 400)
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ------------------------------------------------- POST /tasks (create)
|
|
176
|
+
if (pathname === `${ROUTE_PREFIX}/tasks`) {
|
|
177
|
+
try {
|
|
178
|
+
const title = normalizeTitle(str(body, 'title') ?? '')
|
|
179
|
+
const workspaceId = str(body, 'workspaceId') ?? ''
|
|
180
|
+
if (workspaces.get(workspaceId) === undefined) throw new Error('Error: not_found: unknown workspace')
|
|
181
|
+
const urgency = asUrgency(str(body, 'urgency') ?? '')
|
|
182
|
+
const status = str(body, 'status') === null ? 'todo' as const : asStatus(str(body, 'status')!)
|
|
183
|
+
const execution = normalizeExecution((body.execution as { mode?: string; cron?: string } | undefined) ?? {}, options.now())
|
|
184
|
+
const model = body.model as { provider: string; model: string } | undefined
|
|
185
|
+
const now = options.now()
|
|
186
|
+
const task: TaskRecord = {
|
|
187
|
+
id: newTaskId(),
|
|
188
|
+
title,
|
|
189
|
+
description: (str(body, 'description') ?? '').trim(),
|
|
190
|
+
prompt: normalizePrompt(str(body, 'prompt') ?? undefined),
|
|
191
|
+
workspaceId,
|
|
192
|
+
urgency,
|
|
193
|
+
status,
|
|
194
|
+
blocked: false,
|
|
195
|
+
execution,
|
|
196
|
+
model,
|
|
197
|
+
version: 1,
|
|
198
|
+
createdAt: now,
|
|
199
|
+
updatedAt: now,
|
|
200
|
+
createdBy: { kind: 'user' },
|
|
201
|
+
updatedBy: { kind: 'user' },
|
|
202
|
+
comments: [],
|
|
203
|
+
executions: [],
|
|
204
|
+
}
|
|
205
|
+
await store.mutate('task-created', ledger => {
|
|
206
|
+
ledger.tasks.push(task)
|
|
207
|
+
return [task]
|
|
208
|
+
})
|
|
209
|
+
json(res, { ok: true, value: summarize(task) }, 201)
|
|
210
|
+
} catch (error) {
|
|
211
|
+
const f = toFail(error)
|
|
212
|
+
json(res, f.res, f.status)
|
|
213
|
+
}
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ------------------------------------------- POST /tasks/:id/{action}
|
|
218
|
+
const actionMatch = pathname.match(new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)/(\\w+)$`))
|
|
219
|
+
if (actionMatch !== null) {
|
|
220
|
+
const id = actionMatch[1]!
|
|
221
|
+
const action = actionMatch[2]!
|
|
222
|
+
try {
|
|
223
|
+
const task = store.get(id)
|
|
224
|
+
if (task === undefined) throw new Error('Error: not_found: no such task')
|
|
225
|
+
if (action === 'update') {
|
|
226
|
+
const ifVersion = num(body, 'ifVersion')
|
|
227
|
+
if (ifVersion === undefined || ifVersion === null) throw new Error('Error: version_conflict: ifVersion required')
|
|
228
|
+
if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`)
|
|
229
|
+
const next = structuredClone(task)
|
|
230
|
+
const title = str(body, 'title')
|
|
231
|
+
if (title !== null) next.title = normalizeTitle(title)
|
|
232
|
+
const description = str(body, 'description')
|
|
233
|
+
if (description !== null) next.description = description.trim()
|
|
234
|
+
const prompt = str(body, 'prompt')
|
|
235
|
+
if (prompt !== null) next.prompt = normalizePrompt(prompt)
|
|
236
|
+
const urgency = str(body, 'urgency')
|
|
237
|
+
if (urgency !== null) next.urgency = asUrgency(urgency)
|
|
238
|
+
// GUI-only rebind to another project; validated against the workspace registry.
|
|
239
|
+
const workspaceId = str(body, 'workspaceId')
|
|
240
|
+
if (workspaceId !== null) {
|
|
241
|
+
if (workspaces.get(workspaceId) === undefined) throw new Error('Error: not_found: unknown workspace')
|
|
242
|
+
next.workspaceId = workspaceId
|
|
243
|
+
}
|
|
244
|
+
if (typeof body.blocked === 'boolean') next.blocked = body.blocked
|
|
245
|
+
// The GUI (task owner surface) may edit model/execution; null clears the model.
|
|
246
|
+
if (body.execution !== undefined) next.execution = normalizeExecution(body.execution as { mode?: string; cron?: string }, options.now())
|
|
247
|
+
if (body.model === null) next.model = undefined
|
|
248
|
+
else if (body.model !== undefined) next.model = body.model as { provider: string; model: string }
|
|
249
|
+
next.version = task.version + 1
|
|
250
|
+
next.updatedAt = options.now()
|
|
251
|
+
next.updatedBy = { kind: 'user' }
|
|
252
|
+
await store.mutate('task-updated', ledger => {
|
|
253
|
+
const i = ledger.tasks.findIndex(t => t.id === id)
|
|
254
|
+
ledger.tasks[i] = next
|
|
255
|
+
return [next]
|
|
256
|
+
})
|
|
257
|
+
json(res, { ok: true, value: summarize(next) })
|
|
258
|
+
return
|
|
259
|
+
}
|
|
260
|
+
if (action === 'move') {
|
|
261
|
+
const ifVersion = num(body, 'ifVersion')
|
|
262
|
+
const status = str(body, 'status') ?? ''
|
|
263
|
+
if (ifVersion === undefined || ifVersion === null) throw new Error('Error: version_conflict: ifVersion required')
|
|
264
|
+
if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`)
|
|
265
|
+
const to = asStatus(status)
|
|
266
|
+
if (!canTransition(task.status, to)) throw new Error(`Error: invalid_transition: illegal transition ${task.status} → ${to}`)
|
|
267
|
+
const next = structuredClone(task)
|
|
268
|
+
next.status = to
|
|
269
|
+
next.version = task.version + 1
|
|
270
|
+
next.updatedAt = options.now()
|
|
271
|
+
next.updatedBy = { kind: 'user' }
|
|
272
|
+
if (task.status === 'todo' && to === 'in_progress') next.blocked = false
|
|
273
|
+
await store.mutate('task-moved', ledger => {
|
|
274
|
+
const i = ledger.tasks.findIndex(t => t.id === id)
|
|
275
|
+
ledger.tasks[i] = next
|
|
276
|
+
return [next]
|
|
277
|
+
})
|
|
278
|
+
json(res, { ok: true, value: summarize(next) })
|
|
279
|
+
return
|
|
280
|
+
}
|
|
281
|
+
if (action === 'comment') {
|
|
282
|
+
const bodyText = str(body, 'body') ?? ''
|
|
283
|
+
const comment = { id: newCommentId(), body: normalizeBody(bodyText), version: 1, createdAt: options.now() }
|
|
284
|
+
const next = structuredClone(task)
|
|
285
|
+
next.comments.push(comment)
|
|
286
|
+
next.version = task.version + 1
|
|
287
|
+
next.updatedAt = options.now()
|
|
288
|
+
await store.mutate('comment-added', ledger => {
|
|
289
|
+
const i = ledger.tasks.findIndex(t => t.id === id)
|
|
290
|
+
ledger.tasks[i] = next
|
|
291
|
+
return [next]
|
|
292
|
+
})
|
|
293
|
+
json(res, { ok: true, value: comment }, 201)
|
|
294
|
+
return
|
|
295
|
+
}
|
|
296
|
+
if (action === 'delete') {
|
|
297
|
+
const purge = body.purge === true
|
|
298
|
+
if (purge) {
|
|
299
|
+
if (task.trashedAt === undefined) throw new Error('Error: invalid_input: purge requires a trashed task (soft-delete first)')
|
|
300
|
+
await store.mutate('task-deleted', ledger => {
|
|
301
|
+
ledger.tasks = ledger.tasks.filter(t => t.id !== id)
|
|
302
|
+
return []
|
|
303
|
+
})
|
|
304
|
+
json(res, { ok: true, value: { purged: true } })
|
|
305
|
+
return
|
|
306
|
+
}
|
|
307
|
+
const ifVersion = num(body, 'ifVersion')
|
|
308
|
+
if (ifVersion === undefined || ifVersion === null) throw new Error('Error: version_conflict: ifVersion required')
|
|
309
|
+
if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`)
|
|
310
|
+
const next = structuredClone(task)
|
|
311
|
+
next.trashedAt = options.now()
|
|
312
|
+
next.version = task.version + 1
|
|
313
|
+
await store.mutate('task-deleted', ledger => {
|
|
314
|
+
const i = ledger.tasks.findIndex(t => t.id === id)
|
|
315
|
+
ledger.tasks[i] = next
|
|
316
|
+
return [next]
|
|
317
|
+
})
|
|
318
|
+
json(res, { ok: true, value: { trashed: true } })
|
|
319
|
+
return
|
|
320
|
+
}
|
|
321
|
+
if (action === 'run') {
|
|
322
|
+
if (options.run === undefined) {
|
|
323
|
+
const f = fail('invalid_input', 'execution service unavailable')
|
|
324
|
+
json(res, f.res, 501)
|
|
325
|
+
return
|
|
326
|
+
}
|
|
327
|
+
const result = await options.run(id)
|
|
328
|
+
if (result.ok) json(res, { ok: true, value: result }, 202)
|
|
329
|
+
else {
|
|
330
|
+
const f = fail('invalid_input', result.error)
|
|
331
|
+
json(res, f.res, f.status)
|
|
332
|
+
}
|
|
333
|
+
return
|
|
334
|
+
}
|
|
335
|
+
const f = fail('not_found', `unknown action ${action}`)
|
|
336
|
+
json(res, f.res, f.status)
|
|
337
|
+
} catch (error) {
|
|
338
|
+
const f = toFail(error)
|
|
339
|
+
json(res, f.res, f.status)
|
|
340
|
+
}
|
|
341
|
+
return
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
void taskPath
|
|
345
|
+
res.writeHead(404)
|
|
346
|
+
res.end()
|
|
347
|
+
} catch (error) {
|
|
348
|
+
const f = fail('internal', error instanceof Error ? error.message : String(error))
|
|
349
|
+
json(res, f.res, f.status)
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const sse = (req: IncomingMessage, res: ServerResponse): void => {
|
|
354
|
+
res.writeHead(200, {
|
|
355
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
356
|
+
'cache-control': 'no-cache',
|
|
357
|
+
connection: 'keep-alive',
|
|
358
|
+
})
|
|
359
|
+
res.write('retry: 2000\n\n')
|
|
360
|
+
// Baseline frame: the client reconciles by revision and refetches state on gaps.
|
|
361
|
+
res.write(`event: hello\ndata: ${JSON.stringify({ revision: store.snapshot().revision })}\n\n`)
|
|
362
|
+
subscribers.add(res)
|
|
363
|
+
if (heartbeat === undefined) {
|
|
364
|
+
heartbeat = setInterval(() => {
|
|
365
|
+
for (const current of subscribers) current.write(': ping\n\n')
|
|
366
|
+
}, HEARTBEAT_MS)
|
|
367
|
+
}
|
|
368
|
+
req.on('close', () => {
|
|
369
|
+
subscribers.delete(res)
|
|
370
|
+
if (subscribers.size === 0 && heartbeat !== undefined) {
|
|
371
|
+
clearInterval(heartbeat)
|
|
372
|
+
heartbeat = undefined
|
|
373
|
+
}
|
|
374
|
+
})
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const disposers = [
|
|
378
|
+
ctx.webServer.register({ kind: 'prefix', path: ROUTE_PREFIX, handler }),
|
|
379
|
+
ctx.webServer.register({ kind: 'exact', path: SSE_PATH, handler: sse }),
|
|
380
|
+
]
|
|
381
|
+
return () => {
|
|
382
|
+
for (const dispose of disposers) dispose()
|
|
383
|
+
if (heartbeat !== undefined) clearInterval(heartbeat)
|
|
384
|
+
for (const res of subscribers) res.end()
|
|
385
|
+
subscribers.clear()
|
|
386
|
+
}
|
|
387
|
+
}
|