@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,3211 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
parseMove, parseVersionMutation, parseRelationMutation,
|
|
3
|
-
parseCommentCreate, parseCommentPatch, parseTaskCreate,
|
|
4
|
-
parseTaskPatch, parseTaskFilters, parseTaskTreeQuery, parseProjectReadmeSave,
|
|
5
|
-
} from "../shared/task-input.mjs";
|
|
6
|
-
import {
|
|
7
|
-
ApiError,
|
|
8
|
-
validateProjectId,
|
|
9
|
-
assertPlainObject,
|
|
10
|
-
assertAllowedKeys,
|
|
11
|
-
stringField,
|
|
12
|
-
slugify,
|
|
13
|
-
parseProjectLabel,
|
|
14
|
-
parseThreadId,
|
|
15
|
-
} from "../shared/api-fields.mjs";
|
|
16
|
-
import { createHmac, randomUUID } from "node:crypto";
|
|
17
|
-
import { execFile } from "node:child_process";
|
|
18
|
-
import { chmod, mkdir, open, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
19
|
-
import { createServer } from "node:http";
|
|
20
|
-
import { isIP } from "node:net";
|
|
21
|
-
import os from "node:os";
|
|
22
|
-
import path from "node:path";
|
|
23
|
-
import { Readable } from "node:stream";
|
|
24
|
-
import { fileURLToPath } from "node:url";
|
|
25
|
-
import { promisify } from "node:util";
|
|
26
|
-
import { WebSocket as WebSocketClient, WebSocketServer } from "ws";
|
|
27
|
-
|
|
28
|
-
import {
|
|
29
|
-
DEFAULT_PROJECT_ID,
|
|
30
|
-
JIRA_PROJECT_ID,
|
|
31
|
-
TASK_STATUSES,
|
|
32
|
-
} from "../shared/domain.mjs";
|
|
33
|
-
import { resolveCodexExecutable } from "../shared/codex-executable.mjs";
|
|
34
|
-
import { withoutTaskboardLauncherEnvironment } from "../shared/codex-environment.mjs";
|
|
35
|
-
import { AiChatService } from "./ai-chat.mjs";
|
|
36
|
-
import { resolveAiWorkspace, resolveMappedAiWorkspace } from "./ai-chat-catalog.mjs";
|
|
37
|
-
import { decodeComposerReferenceKey } from "../shared/composer-reference.mjs";
|
|
38
|
-
import { createCloudConfigStore } from "./cloud-config.mjs";
|
|
39
|
-
import {
|
|
40
|
-
CloudProxyError,
|
|
41
|
-
createCloudProxy,
|
|
42
|
-
isLocalCompanionRoute,
|
|
43
|
-
} from "./cloud-proxy.mjs";
|
|
44
|
-
import { TaskboardDatabase } from "./database.mjs";
|
|
45
|
-
import { createJiraConfigStore } from "./jira-config.mjs";
|
|
46
|
-
import { createJiraIntegration } from "./jira-integration.mjs";
|
|
47
|
-
import { ProjectSummaryService } from "./project-summary.mjs";
|
|
48
|
-
|
|
49
|
-
const PROJECT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
50
|
-
const execFileAsync = promisify(execFile);
|
|
51
|
-
const JSON_BODY_LIMIT = 1024 * 1024;
|
|
52
|
-
const PROJECT_README_BODY_LIMIT = 3 * 1024 * 1024;
|
|
53
|
-
const ATTACHMENT_BODY_LIMIT = 25 * 1024 * 1024;
|
|
54
|
-
const AI_CHAT_TURN_BODY_LIMIT = 25 * 1024 * 1024;
|
|
55
|
-
const AI_CHAT_ATTACHMENT_LIMIT = 10;
|
|
56
|
-
const AI_CHAT_SKILL_MARKER = "\uFFFC";
|
|
57
|
-
const HOST_RUNTIME_TTL_MS = 3_000;
|
|
58
|
-
const CODEX_PLAN_TAIL_BYTES = 16 * 1024 * 1024;
|
|
59
|
-
const INLINE_ATTACHMENT_TYPES = new Set([
|
|
60
|
-
"application/pdf",
|
|
61
|
-
"image/avif",
|
|
62
|
-
"image/gif",
|
|
63
|
-
"image/jpeg",
|
|
64
|
-
"image/png",
|
|
65
|
-
"image/webp",
|
|
66
|
-
"text/plain",
|
|
67
|
-
]);
|
|
68
|
-
const PROJECT_BOARD_DISPLAY_SETTINGS_KEY_PREFIX = "taskboard.project-board-display-settings.v3.";
|
|
69
|
-
const TRUSTED_EMBED_ORIGINS = new Set(["app://-"]);
|
|
70
|
-
const TRUSTED_ORIGINS_ENV = "CODEX_TASKBOARD_TRUSTED_ORIGINS";
|
|
71
|
-
const CODEX_AGENT_ACTOR = {
|
|
72
|
-
type: "agent",
|
|
73
|
-
id: "codex-agent",
|
|
74
|
-
name: "Codex Agent",
|
|
75
|
-
avatarUrl: null,
|
|
76
|
-
};
|
|
77
|
-
const CONTENT_TYPES = new Map([
|
|
78
|
-
[".css", "text/css; charset=utf-8"],
|
|
79
|
-
[".html", "text/html; charset=utf-8"],
|
|
80
|
-
[".ico", "image/x-icon"],
|
|
81
|
-
[".jpeg", "image/jpeg"],
|
|
82
|
-
[".jpg", "image/jpeg"],
|
|
83
|
-
[".js", "text/javascript; charset=utf-8"],
|
|
84
|
-
[".json", "application/json; charset=utf-8"],
|
|
85
|
-
[".map", "application/json; charset=utf-8"],
|
|
86
|
-
[".png", "image/png"],
|
|
87
|
-
[".svg", "image/svg+xml"],
|
|
88
|
-
[".webp", "image/webp"],
|
|
89
|
-
[".woff", "font/woff"],
|
|
90
|
-
[".woff2", "font/woff2"],
|
|
91
|
-
]);
|
|
92
|
-
|
|
93
|
-
function sendJson(response, status, value, headers = {}) {
|
|
94
|
-
const body = JSON.stringify(value);
|
|
95
|
-
response.writeHead(status, {
|
|
96
|
-
"cache-control": "no-store",
|
|
97
|
-
"content-length": Buffer.byteLength(body),
|
|
98
|
-
"content-type": "application/json; charset=utf-8",
|
|
99
|
-
...headers,
|
|
100
|
-
});
|
|
101
|
-
response.end(body);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function sendEmpty(response, status, headers = {}) {
|
|
105
|
-
response.writeHead(status, { "cache-control": "no-store", ...headers });
|
|
106
|
-
response.end();
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function toFetchRequest(request) {
|
|
110
|
-
const headers = new Headers();
|
|
111
|
-
for (const [name, value] of Object.entries(request.headers)) {
|
|
112
|
-
if (Array.isArray(value)) {
|
|
113
|
-
for (const entry of value) headers.append(name, entry);
|
|
114
|
-
} else if (value !== undefined) {
|
|
115
|
-
headers.set(name, value);
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
const init = { method: request.method, headers };
|
|
119
|
-
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
120
|
-
init.body = Readable.toWeb(request);
|
|
121
|
-
init.duplex = "half";
|
|
122
|
-
}
|
|
123
|
-
return new Request(`http://127.0.0.1${request.url}`, init);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
async function sendFetchResponse(response, upstream) {
|
|
127
|
-
response.statusCode = upstream.status;
|
|
128
|
-
response.statusMessage = upstream.statusText;
|
|
129
|
-
for (const [name, value] of upstream.headers) {
|
|
130
|
-
if (
|
|
131
|
-
name === "connection"
|
|
132
|
-
|| name === "content-encoding"
|
|
133
|
-
|| name === "content-length"
|
|
134
|
-
|| name === "set-cookie"
|
|
135
|
-
|| name === "transfer-encoding"
|
|
136
|
-
) {
|
|
137
|
-
continue;
|
|
138
|
-
}
|
|
139
|
-
response.setHeader(name, value);
|
|
140
|
-
}
|
|
141
|
-
const cookies = upstream.headers.getSetCookie?.() ?? [];
|
|
142
|
-
if (cookies.length > 0) response.setHeader("set-cookie", cookies);
|
|
143
|
-
if (!upstream.body) {
|
|
144
|
-
response.end();
|
|
145
|
-
return;
|
|
146
|
-
}
|
|
147
|
-
await new Promise((resolve, reject) => {
|
|
148
|
-
const body = Readable.fromWeb(upstream.body);
|
|
149
|
-
body.once("error", reject);
|
|
150
|
-
response.once("finish", resolve);
|
|
151
|
-
body.pipe(response);
|
|
152
|
-
});
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
function normalizeHostname(hostname) {
|
|
156
|
-
return hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
function isTrustedNetworkHost(hostname) {
|
|
160
|
-
const host = normalizeHostname(hostname);
|
|
161
|
-
if (host === "localhost" || host === "::1" || host.endsWith(".local")) return true;
|
|
162
|
-
if (isIP(host) === 4) {
|
|
163
|
-
const octets = host.split(".").map(Number);
|
|
164
|
-
return octets[0] === 127
|
|
165
|
-
|| octets[0] === 10
|
|
166
|
-
|| (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31)
|
|
167
|
-
|| (octets[0] === 192 && octets[1] === 168)
|
|
168
|
-
|| (octets[0] === 169 && octets[1] === 254);
|
|
169
|
-
}
|
|
170
|
-
if (isIP(host) === 6) {
|
|
171
|
-
return host.startsWith("fc")
|
|
172
|
-
|| host.startsWith("fd")
|
|
173
|
-
|| /^fe[89ab]/.test(host);
|
|
174
|
-
}
|
|
175
|
-
return false;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
function parseTrustedOrigins(value) {
|
|
179
|
-
if (value === undefined) return new Set();
|
|
180
|
-
const configured = String(value).trim();
|
|
181
|
-
if (!configured) {
|
|
182
|
-
throw new Error(`${TRUSTED_ORIGINS_ENV} must not be empty when configured`);
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
const origins = new Set();
|
|
186
|
-
for (const rawOrigin of configured.split(",")) {
|
|
187
|
-
const origin = rawOrigin.trim();
|
|
188
|
-
if (!origin || origin.includes("*")) {
|
|
189
|
-
throw new Error(`${TRUSTED_ORIGINS_ENV} must be a comma-separated list of exact HTTPS origins`);
|
|
190
|
-
}
|
|
191
|
-
let url;
|
|
192
|
-
try {
|
|
193
|
-
url = new URL(origin);
|
|
194
|
-
} catch {
|
|
195
|
-
throw new Error(`${TRUSTED_ORIGINS_ENV} must contain valid HTTPS origins`);
|
|
196
|
-
}
|
|
197
|
-
if (
|
|
198
|
-
url.protocol !== "https:"
|
|
199
|
-
|| url.username
|
|
200
|
-
|| url.password
|
|
201
|
-
|| url.pathname !== "/"
|
|
202
|
-
|| url.search
|
|
203
|
-
|| url.hash
|
|
204
|
-
) {
|
|
205
|
-
throw new Error(`${TRUSTED_ORIGINS_ENV} must contain exact HTTPS origins without paths, queries, fragments, or credentials`);
|
|
206
|
-
}
|
|
207
|
-
if (origins.has(url.origin)) {
|
|
208
|
-
throw new Error(`${TRUSTED_ORIGINS_ENV} must not contain duplicate origins`);
|
|
209
|
-
}
|
|
210
|
-
origins.add(url.origin);
|
|
211
|
-
}
|
|
212
|
-
return origins;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
function parseRequestHost(value) {
|
|
216
|
-
if (typeof value !== "string" || !value || value !== value.trim()) {
|
|
217
|
-
throw new ApiError(403, "INVALID_HOST", "Request Host must be local, private, or explicitly trusted");
|
|
218
|
-
}
|
|
219
|
-
let url;
|
|
220
|
-
try {
|
|
221
|
-
url = new URL(`https://${value}`);
|
|
222
|
-
} catch {
|
|
223
|
-
throw new ApiError(403, "INVALID_HOST", "Request Host must be local, private, or explicitly trusted");
|
|
224
|
-
}
|
|
225
|
-
if (
|
|
226
|
-
url.username
|
|
227
|
-
|| url.password
|
|
228
|
-
|| url.pathname !== "/"
|
|
229
|
-
|| url.search
|
|
230
|
-
|| url.hash
|
|
231
|
-
|| !url.hostname
|
|
232
|
-
) {
|
|
233
|
-
throw new ApiError(403, "INVALID_HOST", "Request Host must be local, private, or explicitly trusted");
|
|
234
|
-
}
|
|
235
|
-
return { hostname: url.hostname, httpsOrigin: url.origin };
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
function assertTrustedNetworkRequest(request, allowOpaqueOrigin = false, trustedOrigins = new Set()) {
|
|
239
|
-
const host = parseRequestHost(request.headers.host);
|
|
240
|
-
const trustedNetworkHost = isTrustedNetworkHost(host.hostname);
|
|
241
|
-
const configuredTrustedHost = !trustedNetworkHost && trustedOrigins.has(host.httpsOrigin);
|
|
242
|
-
if (!trustedNetworkHost && !configuredTrustedHost) {
|
|
243
|
-
throw new ApiError(403, "INVALID_HOST", "Request Host must be local, private, or explicitly trusted");
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
const origin = request.headers.origin;
|
|
247
|
-
const configuredTrustedOrigin = trustedOrigins.has(origin);
|
|
248
|
-
if (origin && !configuredTrustedOrigin && !TRUSTED_EMBED_ORIGINS.has(origin)) {
|
|
249
|
-
if (!(allowOpaqueOrigin && origin === "null")) {
|
|
250
|
-
let originHost;
|
|
251
|
-
try {
|
|
252
|
-
originHost = new URL(origin).hostname;
|
|
253
|
-
} catch {
|
|
254
|
-
throw new ApiError(403, "INVALID_ORIGIN", "Request Origin must be local or private");
|
|
255
|
-
}
|
|
256
|
-
if (!isTrustedNetworkHost(originHost)) {
|
|
257
|
-
throw new ApiError(403, "INVALID_ORIGIN", "Request Origin must be local or private");
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
return configuredTrustedHost || configuredTrustedOrigin;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
function assertLoopbackRequest(request) {
|
|
265
|
-
const address = request.socket.remoteAddress;
|
|
266
|
-
if (
|
|
267
|
-
address !== "127.0.0.1"
|
|
268
|
-
&& address !== "::1"
|
|
269
|
-
&& address !== "::ffff:127.0.0.1"
|
|
270
|
-
) {
|
|
271
|
-
throw new ApiError(403, "LOCAL_ONLY", "This endpoint is only available on this device");
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
function assertAllowedQuery(searchParams, allowed, routeLabel) {
|
|
276
|
-
for (const key of searchParams.keys()) {
|
|
277
|
-
if (!allowed.has(key)) {
|
|
278
|
-
throw new ApiError(400, "UNKNOWN_QUERY_PARAMETER", `${routeLabel} does not accept query parameter '${key}'`);
|
|
279
|
-
}
|
|
280
|
-
if (searchParams.getAll(key).length !== 1) {
|
|
281
|
-
throw new ApiError(400, "INVALID_QUERY_PARAMETER", `Query parameter '${key}' cannot be repeated`);
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
function assertNoQuery(searchParams, routeLabel) {
|
|
287
|
-
assertAllowedQuery(searchParams, new Set(), routeLabel);
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
function parseAfterCursor(searchParams, routeLabel) {
|
|
291
|
-
assertAllowedQuery(searchParams, new Set(["after"]), routeLabel);
|
|
292
|
-
const value = searchParams.get("after");
|
|
293
|
-
if (value === null) return null;
|
|
294
|
-
const revision = Number(value);
|
|
295
|
-
if (!/^\d+$/.test(value) || !Number.isSafeInteger(revision)) {
|
|
296
|
-
throw new ApiError(400, "INVALID_CURSOR", "Cursor must be a non-negative integer revision");
|
|
297
|
-
}
|
|
298
|
-
return { value, revision };
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
function nextCursor(items, after) {
|
|
302
|
-
if (items.length === 0) return after?.value ?? "0";
|
|
303
|
-
return String(items.reduce(
|
|
304
|
-
(revision, item) => Math.max(revision, item.changeRevision),
|
|
305
|
-
0,
|
|
306
|
-
));
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
function decodeRouteSegment(value, name) {
|
|
310
|
-
let decoded;
|
|
311
|
-
try {
|
|
312
|
-
decoded = decodeURIComponent(value);
|
|
313
|
-
} catch {
|
|
314
|
-
throw new ApiError(400, "INVALID_PATH", `${name} contains invalid encoding`);
|
|
315
|
-
}
|
|
316
|
-
if (!decoded || decoded.length > 256 || decoded.includes("\0")) {
|
|
317
|
-
throw new ApiError(400, "INVALID_PATH", `${name} is invalid`);
|
|
318
|
-
}
|
|
319
|
-
return decoded;
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
function isLoopbackAddress(value) {
|
|
323
|
-
if (typeof value !== "string") return false;
|
|
324
|
-
const address = value.toLowerCase().split("%", 1)[0];
|
|
325
|
-
return address === "::1"
|
|
326
|
-
|| address === "127.0.0.1"
|
|
327
|
-
|| address.startsWith("127.")
|
|
328
|
-
|| address === "::ffff:127.0.0.1"
|
|
329
|
-
|| address.startsWith("::ffff:127.");
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
function assertAiLoopbackRequest(request) {
|
|
333
|
-
if (!isLoopbackAddress(request.socket.remoteAddress)) {
|
|
334
|
-
throw new ApiError(403, "LOCAL_AI_LOOPBACK_REQUIRED", "Local AI routes are only available from this device");
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
function pathField(value, name) {
|
|
339
|
-
const normalized = stringField(value, name, { nullable: true, maxLength: 4096 });
|
|
340
|
-
if (normalized === "") {
|
|
341
|
-
throw new ApiError(400, "INVALID_FIELD", `'${name}' cannot be empty`);
|
|
342
|
-
}
|
|
343
|
-
if (normalized?.includes("\0")) {
|
|
344
|
-
throw new ApiError(400, "INVALID_FIELD", `'${name}' cannot contain null bytes`);
|
|
345
|
-
}
|
|
346
|
-
return normalized;
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
function parseDevelopmentContext(value) {
|
|
350
|
-
if (value === null) return null;
|
|
351
|
-
assertPlainObject(value);
|
|
352
|
-
if (value.type === "branch") {
|
|
353
|
-
assertAllowedKeys(value, new Set(["type", "branch"]));
|
|
354
|
-
return {
|
|
355
|
-
type: "branch",
|
|
356
|
-
branch: stringField(value.branch, "developmentContext.branch", { required: true, maxLength: 512 }),
|
|
357
|
-
};
|
|
358
|
-
}
|
|
359
|
-
if (value.type === "worktree") {
|
|
360
|
-
assertAllowedKeys(value, new Set(["type", "path", "branch"]));
|
|
361
|
-
const worktreePath = stringField(value.path, "developmentContext.path", { required: true, maxLength: 4096 });
|
|
362
|
-
if (worktreePath.includes("\0")) {
|
|
363
|
-
throw new ApiError(400, "INVALID_FIELD", "'developmentContext.path' cannot contain null bytes");
|
|
364
|
-
}
|
|
365
|
-
return {
|
|
366
|
-
type: "worktree",
|
|
367
|
-
path: worktreePath,
|
|
368
|
-
branch: stringField(value.branch ?? null, "developmentContext.branch", { nullable: true, maxLength: 512 }),
|
|
369
|
-
};
|
|
370
|
-
}
|
|
371
|
-
throw new ApiError(400, "INVALID_FIELD", "'developmentContext.type' must be branch or worktree");
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
function parseProjectCreate(body) {
|
|
375
|
-
assertPlainObject(body);
|
|
376
|
-
assertAllowedKeys(body, new Set(["id", "name", "workspacePath"]));
|
|
377
|
-
const name = stringField(body.name, "name", { required: true, maxLength: 120 });
|
|
378
|
-
const id = validateProjectId(body.id ?? slugify(name));
|
|
379
|
-
if (!id) {
|
|
380
|
-
throw new ApiError(400, "INVALID_FIELD", "Project name must contain at least one letter or number when 'id' is omitted");
|
|
381
|
-
}
|
|
382
|
-
const workspacePath = stringField(body.workspacePath ?? null, "workspacePath", { nullable: true, maxLength: 4096 });
|
|
383
|
-
if (workspacePath === "") {
|
|
384
|
-
throw new ApiError(400, "INVALID_FIELD", "'workspacePath' cannot be empty");
|
|
385
|
-
}
|
|
386
|
-
if (workspacePath?.includes("\0")) {
|
|
387
|
-
throw new ApiError(400, "INVALID_FIELD", "'workspacePath' cannot contain null bytes");
|
|
388
|
-
}
|
|
389
|
-
return { id, name, workspacePath };
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
function requestHeader(request, name) {
|
|
393
|
-
const value = request.headers[name];
|
|
394
|
-
return Array.isArray(value) ? value[0] : value;
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
function actorFromRequest(request) {
|
|
398
|
-
if (request.headers["x-taskboard-client"] === "taskctl") {
|
|
399
|
-
return CODEX_AGENT_ACTOR;
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
const rawId = requestHeader(request, "x-taskboard-user-id");
|
|
403
|
-
const rawName = requestHeader(request, "x-taskboard-user-name");
|
|
404
|
-
const rawAvatarUrl = requestHeader(request, "x-taskboard-user-avatar");
|
|
405
|
-
if (rawId === undefined && rawName === undefined && rawAvatarUrl === undefined) {
|
|
406
|
-
return { type: "user", id: "local-user", name: "本地用户", avatarUrl: null };
|
|
407
|
-
}
|
|
408
|
-
if (rawId === undefined || rawName === undefined) {
|
|
409
|
-
throw new ApiError(400, "INVALID_ACTOR", "User identity requires both an ID and name");
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
const id = stringField(rawId, "X-Taskboard-User-Id", { required: true, maxLength: 96 });
|
|
413
|
-
if (!/^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$/.test(id)) {
|
|
414
|
-
throw new ApiError(400, "INVALID_ACTOR", "User ID contains unsupported characters");
|
|
415
|
-
}
|
|
416
|
-
let decodedName;
|
|
417
|
-
try {
|
|
418
|
-
decodedName = decodeURIComponent(rawName);
|
|
419
|
-
} catch {
|
|
420
|
-
throw new ApiError(400, "INVALID_ACTOR", "User name is not valid URL-encoded text");
|
|
421
|
-
}
|
|
422
|
-
const name = stringField(decodedName, "X-Taskboard-User-Name", { required: true, maxLength: 120 });
|
|
423
|
-
|
|
424
|
-
let avatarUrl = null;
|
|
425
|
-
if (rawAvatarUrl !== undefined) {
|
|
426
|
-
const value = stringField(rawAvatarUrl, "X-Taskboard-User-Avatar", { required: true, maxLength: 2048 });
|
|
427
|
-
let parsed;
|
|
428
|
-
try {
|
|
429
|
-
parsed = new URL(value);
|
|
430
|
-
} catch {
|
|
431
|
-
throw new ApiError(400, "INVALID_ACTOR", "User avatar URL is invalid");
|
|
432
|
-
}
|
|
433
|
-
if (!["http:", "https:"].includes(parsed.protocol)) {
|
|
434
|
-
throw new ApiError(400, "INVALID_ACTOR", "User avatar URL must use HTTP or HTTPS");
|
|
435
|
-
}
|
|
436
|
-
avatarUrl = parsed.toString();
|
|
437
|
-
}
|
|
438
|
-
return { type: "user", id, name, avatarUrl };
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
function resolveAssignee(target, actor) {
|
|
442
|
-
if (target === undefined) return actor;
|
|
443
|
-
if (target === "codex-agent") return CODEX_AGENT_ACTOR;
|
|
444
|
-
if (actor.type !== "user") {
|
|
445
|
-
throw new ApiError(400, "INVALID_FIELD", "'current-user' requires a user request identity");
|
|
446
|
-
}
|
|
447
|
-
return actor;
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
function parseIssueRelationType(value) {
|
|
451
|
-
if (!["parent", "blocks", "blocked_by", "related"].includes(value)) {
|
|
452
|
-
throw new ApiError(
|
|
453
|
-
400,
|
|
454
|
-
"INVALID_FIELD",
|
|
455
|
-
"'relation type' must be parent, blocks, blocked_by, or related",
|
|
456
|
-
);
|
|
457
|
-
}
|
|
458
|
-
return value;
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
function parseAttachmentHeaders(request) {
|
|
462
|
-
const encodedFilename = request.headers["x-taskboard-filename"];
|
|
463
|
-
if (typeof encodedFilename !== "string") {
|
|
464
|
-
throw new ApiError(400, "INVALID_FILENAME", "X-Taskboard-Filename is required");
|
|
465
|
-
}
|
|
466
|
-
let filename;
|
|
467
|
-
try {
|
|
468
|
-
filename = decodeURIComponent(encodedFilename).trim();
|
|
469
|
-
} catch {
|
|
470
|
-
throw new ApiError(400, "INVALID_FILENAME", "Attachment filename contains invalid encoding");
|
|
471
|
-
}
|
|
472
|
-
if (
|
|
473
|
-
filename.length === 0
|
|
474
|
-
|| filename.length > 240
|
|
475
|
-
|| filename === "."
|
|
476
|
-
|| filename === ".."
|
|
477
|
-
|| /[\u0000-\u001f\u007f/\\]/.test(filename)
|
|
478
|
-
) {
|
|
479
|
-
throw new ApiError(400, "INVALID_FILENAME", "Attachment filename is invalid");
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
const rawContentType = request.headers["content-type"];
|
|
483
|
-
const contentType = typeof rawContentType === "string"
|
|
484
|
-
? rawContentType.split(";", 1)[0].trim().toLowerCase()
|
|
485
|
-
: "application/octet-stream";
|
|
486
|
-
if (contentType.length === 0 || contentType.length > 200 || !/^[!#$%&'*+.^_`|~0-9a-z-]+\/[!#$%&'*+.^_`|~0-9a-z-]+$/.test(contentType)) {
|
|
487
|
-
throw new ApiError(415, "UNSUPPORTED_MEDIA_TYPE", "Attachment Content-Type is invalid");
|
|
488
|
-
}
|
|
489
|
-
const kind = request.headers["x-taskboard-attachment-kind"];
|
|
490
|
-
if (kind !== "inline" && kind !== "attachment") {
|
|
491
|
-
throw new ApiError(
|
|
492
|
-
400,
|
|
493
|
-
"INVALID_ATTACHMENT_KIND",
|
|
494
|
-
"X-Taskboard-Attachment-Kind must be inline or attachment",
|
|
495
|
-
);
|
|
496
|
-
}
|
|
497
|
-
return { filename, contentType, kind };
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
async function readBody(request, limit, tooLargeMessage) {
|
|
501
|
-
const declaredLength = Number(request.headers["content-length"] ?? 0);
|
|
502
|
-
if (Number.isFinite(declaredLength) && declaredLength > limit) {
|
|
503
|
-
throw new ApiError(413, "BODY_TOO_LARGE", tooLargeMessage);
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
const chunks = [];
|
|
507
|
-
let length = 0;
|
|
508
|
-
for await (const chunk of request) {
|
|
509
|
-
length += chunk.length;
|
|
510
|
-
if (length > limit) {
|
|
511
|
-
throw new ApiError(413, "BODY_TOO_LARGE", tooLargeMessage);
|
|
512
|
-
}
|
|
513
|
-
chunks.push(chunk);
|
|
514
|
-
}
|
|
515
|
-
return Buffer.concat(chunks);
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
async function readJson(
|
|
519
|
-
request,
|
|
520
|
-
limit = JSON_BODY_LIMIT,
|
|
521
|
-
tooLargeMessage = "Request body cannot exceed 1 MiB",
|
|
522
|
-
) {
|
|
523
|
-
const contentType = request.headers["content-type"]?.split(";", 1)[0].trim().toLowerCase();
|
|
524
|
-
if (contentType !== "application/json") {
|
|
525
|
-
throw new ApiError(415, "UNSUPPORTED_MEDIA_TYPE", "Content-Type must be application/json");
|
|
526
|
-
}
|
|
527
|
-
const body = await readBody(request, limit, tooLargeMessage);
|
|
528
|
-
const length = body.length;
|
|
529
|
-
if (length === 0) {
|
|
530
|
-
throw new ApiError(400, "INVALID_JSON", "Request body cannot be empty");
|
|
531
|
-
}
|
|
532
|
-
try {
|
|
533
|
-
return JSON.parse(body.toString("utf8"));
|
|
534
|
-
} catch {
|
|
535
|
-
throw new ApiError(400, "INVALID_JSON", "Request body must contain valid JSON");
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
async function assertEmptyRequestBody(request, routeLabel) {
|
|
540
|
-
const body = await readBody(request, JSON_BODY_LIMIT, "Request body cannot exceed 1 MiB");
|
|
541
|
-
if (body.length > 0) {
|
|
542
|
-
throw new ApiError(400, "INVALID_BODY", `${routeLabel} does not accept a request body`);
|
|
543
|
-
}
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
function parseAiSandbox(value) {
|
|
547
|
-
if (value === undefined) return undefined;
|
|
548
|
-
if (!["read-only", "workspace-write", "danger-full-access"].includes(value)) {
|
|
549
|
-
throw new ApiError(
|
|
550
|
-
400,
|
|
551
|
-
"INVALID_SANDBOX",
|
|
552
|
-
"'sandbox' must be read-only, workspace-write, or danger-full-access",
|
|
553
|
-
);
|
|
554
|
-
}
|
|
555
|
-
return value;
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
function parseAiSetting(value, name, maxLength) {
|
|
559
|
-
const setting = stringField(value, name, { maxLength });
|
|
560
|
-
if (setting === "") {
|
|
561
|
-
throw new ApiError(400, "INVALID_FIELD", `'${name}' cannot be empty`);
|
|
562
|
-
}
|
|
563
|
-
return setting;
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
function parseAiExecutionTarget(value) {
|
|
567
|
-
const fields = [
|
|
568
|
-
"codexProjectId",
|
|
569
|
-
"codexProjectKind",
|
|
570
|
-
"codexHostId",
|
|
571
|
-
"workspacePath",
|
|
572
|
-
];
|
|
573
|
-
const present = fields.filter((field) => value[field] !== undefined);
|
|
574
|
-
if (present.length === 0) return undefined;
|
|
575
|
-
if (present.length !== fields.length) {
|
|
576
|
-
throw new ApiError(400, "INVALID_CODEX_TARGET", "Codex project identity must contain all four fields");
|
|
577
|
-
}
|
|
578
|
-
const codexProjectKind = parseAiSetting(value.codexProjectKind, "codexProjectKind", 16);
|
|
579
|
-
if (codexProjectKind !== "local" && codexProjectKind !== "remote") {
|
|
580
|
-
throw new ApiError(400, "INVALID_CODEX_TARGET", "'codexProjectKind' must be local or remote");
|
|
581
|
-
}
|
|
582
|
-
const workspacePath = parseAiSetting(value.workspacePath, "workspacePath", 4096);
|
|
583
|
-
if (workspacePath.includes("\0")) {
|
|
584
|
-
throw new ApiError(400, "INVALID_CODEX_TARGET", "'workspacePath' cannot contain null bytes");
|
|
585
|
-
}
|
|
586
|
-
return {
|
|
587
|
-
codexProjectId: parseAiSetting(value.codexProjectId, "codexProjectId", 256),
|
|
588
|
-
codexProjectKind,
|
|
589
|
-
codexHostId: parseAiSetting(value.codexHostId, "codexHostId", 512),
|
|
590
|
-
workspacePath,
|
|
591
|
-
};
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
function aiExecutionTargetFromQuery(searchParams) {
|
|
595
|
-
return parseAiExecutionTarget({
|
|
596
|
-
codexProjectId: searchParams.get("codexProjectId") ?? undefined,
|
|
597
|
-
codexProjectKind: searchParams.get("codexProjectKind") ?? undefined,
|
|
598
|
-
codexHostId: searchParams.get("codexHostId") ?? undefined,
|
|
599
|
-
workspacePath: searchParams.get("workspacePath") ?? undefined,
|
|
600
|
-
});
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
function parseAiThreadCreate(body) {
|
|
604
|
-
assertPlainObject(body);
|
|
605
|
-
assertAllowedKeys(body, new Set([
|
|
606
|
-
"projectId",
|
|
607
|
-
"issueId",
|
|
608
|
-
"title",
|
|
609
|
-
"model",
|
|
610
|
-
"reasoningEffort",
|
|
611
|
-
"sandbox",
|
|
612
|
-
"codexProjectId",
|
|
613
|
-
"codexProjectKind",
|
|
614
|
-
"codexHostId",
|
|
615
|
-
"workspacePath",
|
|
616
|
-
]));
|
|
617
|
-
return {
|
|
618
|
-
projectId: validateProjectId(body.projectId),
|
|
619
|
-
issueId: parseAiSetting(body.issueId, "issueId", 128),
|
|
620
|
-
title: parseAiSetting(body.title, "title", 160),
|
|
621
|
-
model: parseAiSetting(body.model, "model", 128),
|
|
622
|
-
reasoningEffort: parseAiSetting(body.reasoningEffort, "reasoningEffort", 64),
|
|
623
|
-
sandbox: parseAiSandbox(body.sandbox),
|
|
624
|
-
...parseAiExecutionTarget(body),
|
|
625
|
-
};
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
function parseAiThreadPatch(body) {
|
|
629
|
-
assertPlainObject(body);
|
|
630
|
-
assertAllowedKeys(body, new Set(["title", "model", "reasoningEffort", "sandbox"]));
|
|
631
|
-
const input = {};
|
|
632
|
-
if (body.title !== undefined) input.title = parseAiSetting(body.title, "title", 160);
|
|
633
|
-
if (body.model !== undefined) input.model = parseAiSetting(body.model, "model", 128);
|
|
634
|
-
if (body.reasoningEffort !== undefined) {
|
|
635
|
-
input.reasoningEffort = parseAiSetting(body.reasoningEffort, "reasoningEffort", 64);
|
|
636
|
-
}
|
|
637
|
-
if (body.sandbox !== undefined) input.sandbox = parseAiSandbox(body.sandbox);
|
|
638
|
-
if (Object.keys(input).length === 0) {
|
|
639
|
-
throw new ApiError(400, "INVALID_BODY", "PATCH requires at least one thread setting");
|
|
640
|
-
}
|
|
641
|
-
return input;
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
function parseAiSkillIds(value) {
|
|
645
|
-
if (value === undefined) return undefined;
|
|
646
|
-
if (!Array.isArray(value) || value.length > 20) {
|
|
647
|
-
throw new ApiError(400, "INVALID_FIELD", "'skillIds' must be an array with at most 20 entries");
|
|
648
|
-
}
|
|
649
|
-
const skillIds = value.map((skillId, index) => (
|
|
650
|
-
stringField(skillId, `skillIds[${index}]`, { required: true, maxLength: 256 })
|
|
651
|
-
));
|
|
652
|
-
return skillIds;
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
function parseAiAttachments(value) {
|
|
656
|
-
if (value === undefined) return [];
|
|
657
|
-
if (!Array.isArray(value) || value.length > AI_CHAT_ATTACHMENT_LIMIT) {
|
|
658
|
-
throw new ApiError(
|
|
659
|
-
400,
|
|
660
|
-
"INVALID_ATTACHMENT",
|
|
661
|
-
`'attachments' must be an array with at most ${AI_CHAT_ATTACHMENT_LIMIT} files`,
|
|
662
|
-
);
|
|
663
|
-
}
|
|
664
|
-
return value.map((attachment, index) => {
|
|
665
|
-
assertPlainObject(attachment);
|
|
666
|
-
assertAllowedKeys(attachment, new Set(["filename", "contentType", "dataBase64"]));
|
|
667
|
-
const filename = stringField(attachment.filename, `attachments[${index}].filename`, {
|
|
668
|
-
required: true,
|
|
669
|
-
maxLength: 240,
|
|
670
|
-
});
|
|
671
|
-
if (/[\u0000-\u001f\u007f/\\]/.test(filename)) {
|
|
672
|
-
throw new ApiError(
|
|
673
|
-
400,
|
|
674
|
-
"INVALID_ATTACHMENT",
|
|
675
|
-
`'attachments[${index}].filename' is invalid`,
|
|
676
|
-
);
|
|
677
|
-
}
|
|
678
|
-
const contentType = stringField(
|
|
679
|
-
attachment.contentType,
|
|
680
|
-
`attachments[${index}].contentType`,
|
|
681
|
-
{ required: true, maxLength: 256 },
|
|
682
|
-
).toLowerCase();
|
|
683
|
-
const dataBase64 = stringField(
|
|
684
|
-
attachment.dataBase64,
|
|
685
|
-
`attachments[${index}].dataBase64`,
|
|
686
|
-
{ required: true, maxLength: AI_CHAT_TURN_BODY_LIMIT },
|
|
687
|
-
);
|
|
688
|
-
if (
|
|
689
|
-
dataBase64.length % 4 !== 0
|
|
690
|
-
|| !/^[A-Za-z0-9+/]+={0,2}$/.test(dataBase64)
|
|
691
|
-
) {
|
|
692
|
-
throw new ApiError(
|
|
693
|
-
400,
|
|
694
|
-
"INVALID_ATTACHMENT",
|
|
695
|
-
`'attachments[${index}].dataBase64' must contain valid base64`,
|
|
696
|
-
);
|
|
697
|
-
}
|
|
698
|
-
const data = Buffer.from(dataBase64, "base64");
|
|
699
|
-
if (data.length === 0 || data.toString("base64") !== dataBase64) {
|
|
700
|
-
throw new ApiError(
|
|
701
|
-
400,
|
|
702
|
-
"INVALID_ATTACHMENT",
|
|
703
|
-
`'attachments[${index}].dataBase64' must contain valid base64`,
|
|
704
|
-
);
|
|
705
|
-
}
|
|
706
|
-
return { filename, contentType, data, size: data.length };
|
|
707
|
-
});
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
function parseAiTurn(body) {
|
|
711
|
-
assertPlainObject(body);
|
|
712
|
-
if (body.contractVersion !== undefined) return parseComposerTurn(body);
|
|
713
|
-
assertAllowedKeys(body, new Set([
|
|
714
|
-
"message",
|
|
715
|
-
"skillIds",
|
|
716
|
-
"dangerFullAccessConfirmed",
|
|
717
|
-
"attachments",
|
|
718
|
-
]));
|
|
719
|
-
if (
|
|
720
|
-
body.dangerFullAccessConfirmed !== undefined
|
|
721
|
-
&& typeof body.dangerFullAccessConfirmed !== "boolean"
|
|
722
|
-
) {
|
|
723
|
-
throw new ApiError(400, "INVALID_FIELD", "'dangerFullAccessConfirmed' must be a boolean");
|
|
724
|
-
}
|
|
725
|
-
const message = stringField(body.message ?? "", "message", { maxLength: 100_000 });
|
|
726
|
-
const skillIds = parseAiSkillIds(body.skillIds) ?? [];
|
|
727
|
-
if (message.split(AI_CHAT_SKILL_MARKER).length - 1 !== skillIds.length) {
|
|
728
|
-
throw new ApiError(400, "INVALID_FIELD", "'skillIds' must match the Skill markers in 'message'");
|
|
729
|
-
}
|
|
730
|
-
const attachments = parseAiAttachments(body.attachments);
|
|
731
|
-
if (message === "" && attachments.length === 0) {
|
|
732
|
-
throw new ApiError(
|
|
733
|
-
400,
|
|
734
|
-
"INVALID_MESSAGE",
|
|
735
|
-
"A message or at least one attachment is required",
|
|
736
|
-
);
|
|
737
|
-
}
|
|
738
|
-
return {
|
|
739
|
-
message,
|
|
740
|
-
skillIds,
|
|
741
|
-
dangerFullAccessConfirmed: body.dangerFullAccessConfirmed,
|
|
742
|
-
attachments,
|
|
743
|
-
};
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
function parseComposerCandidateQuery(searchParams) {
|
|
747
|
-
assertAllowedQuery(
|
|
748
|
-
searchParams,
|
|
749
|
-
new Set([
|
|
750
|
-
"projectId",
|
|
751
|
-
"threadId",
|
|
752
|
-
"trigger",
|
|
753
|
-
"query",
|
|
754
|
-
"surface",
|
|
755
|
-
"codexProjectId",
|
|
756
|
-
"codexProjectKind",
|
|
757
|
-
"codexHostId",
|
|
758
|
-
"workspacePath",
|
|
759
|
-
]),
|
|
760
|
-
"GET /api/local/ai/composer/candidates",
|
|
761
|
-
);
|
|
762
|
-
let projectId;
|
|
763
|
-
const rawProjectId = searchParams.get("projectId");
|
|
764
|
-
if (rawProjectId !== null) {
|
|
765
|
-
try {
|
|
766
|
-
projectId = validateProjectId(rawProjectId);
|
|
767
|
-
} catch {
|
|
768
|
-
throw new ApiError(400, "INVALID_COMPOSER_QUERY", "Composer project id is invalid");
|
|
769
|
-
}
|
|
770
|
-
}
|
|
771
|
-
const trigger = searchParams.get("trigger");
|
|
772
|
-
if (trigger !== "/" && trigger !== "@") {
|
|
773
|
-
throw new ApiError(400, "INVALID_COMPOSER_QUERY", "Composer trigger must be '/' or '@'");
|
|
774
|
-
}
|
|
775
|
-
const query = searchParams.get("query") ?? "";
|
|
776
|
-
if (query.length > 256) {
|
|
777
|
-
throw new ApiError(400, "INVALID_COMPOSER_QUERY", "Composer query cannot exceed 256 characters");
|
|
778
|
-
}
|
|
779
|
-
let threadId;
|
|
780
|
-
try {
|
|
781
|
-
threadId = parseThreadId(searchParams.get("threadId") ?? undefined);
|
|
782
|
-
} catch {
|
|
783
|
-
throw new ApiError(400, "INVALID_COMPOSER_QUERY", "Composer thread id is invalid");
|
|
784
|
-
}
|
|
785
|
-
const surface = searchParams.get("surface") ?? "ai-chat";
|
|
786
|
-
if (!new Set(["ai-chat", "issue-description", "comment"]).has(surface)) {
|
|
787
|
-
throw new ApiError(400, "INVALID_COMPOSER_QUERY", "Composer surface is invalid");
|
|
788
|
-
}
|
|
789
|
-
return {
|
|
790
|
-
projectId,
|
|
791
|
-
threadId,
|
|
792
|
-
trigger,
|
|
793
|
-
query,
|
|
794
|
-
surface,
|
|
795
|
-
...aiExecutionTargetFromQuery(searchParams),
|
|
796
|
-
};
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
function invalidComposerRebindRequest(message) {
|
|
800
|
-
return new ApiError(400, "INVALID_COMPOSER_REBIND_REQUEST", message);
|
|
801
|
-
}
|
|
802
|
-
|
|
803
|
-
function assertComposerRebindKeys(value, allowed, field) {
|
|
804
|
-
for (const key of Object.keys(value)) {
|
|
805
|
-
if (!allowed.has(key)) {
|
|
806
|
-
throw invalidComposerRebindRequest(`'${field}.${key}' is not allowed`);
|
|
807
|
-
}
|
|
808
|
-
}
|
|
809
|
-
}
|
|
810
|
-
|
|
811
|
-
function parseComposerRebindRequest(value) {
|
|
812
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
813
|
-
throw invalidComposerRebindRequest("Composer rebind body must be an object");
|
|
814
|
-
}
|
|
815
|
-
assertComposerRebindKeys(
|
|
816
|
-
value,
|
|
817
|
-
new Set(["contractVersion", "projectId", "threadId", "document"]),
|
|
818
|
-
"body",
|
|
819
|
-
);
|
|
820
|
-
if (value.contractVersion !== "composer.v1") {
|
|
821
|
-
throw invalidComposerRebindRequest("'contractVersion' must be 'composer.v1'");
|
|
822
|
-
}
|
|
823
|
-
let projectId;
|
|
824
|
-
try {
|
|
825
|
-
projectId = validateProjectId(value.projectId);
|
|
826
|
-
} catch {
|
|
827
|
-
throw invalidComposerRebindRequest("'projectId' is invalid");
|
|
828
|
-
}
|
|
829
|
-
let threadId;
|
|
830
|
-
try {
|
|
831
|
-
threadId = parseThreadId(value.threadId);
|
|
832
|
-
} catch {
|
|
833
|
-
throw invalidComposerRebindRequest("'threadId' is invalid");
|
|
834
|
-
}
|
|
835
|
-
const document = value.document;
|
|
836
|
-
if (!document || typeof document !== "object" || Array.isArray(document)) {
|
|
837
|
-
throw invalidComposerRebindRequest("'document' must be an object");
|
|
838
|
-
}
|
|
839
|
-
assertComposerRebindKeys(document, new Set(["version", "nodes"]), "document");
|
|
840
|
-
if (document.version !== 1) {
|
|
841
|
-
throw invalidComposerRebindRequest("'document.version' must be 1");
|
|
842
|
-
}
|
|
843
|
-
if (!Array.isArray(document.nodes) || document.nodes.length > 200) {
|
|
844
|
-
throw invalidComposerRebindRequest("'document.nodes' must contain at most 200 entries");
|
|
845
|
-
}
|
|
846
|
-
let textLength = 0;
|
|
847
|
-
const nodes = document.nodes.map((node, nodeIndex) => {
|
|
848
|
-
if (!node || typeof node !== "object" || Array.isArray(node)) {
|
|
849
|
-
throw invalidComposerRebindRequest(`'document.nodes[${nodeIndex}]' must be an object`);
|
|
850
|
-
}
|
|
851
|
-
if (node.type === "text") {
|
|
852
|
-
assertComposerRebindKeys(node, new Set(["type", "text"]), `document.nodes[${nodeIndex}]`);
|
|
853
|
-
if (typeof node.text !== "string") {
|
|
854
|
-
throw invalidComposerRebindRequest(`'document.nodes[${nodeIndex}].text' must be a string`);
|
|
855
|
-
}
|
|
856
|
-
textLength += node.text.length;
|
|
857
|
-
return { type: "text", text: node.text };
|
|
858
|
-
}
|
|
859
|
-
if (node.type === "unsupportedReference") {
|
|
860
|
-
assertComposerRebindKeys(
|
|
861
|
-
node,
|
|
862
|
-
new Set(["type", "referenceUri", "label"]),
|
|
863
|
-
`document.nodes[${nodeIndex}]`,
|
|
864
|
-
);
|
|
865
|
-
if (typeof node.label !== "string" || node.label.length === 0 || node.label.length > 256) {
|
|
866
|
-
throw invalidComposerRebindRequest(`'document.nodes[${nodeIndex}].label' is invalid`);
|
|
867
|
-
}
|
|
868
|
-
if (typeof node.referenceUri !== "string" || node.referenceUri.length > 1_024) {
|
|
869
|
-
throw invalidComposerRebindRequest(
|
|
870
|
-
`'document.nodes[${nodeIndex}].referenceUri' is invalid`,
|
|
871
|
-
);
|
|
872
|
-
}
|
|
873
|
-
const match = /^taskboard:\/\/composer-reference\/([^/]+)\/([^/]+)\/([^/]+)$/.exec(
|
|
874
|
-
node.referenceUri,
|
|
875
|
-
);
|
|
876
|
-
if (!match) {
|
|
877
|
-
throw invalidComposerRebindRequest(
|
|
878
|
-
`'document.nodes[${nodeIndex}].referenceUri' is not a composer reference marker`,
|
|
879
|
-
);
|
|
880
|
-
}
|
|
881
|
-
try {
|
|
882
|
-
decodeComposerReferenceKey(match[3]);
|
|
883
|
-
} catch {
|
|
884
|
-
throw invalidComposerRebindRequest(
|
|
885
|
-
`'document.nodes[${nodeIndex}].referenceUri' has an invalid reference key`,
|
|
886
|
-
);
|
|
887
|
-
}
|
|
888
|
-
const reasonCode = match[1] !== "v1"
|
|
889
|
-
? "REFERENCE_FORMAT_UNSUPPORTED"
|
|
890
|
-
: !new Set(["skill", "agent"]).has(match[2])
|
|
891
|
-
? "REFERENCE_KIND_UNSUPPORTED"
|
|
892
|
-
: null;
|
|
893
|
-
if (!reasonCode) {
|
|
894
|
-
throw invalidComposerRebindRequest(
|
|
895
|
-
`'document.nodes[${nodeIndex}]' must use persistedReference for supported markers`,
|
|
896
|
-
);
|
|
897
|
-
}
|
|
898
|
-
return {
|
|
899
|
-
type: "unsupportedReference",
|
|
900
|
-
referenceUri: node.referenceUri,
|
|
901
|
-
label: node.label,
|
|
902
|
-
reasonCode,
|
|
903
|
-
};
|
|
904
|
-
}
|
|
905
|
-
if (node.type !== "persistedReference") {
|
|
906
|
-
throw invalidComposerRebindRequest(
|
|
907
|
-
`'document.nodes[${nodeIndex}].type' must be text, persistedReference or unsupportedReference`,
|
|
908
|
-
);
|
|
909
|
-
}
|
|
910
|
-
assertComposerRebindKeys(
|
|
911
|
-
node,
|
|
912
|
-
new Set(["type", "referenceKind", "referenceKey", "label"]),
|
|
913
|
-
`document.nodes[${nodeIndex}]`,
|
|
914
|
-
);
|
|
915
|
-
if (node.referenceKind !== "skill" && node.referenceKind !== "agent") {
|
|
916
|
-
throw invalidComposerRebindRequest(
|
|
917
|
-
`'document.nodes[${nodeIndex}].referenceKind' must be skill or agent`,
|
|
918
|
-
);
|
|
919
|
-
}
|
|
920
|
-
if (
|
|
921
|
-
typeof node.referenceKey !== "string"
|
|
922
|
-
|| node.referenceKey.length === 0
|
|
923
|
-
|| node.referenceKey.length > 512
|
|
924
|
-
) {
|
|
925
|
-
throw invalidComposerRebindRequest(
|
|
926
|
-
`'document.nodes[${nodeIndex}].referenceKey' is invalid`,
|
|
927
|
-
);
|
|
928
|
-
}
|
|
929
|
-
if (typeof node.label !== "string" || node.label.length === 0 || node.label.length > 256) {
|
|
930
|
-
throw invalidComposerRebindRequest(`'document.nodes[${nodeIndex}].label' is invalid`);
|
|
931
|
-
}
|
|
932
|
-
let stableId;
|
|
933
|
-
try {
|
|
934
|
-
stableId = decodeComposerReferenceKey(node.referenceKey);
|
|
935
|
-
} catch {
|
|
936
|
-
throw invalidComposerRebindRequest(
|
|
937
|
-
`'document.nodes[${nodeIndex}].referenceKey' is not canonical base64url`,
|
|
938
|
-
);
|
|
939
|
-
}
|
|
940
|
-
if (node.referenceKind === "skill" && stableId !== stableId.normalize("NFC")) {
|
|
941
|
-
throw invalidComposerRebindRequest(
|
|
942
|
-
`'document.nodes[${nodeIndex}].referenceKey' does not contain an NFC Skill name`,
|
|
943
|
-
);
|
|
944
|
-
}
|
|
945
|
-
return {
|
|
946
|
-
type: "persistedReference",
|
|
947
|
-
referenceKind: node.referenceKind,
|
|
948
|
-
referenceKey: node.referenceKey,
|
|
949
|
-
label: node.label,
|
|
950
|
-
stableId,
|
|
951
|
-
};
|
|
952
|
-
});
|
|
953
|
-
if (textLength > 100_000) {
|
|
954
|
-
throw invalidComposerRebindRequest("Composer text cannot exceed 100000 characters");
|
|
955
|
-
}
|
|
956
|
-
return {
|
|
957
|
-
contractVersion: "composer.v1",
|
|
958
|
-
projectId,
|
|
959
|
-
threadId,
|
|
960
|
-
document: { version: 1, nodes },
|
|
961
|
-
};
|
|
962
|
-
}
|
|
963
|
-
|
|
964
|
-
async function resolveComposerRebindWorkspace(aiChat, input) {
|
|
965
|
-
let thread;
|
|
966
|
-
if (input.threadId !== undefined) {
|
|
967
|
-
try {
|
|
968
|
-
thread = aiChat.getThread(input.threadId);
|
|
969
|
-
} catch (error) {
|
|
970
|
-
if (error instanceof ApiError && error.code === "AI_CHAT_THREAD_NOT_FOUND") {
|
|
971
|
-
throw new ApiError(400, "INVALID_COMPOSER_QUERY", "Composer thread does not exist");
|
|
972
|
-
}
|
|
973
|
-
throw error;
|
|
974
|
-
}
|
|
975
|
-
if (thread.origin.projectId !== input.projectId) {
|
|
976
|
-
throw new ApiError(
|
|
977
|
-
400,
|
|
978
|
-
"INVALID_COMPOSER_QUERY",
|
|
979
|
-
"Composer thread does not belong to the selected project",
|
|
980
|
-
);
|
|
981
|
-
}
|
|
982
|
-
if (thread.origin.codexProjectKind !== "remote") {
|
|
983
|
-
try {
|
|
984
|
-
if (!(await stat(thread.origin.workspacePath)).isDirectory()) throw new Error("not a directory");
|
|
985
|
-
} catch {
|
|
986
|
-
throw new ApiError(
|
|
987
|
-
409,
|
|
988
|
-
"PROJECT_WORKSPACE_UNAVAILABLE",
|
|
989
|
-
"The conversation workspace is not available on this device",
|
|
990
|
-
);
|
|
991
|
-
}
|
|
992
|
-
}
|
|
993
|
-
return {
|
|
994
|
-
workspacePath: thread.origin.workspacePath,
|
|
995
|
-
composerCatalog: aiChat.composerCatalogForThread(thread),
|
|
996
|
-
};
|
|
997
|
-
}
|
|
998
|
-
let resolved;
|
|
999
|
-
try {
|
|
1000
|
-
resolved = await aiChat.resolveContext(input.projectId, thread?.origin.issueId);
|
|
1001
|
-
} catch (error) {
|
|
1002
|
-
if (
|
|
1003
|
-
error instanceof ApiError
|
|
1004
|
-
&& ["PROJECT_NOT_FOUND", "AI_CHAT_ISSUE_NOT_FOUND"].includes(error.code)
|
|
1005
|
-
) {
|
|
1006
|
-
throw new ApiError(400, "INVALID_COMPOSER_QUERY", "Composer project is invalid");
|
|
1007
|
-
}
|
|
1008
|
-
throw error;
|
|
1009
|
-
}
|
|
1010
|
-
return { workspacePath: resolved.workspacePath, composerCatalog: aiChat.composerCatalog };
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
function parseComposerDocument(value) {
|
|
1014
|
-
assertPlainObject(value);
|
|
1015
|
-
assertAllowedKeys(value, new Set(["version", "nodes"]));
|
|
1016
|
-
if (value.version !== 1) {
|
|
1017
|
-
throw new ApiError(400, "INVALID_COMPOSER_DOCUMENT", "'document.version' must be 1");
|
|
1018
|
-
}
|
|
1019
|
-
if (!Array.isArray(value.nodes) || value.nodes.length > 200) {
|
|
1020
|
-
throw new ApiError(
|
|
1021
|
-
400,
|
|
1022
|
-
"INVALID_COMPOSER_DOCUMENT",
|
|
1023
|
-
"'document.nodes' must be an array with at most 200 entries",
|
|
1024
|
-
);
|
|
1025
|
-
}
|
|
1026
|
-
let textLength = 0;
|
|
1027
|
-
const nodes = value.nodes.map((node, index) => {
|
|
1028
|
-
assertPlainObject(node);
|
|
1029
|
-
if (typeof node.type !== "string" || !node.type) {
|
|
1030
|
-
throw new ApiError(
|
|
1031
|
-
400,
|
|
1032
|
-
"INVALID_COMPOSER_DOCUMENT",
|
|
1033
|
-
`'document.nodes[${index}].type' is required`,
|
|
1034
|
-
);
|
|
1035
|
-
}
|
|
1036
|
-
if (node.type === "text") {
|
|
1037
|
-
assertAllowedKeys(node, new Set(["type", "text"]));
|
|
1038
|
-
if (typeof node.text !== "string") {
|
|
1039
|
-
throw new ApiError(
|
|
1040
|
-
400,
|
|
1041
|
-
"INVALID_COMPOSER_DOCUMENT",
|
|
1042
|
-
`'document.nodes[${index}].text' must be a string`,
|
|
1043
|
-
);
|
|
1044
|
-
}
|
|
1045
|
-
textLength += node.text.length;
|
|
1046
|
-
return { type: "text", text: node.text };
|
|
1047
|
-
}
|
|
1048
|
-
if (node.type === "skill" || node.type === "agent") {
|
|
1049
|
-
assertAllowedKeys(node, new Set(["type", "candidateRef", "label"]));
|
|
1050
|
-
return {
|
|
1051
|
-
type: node.type,
|
|
1052
|
-
candidateRef: stringField(
|
|
1053
|
-
node.candidateRef,
|
|
1054
|
-
`document.nodes[${index}].candidateRef`,
|
|
1055
|
-
{ required: true, maxLength: 512 },
|
|
1056
|
-
),
|
|
1057
|
-
label: stringField(node.label, `document.nodes[${index}].label`, {
|
|
1058
|
-
required: true,
|
|
1059
|
-
maxLength: 256,
|
|
1060
|
-
}),
|
|
1061
|
-
};
|
|
1062
|
-
}
|
|
1063
|
-
return { type: node.type };
|
|
1064
|
-
});
|
|
1065
|
-
if (textLength > 100_000) {
|
|
1066
|
-
throw new ApiError(
|
|
1067
|
-
400,
|
|
1068
|
-
"INVALID_COMPOSER_DOCUMENT",
|
|
1069
|
-
"Composer text cannot exceed 100000 characters",
|
|
1070
|
-
);
|
|
1071
|
-
}
|
|
1072
|
-
return { version: 1, nodes };
|
|
1073
|
-
}
|
|
1074
|
-
|
|
1075
|
-
function parseComposerTurn(body) {
|
|
1076
|
-
assertAllowedKeys(body, new Set([
|
|
1077
|
-
"contractVersion",
|
|
1078
|
-
"revision",
|
|
1079
|
-
"document",
|
|
1080
|
-
"dangerFullAccessConfirmed",
|
|
1081
|
-
"attachments",
|
|
1082
|
-
]));
|
|
1083
|
-
if (body.contractVersion !== "composer.v1") {
|
|
1084
|
-
throw new ApiError(
|
|
1085
|
-
400,
|
|
1086
|
-
"INVALID_COMPOSER_DOCUMENT",
|
|
1087
|
-
"'contractVersion' must be 'composer.v1'",
|
|
1088
|
-
);
|
|
1089
|
-
}
|
|
1090
|
-
if (
|
|
1091
|
-
body.dangerFullAccessConfirmed !== undefined
|
|
1092
|
-
&& typeof body.dangerFullAccessConfirmed !== "boolean"
|
|
1093
|
-
) {
|
|
1094
|
-
throw new ApiError(400, "INVALID_FIELD", "'dangerFullAccessConfirmed' must be a boolean");
|
|
1095
|
-
}
|
|
1096
|
-
return {
|
|
1097
|
-
contractVersion: "composer.v1",
|
|
1098
|
-
revision: stringField(body.revision, "revision", { required: true, maxLength: 512 }),
|
|
1099
|
-
document: parseComposerDocument(body.document),
|
|
1100
|
-
dangerFullAccessConfirmed: body.dangerFullAccessConfirmed,
|
|
1101
|
-
attachments: parseAiAttachments(body.attachments),
|
|
1102
|
-
};
|
|
1103
|
-
}
|
|
1104
|
-
|
|
1105
|
-
class EventHub {
|
|
1106
|
-
constructor() {
|
|
1107
|
-
this.clients = new Set();
|
|
1108
|
-
this.keepAlive = setInterval(() => {
|
|
1109
|
-
for (const response of this.clients) response.write(": keep-alive\n\n");
|
|
1110
|
-
}, 20_000);
|
|
1111
|
-
this.keepAlive.unref();
|
|
1112
|
-
}
|
|
1113
|
-
|
|
1114
|
-
connect(request, response) {
|
|
1115
|
-
response.writeHead(200, {
|
|
1116
|
-
connection: "keep-alive",
|
|
1117
|
-
"cache-control": "no-cache, no-transform",
|
|
1118
|
-
"content-type": "text/event-stream; charset=utf-8",
|
|
1119
|
-
"x-accel-buffering": "no",
|
|
1120
|
-
});
|
|
1121
|
-
response.write(": connected\n\n");
|
|
1122
|
-
this.clients.add(response);
|
|
1123
|
-
request.once("close", () => this.clients.delete(response));
|
|
1124
|
-
}
|
|
1125
|
-
|
|
1126
|
-
emit(type, value) {
|
|
1127
|
-
const event = {
|
|
1128
|
-
type,
|
|
1129
|
-
projectId: value.projectId ?? value.project?.id ?? value.task?.projectId,
|
|
1130
|
-
taskId: value.task?.id ?? value.comment?.taskId ?? value.attachment?.taskId,
|
|
1131
|
-
...value,
|
|
1132
|
-
at: new Date().toISOString(),
|
|
1133
|
-
};
|
|
1134
|
-
const message = `event: ${type}\ndata: ${JSON.stringify(event)}\n\n`;
|
|
1135
|
-
for (const response of this.clients) response.write(message);
|
|
1136
|
-
}
|
|
1137
|
-
|
|
1138
|
-
close() {
|
|
1139
|
-
clearInterval(this.keepAlive);
|
|
1140
|
-
for (const response of this.clients) response.end();
|
|
1141
|
-
this.clients.clear();
|
|
1142
|
-
}
|
|
1143
|
-
}
|
|
1144
|
-
|
|
1145
|
-
async function serveStatic(request, response, pathname, staticDirectory) {
|
|
1146
|
-
if (request.method !== "GET" && request.method !== "HEAD") return false;
|
|
1147
|
-
let decodedPath;
|
|
1148
|
-
try {
|
|
1149
|
-
decodedPath = decodeURIComponent(pathname);
|
|
1150
|
-
} catch {
|
|
1151
|
-
throw new ApiError(400, "INVALID_PATH", "URL path contains invalid encoding");
|
|
1152
|
-
}
|
|
1153
|
-
if (decodedPath.includes("\0")) {
|
|
1154
|
-
throw new ApiError(400, "INVALID_PATH", "URL path is invalid");
|
|
1155
|
-
}
|
|
1156
|
-
|
|
1157
|
-
const root = path.resolve(staticDirectory);
|
|
1158
|
-
const relativePath = decodedPath === "/" ? "index.html" : decodedPath.replace(/^\/+/, "");
|
|
1159
|
-
let filename = path.resolve(root, relativePath);
|
|
1160
|
-
if (filename !== root && !filename.startsWith(`${root}${path.sep}`)) {
|
|
1161
|
-
throw new ApiError(400, "INVALID_PATH", "URL path is outside the static directory");
|
|
1162
|
-
}
|
|
1163
|
-
|
|
1164
|
-
let fileStats;
|
|
1165
|
-
try {
|
|
1166
|
-
fileStats = await stat(filename);
|
|
1167
|
-
} catch (error) {
|
|
1168
|
-
if (error.code !== "ENOENT") throw error;
|
|
1169
|
-
}
|
|
1170
|
-
if (!fileStats?.isFile() && !path.extname(relativePath)) {
|
|
1171
|
-
filename = path.join(root, "index.html");
|
|
1172
|
-
try {
|
|
1173
|
-
fileStats = await stat(filename);
|
|
1174
|
-
} catch (error) {
|
|
1175
|
-
if (error.code !== "ENOENT") throw error;
|
|
1176
|
-
}
|
|
1177
|
-
}
|
|
1178
|
-
if (!fileStats?.isFile()) return false;
|
|
1179
|
-
|
|
1180
|
-
const body = await readFile(filename);
|
|
1181
|
-
const headers = {
|
|
1182
|
-
"cache-control": path.basename(filename) === "index.html" ? "no-cache" : "public, max-age=31536000, immutable",
|
|
1183
|
-
"content-length": body.length,
|
|
1184
|
-
"content-type": CONTENT_TYPES.get(path.extname(filename).toLowerCase()) ?? "application/octet-stream",
|
|
1185
|
-
};
|
|
1186
|
-
response.writeHead(200, headers);
|
|
1187
|
-
response.end(request.method === "HEAD" ? undefined : body);
|
|
1188
|
-
return true;
|
|
1189
|
-
}
|
|
1190
|
-
|
|
1191
|
-
function methodNotAllowed(response, allowed) {
|
|
1192
|
-
sendJson(response, 405, {
|
|
1193
|
-
error: { code: "METHOD_NOT_ALLOWED", message: `Allowed methods: ${allowed.join(", ")}` },
|
|
1194
|
-
}, { allow: allowed.join(", ") });
|
|
1195
|
-
}
|
|
1196
|
-
|
|
1197
|
-
function codexProjectRoot(state, projectId) {
|
|
1198
|
-
if (!projectId || !state || typeof state !== "object") return null;
|
|
1199
|
-
const project = state["local-projects"]?.[projectId];
|
|
1200
|
-
const root = Array.isArray(project?.rootPaths) ? project.rootPaths[0] : null;
|
|
1201
|
-
return typeof root === "string" && root.trim() ? root : null;
|
|
1202
|
-
}
|
|
1203
|
-
|
|
1204
|
-
async function readCodexProjectWorkspaces(codexStatePath) {
|
|
1205
|
-
try {
|
|
1206
|
-
const state = JSON.parse(await readFile(codexStatePath, "utf8"));
|
|
1207
|
-
const projects = state["local-projects"];
|
|
1208
|
-
if (!projects || typeof projects !== "object" || Array.isArray(projects)) return {};
|
|
1209
|
-
return Object.fromEntries(Object.keys(projects).flatMap((projectId) => {
|
|
1210
|
-
const root = codexProjectRoot(state, projectId);
|
|
1211
|
-
return root ? [[projectId, root]] : [];
|
|
1212
|
-
}));
|
|
1213
|
-
} catch {
|
|
1214
|
-
return {};
|
|
1215
|
-
}
|
|
1216
|
-
}
|
|
1217
|
-
|
|
1218
|
-
function latestThreadCwd(value, threadId) {
|
|
1219
|
-
const matches = [];
|
|
1220
|
-
const stack = [value];
|
|
1221
|
-
while (stack.length > 0) {
|
|
1222
|
-
const candidate = stack.pop();
|
|
1223
|
-
if (!candidate || typeof candidate !== "object") continue;
|
|
1224
|
-
if (candidate.conversationId === threadId && typeof candidate.cwd === "string" && candidate.cwd.trim()) {
|
|
1225
|
-
matches.push(candidate);
|
|
1226
|
-
}
|
|
1227
|
-
stack.push(...(Array.isArray(candidate) ? candidate : Object.values(candidate)));
|
|
1228
|
-
}
|
|
1229
|
-
matches.sort((left, right) => Number(right.updatedAtMs ?? 0) - Number(left.updatedAtMs ?? 0));
|
|
1230
|
-
return matches[0]?.cwd ?? null;
|
|
1231
|
-
}
|
|
1232
|
-
|
|
1233
|
-
async function resolveProjectWorkspace(project, codexProjectId, codexThreadId, codexStatePath, codexProcessesPath) {
|
|
1234
|
-
try {
|
|
1235
|
-
const state = JSON.parse(await readFile(codexStatePath, "utf8"));
|
|
1236
|
-
const assignment = state["thread-project-assignments"]?.[codexThreadId];
|
|
1237
|
-
const root = codexProjectRoot(state, project.id)
|
|
1238
|
-
?? codexProjectRoot(state, codexProjectId)
|
|
1239
|
-
?? codexProjectRoot(state, assignment?.projectId)
|
|
1240
|
-
?? (typeof assignment?.cwd === "string" ? assignment.cwd : null);
|
|
1241
|
-
if (root) return root;
|
|
1242
|
-
} catch {}
|
|
1243
|
-
if (project.workspacePath) return project.workspacePath;
|
|
1244
|
-
if (!codexThreadId) return null;
|
|
1245
|
-
try {
|
|
1246
|
-
const processes = JSON.parse(await readFile(codexProcessesPath, "utf8"));
|
|
1247
|
-
return latestThreadCwd(processes, codexThreadId);
|
|
1248
|
-
} catch {
|
|
1249
|
-
return null;
|
|
1250
|
-
}
|
|
1251
|
-
}
|
|
1252
|
-
|
|
1253
|
-
async function parseWorktrees(output) {
|
|
1254
|
-
const contexts = [];
|
|
1255
|
-
for (const block of output.trim().split(/\n\s*\n/)) {
|
|
1256
|
-
if (!block) continue;
|
|
1257
|
-
let worktreePath = "";
|
|
1258
|
-
let branch = null;
|
|
1259
|
-
let prunable = false;
|
|
1260
|
-
for (const line of block.split("\n")) {
|
|
1261
|
-
if (line.startsWith("worktree ")) worktreePath = line.slice(9);
|
|
1262
|
-
if (line.startsWith("branch refs/heads/")) branch = line.slice(18);
|
|
1263
|
-
if (line.startsWith("prunable")) prunable = true;
|
|
1264
|
-
}
|
|
1265
|
-
if (!worktreePath || prunable) continue;
|
|
1266
|
-
try {
|
|
1267
|
-
await stat(worktreePath);
|
|
1268
|
-
} catch (error) {
|
|
1269
|
-
if (error?.code === "ENOENT") continue;
|
|
1270
|
-
throw error;
|
|
1271
|
-
}
|
|
1272
|
-
contexts.push({ type: "worktree", path: worktreePath, branch });
|
|
1273
|
-
}
|
|
1274
|
-
return contexts;
|
|
1275
|
-
}
|
|
1276
|
-
|
|
1277
|
-
async function scanDevelopmentContexts(workspacePath, processEnv = process.env) {
|
|
1278
|
-
if (!workspacePath) return { workspacePath: null, contexts: [] };
|
|
1279
|
-
const environment = withoutTaskboardLauncherEnvironment(processEnv);
|
|
1280
|
-
try {
|
|
1281
|
-
const rootResult = await execFileAsync("git", ["-C", workspacePath, "rev-parse", "--show-toplevel"], {
|
|
1282
|
-
env: environment,
|
|
1283
|
-
timeout: 4_000,
|
|
1284
|
-
maxBuffer: 1024 * 1024,
|
|
1285
|
-
});
|
|
1286
|
-
const root = rootResult.stdout.trim();
|
|
1287
|
-
const [branchesResult, worktreesResult] = await Promise.all([
|
|
1288
|
-
execFileAsync("git", ["-C", root, "for-each-ref", "--format=%(refname:short)", "refs/heads"], {
|
|
1289
|
-
env: environment,
|
|
1290
|
-
timeout: 4_000,
|
|
1291
|
-
maxBuffer: 1024 * 1024,
|
|
1292
|
-
}),
|
|
1293
|
-
execFileAsync("git", ["-C", root, "worktree", "list", "--porcelain"], {
|
|
1294
|
-
env: environment,
|
|
1295
|
-
timeout: 4_000,
|
|
1296
|
-
maxBuffer: 1024 * 1024,
|
|
1297
|
-
}),
|
|
1298
|
-
]);
|
|
1299
|
-
const branches = branchesResult.stdout.split("\n").map((branch) => branch.trim()).filter(Boolean);
|
|
1300
|
-
return {
|
|
1301
|
-
workspacePath: root,
|
|
1302
|
-
contexts: [
|
|
1303
|
-
...branches.map((branch) => ({ type: "branch", branch })),
|
|
1304
|
-
...(await parseWorktrees(worktreesResult.stdout)),
|
|
1305
|
-
],
|
|
1306
|
-
};
|
|
1307
|
-
} catch {
|
|
1308
|
-
return { workspacePath, contexts: [] };
|
|
1309
|
-
}
|
|
1310
|
-
}
|
|
1311
|
-
|
|
1312
|
-
export function resolveServerOptions(options = {}) {
|
|
1313
|
-
const environment = options.processEnv ?? process.env;
|
|
1314
|
-
const configuredDataDirectory = options.dataDirectory ?? environment.CODEX_TASKBOARD_DATA_DIR;
|
|
1315
|
-
const dataDirectory = configuredDataDirectory
|
|
1316
|
-
? path.resolve(configuredDataDirectory)
|
|
1317
|
-
: path.join(PROJECT_ROOT, ".data");
|
|
1318
|
-
const codexHome = process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
|
|
1319
|
-
const instanceToken = String(
|
|
1320
|
-
options.instanceToken ?? environment.CODEX_TASKBOARD_INSTANCE_TOKEN ?? "",
|
|
1321
|
-
).trim();
|
|
1322
|
-
if (instanceToken && !/^[a-z0-9-]{16,128}$/i.test(instanceToken)) {
|
|
1323
|
-
throw new Error("CODEX_TASKBOARD_INSTANCE_TOKEN must be an identifier");
|
|
1324
|
-
}
|
|
1325
|
-
const instanceSecret = String(
|
|
1326
|
-
options.instanceSecret ?? environment.CODEX_TASKBOARD_INSTANCE_SECRET ?? "",
|
|
1327
|
-
).trim();
|
|
1328
|
-
if (instanceToken && !/^[a-f0-9-]{32,128}$/i.test(instanceSecret)) {
|
|
1329
|
-
throw new Error("CODEX_TASKBOARD_INSTANCE_SECRET must be set in launcher mode");
|
|
1330
|
-
}
|
|
1331
|
-
return {
|
|
1332
|
-
dataDirectory,
|
|
1333
|
-
databasePath: options.databasePath ?? path.join(dataDirectory, "taskboard.sqlite"),
|
|
1334
|
-
attachmentsDirectory: options.attachmentsDirectory ?? path.join(dataDirectory, "attachments"),
|
|
1335
|
-
cloudConfigPath: options.cloudConfigPath ?? path.join(dataDirectory, "cloud-companion.json"),
|
|
1336
|
-
jiraConfigPath: options.jiraConfigPath ?? path.join(dataDirectory, "jira-connection.json"),
|
|
1337
|
-
clientStoragePath: options.clientStoragePath ?? path.join(dataDirectory, "client-storage.json"),
|
|
1338
|
-
staticDirectory: options.staticDirectory ?? path.join(PROJECT_ROOT, "dist", "web"),
|
|
1339
|
-
skillPath: options.skillPath
|
|
1340
|
-
?? environment.CODEX_TASKBOARD_SKILL_PATH
|
|
1341
|
-
?? path.join(PROJECT_ROOT, "skills", "manage-taskboard", "SKILL.md"),
|
|
1342
|
-
codexExecutable: resolveCodexExecutable({ explicit: options.codexExecutable }),
|
|
1343
|
-
codexStatePath: options.codexStatePath
|
|
1344
|
-
?? path.join(codexHome, ".codex-global-state.json"),
|
|
1345
|
-
codexProcessesPath: options.codexProcessesPath
|
|
1346
|
-
?? path.join(codexHome, "process_manager", "chat_processes.json"),
|
|
1347
|
-
instanceToken,
|
|
1348
|
-
instanceSecret,
|
|
1349
|
-
trustedOrigins: parseTrustedOrigins(environment[TRUSTED_ORIGINS_ENV]),
|
|
1350
|
-
version: String(
|
|
1351
|
-
options.version ?? environment.CODEX_TASKBOARD_VERSION ?? "development",
|
|
1352
|
-
).trim(),
|
|
1353
|
-
};
|
|
1354
|
-
}
|
|
1355
|
-
|
|
1356
|
-
export function resolvePort(value = process.env.CODEX_TASKBOARD_PORT ?? "47823") {
|
|
1357
|
-
const port = typeof value === "number" ? value : Number(value);
|
|
1358
|
-
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
1359
|
-
throw new Error("CODEX_TASKBOARD_PORT must be an integer between 1 and 65535");
|
|
1360
|
-
}
|
|
1361
|
-
return port;
|
|
1362
|
-
}
|
|
1363
|
-
|
|
1364
|
-
export function resolveHost(value = process.env.CODEX_TASKBOARD_HOST ?? "0.0.0.0") {
|
|
1365
|
-
const host = String(value).trim();
|
|
1366
|
-
if (host !== "127.0.0.1" && host !== "0.0.0.0") {
|
|
1367
|
-
throw new Error("CODEX_TASKBOARD_HOST must be 127.0.0.1 or 0.0.0.0");
|
|
1368
|
-
}
|
|
1369
|
-
return host;
|
|
1370
|
-
}
|
|
1371
|
-
|
|
1372
|
-
export function createTaskboardServer(options = {}) {
|
|
1373
|
-
const resolved = resolveServerOptions(options);
|
|
1374
|
-
const codexProcessEnvironment = withoutTaskboardLauncherEnvironment(
|
|
1375
|
-
options.processEnv ?? process.env,
|
|
1376
|
-
);
|
|
1377
|
-
const routePrefix = resolved.instanceToken ? `/${resolved.instanceToken}` : "";
|
|
1378
|
-
const database = new TaskboardDatabase(resolved.databasePath);
|
|
1379
|
-
const events = new EventHub();
|
|
1380
|
-
let clientStorageWrite = Promise.resolve();
|
|
1381
|
-
|
|
1382
|
-
async function readClientStorage() {
|
|
1383
|
-
try {
|
|
1384
|
-
const value = JSON.parse(await readFile(resolved.clientStoragePath, "utf8"));
|
|
1385
|
-
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
1386
|
-
} catch (error) {
|
|
1387
|
-
if (error.code === "ENOENT") return {};
|
|
1388
|
-
throw error;
|
|
1389
|
-
}
|
|
1390
|
-
}
|
|
1391
|
-
|
|
1392
|
-
function parseClientStorageUpdate(body) {
|
|
1393
|
-
assertPlainObject(body);
|
|
1394
|
-
assertAllowedKeys(body, new Set(["key", "value"]));
|
|
1395
|
-
const key = stringField(body.key, "key", { required: true, maxLength: 512 });
|
|
1396
|
-
const value = stringField(body.value, "value", { nullable: true, maxLength: 100_000 });
|
|
1397
|
-
return { key, value };
|
|
1398
|
-
}
|
|
1399
|
-
|
|
1400
|
-
async function updateClientStorage({ key, value }) {
|
|
1401
|
-
clientStorageWrite = clientStorageWrite.catch(() => {}).then(async () => {
|
|
1402
|
-
const entries = await readClientStorage();
|
|
1403
|
-
if (value === null) delete entries[key];
|
|
1404
|
-
else entries[key] = value;
|
|
1405
|
-
await mkdir(path.dirname(resolved.clientStoragePath), { recursive: true });
|
|
1406
|
-
const temporaryPath = `${resolved.clientStoragePath}.${process.pid}.tmp`;
|
|
1407
|
-
await writeFile(temporaryPath, `${JSON.stringify(entries)}\n`, { mode: 0o600 });
|
|
1408
|
-
await chmod(temporaryPath, 0o600);
|
|
1409
|
-
await rename(temporaryPath, resolved.clientStoragePath);
|
|
1410
|
-
await chmod(resolved.clientStoragePath, 0o600);
|
|
1411
|
-
});
|
|
1412
|
-
await clientStorageWrite;
|
|
1413
|
-
}
|
|
1414
|
-
const cloudConfig = options.cloudConfigStore ?? createCloudConfigStore({
|
|
1415
|
-
configPath: resolved.cloudConfigPath,
|
|
1416
|
-
});
|
|
1417
|
-
const jiraConfig = options.jiraConfigStore ?? createJiraConfigStore({
|
|
1418
|
-
configPath: resolved.jiraConfigPath,
|
|
1419
|
-
});
|
|
1420
|
-
const jira = createJiraIntegration({
|
|
1421
|
-
configStore: jiraConfig,
|
|
1422
|
-
database,
|
|
1423
|
-
fetch: options.jiraFetch ?? globalThis.fetch,
|
|
1424
|
-
});
|
|
1425
|
-
let hostRuntime = null;
|
|
1426
|
-
function currentHostThreadBinding(threadId) {
|
|
1427
|
-
if (
|
|
1428
|
-
!hostRuntime
|
|
1429
|
-
|| hostRuntime.threadId !== threadId
|
|
1430
|
-
|| !hostRuntime.codexProjectId
|
|
1431
|
-
|| !hostRuntime.codexProjectKind
|
|
1432
|
-
|| !hostRuntime.codexHostId
|
|
1433
|
-
|| !hostRuntime.workspacePath
|
|
1434
|
-
) return undefined;
|
|
1435
|
-
return {
|
|
1436
|
-
threadId,
|
|
1437
|
-
codexProjectId: hostRuntime.codexProjectId,
|
|
1438
|
-
codexProjectKind: hostRuntime.codexProjectKind,
|
|
1439
|
-
codexHostId: hostRuntime.codexHostId,
|
|
1440
|
-
workspacePath: hostRuntime.workspacePath,
|
|
1441
|
-
};
|
|
1442
|
-
}
|
|
1443
|
-
function resolveInputThreadBinding(input) {
|
|
1444
|
-
if (input.threadBinding !== undefined) return input;
|
|
1445
|
-
const threadBinding = currentHostThreadBinding(input.threadId);
|
|
1446
|
-
return threadBinding ? { ...input, threadBinding } : input;
|
|
1447
|
-
}
|
|
1448
|
-
const cloudProxy = createCloudProxy({
|
|
1449
|
-
configStore: cloudConfig,
|
|
1450
|
-
fetch: options.remoteFetch ?? globalThis.fetch,
|
|
1451
|
-
resolveThreadBinding: currentHostThreadBinding,
|
|
1452
|
-
resolveDevelopmentContext: async (projectId, context, scans) => {
|
|
1453
|
-
if (!context.branch) return null;
|
|
1454
|
-
const config = await cloudConfig.read();
|
|
1455
|
-
const workspacePath = config.projectMappings[projectId];
|
|
1456
|
-
if (!workspacePath) return null;
|
|
1457
|
-
const key = `${projectId}\0${workspacePath}`;
|
|
1458
|
-
if (!scans.has(key)) {
|
|
1459
|
-
scans.set(key, scanDevelopmentContexts(workspacePath, codexProcessEnvironment));
|
|
1460
|
-
}
|
|
1461
|
-
const result = await scans.get(key);
|
|
1462
|
-
return result.contexts.find((candidate) => (
|
|
1463
|
-
candidate.type === "worktree" && candidate.branch === context.branch
|
|
1464
|
-
)) ?? null;
|
|
1465
|
-
},
|
|
1466
|
-
assertTaskProjectMoveAllowed: (taskId, targetProjectId) => {
|
|
1467
|
-
if (!database.hasAiChatThreadProjectConflict(taskId, targetProjectId)) return;
|
|
1468
|
-
throw new CloudProxyError(
|
|
1469
|
-
409,
|
|
1470
|
-
"AI_CHAT_PROJECT_MOVE_BLOCKED",
|
|
1471
|
-
"Delete issue-linked AI conversations before moving the issue to another project",
|
|
1472
|
-
);
|
|
1473
|
-
},
|
|
1474
|
-
});
|
|
1475
|
-
async function readCloudJson(pathname) {
|
|
1476
|
-
const upstream = await cloudProxy.forward(new Request(`http://127.0.0.1${pathname}`, {
|
|
1477
|
-
headers: { accept: "application/json" },
|
|
1478
|
-
}));
|
|
1479
|
-
let payload;
|
|
1480
|
-
try {
|
|
1481
|
-
payload = await upstream.json();
|
|
1482
|
-
} catch {
|
|
1483
|
-
throw new ApiError(
|
|
1484
|
-
upstream.ok ? 502 : upstream.status,
|
|
1485
|
-
"INVALID_CLOUD_RESPONSE",
|
|
1486
|
-
"Cloud taskboard returned an invalid JSON response",
|
|
1487
|
-
);
|
|
1488
|
-
}
|
|
1489
|
-
if (!upstream.ok) {
|
|
1490
|
-
throw new ApiError(
|
|
1491
|
-
upstream.status,
|
|
1492
|
-
payload?.error?.code ?? "CLOUD_REQUEST_FAILED",
|
|
1493
|
-
payload?.error?.message ?? "Cloud taskboard request failed",
|
|
1494
|
-
payload?.error?.details,
|
|
1495
|
-
);
|
|
1496
|
-
}
|
|
1497
|
-
return payload;
|
|
1498
|
-
}
|
|
1499
|
-
|
|
1500
|
-
async function resolveAiChatContext(projectId, issueId, codexTarget) {
|
|
1501
|
-
const config = await cloudConfig.read();
|
|
1502
|
-
if (!config.remoteUrl) {
|
|
1503
|
-
if (codexTarget?.codexProjectKind === "remote") {
|
|
1504
|
-
const project = database.getProject(projectId);
|
|
1505
|
-
if (!project) {
|
|
1506
|
-
throw new ApiError(404, "PROJECT_NOT_FOUND", `Project '${projectId}' does not exist`);
|
|
1507
|
-
}
|
|
1508
|
-
let issue;
|
|
1509
|
-
if (issueId !== undefined) {
|
|
1510
|
-
issue = database.getTask(issueId);
|
|
1511
|
-
if (!issue || issue.projectId !== projectId || issue.archivedAt != null) {
|
|
1512
|
-
throw new ApiError(
|
|
1513
|
-
404,
|
|
1514
|
-
"AI_CHAT_ISSUE_NOT_FOUND",
|
|
1515
|
-
`Task '${issueId}' is not an active task in project '${projectId}'`,
|
|
1516
|
-
);
|
|
1517
|
-
}
|
|
1518
|
-
}
|
|
1519
|
-
return { project, issue, addDirectories: [], ...codexTarget };
|
|
1520
|
-
}
|
|
1521
|
-
let resolvedWorkspace;
|
|
1522
|
-
try {
|
|
1523
|
-
resolvedWorkspace = await resolveAiWorkspace(
|
|
1524
|
-
projectId,
|
|
1525
|
-
resolved.codexStatePath,
|
|
1526
|
-
database,
|
|
1527
|
-
);
|
|
1528
|
-
} catch (error) {
|
|
1529
|
-
if (
|
|
1530
|
-
!(error instanceof ApiError)
|
|
1531
|
-
|| error.code !== "PROJECT_WORKSPACE_UNAVAILABLE"
|
|
1532
|
-
|| projectId !== DEFAULT_PROJECT_ID
|
|
1533
|
-
) {
|
|
1534
|
-
throw error;
|
|
1535
|
-
}
|
|
1536
|
-
resolvedWorkspace = {
|
|
1537
|
-
workspacePath: PROJECT_ROOT,
|
|
1538
|
-
addDirectories: [],
|
|
1539
|
-
project: database.getProject(projectId),
|
|
1540
|
-
};
|
|
1541
|
-
}
|
|
1542
|
-
let issue;
|
|
1543
|
-
if (issueId !== undefined) {
|
|
1544
|
-
issue = database.getTask(issueId);
|
|
1545
|
-
if (!issue || issue.projectId !== projectId || issue.archivedAt != null) {
|
|
1546
|
-
throw new ApiError(
|
|
1547
|
-
404,
|
|
1548
|
-
"AI_CHAT_ISSUE_NOT_FOUND",
|
|
1549
|
-
`Task '${issueId}' is not an active task in project '${projectId}'`,
|
|
1550
|
-
);
|
|
1551
|
-
}
|
|
1552
|
-
}
|
|
1553
|
-
return { ...resolvedWorkspace, issue };
|
|
1554
|
-
}
|
|
1555
|
-
|
|
1556
|
-
const projectPayload = await readCloudJson("/api/projects");
|
|
1557
|
-
const project = Array.isArray(projectPayload.projects)
|
|
1558
|
-
? projectPayload.projects.find((candidate) => candidate?.id === projectId)
|
|
1559
|
-
: null;
|
|
1560
|
-
if (!project) {
|
|
1561
|
-
throw new ApiError(404, "PROJECT_NOT_FOUND", `Project '${projectId}' does not exist`);
|
|
1562
|
-
}
|
|
1563
|
-
|
|
1564
|
-
let issue;
|
|
1565
|
-
if (issueId !== undefined) {
|
|
1566
|
-
const issuePayload = await readCloudJson(`/api/tasks/${encodeURIComponent(issueId)}`);
|
|
1567
|
-
issue = issuePayload.task;
|
|
1568
|
-
if (!issue || issue.projectId !== projectId || issue.archivedAt != null) {
|
|
1569
|
-
throw new ApiError(
|
|
1570
|
-
404,
|
|
1571
|
-
"AI_CHAT_ISSUE_NOT_FOUND",
|
|
1572
|
-
`Task '${issueId}' is not an active task in project '${projectId}'`,
|
|
1573
|
-
);
|
|
1574
|
-
}
|
|
1575
|
-
}
|
|
1576
|
-
|
|
1577
|
-
if (codexTarget?.codexProjectKind === "remote") {
|
|
1578
|
-
return { project, issue, addDirectories: [], ...codexTarget };
|
|
1579
|
-
}
|
|
1580
|
-
|
|
1581
|
-
const resolvedWorkspace = await resolveMappedAiWorkspace(
|
|
1582
|
-
projectId,
|
|
1583
|
-
project,
|
|
1584
|
-
config.projectMappings,
|
|
1585
|
-
);
|
|
1586
|
-
return { ...resolvedWorkspace, issue };
|
|
1587
|
-
}
|
|
1588
|
-
|
|
1589
|
-
const aiChat = new AiChatService({
|
|
1590
|
-
database,
|
|
1591
|
-
codexExecutable: resolved.codexExecutable,
|
|
1592
|
-
codexStatePath: resolved.codexStatePath,
|
|
1593
|
-
manageTaskboardSkillPath: resolved.skillPath,
|
|
1594
|
-
processEnv: codexProcessEnvironment,
|
|
1595
|
-
resolveContext: resolveAiChatContext,
|
|
1596
|
-
remoteAppServerFactory: options.remoteAppServerFactory,
|
|
1597
|
-
});
|
|
1598
|
-
const projectSummary = new ProjectSummaryService({
|
|
1599
|
-
database,
|
|
1600
|
-
codexExecutable: resolved.codexExecutable,
|
|
1601
|
-
processEnv: codexProcessEnvironment,
|
|
1602
|
-
workspacePath: PROJECT_ROOT,
|
|
1603
|
-
});
|
|
1604
|
-
const aiEventResponses = new Set();
|
|
1605
|
-
const codexSessionSearches = new Map();
|
|
1606
|
-
const codexSessionStateCache = new Map();
|
|
1607
|
-
const codexSessionsDirectory = path.join(path.dirname(resolved.codexStatePath), "sessions");
|
|
1608
|
-
|
|
1609
|
-
async function findCodexSession(threadId) {
|
|
1610
|
-
const cached = codexSessionSearches.get(threadId);
|
|
1611
|
-
if (cached && (cached.path || Date.now() - cached.checkedAt < 5_000)) return cached.path;
|
|
1612
|
-
|
|
1613
|
-
const suffix = `-${threadId}.jsonl`;
|
|
1614
|
-
const directories = [codexSessionsDirectory];
|
|
1615
|
-
while (directories.length > 0) {
|
|
1616
|
-
const directory = directories.pop();
|
|
1617
|
-
let entries;
|
|
1618
|
-
try {
|
|
1619
|
-
entries = await readdir(directory, { withFileTypes: true });
|
|
1620
|
-
} catch (error) {
|
|
1621
|
-
if (error.code === "ENOENT") continue;
|
|
1622
|
-
throw error;
|
|
1623
|
-
}
|
|
1624
|
-
for (const entry of entries) {
|
|
1625
|
-
const entryPath = path.join(directory, entry.name);
|
|
1626
|
-
if (entry.isDirectory()) {
|
|
1627
|
-
directories.push(entryPath);
|
|
1628
|
-
} else if (entry.isFile() && entry.name.endsWith(suffix)) {
|
|
1629
|
-
codexSessionSearches.set(threadId, { path: entryPath, checkedAt: Date.now() });
|
|
1630
|
-
return entryPath;
|
|
1631
|
-
}
|
|
1632
|
-
}
|
|
1633
|
-
}
|
|
1634
|
-
|
|
1635
|
-
codexSessionSearches.set(threadId, { path: null, checkedAt: Date.now() });
|
|
1636
|
-
return null;
|
|
1637
|
-
}
|
|
1638
|
-
|
|
1639
|
-
async function readCodexSessionState(threadId) {
|
|
1640
|
-
const sessionPath = await findCodexSession(threadId);
|
|
1641
|
-
if (!sessionPath) return null;
|
|
1642
|
-
|
|
1643
|
-
const sessionStat = await stat(sessionPath);
|
|
1644
|
-
const cached = codexSessionStateCache.get(sessionPath);
|
|
1645
|
-
if (cached?.size === sessionStat.size && cached.mtimeMs === sessionStat.mtimeMs) {
|
|
1646
|
-
return cached.state;
|
|
1647
|
-
}
|
|
1648
|
-
|
|
1649
|
-
const length = Math.min(sessionStat.size, CODEX_PLAN_TAIL_BYTES);
|
|
1650
|
-
const buffer = Buffer.alloc(length);
|
|
1651
|
-
const handle = await open(sessionPath, "r");
|
|
1652
|
-
try {
|
|
1653
|
-
await handle.read(buffer, 0, length, sessionStat.size - length);
|
|
1654
|
-
} finally {
|
|
1655
|
-
await handle.close();
|
|
1656
|
-
}
|
|
1657
|
-
|
|
1658
|
-
const lines = buffer.toString("utf8").split("\n");
|
|
1659
|
-
if (length < sessionStat.size) lines.shift();
|
|
1660
|
-
const records = [];
|
|
1661
|
-
for (const line of lines) {
|
|
1662
|
-
try {
|
|
1663
|
-
records.push(JSON.parse(line));
|
|
1664
|
-
} catch {}
|
|
1665
|
-
}
|
|
1666
|
-
|
|
1667
|
-
let runningTurnId = null;
|
|
1668
|
-
for (const record of records) {
|
|
1669
|
-
const payload = record?.payload;
|
|
1670
|
-
if (record?.type !== "event_msg" || typeof payload?.turn_id !== "string") continue;
|
|
1671
|
-
if (payload.type === "task_started") runningTurnId = payload.turn_id;
|
|
1672
|
-
if (
|
|
1673
|
-
(payload.type === "task_complete" || payload.type === "turn_aborted")
|
|
1674
|
-
&& payload.turn_id === runningTurnId
|
|
1675
|
-
) {
|
|
1676
|
-
runningTurnId = null;
|
|
1677
|
-
}
|
|
1678
|
-
}
|
|
1679
|
-
|
|
1680
|
-
let progress = null;
|
|
1681
|
-
for (let index = records.length - 1; index >= 0; index -= 1) {
|
|
1682
|
-
const record = records[index];
|
|
1683
|
-
const payload = record?.payload;
|
|
1684
|
-
if (payload?.type !== "custom_tool_call" || typeof payload.input !== "string") continue;
|
|
1685
|
-
|
|
1686
|
-
let statuses = [];
|
|
1687
|
-
if (payload.name === "update_plan") {
|
|
1688
|
-
try {
|
|
1689
|
-
const input = JSON.parse(payload.input);
|
|
1690
|
-
statuses = Array.isArray(input.plan)
|
|
1691
|
-
? input.plan.map((item) => item?.status).filter(Boolean)
|
|
1692
|
-
: [];
|
|
1693
|
-
} catch {}
|
|
1694
|
-
} else if (payload.name === "exec") {
|
|
1695
|
-
const callIndex = payload.input.lastIndexOf("tools.update_plan(");
|
|
1696
|
-
if (callIndex < 0) continue;
|
|
1697
|
-
statuses = [...payload.input.slice(callIndex).matchAll(
|
|
1698
|
-
/["']?status["']?\s*:\s*["'](completed|in_progress|pending)["']/g,
|
|
1699
|
-
)].map((match) => match[1]);
|
|
1700
|
-
}
|
|
1701
|
-
|
|
1702
|
-
if (statuses.length > 0) {
|
|
1703
|
-
progress = {
|
|
1704
|
-
completed: statuses.filter((status) => status === "completed").length,
|
|
1705
|
-
total: statuses.length,
|
|
1706
|
-
};
|
|
1707
|
-
break;
|
|
1708
|
-
}
|
|
1709
|
-
}
|
|
1710
|
-
|
|
1711
|
-
const state = {
|
|
1712
|
-
completed: progress?.completed ?? null,
|
|
1713
|
-
total: progress?.total ?? null,
|
|
1714
|
-
running: runningTurnId !== null,
|
|
1715
|
-
};
|
|
1716
|
-
codexSessionStateCache.set(sessionPath, {
|
|
1717
|
-
size: sessionStat.size,
|
|
1718
|
-
mtimeMs: sessionStat.mtimeMs,
|
|
1719
|
-
state,
|
|
1720
|
-
});
|
|
1721
|
-
return state;
|
|
1722
|
-
}
|
|
1723
|
-
|
|
1724
|
-
const server = createServer(async (request, response) => {
|
|
1725
|
-
response.setHeader("x-content-type-options", "nosniff");
|
|
1726
|
-
response.setHeader("referrer-policy", "no-referrer");
|
|
1727
|
-
try {
|
|
1728
|
-
const incomingUrl = new URL(request.url, "http://127.0.0.1");
|
|
1729
|
-
if (resolved.instanceToken && incomingUrl.pathname !== "/health") {
|
|
1730
|
-
if (incomingUrl.pathname === routePrefix) {
|
|
1731
|
-
response.writeHead(301, { location: `${incomingUrl.pathname}/${incomingUrl.search}` });
|
|
1732
|
-
response.end();
|
|
1733
|
-
return;
|
|
1734
|
-
}
|
|
1735
|
-
if (
|
|
1736
|
-
incomingUrl.pathname !== routePrefix
|
|
1737
|
-
&& !incomingUrl.pathname.startsWith(`${routePrefix}/`)
|
|
1738
|
-
) {
|
|
1739
|
-
throw new ApiError(404, "NOT_FOUND", "Route not found");
|
|
1740
|
-
}
|
|
1741
|
-
request.url = `${incomingUrl.pathname.slice(routePrefix.length) || "/"}${incomingUrl.search}`;
|
|
1742
|
-
}
|
|
1743
|
-
|
|
1744
|
-
const configuredTrustedRequest = assertTrustedNetworkRequest(
|
|
1745
|
-
request,
|
|
1746
|
-
Boolean(resolved.instanceToken),
|
|
1747
|
-
resolved.trustedOrigins,
|
|
1748
|
-
);
|
|
1749
|
-
const origin = request.headers.origin;
|
|
1750
|
-
const trustedEmbedOrigin = TRUSTED_EMBED_ORIGINS.has(origin)
|
|
1751
|
-
|| (Boolean(resolved.instanceToken) && origin === "null");
|
|
1752
|
-
if (trustedEmbedOrigin) {
|
|
1753
|
-
response.setHeader("access-control-allow-origin", origin);
|
|
1754
|
-
response.setHeader("access-control-allow-methods", "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS");
|
|
1755
|
-
response.setHeader(
|
|
1756
|
-
"access-control-allow-headers",
|
|
1757
|
-
request.headers["access-control-request-headers"] ?? "content-type",
|
|
1758
|
-
);
|
|
1759
|
-
response.setHeader("access-control-expose-headers", "x-codex-taskboard-proof");
|
|
1760
|
-
response.setHeader("access-control-allow-private-network", "true");
|
|
1761
|
-
response.setHeader("vary", "origin");
|
|
1762
|
-
if (request.method === "OPTIONS") {
|
|
1763
|
-
response.writeHead(204);
|
|
1764
|
-
response.end();
|
|
1765
|
-
return;
|
|
1766
|
-
}
|
|
1767
|
-
}
|
|
1768
|
-
if (resolved.instanceToken && origin === "app://-") {
|
|
1769
|
-
const challenge = request.headers["x-codex-taskboard-challenge"];
|
|
1770
|
-
if (typeof challenge !== "string" || !/^[a-f0-9]{32,128}$/i.test(challenge)) {
|
|
1771
|
-
throw new ApiError(401, "INVALID_INSTANCE_CHALLENGE", "Launcher challenge is required");
|
|
1772
|
-
}
|
|
1773
|
-
response.setHeader(
|
|
1774
|
-
"x-codex-taskboard-proof",
|
|
1775
|
-
createHmac("sha256", resolved.instanceSecret).update(challenge).digest("hex"),
|
|
1776
|
-
);
|
|
1777
|
-
}
|
|
1778
|
-
const url = new URL(request.url, "http://127.0.0.1");
|
|
1779
|
-
const pathname = url.pathname;
|
|
1780
|
-
const isLocalAiRoute = pathname === "/api/local/ai" || pathname.startsWith("/api/local/ai/");
|
|
1781
|
-
const isDevelopmentContextsRoute = /^\/api\/projects\/[^/]+\/development-contexts$/.test(pathname);
|
|
1782
|
-
if (
|
|
1783
|
-
configuredTrustedRequest
|
|
1784
|
-
&& (
|
|
1785
|
-
pathname.startsWith("/api/local/")
|
|
1786
|
-
|| pathname === "/api/device-workspaces"
|
|
1787
|
-
|| isDevelopmentContextsRoute
|
|
1788
|
-
)
|
|
1789
|
-
) {
|
|
1790
|
-
throw new ApiError(
|
|
1791
|
-
409,
|
|
1792
|
-
"LOCAL_COMPANION_REQUIRED",
|
|
1793
|
-
"This capability requires a device-local Taskboard origin",
|
|
1794
|
-
);
|
|
1795
|
-
}
|
|
1796
|
-
if (isLocalAiRoute) {
|
|
1797
|
-
assertAiLoopbackRequest(request);
|
|
1798
|
-
} else if (pathname.startsWith("/api/local/")) {
|
|
1799
|
-
assertLoopbackRequest(request);
|
|
1800
|
-
}
|
|
1801
|
-
const isMachineCapabilityRoute = pathname === "/api/meta"
|
|
1802
|
-
|| pathname === "/api/device-workspaces"
|
|
1803
|
-
|| isDevelopmentContextsRoute;
|
|
1804
|
-
const capabilityCloudConfig = isMachineCapabilityRoute
|
|
1805
|
-
? await cloudConfig.read()
|
|
1806
|
-
: null;
|
|
1807
|
-
if (capabilityCloudConfig?.remoteUrl) assertLoopbackRequest(request);
|
|
1808
|
-
|
|
1809
|
-
if (pathname === "/health") {
|
|
1810
|
-
if (request.method !== "GET") return methodNotAllowed(response, ["GET"]);
|
|
1811
|
-
if (resolved.instanceToken) {
|
|
1812
|
-
const challenge = request.headers["x-codex-taskboard-challenge"];
|
|
1813
|
-
if (typeof challenge !== "string" || !/^[a-f0-9]{32,128}$/i.test(challenge)) {
|
|
1814
|
-
throw new ApiError(401, "INVALID_INSTANCE_CHALLENGE", "Launcher challenge is required");
|
|
1815
|
-
}
|
|
1816
|
-
return sendJson(response, 200, {
|
|
1817
|
-
status: "ok",
|
|
1818
|
-
product: "codex-taskboard",
|
|
1819
|
-
version: resolved.version,
|
|
1820
|
-
proof: createHmac("sha256", resolved.instanceSecret)
|
|
1821
|
-
.update(challenge)
|
|
1822
|
-
.digest("hex"),
|
|
1823
|
-
});
|
|
1824
|
-
}
|
|
1825
|
-
return sendJson(response, 200, { status: "ok" });
|
|
1826
|
-
}
|
|
1827
|
-
|
|
1828
|
-
if (pathname === "/api/client-storage") {
|
|
1829
|
-
if (request.method === "GET") {
|
|
1830
|
-
await clientStorageWrite;
|
|
1831
|
-
const entries = await readClientStorage();
|
|
1832
|
-
const config = await cloudConfig.read();
|
|
1833
|
-
if (config.remoteUrl) {
|
|
1834
|
-
assertLoopbackRequest(request);
|
|
1835
|
-
const shared = await readCloudJson("/api/client-storage");
|
|
1836
|
-
for (const key of Object.keys(entries)) {
|
|
1837
|
-
if (key.startsWith(PROJECT_BOARD_DISPLAY_SETTINGS_KEY_PREFIX)) delete entries[key];
|
|
1838
|
-
}
|
|
1839
|
-
for (const [key, value] of Object.entries(shared.entries)) {
|
|
1840
|
-
if (key.startsWith(PROJECT_BOARD_DISPLAY_SETTINGS_KEY_PREFIX)) entries[key] = value;
|
|
1841
|
-
}
|
|
1842
|
-
}
|
|
1843
|
-
return sendJson(response, 200, { entries });
|
|
1844
|
-
}
|
|
1845
|
-
if (request.method === "PATCH") {
|
|
1846
|
-
const update = parseClientStorageUpdate(await readJson(request));
|
|
1847
|
-
const config = await cloudConfig.read();
|
|
1848
|
-
if (
|
|
1849
|
-
config.remoteUrl
|
|
1850
|
-
&& update.key.startsWith(PROJECT_BOARD_DISPLAY_SETTINGS_KEY_PREFIX)
|
|
1851
|
-
) {
|
|
1852
|
-
assertLoopbackRequest(request);
|
|
1853
|
-
return sendFetchResponse(
|
|
1854
|
-
response,
|
|
1855
|
-
await cloudProxy.forward(new Request("http://127.0.0.1/api/client-storage", {
|
|
1856
|
-
method: "PATCH",
|
|
1857
|
-
headers: { "content-type": "application/json" },
|
|
1858
|
-
body: JSON.stringify(update),
|
|
1859
|
-
})),
|
|
1860
|
-
);
|
|
1861
|
-
}
|
|
1862
|
-
await updateClientStorage(update);
|
|
1863
|
-
if (update.key.startsWith(PROJECT_BOARD_DISPLAY_SETTINGS_KEY_PREFIX)) {
|
|
1864
|
-
events.emit("client-storage.updated", { key: update.key });
|
|
1865
|
-
}
|
|
1866
|
-
return sendEmpty(response, 204);
|
|
1867
|
-
}
|
|
1868
|
-
return methodNotAllowed(response, ["GET", "PATCH"]);
|
|
1869
|
-
}
|
|
1870
|
-
|
|
1871
|
-
if (pathname === "/api/local/codex-thread-progress") {
|
|
1872
|
-
if (request.method !== "GET") return methodNotAllowed(response, ["GET"]);
|
|
1873
|
-
if ([...url.searchParams.keys()].some((key) => key !== "threadId")) {
|
|
1874
|
-
throw new ApiError(400, "UNKNOWN_QUERY_PARAMETER", "Only 'threadId' is supported");
|
|
1875
|
-
}
|
|
1876
|
-
const threadIds = [...new Set(url.searchParams.getAll("threadId").map((value) => (
|
|
1877
|
-
value.trim().replace(/^(?:local|cloud):/i, "")
|
|
1878
|
-
)))];
|
|
1879
|
-
if (threadIds.length > 64 || threadIds.some((threadId) => (
|
|
1880
|
-
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(threadId)
|
|
1881
|
-
))) {
|
|
1882
|
-
throw new ApiError(400, "INVALID_FIELD", "'threadId' must contain valid Codex thread IDs");
|
|
1883
|
-
}
|
|
1884
|
-
const entries = await Promise.all(threadIds.map(async (threadId) => (
|
|
1885
|
-
[threadId, await readCodexSessionState(threadId)]
|
|
1886
|
-
)));
|
|
1887
|
-
return sendJson(response, 200, { progress: Object.fromEntries(entries) });
|
|
1888
|
-
}
|
|
1889
|
-
|
|
1890
|
-
if (pathname === "/api/local/host-runtime") {
|
|
1891
|
-
if (request.method === "GET") {
|
|
1892
|
-
const runtime = hostRuntime && Date.now() - hostRuntime.updatedAt <= HOST_RUNTIME_TTL_MS
|
|
1893
|
-
? hostRuntime
|
|
1894
|
-
: null;
|
|
1895
|
-
return sendJson(response, 200, { runtime });
|
|
1896
|
-
}
|
|
1897
|
-
if (request.method === "PUT") {
|
|
1898
|
-
const body = await readJson(request);
|
|
1899
|
-
assertPlainObject(body);
|
|
1900
|
-
assertAllowedKeys(body, new Set([
|
|
1901
|
-
"threadId",
|
|
1902
|
-
"threadRunning",
|
|
1903
|
-
"threadTodoProgress",
|
|
1904
|
-
"codexProjectId",
|
|
1905
|
-
"codexProjectKind",
|
|
1906
|
-
"codexHostId",
|
|
1907
|
-
"workspacePath",
|
|
1908
|
-
]));
|
|
1909
|
-
const threadId = stringField(body.threadId, "threadId", { required: true, maxLength: 256 });
|
|
1910
|
-
if (typeof body.threadRunning !== "boolean") {
|
|
1911
|
-
throw new ApiError(400, "INVALID_FIELD", "'threadRunning' must be a boolean");
|
|
1912
|
-
}
|
|
1913
|
-
let threadTodoProgress = null;
|
|
1914
|
-
if (body.threadTodoProgress != null) {
|
|
1915
|
-
assertPlainObject(body.threadTodoProgress);
|
|
1916
|
-
assertAllowedKeys(body.threadTodoProgress, new Set(["completed", "total"]));
|
|
1917
|
-
const { completed, total } = body.threadTodoProgress;
|
|
1918
|
-
if (!Number.isInteger(completed) || !Number.isInteger(total) || completed < 0 || total < 1) {
|
|
1919
|
-
throw new ApiError(400, "INVALID_FIELD", "'threadTodoProgress' is invalid");
|
|
1920
|
-
}
|
|
1921
|
-
threadTodoProgress = { completed: Math.min(completed, total), total };
|
|
1922
|
-
}
|
|
1923
|
-
hostRuntime = {
|
|
1924
|
-
threadId,
|
|
1925
|
-
threadRunning: body.threadRunning,
|
|
1926
|
-
threadTodoProgress,
|
|
1927
|
-
codexProjectId: stringField(body.codexProjectId ?? null, "codexProjectId", {
|
|
1928
|
-
nullable: true,
|
|
1929
|
-
maxLength: 256,
|
|
1930
|
-
}),
|
|
1931
|
-
codexProjectKind: body.codexProjectKind === "local" || body.codexProjectKind === "remote"
|
|
1932
|
-
? body.codexProjectKind
|
|
1933
|
-
: null,
|
|
1934
|
-
codexHostId: stringField(body.codexHostId ?? null, "codexHostId", {
|
|
1935
|
-
nullable: true,
|
|
1936
|
-
maxLength: 256,
|
|
1937
|
-
}),
|
|
1938
|
-
workspacePath: stringField(body.workspacePath ?? null, "workspacePath", {
|
|
1939
|
-
nullable: true,
|
|
1940
|
-
maxLength: 4096,
|
|
1941
|
-
}),
|
|
1942
|
-
updatedAt: Date.now(),
|
|
1943
|
-
};
|
|
1944
|
-
return sendJson(response, 200, { runtime: hostRuntime });
|
|
1945
|
-
}
|
|
1946
|
-
return methodNotAllowed(response, ["GET", "PUT"]);
|
|
1947
|
-
}
|
|
1948
|
-
|
|
1949
|
-
if (pathname === "/api/local/cloud-session") {
|
|
1950
|
-
if ([...url.searchParams.keys()].length > 0) {
|
|
1951
|
-
throw new ApiError(400, "UNKNOWN_QUERY_PARAMETER", "Cloud session routes do not accept query parameters");
|
|
1952
|
-
}
|
|
1953
|
-
if (request.method === "GET") {
|
|
1954
|
-
const config = await cloudConfig.read();
|
|
1955
|
-
return sendJson(response, 200, config.remoteUrl
|
|
1956
|
-
? {
|
|
1957
|
-
mode: "cloud",
|
|
1958
|
-
remoteUrl: config.remoteUrl,
|
|
1959
|
-
actorName: config.actorName,
|
|
1960
|
-
authenticated: true,
|
|
1961
|
-
}
|
|
1962
|
-
: { mode: "local", authenticated: false });
|
|
1963
|
-
}
|
|
1964
|
-
if (request.method === "PUT") {
|
|
1965
|
-
const body = await readJson(request);
|
|
1966
|
-
assertPlainObject(body);
|
|
1967
|
-
assertAllowedKeys(body, new Set(["remoteUrl", "actorName", "sharedKey"]));
|
|
1968
|
-
try {
|
|
1969
|
-
const config = await cloudConfig.configure({
|
|
1970
|
-
remoteUrl: body.remoteUrl,
|
|
1971
|
-
actorName: body.actorName,
|
|
1972
|
-
sharedKey: body.sharedKey,
|
|
1973
|
-
});
|
|
1974
|
-
return sendJson(response, 200, {
|
|
1975
|
-
mode: "cloud",
|
|
1976
|
-
remoteUrl: config.remoteUrl,
|
|
1977
|
-
actorName: config.actorName,
|
|
1978
|
-
authenticated: true,
|
|
1979
|
-
});
|
|
1980
|
-
} catch (error) {
|
|
1981
|
-
throw new ApiError(400, error.code ?? "INVALID_CLOUD_CONFIG", error.message);
|
|
1982
|
-
}
|
|
1983
|
-
}
|
|
1984
|
-
if (request.method === "DELETE") {
|
|
1985
|
-
await cloudConfig.clearCloud();
|
|
1986
|
-
return sendJson(response, 200, { mode: "local", authenticated: false });
|
|
1987
|
-
}
|
|
1988
|
-
return methodNotAllowed(response, ["GET", "PUT", "DELETE"]);
|
|
1989
|
-
}
|
|
1990
|
-
|
|
1991
|
-
if (pathname === "/api/local/jira-connection") {
|
|
1992
|
-
if ([...url.searchParams.keys()].length > 0) {
|
|
1993
|
-
throw new ApiError(400, "UNKNOWN_QUERY_PARAMETER", "Jira 连接接口不接受查询参数");
|
|
1994
|
-
}
|
|
1995
|
-
if (request.method === "GET") {
|
|
1996
|
-
return sendJson(response, 200, { connection: await jira.status() });
|
|
1997
|
-
}
|
|
1998
|
-
if (request.method === "PUT") {
|
|
1999
|
-
const activeCloudConfig = await cloudConfig.read();
|
|
2000
|
-
if (activeCloudConfig.remoteUrl) {
|
|
2001
|
-
throw new ApiError(
|
|
2002
|
-
409,
|
|
2003
|
-
"JIRA_LOCAL_MODE_REQUIRED",
|
|
2004
|
-
"Jira 连接当前仅支持本地数据模式,请先退出云端协作模式",
|
|
2005
|
-
);
|
|
2006
|
-
}
|
|
2007
|
-
const body = await readJson(request);
|
|
2008
|
-
assertPlainObject(body);
|
|
2009
|
-
assertAllowedKeys(body, new Set(["baseUrl", "username", "password", "projects"]));
|
|
2010
|
-
const baseUrl = stringField(body.baseUrl, "baseUrl", { required: true, maxLength: 2048 });
|
|
2011
|
-
const username = stringField(body.username ?? "", "username", { maxLength: 254 });
|
|
2012
|
-
const password = body.password ?? "";
|
|
2013
|
-
if (typeof password !== "string") {
|
|
2014
|
-
throw new ApiError(400, "INVALID_FIELD", "'password' must be a string");
|
|
2015
|
-
}
|
|
2016
|
-
if (password.length > 4096) {
|
|
2017
|
-
throw new ApiError(400, "INVALID_FIELD", "'password' cannot exceed 4096 characters");
|
|
2018
|
-
}
|
|
2019
|
-
try {
|
|
2020
|
-
const connection = await jira.configure({
|
|
2021
|
-
baseUrl,
|
|
2022
|
-
username,
|
|
2023
|
-
password,
|
|
2024
|
-
projects: body.projects,
|
|
2025
|
-
});
|
|
2026
|
-
events.emit("project.labels.updated", { project: database.getProject(JIRA_PROJECT_ID) });
|
|
2027
|
-
return sendJson(response, 200, { connection });
|
|
2028
|
-
} catch (error) {
|
|
2029
|
-
if (error instanceof ApiError) throw error;
|
|
2030
|
-
throw new ApiError(400, error.code ?? "INVALID_JIRA_CONFIG", error.message);
|
|
2031
|
-
}
|
|
2032
|
-
}
|
|
2033
|
-
return methodNotAllowed(response, ["GET", "PUT"]);
|
|
2034
|
-
}
|
|
2035
|
-
|
|
2036
|
-
if (pathname === "/api/local/jira-connection/sync") {
|
|
2037
|
-
if (request.method !== "POST") return methodNotAllowed(response, ["POST"]);
|
|
2038
|
-
if ([...url.searchParams.keys()].length > 0) {
|
|
2039
|
-
throw new ApiError(400, "UNKNOWN_QUERY_PARAMETER", "Jira 同步接口不接受查询参数");
|
|
2040
|
-
}
|
|
2041
|
-
await assertEmptyRequestBody(request, "POST /api/local/jira-connection/sync");
|
|
2042
|
-
const connection = await jira.sync({ force: true });
|
|
2043
|
-
events.emit("project.labels.updated", { project: database.getProject(JIRA_PROJECT_ID) });
|
|
2044
|
-
return sendJson(response, 200, { connection });
|
|
2045
|
-
}
|
|
2046
|
-
|
|
2047
|
-
const projectMappingRoute = pathname.match(/^\/api\/local\/project-mappings\/([^/]+)$/);
|
|
2048
|
-
if (projectMappingRoute) {
|
|
2049
|
-
if (request.method !== "PUT") return methodNotAllowed(response, ["PUT"]);
|
|
2050
|
-
if ([...url.searchParams.keys()].length > 0) {
|
|
2051
|
-
throw new ApiError(400, "UNKNOWN_QUERY_PARAMETER", "Project mapping routes do not accept query parameters");
|
|
2052
|
-
}
|
|
2053
|
-
let projectId;
|
|
2054
|
-
try {
|
|
2055
|
-
projectId = decodeURIComponent(projectMappingRoute[1]);
|
|
2056
|
-
} catch {
|
|
2057
|
-
throw new ApiError(400, "INVALID_PATH", "Project id contains invalid encoding");
|
|
2058
|
-
}
|
|
2059
|
-
validateProjectId(projectId);
|
|
2060
|
-
const body = await readJson(request);
|
|
2061
|
-
assertPlainObject(body);
|
|
2062
|
-
assertAllowedKeys(body, new Set(["workspacePath"]));
|
|
2063
|
-
const workspacePath = pathField(body.workspacePath, "workspacePath");
|
|
2064
|
-
if (!workspacePath || !path.isAbsolute(workspacePath)) {
|
|
2065
|
-
throw new ApiError(400, "INVALID_FIELD", "'workspacePath' must be absolute");
|
|
2066
|
-
}
|
|
2067
|
-
await cloudConfig.setProjectWorkspace(projectId, workspacePath);
|
|
2068
|
-
return sendJson(response, 200, { projectId, workspacePath });
|
|
2069
|
-
}
|
|
2070
|
-
|
|
2071
|
-
if (pathname === "/api/meta") {
|
|
2072
|
-
if (request.method !== "GET") return methodNotAllowed(response, ["GET"]);
|
|
2073
|
-
if ([...url.searchParams.keys()].length > 0) {
|
|
2074
|
-
throw new ApiError(400, "UNKNOWN_QUERY_PARAMETER", "GET /api/meta does not accept query parameters");
|
|
2075
|
-
}
|
|
2076
|
-
return sendJson(response, 200, {
|
|
2077
|
-
...(configuredTrustedRequest ? {} : { manageTaskboardSkillPath: resolved.skillPath }),
|
|
2078
|
-
capabilities: {
|
|
2079
|
-
localAiChat: !configuredTrustedRequest
|
|
2080
|
-
&& isLoopbackAddress(request.socket.remoteAddress),
|
|
2081
|
-
},
|
|
2082
|
-
...(capabilityCloudConfig?.remoteUrl
|
|
2083
|
-
? {
|
|
2084
|
-
mode: "cloud",
|
|
2085
|
-
realtime: {
|
|
2086
|
-
transport: "websocket",
|
|
2087
|
-
endpoint: "/api/events",
|
|
2088
|
-
},
|
|
2089
|
-
localCapabilities: { available: !configuredTrustedRequest },
|
|
2090
|
-
}
|
|
2091
|
-
: {}),
|
|
2092
|
-
});
|
|
2093
|
-
}
|
|
2094
|
-
|
|
2095
|
-
if (pathname === "/api/local/ai/catalog") {
|
|
2096
|
-
if (request.method !== "GET") return methodNotAllowed(response, ["GET"]);
|
|
2097
|
-
assertAllowedQuery(url.searchParams, new Set([
|
|
2098
|
-
"projectId",
|
|
2099
|
-
"codexProjectId",
|
|
2100
|
-
"codexProjectKind",
|
|
2101
|
-
"codexHostId",
|
|
2102
|
-
"workspacePath",
|
|
2103
|
-
]), "GET /api/local/ai/catalog");
|
|
2104
|
-
const projectId = validateProjectId(url.searchParams.get("projectId") ?? undefined);
|
|
2105
|
-
return sendJson(
|
|
2106
|
-
response,
|
|
2107
|
-
200,
|
|
2108
|
-
await aiChat.getCatalog(projectId, undefined, aiExecutionTargetFromQuery(url.searchParams)),
|
|
2109
|
-
);
|
|
2110
|
-
}
|
|
2111
|
-
|
|
2112
|
-
if (pathname === "/api/local/ai/composer/candidates") {
|
|
2113
|
-
if (request.method !== "GET") return methodNotAllowed(response, ["GET"]);
|
|
2114
|
-
const query = parseComposerCandidateQuery(url.searchParams);
|
|
2115
|
-
return sendJson(
|
|
2116
|
-
response,
|
|
2117
|
-
200,
|
|
2118
|
-
await aiChat.composerCatalog.candidatesForSurface(
|
|
2119
|
-
await aiChat.getComposerCandidates(query),
|
|
2120
|
-
query,
|
|
2121
|
-
),
|
|
2122
|
-
);
|
|
2123
|
-
}
|
|
2124
|
-
|
|
2125
|
-
if (pathname === "/api/local/ai/composer/rebind") {
|
|
2126
|
-
if (request.method !== "POST") return methodNotAllowed(response, ["POST"]);
|
|
2127
|
-
assertNoQuery(url.searchParams, "POST /api/local/ai/composer/rebind");
|
|
2128
|
-
const input = parseComposerRebindRequest(await readJson(request));
|
|
2129
|
-
const { workspacePath, composerCatalog } = await resolveComposerRebindWorkspace(aiChat, input);
|
|
2130
|
-
return sendJson(
|
|
2131
|
-
response,
|
|
2132
|
-
200,
|
|
2133
|
-
await composerCatalog.rebindPersistedReferences({
|
|
2134
|
-
workspacePath,
|
|
2135
|
-
nodes: input.document.nodes,
|
|
2136
|
-
}),
|
|
2137
|
-
);
|
|
2138
|
-
}
|
|
2139
|
-
|
|
2140
|
-
const projectSummaryRoute = pathname.match(/^\/api\/local\/projects\/([^/]+)\/summary$/);
|
|
2141
|
-
if (projectSummaryRoute) {
|
|
2142
|
-
if (request.method !== "GET") return methodNotAllowed(response, ["GET"]);
|
|
2143
|
-
assertNoQuery(url.searchParams, "GET /api/local/projects/:id/summary");
|
|
2144
|
-
const projectId = validateProjectId(
|
|
2145
|
-
decodeRouteSegment(projectSummaryRoute[1], "Project id"),
|
|
2146
|
-
);
|
|
2147
|
-
return sendJson(response, 200, projectSummary.get(projectId));
|
|
2148
|
-
}
|
|
2149
|
-
|
|
2150
|
-
if (pathname === "/api/local/ai/threads") {
|
|
2151
|
-
assertNoQuery(url.searchParams, "/api/local/ai/threads");
|
|
2152
|
-
if (request.method === "GET") {
|
|
2153
|
-
return sendJson(response, 200, { threads: await aiChat.listThreads() });
|
|
2154
|
-
}
|
|
2155
|
-
if (request.method === "POST") {
|
|
2156
|
-
const thread = await aiChat.createThread(parseAiThreadCreate(await readJson(request)));
|
|
2157
|
-
return sendJson(response, 201, { thread });
|
|
2158
|
-
}
|
|
2159
|
-
return methodNotAllowed(response, ["GET", "POST"]);
|
|
2160
|
-
}
|
|
2161
|
-
|
|
2162
|
-
const aiThreadSummaryRoute = pathname.match(/^\/api\/local\/ai\/threads\/([^/]+)\/summary$/);
|
|
2163
|
-
if (aiThreadSummaryRoute) {
|
|
2164
|
-
if (request.method !== "GET") return methodNotAllowed(response, ["GET"]);
|
|
2165
|
-
assertNoQuery(url.searchParams, "GET /api/local/ai/threads/:id/summary");
|
|
2166
|
-
const threadId = decodeRouteSegment(aiThreadSummaryRoute[1], "Thread id");
|
|
2167
|
-
return sendJson(response, 200, await aiChat.getThreadSummary(threadId));
|
|
2168
|
-
}
|
|
2169
|
-
|
|
2170
|
-
const aiThreadEventsRoute = pathname.match(/^\/api\/local\/ai\/threads\/([^/]+)\/events$/);
|
|
2171
|
-
if (aiThreadEventsRoute) {
|
|
2172
|
-
if (request.method !== "GET") return methodNotAllowed(response, ["GET"]);
|
|
2173
|
-
assertNoQuery(url.searchParams, "GET /api/local/ai/threads/:id/events");
|
|
2174
|
-
const threadId = decodeRouteSegment(aiThreadEventsRoute[1], "Thread id");
|
|
2175
|
-
await aiChat.getThread(threadId);
|
|
2176
|
-
response.writeHead(200, {
|
|
2177
|
-
connection: "keep-alive",
|
|
2178
|
-
"cache-control": "no-cache, no-transform",
|
|
2179
|
-
"content-type": "text/event-stream; charset=utf-8",
|
|
2180
|
-
"x-accel-buffering": "no",
|
|
2181
|
-
});
|
|
2182
|
-
aiEventResponses.add(response);
|
|
2183
|
-
const unsubscribe = aiChat.subscribe(threadId, (event) => {
|
|
2184
|
-
const type = event?.type === "ai.run" ? "ai.run" : "ai.event";
|
|
2185
|
-
response.write(`event: ${type}\ndata: ${JSON.stringify(event)}\n\n`);
|
|
2186
|
-
});
|
|
2187
|
-
response.write(": connected\n\n");
|
|
2188
|
-
response.write('event: ai.event\ndata: {"type":"ai.event"}\n\n');
|
|
2189
|
-
const keepAlive = setInterval(() => response.write(": keep-alive\n\n"), 20_000);
|
|
2190
|
-
keepAlive.unref();
|
|
2191
|
-
request.once("close", () => {
|
|
2192
|
-
clearInterval(keepAlive);
|
|
2193
|
-
unsubscribe();
|
|
2194
|
-
aiEventResponses.delete(response);
|
|
2195
|
-
});
|
|
2196
|
-
return;
|
|
2197
|
-
}
|
|
2198
|
-
|
|
2199
|
-
const aiThreadTurnRoute = pathname.match(/^\/api\/local\/ai\/threads\/([^/]+)\/turns$/);
|
|
2200
|
-
if (aiThreadTurnRoute) {
|
|
2201
|
-
if (request.method !== "POST") return methodNotAllowed(response, ["POST"]);
|
|
2202
|
-
assertNoQuery(url.searchParams, "POST /api/local/ai/threads/:id/turns");
|
|
2203
|
-
const threadId = decodeRouteSegment(aiThreadTurnRoute[1], "Thread id");
|
|
2204
|
-
const run = await aiChat.startTurn(
|
|
2205
|
-
threadId,
|
|
2206
|
-
parseAiTurn(await readJson(
|
|
2207
|
-
request,
|
|
2208
|
-
AI_CHAT_TURN_BODY_LIMIT,
|
|
2209
|
-
"AI chat turn body cannot exceed 25 MiB",
|
|
2210
|
-
)),
|
|
2211
|
-
);
|
|
2212
|
-
return sendJson(response, 202, { run });
|
|
2213
|
-
}
|
|
2214
|
-
|
|
2215
|
-
const aiThreadCompactRoute = pathname.match(/^\/api\/local\/ai\/threads\/([^/]+)\/compact$/);
|
|
2216
|
-
if (aiThreadCompactRoute) {
|
|
2217
|
-
if (request.method !== "POST") return methodNotAllowed(response, ["POST"]);
|
|
2218
|
-
assertNoQuery(url.searchParams, "POST /api/local/ai/threads/:id/compact");
|
|
2219
|
-
const threadId = decodeRouteSegment(aiThreadCompactRoute[1], "Thread id");
|
|
2220
|
-
await assertEmptyRequestBody(request, "POST /api/local/ai/threads/:id/compact");
|
|
2221
|
-
const thread = await aiChat.compactThread(threadId);
|
|
2222
|
-
return sendJson(response, 200, { thread });
|
|
2223
|
-
}
|
|
2224
|
-
|
|
2225
|
-
const aiThreadRoute = pathname.match(/^\/api\/local\/ai\/threads\/([^/]+)$/);
|
|
2226
|
-
if (aiThreadRoute) {
|
|
2227
|
-
assertNoQuery(url.searchParams, "/api/local/ai/threads/:id");
|
|
2228
|
-
const threadId = decodeRouteSegment(aiThreadRoute[1], "Thread id");
|
|
2229
|
-
if (request.method === "GET") {
|
|
2230
|
-
return sendJson(response, 200, await aiChat.getThreadSnapshot(threadId));
|
|
2231
|
-
}
|
|
2232
|
-
if (request.method === "PATCH") {
|
|
2233
|
-
const thread = await aiChat.updateThread(threadId, parseAiThreadPatch(await readJson(request)));
|
|
2234
|
-
return sendJson(response, 200, { thread });
|
|
2235
|
-
}
|
|
2236
|
-
if (request.method === "DELETE") {
|
|
2237
|
-
await assertEmptyRequestBody(request, "DELETE /api/local/ai/threads/:id");
|
|
2238
|
-
await aiChat.deleteThread(threadId);
|
|
2239
|
-
return sendEmpty(response, 204);
|
|
2240
|
-
}
|
|
2241
|
-
return methodNotAllowed(response, ["GET", "PATCH", "DELETE"]);
|
|
2242
|
-
}
|
|
2243
|
-
|
|
2244
|
-
const aiInterruptRoute = pathname.match(/^\/api\/local\/ai\/runs\/([^/]+)\/interrupt$/);
|
|
2245
|
-
if (aiInterruptRoute) {
|
|
2246
|
-
if (request.method !== "POST") return methodNotAllowed(response, ["POST"]);
|
|
2247
|
-
assertNoQuery(url.searchParams, "POST /api/local/ai/runs/:id/interrupt");
|
|
2248
|
-
const runId = decodeRouteSegment(aiInterruptRoute[1], "Run id");
|
|
2249
|
-
await assertEmptyRequestBody(request, "POST /api/local/ai/runs/:id/interrupt");
|
|
2250
|
-
const run = await aiChat.interrupt(runId);
|
|
2251
|
-
return sendJson(response, 200, { run });
|
|
2252
|
-
}
|
|
2253
|
-
|
|
2254
|
-
if (pathname === "/api/device-workspaces") {
|
|
2255
|
-
if (request.method !== "GET") return methodNotAllowed(response, ["GET"]);
|
|
2256
|
-
if ([...url.searchParams.keys()].length > 0) {
|
|
2257
|
-
throw new ApiError(400, "UNKNOWN_QUERY_PARAMETER", "GET /api/device-workspaces does not accept query parameters");
|
|
2258
|
-
}
|
|
2259
|
-
return sendJson(response, 200, {
|
|
2260
|
-
workspaces: await readCodexProjectWorkspaces(resolved.codexStatePath),
|
|
2261
|
-
});
|
|
2262
|
-
}
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
let currentCloudConfig = null;
|
|
2266
|
-
if (pathname.startsWith("/api/")) {
|
|
2267
|
-
currentCloudConfig = await cloudConfig.read();
|
|
2268
|
-
if (currentCloudConfig.remoteUrl) {
|
|
2269
|
-
assertLoopbackRequest(request);
|
|
2270
|
-
if (!isLocalCompanionRoute(pathname)) {
|
|
2271
|
-
return sendFetchResponse(
|
|
2272
|
-
response,
|
|
2273
|
-
await cloudProxy.forward(toFetchRequest(request)),
|
|
2274
|
-
);
|
|
2275
|
-
}
|
|
2276
|
-
}
|
|
2277
|
-
}
|
|
2278
|
-
|
|
2279
|
-
if (pathname === "/api/projects") {
|
|
2280
|
-
if (request.method === "GET") {
|
|
2281
|
-
if ([...url.searchParams.keys()].length > 0) {
|
|
2282
|
-
throw new ApiError(400, "UNKNOWN_QUERY_PARAMETER", "GET /api/projects does not accept query parameters");
|
|
2283
|
-
}
|
|
2284
|
-
const projects = database.listProjects().map((project) => ({
|
|
2285
|
-
...project,
|
|
2286
|
-
workspacePath: project.id === DEFAULT_PROJECT_ID
|
|
2287
|
-
? null
|
|
2288
|
-
: currentCloudConfig?.projectMappings[project.id] ?? project.workspacePath,
|
|
2289
|
-
}));
|
|
2290
|
-
return sendJson(response, 200, { projects });
|
|
2291
|
-
}
|
|
2292
|
-
if (request.method === "POST") {
|
|
2293
|
-
const project = database.createProject(parseProjectCreate(await readJson(request)));
|
|
2294
|
-
events.emit("project.created", { project });
|
|
2295
|
-
return sendJson(response, 201, { project });
|
|
2296
|
-
}
|
|
2297
|
-
return methodNotAllowed(response, ["GET", "POST"]);
|
|
2298
|
-
}
|
|
2299
|
-
|
|
2300
|
-
const projectRoute = pathname.match(/^\/api\/projects\/([^/]+)$/);
|
|
2301
|
-
if (projectRoute) {
|
|
2302
|
-
if ([...url.searchParams.keys()].length > 0) {
|
|
2303
|
-
throw new ApiError(400, "UNKNOWN_QUERY_PARAMETER", "Project routes do not accept query parameters");
|
|
2304
|
-
}
|
|
2305
|
-
let projectId;
|
|
2306
|
-
try {
|
|
2307
|
-
projectId = decodeURIComponent(projectRoute[1]);
|
|
2308
|
-
} catch {
|
|
2309
|
-
throw new ApiError(400, "INVALID_PATH", "Project id contains invalid encoding");
|
|
2310
|
-
}
|
|
2311
|
-
validateProjectId(projectId);
|
|
2312
|
-
if (request.method === "DELETE") {
|
|
2313
|
-
database.deleteProject(projectId);
|
|
2314
|
-
return sendEmpty(response, 204);
|
|
2315
|
-
}
|
|
2316
|
-
return methodNotAllowed(response, ["DELETE"]);
|
|
2317
|
-
}
|
|
2318
|
-
|
|
2319
|
-
const projectLabelsRoute = pathname.match(/^\/api\/projects\/([^/]+)\/labels$/);
|
|
2320
|
-
if (projectLabelsRoute) {
|
|
2321
|
-
if ([...url.searchParams.keys()].length > 0) {
|
|
2322
|
-
throw new ApiError(400, "UNKNOWN_QUERY_PARAMETER", "Project label routes do not accept query parameters");
|
|
2323
|
-
}
|
|
2324
|
-
let projectId;
|
|
2325
|
-
try {
|
|
2326
|
-
projectId = decodeURIComponent(projectLabelsRoute[1]);
|
|
2327
|
-
} catch {
|
|
2328
|
-
throw new ApiError(400, "INVALID_PATH", "Project id contains invalid encoding");
|
|
2329
|
-
}
|
|
2330
|
-
validateProjectId(projectId);
|
|
2331
|
-
if (request.method !== "POST" && request.method !== "DELETE") {
|
|
2332
|
-
return methodNotAllowed(response, ["POST", "DELETE"]);
|
|
2333
|
-
}
|
|
2334
|
-
if (request.method === "DELETE" && projectId === JIRA_PROJECT_ID) {
|
|
2335
|
-
throw new ApiError(
|
|
2336
|
-
409,
|
|
2337
|
-
"JIRA_LABEL_CATALOG_DELETE_UNAVAILABLE",
|
|
2338
|
-
"Jira 标签目录由同步管理,不能在 Taskboard 中删除",
|
|
2339
|
-
);
|
|
2340
|
-
}
|
|
2341
|
-
const label = parseProjectLabel(await readJson(request));
|
|
2342
|
-
const project = request.method === "POST"
|
|
2343
|
-
? database.addProjectLabel(projectId, label)
|
|
2344
|
-
: database.deleteProjectLabel(projectId, label);
|
|
2345
|
-
events.emit("project.labels.updated", { project });
|
|
2346
|
-
return sendJson(response, 200, { project });
|
|
2347
|
-
}
|
|
2348
|
-
|
|
2349
|
-
const projectReadmeAttachmentsRoute = pathname.match(
|
|
2350
|
-
/^\/api\/projects\/([^/]+)\/readme\/attachments$/,
|
|
2351
|
-
);
|
|
2352
|
-
if (projectReadmeAttachmentsRoute) {
|
|
2353
|
-
if ([...url.searchParams.keys()].length > 0) {
|
|
2354
|
-
throw new ApiError(400, "UNKNOWN_QUERY_PARAMETER", "Project README attachment routes do not accept query parameters");
|
|
2355
|
-
}
|
|
2356
|
-
let projectId;
|
|
2357
|
-
try {
|
|
2358
|
-
projectId = decodeURIComponent(projectReadmeAttachmentsRoute[1]);
|
|
2359
|
-
} catch {
|
|
2360
|
-
throw new ApiError(400, "INVALID_PATH", "Project id contains invalid encoding");
|
|
2361
|
-
}
|
|
2362
|
-
validateProjectId(projectId);
|
|
2363
|
-
if (request.method !== "POST") return methodNotAllowed(response, ["POST"]);
|
|
2364
|
-
const metadata = parseAttachmentHeaders(request);
|
|
2365
|
-
if (metadata.kind !== "inline") {
|
|
2366
|
-
throw new ApiError(400, "INVALID_ATTACHMENT_KIND", "Project README attachments must be inline");
|
|
2367
|
-
}
|
|
2368
|
-
const body = await readBody(request, ATTACHMENT_BODY_LIMIT, "Attachment cannot exceed 25 MiB");
|
|
2369
|
-
const id = randomUUID();
|
|
2370
|
-
await mkdir(resolved.attachmentsDirectory, { recursive: true });
|
|
2371
|
-
const storagePath = path.join(resolved.attachmentsDirectory, id);
|
|
2372
|
-
await writeFile(storagePath, body, { flag: "wx" });
|
|
2373
|
-
let attachment;
|
|
2374
|
-
try {
|
|
2375
|
-
attachment = database.createProjectReadmeAttachment(projectId, {
|
|
2376
|
-
id,
|
|
2377
|
-
...metadata,
|
|
2378
|
-
size: body.length,
|
|
2379
|
-
});
|
|
2380
|
-
} catch (error) {
|
|
2381
|
-
await unlink(storagePath);
|
|
2382
|
-
throw error;
|
|
2383
|
-
}
|
|
2384
|
-
return sendJson(response, 201, { attachment });
|
|
2385
|
-
}
|
|
2386
|
-
|
|
2387
|
-
const projectReadmeRoute = pathname.match(/^\/api\/projects\/([^/]+)\/readme$/);
|
|
2388
|
-
if (projectReadmeRoute) {
|
|
2389
|
-
if ([...url.searchParams.keys()].length > 0) {
|
|
2390
|
-
throw new ApiError(400, "UNKNOWN_QUERY_PARAMETER", "Project README routes do not accept query parameters");
|
|
2391
|
-
}
|
|
2392
|
-
let projectId;
|
|
2393
|
-
try {
|
|
2394
|
-
projectId = decodeURIComponent(projectReadmeRoute[1]);
|
|
2395
|
-
} catch {
|
|
2396
|
-
throw new ApiError(400, "INVALID_PATH", "Project id contains invalid encoding");
|
|
2397
|
-
}
|
|
2398
|
-
validateProjectId(projectId);
|
|
2399
|
-
if (request.method === "GET") {
|
|
2400
|
-
return sendJson(response, 200, { readme: database.getProjectReadme(projectId) });
|
|
2401
|
-
}
|
|
2402
|
-
if (request.method === "PUT") {
|
|
2403
|
-
const input = parseProjectReadmeSave(await readJson(
|
|
2404
|
-
request,
|
|
2405
|
-
PROJECT_README_BODY_LIMIT,
|
|
2406
|
-
"Project README request cannot exceed 3 MiB",
|
|
2407
|
-
));
|
|
2408
|
-
const readme = database.saveProjectReadme(projectId, input.content, input.version);
|
|
2409
|
-
events.emit("project.readme.updated", {
|
|
2410
|
-
projectId,
|
|
2411
|
-
readmeVersion: readme.version,
|
|
2412
|
-
});
|
|
2413
|
-
return sendJson(response, 200, { readme });
|
|
2414
|
-
}
|
|
2415
|
-
return methodNotAllowed(response, ["GET", "PUT"]);
|
|
2416
|
-
}
|
|
2417
|
-
|
|
2418
|
-
const developmentContextsRoute = pathname.match(/^\/api\/projects\/([^/]+)\/development-contexts$/);
|
|
2419
|
-
if (developmentContextsRoute) {
|
|
2420
|
-
if (request.method !== "GET") return methodNotAllowed(response, ["GET"]);
|
|
2421
|
-
const unknownQuery = [...url.searchParams.keys()].filter((key) => (
|
|
2422
|
-
!["codexProjectId", "codexThreadId", "workspacePath"].includes(key)
|
|
2423
|
-
));
|
|
2424
|
-
if (unknownQuery.length > 0) {
|
|
2425
|
-
throw new ApiError(400, "UNKNOWN_QUERY_PARAMETER", `Unknown query parameter: ${unknownQuery[0]}`);
|
|
2426
|
-
}
|
|
2427
|
-
let projectId;
|
|
2428
|
-
try {
|
|
2429
|
-
projectId = decodeURIComponent(developmentContextsRoute[1]);
|
|
2430
|
-
} catch {
|
|
2431
|
-
throw new ApiError(400, "INVALID_PATH", "Project id contains invalid encoding");
|
|
2432
|
-
}
|
|
2433
|
-
validateProjectId(projectId);
|
|
2434
|
-
const project = currentCloudConfig.remoteUrl
|
|
2435
|
-
? {
|
|
2436
|
-
id: projectId,
|
|
2437
|
-
workspacePath: projectId === DEFAULT_PROJECT_ID
|
|
2438
|
-
? null
|
|
2439
|
-
: currentCloudConfig.projectMappings[projectId] ?? null,
|
|
2440
|
-
}
|
|
2441
|
-
: database.getProject(projectId);
|
|
2442
|
-
if (!project) throw new ApiError(404, "PROJECT_NOT_FOUND", `Project '${projectId}' does not exist`);
|
|
2443
|
-
const codexProjectId = stringField(url.searchParams.get("codexProjectId") ?? null, "codexProjectId", {
|
|
2444
|
-
nullable: true,
|
|
2445
|
-
maxLength: 128,
|
|
2446
|
-
});
|
|
2447
|
-
const codexThreadId = stringField(url.searchParams.get("codexThreadId") ?? null, "codexThreadId", {
|
|
2448
|
-
nullable: true,
|
|
2449
|
-
maxLength: 256,
|
|
2450
|
-
});
|
|
2451
|
-
const deviceWorkspacePath = stringField(
|
|
2452
|
-
url.searchParams.get("workspacePath") ?? null,
|
|
2453
|
-
"workspacePath",
|
|
2454
|
-
{ nullable: true, maxLength: 4096 },
|
|
2455
|
-
);
|
|
2456
|
-
if (deviceWorkspacePath?.includes("\0")) {
|
|
2457
|
-
throw new ApiError(400, "INVALID_FIELD", "'workspacePath' cannot contain null bytes");
|
|
2458
|
-
}
|
|
2459
|
-
const workspacePath = deviceWorkspacePath ?? await resolveProjectWorkspace(
|
|
2460
|
-
project,
|
|
2461
|
-
codexProjectId,
|
|
2462
|
-
codexThreadId,
|
|
2463
|
-
resolved.codexStatePath,
|
|
2464
|
-
resolved.codexProcessesPath,
|
|
2465
|
-
);
|
|
2466
|
-
return sendJson(
|
|
2467
|
-
response,
|
|
2468
|
-
200,
|
|
2469
|
-
await scanDevelopmentContexts(workspacePath, codexProcessEnvironment),
|
|
2470
|
-
);
|
|
2471
|
-
}
|
|
2472
|
-
|
|
2473
|
-
if (pathname === "/api/tasks") {
|
|
2474
|
-
if (request.method === "GET") {
|
|
2475
|
-
const filters = parseTaskFilters(url.searchParams);
|
|
2476
|
-
if (!filters.projectId || filters.projectId === JIRA_PROJECT_ID) await jira.sync();
|
|
2477
|
-
return sendJson(response, 200, { tasks: database.listTasks(filters) });
|
|
2478
|
-
}
|
|
2479
|
-
if (request.method === "POST") {
|
|
2480
|
-
const actor = actorFromRequest(request);
|
|
2481
|
-
const { assigneeTarget, ...parsedInput } = parseTaskCreate(await readJson(request), parseDevelopmentContext);
|
|
2482
|
-
const input = resolveInputThreadBinding(parsedInput);
|
|
2483
|
-
if (input.projectId === JIRA_PROJECT_ID) {
|
|
2484
|
-
throw new ApiError(
|
|
2485
|
-
409,
|
|
2486
|
-
"JIRA_CREATE_UNAVAILABLE",
|
|
2487
|
-
"请在 Jira 中新建议题,Taskboard 当前只同步已分配给你的任务",
|
|
2488
|
-
);
|
|
2489
|
-
}
|
|
2490
|
-
const task = database.createTask({
|
|
2491
|
-
...input,
|
|
2492
|
-
actor,
|
|
2493
|
-
assignee: resolveAssignee(assigneeTarget, actor),
|
|
2494
|
-
});
|
|
2495
|
-
events.emit("task.created", { task });
|
|
2496
|
-
return sendJson(response, 201, { task });
|
|
2497
|
-
}
|
|
2498
|
-
return methodNotAllowed(response, ["GET", "POST"]);
|
|
2499
|
-
}
|
|
2500
|
-
|
|
2501
|
-
if (pathname === "/api/events") {
|
|
2502
|
-
if (request.method !== "GET") return methodNotAllowed(response, ["GET"]);
|
|
2503
|
-
if ([...url.searchParams.keys()].length > 0) {
|
|
2504
|
-
throw new ApiError(400, "UNKNOWN_QUERY_PARAMETER", "GET /api/events does not accept query parameters");
|
|
2505
|
-
}
|
|
2506
|
-
events.connect(request, response);
|
|
2507
|
-
return;
|
|
2508
|
-
}
|
|
2509
|
-
|
|
2510
|
-
const taskRelationRoute = pathname.match(
|
|
2511
|
-
/^\/api\/tasks\/([^/]+)\/relations\/([^/]+)\/([^/]+)$/,
|
|
2512
|
-
);
|
|
2513
|
-
if (taskRelationRoute) {
|
|
2514
|
-
let taskId;
|
|
2515
|
-
let type;
|
|
2516
|
-
let relatedTaskId;
|
|
2517
|
-
try {
|
|
2518
|
-
taskId = decodeURIComponent(taskRelationRoute[1]);
|
|
2519
|
-
type = decodeURIComponent(taskRelationRoute[2]);
|
|
2520
|
-
relatedTaskId = decodeURIComponent(taskRelationRoute[3]);
|
|
2521
|
-
} catch {
|
|
2522
|
-
throw new ApiError(400, "INVALID_PATH", "Issue relation path contains invalid encoding");
|
|
2523
|
-
}
|
|
2524
|
-
if (
|
|
2525
|
-
taskId.length === 0
|
|
2526
|
-
|| taskId.length > 128
|
|
2527
|
-
|| relatedTaskId.length === 0
|
|
2528
|
-
|| relatedTaskId.length > 128
|
|
2529
|
-
) {
|
|
2530
|
-
throw new ApiError(400, "INVALID_PATH", "Issue relation task id is invalid");
|
|
2531
|
-
}
|
|
2532
|
-
if ([...url.searchParams.keys()].length > 0) {
|
|
2533
|
-
throw new ApiError(400, "UNKNOWN_QUERY_PARAMETER", "Issue relation routes do not accept query parameters");
|
|
2534
|
-
}
|
|
2535
|
-
const relationType = parseIssueRelationType(type);
|
|
2536
|
-
if (request.method === "POST") {
|
|
2537
|
-
const { version, threadId, threadBinding, origin } = resolveInputThreadBinding(
|
|
2538
|
-
parseRelationMutation(await readJson(request)),
|
|
2539
|
-
);
|
|
2540
|
-
const result = database.addTaskRelation(
|
|
2541
|
-
taskId,
|
|
2542
|
-
version,
|
|
2543
|
-
relationType,
|
|
2544
|
-
relatedTaskId,
|
|
2545
|
-
threadId,
|
|
2546
|
-
threadBinding,
|
|
2547
|
-
actorFromRequest(request),
|
|
2548
|
-
origin,
|
|
2549
|
-
);
|
|
2550
|
-
events.emit("task.relation.updated", result);
|
|
2551
|
-
return sendJson(response, 200, result);
|
|
2552
|
-
}
|
|
2553
|
-
if (request.method === "DELETE") {
|
|
2554
|
-
const { version, threadId, threadBinding, origin } = resolveInputThreadBinding(
|
|
2555
|
-
parseRelationMutation(await readJson(request)),
|
|
2556
|
-
);
|
|
2557
|
-
const result = database.removeTaskRelation(
|
|
2558
|
-
taskId,
|
|
2559
|
-
version,
|
|
2560
|
-
relationType,
|
|
2561
|
-
relatedTaskId,
|
|
2562
|
-
threadId,
|
|
2563
|
-
threadBinding,
|
|
2564
|
-
actorFromRequest(request),
|
|
2565
|
-
origin,
|
|
2566
|
-
);
|
|
2567
|
-
events.emit("task.relation.updated", result);
|
|
2568
|
-
return sendJson(response, 200, result);
|
|
2569
|
-
}
|
|
2570
|
-
return methodNotAllowed(response, ["POST", "DELETE"]);
|
|
2571
|
-
}
|
|
2572
|
-
|
|
2573
|
-
const taskActivitiesRoute = pathname.match(/^\/api\/tasks\/([^/]+)\/activities$/);
|
|
2574
|
-
if (taskActivitiesRoute) {
|
|
2575
|
-
let taskId;
|
|
2576
|
-
try {
|
|
2577
|
-
taskId = decodeURIComponent(taskActivitiesRoute[1]);
|
|
2578
|
-
} catch {
|
|
2579
|
-
throw new ApiError(400, "INVALID_PATH", "Task id contains invalid encoding");
|
|
2580
|
-
}
|
|
2581
|
-
if (taskId.length === 0 || taskId.length > 128) {
|
|
2582
|
-
throw new ApiError(400, "INVALID_PATH", "Task id is invalid");
|
|
2583
|
-
}
|
|
2584
|
-
if ([...url.searchParams.keys()].length > 0) {
|
|
2585
|
-
throw new ApiError(400, "UNKNOWN_QUERY_PARAMETER", "Activity routes do not accept query parameters");
|
|
2586
|
-
}
|
|
2587
|
-
if (request.method === "GET") {
|
|
2588
|
-
return sendJson(response, 200, { activities: database.listTaskActivities(taskId) });
|
|
2589
|
-
}
|
|
2590
|
-
return methodNotAllowed(response, ["GET"]);
|
|
2591
|
-
}
|
|
2592
|
-
|
|
2593
|
-
const taskCommentsRoute = pathname.match(/^\/api\/tasks\/([^/]+)\/comments$/);
|
|
2594
|
-
if (taskCommentsRoute) {
|
|
2595
|
-
let taskId;
|
|
2596
|
-
try {
|
|
2597
|
-
taskId = decodeURIComponent(taskCommentsRoute[1]);
|
|
2598
|
-
} catch {
|
|
2599
|
-
throw new ApiError(400, "INVALID_PATH", "Task id contains invalid encoding");
|
|
2600
|
-
}
|
|
2601
|
-
if (taskId.length === 0 || taskId.length > 128) {
|
|
2602
|
-
throw new ApiError(400, "INVALID_PATH", "Task id is invalid");
|
|
2603
|
-
}
|
|
2604
|
-
if (request.method === "GET") {
|
|
2605
|
-
const after = parseAfterCursor(url.searchParams, "Comment routes");
|
|
2606
|
-
const comments = after
|
|
2607
|
-
? database.listCommentsAfter(taskId, after)
|
|
2608
|
-
: database.listComments(taskId);
|
|
2609
|
-
return sendJson(response, 200, {
|
|
2610
|
-
comments,
|
|
2611
|
-
nextCursor: nextCursor(comments, after),
|
|
2612
|
-
});
|
|
2613
|
-
}
|
|
2614
|
-
if ([...url.searchParams.keys()].length > 0) {
|
|
2615
|
-
throw new ApiError(400, "UNKNOWN_QUERY_PARAMETER", "Comment routes do not accept query parameters");
|
|
2616
|
-
}
|
|
2617
|
-
if (request.method === "POST") {
|
|
2618
|
-
const comment = database.createComment(taskId, {
|
|
2619
|
-
...resolveInputThreadBinding(parseCommentCreate(await readJson(request))),
|
|
2620
|
-
actor: actorFromRequest(request),
|
|
2621
|
-
});
|
|
2622
|
-
const task = database.getTask(taskId);
|
|
2623
|
-
events.emit("comment.created", { comment, task });
|
|
2624
|
-
return sendJson(response, 201, { comment });
|
|
2625
|
-
}
|
|
2626
|
-
return methodNotAllowed(response, ["GET", "POST"]);
|
|
2627
|
-
}
|
|
2628
|
-
|
|
2629
|
-
const commentRoute = pathname.match(/^\/api\/comments\/([^/]+)$/);
|
|
2630
|
-
if (commentRoute) {
|
|
2631
|
-
let id;
|
|
2632
|
-
try {
|
|
2633
|
-
id = decodeURIComponent(commentRoute[1]);
|
|
2634
|
-
} catch {
|
|
2635
|
-
throw new ApiError(400, "INVALID_PATH", "Comment id contains invalid encoding");
|
|
2636
|
-
}
|
|
2637
|
-
if (id.length === 0 || id.length > 128) {
|
|
2638
|
-
throw new ApiError(400, "INVALID_PATH", "Comment id is invalid");
|
|
2639
|
-
}
|
|
2640
|
-
if ([...url.searchParams.keys()].length > 0) {
|
|
2641
|
-
throw new ApiError(400, "UNKNOWN_QUERY_PARAMETER", "Comment routes do not accept query parameters");
|
|
2642
|
-
}
|
|
2643
|
-
if (request.method === "PATCH") {
|
|
2644
|
-
const patch = resolveInputThreadBinding(parseCommentPatch(await readJson(request)));
|
|
2645
|
-
const comment = database.updateComment(
|
|
2646
|
-
id,
|
|
2647
|
-
patch.version,
|
|
2648
|
-
patch.body,
|
|
2649
|
-
patch.threadId,
|
|
2650
|
-
patch.threadBinding,
|
|
2651
|
-
);
|
|
2652
|
-
const task = database.getTask(comment.taskId);
|
|
2653
|
-
events.emit("comment.updated", { comment, task });
|
|
2654
|
-
return sendJson(response, 200, { comment });
|
|
2655
|
-
}
|
|
2656
|
-
if (request.method === "DELETE") {
|
|
2657
|
-
const { version } = parseVersionMutation(await readJson(request));
|
|
2658
|
-
const comment = database.deleteComment(id, version);
|
|
2659
|
-
for (const attachment of comment.attachments) {
|
|
2660
|
-
try {
|
|
2661
|
-
await unlink(path.join(resolved.attachmentsDirectory, attachment.id));
|
|
2662
|
-
} catch (error) {
|
|
2663
|
-
if (error.code !== "ENOENT") throw error;
|
|
2664
|
-
}
|
|
2665
|
-
}
|
|
2666
|
-
const task = database.getTask(comment.taskId);
|
|
2667
|
-
events.emit("comment.deleted", { comment, task });
|
|
2668
|
-
return sendEmpty(response, 204);
|
|
2669
|
-
}
|
|
2670
|
-
return methodNotAllowed(response, ["PATCH", "DELETE"]);
|
|
2671
|
-
}
|
|
2672
|
-
|
|
2673
|
-
const commentAttachmentsRoute = pathname.match(/^\/api\/comments\/([^/]+)\/attachments$/);
|
|
2674
|
-
if (commentAttachmentsRoute) {
|
|
2675
|
-
let commentId;
|
|
2676
|
-
try {
|
|
2677
|
-
commentId = decodeURIComponent(commentAttachmentsRoute[1]);
|
|
2678
|
-
} catch {
|
|
2679
|
-
throw new ApiError(400, "INVALID_PATH", "Comment id contains invalid encoding");
|
|
2680
|
-
}
|
|
2681
|
-
if (commentId.length === 0 || commentId.length > 128) {
|
|
2682
|
-
throw new ApiError(400, "INVALID_PATH", "Comment id is invalid");
|
|
2683
|
-
}
|
|
2684
|
-
if (request.method === "GET") {
|
|
2685
|
-
const after = parseAfterCursor(url.searchParams, "Attachment routes");
|
|
2686
|
-
const attachments = database.listCommentAttachments(commentId, after);
|
|
2687
|
-
return sendJson(response, 200, {
|
|
2688
|
-
attachments,
|
|
2689
|
-
nextCursor: nextCursor(attachments, after),
|
|
2690
|
-
});
|
|
2691
|
-
}
|
|
2692
|
-
if ([...url.searchParams.keys()].length > 0) {
|
|
2693
|
-
throw new ApiError(400, "UNKNOWN_QUERY_PARAMETER", "Attachment routes do not accept query parameters");
|
|
2694
|
-
}
|
|
2695
|
-
if (request.method === "POST") {
|
|
2696
|
-
const comment = database.getComment(commentId);
|
|
2697
|
-
if (!comment) throw new ApiError(404, "COMMENT_NOT_FOUND", `Comment '${commentId}' does not exist`);
|
|
2698
|
-
const metadata = parseAttachmentHeaders(request);
|
|
2699
|
-
const body = await readBody(request, ATTACHMENT_BODY_LIMIT, "Attachment cannot exceed 25 MiB");
|
|
2700
|
-
const id = randomUUID();
|
|
2701
|
-
await mkdir(resolved.attachmentsDirectory, { recursive: true });
|
|
2702
|
-
const storagePath = path.join(resolved.attachmentsDirectory, id);
|
|
2703
|
-
await writeFile(storagePath, body, { flag: "wx" });
|
|
2704
|
-
let attachment;
|
|
2705
|
-
try {
|
|
2706
|
-
attachment = database.createCommentAttachment(commentId, { id, ...metadata, size: body.length });
|
|
2707
|
-
} catch (error) {
|
|
2708
|
-
await unlink(storagePath);
|
|
2709
|
-
throw error;
|
|
2710
|
-
}
|
|
2711
|
-
const task = database.getTask(comment.taskId);
|
|
2712
|
-
events.emit("attachment.created", { attachment, comment: database.getComment(commentId), task });
|
|
2713
|
-
return sendJson(response, 201, { attachment });
|
|
2714
|
-
}
|
|
2715
|
-
return methodNotAllowed(response, ["GET", "POST"]);
|
|
2716
|
-
}
|
|
2717
|
-
|
|
2718
|
-
const taskAttachmentsRoute = pathname.match(/^\/api\/tasks\/([^/]+)\/attachments$/);
|
|
2719
|
-
if (taskAttachmentsRoute) {
|
|
2720
|
-
let taskId;
|
|
2721
|
-
try {
|
|
2722
|
-
taskId = decodeURIComponent(taskAttachmentsRoute[1]);
|
|
2723
|
-
} catch {
|
|
2724
|
-
throw new ApiError(400, "INVALID_PATH", "Task id contains invalid encoding");
|
|
2725
|
-
}
|
|
2726
|
-
if (taskId.length === 0 || taskId.length > 128) {
|
|
2727
|
-
throw new ApiError(400, "INVALID_PATH", "Task id is invalid");
|
|
2728
|
-
}
|
|
2729
|
-
if (request.method === "GET") {
|
|
2730
|
-
const after = parseAfterCursor(url.searchParams, "Attachment routes");
|
|
2731
|
-
const attachments = database.listAttachments(taskId, after);
|
|
2732
|
-
return sendJson(response, 200, {
|
|
2733
|
-
attachments,
|
|
2734
|
-
nextCursor: nextCursor(attachments, after),
|
|
2735
|
-
});
|
|
2736
|
-
}
|
|
2737
|
-
if ([...url.searchParams.keys()].length > 0) {
|
|
2738
|
-
throw new ApiError(400, "UNKNOWN_QUERY_PARAMETER", "Attachment routes do not accept query parameters");
|
|
2739
|
-
}
|
|
2740
|
-
if (request.method === "POST") {
|
|
2741
|
-
const task = database.getTask(taskId);
|
|
2742
|
-
if (!task) throw new ApiError(404, "TASK_NOT_FOUND", `Task '${taskId}' does not exist`);
|
|
2743
|
-
const metadata = parseAttachmentHeaders(request);
|
|
2744
|
-
const body = await readBody(request, ATTACHMENT_BODY_LIMIT, "Attachment cannot exceed 25 MiB");
|
|
2745
|
-
const id = randomUUID();
|
|
2746
|
-
await mkdir(resolved.attachmentsDirectory, { recursive: true });
|
|
2747
|
-
const storagePath = path.join(resolved.attachmentsDirectory, id);
|
|
2748
|
-
await writeFile(storagePath, body, { flag: "wx" });
|
|
2749
|
-
let attachment;
|
|
2750
|
-
try {
|
|
2751
|
-
attachment = database.createAttachment(taskId, { id, ...metadata, size: body.length });
|
|
2752
|
-
} catch (error) {
|
|
2753
|
-
await unlink(storagePath);
|
|
2754
|
-
throw error;
|
|
2755
|
-
}
|
|
2756
|
-
events.emit("attachment.created", { attachment, task });
|
|
2757
|
-
return sendJson(response, 201, { attachment });
|
|
2758
|
-
}
|
|
2759
|
-
return methodNotAllowed(response, ["GET", "POST"]);
|
|
2760
|
-
}
|
|
2761
|
-
|
|
2762
|
-
const attachmentContentRoute = pathname.match(/^\/api\/attachments\/([^/]+)\/(content|download)$/);
|
|
2763
|
-
if (attachmentContentRoute) {
|
|
2764
|
-
let id;
|
|
2765
|
-
try {
|
|
2766
|
-
id = decodeURIComponent(attachmentContentRoute[1]);
|
|
2767
|
-
} catch {
|
|
2768
|
-
throw new ApiError(400, "INVALID_PATH", "Attachment id contains invalid encoding");
|
|
2769
|
-
}
|
|
2770
|
-
if (id.length === 0 || id.length > 128) {
|
|
2771
|
-
throw new ApiError(400, "INVALID_PATH", "Attachment id is invalid");
|
|
2772
|
-
}
|
|
2773
|
-
if ([...url.searchParams.keys()].length > 0) {
|
|
2774
|
-
throw new ApiError(400, "UNKNOWN_QUERY_PARAMETER", "Attachment routes do not accept query parameters");
|
|
2775
|
-
}
|
|
2776
|
-
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
2777
|
-
return methodNotAllowed(response, ["GET", "HEAD"]);
|
|
2778
|
-
}
|
|
2779
|
-
const attachment = database.getAttachment(id) ?? database.getProjectReadmeAttachment(id);
|
|
2780
|
-
if (!attachment) throw new ApiError(404, "ATTACHMENT_NOT_FOUND", `Attachment '${id}' does not exist`);
|
|
2781
|
-
const body = await readFile(path.join(resolved.attachmentsDirectory, attachment.id));
|
|
2782
|
-
const encodedFilename = encodeURIComponent(attachment.filename).replace(/['()*]/g, (character) => (
|
|
2783
|
-
`%${character.charCodeAt(0).toString(16).toUpperCase()}`
|
|
2784
|
-
));
|
|
2785
|
-
const canOpenInline = attachmentContentRoute[2] === "content"
|
|
2786
|
-
&& (
|
|
2787
|
-
INLINE_ATTACHMENT_TYPES.has(attachment.contentType)
|
|
2788
|
-
|| attachment.contentType.startsWith("video/")
|
|
2789
|
-
);
|
|
2790
|
-
response.writeHead(200, {
|
|
2791
|
-
"cache-control": "private, no-store",
|
|
2792
|
-
"content-disposition": `${canOpenInline ? "inline" : "attachment"}; filename*=UTF-8''${encodedFilename}`,
|
|
2793
|
-
"content-length": body.length,
|
|
2794
|
-
"content-security-policy": "sandbox; default-src 'none'",
|
|
2795
|
-
"content-type": canOpenInline ? attachment.contentType : "application/octet-stream",
|
|
2796
|
-
});
|
|
2797
|
-
response.end(request.method === "HEAD" ? undefined : body);
|
|
2798
|
-
return;
|
|
2799
|
-
}
|
|
2800
|
-
|
|
2801
|
-
const attachmentRoute = pathname.match(/^\/api\/attachments\/([^/]+)$/);
|
|
2802
|
-
if (attachmentRoute) {
|
|
2803
|
-
let id;
|
|
2804
|
-
try {
|
|
2805
|
-
id = decodeURIComponent(attachmentRoute[1]);
|
|
2806
|
-
} catch {
|
|
2807
|
-
throw new ApiError(400, "INVALID_PATH", "Attachment id contains invalid encoding");
|
|
2808
|
-
}
|
|
2809
|
-
if (id.length === 0 || id.length > 128) {
|
|
2810
|
-
throw new ApiError(400, "INVALID_PATH", "Attachment id is invalid");
|
|
2811
|
-
}
|
|
2812
|
-
if ([...url.searchParams.keys()].length > 0) {
|
|
2813
|
-
throw new ApiError(400, "UNKNOWN_QUERY_PARAMETER", "Attachment routes do not accept query parameters");
|
|
2814
|
-
}
|
|
2815
|
-
if (request.method !== "DELETE") return methodNotAllowed(response, ["DELETE"]);
|
|
2816
|
-
const attachment = database.getAttachment(id);
|
|
2817
|
-
if (!attachment) throw new ApiError(404, "ATTACHMENT_NOT_FOUND", `Attachment '${id}' does not exist`);
|
|
2818
|
-
try {
|
|
2819
|
-
await unlink(path.join(resolved.attachmentsDirectory, attachment.id));
|
|
2820
|
-
} catch (error) {
|
|
2821
|
-
if (error.code !== "ENOENT") throw error;
|
|
2822
|
-
}
|
|
2823
|
-
database.deleteAttachment(id);
|
|
2824
|
-
const task = database.getTask(attachment.taskId);
|
|
2825
|
-
events.emit("attachment.deleted", { attachment, task });
|
|
2826
|
-
return sendEmpty(response, 204);
|
|
2827
|
-
}
|
|
2828
|
-
|
|
2829
|
-
const taskTreeRoute = pathname.match(/^\/api\/tasks\/([^/]+)\/tree$/);
|
|
2830
|
-
if (taskTreeRoute) {
|
|
2831
|
-
let id;
|
|
2832
|
-
try {
|
|
2833
|
-
id = decodeURIComponent(taskTreeRoute[1]);
|
|
2834
|
-
} catch {
|
|
2835
|
-
throw new ApiError(400, "INVALID_PATH", "Task id contains invalid encoding");
|
|
2836
|
-
}
|
|
2837
|
-
if (id.length === 0 || id.length > 128) {
|
|
2838
|
-
throw new ApiError(400, "INVALID_PATH", "Task id is invalid");
|
|
2839
|
-
}
|
|
2840
|
-
if (request.method !== "GET") return methodNotAllowed(response, ["GET"]);
|
|
2841
|
-
const { direction, depth } = parseTaskTreeQuery(url.searchParams);
|
|
2842
|
-
return sendJson(response, 200, { tree: database.getTaskTree(id, direction, depth) });
|
|
2843
|
-
}
|
|
2844
|
-
|
|
2845
|
-
const taskRoute = pathname.match(/^\/api\/tasks\/([^/]+)(?:\/(archive|restore|move))?$/);
|
|
2846
|
-
if (taskRoute) {
|
|
2847
|
-
let id;
|
|
2848
|
-
try {
|
|
2849
|
-
id = decodeURIComponent(taskRoute[1]);
|
|
2850
|
-
} catch {
|
|
2851
|
-
throw new ApiError(400, "INVALID_PATH", "Task id contains invalid encoding");
|
|
2852
|
-
}
|
|
2853
|
-
if (id.length === 0 || id.length > 128) {
|
|
2854
|
-
throw new ApiError(400, "INVALID_PATH", "Task id is invalid");
|
|
2855
|
-
}
|
|
2856
|
-
const action = taskRoute[2];
|
|
2857
|
-
if (!action && request.method === "GET") {
|
|
2858
|
-
if ([...url.searchParams.keys()].length > 0) {
|
|
2859
|
-
throw new ApiError(400, "UNKNOWN_QUERY_PARAMETER", "GET /api/tasks/:id does not accept query parameters");
|
|
2860
|
-
}
|
|
2861
|
-
const task = database.getTask(id);
|
|
2862
|
-
if (!task) throw new ApiError(404, "TASK_NOT_FOUND", `Task '${id}' does not exist`);
|
|
2863
|
-
return sendJson(response, 200, { task });
|
|
2864
|
-
}
|
|
2865
|
-
if (!action && request.method === "PATCH") {
|
|
2866
|
-
const actor = actorFromRequest(request);
|
|
2867
|
-
const {
|
|
2868
|
-
version,
|
|
2869
|
-
changes,
|
|
2870
|
-
threadId,
|
|
2871
|
-
threadBinding,
|
|
2872
|
-
assigneeTarget,
|
|
2873
|
-
} = resolveInputThreadBinding(parseTaskPatch(await readJson(request), parseDevelopmentContext));
|
|
2874
|
-
const source = database.getTaskSource(id);
|
|
2875
|
-
if (!source) throw new ApiError(404, "TASK_NOT_FOUND", `Task '${id}' does not exist`);
|
|
2876
|
-
let jiraChanged = false;
|
|
2877
|
-
if (source !== "jira" && changes.projectId === JIRA_PROJECT_ID) {
|
|
2878
|
-
throw new ApiError(
|
|
2879
|
-
409,
|
|
2880
|
-
"JIRA_PROJECT_MOVE_UNAVAILABLE",
|
|
2881
|
-
"本地任务不能移入 Jira 同步项目",
|
|
2882
|
-
);
|
|
2883
|
-
}
|
|
2884
|
-
if (source === "jira") {
|
|
2885
|
-
const current = database.getTask(id);
|
|
2886
|
-
if (current.version !== version) {
|
|
2887
|
-
throw new ApiError(409, "VERSION_CONFLICT", "Task changed since it was last read", {
|
|
2888
|
-
expectedVersion: version,
|
|
2889
|
-
actualVersion: current.version,
|
|
2890
|
-
});
|
|
2891
|
-
}
|
|
2892
|
-
if (current.archivedAt !== null) {
|
|
2893
|
-
throw new ApiError(409, "TASK_ARCHIVED", "Archived tasks cannot be updated");
|
|
2894
|
-
}
|
|
2895
|
-
if (Object.hasOwn(changes, "projectId")) {
|
|
2896
|
-
throw new ApiError(409, "JIRA_PROJECT_MOVE_UNAVAILABLE", "Jira 任务不能移到本地项目");
|
|
2897
|
-
}
|
|
2898
|
-
if (assigneeTarget !== undefined) {
|
|
2899
|
-
throw new ApiError(409, "JIRA_ASSIGNEE_UNAVAILABLE", "请在 Jira 中修改经办人");
|
|
2900
|
-
}
|
|
2901
|
-
const dueDate = Object.hasOwn(changes, "dueDate") ? changes.dueDate : current.dueDate;
|
|
2902
|
-
const recurrence = Object.hasOwn(changes, "recurrence")
|
|
2903
|
-
? changes.recurrence
|
|
2904
|
-
: current.recurrence;
|
|
2905
|
-
if (recurrence && !dueDate) {
|
|
2906
|
-
throw new ApiError(400, "INVALID_FIELD", "A recurring issue requires a due date");
|
|
2907
|
-
}
|
|
2908
|
-
jiraChanged = await jira.updateTask(current, changes);
|
|
2909
|
-
}
|
|
2910
|
-
if (assigneeTarget !== undefined) {
|
|
2911
|
-
changes.assignee = resolveAssignee(assigneeTarget, actor);
|
|
2912
|
-
}
|
|
2913
|
-
let task;
|
|
2914
|
-
try {
|
|
2915
|
-
task = database.updateTask(id, version, changes, threadId, threadBinding, actor);
|
|
2916
|
-
} catch (error) {
|
|
2917
|
-
if (jiraChanged) {
|
|
2918
|
-
try {
|
|
2919
|
-
await jira.reconcile();
|
|
2920
|
-
} catch {
|
|
2921
|
-
throw new ApiError(
|
|
2922
|
-
502,
|
|
2923
|
-
"JIRA_RECONCILE_FAILED",
|
|
2924
|
-
"Jira 已更新,但 Taskboard 重新同步失败,请手动同步",
|
|
2925
|
-
);
|
|
2926
|
-
}
|
|
2927
|
-
}
|
|
2928
|
-
throw error;
|
|
2929
|
-
}
|
|
2930
|
-
events.emit("task.updated", { task });
|
|
2931
|
-
return sendJson(response, 200, { task });
|
|
2932
|
-
}
|
|
2933
|
-
if (!action && request.method === "DELETE") {
|
|
2934
|
-
const current = database.getTask(id);
|
|
2935
|
-
if (current?.source === "jira") {
|
|
2936
|
-
throw new ApiError(409, "JIRA_DELETE_UNAVAILABLE", "Jira 任务不能从 Taskboard 永久删除");
|
|
2937
|
-
}
|
|
2938
|
-
const { version } = parseVersionMutation(await readJson(request));
|
|
2939
|
-
const deleted = database.deleteArchivedTask(id, version);
|
|
2940
|
-
for (const attachmentId of deleted.attachmentIds) {
|
|
2941
|
-
try {
|
|
2942
|
-
await unlink(path.join(resolved.attachmentsDirectory, attachmentId));
|
|
2943
|
-
} catch (error) {
|
|
2944
|
-
if (error.code !== "ENOENT") throw error;
|
|
2945
|
-
}
|
|
2946
|
-
}
|
|
2947
|
-
events.emit("task.deleted", { task: deleted.task });
|
|
2948
|
-
return sendEmpty(response, 204);
|
|
2949
|
-
}
|
|
2950
|
-
if (action === "move" && request.method === "POST") {
|
|
2951
|
-
const move = resolveInputThreadBinding(parseMove(await readJson(request)));
|
|
2952
|
-
const current = database.getTask(id);
|
|
2953
|
-
if (!current) throw new ApiError(404, "TASK_NOT_FOUND", `Task '${id}' does not exist`);
|
|
2954
|
-
if (current.source === "jira") {
|
|
2955
|
-
if (current.version !== move.version) {
|
|
2956
|
-
throw new ApiError(409, "VERSION_CONFLICT", "Task changed since it was last read", {
|
|
2957
|
-
expectedVersion: move.version,
|
|
2958
|
-
actualVersion: current.version,
|
|
2959
|
-
});
|
|
2960
|
-
}
|
|
2961
|
-
if (current.archivedAt !== null) {
|
|
2962
|
-
throw new ApiError(409, "TASK_ARCHIVED", "Archived tasks cannot be moved");
|
|
2963
|
-
}
|
|
2964
|
-
await jira.moveTask(current, move.status);
|
|
2965
|
-
}
|
|
2966
|
-
const task = database.moveTask(
|
|
2967
|
-
id,
|
|
2968
|
-
move.version,
|
|
2969
|
-
move.status,
|
|
2970
|
-
move.sortOrder,
|
|
2971
|
-
move.threadId,
|
|
2972
|
-
move.threadBinding,
|
|
2973
|
-
actorFromRequest(request),
|
|
2974
|
-
);
|
|
2975
|
-
events.emit("task.moved", { task });
|
|
2976
|
-
return sendJson(response, 200, { task });
|
|
2977
|
-
}
|
|
2978
|
-
if (action === "archive" && request.method === "POST") {
|
|
2979
|
-
const current = database.getTask(id);
|
|
2980
|
-
if (current?.source === "jira") {
|
|
2981
|
-
throw new ApiError(409, "JIRA_ARCHIVE_UNAVAILABLE", "Jira 任务由同步范围自动管理,不能手动归档");
|
|
2982
|
-
}
|
|
2983
|
-
const { version, threadId, threadBinding } = resolveInputThreadBinding(
|
|
2984
|
-
parseVersionMutation(await readJson(request)),
|
|
2985
|
-
);
|
|
2986
|
-
const task = database.archiveTask(
|
|
2987
|
-
id,
|
|
2988
|
-
version,
|
|
2989
|
-
threadId,
|
|
2990
|
-
threadBinding,
|
|
2991
|
-
actorFromRequest(request),
|
|
2992
|
-
);
|
|
2993
|
-
events.emit("task.archived", { task });
|
|
2994
|
-
return sendJson(response, 200, { task });
|
|
2995
|
-
}
|
|
2996
|
-
if (action === "restore" && request.method === "POST") {
|
|
2997
|
-
const current = database.getTask(id);
|
|
2998
|
-
if (current?.source === "jira") {
|
|
2999
|
-
throw new ApiError(409, "JIRA_RESTORE_UNAVAILABLE", "Jira 任务由同步范围自动管理,不能手动恢复");
|
|
3000
|
-
}
|
|
3001
|
-
const { version, threadId, threadBinding } = resolveInputThreadBinding(
|
|
3002
|
-
parseVersionMutation(await readJson(request)),
|
|
3003
|
-
);
|
|
3004
|
-
const task = database.restoreTask(
|
|
3005
|
-
id,
|
|
3006
|
-
version,
|
|
3007
|
-
threadId,
|
|
3008
|
-
threadBinding,
|
|
3009
|
-
actorFromRequest(request),
|
|
3010
|
-
);
|
|
3011
|
-
events.emit("task.restored", { task });
|
|
3012
|
-
return sendJson(response, 200, { task });
|
|
3013
|
-
}
|
|
3014
|
-
return methodNotAllowed(response, action ? ["POST"] : ["GET", "PATCH", "DELETE"]);
|
|
3015
|
-
}
|
|
3016
|
-
|
|
3017
|
-
if (pathname.startsWith("/api/")) {
|
|
3018
|
-
throw new ApiError(404, "NOT_FOUND", "API route not found");
|
|
3019
|
-
}
|
|
3020
|
-
if (await serveStatic(request, response, pathname, resolved.staticDirectory)) return;
|
|
3021
|
-
throw new ApiError(404, "NOT_FOUND", "Resource not found");
|
|
3022
|
-
} catch (error) {
|
|
3023
|
-
if (response.headersSent) {
|
|
3024
|
-
response.destroy(error);
|
|
3025
|
-
return;
|
|
3026
|
-
}
|
|
3027
|
-
if (error instanceof ApiError) {
|
|
3028
|
-
const payload = { error: { code: error.code, message: error.message } };
|
|
3029
|
-
if (error.details !== undefined) payload.error.details = error.details;
|
|
3030
|
-
sendJson(response, error.status, payload);
|
|
3031
|
-
return;
|
|
3032
|
-
}
|
|
3033
|
-
if (error instanceof CloudProxyError) {
|
|
3034
|
-
const payload = { error: { code: error.code, message: error.message } };
|
|
3035
|
-
if (error.details !== undefined) payload.error.details = error.details;
|
|
3036
|
-
sendJson(response, error.status, payload);
|
|
3037
|
-
return;
|
|
3038
|
-
}
|
|
3039
|
-
console.error(error);
|
|
3040
|
-
sendJson(response, 500, { error: { code: "INTERNAL_ERROR", message: "Internal server error" } });
|
|
3041
|
-
}
|
|
3042
|
-
});
|
|
3043
|
-
|
|
3044
|
-
const cloudRealtimeServer = new WebSocketServer({ noServer: true });
|
|
3045
|
-
const cloudRealtimeSockets = new Set();
|
|
3046
|
-
|
|
3047
|
-
function rejectWebSocketUpgrade(socket, status, message) {
|
|
3048
|
-
const body = `${message}\n`;
|
|
3049
|
-
socket.end([
|
|
3050
|
-
`HTTP/1.1 ${status} ${message}`,
|
|
3051
|
-
"Connection: close",
|
|
3052
|
-
"Content-Type: text/plain; charset=utf-8",
|
|
3053
|
-
`Content-Length: ${Buffer.byteLength(body)}`,
|
|
3054
|
-
"",
|
|
3055
|
-
body,
|
|
3056
|
-
].join("\r\n"));
|
|
3057
|
-
}
|
|
3058
|
-
|
|
3059
|
-
function closeOrTerminateWebSocket(webSocket, code, reason) {
|
|
3060
|
-
if (webSocket.readyState !== WebSocketClient.OPEN) {
|
|
3061
|
-
webSocket.terminate();
|
|
3062
|
-
return;
|
|
3063
|
-
}
|
|
3064
|
-
if (code >= 1000 && ![1004, 1005, 1006, 1015].includes(code)) {
|
|
3065
|
-
webSocket.close(code, reason);
|
|
3066
|
-
} else {
|
|
3067
|
-
webSocket.terminate();
|
|
3068
|
-
}
|
|
3069
|
-
}
|
|
3070
|
-
|
|
3071
|
-
server.on("upgrade", async (request, socket, head) => {
|
|
3072
|
-
let remoteSocket;
|
|
3073
|
-
try {
|
|
3074
|
-
const incomingUrl = new URL(request.url, "http://127.0.0.1");
|
|
3075
|
-
if (resolved.instanceToken) {
|
|
3076
|
-
if (!incomingUrl.pathname.startsWith(`${routePrefix}/`)) {
|
|
3077
|
-
rejectWebSocketUpgrade(socket, 404, "Not Found");
|
|
3078
|
-
return;
|
|
3079
|
-
}
|
|
3080
|
-
request.url = `${incomingUrl.pathname.slice(routePrefix.length) || "/"}${incomingUrl.search}`;
|
|
3081
|
-
}
|
|
3082
|
-
assertTrustedNetworkRequest(
|
|
3083
|
-
request,
|
|
3084
|
-
Boolean(resolved.instanceToken),
|
|
3085
|
-
resolved.trustedOrigins,
|
|
3086
|
-
);
|
|
3087
|
-
const url = new URL(request.url, "http://127.0.0.1");
|
|
3088
|
-
if (url.pathname !== "/api/events" || [...url.searchParams.keys()].length > 0) {
|
|
3089
|
-
rejectWebSocketUpgrade(socket, 404, "Not Found");
|
|
3090
|
-
return;
|
|
3091
|
-
}
|
|
3092
|
-
assertLoopbackRequest(request);
|
|
3093
|
-
const target = await cloudProxy.webSocketTarget("/api/events");
|
|
3094
|
-
remoteSocket = new WebSocketClient(target.url, { headers: target.headers });
|
|
3095
|
-
const pendingMessages = [];
|
|
3096
|
-
const queueMessage = (data, isBinary) => pendingMessages.push({ data, isBinary });
|
|
3097
|
-
remoteSocket.on("message", queueMessage);
|
|
3098
|
-
await new Promise((resolve, reject) => {
|
|
3099
|
-
const cleanup = () => {
|
|
3100
|
-
remoteSocket.off("open", onOpen);
|
|
3101
|
-
remoteSocket.off("error", onError);
|
|
3102
|
-
remoteSocket.off("close", onClose);
|
|
3103
|
-
};
|
|
3104
|
-
const onOpen = () => {
|
|
3105
|
-
cleanup();
|
|
3106
|
-
resolve();
|
|
3107
|
-
};
|
|
3108
|
-
const onError = (error) => {
|
|
3109
|
-
cleanup();
|
|
3110
|
-
reject(error);
|
|
3111
|
-
};
|
|
3112
|
-
const onClose = () => {
|
|
3113
|
-
cleanup();
|
|
3114
|
-
reject(new Error("Cloud realtime connection closed before opening"));
|
|
3115
|
-
};
|
|
3116
|
-
remoteSocket.once("open", onOpen);
|
|
3117
|
-
remoteSocket.once("error", onError);
|
|
3118
|
-
remoteSocket.once("close", onClose);
|
|
3119
|
-
});
|
|
3120
|
-
cloudRealtimeServer.handleUpgrade(request, socket, head, (localSocket) => {
|
|
3121
|
-
const pair = { localSocket, remoteSocket };
|
|
3122
|
-
cloudRealtimeSockets.add(pair);
|
|
3123
|
-
const removePair = () => cloudRealtimeSockets.delete(pair);
|
|
3124
|
-
const forwardMessage = (data, isBinary) => {
|
|
3125
|
-
if (localSocket.readyState === WebSocketClient.OPEN) {
|
|
3126
|
-
localSocket.send(data, { binary: isBinary });
|
|
3127
|
-
}
|
|
3128
|
-
};
|
|
3129
|
-
|
|
3130
|
-
remoteSocket.off("message", queueMessage);
|
|
3131
|
-
remoteSocket.on("message", forwardMessage);
|
|
3132
|
-
for (const { data, isBinary } of pendingMessages) forwardMessage(data, isBinary);
|
|
3133
|
-
|
|
3134
|
-
localSocket.on("message", () => {
|
|
3135
|
-
localSocket.close(1008, "Client messages are not supported");
|
|
3136
|
-
});
|
|
3137
|
-
localSocket.on("close", (code, reason) => {
|
|
3138
|
-
removePair();
|
|
3139
|
-
closeOrTerminateWebSocket(remoteSocket, code, reason);
|
|
3140
|
-
});
|
|
3141
|
-
localSocket.on("error", () => remoteSocket.terminate());
|
|
3142
|
-
|
|
3143
|
-
remoteSocket.on("close", (code, reason) => {
|
|
3144
|
-
removePair();
|
|
3145
|
-
closeOrTerminateWebSocket(localSocket, code, reason);
|
|
3146
|
-
});
|
|
3147
|
-
remoteSocket.on("error", () => {
|
|
3148
|
-
if (localSocket.readyState === WebSocketClient.OPEN) {
|
|
3149
|
-
localSocket.close(1011, "Cloud realtime connection failed");
|
|
3150
|
-
}
|
|
3151
|
-
});
|
|
3152
|
-
});
|
|
3153
|
-
} catch (error) {
|
|
3154
|
-
remoteSocket?.terminate();
|
|
3155
|
-
rejectWebSocketUpgrade(socket, error?.status ?? 502, "WebSocket connection failed");
|
|
3156
|
-
}
|
|
3157
|
-
});
|
|
3158
|
-
|
|
3159
|
-
let listening = false;
|
|
3160
|
-
return {
|
|
3161
|
-
database,
|
|
3162
|
-
aiChat,
|
|
3163
|
-
server,
|
|
3164
|
-
options: resolved,
|
|
3165
|
-
async listen({ host = "127.0.0.1", port = resolvePort(), fd = null } = {}) {
|
|
3166
|
-
if (host !== "127.0.0.1" && host !== "0.0.0.0") {
|
|
3167
|
-
throw new Error("Taskboard server must bind to 127.0.0.1 or 0.0.0.0");
|
|
3168
|
-
}
|
|
3169
|
-
if (fd !== null && (!Number.isInteger(fd) || fd < 3 || fd > 255)) {
|
|
3170
|
-
throw new Error("Taskboard server listen fd must be an inherited file descriptor");
|
|
3171
|
-
}
|
|
3172
|
-
await new Promise((resolve, reject) => {
|
|
3173
|
-
const onError = (error) => {
|
|
3174
|
-
server.off("listening", onListening);
|
|
3175
|
-
reject(error);
|
|
3176
|
-
};
|
|
3177
|
-
const onListening = () => {
|
|
3178
|
-
server.off("error", onError);
|
|
3179
|
-
resolve();
|
|
3180
|
-
};
|
|
3181
|
-
server.once("error", onError);
|
|
3182
|
-
server.once("listening", onListening);
|
|
3183
|
-
if (fd === null) server.listen(port, host);
|
|
3184
|
-
else server.listen({ fd });
|
|
3185
|
-
});
|
|
3186
|
-
listening = true;
|
|
3187
|
-
return server.address();
|
|
3188
|
-
},
|
|
3189
|
-
async close() {
|
|
3190
|
-
for (const { localSocket, remoteSocket } of cloudRealtimeSockets) {
|
|
3191
|
-
localSocket.terminate();
|
|
3192
|
-
remoteSocket.terminate();
|
|
3193
|
-
}
|
|
3194
|
-
cloudRealtimeSockets.clear();
|
|
3195
|
-
cloudRealtimeServer.close();
|
|
3196
|
-
const serverClosed = listening
|
|
3197
|
-
? new Promise((resolve, reject) => {
|
|
3198
|
-
server.close((error) => error ? reject(error) : resolve());
|
|
3199
|
-
})
|
|
3200
|
-
: Promise.resolve();
|
|
3201
|
-
events.close();
|
|
3202
|
-
for (const response of aiEventResponses) response.end();
|
|
3203
|
-
aiEventResponses.clear();
|
|
3204
|
-
await aiChat.close();
|
|
3205
|
-
await projectSummary.close();
|
|
3206
|
-
await serverClosed;
|
|
3207
|
-
listening = false;
|
|
3208
|
-
database.close();
|
|
3209
|
-
},
|
|
3210
|
-
};
|
|
3211
|
-
}
|