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,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
+ }