@co0ontty/wand 4.57.0 → 4.58.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 (177) hide show
  1. package/dist/build-info.json +3 -3
  2. package/dist/ensure-node-pty-helper.d.ts +0 -1
  3. package/dist/ensure-node-pty-helper.js +0 -5
  4. package/dist/git-quick-commit.js +18 -19
  5. package/dist/github-connector.d.ts +11 -5
  6. package/dist/github-connector.js +105 -33
  7. package/dist/missions.d.ts +1 -1
  8. package/dist/missions.js +17 -4
  9. package/dist/server-file-routes.js +43 -2
  10. package/dist/server-github-routes.js +50 -53
  11. package/dist/server-mission-routes.js +11 -10
  12. package/dist/server-task-routes.d.ts +14 -2
  13. package/dist/server-task-routes.js +248 -84
  14. package/dist/server-workspace-routes.js +90 -16
  15. package/dist/server.js +2 -20
  16. package/dist/storage.d.ts +23 -6
  17. package/dist/storage.js +130 -53
  18. package/dist/structured-exec-host.d.ts +13 -2
  19. package/dist/structured-exec-host.js +71 -36
  20. package/dist/structured-exec-pump.d.ts +1 -1
  21. package/dist/structured-exec-pump.js +14 -8
  22. package/dist/task-types.d.ts +15 -0
  23. package/dist/terminal-daemon-client.d.ts +2 -0
  24. package/dist/terminal-daemon-client.js +7 -1
  25. package/dist/terminal-daemon-server.js +17 -18
  26. package/dist/tui/ipc-server.js +3 -1
  27. package/dist/types.d.ts +1 -0
  28. package/dist/wand-task-sync.d.ts +9 -0
  29. package/dist/wand-task-sync.js +50 -0
  30. package/dist/web-ui/content/scripts.js +85 -85
  31. package/dist/web-ui/content/styles.css +1 -1
  32. package/dist/web-ui/embedded-assets.js +2 -2
  33. package/dist/ws-broadcast.js +5 -1
  34. package/package.json +2 -3
  35. package/dist/taskboard-bridge.d.ts +0 -11
  36. package/dist/taskboard-bridge.js +0 -371
  37. package/vendor/codex-taskboard/LICENSE +0 -201
  38. package/vendor/codex-taskboard/dist/web/assets/AiChat-BNn4VJ5a.js +0 -12
  39. package/vendor/codex-taskboard/dist/web/assets/GanttView-D8wnfGrG.css +0 -1
  40. package/vendor/codex-taskboard/dist/web/assets/GanttView-rGWhuEX9.js +0 -39
  41. package/vendor/codex-taskboard/dist/web/assets/abnfDiagram-N423BO3Z-DbFG6lbA.js +0 -1
  42. package/vendor/codex-taskboard/dist/web/assets/arc-BjSQqbzd.js +0 -1
  43. package/vendor/codex-taskboard/dist/web/assets/architecture-TIHT7OUA-C9-t8p4o.js +0 -1
  44. package/vendor/codex-taskboard/dist/web/assets/architectureDiagram-T3A2C74G-Db4kJZwO.js +0 -36
  45. package/vendor/codex-taskboard/dist/web/assets/array-BifhSqXX.js +0 -1
  46. package/vendor/codex-taskboard/dist/web/assets/blockDiagram-VBNYF7ZC-C-BywuWV.js +0 -132
  47. package/vendor/codex-taskboard/dist/web/assets/c4Diagram-5PPSVZJV-Dq-80rjP.js +0 -10
  48. package/vendor/codex-taskboard/dist/web/assets/channel-C7mnz8Xc.js +0 -1
  49. package/vendor/codex-taskboard/dist/web/assets/chunk-2GRJ4B5K-B2G57us9.js +0 -1
  50. package/vendor/codex-taskboard/dist/web/assets/chunk-2Q5K7J3B-C1jixKkw.js +0 -1
  51. package/vendor/codex-taskboard/dist/web/assets/chunk-3NCLNEKW-DVGIRib_.js +0 -1
  52. package/vendor/codex-taskboard/dist/web/assets/chunk-4I5QYGJK-kNoGFqr0.js +0 -1
  53. package/vendor/codex-taskboard/dist/web/assets/chunk-5RXB4S5H-4iv6-xM6.js +0 -231
  54. package/vendor/codex-taskboard/dist/web/assets/chunk-5VM5RSS4-ZNzvKenW.js +0 -15
  55. package/vendor/codex-taskboard/dist/web/assets/chunk-6Q2QTUOP-1J34vxHr.js +0 -88
  56. package/vendor/codex-taskboard/dist/web/assets/chunk-7BUUIJ7U-Bb538aSH.js +0 -1
  57. package/vendor/codex-taskboard/dist/web/assets/chunk-7Z6QIM7H-CDQpQlxX.js +0 -10
  58. package/vendor/codex-taskboard/dist/web/assets/chunk-GF5L2VYU-Dg92oHHl.js +0 -206
  59. package/vendor/codex-taskboard/dist/web/assets/chunk-I66GZJ75-Bk8_gFs2.js +0 -125
  60. package/vendor/codex-taskboard/dist/web/assets/chunk-J7OUQ5F2-CJRcenlr.js +0 -2
  61. package/vendor/codex-taskboard/dist/web/assets/chunk-JQJVKLGR-BLBV_Sus.js +0 -156
  62. package/vendor/codex-taskboard/dist/web/assets/chunk-JWPE2WC7-DVXcaiue.js +0 -1
  63. package/vendor/codex-taskboard/dist/web/assets/chunk-KBJHAD2P-Da9Yjz0p.js +0 -1
  64. package/vendor/codex-taskboard/dist/web/assets/chunk-KEIR6QF5-Dj-OpFgW.js +0 -161
  65. package/vendor/codex-taskboard/dist/web/assets/chunk-NSK5VX7P-Cb56tnGK.js +0 -2
  66. package/vendor/codex-taskboard/dist/web/assets/chunk-QR6OTTB3-BwoszTxn.js +0 -62
  67. package/vendor/codex-taskboard/dist/web/assets/chunk-RYQCIY6F-D_L2RdcQ.js +0 -1
  68. package/vendor/codex-taskboard/dist/web/assets/chunk-UBXNYLIW-YX5mN8cl.js +0 -1
  69. package/vendor/codex-taskboard/dist/web/assets/chunk-W5SLKNZC-DlOLhhDO.js +0 -1
  70. package/vendor/codex-taskboard/dist/web/assets/chunk-WRU74C26-B7SBmP0K.js +0 -70
  71. package/vendor/codex-taskboard/dist/web/assets/chunk-XXDRQBXY-BuE3VzE_.js +0 -1
  72. package/vendor/codex-taskboard/dist/web/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +0 -1
  73. package/vendor/codex-taskboard/dist/web/assets/chunk-ZIRB5QZD-C6fEPe3t.js +0 -32
  74. package/vendor/codex-taskboard/dist/web/assets/classDiagram-JCYQIIEL-KIdCVvbA.js +0 -1
  75. package/vendor/codex-taskboard/dist/web/assets/classDiagram-v2-OCEON4UE-KIdCVvbA.js +0 -1
  76. package/vendor/codex-taskboard/dist/web/assets/cose-bilkent-JH36ORCC-BzQvy5TC.js +0 -1
  77. package/vendor/codex-taskboard/dist/web/assets/cynefin-VYW2F7L2-D1_5O1XR.js +0 -1
  78. package/vendor/codex-taskboard/dist/web/assets/cynefinDiagram-MW4NZA55-DOW_U5QN.js +0 -62
  79. package/vendor/codex-taskboard/dist/web/assets/cytoscape.esm-DnGd5Fuo.js +0 -321
  80. package/vendor/codex-taskboard/dist/web/assets/dagre-VZM6K2ZE-0Dntc3NT.js +0 -4
  81. package/vendor/codex-taskboard/dist/web/assets/dagre-dpRSp0QF.js +0 -1
  82. package/vendor/codex-taskboard/dist/web/assets/defaultLocale-C8Fc0cco.js +0 -1
  83. package/vendor/codex-taskboard/dist/web/assets/diagram-7IWD3JNH-D7GleYAM.js +0 -30
  84. package/vendor/codex-taskboard/dist/web/assets/diagram-B4RE2ZJO-DCXTe6q5.js +0 -3
  85. package/vendor/codex-taskboard/dist/web/assets/diagram-LBJQPF4R-ZFJyXEXl.js +0 -24
  86. package/vendor/codex-taskboard/dist/web/assets/diagram-Q27KOJAE-DUGCuGd9.js +0 -24
  87. package/vendor/codex-taskboard/dist/web/assets/diagram-UB23O5K3-DRmp4Nng.js +0 -41
  88. package/vendor/codex-taskboard/dist/web/assets/dist-D9sYb5Oa.js +0 -1
  89. package/vendor/codex-taskboard/dist/web/assets/ebnfDiagram-BXEA7PRR-CkKoaf7T.js +0 -1
  90. package/vendor/codex-taskboard/dist/web/assets/erDiagram-JOGREHBK-nBX5z4R4.js +0 -85
  91. package/vendor/codex-taskboard/dist/web/assets/eventmodeling-45OFAUF4-pexmK65N.js +0 -1
  92. package/vendor/codex-taskboard/dist/web/assets/flowDiagram-UKHOOZJN-aroohRbL.js +0 -1
  93. package/vendor/codex-taskboard/dist/web/assets/ganttDiagram-PKOTCBZU-BOJWUTXa.js +0 -292
  94. package/vendor/codex-taskboard/dist/web/assets/gitGraph-TEB2WS4Q-DxnuhSZU.js +0 -1
  95. package/vendor/codex-taskboard/dist/web/assets/gitGraphDiagram-DS77QQ5N-Cte975TC.js +0 -106
  96. package/vendor/codex-taskboard/dist/web/assets/graphlib-DS17s2tU.js +0 -1
  97. package/vendor/codex-taskboard/dist/web/assets/index-Docmx4uQ.css +0 -1
  98. package/vendor/codex-taskboard/dist/web/assets/index-Du07lEGz.js +0 -121
  99. package/vendor/codex-taskboard/dist/web/assets/info-DKCQHKI2-DOTjgYxk.js +0 -1
  100. package/vendor/codex-taskboard/dist/web/assets/infoDiagram-6WML65LV-SpyV9UJd.js +0 -2
  101. package/vendor/codex-taskboard/dist/web/assets/init-D6jRqBbL.js +0 -1
  102. package/vendor/codex-taskboard/dist/web/assets/ishikawaDiagram-WSZJBQD7-T0PZwoUZ.js +0 -70
  103. package/vendor/codex-taskboard/dist/web/assets/journeyDiagram-NVQOT4AX-B88b8UgX.js +0 -139
  104. package/vendor/codex-taskboard/dist/web/assets/kanban-definition-27J2QSJJ-CIlfNnl_.js +0 -89
  105. package/vendor/codex-taskboard/dist/web/assets/katex-B7rAX3Vi.js +0 -257
  106. package/vendor/codex-taskboard/dist/web/assets/line-fWrwPZhB.js +0 -1
  107. package/vendor/codex-taskboard/dist/web/assets/linear-B7l8qgEw.js +0 -1
  108. package/vendor/codex-taskboard/dist/web/assets/map-BaFkSB1l.js +0 -1
  109. package/vendor/codex-taskboard/dist/web/assets/mermaid-parser.core-C5gls2nC.js +0 -7
  110. package/vendor/codex-taskboard/dist/web/assets/mermaid.core-POz2Xcvr.js +0 -11
  111. package/vendor/codex-taskboard/dist/web/assets/mindmap-definition-FAOFIHXS-BM0-egZ5.js +0 -96
  112. package/vendor/codex-taskboard/dist/web/assets/ordinal-hYBb2elL.js +0 -1
  113. package/vendor/codex-taskboard/dist/web/assets/packet-7NZHBO7P-DQZzyVos.js +0 -1
  114. package/vendor/codex-taskboard/dist/web/assets/path-BWPyau1x.js +0 -1
  115. package/vendor/codex-taskboard/dist/web/assets/pegDiagram-VL7TDLO6-B8aEwl-7.js +0 -1
  116. package/vendor/codex-taskboard/dist/web/assets/pie-RZYD4A2V-DRUflilR.js +0 -1
  117. package/vendor/codex-taskboard/dist/web/assets/pieDiagram-7S7Q4E2Y-0cDDxlua.js +0 -39
  118. package/vendor/codex-taskboard/dist/web/assets/purify.es-Bwt4ulIK.js +0 -3
  119. package/vendor/codex-taskboard/dist/web/assets/quadrantDiagram-CIZ2JOQS-CBgb0YZ0.js +0 -7
  120. package/vendor/codex-taskboard/dist/web/assets/radar-I7S5WNFK-tRocoKxq.js +0 -1
  121. package/vendor/codex-taskboard/dist/web/assets/railroad-3IZDKUUU-DaS4ZlX_.js +0 -1
  122. package/vendor/codex-taskboard/dist/web/assets/railroad-abnf-AHOZXSZD-BN1T5dBT.js +0 -1
  123. package/vendor/codex-taskboard/dist/web/assets/railroad-ebnf-EBAXGLYW-Cuf8qyKs.js +0 -1
  124. package/vendor/codex-taskboard/dist/web/assets/railroad-peg-LSFZ7HO6-B511_EYq.js +0 -1
  125. package/vendor/codex-taskboard/dist/web/assets/railroadDiagram-AXF67PYL-QtPo-JZb.js +0 -1
  126. package/vendor/codex-taskboard/dist/web/assets/requirementDiagram-LRYGKXZP-CuBIF85r.js +0 -84
  127. package/vendor/codex-taskboard/dist/web/assets/rolldown-runtime-QTnfLwEv.js +0 -1
  128. package/vendor/codex-taskboard/dist/web/assets/rough.esm-CSKSodPl.js +0 -1
  129. package/vendor/codex-taskboard/dist/web/assets/sankeyDiagram-W5VNT64P-CRA-b_za.js +0 -40
  130. package/vendor/codex-taskboard/dist/web/assets/sequenceDiagram-SI44F4Z6-CoddVWwY.js +0 -162
  131. package/vendor/codex-taskboard/dist/web/assets/sizeCapture-X5ZJPWSS-B0uUizjq.js +0 -1
  132. package/vendor/codex-taskboard/dist/web/assets/src-_wZywoZs.js +0 -1
  133. package/vendor/codex-taskboard/dist/web/assets/stateDiagram-OKZ733FA-D8DMM2ln.js +0 -1
  134. package/vendor/codex-taskboard/dist/web/assets/stateDiagram-v2-UEYNNEHI-CVp0eYAS.js +0 -1
  135. package/vendor/codex-taskboard/dist/web/assets/swimlanes-SLNWSIFB-C1fC7EFu.js +0 -2
  136. package/vendor/codex-taskboard/dist/web/assets/swimlanesDiagram-ULZ7WXOC-CPd58Xso.js +0 -8
  137. package/vendor/codex-taskboard/dist/web/assets/timeline-definition-Z64GVDOM-BUBv8aKP.js +0 -120
  138. package/vendor/codex-taskboard/dist/web/assets/treeView-QDETBFTQ-C-Bve2kY.js +0 -1
  139. package/vendor/codex-taskboard/dist/web/assets/treemap-6X3UGDF4-BcphHDdV.js +0 -1
  140. package/vendor/codex-taskboard/dist/web/assets/vennDiagram-T6HMQDX7-0wQnwAyA.js +0 -34
  141. package/vendor/codex-taskboard/dist/web/assets/wardley-OPB4EBWU-CJZ4BevB.js +0 -1
  142. package/vendor/codex-taskboard/dist/web/assets/wardleyDiagram-T6FBY63Y-U9nwPrkN.js +0 -78
  143. package/vendor/codex-taskboard/dist/web/assets/xychartDiagram-ELKLHX3M-CyCqnzgE.js +0 -7
  144. package/vendor/codex-taskboard/dist/web/codex-agent-logo.png +0 -0
  145. package/vendor/codex-taskboard/dist/web/codex-app-icon.png +0 -0
  146. package/vendor/codex-taskboard/dist/web/favicon.svg +0 -7
  147. package/vendor/codex-taskboard/dist/web/index.html +0 -20
  148. package/vendor/codex-taskboard/server/ai-chat-catalog.mjs +0 -1031
  149. package/vendor/codex-taskboard/server/ai-chat-process.mjs +0 -462
  150. package/vendor/codex-taskboard/server/ai-chat.mjs +0 -1237
  151. package/vendor/codex-taskboard/server/ai-turn-owner.mjs +0 -39
  152. package/vendor/codex-taskboard/server/app.mjs +0 -3211
  153. package/vendor/codex-taskboard/server/cloud-config.mjs +0 -188
  154. package/vendor/codex-taskboard/server/cloud-proxy.mjs +0 -310
  155. package/vendor/codex-taskboard/server/codex-app-server.mjs +0 -376
  156. package/vendor/codex-taskboard/server/codex-slash-commands-0.139.0.json +0 -62
  157. package/vendor/codex-taskboard/server/database.mjs +0 -2845
  158. package/vendor/codex-taskboard/server/index.mjs +0 -41
  159. package/vendor/codex-taskboard/server/jira-config.mjs +0 -181
  160. package/vendor/codex-taskboard/server/jira-integration.mjs +0 -487
  161. package/vendor/codex-taskboard/server/project-summary.mjs +0 -159
  162. package/vendor/codex-taskboard/shared/api-fields.mjs +0 -163
  163. package/vendor/codex-taskboard/shared/codex-environment.mjs +0 -5
  164. package/vendor/codex-taskboard/shared/codex-executable.mjs +0 -77
  165. package/vendor/codex-taskboard/shared/codex-permissions.mjs +0 -9
  166. package/vendor/codex-taskboard/shared/composer-reference.d.mts +0 -19
  167. package/vendor/codex-taskboard/shared/composer-reference.mjs +0 -90
  168. package/vendor/codex-taskboard/shared/domain.mjs +0 -34
  169. package/vendor/codex-taskboard/shared/executable-command.mjs +0 -13
  170. package/vendor/codex-taskboard/shared/process-tree.mjs +0 -27
  171. package/vendor/codex-taskboard/shared/task-input.mjs +0 -308
  172. package/vendor/codex-taskboard/shared/task-records.mjs +0 -199
  173. package/vendor/codex-taskboard/shared/task-relations.mjs +0 -55
  174. package/vendor/codex-taskboard/shared/taskboard-automation.mjs +0 -277
  175. package/vendor/codex-taskboard/skills/manage-taskboard/SKILL.md +0 -45
  176. package/vendor/codex-taskboard/skills/manage-taskboard/agents/openai.yaml +0 -4
  177. package/vendor/codex-taskboard/skills/manage-taskboard/references/cli.md +0 -211
