@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.
- package/dist/build-info.json +3 -3
- package/dist/ensure-node-pty-helper.d.ts +0 -1
- package/dist/ensure-node-pty-helper.js +0 -5
- package/dist/git-quick-commit.js +18 -19
- package/dist/github-connector.d.ts +11 -5
- package/dist/github-connector.js +105 -33
- package/dist/missions.d.ts +1 -1
- package/dist/missions.js +17 -4
- package/dist/server-file-routes.js +43 -2
- package/dist/server-github-routes.js +50 -53
- package/dist/server-mission-routes.js +11 -10
- package/dist/server-task-routes.d.ts +14 -2
- package/dist/server-task-routes.js +248 -84
- package/dist/server-workspace-routes.js +90 -16
- package/dist/server.js +2 -20
- package/dist/storage.d.ts +23 -6
- package/dist/storage.js +130 -53
- package/dist/structured-exec-host.d.ts +13 -2
- package/dist/structured-exec-host.js +71 -36
- package/dist/structured-exec-pump.d.ts +1 -1
- package/dist/structured-exec-pump.js +14 -8
- package/dist/task-types.d.ts +15 -0
- package/dist/terminal-daemon-client.d.ts +2 -0
- package/dist/terminal-daemon-client.js +7 -1
- package/dist/terminal-daemon-server.js +17 -18
- package/dist/tui/ipc-server.js +3 -1
- package/dist/types.d.ts +1 -0
- package/dist/wand-task-sync.d.ts +9 -0
- package/dist/wand-task-sync.js +50 -0
- package/dist/web-ui/content/scripts.js +85 -85
- package/dist/web-ui/content/styles.css +1 -1
- package/dist/web-ui/embedded-assets.js +2 -2
- package/dist/ws-broadcast.js +5 -1
- package/package.json +2 -3
- package/dist/taskboard-bridge.d.ts +0 -11
- package/dist/taskboard-bridge.js +0 -371
- package/vendor/codex-taskboard/LICENSE +0 -201
- package/vendor/codex-taskboard/dist/web/assets/AiChat-BNn4VJ5a.js +0 -12
- package/vendor/codex-taskboard/dist/web/assets/GanttView-D8wnfGrG.css +0 -1
- package/vendor/codex-taskboard/dist/web/assets/GanttView-rGWhuEX9.js +0 -39
- package/vendor/codex-taskboard/dist/web/assets/abnfDiagram-N423BO3Z-DbFG6lbA.js +0 -1
- package/vendor/codex-taskboard/dist/web/assets/arc-BjSQqbzd.js +0 -1
- package/vendor/codex-taskboard/dist/web/assets/architecture-TIHT7OUA-C9-t8p4o.js +0 -1
- package/vendor/codex-taskboard/dist/web/assets/architectureDiagram-T3A2C74G-Db4kJZwO.js +0 -36
- package/vendor/codex-taskboard/dist/web/assets/array-BifhSqXX.js +0 -1
- package/vendor/codex-taskboard/dist/web/assets/blockDiagram-VBNYF7ZC-C-BywuWV.js +0 -132
- package/vendor/codex-taskboard/dist/web/assets/c4Diagram-5PPSVZJV-Dq-80rjP.js +0 -10
- package/vendor/codex-taskboard/dist/web/assets/channel-C7mnz8Xc.js +0 -1
- package/vendor/codex-taskboard/dist/web/assets/chunk-2GRJ4B5K-B2G57us9.js +0 -1
- package/vendor/codex-taskboard/dist/web/assets/chunk-2Q5K7J3B-C1jixKkw.js +0 -1
- package/vendor/codex-taskboard/dist/web/assets/chunk-3NCLNEKW-DVGIRib_.js +0 -1
- package/vendor/codex-taskboard/dist/web/assets/chunk-4I5QYGJK-kNoGFqr0.js +0 -1
- package/vendor/codex-taskboard/dist/web/assets/chunk-5RXB4S5H-4iv6-xM6.js +0 -231
- package/vendor/codex-taskboard/dist/web/assets/chunk-5VM5RSS4-ZNzvKenW.js +0 -15
- package/vendor/codex-taskboard/dist/web/assets/chunk-6Q2QTUOP-1J34vxHr.js +0 -88
- package/vendor/codex-taskboard/dist/web/assets/chunk-7BUUIJ7U-Bb538aSH.js +0 -1
- package/vendor/codex-taskboard/dist/web/assets/chunk-7Z6QIM7H-CDQpQlxX.js +0 -10
- package/vendor/codex-taskboard/dist/web/assets/chunk-GF5L2VYU-Dg92oHHl.js +0 -206
- package/vendor/codex-taskboard/dist/web/assets/chunk-I66GZJ75-Bk8_gFs2.js +0 -125
- package/vendor/codex-taskboard/dist/web/assets/chunk-J7OUQ5F2-CJRcenlr.js +0 -2
- package/vendor/codex-taskboard/dist/web/assets/chunk-JQJVKLGR-BLBV_Sus.js +0 -156
- package/vendor/codex-taskboard/dist/web/assets/chunk-JWPE2WC7-DVXcaiue.js +0 -1
- package/vendor/codex-taskboard/dist/web/assets/chunk-KBJHAD2P-Da9Yjz0p.js +0 -1
- package/vendor/codex-taskboard/dist/web/assets/chunk-KEIR6QF5-Dj-OpFgW.js +0 -161
- package/vendor/codex-taskboard/dist/web/assets/chunk-NSK5VX7P-Cb56tnGK.js +0 -2
- package/vendor/codex-taskboard/dist/web/assets/chunk-QR6OTTB3-BwoszTxn.js +0 -62
- package/vendor/codex-taskboard/dist/web/assets/chunk-RYQCIY6F-D_L2RdcQ.js +0 -1
- package/vendor/codex-taskboard/dist/web/assets/chunk-UBXNYLIW-YX5mN8cl.js +0 -1
- package/vendor/codex-taskboard/dist/web/assets/chunk-W5SLKNZC-DlOLhhDO.js +0 -1
- package/vendor/codex-taskboard/dist/web/assets/chunk-WRU74C26-B7SBmP0K.js +0 -70
- package/vendor/codex-taskboard/dist/web/assets/chunk-XXDRQBXY-BuE3VzE_.js +0 -1
- package/vendor/codex-taskboard/dist/web/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +0 -1
- package/vendor/codex-taskboard/dist/web/assets/chunk-ZIRB5QZD-C6fEPe3t.js +0 -32
- package/vendor/codex-taskboard/dist/web/assets/classDiagram-JCYQIIEL-KIdCVvbA.js +0 -1
- package/vendor/codex-taskboard/dist/web/assets/classDiagram-v2-OCEON4UE-KIdCVvbA.js +0 -1
- package/vendor/codex-taskboard/dist/web/assets/cose-bilkent-JH36ORCC-BzQvy5TC.js +0 -1
- package/vendor/codex-taskboard/dist/web/assets/cynefin-VYW2F7L2-D1_5O1XR.js +0 -1
- package/vendor/codex-taskboard/dist/web/assets/cynefinDiagram-MW4NZA55-DOW_U5QN.js +0 -62
- package/vendor/codex-taskboard/dist/web/assets/cytoscape.esm-DnGd5Fuo.js +0 -321
- package/vendor/codex-taskboard/dist/web/assets/dagre-VZM6K2ZE-0Dntc3NT.js +0 -4
- package/vendor/codex-taskboard/dist/web/assets/dagre-dpRSp0QF.js +0 -1
- package/vendor/codex-taskboard/dist/web/assets/defaultLocale-C8Fc0cco.js +0 -1
- package/vendor/codex-taskboard/dist/web/assets/diagram-7IWD3JNH-D7GleYAM.js +0 -30
- package/vendor/codex-taskboard/dist/web/assets/diagram-B4RE2ZJO-DCXTe6q5.js +0 -3
- package/vendor/codex-taskboard/dist/web/assets/diagram-LBJQPF4R-ZFJyXEXl.js +0 -24
- package/vendor/codex-taskboard/dist/web/assets/diagram-Q27KOJAE-DUGCuGd9.js +0 -24
- package/vendor/codex-taskboard/dist/web/assets/diagram-UB23O5K3-DRmp4Nng.js +0 -41
- package/vendor/codex-taskboard/dist/web/assets/dist-D9sYb5Oa.js +0 -1
- package/vendor/codex-taskboard/dist/web/assets/ebnfDiagram-BXEA7PRR-CkKoaf7T.js +0 -1
- package/vendor/codex-taskboard/dist/web/assets/erDiagram-JOGREHBK-nBX5z4R4.js +0 -85
- package/vendor/codex-taskboard/dist/web/assets/eventmodeling-45OFAUF4-pexmK65N.js +0 -1
- package/vendor/codex-taskboard/dist/web/assets/flowDiagram-UKHOOZJN-aroohRbL.js +0 -1
- package/vendor/codex-taskboard/dist/web/assets/ganttDiagram-PKOTCBZU-BOJWUTXa.js +0 -292
- package/vendor/codex-taskboard/dist/web/assets/gitGraph-TEB2WS4Q-DxnuhSZU.js +0 -1
- package/vendor/codex-taskboard/dist/web/assets/gitGraphDiagram-DS77QQ5N-Cte975TC.js +0 -106
- package/vendor/codex-taskboard/dist/web/assets/graphlib-DS17s2tU.js +0 -1
- package/vendor/codex-taskboard/dist/web/assets/index-Docmx4uQ.css +0 -1
- package/vendor/codex-taskboard/dist/web/assets/index-Du07lEGz.js +0 -121
- package/vendor/codex-taskboard/dist/web/assets/info-DKCQHKI2-DOTjgYxk.js +0 -1
- package/vendor/codex-taskboard/dist/web/assets/infoDiagram-6WML65LV-SpyV9UJd.js +0 -2
- package/vendor/codex-taskboard/dist/web/assets/init-D6jRqBbL.js +0 -1
- package/vendor/codex-taskboard/dist/web/assets/ishikawaDiagram-WSZJBQD7-T0PZwoUZ.js +0 -70
- package/vendor/codex-taskboard/dist/web/assets/journeyDiagram-NVQOT4AX-B88b8UgX.js +0 -139
- package/vendor/codex-taskboard/dist/web/assets/kanban-definition-27J2QSJJ-CIlfNnl_.js +0 -89
- package/vendor/codex-taskboard/dist/web/assets/katex-B7rAX3Vi.js +0 -257
- package/vendor/codex-taskboard/dist/web/assets/line-fWrwPZhB.js +0 -1
- package/vendor/codex-taskboard/dist/web/assets/linear-B7l8qgEw.js +0 -1
- package/vendor/codex-taskboard/dist/web/assets/map-BaFkSB1l.js +0 -1
- package/vendor/codex-taskboard/dist/web/assets/mermaid-parser.core-C5gls2nC.js +0 -7
- package/vendor/codex-taskboard/dist/web/assets/mermaid.core-POz2Xcvr.js +0 -11
- package/vendor/codex-taskboard/dist/web/assets/mindmap-definition-FAOFIHXS-BM0-egZ5.js +0 -96
- package/vendor/codex-taskboard/dist/web/assets/ordinal-hYBb2elL.js +0 -1
- package/vendor/codex-taskboard/dist/web/assets/packet-7NZHBO7P-DQZzyVos.js +0 -1
- package/vendor/codex-taskboard/dist/web/assets/path-BWPyau1x.js +0 -1
- package/vendor/codex-taskboard/dist/web/assets/pegDiagram-VL7TDLO6-B8aEwl-7.js +0 -1
- package/vendor/codex-taskboard/dist/web/assets/pie-RZYD4A2V-DRUflilR.js +0 -1
- package/vendor/codex-taskboard/dist/web/assets/pieDiagram-7S7Q4E2Y-0cDDxlua.js +0 -39
- package/vendor/codex-taskboard/dist/web/assets/purify.es-Bwt4ulIK.js +0 -3
- package/vendor/codex-taskboard/dist/web/assets/quadrantDiagram-CIZ2JOQS-CBgb0YZ0.js +0 -7
- package/vendor/codex-taskboard/dist/web/assets/radar-I7S5WNFK-tRocoKxq.js +0 -1
- package/vendor/codex-taskboard/dist/web/assets/railroad-3IZDKUUU-DaS4ZlX_.js +0 -1
- package/vendor/codex-taskboard/dist/web/assets/railroad-abnf-AHOZXSZD-BN1T5dBT.js +0 -1
- package/vendor/codex-taskboard/dist/web/assets/railroad-ebnf-EBAXGLYW-Cuf8qyKs.js +0 -1
- package/vendor/codex-taskboard/dist/web/assets/railroad-peg-LSFZ7HO6-B511_EYq.js +0 -1
- package/vendor/codex-taskboard/dist/web/assets/railroadDiagram-AXF67PYL-QtPo-JZb.js +0 -1
- package/vendor/codex-taskboard/dist/web/assets/requirementDiagram-LRYGKXZP-CuBIF85r.js +0 -84
- package/vendor/codex-taskboard/dist/web/assets/rolldown-runtime-QTnfLwEv.js +0 -1
- package/vendor/codex-taskboard/dist/web/assets/rough.esm-CSKSodPl.js +0 -1
- package/vendor/codex-taskboard/dist/web/assets/sankeyDiagram-W5VNT64P-CRA-b_za.js +0 -40
- package/vendor/codex-taskboard/dist/web/assets/sequenceDiagram-SI44F4Z6-CoddVWwY.js +0 -162
- package/vendor/codex-taskboard/dist/web/assets/sizeCapture-X5ZJPWSS-B0uUizjq.js +0 -1
- package/vendor/codex-taskboard/dist/web/assets/src-_wZywoZs.js +0 -1
- package/vendor/codex-taskboard/dist/web/assets/stateDiagram-OKZ733FA-D8DMM2ln.js +0 -1
- package/vendor/codex-taskboard/dist/web/assets/stateDiagram-v2-UEYNNEHI-CVp0eYAS.js +0 -1
- package/vendor/codex-taskboard/dist/web/assets/swimlanes-SLNWSIFB-C1fC7EFu.js +0 -2
- package/vendor/codex-taskboard/dist/web/assets/swimlanesDiagram-ULZ7WXOC-CPd58Xso.js +0 -8
- package/vendor/codex-taskboard/dist/web/assets/timeline-definition-Z64GVDOM-BUBv8aKP.js +0 -120
- package/vendor/codex-taskboard/dist/web/assets/treeView-QDETBFTQ-C-Bve2kY.js +0 -1
- package/vendor/codex-taskboard/dist/web/assets/treemap-6X3UGDF4-BcphHDdV.js +0 -1
- package/vendor/codex-taskboard/dist/web/assets/vennDiagram-T6HMQDX7-0wQnwAyA.js +0 -34
- package/vendor/codex-taskboard/dist/web/assets/wardley-OPB4EBWU-CJZ4BevB.js +0 -1
- package/vendor/codex-taskboard/dist/web/assets/wardleyDiagram-T6FBY63Y-U9nwPrkN.js +0 -78
- package/vendor/codex-taskboard/dist/web/assets/xychartDiagram-ELKLHX3M-CyCqnzgE.js +0 -7
- package/vendor/codex-taskboard/dist/web/codex-agent-logo.png +0 -0
- package/vendor/codex-taskboard/dist/web/codex-app-icon.png +0 -0
- package/vendor/codex-taskboard/dist/web/favicon.svg +0 -7
- package/vendor/codex-taskboard/dist/web/index.html +0 -20
- package/vendor/codex-taskboard/server/ai-chat-catalog.mjs +0 -1031
- package/vendor/codex-taskboard/server/ai-chat-process.mjs +0 -462
- package/vendor/codex-taskboard/server/ai-chat.mjs +0 -1237
- package/vendor/codex-taskboard/server/ai-turn-owner.mjs +0 -39
- package/vendor/codex-taskboard/server/app.mjs +0 -3211
- package/vendor/codex-taskboard/server/cloud-config.mjs +0 -188
- package/vendor/codex-taskboard/server/cloud-proxy.mjs +0 -310
- package/vendor/codex-taskboard/server/codex-app-server.mjs +0 -376
- package/vendor/codex-taskboard/server/codex-slash-commands-0.139.0.json +0 -62
- package/vendor/codex-taskboard/server/database.mjs +0 -2845
- package/vendor/codex-taskboard/server/index.mjs +0 -41
- package/vendor/codex-taskboard/server/jira-config.mjs +0 -181
- package/vendor/codex-taskboard/server/jira-integration.mjs +0 -487
- package/vendor/codex-taskboard/server/project-summary.mjs +0 -159
- package/vendor/codex-taskboard/shared/api-fields.mjs +0 -163
- package/vendor/codex-taskboard/shared/codex-environment.mjs +0 -5
- package/vendor/codex-taskboard/shared/codex-executable.mjs +0 -77
- package/vendor/codex-taskboard/shared/codex-permissions.mjs +0 -9
- package/vendor/codex-taskboard/shared/composer-reference.d.mts +0 -19
- package/vendor/codex-taskboard/shared/composer-reference.mjs +0 -90
- package/vendor/codex-taskboard/shared/domain.mjs +0 -34
- package/vendor/codex-taskboard/shared/executable-command.mjs +0 -13
- package/vendor/codex-taskboard/shared/process-tree.mjs +0 -27
- package/vendor/codex-taskboard/shared/task-input.mjs +0 -308
- package/vendor/codex-taskboard/shared/task-records.mjs +0 -199
- package/vendor/codex-taskboard/shared/task-relations.mjs +0 -55
- package/vendor/codex-taskboard/shared/taskboard-automation.mjs +0 -277
- package/vendor/codex-taskboard/skills/manage-taskboard/SKILL.md +0 -45
- package/vendor/codex-taskboard/skills/manage-taskboard/agents/openai.yaml +0 -4
- 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
|
-
}
|