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