@@ -1,2845 +0,0 @@
1
- import { ApiError } from "../shared/api-fields.mjs";
2
- import { taskRelationsQuery, taskRelationsFromRows } from "../shared/task-relations.mjs";
3
- import {
4
- commentConversationTitle,
5
- threadBindingFromRow,
6
- legacyLocalThreadIdFromRow,
7
- storedThreadBinding,
8
- attachTaskActivity,
9
- taskActivityFromRow,
10
- taskFieldChanges,
11
- taskTreeNode,
12
- projectReadmeAttachmentFromRow,
13
- projectPrefix,
14
- } from "../shared/task-records.mjs";
15
- import { randomUUID } from "node:crypto";
16
- import { mkdirSync } from "node:fs";
17
- import path from "node:path";
18
- import { DatabaseSync } from "node:sqlite";
19
-
20
- import { DEFAULT_LABEL_NAMES, JIRA_PROJECT_ID } from "../shared/domain.mjs";
21
-
22
- const DEFAULT_PROJECT_LABELS_JSON = JSON.stringify(DEFAULT_LABEL_NAMES);
23
- const TASK_TREE_MAX_NODES = 1_000;
24
-
25
- function now() {
26
- return new Date().toISOString();
27
- }
28
-
29
- function storedThreadBindingForExisting(current, threadBinding, threadId) {
30
- if (
31
- threadBinding === undefined
32
- && current?.threadBinding
33
- && current.threadBinding.threadId === threadId
34
- ) {
35
- return storedThreadBinding(current.threadBinding, threadId);
36
- }
37
- return storedThreadBinding(threadBinding, threadId);
38
- }
39
-
40
- function relationActivityValue(type, task) {
41
- return {
42
- type,
43
- identifier: task.identifier,
44
- externalKey: task.externalKey ?? null,
45
- title: task.title,
46
- };
47
- }
48
-
49
- function parseAiChatTodoProgress(row) {
50
- try {
51
- const data = row.data === null ? null : JSON.parse(row.data);
52
- const detail = typeof data?.detail === "string" ? JSON.parse(data.detail) : data?.detail;
53
- if (!Array.isArray(detail)) return null;
54
- const items = detail.filter((item) => (
55
- item && typeof item === "object" && typeof item.text === "string" && item.text.trim()
56
- ));
57
- if (items.length === 0) return null;
58
- return {
59
- completed: items.filter((item) => item.completed === true).length,
60
- total: items.length,
61
- eventId: row.id,
62
- updatedAt: row.created_at,
63
- };
64
- } catch {
65
- return null;
66
- }
67
- }
68
-
69
- function taskFromRow(row) {
70
- const developmentContext = row.worktree_path
71
- ? { type: "worktree", path: row.worktree_path, branch: row.worktree_branch }
72
- : row.git_branch
73
- ? { type: "branch", branch: row.git_branch }
74
- : null;
75
- return {
76
- id: row.id,
77
- identifier: row.identifier,
78
- projectId: row.project_id,
79
- title: row.title,
80
- description: row.description,
81
- status: row.status,
82
- priority: row.priority,
83
- labels: JSON.parse(row.labels),
84
- sortOrder: row.sort_order,
85
- threadId: row.thread_id,
86
- threadBinding: threadBindingFromRow(row),
87
- legacyLocalThreadId: legacyLocalThreadIdFromRow(row),
88
- creatorType: row.creator_type,
89
- creatorId: row.creator_id,
90
- creatorName: row.creator_name,
91
- creatorAvatarUrl: row.creator_avatar_url,
92
- assignee: {
93
- type: row.assignee_type,
94
- id: row.assignee_id,
95
- name: row.assignee_name,
96
- avatarUrl: row.assignee_avatar_url,
97
- },
98
- developmentContext,
99
- startDate: row.start_date,
100
- dueDate: row.due_date,
101
- recurrence: row.recurrence_interval && row.recurrence_unit
102
- ? { interval: row.recurrence_interval, unit: row.recurrence_unit }
103
- : null,
104
- source: row.external_source === "jira" ? "jira" : "local",
105
- externalOrigin: row.external_origin ?? null,
106
- externalKey: row.external_key ?? null,
107
- externalUrl: row.external_url ?? null,
108
- archivedAt: row.archived_at,
109
- version: row.version,
110
- createdAt: row.created_at,
111
- updatedAt: row.updated_at,
112
- };
113
- }
114
-
115
- function taskRelationSummaryFromRow(row) {
116
- return {
117
- id: row.id,
118
- identifier: row.identifier,
119
- externalKey: row.external_key ?? null,
120
- projectId: row.project_id,
121
- title: row.title,
122
- status: row.status,
123
- priority: row.priority,
124
- assignee: {
125
- type: row.assignee_type,
126
- id: row.assignee_id,
127
- name: row.assignee_name,
128
- avatarUrl: row.assignee_avatar_url,
129
- },
130
- archivedAt: row.archived_at,
131
- };
132
- }
133
-
134
- function commentFromRow(row) {
135
- const comment = {
136
- id: row.id,
137
- taskId: row.task_id,
138
- body: row.body,
139
- threadId: row.thread_id,
140
- threadBinding: threadBindingFromRow(row),
141
- legacyLocalThreadId: legacyLocalThreadIdFromRow(row),
142
- authorType: row.author_type,
143
- authorId: row.author_id,
144
- authorName: row.author_name,
145
- authorAvatarUrl: row.author_avatar_url,
146
- attachments: [],
147
- version: row.version,
148
- createdAt: row.created_at,
149
- updatedAt: row.updated_at,
150
- };
151
- Object.defineProperty(comment, "changeRevision", { value: row.change_revision });
152
- return comment;
153
- }
154
-
155
- function attachmentFromRow(row) {
156
- const attachment = {
157
- id: row.id,
158
- taskId: row.task_id,
159
- commentId: row.comment_id,
160
- kind: row.kind,
161
- filename: row.filename,
162
- contentType: row.content_type,
163
- size: row.size,
164
- createdAt: row.created_at,
165
- };
166
- Object.defineProperty(attachment, "changeRevision", { value: row.change_revision });
167
- return attachment;
168
- }
169
-
170
- function projectFromRow(row) {
171
- return {
172
- id: row.id,
173
- name: row.name,
174
- workspacePath: row.workspace_path,
175
- source: row.id === JIRA_PROJECT_ID ? "jira" : "local",
176
- labels: JSON.parse(row.labels),
177
- issueCount: Number(row.issue_count ?? 0),
178
- createdAt: row.created_at,
179
- updatedAt: row.updated_at,
180
- };
181
- }
182
-
183
- function projectSummaryFromRow(row) {
184
- return {
185
- projectId: row.project_id,
186
- summary: row.summary,
187
- generatedAt: row.generated_at,
188
- attemptedAt: row.attempted_at,
189
- error: row.error,
190
- };
191
- }
192
-
193
- function projectReadmeFromRow(row, projectId) {
194
- return {
195
- projectId: row.project_id ?? projectId,
196
- content: row.content,
197
- version: row.version,
198
- createdAt: row.created_at,
199
- updatedAt: row.updated_at,
200
- };
201
- }
202
-
203
- function aiChatRunFromRow(row) {
204
- return {
205
- id: row.id,
206
- threadId: row.thread_id,
207
- status: row.status,
208
- exitCode: row.exit_code,
209
- error: row.error,
210
- startedAt: row.started_at,
211
- finishedAt: row.finished_at,
212
- };
213
- }
214
-
215
- function aiChatThreadFromRow(row) {
216
- return {
217
- id: row.id,
218
- title: row.title,
219
- status: row.status,
220
- origin: {
221
- projectId: row.origin_project_id,
222
- projectName: row.origin_project_name,
223
- workspacePath: row.origin_workspace_path,
224
- ...(row.origin_codex_project_id ? { codexProjectId: row.origin_codex_project_id } : {}),
225
- ...(row.origin_codex_project_kind ? { codexProjectKind: row.origin_codex_project_kind } : {}),
226
- ...(row.origin_codex_host_id ? { codexHostId: row.origin_codex_host_id } : {}),
227
- ...(row.origin_issue_id ? { issueId: row.origin_issue_id } : {}),
228
- ...(row.origin_issue_identifier ? { issueIdentifier: row.origin_issue_identifier } : {}),
229
- },
230
- codexThreadId: row.codex_thread_id,
231
- model: row.model,
232
- reasoningEffort: row.reasoning_effort,
233
- sandbox: row.sandbox,
234
- currentRun: null,
235
- latestTodo: null,
236
- createdAt: row.created_at,
237
- updatedAt: row.updated_at,
238
- };
239
- }
240
-
241
- function aiChatEventFromRow(row) {
242
- return {
243
- id: row.id,
244
- threadId: row.thread_id,
245
- runId: row.run_id,
246
- type: row.type,
247
- role: row.role,
248
- content: row.content,
249
- data: row.data === null ? null : JSON.parse(row.data),
250
- createdAt: row.created_at,
251
- };
252
- }
253
-
254
- export class TaskboardDatabase {
255
- constructor(filename) {
256
- mkdirSync(path.dirname(filename), { recursive: true });
257
- this.database = new DatabaseSync(filename);
258
- this.database.exec("PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000;");
259
- this.#migrate();
260
- this.interruptAbandonedAiChatRuns();
261
- }
262
-
263
- #migrate() {
264
- this.database.exec(`
265
- CREATE TABLE IF NOT EXISTS projects (
266
- id TEXT PRIMARY KEY,
267
- name TEXT NOT NULL,
268
- workspace_path TEXT,
269
- labels TEXT NOT NULL DEFAULT '${DEFAULT_PROJECT_LABELS_JSON}',
270
- next_task_number INTEGER NOT NULL DEFAULT 1 CHECK (next_task_number > 0),
271
- created_at TEXT NOT NULL,
272
- updated_at TEXT NOT NULL
273
- );
274
-
275
- CREATE TABLE IF NOT EXISTS tasks (
276
- id TEXT PRIMARY KEY,
277
- identifier TEXT NOT NULL UNIQUE,
278
- project_id TEXT NOT NULL REFERENCES projects(id),
279
- title TEXT NOT NULL,
280
- description TEXT NOT NULL DEFAULT '',
281
- status TEXT NOT NULL CHECK (status IN (
282
- 'backlog', 'todo', 'in_progress', 'in_review', 'blocked', 'done', 'canceled'
283
- )),
284
- priority TEXT NOT NULL CHECK (priority IN ('none', 'urgent', 'high', 'medium', 'low')),
285
- labels TEXT NOT NULL DEFAULT '[]',
286
- sort_order REAL NOT NULL,
287
- thread_id TEXT,
288
- thread_codex_project_id TEXT,
289
- thread_codex_project_kind TEXT,
290
- thread_codex_host_id TEXT,
291
- thread_workspace_path TEXT,
292
- creator_type TEXT NOT NULL DEFAULT 'user',
293
- creator_id TEXT NOT NULL DEFAULT 'local-user',
294
- creator_name TEXT NOT NULL DEFAULT '本地用户',
295
- creator_avatar_url TEXT,
296
- assignee_type TEXT NOT NULL DEFAULT 'user' CHECK (assignee_type IN ('user', 'agent')),
297
- assignee_id TEXT NOT NULL DEFAULT 'local-user',
298
- assignee_name TEXT NOT NULL DEFAULT '本地用户',
299
- assignee_avatar_url TEXT,
300
- git_branch TEXT,
301
- worktree_path TEXT,
302
- worktree_branch TEXT,
303
- start_date TEXT,
304
- due_date TEXT,
305
- recurrence_interval INTEGER,
306
- recurrence_unit TEXT,
307
- external_source TEXT,
308
- external_origin TEXT,
309
- external_id TEXT,
310
- external_key TEXT,
311
- external_url TEXT,
312
- archived_at TEXT,
313
- version INTEGER NOT NULL DEFAULT 1 CHECK (version > 0),
314
- created_at TEXT NOT NULL,
315
- updated_at TEXT NOT NULL
316
- );
317
-
318
- CREATE INDEX IF NOT EXISTS tasks_project_status_sort
319
- ON tasks(project_id, archived_at, status, sort_order, created_at);
320
-
321
- CREATE TABLE IF NOT EXISTS comments (
322
- id TEXT PRIMARY KEY,
323
- task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
324
- body TEXT NOT NULL,
325
- thread_id TEXT,
326
- thread_codex_project_id TEXT,
327
- thread_codex_project_kind TEXT,
328
- thread_codex_host_id TEXT,
329
- thread_workspace_path TEXT,
330
- author_type TEXT NOT NULL DEFAULT 'user',
331
- author_id TEXT NOT NULL,
332
- author_name TEXT NOT NULL,
333
- author_avatar_url TEXT,
334
- version INTEGER NOT NULL DEFAULT 1 CHECK (version > 0),
335
- created_at TEXT NOT NULL,
336
- updated_at TEXT NOT NULL,
337
- change_revision INTEGER NOT NULL DEFAULT 0
338
- );
339
-
340
- CREATE INDEX IF NOT EXISTS comments_task_created
341
- ON comments(task_id, created_at, id);
342
-
343
- CREATE TABLE IF NOT EXISTS task_activities (
344
- id TEXT PRIMARY KEY,
345
- task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
346
- actor_type TEXT NOT NULL CHECK (actor_type IN ('user', 'agent')),
347
- actor_id TEXT NOT NULL,
348
- actor_name TEXT NOT NULL,
349
- actor_avatar_url TEXT,
350
- changes TEXT NOT NULL,
351
- created_at TEXT NOT NULL
352
- );
353
-
354
- CREATE INDEX IF NOT EXISTS task_activities_task_created
355
- ON task_activities(task_id, created_at, id);
356
-
357
- CREATE TABLE IF NOT EXISTS attachments (
358
- id TEXT PRIMARY KEY,
359
- task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
360
- comment_id TEXT REFERENCES comments(id) ON DELETE CASCADE,
361
- kind TEXT NOT NULL CHECK (kind IN ('inline', 'attachment')),
362
- filename TEXT NOT NULL,
363
- content_type TEXT NOT NULL,
364
- size INTEGER NOT NULL CHECK (size >= 0),
365
- created_at TEXT NOT NULL,
366
- change_revision INTEGER NOT NULL DEFAULT 0
367
- );
368
-
369
- CREATE INDEX IF NOT EXISTS attachments_task_created
370
- ON attachments(task_id, created_at, id);
371
-
372
- CREATE TABLE IF NOT EXISTS comment_attachment_revision (
373
- id INTEGER PRIMARY KEY CHECK (id = 1),
374
- value INTEGER NOT NULL CHECK (value >= 0)
375
- );
376
-
377
- CREATE TABLE IF NOT EXISTS project_readmes (
378
- project_id TEXT PRIMARY KEY REFERENCES projects(id) ON DELETE CASCADE,
379
- content TEXT NOT NULL DEFAULT '',
380
- version INTEGER NOT NULL DEFAULT 1 CHECK (version > 0),
381
- created_at TEXT NOT NULL,
382
- updated_at TEXT NOT NULL
383
- );
384
-
385
- CREATE TABLE IF NOT EXISTS project_readme_attachments (
386
- id TEXT PRIMARY KEY,
387
- project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
388
- filename TEXT NOT NULL,
389
- content_type TEXT NOT NULL,
390
- size INTEGER NOT NULL CHECK (size >= 0),
391
- created_at TEXT NOT NULL
392
- );
393
-
394
- CREATE TABLE IF NOT EXISTS project_summaries (
395
- project_id TEXT PRIMARY KEY REFERENCES projects(id) ON DELETE CASCADE,
396
- summary TEXT,
397
- generated_at TEXT,
398
- attempted_at TEXT NOT NULL,
399
- error TEXT
400
- );
401
-
402
- CREATE TABLE IF NOT EXISTS ai_chat_threads (
403
- id TEXT PRIMARY KEY,
404
- title TEXT NOT NULL,
405
- status TEXT NOT NULL CHECK (status IN ('idle', 'running', 'failed')),
406
- origin_project_id TEXT NOT NULL,
407
- origin_project_name TEXT NOT NULL,
408
- origin_workspace_path TEXT NOT NULL,
409
- origin_codex_project_id TEXT,
410
- origin_codex_project_kind TEXT,
411
- origin_codex_host_id TEXT,
412
- origin_issue_id TEXT,
413
- origin_issue_identifier TEXT,
414
- codex_thread_id TEXT,
415
- model TEXT NOT NULL,
416
- reasoning_effort TEXT NOT NULL,
417
- sandbox TEXT NOT NULL CHECK (sandbox IN (
418
- 'read-only', 'workspace-write', 'danger-full-access'
419
- )),
420
- created_at TEXT NOT NULL,
421
- updated_at TEXT NOT NULL
422
- );
423
-
424
- CREATE INDEX IF NOT EXISTS ai_chat_threads_updated
425
- ON ai_chat_threads(updated_at DESC, id);
426
-
427
- CREATE TABLE IF NOT EXISTS ai_chat_runs (
428
- id TEXT PRIMARY KEY,
429
- thread_id TEXT NOT NULL REFERENCES ai_chat_threads(id) ON DELETE CASCADE,
430
- status TEXT NOT NULL CHECK (status IN (
431
- 'running', 'completed', 'failed', 'interrupted'
432
- )),
433
- exit_code INTEGER,
434
- error TEXT,
435
- started_at TEXT NOT NULL,
436
- finished_at TEXT
437
- );
438
-
439
- CREATE INDEX IF NOT EXISTS ai_chat_runs_thread_started
440
- ON ai_chat_runs(thread_id, started_at, id);
441
-
442
- CREATE UNIQUE INDEX IF NOT EXISTS ai_chat_runs_one_active
443
- ON ai_chat_runs(thread_id)
444
- WHERE status = 'running';
445
-
446
- CREATE TABLE IF NOT EXISTS ai_chat_events (
447
- id TEXT PRIMARY KEY,
448
- thread_id TEXT NOT NULL REFERENCES ai_chat_threads(id) ON DELETE CASCADE,
449
- run_id TEXT REFERENCES ai_chat_runs(id) ON DELETE CASCADE,
450
- type TEXT NOT NULL,
451
- role TEXT NOT NULL CHECK (role IN ('user', 'assistant', 'activity', 'error')),
452
- content TEXT NOT NULL,
453
- data TEXT,
454
- created_at TEXT NOT NULL
455
- );
456
-
457
- CREATE INDEX IF NOT EXISTS ai_chat_events_thread_created
458
- ON ai_chat_events(thread_id, created_at, id);
459
-
460
- `);
461
-
462
- const projectColumns = this.database.prepare("PRAGMA table_info(projects)").all();
463
- if (!projectColumns.some((column) => column.name === "workspace_path")) {
464
- this.database.exec("ALTER TABLE projects ADD COLUMN workspace_path TEXT");
465
- }
466
-
467
- const aiChatThreadColumns = this.database.prepare("PRAGMA table_info(ai_chat_threads)").all();
468
- for (const column of [
469
- "origin_codex_project_id",
470
- "origin_codex_project_kind",
471
- "origin_codex_host_id",
472
- ]) {
473
- if (!aiChatThreadColumns.some((candidate) => candidate.name === column)) {
474
- this.database.exec(`ALTER TABLE ai_chat_threads ADD COLUMN ${column} TEXT`);
475
- }
476
- }
477
-
478
- const taskColumns = this.database.prepare("PRAGMA table_info(tasks)").all();
479
- const hasWorkflowId = taskColumns.some((column) => column.name === "workflow_id");
480
- if (hasWorkflowId) {
481
- this.database.exec("ALTER TABLE tasks DROP COLUMN workflow_id");
482
- }
483
- this.database.exec("DROP TABLE IF EXISTS workflow_workspaces");
484
- const hasThreadId = taskColumns.some((column) => column.name === "thread_id");
485
- const hasLinkedThreadId = taskColumns.some((column) => column.name === "linked_thread_id");
486
- if (!hasThreadId) {
487
- this.database.exec("ALTER TABLE tasks ADD COLUMN thread_id TEXT");
488
- }
489
- for (const column of [
490
- "thread_codex_project_id",
491
- "thread_codex_project_kind",
492
- "thread_codex_host_id",
493
- "thread_workspace_path",
494
- ]) {
495
- if (!taskColumns.some((candidate) => candidate.name === column)) {
496
- this.database.exec(`ALTER TABLE tasks ADD COLUMN ${column} TEXT`);
497
- }
498
- }
499
- this.database.exec(`
500
- DROP TRIGGER IF EXISTS tasks_todo_execution_target_insert;
501
- DROP TRIGGER IF EXISTS tasks_todo_execution_target_update;
502
- `);
503
- if (hasLinkedThreadId) {
504
- this.database.exec(`
505
- UPDATE tasks
506
- SET thread_id = COALESCE(thread_id, linked_thread_id)
507
- `);
508
- this.database.exec("ALTER TABLE tasks DROP COLUMN linked_thread_id");
509
- }
510
- if (!taskColumns.some((column) => column.name === "git_branch")) {
511
- this.database.exec("ALTER TABLE tasks ADD COLUMN git_branch TEXT");
512
- }
513
- if (!taskColumns.some((column) => column.name === "worktree_path")) {
514
- this.database.exec("ALTER TABLE tasks ADD COLUMN worktree_path TEXT");
515
- }
516
- if (!taskColumns.some((column) => column.name === "worktree_branch")) {
517
- this.database.exec("ALTER TABLE tasks ADD COLUMN worktree_branch TEXT");
518
- }
519
- if (!taskColumns.some((column) => column.name === "due_date")) {
520
- this.database.exec("ALTER TABLE tasks ADD COLUMN due_date TEXT");
521
- }
522
- if (!taskColumns.some((column) => column.name === "start_date")) {
523
- this.database.exec("ALTER TABLE tasks ADD COLUMN start_date TEXT");
524
- }
525
- if (!taskColumns.some((column) => column.name === "recurrence_interval")) {
526
- this.database.exec("ALTER TABLE tasks ADD COLUMN recurrence_interval INTEGER");
527
- }
528
- if (!taskColumns.some((column) => column.name === "recurrence_unit")) {
529
- this.database.exec("ALTER TABLE tasks ADD COLUMN recurrence_unit TEXT");
530
- }
531
- this.#migrateTaskStatuses();
532
- const migratedTaskColumns = this.database.prepare("PRAGMA table_info(tasks)").all();
533
- if (!migratedTaskColumns.some((column) => column.name === "creator_type")) {
534
- this.database.exec("ALTER TABLE tasks ADD COLUMN creator_type TEXT NOT NULL DEFAULT 'user'");
535
- }
536
- if (!migratedTaskColumns.some((column) => column.name === "creator_id")) {
537
- this.database.exec("ALTER TABLE tasks ADD COLUMN creator_id TEXT NOT NULL DEFAULT 'local-user'");
538
- }
539
- if (!migratedTaskColumns.some((column) => column.name === "creator_name")) {
540
- this.database.exec("ALTER TABLE tasks ADD COLUMN creator_name TEXT NOT NULL DEFAULT '本地用户'");
541
- }
542
- if (!migratedTaskColumns.some((column) => column.name === "creator_avatar_url")) {
543
- this.database.exec("ALTER TABLE tasks ADD COLUMN creator_avatar_url TEXT");
544
- }
545
- if (!migratedTaskColumns.some((column) => column.name === "external_source")) {
546
- this.database.exec("ALTER TABLE tasks ADD COLUMN external_source TEXT");
547
- }
548
- if (!migratedTaskColumns.some((column) => column.name === "external_id")) {
549
- this.database.exec("ALTER TABLE tasks ADD COLUMN external_id TEXT");
550
- }
551
- if (!migratedTaskColumns.some((column) => column.name === "external_origin")) {
552
- this.database.exec("ALTER TABLE tasks ADD COLUMN external_origin TEXT");
553
- }
554
- if (!migratedTaskColumns.some((column) => column.name === "external_key")) {
555
- this.database.exec("ALTER TABLE tasks ADD COLUMN external_key TEXT");
556
- }
557
- if (!migratedTaskColumns.some((column) => column.name === "external_url")) {
558
- this.database.exec("ALTER TABLE tasks ADD COLUMN external_url TEXT");
559
- }
560
- this.database.exec(`
561
- DROP INDEX IF EXISTS tasks_external_source_id;
562
- CREATE UNIQUE INDEX IF NOT EXISTS tasks_external_source_origin_id
563
- ON tasks(external_source, external_origin, external_id)
564
- WHERE external_source IS NOT NULL AND external_origin IS NOT NULL AND external_id IS NOT NULL
565
- `);
566
- this.database.exec(`
567
- UPDATE tasks
568
- SET creator_type = 'agent', creator_id = 'codex-agent', creator_name = 'Codex Agent'
569
- WHERE thread_id IS NOT NULL AND version = 1 AND creator_id = 'local-user'
570
- `);
571
- const identityTaskColumns = this.database.prepare("PRAGMA table_info(tasks)").all();
572
- const assigneeMigrations = [
573
- ["assignee_type", "TEXT CHECK (assignee_type IN ('user', 'agent'))", "creator_type"],
574
- ["assignee_id", "TEXT", "creator_id"],
575
- ["assignee_name", "TEXT", "creator_name"],
576
- ["assignee_avatar_url", "TEXT", "creator_avatar_url"],
577
- ].filter(([column]) => !identityTaskColumns.some((current) => current.name === column));
578
- if (assigneeMigrations.length > 0) {
579
- this.database.exec("BEGIN IMMEDIATE");
580
- try {
581
- for (const [column, definition, source] of assigneeMigrations) {
582
- this.database.exec(`ALTER TABLE tasks ADD COLUMN ${column} ${definition}`);
583
- this.database.exec(`UPDATE tasks SET ${column} = ${source}`);
584
- }
585
- this.database.exec("COMMIT");
586
- } catch (error) {
587
- this.database.exec("ROLLBACK");
588
- throw error;
589
- }
590
- }
591
- if (!projectColumns.some((column) => column.name === "labels")) {
592
- this.database.exec("BEGIN IMMEDIATE");
593
- try {
594
- this.database.exec(`
595
- ALTER TABLE projects
596
- ADD COLUMN labels TEXT NOT NULL DEFAULT '${DEFAULT_PROJECT_LABELS_JSON}'
597
- `);
598
- const labelsByProject = new Map(
599
- this.database.prepare("SELECT id FROM projects").all().map((project) => (
600
- [project.id, [...DEFAULT_LABEL_NAMES]]
601
- )),
602
- );
603
- for (const task of this.database.prepare(`
604
- SELECT project_id, labels
605
- FROM tasks
606
- ORDER BY created_at, id
607
- `).all()) {
608
- const projectLabels = labelsByProject.get(task.project_id);
609
- if (!projectLabels) continue;
610
- for (const label of JSON.parse(task.labels)) {
611
- if (!projectLabels.includes(label)) projectLabels.push(label);
612
- }
613
- }
614
- const updateProjectLabels = this.database.prepare(`
615
- UPDATE projects SET labels = ? WHERE id = ?
616
- `);
617
- for (const [projectId, labels] of labelsByProject) {
618
- updateProjectLabels.run(JSON.stringify(labels), projectId);
619
- }
620
- this.database.exec("COMMIT");
621
- } catch (error) {
622
- this.database.exec("ROLLBACK");
623
- throw error;
624
- }
625
- }
626
- this.database.exec(`
627
- CREATE INDEX IF NOT EXISTS tasks_project_status_sort
628
- ON tasks(project_id, archived_at, status, sort_order, created_at)
629
- `);
630
- this.database.exec(`
631
- CREATE TABLE IF NOT EXISTS task_relations (
632
- relation_type TEXT NOT NULL CHECK (relation_type IN ('parent', 'blocks', 'related')),
633
- source_task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
634
- target_task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
635
- origin TEXT NOT NULL DEFAULT 'manual' CHECK (origin IN ('manual', 'mention')),
636
- created_at TEXT NOT NULL,
637
- CHECK (source_task_id <> target_task_id),
638
- CHECK (relation_type <> 'related' OR source_task_id < target_task_id),
639
- PRIMARY KEY (relation_type, source_task_id, target_task_id)
640
- );
641
-
642
- CREATE INDEX IF NOT EXISTS task_relations_target
643
- ON task_relations(relation_type, target_task_id);
644
-
645
- CREATE UNIQUE INDEX IF NOT EXISTS task_relations_one_parent
646
- ON task_relations(target_task_id)
647
- WHERE relation_type = 'parent';
648
-
649
- CREATE TRIGGER IF NOT EXISTS task_relations_require_same_project
650
- BEFORE INSERT ON task_relations
651
- BEGIN
652
- SELECT RAISE(ABORT, 'CROSS_PROJECT_RELATION')
653
- WHERE EXISTS (
654
- SELECT 1
655
- FROM tasks AS source
656
- JOIN tasks AS target ON target.id = NEW.target_task_id
657
- WHERE source.id = NEW.source_task_id
658
- AND source.project_id != target.project_id
659
- );
660
- END;
661
-
662
- CREATE TRIGGER IF NOT EXISTS task_relations_prevent_parent_cycle
663
- BEFORE INSERT ON task_relations
664
- WHEN NEW.relation_type = 'parent'
665
- BEGIN
666
- SELECT RAISE(ABORT, 'RELATION_CYCLE')
667
- WHERE EXISTS (
668
- WITH RECURSIVE ancestors(id) AS (
669
- SELECT source_task_id
670
- FROM task_relations
671
- WHERE relation_type = 'parent' AND target_task_id = NEW.source_task_id
672
- UNION
673
- SELECT task_relations.source_task_id
674
- FROM task_relations
675
- JOIN ancestors ON task_relations.target_task_id = ancestors.id
676
- WHERE task_relations.relation_type = 'parent'
677
- )
678
- SELECT 1 FROM ancestors WHERE id = NEW.target_task_id
679
- );
680
- END;
681
- `);
682
-
683
- const taskRelationColumns = this.database.prepare("PRAGMA table_info(task_relations)").all();
684
- if (!taskRelationColumns.some((column) => column.name === "origin")) {
685
- this.database.exec(`
686
- ALTER TABLE task_relations
687
- ADD COLUMN origin TEXT NOT NULL DEFAULT 'manual'
688
- CHECK (origin IN ('manual', 'mention'))
689
- `);
690
- }
691
-
692
- const commentColumns = this.database.prepare("PRAGMA table_info(comments)").all();
693
- if (!commentColumns.some((column) => column.name === "thread_id")) {
694
- this.database.exec("ALTER TABLE comments ADD COLUMN thread_id TEXT");
695
- }
696
- for (const column of [
697
- "thread_codex_project_id",
698
- "thread_codex_project_kind",
699
- "thread_codex_host_id",
700
- "thread_workspace_path",
701
- ]) {
702
- if (!commentColumns.some((candidate) => candidate.name === column)) {
703
- this.database.exec(`ALTER TABLE comments ADD COLUMN ${column} TEXT`);
704
- }
705
- }
706
- if (!commentColumns.some((column) => column.name === "author_type")) {
707
- this.database.exec("ALTER TABLE comments ADD COLUMN author_type TEXT NOT NULL DEFAULT 'user'");
708
- }
709
- if (!commentColumns.some((column) => column.name === "author_avatar_url")) {
710
- this.database.exec("ALTER TABLE comments ADD COLUMN author_avatar_url TEXT");
711
- }
712
- if (!commentColumns.some((column) => column.name === "change_revision")) {
713
- this.database.exec("ALTER TABLE comments ADD COLUMN change_revision INTEGER NOT NULL DEFAULT 0");
714
- }
715
- this.database.exec(`
716
- UPDATE comments
717
- SET author_type = 'agent', author_id = 'codex-agent', author_name = 'Codex Agent'
718
- WHERE thread_id IS NOT NULL AND author_id = 'local'
719
- `);
720
- this.database.exec(`
721
- UPDATE comments
722
- SET author_id = 'local-user'
723
- WHERE author_id = 'local'
724
- `);
725
-
726
- const hasTaskThreads = this.database.prepare(`
727
- SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'task_threads'
728
- `).get();
729
- if (hasTaskThreads) {
730
- this.database.exec(`
731
- UPDATE tasks AS migrated_task
732
- SET thread_id = COALESCE(thread_id, (
733
- SELECT task_threads.thread_id
734
- FROM task_threads
735
- LEFT JOIN comments
736
- ON comments.task_id = task_threads.task_id
737
- AND comments.thread_id = task_threads.thread_id
738
- WHERE task_threads.task_id = migrated_task.id
739
- ORDER BY
740
- CASE WHEN comments.id IS NOT NULL THEN 1 ELSE 0 END,
741
- task_threads.created_at DESC,
742
- task_threads.thread_id DESC
743
- LIMIT 1
744
- ))
745
- WHERE thread_id IS NULL
746
- `);
747
- this.database.exec("DROP TABLE task_threads");
748
- }
749
-
750
- const attachmentColumns = this.database.prepare("PRAGMA table_info(attachments)").all();
751
- if (!attachmentColumns.some((column) => column.name === "comment_id")) {
752
- this.database.exec("ALTER TABLE attachments ADD COLUMN comment_id TEXT REFERENCES comments(id) ON DELETE CASCADE");
753
- }
754
- if (!attachmentColumns.some((column) => column.name === "kind")) {
755
- this.database.exec("ALTER TABLE attachments ADD COLUMN kind TEXT NOT NULL DEFAULT 'attachment' CHECK (kind IN ('inline', 'attachment'))");
756
- this.database.exec(`
757
- UPDATE attachments
758
- SET kind = 'inline'
759
- WHERE content_type LIKE 'image/%'
760
- AND (
761
- (
762
- comment_id IS NULL
763
- AND EXISTS (
764
- SELECT 1 FROM tasks
765
- WHERE tasks.id = attachments.task_id
766
- AND instr(tasks.description, 'api/attachments/' || attachments.id || '/content') > 0
767
- )
768
- )
769
- OR (
770
- comment_id IS NOT NULL
771
- AND EXISTS (
772
- SELECT 1 FROM comments
773
- WHERE comments.id = attachments.comment_id
774
- AND instr(comments.body, 'api/attachments/' || attachments.id || '/content') > 0
775
- )
776
- )
777
- )
778
- `);
779
- }
780
- if (!attachmentColumns.some((column) => column.name === "change_revision")) {
781
- this.database.exec("ALTER TABLE attachments ADD COLUMN change_revision INTEGER NOT NULL DEFAULT 0");
782
- }
783
- this.database.exec("CREATE INDEX IF NOT EXISTS comments_task_change_revision ON comments(task_id, change_revision)");
784
- this.database.exec("CREATE INDEX IF NOT EXISTS attachments_comment_created ON attachments(comment_id, created_at, id)");
785
- this.database.exec("CREATE INDEX IF NOT EXISTS attachments_task_change_revision ON attachments(task_id, change_revision) WHERE comment_id IS NULL");
786
- this.database.exec("CREATE INDEX IF NOT EXISTS attachments_comment_change_revision ON attachments(comment_id, change_revision) WHERE comment_id IS NOT NULL");
787
- const maxChangeRevision = this.database.prepare(`
788
- SELECT MAX(change_revision) AS value
789
- FROM (
790
- SELECT change_revision FROM comments
791
- UNION ALL
792
- SELECT change_revision FROM attachments
793
- )
794
- `).get().value ?? 0;
795
- this.database.prepare(`
796
- INSERT INTO comment_attachment_revision (id, value)
797
- VALUES (1, ?)
798
- ON CONFLICT(id) DO UPDATE SET value = MAX(value, excluded.value)
799
- `).run(maxChangeRevision);
800
-
801
- const timestamp = now();
802
- this.database.prepare(`
803
- INSERT INTO projects (id, name, workspace_path, next_task_number, created_at, updated_at)
804
- VALUES ('local', '全局', NULL, 1, ?, ?)
805
- ON CONFLICT(id) DO NOTHING
806
- `).run(timestamp, timestamp);
807
- this.database.prepare(`
808
- UPDATE projects
809
- SET name = '全局', workspace_path = NULL, updated_at = ?
810
- WHERE id = 'local' AND (name != '全局' OR workspace_path IS NOT NULL)
811
- `).run(timestamp);
812
- }
813
-
814
- close() {
815
- this.database.close();
816
- }
817
-
818
- #migrateTaskStatuses() {
819
- const tasksSql = this.database.prepare(`
820
- SELECT sql FROM sqlite_schema WHERE type = 'table' AND name = 'tasks'
821
- `).get()?.sql ?? "";
822
- if (
823
- tasksSql.includes("'in_review'")
824
- && tasksSql.includes("'blocked'")
825
- && tasksSql.includes("'canceled'")
826
- ) {
827
- return;
828
- }
829
-
830
- this.database.exec("PRAGMA foreign_keys = OFF; BEGIN IMMEDIATE");
831
- try {
832
- this.database.exec(`
833
- CREATE TABLE tasks_status_migration (
834
- id TEXT PRIMARY KEY,
835
- identifier TEXT NOT NULL UNIQUE,
836
- project_id TEXT NOT NULL REFERENCES projects(id),
837
- title TEXT NOT NULL,
838
- description TEXT NOT NULL DEFAULT '',
839
- status TEXT NOT NULL CHECK (status IN (
840
- 'backlog', 'todo', 'in_progress', 'in_review', 'blocked', 'done', 'canceled'
841
- )),
842
- priority TEXT NOT NULL CHECK (priority IN ('none', 'urgent', 'high', 'medium', 'low')),
843
- labels TEXT NOT NULL DEFAULT '[]',
844
- sort_order REAL NOT NULL,
845
- thread_id TEXT,
846
- thread_codex_project_id TEXT,
847
- thread_codex_project_kind TEXT,
848
- thread_codex_host_id TEXT,
849
- thread_workspace_path TEXT,
850
- git_branch TEXT,
851
- worktree_path TEXT,
852
- worktree_branch TEXT,
853
- start_date TEXT,
854
- due_date TEXT,
855
- recurrence_interval INTEGER,
856
- recurrence_unit TEXT,
857
- archived_at TEXT,
858
- version INTEGER NOT NULL DEFAULT 1 CHECK (version > 0),
859
- created_at TEXT NOT NULL,
860
- updated_at TEXT NOT NULL
861
- );
862
-
863
- INSERT INTO tasks_status_migration (
864
- id, identifier, project_id, title, description, status, priority, labels,
865
- sort_order, thread_id, thread_codex_project_id, thread_codex_project_kind,
866
- thread_codex_host_id, thread_workspace_path, git_branch, worktree_path, worktree_branch,
867
- start_date, due_date, recurrence_interval, recurrence_unit,
868
- archived_at, version, created_at, updated_at
869
- )
870
- SELECT
871
- id, identifier, project_id, title, description, status, priority, labels,
872
- sort_order, thread_id, thread_codex_project_id, thread_codex_project_kind,
873
- thread_codex_host_id, thread_workspace_path, git_branch, worktree_path, worktree_branch,
874
- start_date, due_date, recurrence_interval, recurrence_unit,
875
- archived_at, version, created_at, updated_at
876
- FROM tasks;
877
-
878
- DROP TABLE tasks;
879
- ALTER TABLE tasks_status_migration RENAME TO tasks;
880
- `);
881
- this.database.exec("COMMIT");
882
- } catch (error) {
883
- this.database.exec("ROLLBACK");
884
- throw error;
885
- } finally {
886
- this.database.exec("PRAGMA foreign_keys = ON");
887
- }
888
-
889
- const violation = this.database.prepare("PRAGMA foreign_key_check").get();
890
- if (violation) {
891
- throw new Error(`Task status migration produced a foreign key violation in '${violation.table}'`);
892
- }
893
- }
894
-
895
- listProjects() {
896
- return this.database.prepare(`
897
- SELECT
898
- projects.id,
899
- projects.name,
900
- projects.workspace_path,
901
- projects.labels,
902
- projects.created_at,
903
- projects.updated_at,
904
- COUNT(tasks.id) AS issue_count
905
- FROM projects
906
- LEFT JOIN tasks
907
- ON tasks.project_id = projects.id
908
- AND tasks.archived_at IS NULL
909
- GROUP BY
910
- projects.id,
911
- projects.name,
912
- projects.workspace_path,
913
- projects.labels,
914
- projects.created_at,
915
- projects.updated_at
916
- ORDER BY projects.created_at, projects.id
917
- `).all().map(projectFromRow);
918
- }
919
-
920
- createProject(input) {
921
- const timestamp = now();
922
- try {
923
- this.database.prepare(`
924
- INSERT INTO projects (
925
- id, name, workspace_path, labels, next_task_number, created_at, updated_at
926
- ) VALUES (?, ?, ?, ?, 1, ?, ?)
927
- `).run(
928
- input.id,
929
- input.name,
930
- input.workspacePath,
931
- DEFAULT_PROJECT_LABELS_JSON,
932
- timestamp,
933
- timestamp,
934
- );
935
- } catch (error) {
936
- if (String(error.message).includes("UNIQUE constraint failed")) {
937
- throw new ApiError(409, "PROJECT_EXISTS", `Project '${input.id}' already exists`);
938
- }
939
- throw error;
940
- }
941
- return this.getProject(input.id);
942
- }
943
-
944
- ensureJiraProject(name) {
945
- const timestamp = now();
946
- this.database.prepare(`
947
- INSERT INTO projects (id, name, workspace_path, next_task_number, created_at, updated_at)
948
- VALUES (?, ?, NULL, 1, ?, ?)
949
- ON CONFLICT(id) DO UPDATE SET name = excluded.name, updated_at = excluded.updated_at
950
- `).run(JIRA_PROJECT_ID, name, timestamp, timestamp);
951
- return this.database.prepare(`
952
- SELECT
953
- projects.id,
954
- projects.name,
955
- projects.workspace_path,
956
- projects.created_at,
957
- projects.updated_at,
958
- COUNT(tasks.id) AS issue_count
959
- FROM projects
960
- LEFT JOIN tasks ON tasks.project_id = projects.id AND tasks.archived_at IS NULL
961
- WHERE projects.id = ?
962
- GROUP BY projects.id
963
- `).get(JIRA_PROJECT_ID);
964
- }
965
-
966
- syncJiraTasks(issues, { archiveMissing = true, projectName, legacyIdentity = null } = {}) {
967
- const timestamp = now();
968
- const seenTaskIds = new Set();
969
- const projectLabels = JSON.stringify([
970
- ...new Set(issues.flatMap((issue) => issue.labels)),
971
- ]);
972
- this.database.exec("BEGIN IMMEDIATE");
973
- try {
974
- this.database.prepare(`
975
- INSERT INTO projects (id, name, workspace_path, labels, next_task_number, created_at, updated_at)
976
- VALUES (?, ?, NULL, ?, 1, ?, ?)
977
- ON CONFLICT(id) DO UPDATE SET
978
- name = excluded.name,
979
- labels = excluded.labels,
980
- updated_at = excluded.updated_at
981
- `).run(JIRA_PROJECT_ID, projectName, projectLabels, timestamp, timestamp);
982
- const findExisting = this.database.prepare(`
983
- SELECT * FROM tasks
984
- WHERE external_source = 'jira' AND external_origin = ? AND external_id = ?
985
- `);
986
- const migrateLegacyIdentity = this.database.prepare(`
987
- UPDATE tasks SET
988
- identifier = ?, external_origin = ?, external_id = ?, external_key = ?
989
- WHERE id = ?
990
- `);
991
- if (legacyIdentity) {
992
- const legacyTasks = this.database.prepare(`
993
- SELECT id, identifier, external_id
994
- FROM tasks
995
- WHERE project_id = ?
996
- AND external_source = 'jira'
997
- AND external_origin IS NULL
998
- AND substr(external_id, 1, 17) = ?
999
- AND id = 'jira:' || external_id
1000
- `).all(JIRA_PROJECT_ID, `${legacyIdentity.urlHash}:`);
1001
- for (const legacyTask of legacyTasks) {
1002
- const externalId = legacyTask.external_id.slice(17);
1003
- migrateLegacyIdentity.run(
1004
- `JIRA:${legacyIdentity.originId.toUpperCase()}:${externalId}`,
1005
- legacyIdentity.originId,
1006
- externalId,
1007
- legacyTask.identifier,
1008
- legacyTask.id,
1009
- );
1010
- }
1011
- }
1012
- const insertTask = this.database.prepare(`
1013
- INSERT INTO tasks (
1014
- id, identifier, project_id, title, description, status, priority, labels,
1015
- sort_order, thread_id, thread_codex_project_id, thread_codex_project_kind,
1016
- thread_codex_host_id, thread_workspace_path,
1017
- creator_type, creator_id, creator_name, creator_avatar_url,
1018
- assignee_type, assignee_id, assignee_name, assignee_avatar_url,
1019
- git_branch, worktree_path, worktree_branch,
1020
- start_date, due_date, recurrence_interval, recurrence_unit,
1021
- external_source, external_origin, external_id, external_key, external_url,
1022
- archived_at, version, created_at, updated_at
1023
- ) VALUES (
1024
- ?, ?, ?, ?, ?, ?, ?, ?,
1025
- ?, NULL, NULL, NULL, NULL, NULL,
1026
- ?, ?, ?, ?,
1027
- ?, ?, ?, ?,
1028
- NULL, NULL, NULL,
1029
- NULL, ?, NULL, NULL,
1030
- 'jira', ?, ?, ?, ?,
1031
- NULL, 1, ?, ?
1032
- )
1033
- `);
1034
- const updateTask = this.database.prepare(`
1035
- UPDATE tasks SET
1036
- identifier = ?, title = ?, description = ?, status = ?, priority = ?, labels = ?,
1037
- sort_order = ?, creator_type = ?, creator_id = ?, creator_name = ?, creator_avatar_url = ?,
1038
- assignee_type = ?, assignee_id = ?, assignee_name = ?, assignee_avatar_url = ?,
1039
- due_date = ?, external_origin = ?, external_id = ?, external_key = ?, external_url = ?,
1040
- archived_at = NULL,
1041
- version = version + 1, updated_at = ?
1042
- WHERE id = ?
1043
- `);
1044
-
1045
- for (const issue of issues) {
1046
- const existing = findExisting.get(issue.externalOrigin, issue.externalId);
1047
- seenTaskIds.add(existing?.id ?? issue.id);
1048
- const labels = JSON.stringify(issue.labels);
1049
- if (!existing) {
1050
- insertTask.run(
1051
- issue.id,
1052
- issue.identifier,
1053
- JIRA_PROJECT_ID,
1054
- issue.title,
1055
- issue.description,
1056
- issue.status,
1057
- issue.priority,
1058
- labels,
1059
- issue.sortOrder,
1060
- issue.creator.type,
1061
- issue.creator.id,
1062
- issue.creator.name,
1063
- issue.creator.avatarUrl,
1064
- issue.assignee.type,
1065
- issue.assignee.id,
1066
- issue.assignee.name,
1067
- issue.assignee.avatarUrl,
1068
- issue.dueDate,
1069
- issue.externalOrigin,
1070
- issue.externalId,
1071
- issue.externalKey,
1072
- issue.externalUrl,
1073
- issue.createdAt,
1074
- issue.updatedAt,
1075
- );
1076
- continue;
1077
- }
1078
-
1079
- const changed = existing.identifier !== issue.identifier
1080
- || existing.title !== issue.title
1081
- || existing.description !== issue.description
1082
- || existing.status !== issue.status
1083
- || existing.priority !== issue.priority
1084
- || existing.labels !== labels
1085
- || existing.sort_order !== issue.sortOrder
1086
- || existing.creator_type !== issue.creator.type
1087
- || existing.creator_id !== issue.creator.id
1088
- || existing.creator_name !== issue.creator.name
1089
- || existing.creator_avatar_url !== issue.creator.avatarUrl
1090
- || existing.assignee_type !== issue.assignee.type
1091
- || existing.assignee_id !== issue.assignee.id
1092
- || existing.assignee_name !== issue.assignee.name
1093
- || existing.assignee_avatar_url !== issue.assignee.avatarUrl
1094
- || existing.due_date !== issue.dueDate
1095
- || existing.external_origin !== issue.externalOrigin
1096
- || existing.external_id !== issue.externalId
1097
- || existing.external_key !== issue.externalKey
1098
- || existing.external_url !== issue.externalUrl
1099
- || existing.archived_at !== null;
1100
- if (!changed) continue;
1101
- updateTask.run(
1102
- issue.identifier,
1103
- issue.title,
1104
- issue.description,
1105
- issue.status,
1106
- issue.priority,
1107
- labels,
1108
- issue.sortOrder,
1109
- issue.creator.type,
1110
- issue.creator.id,
1111
- issue.creator.name,
1112
- issue.creator.avatarUrl,
1113
- issue.assignee.type,
1114
- issue.assignee.id,
1115
- issue.assignee.name,
1116
- issue.assignee.avatarUrl,
1117
- issue.dueDate,
1118
- issue.externalOrigin,
1119
- issue.externalId,
1120
- issue.externalKey,
1121
- issue.externalUrl,
1122
- issue.updatedAt,
1123
- existing.id,
1124
- );
1125
- }
1126
-
1127
- if (archiveMissing) {
1128
- const existingTasks = this.database.prepare(`
1129
- SELECT id FROM tasks
1130
- WHERE project_id = ? AND external_source = 'jira' AND archived_at IS NULL
1131
- `).all(JIRA_PROJECT_ID);
1132
- const archiveTask = this.database.prepare(`
1133
- UPDATE tasks
1134
- SET archived_at = ?, version = version + 1, updated_at = ?
1135
- WHERE id = ?
1136
- `);
1137
- for (const task of existingTasks) {
1138
- if (!seenTaskIds.has(task.id)) {
1139
- archiveTask.run(timestamp, timestamp, task.id);
1140
- }
1141
- }
1142
- }
1143
- this.database.prepare("UPDATE projects SET updated_at = ? WHERE id = ?")
1144
- .run(timestamp, JIRA_PROJECT_ID);
1145
- this.database.exec("COMMIT");
1146
- } catch (error) {
1147
- this.database.exec("ROLLBACK");
1148
- throw error;
1149
- }
1150
- }
1151
-
1152
- deleteProject(id) {
1153
- const project = this.getProject(id);
1154
- if (!project) {
1155
- throw new ApiError(404, "PROJECT_NOT_FOUND", `Project '${id}' does not exist`);
1156
- }
1157
- if (!id.startsWith("temp-")) {
1158
- throw new ApiError(403, "PROJECT_DELETE_FORBIDDEN", "Only manually created projects can be deleted");
1159
- }
1160
- const result = this.database.prepare(`
1161
- DELETE FROM projects
1162
- WHERE id = ?
1163
- AND NOT EXISTS (SELECT 1 FROM tasks WHERE project_id = ?)
1164
- `).run(id, id);
1165
- if (result.changes !== 1) {
1166
- const issueCount = Number(this.database.prepare(`
1167
- SELECT COUNT(*) AS issue_count FROM tasks WHERE project_id = ?
1168
- `).get(id).issue_count);
1169
- throw new ApiError(409, "PROJECT_NOT_EMPTY", "Project still contains issues", { issueCount });
1170
- }
1171
- return project;
1172
- }
1173
-
1174
- getProject(id) {
1175
- const row = this.database.prepare(`
1176
- SELECT
1177
- projects.id,
1178
- projects.name,
1179
- projects.workspace_path,
1180
- projects.labels,
1181
- projects.created_at,
1182
- projects.updated_at,
1183
- COUNT(tasks.id) AS issue_count
1184
- FROM projects
1185
- LEFT JOIN tasks
1186
- ON tasks.project_id = projects.id
1187
- AND tasks.archived_at IS NULL
1188
- WHERE projects.id = ?
1189
- GROUP BY
1190
- projects.id,
1191
- projects.name,
1192
- projects.workspace_path,
1193
- projects.labels,
1194
- projects.created_at,
1195
- projects.updated_at
1196
- `).get(id);
1197
- return row ? projectFromRow(row) : null;
1198
- }
1199
-
1200
- addProjectLabel(projectId, label) {
1201
- const project = this.database.prepare("SELECT labels FROM projects WHERE id = ?").get(projectId);
1202
- if (!project) {
1203
- throw new ApiError(404, "PROJECT_NOT_FOUND", `Project '${projectId}' does not exist`);
1204
- }
1205
- const labels = JSON.parse(project.labels);
1206
- if (!labels.includes(label)) {
1207
- this.database.prepare(`
1208
- UPDATE projects SET labels = ?, updated_at = ? WHERE id = ?
1209
- `).run(JSON.stringify([...labels, label]), now(), projectId);
1210
- }
1211
- return this.getProject(projectId);
1212
- }
1213
-
1214
- deleteProjectLabel(projectId, label) {
1215
- this.database.exec("BEGIN IMMEDIATE");
1216
- try {
1217
- const project = this.database.prepare("SELECT labels FROM projects WHERE id = ?").get(projectId);
1218
- if (!project) {
1219
- throw new ApiError(404, "PROJECT_NOT_FOUND", `Project '${projectId}' does not exist`);
1220
- }
1221
- const timestamp = now();
1222
- const labels = JSON.parse(project.labels);
1223
- if (labels.includes(label)) {
1224
- this.database.prepare(`
1225
- UPDATE projects SET labels = ?, updated_at = ? WHERE id = ?
1226
- `).run(JSON.stringify(labels.filter((current) => current !== label)), timestamp, projectId);
1227
- }
1228
- const updateTask = this.database.prepare(`
1229
- UPDATE tasks
1230
- SET labels = ?, version = version + 1, updated_at = ?
1231
- WHERE id = ?
1232
- `);
1233
- for (const task of this.database.prepare(`
1234
- SELECT id, labels FROM tasks WHERE project_id = ?
1235
- `).all(projectId)) {
1236
- const taskLabels = JSON.parse(task.labels);
1237
- if (taskLabels.includes(label)) {
1238
- updateTask.run(
1239
- JSON.stringify(taskLabels.filter((current) => current !== label)),
1240
- timestamp,
1241
- task.id,
1242
- );
1243
- }
1244
- }
1245
- this.database.exec("COMMIT");
1246
- } catch (error) {
1247
- this.database.exec("ROLLBACK");
1248
- throw error;
1249
- }
1250
- return this.getProject(projectId);
1251
- }
1252
-
1253
- getProjectSummary(projectId) {
1254
- const row = this.database.prepare(`
1255
- SELECT project_id, summary, generated_at, attempted_at, error
1256
- FROM project_summaries
1257
- WHERE project_id = ?
1258
- `).get(projectId);
1259
- return row ? projectSummaryFromRow(row) : {
1260
- projectId,
1261
- summary: null,
1262
- generatedAt: null,
1263
- attemptedAt: null,
1264
- error: null,
1265
- };
1266
- }
1267
-
1268
- listProjectSummaries() {
1269
- return this.database.prepare(`
1270
- SELECT project_id, summary, generated_at, attempted_at, error
1271
- FROM project_summaries
1272
- ORDER BY project_id
1273
- `).all().map(projectSummaryFromRow);
1274
- }
1275
-
1276
- saveProjectSummary(projectId, summary) {
1277
- const timestamp = now();
1278
- this.database.prepare(`
1279
- INSERT INTO project_summaries (
1280
- project_id, summary, generated_at, attempted_at, error
1281
- ) VALUES (?, ?, ?, ?, NULL)
1282
- ON CONFLICT(project_id) DO UPDATE SET
1283
- summary = excluded.summary,
1284
- generated_at = excluded.generated_at,
1285
- attempted_at = excluded.attempted_at,
1286
- error = NULL
1287
- `).run(projectId, summary, timestamp, timestamp);
1288
- return this.getProjectSummary(projectId);
1289
- }
1290
-
1291
- saveProjectSummaryError(projectId, error) {
1292
- const timestamp = now();
1293
- this.database.prepare(`
1294
- INSERT INTO project_summaries (
1295
- project_id, summary, generated_at, attempted_at, error
1296
- ) VALUES (?, NULL, NULL, ?, ?)
1297
- ON CONFLICT(project_id) DO UPDATE SET
1298
- attempted_at = excluded.attempted_at,
1299
- error = excluded.error
1300
- `).run(projectId, timestamp, error);
1301
- return this.getProjectSummary(projectId);
1302
- }
1303
-
1304
- getProjectReadme(projectId) {
1305
- if (!this.database.prepare("SELECT 1 FROM projects WHERE id = ?").get(projectId)) {
1306
- throw new ApiError(404, "PROJECT_NOT_FOUND", `Project '${projectId}' does not exist`);
1307
- }
1308
- const row = this.database.prepare(`
1309
- SELECT project_id, content, version, created_at, updated_at
1310
- FROM project_readmes
1311
- WHERE project_id = ?
1312
- `).get(projectId);
1313
- return row
1314
- ? projectReadmeFromRow(row, projectId)
1315
- : { projectId, content: "", version: 0, createdAt: null, updatedAt: null };
1316
- }
1317
-
1318
- saveProjectReadme(projectId, content, expectedVersion) {
1319
- const timestamp = now();
1320
- this.database.exec("BEGIN IMMEDIATE");
1321
- try {
1322
- if (!this.database.prepare("SELECT 1 FROM projects WHERE id = ?").get(projectId)) {
1323
- throw new ApiError(404, "PROJECT_NOT_FOUND", `Project '${projectId}' does not exist`);
1324
- }
1325
- const current = this.database.prepare(`
1326
- SELECT version FROM project_readmes WHERE project_id = ?
1327
- `).get(projectId);
1328
- if (expectedVersion !== undefined) {
1329
- const actualVersion = current?.version ?? 0;
1330
- if (actualVersion !== expectedVersion) {
1331
- throw new ApiError(409, "VERSION_CONFLICT", "Project README changed since it was last read", {
1332
- expectedVersion,
1333
- actualVersion,
1334
- });
1335
- }
1336
- }
1337
- if (current) {
1338
- const versionCondition = expectedVersion !== undefined ? " AND version = ?" : "";
1339
- const params = expectedVersion !== undefined
1340
- ? [content, timestamp, projectId, expectedVersion]
1341
- : [content, timestamp, projectId];
1342
- this.database.prepare(`
1343
- UPDATE project_readmes
1344
- SET content = ?, version = version + 1, updated_at = ?
1345
- WHERE project_id = ?${versionCondition}
1346
- `).run(...params);
1347
- } else {
1348
- this.database.prepare(`
1349
- INSERT INTO project_readmes (project_id, content, version, created_at, updated_at)
1350
- VALUES (?, ?, 1, ?, ?)
1351
- `).run(projectId, content, timestamp, timestamp);
1352
- }
1353
- this.database.exec("COMMIT");
1354
- } catch (error) {
1355
- this.database.exec("ROLLBACK");
1356
- throw error;
1357
- }
1358
- return this.getProjectReadme(projectId);
1359
- }
1360
-
1361
- createProjectReadmeAttachment(projectId, input) {
1362
- if (!this.database.prepare("SELECT 1 FROM projects WHERE id = ?").get(projectId)) {
1363
- throw new ApiError(404, "PROJECT_NOT_FOUND", `Project '${projectId}' does not exist`);
1364
- }
1365
- this.database.prepare(`
1366
- INSERT INTO project_readme_attachments (
1367
- id, project_id, filename, content_type, size, created_at
1368
- ) VALUES (?, ?, ?, ?, ?, ?)
1369
- `).run(
1370
- input.id,
1371
- projectId,
1372
- input.filename,
1373
- input.contentType,
1374
- input.size,
1375
- now(),
1376
- );
1377
- return this.getProjectReadmeAttachment(input.id);
1378
- }
1379
-
1380
- getProjectReadmeAttachment(id) {
1381
- const row = this.database.prepare(`
1382
- SELECT * FROM project_readme_attachments WHERE id = ?
1383
- `).get(id);
1384
- return row ? projectReadmeAttachmentFromRow(row) : null;
1385
- }
1386
-
1387
- listAiChatThreads() {
1388
- const rows = this.database.prepare(`
1389
- SELECT * FROM ai_chat_threads
1390
- ORDER BY updated_at DESC, id
1391
- `).all();
1392
- if (rows.length === 0) return [];
1393
-
1394
- const currentRuns = new Map();
1395
- for (const row of this.database.prepare(`
1396
- SELECT * FROM ai_chat_runs
1397
- WHERE status = 'running'
1398
- ORDER BY thread_id, started_at DESC, id DESC
1399
- `).all()) {
1400
- if (!currentRuns.has(row.thread_id)) currentRuns.set(row.thread_id, aiChatRunFromRow(row));
1401
- }
1402
-
1403
- const latestTodos = new Map();
1404
- for (const row of this.database.prepare(`
1405
- SELECT id, thread_id, run_id, data, created_at
1406
- FROM ai_chat_events
1407
- WHERE type = 'todo_list'
1408
- ORDER BY thread_id, created_at DESC, rowid DESC
1409
- `).all()) {
1410
- if (latestTodos.has(row.thread_id)) continue;
1411
- const currentRun = currentRuns.get(row.thread_id);
1412
- if (currentRun && row.run_id !== currentRun.id) continue;
1413
- const progress = parseAiChatTodoProgress(row);
1414
- if (progress) latestTodos.set(row.thread_id, progress);
1415
- }
1416
-
1417
- return rows.map((row) => {
1418
- const thread = aiChatThreadFromRow(row);
1419
- thread.currentRun = currentRuns.get(thread.id) ?? null;
1420
- thread.latestTodo = latestTodos.get(thread.id) ?? null;
1421
- return thread;
1422
- });
1423
- }
1424
-
1425
- getAiChatThread(id) {
1426
- const row = this.database.prepare("SELECT * FROM ai_chat_threads WHERE id = ?").get(id);
1427
- return row ? this.#aiChatThreadWithCurrentRun(row) : null;
1428
- }
1429
-
1430
- hasAiChatThreadProjectConflict(issueRef, projectId) {
1431
- return Boolean(this.database.prepare(`
1432
- SELECT 1
1433
- FROM ai_chat_threads
1434
- WHERE (origin_issue_id = ? OR origin_issue_identifier = ?)
1435
- AND origin_project_id != ?
1436
- LIMIT 1
1437
- `).get(issueRef, issueRef, projectId));
1438
- }
1439
-
1440
- createAiChatThread(input) {
1441
- const id = input.id ?? randomUUID();
1442
- const timestamp = input.createdAt ?? now();
1443
- this.database.prepare(`
1444
- INSERT INTO ai_chat_threads (
1445
- id, title, status,
1446
- origin_project_id, origin_project_name, origin_workspace_path,
1447
- origin_codex_project_id, origin_codex_project_kind, origin_codex_host_id,
1448
- origin_issue_id, origin_issue_identifier,
1449
- codex_thread_id, model, reasoning_effort, sandbox,
1450
- created_at, updated_at
1451
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1452
- `).run(
1453
- id,
1454
- input.title,
1455
- input.status ?? "idle",
1456
- input.origin.projectId,
1457
- input.origin.projectName,
1458
- input.origin.workspacePath,
1459
- input.origin.codexProjectId ?? null,
1460
- input.origin.codexProjectKind ?? null,
1461
- input.origin.codexHostId ?? null,
1462
- input.origin.issueId ?? null,
1463
- input.origin.issueIdentifier ?? null,
1464
- input.codexThreadId ?? null,
1465
- input.model,
1466
- input.reasoningEffort,
1467
- input.sandbox,
1468
- timestamp,
1469
- input.updatedAt ?? timestamp,
1470
- );
1471
- return this.getAiChatThread(id);
1472
- }
1473
-
1474
- updateAiChatThread(id, changes) {
1475
- const current = this.getAiChatThread(id);
1476
- if (!current) {
1477
- throw new ApiError(404, "AI_CHAT_THREAD_NOT_FOUND", `AI chat thread '${id}' does not exist`);
1478
- }
1479
- const columns = {
1480
- title: "title",
1481
- status: "status",
1482
- codexThreadId: "codex_thread_id",
1483
- model: "model",
1484
- reasoningEffort: "reasoning_effort",
1485
- sandbox: "sandbox",
1486
- };
1487
- const assignments = [];
1488
- const values = [];
1489
- for (const [key, column] of Object.entries(columns)) {
1490
- if (!Object.hasOwn(changes, key)) continue;
1491
- assignments.push(`${column} = ?`);
1492
- values.push(changes[key]);
1493
- }
1494
- if (assignments.length === 0) return current;
1495
- assignments.push("updated_at = ?");
1496
- values.push(changes.updatedAt ?? now(), id);
1497
- this.database.prepare(`
1498
- UPDATE ai_chat_threads SET ${assignments.join(", ")} WHERE id = ?
1499
- `).run(...values);
1500
- return this.getAiChatThread(id);
1501
- }
1502
-
1503
- deleteAiChatThread(id) {
1504
- const current = this.getAiChatThread(id);
1505
- if (!current) {
1506
- throw new ApiError(404, "AI_CHAT_THREAD_NOT_FOUND", `AI chat thread '${id}' does not exist`);
1507
- }
1508
- this.database.prepare("DELETE FROM ai_chat_threads WHERE id = ?").run(id);
1509
- return current;
1510
- }
1511
-
1512
- listAiChatRuns(threadId) {
1513
- return this.database.prepare(`
1514
- SELECT * FROM ai_chat_runs
1515
- WHERE thread_id = ?
1516
- ORDER BY started_at, id
1517
- `).all(threadId).map(aiChatRunFromRow);
1518
- }
1519
-
1520
- getAiChatRun(id) {
1521
- const row = this.database.prepare("SELECT * FROM ai_chat_runs WHERE id = ?").get(id);
1522
- return row ? aiChatRunFromRow(row) : null;
1523
- }
1524
-
1525
- createAiChatRun(input) {
1526
- const id = input.id ?? randomUUID();
1527
- const timestamp = input.startedAt ?? now();
1528
- this.database.exec("BEGIN IMMEDIATE");
1529
- try {
1530
- this.database.prepare(`
1531
- INSERT INTO ai_chat_runs (
1532
- id, thread_id, status, exit_code, error, started_at, finished_at
1533
- ) VALUES (?, ?, ?, ?, ?, ?, ?)
1534
- `).run(
1535
- id,
1536
- input.threadId,
1537
- input.status ?? "running",
1538
- input.exitCode ?? null,
1539
- input.error ?? null,
1540
- timestamp,
1541
- input.finishedAt ?? null,
1542
- );
1543
- if ((input.status ?? "running") === "running") {
1544
- this.database.prepare(`
1545
- UPDATE ai_chat_threads
1546
- SET status = 'running', updated_at = ?
1547
- WHERE id = ?
1548
- `).run(timestamp, input.threadId);
1549
- }
1550
- this.database.exec("COMMIT");
1551
- } catch (error) {
1552
- this.database.exec("ROLLBACK");
1553
- throw error;
1554
- }
1555
- return this.getAiChatRun(id);
1556
- }
1557
-
1558
- updateAiChatRun(id, changes) {
1559
- const current = this.getAiChatRun(id);
1560
- if (!current) {
1561
- throw new ApiError(404, "AI_CHAT_RUN_NOT_FOUND", `AI chat run '${id}' does not exist`);
1562
- }
1563
- const columns = {
1564
- status: "status",
1565
- exitCode: "exit_code",
1566
- error: "error",
1567
- finishedAt: "finished_at",
1568
- };
1569
- const assignments = [];
1570
- const values = [];
1571
- for (const [key, column] of Object.entries(columns)) {
1572
- if (!Object.hasOwn(changes, key)) continue;
1573
- assignments.push(`${column} = ?`);
1574
- values.push(changes[key]);
1575
- }
1576
- if (assignments.length === 0) return current;
1577
-
1578
- this.database.exec("BEGIN IMMEDIATE");
1579
- try {
1580
- values.push(id);
1581
- this.database.prepare(`
1582
- UPDATE ai_chat_runs SET ${assignments.join(", ")} WHERE id = ?
1583
- `).run(...values);
1584
- const status = changes.status ?? current.status;
1585
- if (status !== "running") {
1586
- const threadStatus = status === "failed" ? "failed" : "idle";
1587
- this.database.prepare(`
1588
- UPDATE ai_chat_threads
1589
- SET status = ?, updated_at = ?
1590
- WHERE id = ?
1591
- AND NOT EXISTS (
1592
- SELECT 1 FROM ai_chat_runs
1593
- WHERE thread_id = ? AND status = 'running'
1594
- )
1595
- `).run(threadStatus, changes.finishedAt ?? now(), current.threadId, current.threadId);
1596
- }
1597
- this.database.exec("COMMIT");
1598
- } catch (error) {
1599
- this.database.exec("ROLLBACK");
1600
- throw error;
1601
- }
1602
- return this.getAiChatRun(id);
1603
- }
1604
-
1605
- insertAiChatEvent(input) {
1606
- const id = input.id ?? randomUUID();
1607
- const timestamp = input.createdAt ?? now();
1608
- this.database.prepare(`
1609
- INSERT INTO ai_chat_events (
1610
- id, thread_id, run_id, type, role, content, data, created_at
1611
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1612
- `).run(
1613
- id,
1614
- input.threadId,
1615
- input.runId ?? null,
1616
- input.type,
1617
- input.role,
1618
- input.content,
1619
- input.data === undefined || input.data === null ? null : JSON.stringify(input.data),
1620
- timestamp,
1621
- );
1622
- const row = this.database.prepare("SELECT * FROM ai_chat_events WHERE id = ?").get(id);
1623
- return aiChatEventFromRow(row);
1624
- }
1625
-
1626
- listAiChatEvents(threadId) {
1627
- return this.database.prepare(`
1628
- SELECT * FROM ai_chat_events
1629
- WHERE thread_id = ?
1630
- ORDER BY created_at, rowid
1631
- `).all(threadId).map(aiChatEventFromRow);
1632
- }
1633
-
1634
- interruptAbandonedAiChatRuns() {
1635
- const timestamp = now();
1636
- this.database.exec("BEGIN IMMEDIATE");
1637
- try {
1638
- const result = this.database.prepare(`
1639
- UPDATE ai_chat_runs
1640
- SET
1641
- status = 'interrupted',
1642
- error = COALESCE(error, 'Taskboard service restarted'),
1643
- finished_at = COALESCE(finished_at, ?)
1644
- WHERE status = 'running'
1645
- `).run(timestamp);
1646
- if (result.changes > 0) {
1647
- this.database.prepare(`
1648
- UPDATE ai_chat_threads
1649
- SET status = 'idle', updated_at = ?
1650
- WHERE status = 'running'
1651
- AND NOT EXISTS (
1652
- SELECT 1 FROM ai_chat_runs
1653
- WHERE ai_chat_runs.thread_id = ai_chat_threads.id
1654
- AND ai_chat_runs.status = 'running'
1655
- )
1656
- `).run(timestamp);
1657
- }
1658
- this.database.exec("COMMIT");
1659
- return Number(result.changes);
1660
- } catch (error) {
1661
- this.database.exec("ROLLBACK");
1662
- throw error;
1663
- }
1664
- }
1665
-
1666
- listTasks(filters) {
1667
- const where = [];
1668
- const values = [];
1669
- if (filters.projectId) {
1670
- where.push("project_id = ?");
1671
- values.push(filters.projectId);
1672
- }
1673
- if (filters.status) {
1674
- where.push("status = ?");
1675
- values.push(filters.status);
1676
- }
1677
- if (filters.archived === "false") {
1678
- where.push("archived_at IS NULL");
1679
- } else if (filters.archived === "true") {
1680
- where.push("archived_at IS NOT NULL");
1681
- }
1682
-
1683
- const sql = `
1684
- SELECT * FROM tasks
1685
- ${where.length > 0 ? `WHERE ${where.join(" AND ")}` : ""}
1686
- ORDER BY
1687
- CASE status
1688
- WHEN 'backlog' THEN 1
1689
- WHEN 'todo' THEN 2
1690
- WHEN 'in_progress' THEN 3
1691
- WHEN 'in_review' THEN 4
1692
- WHEN 'blocked' THEN 5
1693
- WHEN 'done' THEN 6
1694
- WHEN 'canceled' THEN 7
1695
- END,
1696
- sort_order,
1697
- created_at,
1698
- id
1699
- `;
1700
- const rows = this.database.prepare(sql).all(...values);
1701
- const relationsByTask = this.#taskRelationsForTasks(rows.map((row) => row.id));
1702
- const commentsByTask = this.#commentsForTaskActivity(rows.map((row) => row.id));
1703
- const activitiesByTask = this.#activitiesForTasks(rows.map((row) => row.id));
1704
- const previewImagesByTask = this.#taskPreviewImages(rows.map((row) => row.id));
1705
- return rows.map((row) => attachTaskActivity(
1706
- this.#taskWithRelations(row, relationsByTask.get(row.id)),
1707
- commentsByTask.get(row.id) ?? [],
1708
- activitiesByTask.get(row.id) ?? [],
1709
- previewImagesByTask.get(row.id) ?? null,
1710
- ));
1711
- }
1712
-
1713
- getTaskSource(id) {
1714
- const row = this.database.prepare(
1715
- "SELECT external_source FROM tasks WHERE id = ? OR identifier = ?",
1716
- ).get(id, id);
1717
- return row ? (row.external_source === "jira" ? "jira" : "local") : null;
1718
- }
1719
-
1720
- getTask(id) {
1721
- const row = this.database.prepare("SELECT * FROM tasks WHERE id = ? OR identifier = ?").get(id, id);
1722
- if (!row) return null;
1723
- const task = this.#taskWithRelations(row);
1724
- const comments = this.#commentsForTaskActivity([task.id]).get(task.id) ?? [];
1725
- const activities = this.#activitiesForTasks([task.id]).get(task.id) ?? [];
1726
- const previewImage = this.#taskPreviewImages([task.id]).get(task.id) ?? null;
1727
- return attachTaskActivity(task, comments, activities, previewImage);
1728
- }
1729
-
1730
- getTaskTree(id, direction, depth) {
1731
- const root = this.database.prepare(
1732
- "SELECT * FROM tasks WHERE id = ? OR identifier = ?",
1733
- ).get(id, id);
1734
- if (!root) throw new ApiError(404, "TASK_NOT_FOUND", `Task '${id}' does not exist`);
1735
-
1736
- const nodes = [taskTreeNode(root, null, 0, [root.id])];
1737
- const seen = new Set([root.id]);
1738
- let frontier = [nodes[0]];
1739
- const relationJoin = direction === "descendants"
1740
- ? `
1741
- FROM task_relations
1742
- JOIN tasks ON tasks.id = task_relations.target_task_id
1743
- WHERE task_relations.relation_type = 'parent'
1744
- AND task_relations.source_task_id IN (%PLACEHOLDERS%)
1745
- `
1746
- : `
1747
- FROM task_relations
1748
- JOIN tasks ON tasks.id = task_relations.source_task_id
1749
- WHERE task_relations.relation_type = 'parent'
1750
- AND task_relations.target_task_id IN (%PLACEHOLDERS%)
1751
- `;
1752
- const parentColumn = direction === "descendants"
1753
- ? "task_relations.source_task_id"
1754
- : "task_relations.target_task_id";
1755
-
1756
- for (let level = 1; level <= depth && frontier.length > 0; level += 1) {
1757
- const placeholders = frontier.map(() => "?").join(", ");
1758
- const rows = this.database.prepare(`
1759
- SELECT tasks.*, ${parentColumn} AS tree_parent_id
1760
- ${relationJoin.replace("%PLACEHOLDERS%", placeholders)}
1761
- ORDER BY tasks.sort_order, tasks.created_at, tasks.id
1762
- `).all(...frontier.map((node) => node.id));
1763
- const rowsByParent = new Map();
1764
- for (const row of rows) {
1765
- const siblings = rowsByParent.get(row.tree_parent_id) ?? [];
1766
- siblings.push(row);
1767
- rowsByParent.set(row.tree_parent_id, siblings);
1768
- }
1769
- const next = [];
1770
- for (const parent of frontier) {
1771
- for (const row of rowsByParent.get(parent.id) ?? []) {
1772
- if (seen.has(row.id)) continue;
1773
- if (nodes.length >= TASK_TREE_MAX_NODES) {
1774
- throw new ApiError(413, "TREE_TOO_LARGE", `Task tree cannot exceed ${TASK_TREE_MAX_NODES} nodes`);
1775
- }
1776
- const node = taskTreeNode(row, parent.id, level, [...parent.path, row.id]);
1777
- nodes.push(node);
1778
- next.push(node);
1779
- seen.add(row.id);
1780
- }
1781
- }
1782
- frontier = next;
1783
- }
1784
-
1785
- return {
1786
- rootId: root.id,
1787
- direction,
1788
- depth,
1789
- nodeCount: nodes.length,
1790
- nodes,
1791
- };
1792
- }
1793
-
1794
- createTask(input) {
1795
- this.database.exec("BEGIN IMMEDIATE");
1796
- try {
1797
- const project = this.database.prepare(`
1798
- SELECT
1799
- projects.id,
1800
- projects.name,
1801
- projects.labels,
1802
- projects.next_task_number,
1803
- (
1804
- SELECT tasks.identifier
1805
- FROM tasks
1806
- WHERE tasks.project_id = projects.id
1807
- ORDER BY tasks.created_at, tasks.id
1808
- LIMIT 1
1809
- ) AS first_identifier
1810
- FROM projects
1811
- WHERE projects.id = ?
1812
- `).get(input.projectId);
1813
- if (!project) {
1814
- throw new ApiError(404, "PROJECT_NOT_FOUND", `Project '${input.projectId}' does not exist`);
1815
- }
1816
-
1817
- const prefix = projectPrefix(project);
1818
- const maximum = this.database.prepare(`
1819
- SELECT MAX(CAST(substr(identifier, ?) AS INTEGER)) AS number
1820
- FROM tasks
1821
- WHERE identifier GLOB ?
1822
- `).get(prefix.length + 2, `${prefix}-[0-9]*`).number;
1823
- const number = Math.max(project.next_task_number, maximum === null ? 1 : maximum + 1);
1824
- const identifier = `${prefix}-${number}`;
1825
- const id = randomUUID();
1826
- const timestamp = now();
1827
- let sortOrder = input.sortOrder;
1828
- if (sortOrder === undefined) {
1829
- const row = this.database.prepare(`
1830
- SELECT MIN(sort_order) AS minimum
1831
- FROM tasks
1832
- WHERE project_id = ? AND status = ? AND archived_at IS NULL
1833
- `).get(input.projectId, input.status);
1834
- sortOrder = row.minimum === null ? 1000 : row.minimum - 1000;
1835
- }
1836
-
1837
- this.database.prepare(`
1838
- UPDATE projects SET next_task_number = ?, labels = ?, updated_at = ? WHERE id = ?
1839
- `).run(
1840
- number + 1,
1841
- JSON.stringify([...new Set([...JSON.parse(project.labels), ...input.labels])]),
1842
- timestamp,
1843
- input.projectId,
1844
- );
1845
- this.database.prepare(`
1846
- INSERT INTO tasks (
1847
- id, identifier, project_id, title, description, status, priority, labels,
1848
- sort_order, thread_id, thread_codex_project_id, thread_codex_project_kind,
1849
- thread_codex_host_id, thread_workspace_path,
1850
- creator_type, creator_id, creator_name, creator_avatar_url,
1851
- assignee_type, assignee_id, assignee_name, assignee_avatar_url,
1852
- git_branch, worktree_path, worktree_branch,
1853
- start_date, due_date, recurrence_interval, recurrence_unit,
1854
- archived_at, version, created_at, updated_at
1855
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, 1, ?, ?)
1856
- `).run(
1857
- id,
1858
- identifier,
1859
- input.projectId,
1860
- input.title,
1861
- input.description,
1862
- input.status,
1863
- input.priority,
1864
- JSON.stringify(input.labels),
1865
- sortOrder,
1866
- ...(storedThreadBinding(input.threadBinding, input.threadId) ?? [null, null, null, null, null]),
1867
- input.actor.type,
1868
- input.actor.id,
1869
- input.actor.name,
1870
- input.actor.avatarUrl,
1871
- input.assignee.type,
1872
- input.assignee.id,
1873
- input.assignee.name,
1874
- input.assignee.avatarUrl,
1875
- input.developmentContext?.type === "branch" ? input.developmentContext.branch : null,
1876
- input.developmentContext?.type === "worktree" ? input.developmentContext.path : null,
1877
- input.developmentContext?.type === "worktree" ? input.developmentContext.branch : null,
1878
- input.startDate,
1879
- input.dueDate,
1880
- input.recurrence?.interval ?? null,
1881
- input.recurrence?.unit ?? null,
1882
- timestamp,
1883
- timestamp,
1884
- );
1885
- this.database.exec("COMMIT");
1886
- return this.getTask(id);
1887
- } catch (error) {
1888
- this.database.exec("ROLLBACK");
1889
- throw error;
1890
- }
1891
- }
1892
-
1893
- updateTask(id, version, changes, threadId, threadBinding, actor) {
1894
- const current = this.#requireTaskRecord(id);
1895
- this.#requireVersion(current, version);
1896
- const activityChanges = taskFieldChanges(current, changes);
1897
- const targetProject = Object.hasOwn(changes, "projectId")
1898
- ? this.database.prepare("SELECT id, name, workspace_path, labels FROM projects WHERE id = ?").get(changes.projectId)
1899
- : null;
1900
- if (Object.hasOwn(changes, "projectId") && !targetProject) {
1901
- throw new ApiError(404, "PROJECT_NOT_FOUND", `Project '${changes.projectId}' does not exist`);
1902
- }
1903
- const projectChanged = Boolean(targetProject && targetProject.id !== current.projectId);
1904
- if (projectChanged) {
1905
- const relation = this.database.prepare(`
1906
- SELECT 1
1907
- FROM task_relations
1908
- WHERE source_task_id = ? OR target_task_id = ?
1909
- LIMIT 1
1910
- `).get(current.id, current.id);
1911
- if (relation) {
1912
- throw new ApiError(
1913
- 409,
1914
- "CROSS_PROJECT_RELATION",
1915
- "Remove issue relations before moving the issue to another project",
1916
- );
1917
- }
1918
- if (this.hasAiChatThreadProjectConflict(current.id, targetProject.id)) {
1919
- throw new ApiError(
1920
- 409,
1921
- "AI_CHAT_PROJECT_MOVE_BLOCKED",
1922
- "Delete issue-linked AI conversations before moving the issue to another project",
1923
- );
1924
- }
1925
- }
1926
- const dueDate = Object.hasOwn(changes, "dueDate") ? changes.dueDate : current.dueDate;
1927
- const recurrence = Object.hasOwn(changes, "recurrence") ? changes.recurrence : current.recurrence;
1928
- if (recurrence && !dueDate) {
1929
- throw new ApiError(400, "INVALID_FIELD", "A recurring issue requires a due date");
1930
- }
1931
-
1932
- const columns = {
1933
- projectId: "project_id",
1934
- title: "title",
1935
- description: "description",
1936
- status: "status",
1937
- priority: "priority",
1938
- labels: "labels",
1939
- startDate: "start_date",
1940
- dueDate: "due_date",
1941
- };
1942
- const assignments = [];
1943
- const values = [];
1944
- for (const [key, value] of Object.entries(changes)) {
1945
- if (key === "developmentContext") {
1946
- assignments.push("git_branch = ?", "worktree_path = ?", "worktree_branch = ?");
1947
- values.push(
1948
- value?.type === "branch" ? value.branch : null,
1949
- value?.type === "worktree" ? value.path : null,
1950
- value?.type === "worktree" ? value.branch : null,
1951
- );
1952
- continue;
1953
- }
1954
- if (key === "recurrence") {
1955
- assignments.push("recurrence_interval = ?", "recurrence_unit = ?");
1956
- values.push(value?.interval ?? null, value?.unit ?? null);
1957
- continue;
1958
- }
1959
- if (key === "assignee") {
1960
- assignments.push(
1961
- "assignee_type = ?",
1962
- "assignee_id = ?",
1963
- "assignee_name = ?",
1964
- "assignee_avatar_url = ?",
1965
- );
1966
- values.push(value.type, value.id, value.name, value.avatarUrl);
1967
- continue;
1968
- }
1969
- assignments.push(`${columns[key]} = ?`);
1970
- values.push(key === "labels" ? JSON.stringify(value) : value);
1971
- }
1972
- if (Object.hasOwn(changes, "status") && changes.status !== current.status) {
1973
- const placementProjectId = projectChanged ? targetProject.id : current.projectId;
1974
- const row = this.database.prepare(`
1975
- SELECT MIN(sort_order) AS minimum
1976
- FROM tasks
1977
- WHERE project_id = ? AND status = ? AND archived_at IS NULL AND id != ?
1978
- `).get(placementProjectId, changes.status, current.id);
1979
- assignments.push("sort_order = ?");
1980
- values.push(row.minimum === null ? 1000 : row.minimum - 1000);
1981
- }
1982
- const storedBinding = storedThreadBindingForExisting(current, threadBinding, threadId);
1983
- if (storedBinding && !Object.hasOwn(changes, "projectId")) {
1984
- assignments.push(
1985
- "thread_id = ?",
1986
- "thread_codex_project_id = ?",
1987
- "thread_codex_project_kind = ?",
1988
- "thread_codex_host_id = ?",
1989
- "thread_workspace_path = ?",
1990
- );
1991
- values.push(...storedBinding);
1992
- }
1993
- assignments.push("version = version + 1", "updated_at = ?");
1994
- const timestamp = now();
1995
- values.push(timestamp, current.id, version);
1996
-
1997
- this.database.exec("BEGIN IMMEDIATE");
1998
- try {
1999
- const result = this.database.prepare(`
2000
- UPDATE tasks SET ${assignments.join(", ")} WHERE id = ? AND version = ?
2001
- `).run(...values);
2002
- if (result.changes !== 1) {
2003
- this.#throwMissingOrConflict(id, version);
2004
- }
2005
- if (projectChanged) {
2006
- this.database.prepare(`
2007
- UPDATE projects SET updated_at = ? WHERE id IN (?, ?)
2008
- `).run(timestamp, current.projectId, targetProject.id);
2009
- }
2010
- const destinationProjectId = projectChanged ? targetProject.id : current.projectId;
2011
- const destinationProject = this.database.prepare(`
2012
- SELECT labels FROM projects WHERE id = ?
2013
- `).get(destinationProjectId);
2014
- const taskLabels = Object.hasOwn(changes, "labels") ? changes.labels : current.labels;
2015
- const projectLabels = JSON.parse(destinationProject.labels);
2016
- const mergedLabels = [...new Set([...projectLabels, ...taskLabels])];
2017
- if (mergedLabels.length !== projectLabels.length) {
2018
- this.database.prepare(`
2019
- UPDATE projects SET labels = ?, updated_at = ? WHERE id = ?
2020
- `).run(JSON.stringify(mergedLabels), timestamp, destinationProjectId);
2021
- }
2022
- this.#recordTaskActivity(current.id, actor, activityChanges, timestamp);
2023
- this.database.exec("COMMIT");
2024
- } catch (error) {
2025
- this.database.exec("ROLLBACK");
2026
- throw error;
2027
- }
2028
- return this.getTask(current.id);
2029
- }
2030
-
2031
- moveTask(id, version, status, sortOrder, threadId, threadBinding, actor) {
2032
- const current = this.#requireTask(id);
2033
- this.#requireVersion(current, version);
2034
- if (current.archivedAt !== null) {
2035
- throw new ApiError(409, "TASK_ARCHIVED", "Archived tasks cannot be moved");
2036
- }
2037
- if (status !== current.status && sortOrder === undefined) {
2038
- const row = this.database.prepare(`
2039
- SELECT MIN(sort_order) AS minimum
2040
- FROM tasks
2041
- WHERE project_id = ? AND status = ? AND archived_at IS NULL AND id != ?
2042
- `).get(current.projectId, status, current.id);
2043
- sortOrder = row.minimum === null ? 1000 : row.minimum - 1000;
2044
- } else if (sortOrder === undefined) {
2045
- const row = this.database.prepare(`
2046
- SELECT COALESCE(MAX(sort_order), 0) AS maximum
2047
- FROM tasks
2048
- WHERE project_id = ? AND status = ? AND archived_at IS NULL AND id != ?
2049
- `).get(current.projectId, status, current.id);
2050
- sortOrder = row.maximum + 1000;
2051
- }
2052
-
2053
- const timestamp = now();
2054
- const storedBinding = storedThreadBindingForExisting(current, threadBinding, threadId);
2055
- const threadAssignment = storedBinding
2056
- ? `thread_id = ?, thread_codex_project_id = ?, thread_codex_project_kind = ?,
2057
- thread_codex_host_id = ?, thread_workspace_path = ?,`
2058
- : "";
2059
- this.database.exec("BEGIN IMMEDIATE");
2060
- try {
2061
- const result = this.database.prepare(`
2062
- UPDATE tasks
2063
- SET status = ?, sort_order = ?, ${threadAssignment} version = version + 1, updated_at = ?
2064
- WHERE id = ? AND version = ?
2065
- `).run(status, sortOrder, ...(storedBinding ?? []), timestamp, current.id, version);
2066
- if (result.changes !== 1) {
2067
- this.#throwMissingOrConflict(id, version);
2068
- }
2069
- this.#recordTaskActivity(
2070
- current.id,
2071
- actor,
2072
- taskFieldChanges(current, { status }),
2073
- timestamp,
2074
- );
2075
- this.database.exec("COMMIT");
2076
- } catch (error) {
2077
- this.database.exec("ROLLBACK");
2078
- throw error;
2079
- }
2080
- return this.getTask(current.id);
2081
- }
2082
-
2083
- archiveTask(id, version, threadId, threadBinding, actor) {
2084
- const current = this.#requireTask(id);
2085
- this.#requireVersion(current, version);
2086
- const timestamp = now();
2087
- const storedBinding = storedThreadBindingForExisting(current, threadBinding, threadId);
2088
- const threadAssignment = storedBinding
2089
- ? `thread_id = ?, thread_codex_project_id = ?, thread_codex_project_kind = ?,
2090
- thread_codex_host_id = ?, thread_workspace_path = ?,`
2091
- : "";
2092
- this.database.exec("BEGIN IMMEDIATE");
2093
- try {
2094
- const result = this.database.prepare(`
2095
- UPDATE tasks
2096
- SET archived_at = ?, ${threadAssignment} version = version + 1, updated_at = ?
2097
- WHERE id = ? AND version = ?
2098
- `).run(timestamp, ...(storedBinding ?? []), timestamp, current.id, version);
2099
- if (result.changes !== 1) {
2100
- this.#throwMissingOrConflict(id, version);
2101
- }
2102
- this.#recordTaskActivity(
2103
- current.id,
2104
- actor,
2105
- [{ field: "archivedAt", before: current.archivedAt, after: timestamp }],
2106
- timestamp,
2107
- );
2108
- this.database.exec("COMMIT");
2109
- } catch (error) {
2110
- this.database.exec("ROLLBACK");
2111
- throw error;
2112
- }
2113
- return this.getTask(current.id);
2114
- }
2115
-
2116
- restoreTask(id, version, threadId, threadBinding, actor) {
2117
- const current = this.#requireTask(id);
2118
- this.#requireVersion(current, version);
2119
- if (current.archivedAt === null) {
2120
- throw new ApiError(409, "TASK_NOT_ARCHIVED", "Only archived tasks can be restored");
2121
- }
2122
- const timestamp = now();
2123
- const storedBinding = storedThreadBindingForExisting(current, threadBinding, threadId);
2124
- const threadAssignment = storedBinding
2125
- ? `thread_id = ?, thread_codex_project_id = ?, thread_codex_project_kind = ?,
2126
- thread_codex_host_id = ?, thread_workspace_path = ?,`
2127
- : "";
2128
- this.database.exec("BEGIN IMMEDIATE");
2129
- try {
2130
- const result = this.database.prepare(`
2131
- UPDATE tasks
2132
- SET archived_at = NULL, ${threadAssignment} version = version + 1, updated_at = ?
2133
- WHERE id = ? AND version = ?
2134
- `).run(...(storedBinding ?? []), timestamp, current.id, version);
2135
- if (result.changes !== 1) {
2136
- this.#throwMissingOrConflict(id, version);
2137
- }
2138
- this.#recordTaskActivity(
2139
- current.id,
2140
- actor,
2141
- [{ field: "archivedAt", before: current.archivedAt, after: null }],
2142
- timestamp,
2143
- );
2144
- this.database.exec("COMMIT");
2145
- } catch (error) {
2146
- this.database.exec("ROLLBACK");
2147
- throw error;
2148
- }
2149
- return this.getTask(current.id);
2150
- }
2151
-
2152
- deleteArchivedTask(id, version) {
2153
- this.database.exec("BEGIN IMMEDIATE");
2154
- try {
2155
- const current = this.#requireTask(id);
2156
- this.#requireVersion(current, version);
2157
- if (current.archivedAt === null) {
2158
- throw new ApiError(409, "TASK_NOT_ARCHIVED", "Only archived tasks can be deleted");
2159
- }
2160
- const attachmentIds = this.database.prepare(
2161
- "SELECT id FROM attachments WHERE task_id = ? ORDER BY created_at, id",
2162
- ).all(current.id).map((attachment) => attachment.id);
2163
- const result = this.database.prepare(
2164
- "DELETE FROM tasks WHERE id = ? AND version = ? AND archived_at IS NOT NULL",
2165
- ).run(current.id, version);
2166
- if (result.changes !== 1) this.#throwMissingOrConflict(id, version);
2167
- this.database.exec("COMMIT");
2168
- return { task: current, attachmentIds };
2169
- } catch (error) {
2170
- this.database.exec("ROLLBACK");
2171
- throw error;
2172
- }
2173
- }
2174
-
2175
- addTaskRelation(id, version, type, relatedId, threadId, threadBinding, actor, origin = "manual") {
2176
- this.database.exec("BEGIN IMMEDIATE");
2177
- try {
2178
- const task = this.#requireTask(id);
2179
- const relatedTask = this.#requireTask(relatedId);
2180
- this.#requireVersion(task, version);
2181
- this.#validateRelationTasks(task, relatedTask);
2182
-
2183
- const { relationType, sourceTaskId, targetTaskId } = this.#relationEndpoints(
2184
- type,
2185
- task.id,
2186
- relatedTask.id,
2187
- );
2188
- if (relationType === "parent") {
2189
- this.#assertNoParentCycle(task.id, relatedTask.id);
2190
- const existing = this.database.prepare(`
2191
- SELECT source_task_id
2192
- FROM task_relations
2193
- WHERE relation_type = 'parent' AND target_task_id = ?
2194
- `).get(task.id);
2195
- if (existing?.source_task_id === relatedTask.id) {
2196
- throw new ApiError(409, "RELATION_EXISTS", "This parent relation already exists");
2197
- }
2198
- if (existing) {
2199
- this.database.prepare(`
2200
- DELETE FROM task_relations
2201
- WHERE relation_type = 'parent' AND target_task_id = ?
2202
- `).run(task.id);
2203
- }
2204
- } else {
2205
- const existing = this.database.prepare(`
2206
- SELECT 1
2207
- FROM task_relations
2208
- WHERE relation_type = ? AND source_task_id = ? AND target_task_id = ?
2209
- `).get(relationType, sourceTaskId, targetTaskId);
2210
- if (existing) {
2211
- throw new ApiError(409, "RELATION_EXISTS", "This issue relation already exists");
2212
- }
2213
- }
2214
-
2215
- const timestamp = now();
2216
- const previousRelation = type === "parent" && task.relations.parent
2217
- ? relationActivityValue(type, task.relations.parent)
2218
- : null;
2219
- this.database.prepare(`
2220
- INSERT INTO task_relations (
2221
- relation_type, source_task_id, target_task_id, origin, created_at
2222
- ) VALUES (?, ?, ?, ?, ?)
2223
- `).run(relationType, sourceTaskId, targetTaskId, origin, timestamp);
2224
- this.#touchTask(task.id, version, threadId, threadBinding, timestamp);
2225
- this.#recordTaskActivity(task.id, actor, [{
2226
- field: "relation",
2227
- before: previousRelation,
2228
- after: relationActivityValue(type, relatedTask),
2229
- }], timestamp);
2230
- this.database.exec("COMMIT");
2231
- return {
2232
- task: this.getTask(task.id),
2233
- relatedTask: this.getTask(relatedTask.id),
2234
- };
2235
- } catch (error) {
2236
- this.database.exec("ROLLBACK");
2237
- throw error;
2238
- }
2239
- }
2240
-
2241
- removeTaskRelation(id, version, type, relatedId, threadId, threadBinding, actor, origin) {
2242
- this.database.exec("BEGIN IMMEDIATE");
2243
- try {
2244
- const task = this.#requireTask(id);
2245
- const relatedTask = this.#requireTask(relatedId);
2246
- this.#requireVersion(task, version);
2247
- this.#validateRelationTasks(task, relatedTask);
2248
- const { relationType, sourceTaskId, targetTaskId } = this.#relationEndpoints(
2249
- type,
2250
- task.id,
2251
- relatedTask.id,
2252
- );
2253
- const relation = this.database.prepare(`
2254
- SELECT origin
2255
- FROM task_relations
2256
- WHERE relation_type = ? AND source_task_id = ? AND target_task_id = ?
2257
- `).get(relationType, sourceTaskId, targetTaskId);
2258
- if (!relation) {
2259
- throw new ApiError(404, "RELATION_NOT_FOUND", "This issue relation does not exist");
2260
- }
2261
- if (origin && relation.origin !== origin) {
2262
- this.database.exec("COMMIT");
2263
- return {
2264
- task: this.getTask(task.id),
2265
- relatedTask: this.getTask(relatedTask.id),
2266
- };
2267
- }
2268
- let deleted;
2269
- if (origin === "mention" && relationType === "related") {
2270
- const taskReference = `](?${new URLSearchParams({
2271
- project: task.projectId,
2272
- issue: relatedTask.identifier,
2273
- })})`;
2274
- const relatedTaskReference = `](?${new URLSearchParams({
2275
- project: task.projectId,
2276
- issue: task.identifier,
2277
- })})`;
2278
- deleted = this.database.prepare(`
2279
- DELETE FROM task_relations
2280
- WHERE relation_type = ? AND source_task_id = ? AND target_task_id = ?
2281
- AND origin = 'mention'
2282
- AND NOT EXISTS (
2283
- SELECT 1
2284
- FROM tasks
2285
- WHERE (id = ? AND instr(description, ?) > 0)
2286
- OR (id = ? AND instr(description, ?) > 0)
2287
- )
2288
- AND NOT EXISTS (
2289
- SELECT 1
2290
- FROM comments
2291
- WHERE (task_id = ? AND instr(body, ?) > 0)
2292
- OR (task_id = ? AND instr(body, ?) > 0)
2293
- )
2294
- `).run(
2295
- relationType,
2296
- sourceTaskId,
2297
- targetTaskId,
2298
- task.id,
2299
- taskReference,
2300
- relatedTask.id,
2301
- relatedTaskReference,
2302
- task.id,
2303
- taskReference,
2304
- relatedTask.id,
2305
- relatedTaskReference,
2306
- );
2307
- } else {
2308
- deleted = this.database.prepare(`
2309
- DELETE FROM task_relations
2310
- WHERE relation_type = ? AND source_task_id = ? AND target_task_id = ?
2311
- `).run(relationType, sourceTaskId, targetTaskId);
2312
- }
2313
- if (origin === "mention" && relationType === "related" && deleted.changes === 0) {
2314
- this.database.exec("COMMIT");
2315
- return {
2316
- task: this.getTask(task.id),
2317
- relatedTask: this.getTask(relatedTask.id),
2318
- };
2319
- }
2320
- const timestamp = now();
2321
- this.#touchTask(task.id, version, threadId, threadBinding, timestamp);
2322
- this.#recordTaskActivity(task.id, actor, [{
2323
- field: "relation",
2324
- before: relationActivityValue(type, relatedTask),
2325
- after: null,
2326
- }], timestamp);
2327
- this.database.exec("COMMIT");
2328
- return {
2329
- task: this.getTask(task.id),
2330
- relatedTask: this.getTask(relatedTask.id),
2331
- };
2332
- } catch (error) {
2333
- this.database.exec("ROLLBACK");
2334
- throw error;
2335
- }
2336
- }
2337
-
2338
- listTaskActivities(taskId) {
2339
- const task = this.#requireTask(taskId);
2340
- return this.database.prepare(`
2341
- SELECT * FROM task_activities
2342
- WHERE task_id = ?
2343
- ORDER BY created_at, id
2344
- `).all(task.id).map(taskActivityFromRow);
2345
- }
2346
-
2347
- listComments(taskId) {
2348
- const task = this.#requireTaskRecord(taskId);
2349
- const rows = this.database.prepare(`
2350
- SELECT * FROM comments
2351
- WHERE task_id = ?
2352
- ORDER BY created_at, id
2353
- `).all(task.id);
2354
- return this.#commentsWithAttachments(rows);
2355
- }
2356
-
2357
- listCommentsAfter(taskId, after) {
2358
- const task = this.#requireTaskRecord(taskId);
2359
- const rows = this.database.prepare(`
2360
- SELECT * FROM comments
2361
- WHERE task_id = ?
2362
- AND change_revision > ?
2363
- ORDER BY change_revision
2364
- `).all(task.id, after.revision);
2365
- return this.#commentsWithAttachments(rows);
2366
- }
2367
-
2368
- createComment(taskId, input) {
2369
- const id = randomUUID();
2370
- const timestamp = now();
2371
- this.database.exec("BEGIN IMMEDIATE");
2372
- try {
2373
- const task = this.#requireTask(taskId);
2374
- const changeRevision = this.#nextCommentAttachmentRevision();
2375
- this.database.prepare(`
2376
- INSERT INTO comments (
2377
- id, task_id, body, thread_id, thread_codex_project_id, thread_codex_project_kind,
2378
- thread_codex_host_id, thread_workspace_path,
2379
- author_type, author_id, author_name, author_avatar_url,
2380
- version, created_at, updated_at, change_revision
2381
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)
2382
- `).run(
2383
- id,
2384
- task.id,
2385
- input.body,
2386
- ...(storedThreadBinding(input.threadBinding, input.threadId) ?? [null, null, null, null, null]),
2387
- input.actor.type,
2388
- input.actor.id,
2389
- input.actor.name,
2390
- input.actor.avatarUrl,
2391
- timestamp,
2392
- timestamp,
2393
- changeRevision,
2394
- );
2395
- this.database.exec("COMMIT");
2396
- } catch (error) {
2397
- this.database.exec("ROLLBACK");
2398
- throw error;
2399
- }
2400
- return this.getComment(id);
2401
- }
2402
-
2403
- getComment(id) {
2404
- const row = this.database.prepare("SELECT * FROM comments WHERE id = ?").get(id);
2405
- return row ? this.#commentWithAttachments(row) : null;
2406
- }
2407
-
2408
- updateComment(id, version, body, threadId, threadBinding) {
2409
- const storedBinding = storedThreadBinding(threadBinding, threadId);
2410
- const threadAssignment = storedBinding
2411
- ? `thread_id = ?, thread_codex_project_id = ?, thread_codex_project_kind = ?,
2412
- thread_codex_host_id = ?, thread_workspace_path = ?,`
2413
- : "";
2414
- this.database.exec("BEGIN IMMEDIATE");
2415
- try {
2416
- const current = this.#requireComment(id);
2417
- this.#requireCommentVersion(current, version);
2418
- const changeRevision = this.#nextCommentAttachmentRevision();
2419
- const result = this.database.prepare(`
2420
- UPDATE comments
2421
- SET body = ?, ${threadAssignment} version = version + 1, updated_at = ?,
2422
- change_revision = ?
2423
- WHERE id = ? AND version = ?
2424
- `).run(body, ...(storedBinding ?? []), now(), changeRevision, id, version);
2425
- if (result.changes !== 1) {
2426
- this.#throwMissingCommentOrConflict(id, version);
2427
- }
2428
- this.database.exec("COMMIT");
2429
- } catch (error) {
2430
- this.database.exec("ROLLBACK");
2431
- throw error;
2432
- }
2433
- return this.getComment(id);
2434
- }
2435
-
2436
- deleteComment(id, version) {
2437
- const current = this.#requireComment(id);
2438
- this.#requireCommentVersion(current, version);
2439
- const result = this.database.prepare(`
2440
- DELETE FROM comments WHERE id = ? AND version = ?
2441
- `).run(id, version);
2442
- if (result.changes !== 1) {
2443
- this.#throwMissingCommentOrConflict(id, version);
2444
- }
2445
- return current;
2446
- }
2447
-
2448
- listAttachments(taskId, after = null) {
2449
- const task = this.#requireTask(taskId);
2450
- if (after) {
2451
- return this.database.prepare(`
2452
- SELECT * FROM attachments
2453
- WHERE task_id = ? AND comment_id IS NULL
2454
- AND change_revision > ?
2455
- ORDER BY change_revision
2456
- `).all(task.id, after.revision).map(attachmentFromRow);
2457
- }
2458
- return this.database.prepare(`
2459
- SELECT * FROM attachments
2460
- WHERE task_id = ? AND comment_id IS NULL
2461
- ORDER BY created_at, id
2462
- `).all(task.id).map(attachmentFromRow);
2463
- }
2464
-
2465
- createAttachment(taskId, input) {
2466
- this.database.exec("BEGIN IMMEDIATE");
2467
- try {
2468
- const task = this.#requireTask(taskId);
2469
- const changeRevision = this.#nextCommentAttachmentRevision();
2470
- this.database.prepare(`
2471
- INSERT INTO attachments (
2472
- id, task_id, comment_id, kind, filename, content_type, size, created_at, change_revision
2473
- ) VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?)
2474
- `).run(
2475
- input.id,
2476
- task.id,
2477
- input.kind,
2478
- input.filename,
2479
- input.contentType,
2480
- input.size,
2481
- now(),
2482
- changeRevision,
2483
- );
2484
- this.database.exec("COMMIT");
2485
- } catch (error) {
2486
- this.database.exec("ROLLBACK");
2487
- throw error;
2488
- }
2489
- return this.getAttachment(input.id);
2490
- }
2491
-
2492
- listCommentAttachments(commentId, after = null) {
2493
- const comment = this.database.prepare("SELECT id FROM comments WHERE id = ?").get(commentId);
2494
- if (!comment) {
2495
- throw new ApiError(404, "COMMENT_NOT_FOUND", `Comment '${commentId}' does not exist`);
2496
- }
2497
- return this.#attachmentsForComment(commentId, after);
2498
- }
2499
-
2500
- createCommentAttachment(commentId, input) {
2501
- this.database.exec("BEGIN IMMEDIATE");
2502
- try {
2503
- const comment = this.#requireComment(commentId);
2504
- const changeRevision = this.#nextCommentAttachmentRevision();
2505
- this.database.prepare(`
2506
- INSERT INTO attachments (
2507
- id, task_id, comment_id, kind, filename, content_type, size, created_at, change_revision
2508
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
2509
- `).run(
2510
- input.id,
2511
- comment.taskId,
2512
- comment.id,
2513
- input.kind,
2514
- input.filename,
2515
- input.contentType,
2516
- input.size,
2517
- now(),
2518
- changeRevision,
2519
- );
2520
- this.database.exec("COMMIT");
2521
- } catch (error) {
2522
- this.database.exec("ROLLBACK");
2523
- throw error;
2524
- }
2525
- return this.getAttachment(input.id);
2526
- }
2527
-
2528
- getAttachment(id) {
2529
- const row = this.database.prepare("SELECT * FROM attachments WHERE id = ?").get(id);
2530
- return row ? attachmentFromRow(row) : null;
2531
- }
2532
-
2533
- deleteAttachment(id) {
2534
- const attachment = this.getAttachment(id);
2535
- if (!attachment) {
2536
- throw new ApiError(404, "ATTACHMENT_NOT_FOUND", `Attachment '${id}' does not exist`);
2537
- }
2538
- this.database.prepare("DELETE FROM attachments WHERE id = ?").run(id);
2539
- return attachment;
2540
- }
2541
-
2542
- #commentWithAttachments(row, attachments = this.#attachmentsForComment(row.id)) {
2543
- const comment = commentFromRow(row);
2544
- comment.attachments = attachments;
2545
- return comment;
2546
- }
2547
-
2548
- #commentsWithAttachments(rows) {
2549
- const attachmentsByComment = new Map(rows.map((row) => [row.id, []]));
2550
- const commentIds = rows.map((row) => row.id);
2551
- for (let offset = 0; offset < commentIds.length; offset += 400) {
2552
- const chunk = commentIds.slice(offset, offset + 400);
2553
- const placeholders = chunk.map(() => "?").join(", ");
2554
- const attachments = this.database.prepare(`
2555
- SELECT * FROM attachments
2556
- WHERE comment_id IN (${placeholders})
2557
- ORDER BY comment_id, created_at, id
2558
- `).all(...chunk);
2559
- for (const attachment of attachments) {
2560
- attachmentsByComment.get(attachment.comment_id).push(attachmentFromRow(attachment));
2561
- }
2562
- }
2563
- return rows.map((row) => this.#commentWithAttachments(row, attachmentsByComment.get(row.id)));
2564
- }
2565
-
2566
- #aiChatThreadWithCurrentRun(row) {
2567
- const thread = aiChatThreadFromRow(row);
2568
- const currentRun = this.database.prepare(`
2569
- SELECT * FROM ai_chat_runs
2570
- WHERE thread_id = ? AND status = 'running'
2571
- ORDER BY started_at DESC, id DESC
2572
- LIMIT 1
2573
- `).get(thread.id);
2574
- thread.currentRun = currentRun ? aiChatRunFromRow(currentRun) : null;
2575
- const todoRows = this.database.prepare(`
2576
- SELECT id, thread_id, run_id, data, created_at
2577
- FROM ai_chat_events
2578
- WHERE thread_id = ? AND type = 'todo_list'
2579
- ORDER BY created_at DESC, rowid DESC
2580
- `).all(thread.id);
2581
- thread.latestTodo = todoRows
2582
- .filter((row) => !thread.currentRun || row.run_id === thread.currentRun.id)
2583
- .map(parseAiChatTodoProgress)
2584
- .find(Boolean) ?? null;
2585
- return thread;
2586
- }
2587
-
2588
- #commentsForTaskActivity(taskIds) {
2589
- const commentsByTask = new Map(taskIds.map((taskId) => [taskId, []]));
2590
- for (let offset = 0; offset < taskIds.length; offset += 400) {
2591
- const chunk = taskIds.slice(offset, offset + 400);
2592
- if (chunk.length === 0) continue;
2593
- const placeholders = chunk.map(() => "?").join(", ");
2594
- const rows = this.database.prepare(`
2595
- SELECT
2596
- id, task_id,
2597
- CASE WHEN thread_id IS NULL THEN NULL ELSE substr(body, 1, 512) END AS body,
2598
- thread_id, thread_codex_project_id, thread_codex_project_kind,
2599
- thread_codex_host_id, thread_workspace_path,
2600
- author_type, author_id, author_name,
2601
- author_avatar_url, version, updated_at
2602
- FROM comments
2603
- WHERE task_id IN (${placeholders})
2604
- ORDER BY task_id, id
2605
- `).all(...chunk);
2606
- for (const row of rows) commentsByTask.get(row.task_id)?.push(row);
2607
- }
2608
- return commentsByTask;
2609
- }
2610
-
2611
- #activitiesForTasks(taskIds) {
2612
- const activitiesByTask = new Map(taskIds.map((taskId) => [taskId, []]));
2613
- for (let offset = 0; offset < taskIds.length; offset += 400) {
2614
- const chunk = taskIds.slice(offset, offset + 400);
2615
- if (chunk.length === 0) continue;
2616
- const placeholders = chunk.map(() => "?").join(", ");
2617
- const rows = this.database.prepare(`
2618
- SELECT
2619
- id, task_id, actor_type, actor_id, actor_name, actor_avatar_url, created_at
2620
- FROM task_activities
2621
- WHERE task_id IN (${placeholders})
2622
- ORDER BY task_id, created_at, id
2623
- `).all(...chunk);
2624
- for (const row of rows) activitiesByTask.get(row.task_id)?.push(row);
2625
- }
2626
- return activitiesByTask;
2627
- }
2628
-
2629
- #taskPreviewImages(taskIds) {
2630
- const imagesByTask = new Map();
2631
- for (let offset = 0; offset < taskIds.length; offset += 400) {
2632
- const chunk = taskIds.slice(offset, offset + 400);
2633
- if (chunk.length === 0) continue;
2634
- const placeholders = chunk.map(() => "?").join(", ");
2635
- const rows = this.database.prepare(`
2636
- SELECT attachments.*
2637
- FROM attachments
2638
- JOIN tasks ON tasks.id = attachments.task_id
2639
- WHERE attachments.task_id IN (${placeholders})
2640
- AND attachments.comment_id IS NULL
2641
- AND attachments.content_type LIKE 'image/%'
2642
- AND instr(tasks.description, 'api/attachments/' || attachments.id || '/content') > 0
2643
- ORDER BY attachments.task_id, attachments.created_at, attachments.id
2644
- `).all(...chunk);
2645
- for (const row of rows) {
2646
- if (!imagesByTask.has(row.task_id)) imagesByTask.set(row.task_id, attachmentFromRow(row));
2647
- }
2648
- }
2649
- return imagesByTask;
2650
- }
2651
-
2652
- #attachmentsForComment(commentId, after = null) {
2653
- if (after) {
2654
- return this.database.prepare(`
2655
- SELECT * FROM attachments
2656
- WHERE comment_id = ?
2657
- AND change_revision > ?
2658
- ORDER BY change_revision
2659
- `).all(commentId, after.revision).map(attachmentFromRow);
2660
- }
2661
- return this.database.prepare(`
2662
- SELECT * FROM attachments
2663
- WHERE comment_id = ?
2664
- ORDER BY created_at, id
2665
- `).all(commentId).map(attachmentFromRow);
2666
- }
2667
-
2668
- #nextCommentAttachmentRevision() {
2669
- return this.database.prepare(`
2670
- UPDATE comment_attachment_revision
2671
- SET value = value + 1
2672
- WHERE id = 1
2673
- RETURNING value
2674
- `).get().value;
2675
- }
2676
-
2677
- #taskRelationsForTasks(taskIds) {
2678
- const relationsByTask = new Map();
2679
- for (let offset = 0; offset < taskIds.length; offset += 400) {
2680
- const chunk = taskIds.slice(offset, offset + 400);
2681
- const placeholders = chunk.map(() => "?").join(", ");
2682
- const rows = this.database.prepare(taskRelationsQuery(placeholders)).all(...chunk);
2683
- for (const [taskId, relations] of taskRelationsFromRows(chunk, rows, taskRelationSummaryFromRow)) {
2684
- relationsByTask.set(taskId, relations);
2685
- }
2686
- }
2687
- return relationsByTask;
2688
- }
2689
-
2690
- #taskWithRelations(row, relations = this.#taskRelationsForTasks([row.id]).get(row.id)) {
2691
- const task = taskFromRow(row);
2692
- task.relations = relations;
2693
- return task;
2694
- }
2695
-
2696
- #validateRelationTasks(task, relatedTask) {
2697
- if (task.id === relatedTask.id) {
2698
- throw new ApiError(400, "SELF_RELATION", "An issue cannot be related to itself");
2699
- }
2700
- if (task.projectId !== relatedTask.projectId) {
2701
- throw new ApiError(400, "CROSS_PROJECT_RELATION", "Issue relations must stay within one project");
2702
- }
2703
- }
2704
-
2705
- #relationEndpoints(type, taskId, relatedTaskId) {
2706
- if (type === "parent") {
2707
- return {
2708
- relationType: "parent",
2709
- sourceTaskId: relatedTaskId,
2710
- targetTaskId: taskId,
2711
- };
2712
- }
2713
- if (type === "blocks") {
2714
- return {
2715
- relationType: "blocks",
2716
- sourceTaskId: taskId,
2717
- targetTaskId: relatedTaskId,
2718
- };
2719
- }
2720
- if (type === "blocked_by") {
2721
- return {
2722
- relationType: "blocks",
2723
- sourceTaskId: relatedTaskId,
2724
- targetTaskId: taskId,
2725
- };
2726
- }
2727
- const [sourceTaskId, targetTaskId] = [taskId, relatedTaskId].sort();
2728
- return { relationType: "related", sourceTaskId, targetTaskId };
2729
- }
2730
-
2731
- #assertNoParentCycle(childId, parentId) {
2732
- const cycle = this.database.prepare(`
2733
- WITH RECURSIVE ancestors(id) AS (
2734
- SELECT source_task_id
2735
- FROM task_relations
2736
- WHERE relation_type = 'parent' AND target_task_id = ?
2737
- UNION
2738
- SELECT task_relations.source_task_id
2739
- FROM task_relations
2740
- JOIN ancestors ON task_relations.target_task_id = ancestors.id
2741
- WHERE task_relations.relation_type = 'parent'
2742
- )
2743
- SELECT 1 FROM ancestors WHERE id = ?
2744
- `).get(parentId, childId);
2745
- if (cycle) {
2746
- throw new ApiError(409, "RELATION_CYCLE", "This parent would create a cycle");
2747
- }
2748
- }
2749
-
2750
- #recordTaskActivity(taskId, actor, changes, timestamp) {
2751
- if (changes.length === 0) return;
2752
- this.database.prepare(`
2753
- INSERT INTO task_activities (
2754
- id, task_id, actor_type, actor_id, actor_name, actor_avatar_url, changes, created_at
2755
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
2756
- `).run(
2757
- randomUUID(),
2758
- taskId,
2759
- actor.type,
2760
- actor.id,
2761
- actor.name,
2762
- actor.avatarUrl,
2763
- JSON.stringify(changes),
2764
- timestamp,
2765
- );
2766
- }
2767
-
2768
- #touchTask(id, version, threadId, threadBinding, timestamp) {
2769
- const current = this.#requireTask(id);
2770
- const storedBinding = storedThreadBindingForExisting(current, threadBinding, threadId);
2771
- const threadAssignment = storedBinding
2772
- ? `thread_id = ?, thread_codex_project_id = ?, thread_codex_project_kind = ?,
2773
- thread_codex_host_id = ?, thread_workspace_path = ?,`
2774
- : "";
2775
- const result = this.database.prepare(`
2776
- UPDATE tasks
2777
- SET ${threadAssignment} version = version + 1, updated_at = ?
2778
- WHERE id = ? AND version = ?
2779
- `).run(...(storedBinding ?? []), timestamp, id, version);
2780
- if (result.changes !== 1) {
2781
- this.#throwMissingOrConflict(id, version);
2782
- }
2783
- }
2784
-
2785
- #requireTaskRecord(id) {
2786
- const row = this.database.prepare("SELECT * FROM tasks WHERE id = ? OR identifier = ?").get(id, id);
2787
- if (!row) {
2788
- throw new ApiError(404, "TASK_NOT_FOUND", `Task '${id}' does not exist`);
2789
- }
2790
- return taskFromRow(row);
2791
- }
2792
-
2793
- #requireTask(id) {
2794
- const task = this.getTask(id);
2795
- if (!task) {
2796
- throw new ApiError(404, "TASK_NOT_FOUND", `Task '${id}' does not exist`);
2797
- }
2798
- return task;
2799
- }
2800
-
2801
- #requireComment(id) {
2802
- const comment = this.getComment(id);
2803
- if (!comment) {
2804
- throw new ApiError(404, "COMMENT_NOT_FOUND", `Comment '${id}' does not exist`);
2805
- }
2806
- return comment;
2807
- }
2808
-
2809
- #requireVersion(task, expectedVersion) {
2810
- if (task.version !== expectedVersion) {
2811
- throw new ApiError(409, "VERSION_CONFLICT", "Task was changed by another client", {
2812
- expectedVersion,
2813
- actualVersion: task.version,
2814
- });
2815
- }
2816
- }
2817
-
2818
- #requireCommentVersion(comment, expectedVersion) {
2819
- if (comment.version !== expectedVersion) {
2820
- throw new ApiError(409, "VERSION_CONFLICT", "Comment was changed by another client", {
2821
- expectedVersion,
2822
- actualVersion: comment.version,
2823
- });
2824
- }
2825
- }
2826
-
2827
- #throwMissingOrConflict(id, expectedVersion) {
2828
- const task = this.#requireTaskRecord(id);
2829
- throw new ApiError(409, "VERSION_CONFLICT", "Task was changed by another client", {
2830
- expectedVersion,
2831
- actualVersion: task.version,
2832
- });
2833
- }
2834
-
2835
- #throwMissingCommentOrConflict(id, expectedVersion) {
2836
- const comment = this.getComment(id);
2837
- if (!comment) {
2838
- throw new ApiError(404, "COMMENT_NOT_FOUND", `Comment '${id}' does not exist`);
2839
- }
2840
- throw new ApiError(409, "VERSION_CONFLICT", "Comment was changed by another client", {
2841
- expectedVersion,
2842
- actualVersion: comment.version,
2843
- });
2844
- }
2845
- }