@polderlabs/openkan 0.4.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/CHANGELOG.md +226 -0
- package/LICENSE +21 -0
- package/README.md +318 -0
- package/agents/openkan.md +254 -0
- package/bin/install-agent.mjs +63 -0
- package/bin/ok.mjs +17 -0
- package/bin/openkan.mjs +10 -0
- package/dist/.claude/skills/ok-planning/SKILL.md +285 -0
- package/dist/.claude/skills/ok-planning/references/integration.md +153 -0
- package/dist/.claude/skills/ok-planning/references/schemas.md +270 -0
- package/dist/.claude/skills/ok-planning/references/workflows.md +185 -0
- package/dist/.claude/skills/ok-planning/scripts/ok-init.sh +14 -0
- package/dist/.claude/skills/ok-planning/scripts/ok-resume.sh +38 -0
- package/dist/.claude/skills/ok-planning/scripts/ok-status.sh +24 -0
- package/dist/agents/openkan.md +254 -0
- package/dist/bin/install-agent.mjs +76 -0
- package/dist/bin/ok-install.js +58 -0
- package/dist/bin/ok.js +138 -0
- package/dist/bin/openkan.js +804 -0
- package/dist/commands/organize.md +15 -0
- package/dist/kanban/agent-profile.js +8 -0
- package/dist/kanban/archive.js +49 -0
- package/dist/kanban/bizar.js +242 -0
- package/dist/kanban/board.js +367 -0
- package/dist/kanban/bulk.js +139 -0
- package/dist/kanban/changelog.js +186 -0
- package/dist/kanban/chat.js +1280 -0
- package/dist/kanban/claude-state.js +974 -0
- package/dist/kanban/comments.js +80 -0
- package/dist/kanban/docs.js +144 -0
- package/dist/kanban/fs.js +163 -0
- package/dist/kanban/git.js +196 -0
- package/dist/kanban/images.js +140 -0
- package/dist/kanban/import.js +295 -0
- package/dist/kanban/inputs.js +94 -0
- package/dist/kanban/insights.js +140 -0
- package/dist/kanban/io.js +75 -0
- package/dist/kanban/mdx-render.js +348 -0
- package/dist/kanban/mdx.js +231 -0
- package/dist/kanban/projects.js +545 -0
- package/dist/kanban/search.js +121 -0
- package/dist/kanban/server.js +3296 -0
- package/dist/kanban/tags.js +124 -0
- package/dist/kanban/template.js +145 -0
- package/dist/kanban/tsx-sandbox.js +187 -0
- package/dist/kanban/watcher.js +270 -0
- package/dist/ok/commands/goal.js +65 -0
- package/dist/ok/commands/index.js +87 -0
- package/dist/ok/commands/init.js +15 -0
- package/dist/ok/commands/plan.js +155 -0
- package/dist/ok/commands/prd.js +202 -0
- package/dist/ok/commands/progress.js +31 -0
- package/dist/ok/commands/task.js +377 -0
- package/dist/ok/ids.js +98 -0
- package/dist/ok/lock.js +156 -0
- package/dist/ok/migrate.js +197 -0
- package/dist/ok/schemas.js +402 -0
- package/dist/ok/storage.js +222 -0
- package/dist/skills/openkan/SKILL.md +111 -0
- package/dist/skills/openkan/agents/openai.yaml +4 -0
- package/dist/skills/openkan/examples/simple-task.mdx +34 -0
- package/dist/skills/openkan/examples/with-ask.mdx +32 -0
- package/dist/skills/openkan/examples/with-choice.mdx +51 -0
- package/dist/skills/openkan/examples/with-preview.mdx +54 -0
- package/dist/skills/openkan/references/api.md +169 -0
- package/dist/skills/openkan/templates/task.mdx +46 -0
- package/dist/web/api.js +257 -0
- package/dist/web/app.js +4251 -0
- package/dist/web/bizar.js +39 -0
- package/dist/web/brand/agent-activity-sprite.svg +1 -0
- package/dist/web/brand/banner-docs.svg +24 -0
- package/dist/web/brand/banner.svg +32 -0
- package/dist/web/brand/empty-sessions.svg +17 -0
- package/dist/web/brand/empty-tasks.svg +17 -0
- package/dist/web/brand/favicon.svg +9 -0
- package/dist/web/brand/infinity-loader-animated.svg +220 -0
- package/dist/web/brand/infinity-loader-spritesheet.svg +230 -0
- package/dist/web/brand/logo-wordmark.svg +10 -0
- package/dist/web/brand/logo.svg +9 -0
- package/dist/web/brand/pixel-infinity-track.svg +1 -0
- package/dist/web/brand/social-card.svg +26 -0
- package/dist/web/changelog-view.js +456 -0
- package/dist/web/charts.js +269 -0
- package/dist/web/chat-sidebar.js +2397 -0
- package/dist/web/chat-status-motion.js +154 -0
- package/dist/web/claude-pane.js +820 -0
- package/dist/web/command-palette.js +381 -0
- package/dist/web/contributors-view.js +317 -0
- package/dist/web/cross-tab.js +102 -0
- package/dist/web/docs-view.js +168 -0
- package/dist/web/experience.css +165 -0
- package/dist/web/goals-view.js +45 -0
- package/dist/web/home-view.js +113 -0
- package/dist/web/images.js +311 -0
- package/dist/web/index.html +485 -0
- package/dist/web/insights.js +217 -0
- package/dist/web/keyboard.js +446 -0
- package/dist/web/mdx-viewer.js +600 -0
- package/dist/web/path-picker.js +787 -0
- package/dist/web/preview-frame.html +187 -0
- package/dist/web/settings.js +582 -0
- package/dist/web/style.css +8545 -0
- package/dist/web/task-view.js +1759 -0
- package/dist/web/vendor/gsap.min.js +11 -0
- package/dist/web/workspace.css +1513 -0
- package/package.json +71 -0
- package/skills/openkan/SKILL.md +111 -0
- package/skills/openkan/agents/openai.yaml +4 -0
- package/skills/openkan/examples/simple-task.mdx +34 -0
- package/skills/openkan/examples/with-ask.mdx +32 -0
- package/skills/openkan/examples/with-choice.mdx +51 -0
- package/skills/openkan/examples/with-preview.mdx +54 -0
- package/skills/openkan/references/api.md +169 -0
- package/skills/openkan/templates/task.mdx +46 -0
|
@@ -0,0 +1,3296 @@
|
|
|
1
|
+
// OpenKan — HTTP API server.
|
|
2
|
+
import { readFileSync, existsSync, statSync, writeFileSync, unlinkSync, mkdirSync, rmSync, cpSync } from "fs";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { createServer } from "node:http";
|
|
5
|
+
import { Readable } from "node:stream";
|
|
6
|
+
import { join, extname, resolve } from "path";
|
|
7
|
+
import { openSync, closeSync } from "node:fs";
|
|
8
|
+
import { spawnSync } from "node:child_process";
|
|
9
|
+
import { DEFAULT_COLUMNS, withWrite, getBoard, renormalizeOrder, newId, nowIso, KANBAN_DIR, taskArtifacts, ensureBoardForProject, reconcileOkTask, } from "./board.js";
|
|
10
|
+
import { extractMetadata } from "./tags.js";
|
|
11
|
+
import { writeTaskMdx, writeBoardMdx, writeSessionMdx, extractDescription, statMdxMtime, } from "./mdx.js";
|
|
12
|
+
import { listInputs, getPendingInput, addInput, respondInput } from "./inputs.js";
|
|
13
|
+
import { listComments, addComment, deleteComment, resolveComment } from "./comments.js";
|
|
14
|
+
import { renderMdx, stripMdxFrontmatter } from "./mdx-render.js";
|
|
15
|
+
import { buildPreview } from "./tsx-sandbox.js";
|
|
16
|
+
import { writeFileAtomic, ensureDir, removeDir } from "./io.js";
|
|
17
|
+
import { recordEvent, readEvents, readSummary } from "./changelog.js";
|
|
18
|
+
import { computeVelocity } from "./insights.js";
|
|
19
|
+
import { listContributors, attributeCommitsToTasks, isGitRepo } from "./git.js";
|
|
20
|
+
import { archiveTask, restoreTask } from "./archive.js";
|
|
21
|
+
import { listProjects, activeProject, setActiveProject, addProject, removeProject, getActiveProjectRoot, autoDetectProjects, findProject, resolveKanbanDir, } from "./projects.js";
|
|
22
|
+
import { listDocs, readDoc } from "./docs.js";
|
|
23
|
+
import { saveImage, listImages, deleteImage, readImage } from "./images.js";
|
|
24
|
+
import { search } from "./search.js";
|
|
25
|
+
import { applyBulk } from "./bulk.js";
|
|
26
|
+
import { TASK_MDX_TEMPLATE, TEMPLATE_PARSE_HINTS } from "./template.js";
|
|
27
|
+
import { watch, sourcePathOfTask } from "./watcher.js";
|
|
28
|
+
import { listFs, readHome, parents, isDenyListed, realPathIfAllowed } from "./fs.js";
|
|
29
|
+
import { attachBizarWebSocket, executeBizarCommand, getBizarSnapshot, handleBizarRequest, } from "./bizar.js";
|
|
30
|
+
import * as claudeState from "./claude-state.js";
|
|
31
|
+
import { handleClaudeRequest } from "./claude-state.js";
|
|
32
|
+
import { validateAgentsConfig, } from "../ok/schemas.js";
|
|
33
|
+
import { handleChatRequest, sendTurn } from "./chat.js";
|
|
34
|
+
import { initIfMissing as initOkIfMissing, listPrds as listOkPrds, readPrd as readOkPrd, writePrd as writeOkPrd, rebuildIndex as rebuildOkIndex } from "../ok/storage.js";
|
|
35
|
+
import { WebSocketServer, WebSocket } from "ws";
|
|
36
|
+
import { runImport } from "./import.js";
|
|
37
|
+
// ─── Module-level server state ────────────────────────────────────────────────
|
|
38
|
+
let watcherHandle = null;
|
|
39
|
+
/** Timestamp up to which filesystem-change events should be suppressed (self-write guard). */
|
|
40
|
+
let selfWriteUntil = 0;
|
|
41
|
+
// ─── Self-write guard ─────────────────────────────────────────────────────────
|
|
42
|
+
/**
|
|
43
|
+
* Wrap a server-initiated write so the file-watcher ignores the resulting
|
|
44
|
+
* fs-watch events. The suppression window is 250 ms which covers the
|
|
45
|
+
* synchronous fs.watch delivery on Linux/macOS and any microtask deferral.
|
|
46
|
+
*/
|
|
47
|
+
async function suppressSelfWrite(fn) {
|
|
48
|
+
selfWriteUntil = Date.now() + 250;
|
|
49
|
+
try {
|
|
50
|
+
return await fn();
|
|
51
|
+
}
|
|
52
|
+
finally { /* keep flag active for 250 ms to flush queued watch events */ }
|
|
53
|
+
}
|
|
54
|
+
let runningServer = null;
|
|
55
|
+
let webRoot = null;
|
|
56
|
+
let bizarSocketBridge = null;
|
|
57
|
+
let claudeSocketBridge = null;
|
|
58
|
+
/**
|
|
59
|
+
* Read the current git user from local config (same logic as git.ts currentUser
|
|
60
|
+
* but inlined here so server.ts stays self-contained; kanban/git.ts is the
|
|
61
|
+
* canonical source of truth).
|
|
62
|
+
*/
|
|
63
|
+
function currentGitUser(cwd) {
|
|
64
|
+
try {
|
|
65
|
+
// Without `--local` / `--global` flags, `git config` reads the merged
|
|
66
|
+
// config: local repo wins, then global, then system. This way users with
|
|
67
|
+
// only a global `user.name` (very common on dev machines) are picked up
|
|
68
|
+
// too, instead of falling through to the literal "user" placeholder.
|
|
69
|
+
const name = spawnSync("git", ["config", "user.name"], { cwd, encoding: "utf-8" }).stdout?.trim();
|
|
70
|
+
const email = spawnSync("git", ["config", "user.email"], { cwd, encoding: "utf-8" }).stdout?.trim();
|
|
71
|
+
if (!name)
|
|
72
|
+
return null;
|
|
73
|
+
return { name, email: email ?? "" };
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// ─── Renderer ─────────────────────────────────────────────────────────────────
|
|
80
|
+
const ALLOWED_TAGS = [
|
|
81
|
+
"h1", "h2", "h3", "h4", "h5", "h6", "p", "ul", "ol", "li",
|
|
82
|
+
"code", "pre", "blockquote", "a", "strong", "em", "hr", "br",
|
|
83
|
+
"table", "thead", "tbody", "tr", "th", "td",
|
|
84
|
+
];
|
|
85
|
+
const ALLOWED_ATTRS = {
|
|
86
|
+
a: ["href", "title"],
|
|
87
|
+
code: ["class"],
|
|
88
|
+
};
|
|
89
|
+
function urlFilter(url) {
|
|
90
|
+
try {
|
|
91
|
+
const u = new URL(url);
|
|
92
|
+
if (u.protocol === "http:" || u.protocol === "https:" ||
|
|
93
|
+
u.protocol === "mailto:" || u.pathname.startsWith("/artifacts/"))
|
|
94
|
+
return true;
|
|
95
|
+
if (u.protocol === "data:")
|
|
96
|
+
return url.startsWith("data:image/");
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return !url.startsWith("javascript:") && !url.startsWith("data:");
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
async function renderMarkdown(raw) {
|
|
104
|
+
const { marked } = await import("marked");
|
|
105
|
+
const html = await marked(stripMdxFrontmatter(raw));
|
|
106
|
+
return html;
|
|
107
|
+
}
|
|
108
|
+
async function renderArtifact(markdownPath, rawFlag, theme) {
|
|
109
|
+
if (!existsSync(markdownPath))
|
|
110
|
+
throw new Error(`Artifact not found: ${markdownPath}`);
|
|
111
|
+
try {
|
|
112
|
+
if (!statSync(markdownPath).isFile())
|
|
113
|
+
throw new Error("not a file");
|
|
114
|
+
}
|
|
115
|
+
catch (e) {
|
|
116
|
+
throw new Error(`Artifact unavailable: ${markdownPath} (${e.message})`);
|
|
117
|
+
}
|
|
118
|
+
if (rawFlag)
|
|
119
|
+
return { body: readFileSync(markdownPath, "utf-8"), contentType: "text/markdown" };
|
|
120
|
+
const raw = readFileSync(markdownPath, "utf-8");
|
|
121
|
+
const html = await renderMarkdown(raw);
|
|
122
|
+
const sanitizeHtml = (await import("sanitize-html")).default;
|
|
123
|
+
const clean = sanitizeHtml(html, {
|
|
124
|
+
allowedTags: ALLOWED_TAGS,
|
|
125
|
+
allowedAttributes: ALLOWED_ATTRS,
|
|
126
|
+
});
|
|
127
|
+
return {
|
|
128
|
+
body: `<!DOCTYPE html>
|
|
129
|
+
<html>
|
|
130
|
+
<head>
|
|
131
|
+
<meta charset="utf-8">
|
|
132
|
+
<meta name="color-scheme" content="light dark">
|
|
133
|
+
<title>Kanban Artifact</title>
|
|
134
|
+
<script>
|
|
135
|
+
// Read parent window theme (same origin only: same 127.0.0.1:7777)
|
|
136
|
+
var theme = "${theme ?? ""}";
|
|
137
|
+
if (!theme && window.parent && window.parent.location) {
|
|
138
|
+
try {
|
|
139
|
+
var stored = window.parent.localStorage.getItem("openkan:theme");
|
|
140
|
+
if (stored) theme = stored;
|
|
141
|
+
} catch(e) { /* cross-origin, ignore */ }
|
|
142
|
+
}
|
|
143
|
+
if (!theme) theme = "system";
|
|
144
|
+
if (theme === "system") {
|
|
145
|
+
theme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
|
146
|
+
}
|
|
147
|
+
document.documentElement.dataset.theme = theme;
|
|
148
|
+
</script>
|
|
149
|
+
<style>
|
|
150
|
+
:root[data-theme="light"] {
|
|
151
|
+
--text: #111;
|
|
152
|
+
--text-dim: #555;
|
|
153
|
+
--bg: #fff;
|
|
154
|
+
--accent: #2563eb;
|
|
155
|
+
--rule: #e5e5e5;
|
|
156
|
+
--code-bg: #f4f4f4;
|
|
157
|
+
--row-alt: #f9fafb;
|
|
158
|
+
}
|
|
159
|
+
:root[data-theme="dark"] {
|
|
160
|
+
--text: #e5e5e5;
|
|
161
|
+
--text-dim: #9ca3af;
|
|
162
|
+
--bg: #111827;
|
|
163
|
+
--accent: #60a5fa;
|
|
164
|
+
--rule: #374151;
|
|
165
|
+
--code-bg: #1f2937;
|
|
166
|
+
--row-alt: #1f2937;
|
|
167
|
+
}
|
|
168
|
+
body {
|
|
169
|
+
font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
|
|
170
|
+
padding: 2rem;
|
|
171
|
+
max-width: 860px;
|
|
172
|
+
margin: 0 auto;
|
|
173
|
+
line-height: 1.6;
|
|
174
|
+
overflow-y: auto;
|
|
175
|
+
min-height: 100vh;
|
|
176
|
+
color: var(--text);
|
|
177
|
+
background: var(--bg);
|
|
178
|
+
}
|
|
179
|
+
body > :first-child { margin-top: 0; }
|
|
180
|
+
body h1, body h2, body h3, body h4, body h5, body h6 {
|
|
181
|
+
font-weight: 600;
|
|
182
|
+
line-height: 1.25;
|
|
183
|
+
margin-top: 1.5em;
|
|
184
|
+
margin-bottom: 0.5em;
|
|
185
|
+
}
|
|
186
|
+
body h1 { font-size: 1.875rem; border-bottom: 1px solid var(--rule); padding-bottom: 0.3em; }
|
|
187
|
+
body h2 { font-size: 1.5rem; border-bottom: 1px solid var(--rule); padding-bottom: 0.3em; }
|
|
188
|
+
body p { margin: 0 0 1em; }
|
|
189
|
+
body a { color: var(--accent); text-decoration: none; }
|
|
190
|
+
body a:hover { text-decoration: underline; }
|
|
191
|
+
body code { background: var(--code-bg); padding: 0.15em 0.4em; border-radius: 4px; font-size: 0.9em; }
|
|
192
|
+
body pre { background: var(--code-bg); padding: 1rem; border-radius: 8px; overflow-x: auto; line-height: 1.5; }
|
|
193
|
+
body pre code { padding: 0; background: transparent; }
|
|
194
|
+
body blockquote { border-left: 3px solid var(--accent); padding: 0.2em 1em; color: var(--text-dim); font-style: italic; margin: 0 0 1em; }
|
|
195
|
+
body ul, body ol { padding-left: 1.5em; margin: 0 0 1em; }
|
|
196
|
+
body table { border-collapse: collapse; width: 100%; margin: 0 0 1em; }
|
|
197
|
+
body th, body td { border: 1px solid var(--rule); padding: 0.5em 0.75em; text-align: left; }
|
|
198
|
+
body tr:nth-child(odd) { background: var(--row-alt); }
|
|
199
|
+
body img { max-width: 100%; border-radius: 8px; border: 1px solid var(--rule); }
|
|
200
|
+
.back-link { display: inline-block; margin-bottom: 1.5rem; font-size: 0.875rem; }
|
|
201
|
+
</style>
|
|
202
|
+
</head>
|
|
203
|
+
<body>
|
|
204
|
+
<a href="/" class="back-link">← Back to board</a>
|
|
205
|
+
${clean}
|
|
206
|
+
</body>
|
|
207
|
+
</html>`,
|
|
208
|
+
contentType: "text/html",
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
// ─── Session status cache ────────────────────────────────────────────────────
|
|
212
|
+
const sessionStatusCache = new Map();
|
|
213
|
+
// ─── SSE broadcaster ─────────────────────────────────────────────────────────
|
|
214
|
+
const sseControllers = new Set();
|
|
215
|
+
function broadcast(event, data) {
|
|
216
|
+
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
217
|
+
for (const ctrl of sseControllers) {
|
|
218
|
+
try {
|
|
219
|
+
ctrl.enqueue(new TextEncoder().encode(payload));
|
|
220
|
+
}
|
|
221
|
+
catch (_) { /* client gone */ }
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
// ─── Drift detection ───────────────────────────────────────────────────────────
|
|
225
|
+
/**
|
|
226
|
+
* Returns true if the source file for `task` has changed since import.
|
|
227
|
+
* A missing file is considered stale.
|
|
228
|
+
*/
|
|
229
|
+
async function checkSourceDrift(task, kanbanDir) {
|
|
230
|
+
if (!task.source)
|
|
231
|
+
return false;
|
|
232
|
+
const absPath = join(kanbanDir, "..", task.source.path);
|
|
233
|
+
if (!existsSync(absPath))
|
|
234
|
+
return true; // file gone = stale
|
|
235
|
+
let content;
|
|
236
|
+
try {
|
|
237
|
+
content = readFileSync(absPath, "utf-8");
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
const newHash = createHash("sha256").update(content).digest("hex").slice(0, 16);
|
|
243
|
+
return newHash !== task.sourceHash;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Re-check stale status for all tasks that have a source file.
|
|
247
|
+
* Updates the board and broadcasts task.updated events for any changed tasks.
|
|
248
|
+
* Debounced via the `sweepLock` promise chain.
|
|
249
|
+
*/
|
|
250
|
+
let _sweepLock = Promise.resolve();
|
|
251
|
+
async function sweepSourceDrift(kanbanDir) {
|
|
252
|
+
_sweepLock = _sweepLock.then(async () => {
|
|
253
|
+
const board = await getBoard();
|
|
254
|
+
const updates = [];
|
|
255
|
+
for (const task of board.tasks) {
|
|
256
|
+
if (!task.source)
|
|
257
|
+
continue;
|
|
258
|
+
const isStale = await checkSourceDrift(task, kanbanDir);
|
|
259
|
+
if (isStale !== task.stale) {
|
|
260
|
+
task.stale = isStale;
|
|
261
|
+
task.lastSourceCheck = nowIso();
|
|
262
|
+
updates.push(task);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (updates.length > 0) {
|
|
266
|
+
await withWrite(async (b) => {
|
|
267
|
+
for (const updated of updates) {
|
|
268
|
+
const t = b.tasks.find(t => t.id === updated.id);
|
|
269
|
+
if (t) {
|
|
270
|
+
t.stale = updated.stale;
|
|
271
|
+
t.lastSourceCheck = updated.lastSourceCheck;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
for (const updated of updates) {
|
|
276
|
+
broadcast("task.updated", updated);
|
|
277
|
+
await writeTaskMdx(updated, kanbanDir, await getBoard());
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
await _sweepLock;
|
|
282
|
+
}
|
|
283
|
+
// ─── HTTP helpers ─────────────────────────────────────────────────────────────
|
|
284
|
+
async function toRequest(req) {
|
|
285
|
+
const url = `http://${req.headers.host ?? "127.0.0.1"}${req.url ?? "/"}`;
|
|
286
|
+
const method = req.method ?? "GET";
|
|
287
|
+
const headers = new Headers();
|
|
288
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
289
|
+
if (Array.isArray(value)) {
|
|
290
|
+
for (const item of value)
|
|
291
|
+
headers.append(key, item);
|
|
292
|
+
}
|
|
293
|
+
else if (value !== undefined)
|
|
294
|
+
headers.set(key, value);
|
|
295
|
+
}
|
|
296
|
+
if (method === "GET" || method === "HEAD")
|
|
297
|
+
return new Request(url, { method, headers });
|
|
298
|
+
const init = {
|
|
299
|
+
method,
|
|
300
|
+
headers,
|
|
301
|
+
body: Readable.toWeb(req),
|
|
302
|
+
duplex: "half",
|
|
303
|
+
};
|
|
304
|
+
return new Request(url, init);
|
|
305
|
+
}
|
|
306
|
+
async function writeResponse(res, response) {
|
|
307
|
+
res.statusCode = response.status;
|
|
308
|
+
response.headers.forEach((value, key) => { res.setHeader(key, value); });
|
|
309
|
+
if (!response.body) {
|
|
310
|
+
res.end();
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
const body = Readable.fromWeb(response.body);
|
|
314
|
+
await new Promise((resolve, reject) => {
|
|
315
|
+
body.on("error", reject);
|
|
316
|
+
res.on("error", reject);
|
|
317
|
+
res.on("finish", resolve);
|
|
318
|
+
body.pipe(res);
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
function serveStatic(root, urlPath) {
|
|
322
|
+
const fileName = urlPath.replace(/^\//, "");
|
|
323
|
+
const filePath = join(root, fileName);
|
|
324
|
+
if (filePath !== root && !filePath.startsWith(root + "/"))
|
|
325
|
+
return null;
|
|
326
|
+
if (!existsSync(filePath))
|
|
327
|
+
return null;
|
|
328
|
+
let st;
|
|
329
|
+
try {
|
|
330
|
+
st = statSync(filePath);
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
if (!st.isFile())
|
|
336
|
+
return null;
|
|
337
|
+
const ext = extname(fileName);
|
|
338
|
+
const ctMap = {
|
|
339
|
+
".html": "text/html", ".css": "text/css", ".js": "application/javascript",
|
|
340
|
+
".json": "application/json", ".md": "text/markdown",
|
|
341
|
+
".svg": "image/svg+xml",
|
|
342
|
+
};
|
|
343
|
+
return { body: readFileSync(filePath), contentType: ctMap[ext] ?? "application/octet-stream" };
|
|
344
|
+
}
|
|
345
|
+
function jsonResponse(data, status = 200, extraHeaders) {
|
|
346
|
+
const headers = { "Content-Type": "application/json", ...extraHeaders };
|
|
347
|
+
return new Response(JSON.stringify(data), { status, headers });
|
|
348
|
+
}
|
|
349
|
+
function errorResponse(message, status = 400) {
|
|
350
|
+
return jsonResponse({ error: message }, status);
|
|
351
|
+
}
|
|
352
|
+
function escapeHtml(value) {
|
|
353
|
+
return value.replace(/[&<>'"]/g, (character) => ({
|
|
354
|
+
"&": "&",
|
|
355
|
+
"<": "<",
|
|
356
|
+
">": ">",
|
|
357
|
+
"'": "'",
|
|
358
|
+
'"': """,
|
|
359
|
+
})[character] ?? character);
|
|
360
|
+
}
|
|
361
|
+
/** A self-contained browser error page; API clients continue to receive JSON. */
|
|
362
|
+
export function browserErrorPage(status, pathname = "/") {
|
|
363
|
+
const notFound = status === 404;
|
|
364
|
+
const title = notFound ? "Page not found" : "Workspace error";
|
|
365
|
+
const eyebrow = notFound ? "Route unavailable" : "Something went wrong";
|
|
366
|
+
const message = notFound
|
|
367
|
+
? "This workspace page does not exist, or it may have moved."
|
|
368
|
+
: "OpenKan could not complete this page request. Your project files are still safe.";
|
|
369
|
+
const route = escapeHtml(pathname || "/");
|
|
370
|
+
const body = `<!doctype html>
|
|
371
|
+
<html lang="en">
|
|
372
|
+
<head>
|
|
373
|
+
<meta charset="utf-8">
|
|
374
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
375
|
+
<meta name="color-scheme" content="dark">
|
|
376
|
+
<title>${status} — ${title} · OpenKan</title>
|
|
377
|
+
<style>
|
|
378
|
+
:root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
|
379
|
+
* { box-sizing: border-box; }
|
|
380
|
+
body { min-height: 100vh; margin: 0; display: grid; place-items: center; overflow: hidden; color: #e8edf7; background: #090d14; }
|
|
381
|
+
body::before, body::after { content: ""; position: fixed; width: 48rem; height: 48rem; border-radius: 50%; pointer-events: none; filter: blur(20px); opacity: .18; }
|
|
382
|
+
body::before { top: -32rem; left: -26rem; background: #6c63ff; }
|
|
383
|
+
body::after { right: -30rem; bottom: -35rem; background: #2fba91; }
|
|
384
|
+
main { position: relative; width: min(42rem, calc(100vw - 2rem)); padding: clamp(1.5rem, 6vw, 4rem); border: 1px solid #263247; border-radius: 1.5rem; background: rgba(16, 23, 35, .9); box-shadow: 0 1.5rem 5rem rgba(0, 0, 0, .38); }
|
|
385
|
+
.brand { display: flex; align-items: center; gap: .65rem; color: #b9c4d7; font-size: .85rem; font-weight: 750; letter-spacing: .08em; text-transform: uppercase; }
|
|
386
|
+
.mark { display: grid; place-items: center; width: 1.75rem; height: 1.75rem; border-radius: .55rem; color: #fff; background: linear-gradient(135deg, #766dff, #4850db); font-size: 1.1rem; font-weight: 800; letter-spacing: 0; text-transform: none; box-shadow: 0 .35rem 1.25rem rgba(105, 96, 255, .3); }
|
|
387
|
+
.code { margin: clamp(2.5rem, 7vw, 4.5rem) 0 .6rem; color: #f1f4fb; font-size: clamp(4.75rem, 18vw, 8.5rem); font-weight: 800; letter-spacing: -.09em; line-height: .82; }
|
|
388
|
+
.eyebrow { margin: 0 0 .7rem; color: #8f87ff; font-size: .76rem; font-weight: 800; letter-spacing: .13em; text-transform: uppercase; }
|
|
389
|
+
h1 { max-width: 28rem; margin: 0; font-size: clamp(1.65rem, 4vw, 2.35rem); letter-spacing: -.045em; line-height: 1.05; }
|
|
390
|
+
p { max-width: 32rem; margin: 1rem 0 0; color: #aeb9cb; font-size: 1rem; line-height: 1.65; }
|
|
391
|
+
code { padding: .16rem .38rem; border: 1px solid #2c3850; border-radius: .36rem; color: #d4dbeb; background: #111a29; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .85em; overflow-wrap: anywhere; }
|
|
392
|
+
.actions { display: flex; flex-wrap: wrap; gap: .7rem; margin-top: 2rem; }
|
|
393
|
+
a { display: inline-flex; align-items: center; justify-content: center; min-height: 2.7rem; padding: .65rem 1rem; border: 1px solid #34435e; border-radius: .7rem; color: #d7e0ef; background: #172236; font-weight: 700; text-decoration: none; transition: transform .16s ease, border-color .16s ease, background .16s ease; }
|
|
394
|
+
a:hover { transform: translateY(-1px); border-color: #837cff; background: #202e48; }
|
|
395
|
+
a.primary { border-color: transparent; color: #fff; background: #665cf6; box-shadow: 0 .55rem 1.4rem rgba(94, 83, 237, .28); }
|
|
396
|
+
a.primary:hover { background: #756cff; }
|
|
397
|
+
a:focus-visible { outline: 3px solid #a39cff; outline-offset: 3px; }
|
|
398
|
+
.route { margin-top: 2.1rem; color: #728099; font-size: .8rem; }
|
|
399
|
+
@media (max-width: 34rem) { main { border-radius: 1.1rem; } .actions { display: grid; } a { width: 100%; } }
|
|
400
|
+
</style>
|
|
401
|
+
</head>
|
|
402
|
+
<body>
|
|
403
|
+
<main>
|
|
404
|
+
<div class="brand"><span class="mark" aria-hidden="true">K</span> OpenKan</div>
|
|
405
|
+
<div class="code" aria-label="Error ${status}">${status}</div>
|
|
406
|
+
<p class="eyebrow">${eyebrow}</p>
|
|
407
|
+
<h1>${title}</h1>
|
|
408
|
+
<p>${message}</p>
|
|
409
|
+
<div class="actions"><a class="primary" href="/">Open workspace</a></div>
|
|
410
|
+
<p class="route">Requested route: <code>${route}</code></p>
|
|
411
|
+
</main>
|
|
412
|
+
</body>
|
|
413
|
+
</html>`;
|
|
414
|
+
return new Response(body, {
|
|
415
|
+
status,
|
|
416
|
+
headers: {
|
|
417
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
418
|
+
"Cache-Control": "no-store",
|
|
419
|
+
"X-Content-Type-Options": "nosniff",
|
|
420
|
+
},
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
function isBrowserNavigation(req) {
|
|
424
|
+
if (req.method !== "GET" && req.method !== "HEAD")
|
|
425
|
+
return false;
|
|
426
|
+
return req.headers.get("accept")?.includes("text/html") ?? false;
|
|
427
|
+
}
|
|
428
|
+
export function requestErrorResponse(req, status, message) {
|
|
429
|
+
if (isBrowserNavigation(req) && !new URL(req.url).pathname.startsWith("/api/")) {
|
|
430
|
+
return browserErrorPage(status, new URL(req.url).pathname);
|
|
431
|
+
}
|
|
432
|
+
return errorResponse(message, status);
|
|
433
|
+
}
|
|
434
|
+
// ─── Tasks index ───────────────────────────────────────────────────────────────
|
|
435
|
+
// TaskIndexEntry and taskToIndexEntry are defined in the new handlers section below
|
|
436
|
+
// ─── API handlers ─────────────────────────────────────────────────────────────
|
|
437
|
+
async function apiGetBoard() {
|
|
438
|
+
return jsonResponse(await getBoard());
|
|
439
|
+
}
|
|
440
|
+
// ─── Goals (PRDs stored in the canonical .ok/ planning workspace) ──────────
|
|
441
|
+
async function apiGetGoals(projectRoot) {
|
|
442
|
+
const paths = await initOkIfMissing(projectRoot);
|
|
443
|
+
const prds = await listOkPrds(paths);
|
|
444
|
+
prds.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
445
|
+
return jsonResponse({ prds });
|
|
446
|
+
}
|
|
447
|
+
async function apiPatchGoal(projectRoot, prdId, goalId, req) {
|
|
448
|
+
let body;
|
|
449
|
+
try {
|
|
450
|
+
body = await req.json();
|
|
451
|
+
}
|
|
452
|
+
catch {
|
|
453
|
+
return errorResponse("invalid JSON body");
|
|
454
|
+
}
|
|
455
|
+
const statuses = ["open", "in_progress", "met", "dropped"];
|
|
456
|
+
if (!statuses.includes(body.status)) {
|
|
457
|
+
return errorResponse("status must be open, in_progress, met, or dropped");
|
|
458
|
+
}
|
|
459
|
+
const paths = await initOkIfMissing(projectRoot);
|
|
460
|
+
const prd = await readOkPrd(paths, prdId);
|
|
461
|
+
if (!prd)
|
|
462
|
+
return errorResponse("PRD not found", 404);
|
|
463
|
+
const goal = prd.goals.find((item) => item.id === goalId);
|
|
464
|
+
if (!goal)
|
|
465
|
+
return errorResponse("goal not found", 404);
|
|
466
|
+
goal.status = body.status;
|
|
467
|
+
prd.updatedAt = new Date().toISOString();
|
|
468
|
+
await writeOkPrd(paths, prd);
|
|
469
|
+
await rebuildOkIndex(paths);
|
|
470
|
+
return jsonResponse({ prd });
|
|
471
|
+
}
|
|
472
|
+
// ─── Search endpoint ─────────────────────────────────────────────────────────
|
|
473
|
+
async function apiSearch(req) {
|
|
474
|
+
const url = new URL(req.url);
|
|
475
|
+
const query = url.searchParams.get("q") ?? undefined;
|
|
476
|
+
const column = url.searchParams.get("column") ?? undefined;
|
|
477
|
+
const tags = url.searchParams.getAll("tags");
|
|
478
|
+
const assignee = url.searchParams.get("assignee") ?? undefined;
|
|
479
|
+
const priority = url.searchParams.get("priority") ?? undefined;
|
|
480
|
+
const category = url.searchParams.get("category") ?? undefined;
|
|
481
|
+
const includeArchived = url.searchParams.get("includeArchived") === "true";
|
|
482
|
+
const limit = parseInt(url.searchParams.get("limit") ?? "50", 10);
|
|
483
|
+
const offset = parseInt(url.searchParams.get("offset") ?? "0", 10);
|
|
484
|
+
const opts = {
|
|
485
|
+
kanbanDir: KANBAN_DIR,
|
|
486
|
+
query,
|
|
487
|
+
column: column || undefined,
|
|
488
|
+
tags: tags.length > 0 ? tags : undefined,
|
|
489
|
+
assignee,
|
|
490
|
+
priority: priority ?? undefined,
|
|
491
|
+
category: category ?? undefined,
|
|
492
|
+
archived: includeArchived,
|
|
493
|
+
limit,
|
|
494
|
+
offset,
|
|
495
|
+
};
|
|
496
|
+
const result = await search(opts);
|
|
497
|
+
return jsonResponse(result);
|
|
498
|
+
}
|
|
499
|
+
// ─── Bulk operations endpoint ───────────────────────────────────────────────
|
|
500
|
+
async function apiBulk(_ctx, req) {
|
|
501
|
+
let body;
|
|
502
|
+
try {
|
|
503
|
+
body = await req.json();
|
|
504
|
+
}
|
|
505
|
+
catch {
|
|
506
|
+
return errorResponse("Invalid JSON");
|
|
507
|
+
}
|
|
508
|
+
if (!body.operation)
|
|
509
|
+
return errorResponse("operation is required", 422);
|
|
510
|
+
const board = await getBoard();
|
|
511
|
+
selfWriteUntil = Date.now() + 250;
|
|
512
|
+
const result = await applyBulk(board, body.operation);
|
|
513
|
+
await writeBoardMdx(await getBoard(), KANBAN_DIR);
|
|
514
|
+
broadcast("board.updated", {});
|
|
515
|
+
return jsonResponse(result);
|
|
516
|
+
}
|
|
517
|
+
// ─── Template endpoint ──────────────────────────────────────────────────────
|
|
518
|
+
async function apiGetTemplate() {
|
|
519
|
+
return jsonResponse({ template: TASK_MDX_TEMPLATE, parseHints: TEMPLATE_PARSE_HINTS });
|
|
520
|
+
}
|
|
521
|
+
export async function apiCreateTask(_ctx, req) {
|
|
522
|
+
let body;
|
|
523
|
+
try {
|
|
524
|
+
body = await req.json();
|
|
525
|
+
}
|
|
526
|
+
catch {
|
|
527
|
+
return errorResponse("Invalid JSON");
|
|
528
|
+
}
|
|
529
|
+
if (!body.title?.trim())
|
|
530
|
+
return errorResponse("title is required", 422);
|
|
531
|
+
// Validate parentId if provided
|
|
532
|
+
if (body.parentId !== undefined) {
|
|
533
|
+
const board = await getBoard();
|
|
534
|
+
const parent = board.tasks.find(t => t.id === body.parentId);
|
|
535
|
+
if (!parent)
|
|
536
|
+
return errorResponse("Parent task not found", 404);
|
|
537
|
+
// No transitive nesting in v1
|
|
538
|
+
if (parent.parentId !== null)
|
|
539
|
+
return errorResponse("Cannot nest a subtask under another subtask (v1)", 422);
|
|
540
|
+
}
|
|
541
|
+
const id = newId("tsk");
|
|
542
|
+
const arts = taskArtifacts(id);
|
|
543
|
+
const now = nowIso();
|
|
544
|
+
// Derive metadata from title + description
|
|
545
|
+
const derived = extractMetadata({ title: body.title, description: body.description ?? "" });
|
|
546
|
+
// Merge: explicit tags/category from body override derived values
|
|
547
|
+
const tags = (body.tags && body.tags.length > 0)
|
|
548
|
+
? [...new Set([...derived.tags, ...body.tags])]
|
|
549
|
+
: derived.tags;
|
|
550
|
+
const category = body.category ?? derived.category;
|
|
551
|
+
// Auto-assign: use body.assignee if provided, otherwise current git user, else "user"
|
|
552
|
+
// (Agent-created tasks via kanban_add tool also flow through here; the tool calls
|
|
553
|
+
// POST /api/tasks with a body that may include assignee, which gets processed below.)
|
|
554
|
+
const projectRoot = join(KANBAN_DIR, "..");
|
|
555
|
+
const gitUser = currentGitUser(projectRoot);
|
|
556
|
+
const assigneeName = (body.assignee ?? gitUser?.name ?? "user");
|
|
557
|
+
const task = {
|
|
558
|
+
id,
|
|
559
|
+
title: body.title.trim(),
|
|
560
|
+
description: body.description ?? "",
|
|
561
|
+
column: body.column ?? "todo",
|
|
562
|
+
order: 0,
|
|
563
|
+
sessionId: null,
|
|
564
|
+
agent: body.agent ?? "",
|
|
565
|
+
model: body.model ?? null,
|
|
566
|
+
status: "idle",
|
|
567
|
+
state: "idle",
|
|
568
|
+
lastError: null,
|
|
569
|
+
createdAt: now,
|
|
570
|
+
updatedAt: now,
|
|
571
|
+
artifact: arts.mdxPath,
|
|
572
|
+
sessionArtifact: null,
|
|
573
|
+
pendingInputs: [],
|
|
574
|
+
artifacts: arts,
|
|
575
|
+
tags,
|
|
576
|
+
category,
|
|
577
|
+
priority: derived.priority,
|
|
578
|
+
effort: derived.effort,
|
|
579
|
+
archived: false,
|
|
580
|
+
assignees: [assigneeName],
|
|
581
|
+
images: [],
|
|
582
|
+
parentId: body.parentId ?? null,
|
|
583
|
+
subtaskIds: [],
|
|
584
|
+
};
|
|
585
|
+
let created;
|
|
586
|
+
selfWriteUntil = Date.now() + 250;
|
|
587
|
+
await withWrite(async (board) => {
|
|
588
|
+
const colTasks = board.tasks.filter(t => t.column === task.column);
|
|
589
|
+
task.order = colTasks.length;
|
|
590
|
+
board.tasks.push(task);
|
|
591
|
+
// Add child id to parent's subtaskIds
|
|
592
|
+
if (task.parentId) {
|
|
593
|
+
const parent = board.tasks.find(t => t.id === task.parentId);
|
|
594
|
+
if (parent) {
|
|
595
|
+
parent.subtaskIds = [...new Set([...parent.subtaskIds, task.id])];
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
created = task;
|
|
599
|
+
});
|
|
600
|
+
// Create per-task directory
|
|
601
|
+
const taskDir = join(KANBAN_DIR, "tasks", id);
|
|
602
|
+
ensureDir(taskDir);
|
|
603
|
+
await writeTaskMdx(created, KANBAN_DIR, await getBoard());
|
|
604
|
+
await writeBoardMdx(await getBoard(), KANBAN_DIR);
|
|
605
|
+
broadcast("task.created", created);
|
|
606
|
+
recordEvent(KANBAN_DIR, "task.created", {
|
|
607
|
+
taskId: id,
|
|
608
|
+
author: "user",
|
|
609
|
+
summary: `created '${created.title}'`,
|
|
610
|
+
payload: { column: created.column, parentId: created.parentId },
|
|
611
|
+
});
|
|
612
|
+
return jsonResponse(created, 201);
|
|
613
|
+
}
|
|
614
|
+
export async function apiUpdateTask(_ctx, taskId, req) {
|
|
615
|
+
let patch;
|
|
616
|
+
try {
|
|
617
|
+
patch = await req.json();
|
|
618
|
+
}
|
|
619
|
+
catch {
|
|
620
|
+
return errorResponse("Invalid JSON");
|
|
621
|
+
}
|
|
622
|
+
// Validate empty title
|
|
623
|
+
if (patch.title !== undefined && patch.title.trim().length === 0) {
|
|
624
|
+
return errorResponse("title cannot be empty", 422);
|
|
625
|
+
}
|
|
626
|
+
let updated;
|
|
627
|
+
let columnChanged = false;
|
|
628
|
+
let isEdit = false; // true if title or description changed
|
|
629
|
+
selfWriteUntil = Date.now() + 250;
|
|
630
|
+
await withWrite(async (board) => {
|
|
631
|
+
const idx = board.tasks.findIndex(t => t.id === taskId);
|
|
632
|
+
if (idx === -1)
|
|
633
|
+
return;
|
|
634
|
+
const task = board.tasks[idx];
|
|
635
|
+
// Handle re-parenting
|
|
636
|
+
if (patch.parentId !== undefined) {
|
|
637
|
+
const oldParentId = task.parentId;
|
|
638
|
+
const newParentId = patch.parentId;
|
|
639
|
+
// Check new parent exists and is not a subtask itself
|
|
640
|
+
if (newParentId !== null) {
|
|
641
|
+
const newParent = board.tasks.find(t => t.id === newParentId);
|
|
642
|
+
if (!newParent)
|
|
643
|
+
return; // will be caught after withWrite
|
|
644
|
+
if (newParent.parentId !== null)
|
|
645
|
+
return; // cannot re-parent under a subtask
|
|
646
|
+
if (newParentId === taskId)
|
|
647
|
+
return; // cannot parent to self
|
|
648
|
+
}
|
|
649
|
+
// Remove from old parent's subtaskIds
|
|
650
|
+
if (oldParentId !== null) {
|
|
651
|
+
const oldParent = board.tasks.find(t => t.id === oldParentId);
|
|
652
|
+
if (oldParent) {
|
|
653
|
+
oldParent.subtaskIds = oldParent.subtaskIds.filter(id => id !== taskId);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
// Add to new parent's subtaskIds
|
|
657
|
+
if (newParentId !== null) {
|
|
658
|
+
const newParent = board.tasks.find(t => t.id === newParentId);
|
|
659
|
+
if (newParent) {
|
|
660
|
+
newParent.subtaskIds = [...new Set([...newParent.subtaskIds, taskId])];
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
task.parentId = newParentId;
|
|
664
|
+
}
|
|
665
|
+
if (patch.column !== undefined && patch.column !== task.column)
|
|
666
|
+
columnChanged = true;
|
|
667
|
+
if (patch.title !== undefined) {
|
|
668
|
+
task.title = patch.title;
|
|
669
|
+
isEdit = true;
|
|
670
|
+
}
|
|
671
|
+
if (patch.description !== undefined) {
|
|
672
|
+
task.description = patch.description;
|
|
673
|
+
isEdit = true;
|
|
674
|
+
}
|
|
675
|
+
if (patch.column !== undefined)
|
|
676
|
+
task.column = patch.column;
|
|
677
|
+
if (patch.agent !== undefined)
|
|
678
|
+
task.agent = patch.agent;
|
|
679
|
+
if (patch.model !== undefined)
|
|
680
|
+
task.model = patch.model;
|
|
681
|
+
if (patch.state !== undefined)
|
|
682
|
+
task.state = patch.state;
|
|
683
|
+
if (patch.order !== undefined)
|
|
684
|
+
task.order = patch.order;
|
|
685
|
+
if (patch.archived !== undefined)
|
|
686
|
+
task.archived = patch.archived;
|
|
687
|
+
if (patch.stale !== undefined)
|
|
688
|
+
task.stale = patch.stale;
|
|
689
|
+
// Merge assignees (add-only, not destructive)
|
|
690
|
+
if (patch.assignees !== undefined) {
|
|
691
|
+
task.assignees = [...new Set([...task.assignees, ...patch.assignees])];
|
|
692
|
+
}
|
|
693
|
+
// Re-derive metadata if title or description changed
|
|
694
|
+
if (patch.title !== undefined || patch.description !== undefined) {
|
|
695
|
+
const derived = extractMetadata({ title: task.title, description: task.description });
|
|
696
|
+
task.tags = (patch.tags && patch.tags.length > 0)
|
|
697
|
+
? [...new Set([...derived.tags, ...patch.tags])]
|
|
698
|
+
: derived.tags;
|
|
699
|
+
task.category = patch.category ?? derived.category;
|
|
700
|
+
task.priority = derived.priority;
|
|
701
|
+
task.effort = derived.effort;
|
|
702
|
+
}
|
|
703
|
+
else {
|
|
704
|
+
// Apply explicit overrides even without re-derivation
|
|
705
|
+
if (patch.tags !== undefined) {
|
|
706
|
+
task.tags = patch.tags;
|
|
707
|
+
}
|
|
708
|
+
if (patch.category !== undefined) {
|
|
709
|
+
task.category = patch.category;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
task.updatedAt = nowIso();
|
|
713
|
+
if (columnChanged)
|
|
714
|
+
board.tasks = renormalizeOrder(board.tasks);
|
|
715
|
+
updated = { ...task };
|
|
716
|
+
});
|
|
717
|
+
if (!updated)
|
|
718
|
+
return errorResponse("Task not found", 404);
|
|
719
|
+
await writeTaskMdx(updated, KANBAN_DIR, await getBoard());
|
|
720
|
+
await writeBoardMdx(await getBoard(), KANBAN_DIR);
|
|
721
|
+
broadcast("task.updated", updated);
|
|
722
|
+
if (columnChanged) {
|
|
723
|
+
recordEvent(KANBAN_DIR, "task.moved", {
|
|
724
|
+
taskId,
|
|
725
|
+
author: "user",
|
|
726
|
+
summary: `moved '${updated.title}' to ${updated.column}`,
|
|
727
|
+
payload: { from: patch.column },
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
else if (isEdit) {
|
|
731
|
+
recordEvent(KANBAN_DIR, "task.updated", {
|
|
732
|
+
taskId,
|
|
733
|
+
author: "user",
|
|
734
|
+
summary: `edited '${updated.title}'`,
|
|
735
|
+
payload: { changes: Object.keys(patch) },
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
else {
|
|
739
|
+
recordEvent(KANBAN_DIR, "task.updated", {
|
|
740
|
+
taskId,
|
|
741
|
+
author: "user",
|
|
742
|
+
summary: `updated '${updated.title}'`,
|
|
743
|
+
payload: { changes: Object.keys(patch) },
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
return jsonResponse(updated);
|
|
747
|
+
}
|
|
748
|
+
// ─── Stale recheck endpoint ─────────────────────────────────────────────────
|
|
749
|
+
/**
|
|
750
|
+
* POST /api/tasks/recheck-stale
|
|
751
|
+
* Body: { taskId: string }
|
|
752
|
+
* Returns: { stale: boolean, sourceHash: string }
|
|
753
|
+
*
|
|
754
|
+
* Checks whether the source file for a task has changed since import.
|
|
755
|
+
*/
|
|
756
|
+
export async function apiRecheckStale(_ctx, req) {
|
|
757
|
+
let body;
|
|
758
|
+
try {
|
|
759
|
+
body = await req.json();
|
|
760
|
+
}
|
|
761
|
+
catch {
|
|
762
|
+
return errorResponse("Invalid JSON");
|
|
763
|
+
}
|
|
764
|
+
if (!body.taskId)
|
|
765
|
+
return errorResponse("taskId is required", 422);
|
|
766
|
+
const board = await getBoard();
|
|
767
|
+
const task = board.tasks.find(t => t.id === body.taskId);
|
|
768
|
+
if (!task)
|
|
769
|
+
return errorResponse("Task not found", 404);
|
|
770
|
+
// No source → never stale
|
|
771
|
+
if (!task.source) {
|
|
772
|
+
return jsonResponse({ stale: false, sourceHash: "" });
|
|
773
|
+
}
|
|
774
|
+
const absPath = join(KANBAN_DIR, "..", task.source.path);
|
|
775
|
+
let content;
|
|
776
|
+
try {
|
|
777
|
+
content = readFileSync(absPath, "utf-8");
|
|
778
|
+
}
|
|
779
|
+
catch {
|
|
780
|
+
return errorResponse("Source file not readable", 422);
|
|
781
|
+
}
|
|
782
|
+
const newHash = createHash("sha256").update(content).digest("hex");
|
|
783
|
+
const isStale = newHash !== task.sourceHash;
|
|
784
|
+
if (isStale !== task.stale || task.lastSourceCheck === undefined) {
|
|
785
|
+
task.stale = isStale;
|
|
786
|
+
task.lastSourceCheck = nowIso();
|
|
787
|
+
if (isStale)
|
|
788
|
+
task.sourceHash = newHash; // update hash so subsequent recheck is consistent
|
|
789
|
+
selfWriteUntil = Date.now() + 250;
|
|
790
|
+
await withWrite(async (b) => {
|
|
791
|
+
const t = b.tasks.find(t => t.id === body.taskId);
|
|
792
|
+
if (t) {
|
|
793
|
+
t.stale = isStale;
|
|
794
|
+
t.lastSourceCheck = task.lastSourceCheck;
|
|
795
|
+
if (isStale)
|
|
796
|
+
t.sourceHash = newHash;
|
|
797
|
+
}
|
|
798
|
+
});
|
|
799
|
+
const updated = (await getBoard()).tasks.find(t => t.id === body.taskId);
|
|
800
|
+
await writeTaskMdx(updated, KANBAN_DIR, await getBoard());
|
|
801
|
+
broadcast("task.updated", updated);
|
|
802
|
+
return jsonResponse({ stale: isStale, sourceHash: newHash });
|
|
803
|
+
}
|
|
804
|
+
return jsonResponse({ stale: isStale, sourceHash: task.sourceHash ?? newHash });
|
|
805
|
+
}
|
|
806
|
+
export async function apiDeleteTask(_ctx, taskId) {
|
|
807
|
+
let removedId = "";
|
|
808
|
+
const subtaskIds = [];
|
|
809
|
+
selfWriteUntil = Date.now() + 250;
|
|
810
|
+
await withWrite(async (board) => {
|
|
811
|
+
const idx = board.tasks.findIndex(t => t.id === taskId);
|
|
812
|
+
if (idx === -1)
|
|
813
|
+
return;
|
|
814
|
+
removedId = board.tasks[idx].id;
|
|
815
|
+
subtaskIds.push(...board.tasks[idx].subtaskIds);
|
|
816
|
+
// Remove child's id from parent's subtaskIds
|
|
817
|
+
const parentId = board.tasks[idx].parentId;
|
|
818
|
+
if (parentId) {
|
|
819
|
+
const parent = board.tasks.find(t => t.id === parentId);
|
|
820
|
+
if (parent) {
|
|
821
|
+
parent.subtaskIds = parent.subtaskIds.filter(id => id !== taskId);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
board.tasks.splice(idx, 1);
|
|
825
|
+
board.tasks = renormalizeOrder(board.tasks);
|
|
826
|
+
});
|
|
827
|
+
if (!removedId)
|
|
828
|
+
return errorResponse("Task not found", 404);
|
|
829
|
+
// Cascade delete all subtasks (collect grandchildren first, then delete leaves)
|
|
830
|
+
async function deleteSubtasks(ids) {
|
|
831
|
+
for (const id of ids) {
|
|
832
|
+
const taskDir = join(KANBAN_DIR, "tasks", id);
|
|
833
|
+
removeDir(taskDir);
|
|
834
|
+
// Record event before board modification
|
|
835
|
+
recordEvent(KANBAN_DIR, "task.deleted", {
|
|
836
|
+
taskId: id,
|
|
837
|
+
author: "user",
|
|
838
|
+
summary: `deleted subtask '${id}'`,
|
|
839
|
+
payload: {},
|
|
840
|
+
});
|
|
841
|
+
broadcast("task.deleted", { id });
|
|
842
|
+
await withWrite(async (board) => {
|
|
843
|
+
const idx = board.tasks.findIndex(t => t.id === id);
|
|
844
|
+
if (idx === -1)
|
|
845
|
+
return;
|
|
846
|
+
// Remove from parent's subtaskIds
|
|
847
|
+
const pId = board.tasks[idx].parentId;
|
|
848
|
+
if (pId) {
|
|
849
|
+
const parent = board.tasks.find(t => t.id === pId);
|
|
850
|
+
if (parent)
|
|
851
|
+
parent.subtaskIds = parent.subtaskIds.filter(x => x !== id);
|
|
852
|
+
}
|
|
853
|
+
board.tasks.splice(idx, 1);
|
|
854
|
+
board.tasks = renormalizeOrder(board.tasks);
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
await deleteSubtasks(subtaskIds);
|
|
859
|
+
// Delete task directory
|
|
860
|
+
const taskDir = join(KANBAN_DIR, "tasks", taskId);
|
|
861
|
+
removeDir(taskDir);
|
|
862
|
+
await writeBoardMdx(await getBoard(), KANBAN_DIR);
|
|
863
|
+
broadcast("task.deleted", { id: taskId });
|
|
864
|
+
recordEvent(KANBAN_DIR, "task.deleted", {
|
|
865
|
+
taskId,
|
|
866
|
+
author: "user",
|
|
867
|
+
summary: `deleted task '${taskId}'`,
|
|
868
|
+
payload: {},
|
|
869
|
+
});
|
|
870
|
+
return jsonResponse({ ok: true });
|
|
871
|
+
}
|
|
872
|
+
// ─── Tags endpoint ────────────────────────────────────────────────────────────
|
|
873
|
+
async function apiGetTags() {
|
|
874
|
+
const board = await getBoard();
|
|
875
|
+
const categorySet = new Set();
|
|
876
|
+
const tagCounts = new Map();
|
|
877
|
+
const priorityCounts = new Map();
|
|
878
|
+
const effortCounts = new Map();
|
|
879
|
+
for (const task of board.tasks) {
|
|
880
|
+
const cat = (task.category ?? "task");
|
|
881
|
+
categorySet.add(cat);
|
|
882
|
+
priorityCounts.set(task.priority ?? "normal", (priorityCounts.get(task.priority ?? "normal") ?? 0) + 1);
|
|
883
|
+
if (task.effort !== null && task.effort !== undefined) {
|
|
884
|
+
effortCounts.set(task.effort, (effortCounts.get(task.effort) ?? 0) + 1);
|
|
885
|
+
}
|
|
886
|
+
for (const tag of (task.tags ?? [])) {
|
|
887
|
+
tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
const tagCountObj = {};
|
|
891
|
+
for (const [tag, count] of tagCounts)
|
|
892
|
+
tagCountObj[tag] = count;
|
|
893
|
+
const priorityCountObj = {};
|
|
894
|
+
for (const [p, count] of priorityCounts)
|
|
895
|
+
priorityCountObj[p] = count;
|
|
896
|
+
const effortCountObj = {};
|
|
897
|
+
for (const [e, count] of effortCounts)
|
|
898
|
+
effortCountObj[e] = count;
|
|
899
|
+
return jsonResponse({
|
|
900
|
+
categories: [...categorySet].sort(),
|
|
901
|
+
tagCounts: tagCountObj,
|
|
902
|
+
priorityCounts: priorityCountObj,
|
|
903
|
+
effortCounts: effortCountObj,
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
// ─── Input endpoints ──────────────────────────────────────────────────────────
|
|
907
|
+
async function apiAskInput(_ctx, taskId, req) {
|
|
908
|
+
let body;
|
|
909
|
+
try {
|
|
910
|
+
body = await req.json();
|
|
911
|
+
}
|
|
912
|
+
catch {
|
|
913
|
+
return errorResponse("Invalid JSON");
|
|
914
|
+
}
|
|
915
|
+
if (!body.question)
|
|
916
|
+
return errorResponse("question is required", 422);
|
|
917
|
+
const board = await getBoard();
|
|
918
|
+
const task = board.tasks.find(t => t.id === taskId);
|
|
919
|
+
if (!task)
|
|
920
|
+
return errorResponse("Task not found", 404);
|
|
921
|
+
// Check for existing pending input
|
|
922
|
+
const existing = getPendingInput(taskId, KANBAN_DIR);
|
|
923
|
+
if (existing)
|
|
924
|
+
return errorResponse("A pending input already exists for this task", 409);
|
|
925
|
+
const inputType = body.type ?? "ask";
|
|
926
|
+
const input = addInput(taskId, KANBAN_DIR, {
|
|
927
|
+
type: inputType,
|
|
928
|
+
question: body.question,
|
|
929
|
+
options: body.options,
|
|
930
|
+
placeholder: body.placeholder,
|
|
931
|
+
blockId: body.blockId,
|
|
932
|
+
});
|
|
933
|
+
// Update task state to waiting-for-input
|
|
934
|
+
selfWriteUntil = Date.now() + 250;
|
|
935
|
+
await withWrite(async (b) => {
|
|
936
|
+
const t = b.tasks.find(t => t.id === taskId);
|
|
937
|
+
if (t) {
|
|
938
|
+
t.state = "waiting-for-input";
|
|
939
|
+
t.pendingInputs = [...(t.pendingInputs ?? []), input.id];
|
|
940
|
+
t.updatedAt = nowIso();
|
|
941
|
+
}
|
|
942
|
+
});
|
|
943
|
+
const updated = (await getBoard()).tasks.find(t => t.id === taskId);
|
|
944
|
+
await writeTaskMdx(updated, KANBAN_DIR, await getBoard());
|
|
945
|
+
broadcast("task.updated", updated);
|
|
946
|
+
broadcast("task.input.asked", { taskId, input });
|
|
947
|
+
recordEvent(KANBAN_DIR, "task.input.asked", {
|
|
948
|
+
taskId,
|
|
949
|
+
author: "user",
|
|
950
|
+
summary: `asked '${body.question}' on '${task.title}'`,
|
|
951
|
+
payload: { inputId: input.id, type: input.type },
|
|
952
|
+
});
|
|
953
|
+
return jsonResponse(input, 201);
|
|
954
|
+
}
|
|
955
|
+
async function apiRespondInput(_ctx, taskId, req) {
|
|
956
|
+
let body;
|
|
957
|
+
try {
|
|
958
|
+
body = await req.json();
|
|
959
|
+
}
|
|
960
|
+
catch {
|
|
961
|
+
return errorResponse("Invalid JSON");
|
|
962
|
+
}
|
|
963
|
+
if (!body.inputId)
|
|
964
|
+
return errorResponse("inputId is required", 422);
|
|
965
|
+
const board = await getBoard();
|
|
966
|
+
const task = board.tasks.find(t => t.id === taskId);
|
|
967
|
+
if (!task)
|
|
968
|
+
return errorResponse("Task not found", 404);
|
|
969
|
+
let updatedInput;
|
|
970
|
+
try {
|
|
971
|
+
updatedInput = respondInput(taskId, KANBAN_DIR, body.inputId, { value: body.value, optionId: body.optionId });
|
|
972
|
+
}
|
|
973
|
+
catch (e) {
|
|
974
|
+
return errorResponse("respondInput failed: " + String(e?.message ?? e), 422);
|
|
975
|
+
}
|
|
976
|
+
// Restore task to prior state (default running)
|
|
977
|
+
const priorState = task.sessionId ? "running" : "idle";
|
|
978
|
+
selfWriteUntil = Date.now() + 250;
|
|
979
|
+
await withWrite(async (b) => {
|
|
980
|
+
const t = b.tasks.find(t => t.id === taskId);
|
|
981
|
+
if (t) {
|
|
982
|
+
t.state = priorState;
|
|
983
|
+
t.pendingInputs = (t.pendingInputs ?? []).filter(id => id !== body.inputId);
|
|
984
|
+
t.updatedAt = nowIso();
|
|
985
|
+
}
|
|
986
|
+
});
|
|
987
|
+
const updated = (await getBoard()).tasks.find(t => t.id === taskId);
|
|
988
|
+
await writeTaskMdx(updated, KANBAN_DIR, await getBoard());
|
|
989
|
+
broadcast("task.updated", updated);
|
|
990
|
+
broadcast("task.input.responded", { taskId, input: updatedInput });
|
|
991
|
+
recordEvent(KANBAN_DIR, "task.input.responded", {
|
|
992
|
+
taskId,
|
|
993
|
+
author: "user",
|
|
994
|
+
summary: `responded to input on '${task.title}'`,
|
|
995
|
+
payload: { inputId: updatedInput.id },
|
|
996
|
+
});
|
|
997
|
+
return jsonResponse(updatedInput);
|
|
998
|
+
}
|
|
999
|
+
// ─── Comment endpoints ────────────────────────────────────────────────────────
|
|
1000
|
+
async function apiGetComments(_ctx, taskId) {
|
|
1001
|
+
const comments = listComments(taskId, KANBAN_DIR);
|
|
1002
|
+
return jsonResponse(comments);
|
|
1003
|
+
}
|
|
1004
|
+
async function apiAddComment(_ctx, taskId, req) {
|
|
1005
|
+
let body;
|
|
1006
|
+
try {
|
|
1007
|
+
body = await req.json();
|
|
1008
|
+
}
|
|
1009
|
+
catch {
|
|
1010
|
+
return errorResponse("Invalid JSON");
|
|
1011
|
+
}
|
|
1012
|
+
if (!body.blockId || !body.text)
|
|
1013
|
+
return errorResponse("blockId and text are required", 422);
|
|
1014
|
+
// Resolve author: explicit "agent:<name>" wins, else body.author, else git user, else "user"
|
|
1015
|
+
let author = body.author ?? "user";
|
|
1016
|
+
const projectRoot = getActiveProjectRoot();
|
|
1017
|
+
if (author === "user" || !author) {
|
|
1018
|
+
const gitUser = currentGitUser(projectRoot);
|
|
1019
|
+
author = gitUser?.name ?? "user";
|
|
1020
|
+
}
|
|
1021
|
+
const comment = addComment(taskId, KANBAN_DIR, {
|
|
1022
|
+
blockId: body.blockId,
|
|
1023
|
+
line: body.line ?? 1,
|
|
1024
|
+
text: body.text,
|
|
1025
|
+
author,
|
|
1026
|
+
});
|
|
1027
|
+
broadcast("task.comment.added", { taskId, comment });
|
|
1028
|
+
recordEvent(KANBAN_DIR, "task.commented", {
|
|
1029
|
+
taskId,
|
|
1030
|
+
author: comment.author,
|
|
1031
|
+
summary: `commented on task`,
|
|
1032
|
+
payload: { commentId: comment.id, text: comment.text.slice(0, 80) },
|
|
1033
|
+
});
|
|
1034
|
+
return jsonResponse(comment, 201);
|
|
1035
|
+
}
|
|
1036
|
+
async function apiResolveComment(_ctx, taskId, commentId, req) {
|
|
1037
|
+
let body;
|
|
1038
|
+
try {
|
|
1039
|
+
body = await req.json();
|
|
1040
|
+
}
|
|
1041
|
+
catch {
|
|
1042
|
+
return errorResponse("Invalid JSON");
|
|
1043
|
+
}
|
|
1044
|
+
// Resolve resolvedBy: explicit author wins, else git user, else "user"
|
|
1045
|
+
let resolvedBy = body.author ?? "user";
|
|
1046
|
+
if (!resolvedBy || resolvedBy === "user") {
|
|
1047
|
+
const projectRoot = getActiveProjectRoot();
|
|
1048
|
+
const gitUser = currentGitUser(projectRoot);
|
|
1049
|
+
resolvedBy = gitUser?.name ?? "user";
|
|
1050
|
+
}
|
|
1051
|
+
const updated = resolveComment(taskId, KANBAN_DIR, commentId, body.resolved, resolvedBy, undefined, body.reason);
|
|
1052
|
+
if (!updated)
|
|
1053
|
+
return errorResponse("Comment not found", 404);
|
|
1054
|
+
broadcast("task.comment.resolved", { taskId, comment: updated });
|
|
1055
|
+
recordEvent(KANBAN_DIR, "task.comment.resolved", {
|
|
1056
|
+
taskId,
|
|
1057
|
+
author: resolvedBy,
|
|
1058
|
+
summary: `${body.resolved ? "resolved" : "unresolved"} comment on task`,
|
|
1059
|
+
payload: { commentId },
|
|
1060
|
+
});
|
|
1061
|
+
return jsonResponse(updated);
|
|
1062
|
+
}
|
|
1063
|
+
async function apiDeleteComment(_ctx, taskId, commentId) {
|
|
1064
|
+
const ok = deleteComment(taskId, KANBAN_DIR, commentId);
|
|
1065
|
+
if (!ok)
|
|
1066
|
+
return errorResponse("Comment not found", 404);
|
|
1067
|
+
broadcast("task.comment.deleted", { taskId, commentId });
|
|
1068
|
+
recordEvent(KANBAN_DIR, "task.comment.deleted", {
|
|
1069
|
+
taskId,
|
|
1070
|
+
author: "user",
|
|
1071
|
+
summary: `deleted comment on task`,
|
|
1072
|
+
payload: { commentId },
|
|
1073
|
+
});
|
|
1074
|
+
return jsonResponse({ ok: true });
|
|
1075
|
+
}
|
|
1076
|
+
// ─── MDX rendered ────────────────────────────────────────────────────────────
|
|
1077
|
+
async function apiGetMdxRendered(taskId) {
|
|
1078
|
+
const board = await getBoard();
|
|
1079
|
+
const task = board.tasks.find(t => t.id === taskId);
|
|
1080
|
+
if (!task)
|
|
1081
|
+
return errorResponse("Task not found", 404);
|
|
1082
|
+
const mdxPath = join(KANBAN_DIR, task.artifacts.mdxPath);
|
|
1083
|
+
let mdx = "";
|
|
1084
|
+
if (existsSync(mdxPath)) {
|
|
1085
|
+
try {
|
|
1086
|
+
if (statSync(mdxPath).isFile())
|
|
1087
|
+
mdx = readFileSync(mdxPath, "utf-8");
|
|
1088
|
+
}
|
|
1089
|
+
catch { /* ignore */ }
|
|
1090
|
+
}
|
|
1091
|
+
const result = await renderMdx(mdx);
|
|
1092
|
+
return jsonResponse({ html: result.html, blocks: result.blocks });
|
|
1093
|
+
}
|
|
1094
|
+
// ─── TSX preview ─────────────────────────────────────────────────────────────
|
|
1095
|
+
async function apiPreview(req) {
|
|
1096
|
+
let body;
|
|
1097
|
+
try {
|
|
1098
|
+
body = await req.json();
|
|
1099
|
+
}
|
|
1100
|
+
catch {
|
|
1101
|
+
return errorResponse("Invalid JSON");
|
|
1102
|
+
}
|
|
1103
|
+
if (!body.tsx)
|
|
1104
|
+
return errorResponse("tsx is required", 422);
|
|
1105
|
+
const result = await buildPreview(body.tsx, body.props);
|
|
1106
|
+
if (result.error)
|
|
1107
|
+
return jsonResponse({ error: result.error }, 422);
|
|
1108
|
+
return jsonResponse({ js: result.js, sandboxHtml: result.sandboxHtml });
|
|
1109
|
+
}
|
|
1110
|
+
// ─── Session/status handlers (reuse existing) ─────────────────────────────────
|
|
1111
|
+
export async function apiStartTask(projectRoot, taskId, req) {
|
|
1112
|
+
let body;
|
|
1113
|
+
try {
|
|
1114
|
+
body = await req.json();
|
|
1115
|
+
}
|
|
1116
|
+
catch {
|
|
1117
|
+
body = {};
|
|
1118
|
+
}
|
|
1119
|
+
const board = await getBoard();
|
|
1120
|
+
const task = board.tasks.find(t => t.id === taskId);
|
|
1121
|
+
if (!task)
|
|
1122
|
+
return errorResponse("Task not found", 404);
|
|
1123
|
+
if (task.sessionId)
|
|
1124
|
+
return errorResponse("Task already has an active session", 409);
|
|
1125
|
+
let knownAgents = [];
|
|
1126
|
+
try {
|
|
1127
|
+
const snapshot = await getBizarSnapshot(projectRoot);
|
|
1128
|
+
knownAgents = Array.isArray(snapshot?.agents)
|
|
1129
|
+
? snapshot.agents.map((candidate) => candidate.id ?? candidate.name).filter(Boolean)
|
|
1130
|
+
: [];
|
|
1131
|
+
}
|
|
1132
|
+
catch (e) {
|
|
1133
|
+
return errorResponse("Unable to load Bizar agents: " + String(e?.message ?? e), 502);
|
|
1134
|
+
}
|
|
1135
|
+
if (body.agent && knownAgents.length > 0 && !knownAgents.includes(body.agent)) {
|
|
1136
|
+
return errorResponse(`Unknown agent "${body.agent}". Available: ${knownAgents.join(", ")}`, 400);
|
|
1137
|
+
}
|
|
1138
|
+
// Read agents.active from .ok/openkan.json if present
|
|
1139
|
+
let agentsActive;
|
|
1140
|
+
try {
|
|
1141
|
+
const agentsConfigPath = join(projectRoot, ".ok", "openkan.json");
|
|
1142
|
+
if (existsSync(agentsConfigPath)) {
|
|
1143
|
+
const raw = JSON.parse(readFileSync(agentsConfigPath, "utf-8"));
|
|
1144
|
+
const agentsBlock = raw["agents"];
|
|
1145
|
+
if (agentsBlock && typeof agentsBlock["active"] === "string" && agentsBlock["active"]) {
|
|
1146
|
+
agentsActive = agentsBlock["active"];
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
catch { /* ignore config read errors */ }
|
|
1151
|
+
const preferred = body.agent || task.agent;
|
|
1152
|
+
const agent = (preferred && knownAgents.includes(preferred) ? preferred : "")
|
|
1153
|
+
|| (agentsActive && knownAgents.includes(agentsActive) ? agentsActive : "")
|
|
1154
|
+
|| (knownAgents.includes("mike") ? "mike" : knownAgents[0]);
|
|
1155
|
+
if (!agent)
|
|
1156
|
+
return errorResponse("No Bizar agents are available", 503);
|
|
1157
|
+
let sessionId;
|
|
1158
|
+
try {
|
|
1159
|
+
const started = executeBizarCommand(projectRoot, "start-session", {
|
|
1160
|
+
agent,
|
|
1161
|
+
name: `OpenKan: ${task.title}`,
|
|
1162
|
+
prompt: [
|
|
1163
|
+
`Work on OpenKan task ${task.id}: ${task.title}`,
|
|
1164
|
+
task.description,
|
|
1165
|
+
`Keep the task workspace at .ok/tasks/${task.id}/task.mdx synchronized with progress.`,
|
|
1166
|
+
].filter(Boolean).join("\n\n"),
|
|
1167
|
+
});
|
|
1168
|
+
sessionId = started?.session?.sessionId ?? started?.session?.id ?? "";
|
|
1169
|
+
if (!sessionId)
|
|
1170
|
+
throw new Error("Bizar did not return a session ID");
|
|
1171
|
+
}
|
|
1172
|
+
catch (e) {
|
|
1173
|
+
return errorResponse("Bizar session start failed: " + String(e?.message ?? e), 502);
|
|
1174
|
+
}
|
|
1175
|
+
const startedAt = nowIso();
|
|
1176
|
+
selfWriteUntil = Date.now() + 250;
|
|
1177
|
+
await withWrite(async (b) => {
|
|
1178
|
+
const t = b.tasks.find(t => t.id === taskId);
|
|
1179
|
+
if (!t)
|
|
1180
|
+
return;
|
|
1181
|
+
t.sessionId = sessionId;
|
|
1182
|
+
t.agent = agent;
|
|
1183
|
+
if (body.model)
|
|
1184
|
+
t.model = body.model;
|
|
1185
|
+
t.state = "running";
|
|
1186
|
+
t.updatedAt = nowIso();
|
|
1187
|
+
b.sessions[sessionId] = { taskId, status: "running", startedAt, endedAt: null };
|
|
1188
|
+
});
|
|
1189
|
+
const updatedTask = (await getBoard()).tasks.find(t => t.id === taskId);
|
|
1190
|
+
await writeTaskMdx(updatedTask, KANBAN_DIR, await getBoard());
|
|
1191
|
+
await writeBoardMdx(await getBoard(), KANBAN_DIR);
|
|
1192
|
+
broadcast("task.updated", updatedTask);
|
|
1193
|
+
recordEvent(KANBAN_DIR, "agent.started", {
|
|
1194
|
+
taskId,
|
|
1195
|
+
author: `agent:${agent}`,
|
|
1196
|
+
summary: `started on '${task.title}'`,
|
|
1197
|
+
payload: { sessionId, agent },
|
|
1198
|
+
});
|
|
1199
|
+
return jsonResponse(updatedTask);
|
|
1200
|
+
}
|
|
1201
|
+
export async function apiAbortTask(projectRoot, taskId) {
|
|
1202
|
+
const board = await getBoard();
|
|
1203
|
+
const task = board.tasks.find(t => t.id === taskId);
|
|
1204
|
+
if (!task)
|
|
1205
|
+
return errorResponse("Task not found", 404);
|
|
1206
|
+
const sessionId = task.sessionId;
|
|
1207
|
+
if (!sessionId)
|
|
1208
|
+
return errorResponse("Task has no active session", 409);
|
|
1209
|
+
try {
|
|
1210
|
+
executeBizarCommand(projectRoot, "stop-session", { sessionId });
|
|
1211
|
+
}
|
|
1212
|
+
catch (e) {
|
|
1213
|
+
return errorResponse("Bizar session stop failed: " + String(e?.message ?? e), 502);
|
|
1214
|
+
}
|
|
1215
|
+
selfWriteUntil = Date.now() + 250;
|
|
1216
|
+
await withWrite(async (b) => {
|
|
1217
|
+
const t = b.tasks.find(t => t.id === taskId);
|
|
1218
|
+
if (!t)
|
|
1219
|
+
return;
|
|
1220
|
+
t.state = "cancelled";
|
|
1221
|
+
t.updatedAt = nowIso();
|
|
1222
|
+
const r = b.sessions[sessionId];
|
|
1223
|
+
if (r) {
|
|
1224
|
+
r.status = "cancelled";
|
|
1225
|
+
r.endedAt = nowIso();
|
|
1226
|
+
}
|
|
1227
|
+
});
|
|
1228
|
+
const updated = (await getBoard()).tasks.find(t => t.id === taskId);
|
|
1229
|
+
await writeTaskMdx(updated, KANBAN_DIR, await getBoard());
|
|
1230
|
+
await writeBoardMdx(await getBoard(), KANBAN_DIR);
|
|
1231
|
+
broadcast("task.updated", updated);
|
|
1232
|
+
return jsonResponse(updated);
|
|
1233
|
+
}
|
|
1234
|
+
async function apiSessionStatus(sessionId) {
|
|
1235
|
+
return jsonResponse({ status: sessionStatusCache.get(sessionId) ?? "unknown" });
|
|
1236
|
+
}
|
|
1237
|
+
// ─── Archive / restore ──────────────────────────────────────────────────────
|
|
1238
|
+
export async function apiArchiveTask(_ctx, taskId) {
|
|
1239
|
+
const board = await getBoard();
|
|
1240
|
+
const task = board.tasks.find(t => t.id === taskId);
|
|
1241
|
+
if (!task)
|
|
1242
|
+
return errorResponse("Task not found", 404);
|
|
1243
|
+
const author = "user";
|
|
1244
|
+
selfWriteUntil = Date.now() + 250;
|
|
1245
|
+
// Cascade archive subtasks
|
|
1246
|
+
async function archiveSubtasks(ids) {
|
|
1247
|
+
for (const id of ids) {
|
|
1248
|
+
const subtask = (await getBoard()).tasks.find(t => t.id === id);
|
|
1249
|
+
if (!subtask || subtask.archived)
|
|
1250
|
+
continue;
|
|
1251
|
+
const updated = await archiveTask(subtask, KANBAN_DIR, author);
|
|
1252
|
+
await writeTaskMdx(updated, KANBAN_DIR, await getBoard());
|
|
1253
|
+
broadcast("task.updated", updated);
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
const updated = await archiveTask(task, KANBAN_DIR, author);
|
|
1257
|
+
await writeTaskMdx(updated, KANBAN_DIR, await getBoard());
|
|
1258
|
+
await writeBoardMdx(await getBoard(), KANBAN_DIR);
|
|
1259
|
+
broadcast("task.updated", updated);
|
|
1260
|
+
// Cascade to subtasks
|
|
1261
|
+
await archiveSubtasks(task.subtaskIds);
|
|
1262
|
+
return jsonResponse(updated);
|
|
1263
|
+
}
|
|
1264
|
+
export async function apiRestoreTask(_ctx, taskId) {
|
|
1265
|
+
const board = await getBoard();
|
|
1266
|
+
const task = board.tasks.find(t => t.id === taskId);
|
|
1267
|
+
if (!task)
|
|
1268
|
+
return errorResponse("Task not found", 404);
|
|
1269
|
+
const author = "user";
|
|
1270
|
+
selfWriteUntil = Date.now() + 250;
|
|
1271
|
+
// Cascade restore subtasks
|
|
1272
|
+
async function restoreSubtasks(ids) {
|
|
1273
|
+
for (const id of ids) {
|
|
1274
|
+
const subtask = (await getBoard()).tasks.find(t => t.id === id);
|
|
1275
|
+
if (!subtask || !subtask.archived)
|
|
1276
|
+
continue;
|
|
1277
|
+
const updated = await restoreTask(subtask, KANBAN_DIR, author);
|
|
1278
|
+
await writeTaskMdx(updated, KANBAN_DIR, await getBoard());
|
|
1279
|
+
broadcast("task.updated", updated);
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
const updated = await restoreTask(task, KANBAN_DIR, author);
|
|
1283
|
+
await writeTaskMdx(updated, KANBAN_DIR, await getBoard());
|
|
1284
|
+
await writeBoardMdx(await getBoard(), KANBAN_DIR);
|
|
1285
|
+
broadcast("task.updated", updated);
|
|
1286
|
+
// Cascade to subtasks
|
|
1287
|
+
await restoreSubtasks(task.subtaskIds);
|
|
1288
|
+
return jsonResponse(updated);
|
|
1289
|
+
}
|
|
1290
|
+
// ─── Changelog ─────────────────────────────────────────────────────────────
|
|
1291
|
+
export async function apiGetChangelog(req) {
|
|
1292
|
+
const url = new URL(req.url);
|
|
1293
|
+
const since = url.searchParams.get("since") ?? undefined;
|
|
1294
|
+
const until = url.searchParams.get("until") ?? undefined;
|
|
1295
|
+
const kindStr = url.searchParams.get("kind");
|
|
1296
|
+
const kind = kindStr;
|
|
1297
|
+
const taskId = url.searchParams.get("taskId") ?? undefined;
|
|
1298
|
+
const author = url.searchParams.get("author") ?? undefined;
|
|
1299
|
+
const limit = parseInt(url.searchParams.get("limit") ?? "200", 10);
|
|
1300
|
+
const offset = parseInt(url.searchParams.get("offset") ?? "0", 10);
|
|
1301
|
+
const completedOnly = url.searchParams.get("completedOnly") === "true" || url.searchParams.get("completedOnly") === "1";
|
|
1302
|
+
const reset = url.searchParams.get("reset") === "true";
|
|
1303
|
+
const result = readEvents(KANBAN_DIR, { since, until, kind, taskId, author, limit, offset, completedOnly, reset, kanbanDirForCompletedOnly: KANBAN_DIR });
|
|
1304
|
+
return jsonResponse(result, 200, { "Cache-Control": "no-cache, no-transform" });
|
|
1305
|
+
}
|
|
1306
|
+
async function apiGetChangelogSummary(req) {
|
|
1307
|
+
const url = new URL(req.url);
|
|
1308
|
+
const days = parseInt(url.searchParams.get("days") ?? "30", 10);
|
|
1309
|
+
const summary = readSummary(KANBAN_DIR, { days });
|
|
1310
|
+
return jsonResponse(summary);
|
|
1311
|
+
}
|
|
1312
|
+
async function apiGetInsightsVelocity(req) {
|
|
1313
|
+
const url = new URL(req.url);
|
|
1314
|
+
const rawDays = parseInt(url.searchParams.get("days") ?? "30", 10);
|
|
1315
|
+
const days = Math.max(1, Math.min(365, isFinite(rawDays) ? rawDays : 30));
|
|
1316
|
+
const buckets = computeVelocity(KANBAN_DIR, days);
|
|
1317
|
+
return jsonResponse({
|
|
1318
|
+
days: buckets.days,
|
|
1319
|
+
columns: {
|
|
1320
|
+
backlog: buckets.backlog,
|
|
1321
|
+
todo: buckets.todo,
|
|
1322
|
+
doing: buckets.doing,
|
|
1323
|
+
review: buckets.review,
|
|
1324
|
+
done: buckets.done,
|
|
1325
|
+
},
|
|
1326
|
+
windowDays: buckets.windowDays,
|
|
1327
|
+
generatedAt: buckets.generatedAt,
|
|
1328
|
+
});
|
|
1329
|
+
}
|
|
1330
|
+
// ─── Contributors & git attribution ────────────────────────────────────────
|
|
1331
|
+
async function apiGetContributors(req) {
|
|
1332
|
+
const url = new URL(req.url);
|
|
1333
|
+
const since = url.searchParams.get("since") ?? undefined;
|
|
1334
|
+
const until = url.searchParams.get("until") ?? undefined;
|
|
1335
|
+
const maxCount = parseInt(url.searchParams.get("maxCount") ?? "1000", 10);
|
|
1336
|
+
const projectRoot = join(KANBAN_DIR, "..");
|
|
1337
|
+
if (!isGitRepo(projectRoot))
|
|
1338
|
+
return jsonResponse([]);
|
|
1339
|
+
const contributors = listContributors(projectRoot, { since, until, maxCount });
|
|
1340
|
+
return jsonResponse(contributors);
|
|
1341
|
+
}
|
|
1342
|
+
async function apiGetTaskContributors(_ctx, taskId) {
|
|
1343
|
+
const board = await getBoard();
|
|
1344
|
+
const task = board.tasks.find(t => t.id === taskId);
|
|
1345
|
+
if (!task)
|
|
1346
|
+
return errorResponse("Task not found", 404);
|
|
1347
|
+
const projectRoot = join(KANBAN_DIR, "..");
|
|
1348
|
+
const attributed = attributeCommitsToTasks(projectRoot, [{ id: task.id, title: task.title, source: task.source }], {});
|
|
1349
|
+
const commits = attributed.get(task.id) ?? [];
|
|
1350
|
+
return jsonResponse(commits);
|
|
1351
|
+
}
|
|
1352
|
+
export async function apiOrganize(_ctx, req) {
|
|
1353
|
+
let body;
|
|
1354
|
+
try {
|
|
1355
|
+
body = await req.json();
|
|
1356
|
+
}
|
|
1357
|
+
catch {
|
|
1358
|
+
return errorResponse("Invalid JSON");
|
|
1359
|
+
}
|
|
1360
|
+
if (!Array.isArray(body.operations))
|
|
1361
|
+
return errorResponse("operations must be an array", 422);
|
|
1362
|
+
const applied = [];
|
|
1363
|
+
const skipped = [];
|
|
1364
|
+
selfWriteUntil = Date.now() + 1000;
|
|
1365
|
+
await withWrite(async (board) => {
|
|
1366
|
+
for (const op of body.operations) {
|
|
1367
|
+
const t = board.tasks.find(x => x.id === op.taskId);
|
|
1368
|
+
if (!t) {
|
|
1369
|
+
skipped.push({ taskId: op.taskId, kind: op.kind, reason: `Task not found` });
|
|
1370
|
+
continue;
|
|
1371
|
+
}
|
|
1372
|
+
const before = { column: t.column, tags: [...t.tags], priority: t.priority, effort: t.effort, category: t.category, archived: t.archived };
|
|
1373
|
+
try {
|
|
1374
|
+
switch (op.kind) {
|
|
1375
|
+
case "rederive": {
|
|
1376
|
+
const derived = extractMetadata({ title: t.title, description: t.description });
|
|
1377
|
+
t.tags = derived.tags;
|
|
1378
|
+
t.category = derived.category;
|
|
1379
|
+
t.priority = derived.priority;
|
|
1380
|
+
t.effort = derived.effort;
|
|
1381
|
+
break;
|
|
1382
|
+
}
|
|
1383
|
+
case "set-tags":
|
|
1384
|
+
t.tags = op.tags;
|
|
1385
|
+
break;
|
|
1386
|
+
case "add-tags":
|
|
1387
|
+
t.tags = [...new Set([...t.tags, ...op.tags])];
|
|
1388
|
+
break;
|
|
1389
|
+
case "remove-tag":
|
|
1390
|
+
t.tags = t.tags.filter(tag => tag !== op.tag);
|
|
1391
|
+
break;
|
|
1392
|
+
case "set-priority":
|
|
1393
|
+
t.priority = op.priority;
|
|
1394
|
+
break;
|
|
1395
|
+
case "set-effort":
|
|
1396
|
+
t.effort = op.effort;
|
|
1397
|
+
break;
|
|
1398
|
+
case "set-category":
|
|
1399
|
+
t.category = op.category;
|
|
1400
|
+
break;
|
|
1401
|
+
case "move":
|
|
1402
|
+
t.column = op.column;
|
|
1403
|
+
break;
|
|
1404
|
+
case "archive":
|
|
1405
|
+
t.archived = true;
|
|
1406
|
+
break;
|
|
1407
|
+
case "restore":
|
|
1408
|
+
t.archived = false;
|
|
1409
|
+
break;
|
|
1410
|
+
case "add-area":
|
|
1411
|
+
if (!t.tags.includes(`area:${op.area}`))
|
|
1412
|
+
t.tags = [...t.tags, `area:${op.area}`];
|
|
1413
|
+
break;
|
|
1414
|
+
default:
|
|
1415
|
+
skipped.push({
|
|
1416
|
+
taskId: op.taskId,
|
|
1417
|
+
kind: op.kind,
|
|
1418
|
+
reason: "Unknown operation kind",
|
|
1419
|
+
});
|
|
1420
|
+
continue;
|
|
1421
|
+
}
|
|
1422
|
+
t.updatedAt = nowIso();
|
|
1423
|
+
const after = { column: t.column, tags: [...t.tags], priority: t.priority, effort: t.effort, category: t.category, archived: t.archived };
|
|
1424
|
+
applied.push({ taskId: op.taskId, kind: op.kind, before, after });
|
|
1425
|
+
}
|
|
1426
|
+
catch (e) {
|
|
1427
|
+
skipped.push({ taskId: op.taskId, kind: op.kind, reason: String(e?.message ?? e) });
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
// Renormalize after any move ops
|
|
1431
|
+
if (applied.some(a => a.kind === "move")) {
|
|
1432
|
+
board.tasks = renormalizeOrder(board.tasks);
|
|
1433
|
+
}
|
|
1434
|
+
});
|
|
1435
|
+
const summary = {
|
|
1436
|
+
moved: applied.filter(a => a.kind === "move").length,
|
|
1437
|
+
retagged: applied.filter(a => ["set-tags", "add-tags", "remove-tag", "add-area", "set-category", "rederive"].includes(a.kind)).length,
|
|
1438
|
+
archived: applied.filter(a => a.kind === "archive").length,
|
|
1439
|
+
errors: skipped.length,
|
|
1440
|
+
};
|
|
1441
|
+
if (applied.length > 0) {
|
|
1442
|
+
const board = await getBoard();
|
|
1443
|
+
for (const a of applied) {
|
|
1444
|
+
const t = board.tasks.find(x => x.id === a.taskId);
|
|
1445
|
+
if (t) {
|
|
1446
|
+
await writeTaskMdx(t, KANBAN_DIR, board);
|
|
1447
|
+
broadcast("task.updated", t);
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
await writeBoardMdx(board, KANBAN_DIR);
|
|
1451
|
+
}
|
|
1452
|
+
recordEvent(KANBAN_DIR, "kanban.organized", {
|
|
1453
|
+
author: "user",
|
|
1454
|
+
summary: `organized ${applied.length} task(s)`,
|
|
1455
|
+
payload: { operations: applied, skipped },
|
|
1456
|
+
});
|
|
1457
|
+
return jsonResponse({ applied, skipped, summary });
|
|
1458
|
+
}
|
|
1459
|
+
/**
|
|
1460
|
+
* Move one or more selected tasks from the active project into another
|
|
1461
|
+
* registered project. Each task is cloned into the target `.ok/`
|
|
1462
|
+
* directory with a freshly-minted id, its source-side copy is removed,
|
|
1463
|
+
* and parent/child links are re-established inside the target board.
|
|
1464
|
+
*
|
|
1465
|
+
* Body: { taskIds: string[] }
|
|
1466
|
+
* Returns: 200 with { moved, skipped }.
|
|
1467
|
+
*/
|
|
1468
|
+
export async function apiMoveTasksToProject(targetProjectId, req) {
|
|
1469
|
+
let body;
|
|
1470
|
+
try {
|
|
1471
|
+
body = await req.json();
|
|
1472
|
+
}
|
|
1473
|
+
catch {
|
|
1474
|
+
return errorResponse("invalid JSON body");
|
|
1475
|
+
}
|
|
1476
|
+
if (!Array.isArray(body.taskIds) || body.taskIds.length === 0) {
|
|
1477
|
+
return errorResponse("taskIds must be a non-empty array", 400);
|
|
1478
|
+
}
|
|
1479
|
+
const taskIds = body.taskIds.map((id) => String(id)).filter((s) => s.length > 0);
|
|
1480
|
+
if (taskIds.length === 0)
|
|
1481
|
+
return errorResponse("taskIds must be a non-empty array", 400);
|
|
1482
|
+
// Resolve target via the project registry.
|
|
1483
|
+
const target = findProject(targetProjectId);
|
|
1484
|
+
if (!target)
|
|
1485
|
+
return errorResponse(`Unknown project: ${targetProjectId}`, 404);
|
|
1486
|
+
// Compute the target's `.ok/` directory. Falls back to `<root>/.ok`
|
|
1487
|
+
// when the registry entry does not yet have a `.ok/` on disk.
|
|
1488
|
+
const targetKanbanDir = (() => {
|
|
1489
|
+
const resolved = resolveKanbanDir(target.root);
|
|
1490
|
+
return resolved ?? join(target.root, ".ok");
|
|
1491
|
+
})();
|
|
1492
|
+
ensureDir(targetKanbanDir);
|
|
1493
|
+
ensureDir(join(targetKanbanDir, "tasks"));
|
|
1494
|
+
// Load the active board from memory; this is the source of truth for
|
|
1495
|
+
// what is being moved.
|
|
1496
|
+
const sourceBoard = await getBoard();
|
|
1497
|
+
const sourceKanbanDir = KANBAN_DIR;
|
|
1498
|
+
// Load the target board directly from disk so we don't disturb the
|
|
1499
|
+
// server's in-memory `_board` cache. The server stays bound to the
|
|
1500
|
+
// active project; the target is just an external file we mutate.
|
|
1501
|
+
const targetBoardFile = join(targetKanbanDir, "board.json");
|
|
1502
|
+
const targetBoard = (() => {
|
|
1503
|
+
const fallback = { version: 1, columns: [...DEFAULT_COLUMNS], tasks: [], sessions: {} };
|
|
1504
|
+
if (!existsSync(targetBoardFile))
|
|
1505
|
+
return fallback;
|
|
1506
|
+
try {
|
|
1507
|
+
const parsed = JSON.parse(readFileSync(targetBoardFile, "utf-8"));
|
|
1508
|
+
return {
|
|
1509
|
+
version: 1,
|
|
1510
|
+
columns: Array.isArray(parsed.columns) && parsed.columns.length ? parsed.columns : [...DEFAULT_COLUMNS],
|
|
1511
|
+
tasks: Array.isArray(parsed.tasks) ? parsed.tasks : [],
|
|
1512
|
+
sessions: parsed.sessions ?? {},
|
|
1513
|
+
};
|
|
1514
|
+
}
|
|
1515
|
+
catch {
|
|
1516
|
+
return fallback;
|
|
1517
|
+
}
|
|
1518
|
+
})();
|
|
1519
|
+
const movingIdSet = new Set(taskIds);
|
|
1520
|
+
// Resolve the target column for a (sourceColumnId, sourceColumnTitle)
|
|
1521
|
+
// pair. Tries id, then case-insensitive title, then the first column.
|
|
1522
|
+
const resolveTargetColumn = (sourceColumnId, sourceColumnTitle) => {
|
|
1523
|
+
if (sourceColumnId) {
|
|
1524
|
+
const byId = targetBoard.columns.find((c) => c.id === sourceColumnId);
|
|
1525
|
+
if (byId)
|
|
1526
|
+
return byId.id;
|
|
1527
|
+
}
|
|
1528
|
+
if (sourceColumnTitle) {
|
|
1529
|
+
const lower = sourceColumnTitle.toLowerCase();
|
|
1530
|
+
const byTitle = targetBoard.columns.find((c) => c.title && c.title.toLowerCase() === lower);
|
|
1531
|
+
if (byTitle)
|
|
1532
|
+
return byTitle.id;
|
|
1533
|
+
}
|
|
1534
|
+
return targetBoard.columns[0].id;
|
|
1535
|
+
};
|
|
1536
|
+
const moved = [];
|
|
1537
|
+
const skipped = [];
|
|
1538
|
+
const stages = [];
|
|
1539
|
+
for (const taskId of taskIds) {
|
|
1540
|
+
const src = sourceBoard.tasks.find((t) => t.id === taskId);
|
|
1541
|
+
if (!src) {
|
|
1542
|
+
skipped.push({ id: taskId, reason: "not found" });
|
|
1543
|
+
continue;
|
|
1544
|
+
}
|
|
1545
|
+
const freshId = newId("tsk");
|
|
1546
|
+
const srcCol = sourceBoard.columns?.find((c) => c.id === src.column);
|
|
1547
|
+
const newColumn = resolveTargetColumn(src.column, srcCol?.title);
|
|
1548
|
+
const orderCount = targetBoard.tasks.filter((t) => t.column === newColumn && !t.archived).length;
|
|
1549
|
+
const now = nowIso();
|
|
1550
|
+
const arts = taskArtifacts(freshId);
|
|
1551
|
+
const cloned = {
|
|
1552
|
+
...src,
|
|
1553
|
+
id: freshId,
|
|
1554
|
+
column: newColumn,
|
|
1555
|
+
sessionId: null,
|
|
1556
|
+
sessionArtifact: null,
|
|
1557
|
+
subtaskIds: [],
|
|
1558
|
+
parentId: null, // resolved in stage 2
|
|
1559
|
+
artifacts: arts,
|
|
1560
|
+
order: orderCount,
|
|
1561
|
+
createdAt: now,
|
|
1562
|
+
updatedAt: now,
|
|
1563
|
+
};
|
|
1564
|
+
stages.push({ sourceId: src.id, newId: freshId, newColumn, newTask: cloned });
|
|
1565
|
+
targetBoard.tasks.push(cloned);
|
|
1566
|
+
}
|
|
1567
|
+
// Stage 2: re-establish parent/child links using the source->new id
|
|
1568
|
+
// mapping. Only relink when both ends are being moved; orphaned
|
|
1569
|
+
// references fall back to null/undefined so the rest of the system
|
|
1570
|
+
// treats the task as a top-level entry.
|
|
1571
|
+
for (const stage of stages) {
|
|
1572
|
+
const src = sourceBoard.tasks.find((t) => t.id === stage.sourceId);
|
|
1573
|
+
if (!src)
|
|
1574
|
+
continue;
|
|
1575
|
+
// parent: if the source parent is moving, use the new parent id.
|
|
1576
|
+
if (src.parentId && movingIdSet.has(src.parentId)) {
|
|
1577
|
+
const parentStage = stages.find((s) => s.sourceId === src.parentId);
|
|
1578
|
+
if (parentStage) {
|
|
1579
|
+
stage.newTask.parentId = parentStage.newId;
|
|
1580
|
+
const parentCopy = targetBoard.tasks.find((t) => t.id === parentStage.newId);
|
|
1581
|
+
if (parentCopy && !parentCopy.subtaskIds.includes(stage.newId)) {
|
|
1582
|
+
parentCopy.subtaskIds = [...parentCopy.subtaskIds, stage.newId];
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
// subtasks: map each source subtask id to its new id (when moving)
|
|
1587
|
+
// and register it on the cloned parent.
|
|
1588
|
+
const childNewIds = [];
|
|
1589
|
+
for (const childSrcId of src.subtaskIds ?? []) {
|
|
1590
|
+
const childStage = stages.find((s) => s.sourceId === childSrcId);
|
|
1591
|
+
if (childStage)
|
|
1592
|
+
childNewIds.push(childStage.newId);
|
|
1593
|
+
}
|
|
1594
|
+
if (childNewIds.length) {
|
|
1595
|
+
const parentCopy = targetBoard.tasks.find((t) => t.id === stage.newId);
|
|
1596
|
+
if (parentCopy) {
|
|
1597
|
+
const seen = new Set(parentCopy.subtaskIds);
|
|
1598
|
+
for (const c of childNewIds)
|
|
1599
|
+
seen.add(c);
|
|
1600
|
+
parentCopy.subtaskIds = [...seen];
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
// Stage 3: copy per-task artifact directories from source to target.
|
|
1605
|
+
// If a copy fails we roll the cloned record back out of the target
|
|
1606
|
+
// board and surface it as a skipped entry so the response reflects
|
|
1607
|
+
// the true outcome.
|
|
1608
|
+
const successful = [];
|
|
1609
|
+
for (const stage of stages) {
|
|
1610
|
+
const srcDir = join(sourceKanbanDir, "tasks", stage.sourceId);
|
|
1611
|
+
const destDir = join(targetKanbanDir, "tasks", stage.newId);
|
|
1612
|
+
ensureDir(destDir);
|
|
1613
|
+
try {
|
|
1614
|
+
cpSync(srcDir, destDir, { recursive: true });
|
|
1615
|
+
successful.push(stage);
|
|
1616
|
+
}
|
|
1617
|
+
catch (e) {
|
|
1618
|
+
skipped.push({ id: stage.sourceId, reason: `copy failed: ${e?.message ?? e}` });
|
|
1619
|
+
targetBoard.tasks = targetBoard.tasks.filter((t) => t.id !== stage.newId);
|
|
1620
|
+
// Also drop this id from any parent's subtaskIds on the cloned
|
|
1621
|
+
// target so the on-disk board stays self-consistent.
|
|
1622
|
+
for (const t of targetBoard.tasks) {
|
|
1623
|
+
if (t.subtaskIds.includes(stage.newId))
|
|
1624
|
+
t.subtaskIds = t.subtaskIds.filter((x) => x !== stage.newId);
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
// Build the public `moved` summary from the records that survived
|
|
1629
|
+
// every stage.
|
|
1630
|
+
for (const stage of successful) {
|
|
1631
|
+
moved.push({
|
|
1632
|
+
sourceId: stage.sourceId,
|
|
1633
|
+
id: stage.newId,
|
|
1634
|
+
title: stage.newTask.title,
|
|
1635
|
+
column: stage.newTask.column,
|
|
1636
|
+
});
|
|
1637
|
+
}
|
|
1638
|
+
// Persist the target board atomically and re-render its MDX mirror.
|
|
1639
|
+
ensureDir(targetKanbanDir);
|
|
1640
|
+
writeFileAtomic(targetBoardFile, JSON.stringify(targetBoard, null, 2));
|
|
1641
|
+
writeBoardMdx(targetBoard, targetKanbanDir);
|
|
1642
|
+
// Finally, remove the source tasks from the active board and clean
|
|
1643
|
+
// their per-task directories. We do this last so a target-write
|
|
1644
|
+
// failure above doesn't leave the source board in a half-moved state.
|
|
1645
|
+
const successfulSourceIds = new Set(successful.map((s) => s.sourceId));
|
|
1646
|
+
if (successfulSourceIds.size > 0) {
|
|
1647
|
+
selfWriteUntil = Date.now() + 250;
|
|
1648
|
+
await withWrite(async (board) => {
|
|
1649
|
+
for (const sid of successfulSourceIds) {
|
|
1650
|
+
const idx = board.tasks.findIndex((t) => t.id === sid);
|
|
1651
|
+
if (idx === -1)
|
|
1652
|
+
continue;
|
|
1653
|
+
const removed = board.tasks[idx];
|
|
1654
|
+
if (removed.parentId) {
|
|
1655
|
+
const parent = board.tasks.find((t) => t.id === removed.parentId);
|
|
1656
|
+
if (parent)
|
|
1657
|
+
parent.subtaskIds = parent.subtaskIds.filter((x) => x !== sid);
|
|
1658
|
+
}
|
|
1659
|
+
board.tasks.splice(idx, 1);
|
|
1660
|
+
}
|
|
1661
|
+
board.tasks = renormalizeOrder(board.tasks);
|
|
1662
|
+
});
|
|
1663
|
+
for (const sid of successfulSourceIds) {
|
|
1664
|
+
removeDir(join(sourceKanbanDir, "tasks", sid));
|
|
1665
|
+
}
|
|
1666
|
+
const refreshed = await getBoard();
|
|
1667
|
+
await writeBoardMdx(refreshed, sourceKanbanDir);
|
|
1668
|
+
broadcast("board.updated", {});
|
|
1669
|
+
recordEvent(sourceKanbanDir, "task.deleted", {
|
|
1670
|
+
author: "user",
|
|
1671
|
+
summary: `moved ${successfulSourceIds.size} task(s) to project '${targetProjectId}'`,
|
|
1672
|
+
payload: { taskIds: [...successfulSourceIds], targetProjectId },
|
|
1673
|
+
});
|
|
1674
|
+
}
|
|
1675
|
+
return jsonResponse({ moved, skipped });
|
|
1676
|
+
}
|
|
1677
|
+
// ─── Import ──────────────────────────────────────────────────────────────────
|
|
1678
|
+
export async function apiImport(_ctx, req) {
|
|
1679
|
+
let body;
|
|
1680
|
+
try {
|
|
1681
|
+
body = await req.json();
|
|
1682
|
+
}
|
|
1683
|
+
catch {
|
|
1684
|
+
return errorResponse("Invalid JSON");
|
|
1685
|
+
}
|
|
1686
|
+
// Read defaults from config
|
|
1687
|
+
const configPath = join(KANBAN_DIR, "config.json");
|
|
1688
|
+
let config = {};
|
|
1689
|
+
try {
|
|
1690
|
+
if (existsSync(configPath))
|
|
1691
|
+
config = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
1692
|
+
}
|
|
1693
|
+
catch { /* ignore */ }
|
|
1694
|
+
const importConfig = config["import"] ?? {};
|
|
1695
|
+
const include = body.include ?? importConfig["include"] ?? ["docs/**", "*.md", "*.mdx"];
|
|
1696
|
+
const exclude = body.exclude ?? importConfig["exclude"] ?? [];
|
|
1697
|
+
const importCtx = {
|
|
1698
|
+
directory: KANBAN_DIR,
|
|
1699
|
+
client: null,
|
|
1700
|
+
log: async () => { },
|
|
1701
|
+
};
|
|
1702
|
+
const result = await runImport(importCtx, { include, exclude });
|
|
1703
|
+
return jsonResponse({ ok: true, imported: result.imported }, 201);
|
|
1704
|
+
}
|
|
1705
|
+
// ─── Settings ──────────────────────────────────────────────────────────────
|
|
1706
|
+
const DEFAULT_SETTINGS = {
|
|
1707
|
+
columns: ["backlog", "todo", "doing", "review", "done"],
|
|
1708
|
+
defaultAgent: "",
|
|
1709
|
+
defaultModel: null,
|
|
1710
|
+
};
|
|
1711
|
+
async function apiGetSettings() {
|
|
1712
|
+
const configPath = join(KANBAN_DIR, "openkan.json");
|
|
1713
|
+
let config = {};
|
|
1714
|
+
try {
|
|
1715
|
+
if (existsSync(configPath))
|
|
1716
|
+
config = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
1717
|
+
}
|
|
1718
|
+
catch { /* ignore */ }
|
|
1719
|
+
const projectRoot = join(KANBAN_DIR, "..");
|
|
1720
|
+
const gitUser = isGitRepo(projectRoot) ? { name: "git", email: "" } : null;
|
|
1721
|
+
const merged = { ...DEFAULT_SETTINGS, ...config, gitUser };
|
|
1722
|
+
return jsonResponse(merged);
|
|
1723
|
+
}
|
|
1724
|
+
async function apiPatchSettings(_ctx, req) {
|
|
1725
|
+
let patch;
|
|
1726
|
+
try {
|
|
1727
|
+
patch = await req.json();
|
|
1728
|
+
}
|
|
1729
|
+
catch {
|
|
1730
|
+
return errorResponse("Invalid JSON");
|
|
1731
|
+
}
|
|
1732
|
+
const configPath = join(KANBAN_DIR, "openkan.json");
|
|
1733
|
+
let existing = {};
|
|
1734
|
+
try {
|
|
1735
|
+
if (existsSync(configPath))
|
|
1736
|
+
existing = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
1737
|
+
}
|
|
1738
|
+
catch { /* ignore */ }
|
|
1739
|
+
const merged = { ...existing, ...patch };
|
|
1740
|
+
writeFileAtomic(configPath, JSON.stringify(merged, null, 2));
|
|
1741
|
+
recordEvent(KANBAN_DIR, "settings.changed", {
|
|
1742
|
+
author: "user",
|
|
1743
|
+
summary: "changed settings",
|
|
1744
|
+
payload: { changes: Object.keys(patch) },
|
|
1745
|
+
});
|
|
1746
|
+
return jsonResponse({ ok: true, settings: merged });
|
|
1747
|
+
}
|
|
1748
|
+
function loadConfig() {
|
|
1749
|
+
const configPath = join(KANBAN_DIR, "openkan.json");
|
|
1750
|
+
try {
|
|
1751
|
+
if (existsSync(configPath)) {
|
|
1752
|
+
return JSON.parse(readFileSync(configPath, "utf-8"));
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
catch { /* ignore */ }
|
|
1756
|
+
return {};
|
|
1757
|
+
}
|
|
1758
|
+
export async function apiGetConfigSections() {
|
|
1759
|
+
const config = loadConfig();
|
|
1760
|
+
const sections = [
|
|
1761
|
+
{
|
|
1762
|
+
id: "project",
|
|
1763
|
+
label: "Project",
|
|
1764
|
+
fields: [
|
|
1765
|
+
{ key: "defaultAgent", label: "Default agent", type: "text", value: config["project"]?.["defaultAgent"] ?? "" },
|
|
1766
|
+
{ key: "defaultModel", label: "Default model", type: "text", value: config["project"]?.["defaultModel"] ?? "" },
|
|
1767
|
+
{ key: "defaultColumn", label: "Default column", type: "select", value: config["project"]?.["defaultColumn"] ?? "backlog", options: [{ label: "Backlog", value: "backlog" }, { label: "To Do", value: "todo" }, { label: "In Progress", value: "doing" }, { label: "Review", value: "review" }, { label: "Done", value: "done" }], description: "Where newly created tasks start." },
|
|
1768
|
+
{ key: "autoArchiveDays", label: "Auto-archive days", type: "number", value: config["project"]?.["autoArchiveDays"] ?? 0, description: "Use 0 to keep completed tasks indefinitely." },
|
|
1769
|
+
],
|
|
1770
|
+
},
|
|
1771
|
+
{
|
|
1772
|
+
id: "server",
|
|
1773
|
+
label: "Server",
|
|
1774
|
+
fields: [
|
|
1775
|
+
{ key: "port", label: "Port", type: "number", value: config["port"] ?? 7777 },
|
|
1776
|
+
{ key: "host", label: "Host", type: "text", value: config["host"] ?? "127.0.0.1" },
|
|
1777
|
+
],
|
|
1778
|
+
},
|
|
1779
|
+
{
|
|
1780
|
+
id: "ui",
|
|
1781
|
+
label: "UI",
|
|
1782
|
+
fields: [
|
|
1783
|
+
{
|
|
1784
|
+
key: "theme", label: "Theme", type: "select", description: "Applied immediately and remembered for this browser.",
|
|
1785
|
+
value: config["theme"] ?? "dark",
|
|
1786
|
+
options: [
|
|
1787
|
+
{ label: "Light", value: "light" },
|
|
1788
|
+
{ label: "Dark", value: "dark" },
|
|
1789
|
+
{ label: "System", value: "system" },
|
|
1790
|
+
],
|
|
1791
|
+
},
|
|
1792
|
+
],
|
|
1793
|
+
},
|
|
1794
|
+
{
|
|
1795
|
+
id: "sandbox",
|
|
1796
|
+
label: "Sandbox",
|
|
1797
|
+
fields: [
|
|
1798
|
+
{ key: "tsxMaxBytes", label: "TSX max bytes", type: "number", value: config["sandbox"]?.["tsxMaxBytes"] ?? 32768 },
|
|
1799
|
+
],
|
|
1800
|
+
},
|
|
1801
|
+
{
|
|
1802
|
+
id: "bizar",
|
|
1803
|
+
label: "Agent runtime",
|
|
1804
|
+
fields: [
|
|
1805
|
+
{
|
|
1806
|
+
key: "enabled",
|
|
1807
|
+
label: "Enabled",
|
|
1808
|
+
type: "boolean",
|
|
1809
|
+
value: config["bizar"]?.["enabled"] ?? true,
|
|
1810
|
+
description: "Expose this project's configured Bizar control plane.",
|
|
1811
|
+
},
|
|
1812
|
+
{
|
|
1813
|
+
key: "projectRoot",
|
|
1814
|
+
label: "Bizar project root",
|
|
1815
|
+
type: "text",
|
|
1816
|
+
value: config["bizar"]?.["projectRoot"] ?? ".",
|
|
1817
|
+
description: "Absolute path or path relative to the OpenKan project.",
|
|
1818
|
+
},
|
|
1819
|
+
{
|
|
1820
|
+
key: "command",
|
|
1821
|
+
label: "Bizar command",
|
|
1822
|
+
type: "text",
|
|
1823
|
+
value: config["bizar"]?.["command"] ?? "bizar",
|
|
1824
|
+
description: "Executable or local cli/bin.mjs path. Never evaluated through a shell.",
|
|
1825
|
+
},
|
|
1826
|
+
],
|
|
1827
|
+
},
|
|
1828
|
+
{
|
|
1829
|
+
id: "import",
|
|
1830
|
+
label: "Import",
|
|
1831
|
+
fields: [
|
|
1832
|
+
{ key: "include", label: "Include paths", type: "text", value: JSON.stringify(config["import"]?.["include"] ?? []) },
|
|
1833
|
+
{ key: "exclude", label: "Exclude paths", type: "text", value: JSON.stringify(config["import"]?.["exclude"] ?? []) },
|
|
1834
|
+
],
|
|
1835
|
+
},
|
|
1836
|
+
{
|
|
1837
|
+
id: "chat",
|
|
1838
|
+
label: "Chat",
|
|
1839
|
+
fields: [
|
|
1840
|
+
{ key: "defaultModel", label: "Default model", type: "text", value: config["chat"]?.["defaultModel"] ?? "", description: "Leave empty to use the model router's configured default." },
|
|
1841
|
+
{ key: "defaultEffort", label: "Default effort", type: "select", value: config["chat"]?.["defaultEffort"] ?? "high", options: [{ label: "Low", value: "low" }, { label: "Medium", value: "medium" }, { label: "High", value: "high" }] },
|
|
1842
|
+
{ key: "permissionMode", label: "Permission mode", type: "select", value: config["chat"]?.["permissionMode"] ?? "default", options: [{ label: "Default", value: "default" }, { label: "Accept edits", value: "acceptEdits" }, { label: "Plan", value: "plan" }] },
|
|
1843
|
+
],
|
|
1844
|
+
},
|
|
1845
|
+
{
|
|
1846
|
+
id: "notifications",
|
|
1847
|
+
label: "Notifications",
|
|
1848
|
+
fields: [
|
|
1849
|
+
{ key: "desktop", label: "Desktop notifications", type: "boolean", value: config["notifications"]?.["desktop"] ?? false },
|
|
1850
|
+
{ key: "sound", label: "Completion sound", type: "boolean", value: config["notifications"]?.["sound"] ?? false },
|
|
1851
|
+
],
|
|
1852
|
+
},
|
|
1853
|
+
{
|
|
1854
|
+
id: "advanced",
|
|
1855
|
+
label: "Advanced",
|
|
1856
|
+
fields: [
|
|
1857
|
+
{ key: "autoArchiveAfterDays", label: "Auto-archive after days", type: "number", value: config["autoArchiveAfterDays"] ?? 0 },
|
|
1858
|
+
],
|
|
1859
|
+
},
|
|
1860
|
+
{
|
|
1861
|
+
id: "agents",
|
|
1862
|
+
label: "Agents",
|
|
1863
|
+
fields: [
|
|
1864
|
+
{
|
|
1865
|
+
key: "active",
|
|
1866
|
+
label: "Active profile",
|
|
1867
|
+
type: "text",
|
|
1868
|
+
value: config["agents"]?.["active"] ?? "claude-code",
|
|
1869
|
+
},
|
|
1870
|
+
{
|
|
1871
|
+
key: "profiles",
|
|
1872
|
+
label: "Profiles (read-only)",
|
|
1873
|
+
type: "text",
|
|
1874
|
+
value: JSON.stringify(config["agents"]?.["profiles"] ?? []),
|
|
1875
|
+
},
|
|
1876
|
+
],
|
|
1877
|
+
},
|
|
1878
|
+
{
|
|
1879
|
+
id: "contributors",
|
|
1880
|
+
label: "Contributors",
|
|
1881
|
+
fields: [],
|
|
1882
|
+
},
|
|
1883
|
+
];
|
|
1884
|
+
return jsonResponse({ sections });
|
|
1885
|
+
}
|
|
1886
|
+
export async function apiPatchConfigSection(_ctx, sectionId, req) {
|
|
1887
|
+
let body;
|
|
1888
|
+
try {
|
|
1889
|
+
body = await req.json();
|
|
1890
|
+
}
|
|
1891
|
+
catch {
|
|
1892
|
+
return errorResponse("Invalid JSON");
|
|
1893
|
+
}
|
|
1894
|
+
if (!Array.isArray(body))
|
|
1895
|
+
return errorResponse("body must be an array of { key, value }", 422);
|
|
1896
|
+
const config = loadConfig();
|
|
1897
|
+
// Map sectionId to config path
|
|
1898
|
+
for (const { key, value } of body) {
|
|
1899
|
+
switch (sectionId) {
|
|
1900
|
+
case "project":
|
|
1901
|
+
if (!config["project"])
|
|
1902
|
+
config["project"] = {};
|
|
1903
|
+
config["project"][key] = value;
|
|
1904
|
+
break;
|
|
1905
|
+
case "server":
|
|
1906
|
+
config[key] = value;
|
|
1907
|
+
break;
|
|
1908
|
+
case "ui":
|
|
1909
|
+
config[key] = value;
|
|
1910
|
+
break;
|
|
1911
|
+
case "sandbox":
|
|
1912
|
+
if (!config["sandbox"])
|
|
1913
|
+
config["sandbox"] = {};
|
|
1914
|
+
config["sandbox"][key] = value;
|
|
1915
|
+
break;
|
|
1916
|
+
case "bizar":
|
|
1917
|
+
if (!config["bizar"])
|
|
1918
|
+
config["bizar"] = {};
|
|
1919
|
+
config["bizar"][key] = value;
|
|
1920
|
+
break;
|
|
1921
|
+
case "import":
|
|
1922
|
+
if (!config["import"])
|
|
1923
|
+
config["import"] = {};
|
|
1924
|
+
if (key === "include" || key === "exclude") {
|
|
1925
|
+
// Stored as JSON arrays
|
|
1926
|
+
config["import"][key] = typeof value === "string" ? JSON.parse(value) : value;
|
|
1927
|
+
}
|
|
1928
|
+
else {
|
|
1929
|
+
config["import"][key] = value;
|
|
1930
|
+
}
|
|
1931
|
+
break;
|
|
1932
|
+
case "chat":
|
|
1933
|
+
case "notifications":
|
|
1934
|
+
if (!config[sectionId])
|
|
1935
|
+
config[sectionId] = {};
|
|
1936
|
+
config[sectionId][key] = value;
|
|
1937
|
+
break;
|
|
1938
|
+
case "advanced":
|
|
1939
|
+
config[key] = value;
|
|
1940
|
+
break;
|
|
1941
|
+
case "agents":
|
|
1942
|
+
if (!config["agents"])
|
|
1943
|
+
config["agents"] = { active: "claude-code", profiles: [] };
|
|
1944
|
+
config["agents"][key] = value;
|
|
1945
|
+
break;
|
|
1946
|
+
default:
|
|
1947
|
+
return errorResponse(`Unknown section: ${sectionId}`, 404);
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
const configPath = join(KANBAN_DIR, "openkan.json");
|
|
1951
|
+
// Validate agents section before persisting
|
|
1952
|
+
if (sectionId === "agents") {
|
|
1953
|
+
const agents = config["agents"];
|
|
1954
|
+
if (agents) {
|
|
1955
|
+
const err = validateAgentsConfig(agents);
|
|
1956
|
+
if (err)
|
|
1957
|
+
return errorResponse(`Invalid agents config at ${err.path}: ${err.reason}`, 422);
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
writeFileAtomic(configPath, JSON.stringify(config, null, 2));
|
|
1961
|
+
// Return the updated section
|
|
1962
|
+
return apiGetConfigSections();
|
|
1963
|
+
}
|
|
1964
|
+
// ─── Updated tasks index (includes archived) ────────────────────────────────
|
|
1965
|
+
function taskToIndexEntry(task, _includeArchived) {
|
|
1966
|
+
return {
|
|
1967
|
+
id: task.id,
|
|
1968
|
+
title: task.title,
|
|
1969
|
+
column: task.column,
|
|
1970
|
+
order: task.order,
|
|
1971
|
+
state: task.state,
|
|
1972
|
+
mdxPath: task.artifacts?.mdxPath ?? task.artifact,
|
|
1973
|
+
agent: task.agent,
|
|
1974
|
+
model: task.model,
|
|
1975
|
+
createdAt: task.createdAt,
|
|
1976
|
+
updatedAt: task.updatedAt,
|
|
1977
|
+
source: task.source,
|
|
1978
|
+
tags: task.tags ?? [],
|
|
1979
|
+
category: task.category ?? "task",
|
|
1980
|
+
priority: task.priority ?? "normal",
|
|
1981
|
+
effort: task.effort ?? null,
|
|
1982
|
+
archived: task.archived,
|
|
1983
|
+
contributors: [],
|
|
1984
|
+
};
|
|
1985
|
+
}
|
|
1986
|
+
async function apiGetTasksIndex(req) {
|
|
1987
|
+
const url = new URL(req.url);
|
|
1988
|
+
const includeArchived = url.searchParams.get("includeArchived") === "true";
|
|
1989
|
+
const board = await getBoard();
|
|
1990
|
+
let tasks = board.tasks.map(t => taskToIndexEntry(t, includeArchived));
|
|
1991
|
+
if (!includeArchived)
|
|
1992
|
+
tasks = tasks.filter(t => !t.archived);
|
|
1993
|
+
// Add top-5 contributors per task
|
|
1994
|
+
const projectRoot = join(KANBAN_DIR, "..");
|
|
1995
|
+
if (isGitRepo(projectRoot)) {
|
|
1996
|
+
const allContributors = listContributors(projectRoot, {});
|
|
1997
|
+
for (const t of tasks) {
|
|
1998
|
+
const attributed = attributeCommitsToTasks(projectRoot, [{ id: t.id, title: t.title, source: t.source }], {});
|
|
1999
|
+
const taskCommits = attributed.get(t.id) ?? [];
|
|
2000
|
+
const topEmails = [...new Set(taskCommits.map(c => c.email))].slice(0, 5);
|
|
2001
|
+
t.contributors = topEmails.map(email => {
|
|
2002
|
+
const c = allContributors.find(x => x.email === email);
|
|
2003
|
+
return c ? { name: c.name, email: c.email, lastSeen: c.lastSeen } : { name: email, email, lastSeen: "" };
|
|
2004
|
+
});
|
|
2005
|
+
}
|
|
2006
|
+
}
|
|
2007
|
+
return jsonResponse({ tasks });
|
|
2008
|
+
}
|
|
2009
|
+
// ─── Updated task detail (adds contributors) ───────────────────────────────
|
|
2010
|
+
export async function apiGetTask(taskId) {
|
|
2011
|
+
const board = await getBoard();
|
|
2012
|
+
const task = board.tasks.find(t => t.id === taskId);
|
|
2013
|
+
if (!task)
|
|
2014
|
+
return errorResponse("Task not found", 404);
|
|
2015
|
+
let mdx = "";
|
|
2016
|
+
const mdxPath = join(KANBAN_DIR, task.artifacts.mdxPath);
|
|
2017
|
+
if (existsSync(mdxPath)) {
|
|
2018
|
+
try {
|
|
2019
|
+
if (statSync(mdxPath).isFile())
|
|
2020
|
+
mdx = readFileSync(mdxPath, "utf-8");
|
|
2021
|
+
}
|
|
2022
|
+
catch { /* ignore */ }
|
|
2023
|
+
}
|
|
2024
|
+
const blocks = await renderMdx(mdx);
|
|
2025
|
+
const renderedHtml = await renderMarkdown(mdx);
|
|
2026
|
+
const comments = listComments(taskId, KANBAN_DIR);
|
|
2027
|
+
const inputs = listInputs(taskId, KANBAN_DIR);
|
|
2028
|
+
// Fetch immediate subtasks
|
|
2029
|
+
const subtasks = task.subtaskIds
|
|
2030
|
+
.map(id => board.tasks.find(t => t.id === id))
|
|
2031
|
+
.filter((t) => t !== undefined);
|
|
2032
|
+
const projectRoot = join(KANBAN_DIR, "..");
|
|
2033
|
+
let commits = [];
|
|
2034
|
+
if (isGitRepo(projectRoot)) {
|
|
2035
|
+
const attributed = attributeCommitsToTasks(projectRoot, [{ id: task.id, title: task.title, source: task.source }], {});
|
|
2036
|
+
commits = attributed.get(task.id) ?? [];
|
|
2037
|
+
}
|
|
2038
|
+
return jsonResponse({
|
|
2039
|
+
task,
|
|
2040
|
+
mdx,
|
|
2041
|
+
metadata: {
|
|
2042
|
+
title: task.title,
|
|
2043
|
+
description: extractDescription(mdx),
|
|
2044
|
+
tags: task.tags || [],
|
|
2045
|
+
category: task.category,
|
|
2046
|
+
priority: task.priority,
|
|
2047
|
+
effort: task.effort,
|
|
2048
|
+
assignees: task.assignees || [],
|
|
2049
|
+
updatedAt: task.updatedAt,
|
|
2050
|
+
mtime: statMdxMtime(taskId, KANBAN_DIR) || null,
|
|
2051
|
+
},
|
|
2052
|
+
html: renderedHtml,
|
|
2053
|
+
blocks: blocks.blocks,
|
|
2054
|
+
comments,
|
|
2055
|
+
inputs,
|
|
2056
|
+
subtasks,
|
|
2057
|
+
attributions: commits,
|
|
2058
|
+
});
|
|
2059
|
+
}
|
|
2060
|
+
export async function apiGetSubtasks(taskId) {
|
|
2061
|
+
const board = await getBoard();
|
|
2062
|
+
const task = board.tasks.find(t => t.id === taskId);
|
|
2063
|
+
if (!task)
|
|
2064
|
+
return errorResponse("Task not found", 404);
|
|
2065
|
+
const subtasks = task.subtaskIds
|
|
2066
|
+
.map(id => board.tasks.find(t => t.id === id))
|
|
2067
|
+
.filter((t) => t !== undefined);
|
|
2068
|
+
return jsonResponse({ subtasks });
|
|
2069
|
+
}
|
|
2070
|
+
// ─── Image endpoints ──────────────────────────────────────────────────────────
|
|
2071
|
+
/**
|
|
2072
|
+
* POST /api/tasks/:id/images
|
|
2073
|
+
* Body (v1 JSON): { data: base64, filename?: string, contentType?: string }
|
|
2074
|
+
* Frontend sends files as base64 via FileReader.readAsDataURL().
|
|
2075
|
+
* Alternative (not implemented in v1): multipart/form-data with raw bytes.
|
|
2076
|
+
*/
|
|
2077
|
+
async function apiUploadImage(_ctx, taskId, req) {
|
|
2078
|
+
let body;
|
|
2079
|
+
try {
|
|
2080
|
+
body = await req.json();
|
|
2081
|
+
}
|
|
2082
|
+
catch {
|
|
2083
|
+
return errorResponse("Invalid JSON");
|
|
2084
|
+
}
|
|
2085
|
+
if (!body.data)
|
|
2086
|
+
return errorResponse("data (base64) is required", 422);
|
|
2087
|
+
// Decode base64
|
|
2088
|
+
let buffer;
|
|
2089
|
+
try {
|
|
2090
|
+
const base64Data = body.data.replace(/^data:[^;]+;base64,/, ""); // strip optional mime prefix
|
|
2091
|
+
buffer = Buffer.from(base64Data, "base64");
|
|
2092
|
+
}
|
|
2093
|
+
catch {
|
|
2094
|
+
return errorResponse("Invalid base64 data", 422);
|
|
2095
|
+
}
|
|
2096
|
+
// Determine extension from contentType or filename
|
|
2097
|
+
let ext = "";
|
|
2098
|
+
if (body.filename) {
|
|
2099
|
+
const m = body.filename.match(/\.([^.]+)$/);
|
|
2100
|
+
if (m)
|
|
2101
|
+
ext = m[1];
|
|
2102
|
+
}
|
|
2103
|
+
if (!ext && body.contentType) {
|
|
2104
|
+
const map = {
|
|
2105
|
+
"image/png": "png", "image/jpeg": "jpg", "image/gif": "gif",
|
|
2106
|
+
"image/webp": "webp", "image/svg+xml": "svg",
|
|
2107
|
+
};
|
|
2108
|
+
ext = map[body.contentType] ?? "";
|
|
2109
|
+
}
|
|
2110
|
+
if (!ext)
|
|
2111
|
+
ext = "png"; // default
|
|
2112
|
+
const author = "user";
|
|
2113
|
+
let meta;
|
|
2114
|
+
try {
|
|
2115
|
+
meta = saveImage(taskId, KANBAN_DIR, buffer, ext, body.contentType ?? `image/${ext}`, author);
|
|
2116
|
+
}
|
|
2117
|
+
catch (e) {
|
|
2118
|
+
return errorResponse(e?.message ?? "Failed to save image", 422);
|
|
2119
|
+
}
|
|
2120
|
+
// Add image filename to task.images list
|
|
2121
|
+
selfWriteUntil = Date.now() + 250;
|
|
2122
|
+
await withWrite(async (board) => {
|
|
2123
|
+
const t = board.tasks.find(t => t.id === taskId);
|
|
2124
|
+
if (t) {
|
|
2125
|
+
if (!t.images)
|
|
2126
|
+
t.images = [];
|
|
2127
|
+
if (!t.images.includes(meta.name))
|
|
2128
|
+
t.images.push(meta.name);
|
|
2129
|
+
t.updatedAt = nowIso();
|
|
2130
|
+
}
|
|
2131
|
+
});
|
|
2132
|
+
broadcast("task.image-added", { taskId, image: meta });
|
|
2133
|
+
recordEvent(KANBAN_DIR, "task.image-added", {
|
|
2134
|
+
taskId,
|
|
2135
|
+
author,
|
|
2136
|
+
summary: `uploaded image '${meta.name}'`,
|
|
2137
|
+
payload: { imageName: meta.name, size: meta.size },
|
|
2138
|
+
});
|
|
2139
|
+
return jsonResponse(meta, 201);
|
|
2140
|
+
}
|
|
2141
|
+
async function apiListImages(_ctx, taskId) {
|
|
2142
|
+
const images = listImages(taskId, KANBAN_DIR);
|
|
2143
|
+
return jsonResponse({ images });
|
|
2144
|
+
}
|
|
2145
|
+
async function apiGetImage(_ctx, taskId, name) {
|
|
2146
|
+
const result = readImage(taskId, KANBAN_DIR, name);
|
|
2147
|
+
if (!result)
|
|
2148
|
+
return errorResponse("Image not found", 404);
|
|
2149
|
+
return new Response(result.buffer, {
|
|
2150
|
+
headers: {
|
|
2151
|
+
"Content-Type": result.contentType,
|
|
2152
|
+
"Cache-Control": "max-age=3600",
|
|
2153
|
+
},
|
|
2154
|
+
});
|
|
2155
|
+
}
|
|
2156
|
+
async function apiDeleteImage(_ctx, taskId, name) {
|
|
2157
|
+
const ok = deleteImage(taskId, KANBAN_DIR, name);
|
|
2158
|
+
if (!ok)
|
|
2159
|
+
return errorResponse("Image not found", 404);
|
|
2160
|
+
// Remove from task.images list
|
|
2161
|
+
selfWriteUntil = Date.now() + 250;
|
|
2162
|
+
await withWrite(async (board) => {
|
|
2163
|
+
const t = board.tasks.find(t => t.id === taskId);
|
|
2164
|
+
if (t && t.images) {
|
|
2165
|
+
t.images = t.images.filter(n => n !== name);
|
|
2166
|
+
t.updatedAt = nowIso();
|
|
2167
|
+
}
|
|
2168
|
+
});
|
|
2169
|
+
broadcast("task.image-deleted", { taskId, imageName: name });
|
|
2170
|
+
recordEvent(KANBAN_DIR, "task.image-deleted", {
|
|
2171
|
+
taskId,
|
|
2172
|
+
author: "user",
|
|
2173
|
+
summary: `deleted image '${name}'`,
|
|
2174
|
+
payload: { imageName: name },
|
|
2175
|
+
});
|
|
2176
|
+
return jsonResponse({ ok: true });
|
|
2177
|
+
}
|
|
2178
|
+
// ─── /api/me ─────────────────────────────────────────────────────────────────
|
|
2179
|
+
async function apiGetMe(_ctx) {
|
|
2180
|
+
const projectRoot = join(KANBAN_DIR, "..");
|
|
2181
|
+
const gitUser = currentGitUser(projectRoot);
|
|
2182
|
+
const name = gitUser?.name ?? "user";
|
|
2183
|
+
const email = gitUser?.email ?? "";
|
|
2184
|
+
// Count tasks assigned to this user
|
|
2185
|
+
const board = await getBoard();
|
|
2186
|
+
const currentTasks = board.tasks.filter(t => t.assignees?.includes(name) && !t.archived).length;
|
|
2187
|
+
return jsonResponse({ name, email, currentTasks });
|
|
2188
|
+
}
|
|
2189
|
+
// ─── Project registry endpoints ───────────────────────────────────────────────
|
|
2190
|
+
async function apiGetProjects() {
|
|
2191
|
+
return jsonResponse({ active: activeProject(), projects: listProjects() });
|
|
2192
|
+
}
|
|
2193
|
+
async function apiCreateProject(req) {
|
|
2194
|
+
let body;
|
|
2195
|
+
try {
|
|
2196
|
+
body = await req.json();
|
|
2197
|
+
}
|
|
2198
|
+
catch {
|
|
2199
|
+
return errorResponse("Invalid JSON");
|
|
2200
|
+
}
|
|
2201
|
+
if (!body.root)
|
|
2202
|
+
return errorResponse("root is required", 422);
|
|
2203
|
+
const entry = addProject({ name: body.name ?? body.root, root: body.root });
|
|
2204
|
+
await ensureBoardForProject(projectBoardContext(entry.root));
|
|
2205
|
+
return jsonResponse(entry, 201);
|
|
2206
|
+
}
|
|
2207
|
+
async function apiAutoDetectProjects(req) {
|
|
2208
|
+
let body;
|
|
2209
|
+
try {
|
|
2210
|
+
body = await req.json().catch(() => ({}));
|
|
2211
|
+
}
|
|
2212
|
+
catch {
|
|
2213
|
+
body = {};
|
|
2214
|
+
}
|
|
2215
|
+
const result = await autoDetectProjects({
|
|
2216
|
+
homes: body.homes,
|
|
2217
|
+
suffixes: body.suffixes,
|
|
2218
|
+
maxResults: body.maxResults,
|
|
2219
|
+
});
|
|
2220
|
+
return jsonResponse(result);
|
|
2221
|
+
}
|
|
2222
|
+
async function apiDeleteProject(id) {
|
|
2223
|
+
const wasActive = activeProject()?.id === id;
|
|
2224
|
+
const ok = removeProject(id);
|
|
2225
|
+
if (!ok)
|
|
2226
|
+
return errorResponse("Project not found", 404);
|
|
2227
|
+
// If the deleted project was active, the registry already switched to the next one
|
|
2228
|
+
if (wasActive) {
|
|
2229
|
+
const newActive = activeProject();
|
|
2230
|
+
if (newActive) {
|
|
2231
|
+
await ensureBoardForProject(projectBoardContext(newActive.root));
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
return jsonResponse({ ok: true });
|
|
2235
|
+
}
|
|
2236
|
+
async function apiActivateProject(id) {
|
|
2237
|
+
const prev = setActiveProject(id);
|
|
2238
|
+
if (prev === null)
|
|
2239
|
+
return errorResponse("Project not found", 404);
|
|
2240
|
+
const entry = activeProject();
|
|
2241
|
+
if (entry)
|
|
2242
|
+
await ensureBoardForProject(projectBoardContext(entry.root));
|
|
2243
|
+
return jsonResponse(entry);
|
|
2244
|
+
}
|
|
2245
|
+
function projectBoardContext(directory) {
|
|
2246
|
+
return { directory, client: null, log: async () => undefined };
|
|
2247
|
+
}
|
|
2248
|
+
// ─── Active project info endpoint ─────────────────────────────────────────────
|
|
2249
|
+
async function apiGetProject() {
|
|
2250
|
+
return jsonResponse({ active: activeProject() });
|
|
2251
|
+
}
|
|
2252
|
+
// ─── Docs endpoints ───────────────────────────────────────────────────────────
|
|
2253
|
+
async function apiGetDocs() {
|
|
2254
|
+
const root = getActiveProjectRoot();
|
|
2255
|
+
const { entries } = listDocs({ root });
|
|
2256
|
+
return jsonResponse({ entries });
|
|
2257
|
+
}
|
|
2258
|
+
async function apiGetDoc(req, path) {
|
|
2259
|
+
const root = getActiveProjectRoot();
|
|
2260
|
+
const url = new URL(req.url);
|
|
2261
|
+
const rawFlag = url.searchParams.get("raw") === "1";
|
|
2262
|
+
try {
|
|
2263
|
+
const doc = await readDoc({ root, relPath: path, render: !rawFlag });
|
|
2264
|
+
if (rawFlag) {
|
|
2265
|
+
return new Response(doc.raw, { headers: { "Content-Type": "text/markdown" } });
|
|
2266
|
+
}
|
|
2267
|
+
return jsonResponse(doc);
|
|
2268
|
+
}
|
|
2269
|
+
catch (e) {
|
|
2270
|
+
return errorResponse(e.message ?? "Not found", 404);
|
|
2271
|
+
}
|
|
2272
|
+
}
|
|
2273
|
+
async function apiWriteDoc(req, relPath) {
|
|
2274
|
+
const root = getActiveProjectRoot();
|
|
2275
|
+
if (!relPath || relPath.includes("..") || !/\.(md|mdx|txt)$/i.test(relPath))
|
|
2276
|
+
return errorResponse("Use a safe .md, .mdx, or .txt path", 422);
|
|
2277
|
+
let body;
|
|
2278
|
+
try {
|
|
2279
|
+
body = await req.json();
|
|
2280
|
+
}
|
|
2281
|
+
catch {
|
|
2282
|
+
return errorResponse("Invalid JSON");
|
|
2283
|
+
}
|
|
2284
|
+
if (typeof body.content !== "string")
|
|
2285
|
+
return errorResponse("content is required", 422);
|
|
2286
|
+
const docsRoot = resolve(root, "docs");
|
|
2287
|
+
const target = resolve(docsRoot, relPath);
|
|
2288
|
+
if (!target.startsWith(`${docsRoot}/`))
|
|
2289
|
+
return errorResponse("Unsafe path", 422);
|
|
2290
|
+
mkdirSync(join(target, ".."), { recursive: true });
|
|
2291
|
+
writeFileSync(target, body.content, "utf8");
|
|
2292
|
+
return apiGetDoc(new Request(`http://local/api/docs/${encodeURI(relPath)}`), relPath);
|
|
2293
|
+
}
|
|
2294
|
+
async function apiDeleteDoc(relPath) {
|
|
2295
|
+
const root = getActiveProjectRoot();
|
|
2296
|
+
const docsRoot = resolve(root, "docs");
|
|
2297
|
+
const target = resolve(docsRoot, relPath);
|
|
2298
|
+
if (!relPath || relPath.includes("..") || !target.startsWith(`${docsRoot}/`) || !existsSync(target))
|
|
2299
|
+
return errorResponse("Document not found", 404);
|
|
2300
|
+
rmSync(target);
|
|
2301
|
+
return jsonResponse({ ok: true, path: relPath });
|
|
2302
|
+
}
|
|
2303
|
+
async function apiRenderDoc(req) {
|
|
2304
|
+
let body;
|
|
2305
|
+
try {
|
|
2306
|
+
body = await req.json();
|
|
2307
|
+
}
|
|
2308
|
+
catch {
|
|
2309
|
+
return errorResponse("Invalid JSON");
|
|
2310
|
+
}
|
|
2311
|
+
if (typeof body.content !== "string")
|
|
2312
|
+
return errorResponse("content is required", 422);
|
|
2313
|
+
const rendered = await renderMdx(body.content);
|
|
2314
|
+
return jsonResponse({ html: rendered.html, rendered: rendered.html, blocks: rendered.blocks });
|
|
2315
|
+
}
|
|
2316
|
+
async function apiGenerateDoc(req) {
|
|
2317
|
+
const root = getActiveProjectRoot();
|
|
2318
|
+
let body;
|
|
2319
|
+
try {
|
|
2320
|
+
body = await req.json();
|
|
2321
|
+
}
|
|
2322
|
+
catch {
|
|
2323
|
+
return errorResponse("Invalid JSON");
|
|
2324
|
+
}
|
|
2325
|
+
const path = typeof body.path === "string" ? body.path : "";
|
|
2326
|
+
const prompt = typeof body.prompt === "string" ? body.prompt : "";
|
|
2327
|
+
if (!path || !prompt)
|
|
2328
|
+
return errorResponse("path and prompt are required", 422);
|
|
2329
|
+
const result = await sendTurn(root, { message: `Write a complete Markdown document for docs/${path}. ${prompt}. Return only the document content.`, model: typeof body.model === "string" ? body.model : "default", effort: typeof body.effort === "string" ? body.effort : "high", permissionMode: typeof body.permissionMode === "string" ? body.permissionMode : "bypassPermissions" });
|
|
2330
|
+
const content = result.assistantTurn.content;
|
|
2331
|
+
const writeReq = new Request("http://local/api/docs", { method: "PUT", body: JSON.stringify({ content }), headers: { "content-type": "application/json" } });
|
|
2332
|
+
return apiWriteDoc(writeReq, path);
|
|
2333
|
+
}
|
|
2334
|
+
// ─── File-system browser endpoints ─────────────────────────────────────────────
|
|
2335
|
+
/**
|
|
2336
|
+
* GET /api/fs?path=/abs/path&depth=2&includeHidden=0
|
|
2337
|
+
* Returns FsEntry for that path with children up to depth.
|
|
2338
|
+
* 400 if path is outside allowed roots.
|
|
2339
|
+
*/
|
|
2340
|
+
async function apiFs(req) {
|
|
2341
|
+
const url = new URL(req.url);
|
|
2342
|
+
const rawPath = url.searchParams.get("path");
|
|
2343
|
+
if (!rawPath)
|
|
2344
|
+
return errorResponse("path is required", 400);
|
|
2345
|
+
// Must be absolute
|
|
2346
|
+
if (!rawPath.startsWith("/"))
|
|
2347
|
+
return errorResponse("path must be absolute", 400);
|
|
2348
|
+
// Deny-list check
|
|
2349
|
+
if (isDenyListed(rawPath))
|
|
2350
|
+
return errorResponse("Access denied: deny-listed path", 403);
|
|
2351
|
+
// Symlink check: canonicalize and verify allowed
|
|
2352
|
+
const { realPath, allowed } = realPathIfAllowed(rawPath);
|
|
2353
|
+
if (!allowed)
|
|
2354
|
+
return errorResponse("Access denied: symlink resolves outside allowed tree", 403);
|
|
2355
|
+
// Cap depth at 5, maxEntries at 1000
|
|
2356
|
+
const depth = Math.min(parseInt(url.searchParams.get("depth") ?? "1", 10), 5);
|
|
2357
|
+
const includeHidden = url.searchParams.get("includeHidden") === "1" || url.searchParams.get("includeHidden") === "true";
|
|
2358
|
+
const maxEntries = Math.min(parseInt(url.searchParams.get("maxEntries") ?? "500", 10), 1000);
|
|
2359
|
+
const result = await listFs({ root: realPath, depth, includeHidden, followSymlinks: false, maxEntries });
|
|
2360
|
+
return jsonResponse(result);
|
|
2361
|
+
}
|
|
2362
|
+
/**
|
|
2363
|
+
* GET /api/home
|
|
2364
|
+
* Returns { home, entries } for the user's home directory.
|
|
2365
|
+
*/
|
|
2366
|
+
async function apiHome() {
|
|
2367
|
+
const result = await readHome();
|
|
2368
|
+
return jsonResponse(result);
|
|
2369
|
+
}
|
|
2370
|
+
/**
|
|
2371
|
+
* GET /api/parents?path=/abs/path&maxDepth=8
|
|
2372
|
+
* Returns array of FsEntry for ancestor directories (each depth 0, no children).
|
|
2373
|
+
* Used for breadcrumbs.
|
|
2374
|
+
*/
|
|
2375
|
+
async function apiParents(req) {
|
|
2376
|
+
const url = new URL(req.url);
|
|
2377
|
+
const rawPath = url.searchParams.get("path");
|
|
2378
|
+
if (!rawPath)
|
|
2379
|
+
return errorResponse("path is required", 400);
|
|
2380
|
+
if (!rawPath.startsWith("/"))
|
|
2381
|
+
return errorResponse("path must be absolute", 400);
|
|
2382
|
+
if (isDenyListed(rawPath))
|
|
2383
|
+
return errorResponse("Access denied: deny-listed path", 403);
|
|
2384
|
+
const { allowed } = realPathIfAllowed(rawPath);
|
|
2385
|
+
if (!allowed)
|
|
2386
|
+
return errorResponse("Access denied: symlink resolves outside allowed tree", 403);
|
|
2387
|
+
const maxDepth = Math.min(parseInt(url.searchParams.get("maxDepth") ?? "8", 10), 8);
|
|
2388
|
+
const result = parents(rawPath, maxDepth);
|
|
2389
|
+
return jsonResponse(result);
|
|
2390
|
+
}
|
|
2391
|
+
// ─── Event handler ───────────────────────────────────────────────────────────
|
|
2392
|
+
export async function handleEvent(ctx, event) {
|
|
2393
|
+
if (!event?.type)
|
|
2394
|
+
return;
|
|
2395
|
+
const sid = event.properties?.sessionID ?? event.properties?.sessionId ?? "";
|
|
2396
|
+
switch (event.type) {
|
|
2397
|
+
case "session.idle": {
|
|
2398
|
+
if (!sid)
|
|
2399
|
+
break;
|
|
2400
|
+
const board = await getBoard();
|
|
2401
|
+
const rec = board.sessions[sid];
|
|
2402
|
+
if (!rec)
|
|
2403
|
+
break;
|
|
2404
|
+
const task = board.tasks.find(t => t.id === rec.taskId);
|
|
2405
|
+
const endedAt = nowIso();
|
|
2406
|
+
await withWrite(async (b) => {
|
|
2407
|
+
const r = b.sessions[sid];
|
|
2408
|
+
if (!r)
|
|
2409
|
+
return;
|
|
2410
|
+
r.status = "done";
|
|
2411
|
+
r.endedAt = endedAt;
|
|
2412
|
+
const t = b.tasks.find(t => t.id === r.taskId);
|
|
2413
|
+
if (t) {
|
|
2414
|
+
t.state = "done";
|
|
2415
|
+
t.updatedAt = nowIso();
|
|
2416
|
+
}
|
|
2417
|
+
});
|
|
2418
|
+
const updated = (await getBoard()).tasks.find(t => t.id === rec.taskId);
|
|
2419
|
+
if (updated) {
|
|
2420
|
+
await writeTaskMdx(updated, KANBAN_DIR, await getBoard());
|
|
2421
|
+
broadcast("task.updated", updated);
|
|
2422
|
+
}
|
|
2423
|
+
await writeSessionMdx(sid, (await getBoard()).sessions[sid], updated, KANBAN_DIR, ctx.client);
|
|
2424
|
+
await writeBoardMdx(await getBoard(), KANBAN_DIR);
|
|
2425
|
+
broadcast("session.ended", { sessionId: sid, status: "done" });
|
|
2426
|
+
if (rec) {
|
|
2427
|
+
recordEvent(KANBAN_DIR, "agent.ended", {
|
|
2428
|
+
taskId: rec.taskId,
|
|
2429
|
+
author: "agent",
|
|
2430
|
+
summary: `agent ended (done) on '${task?.title ?? rec.taskId}'`,
|
|
2431
|
+
payload: { sessionId: sid, status: "done" },
|
|
2432
|
+
});
|
|
2433
|
+
}
|
|
2434
|
+
break;
|
|
2435
|
+
}
|
|
2436
|
+
case "session.error": {
|
|
2437
|
+
if (!sid)
|
|
2438
|
+
break;
|
|
2439
|
+
const board = await getBoard();
|
|
2440
|
+
const rec = board.sessions[sid];
|
|
2441
|
+
if (!rec)
|
|
2442
|
+
break;
|
|
2443
|
+
const errMsg = event.properties?.error ?? event.properties?.message ?? "Unknown error";
|
|
2444
|
+
const endedAt = nowIso();
|
|
2445
|
+
await withWrite(async (b) => {
|
|
2446
|
+
const r = b.sessions[sid];
|
|
2447
|
+
if (!r)
|
|
2448
|
+
return;
|
|
2449
|
+
r.status = "failed";
|
|
2450
|
+
r.endedAt = endedAt;
|
|
2451
|
+
const t = b.tasks.find(t => t.id === r.taskId);
|
|
2452
|
+
if (t) {
|
|
2453
|
+
t.state = "failed";
|
|
2454
|
+
t.lastError = errMsg;
|
|
2455
|
+
t.updatedAt = nowIso();
|
|
2456
|
+
}
|
|
2457
|
+
});
|
|
2458
|
+
const updated = (await getBoard()).tasks.find(t => t.id === rec.taskId);
|
|
2459
|
+
if (updated) {
|
|
2460
|
+
await writeTaskMdx(updated, KANBAN_DIR, await getBoard());
|
|
2461
|
+
broadcast("task.updated", updated);
|
|
2462
|
+
}
|
|
2463
|
+
await writeSessionMdx(sid, (await getBoard()).sessions[sid], updated, KANBAN_DIR, ctx.client);
|
|
2464
|
+
await writeBoardMdx(await getBoard(), KANBAN_DIR);
|
|
2465
|
+
broadcast("session.ended", { sessionId: sid, status: "failed", error: errMsg });
|
|
2466
|
+
if (rec) {
|
|
2467
|
+
recordEvent(KANBAN_DIR, "agent.ended", {
|
|
2468
|
+
taskId: rec.taskId,
|
|
2469
|
+
author: "agent",
|
|
2470
|
+
summary: `agent ended (failed) on '${updated?.title ?? rec.taskId}'`,
|
|
2471
|
+
payload: { sessionId: sid, status: "failed", error: errMsg },
|
|
2472
|
+
});
|
|
2473
|
+
}
|
|
2474
|
+
break;
|
|
2475
|
+
}
|
|
2476
|
+
case "session.status": {
|
|
2477
|
+
if (sid)
|
|
2478
|
+
sessionStatusCache.set(sid, event.properties?.status ?? "unknown");
|
|
2479
|
+
break;
|
|
2480
|
+
}
|
|
2481
|
+
default: break;
|
|
2482
|
+
}
|
|
2483
|
+
}
|
|
2484
|
+
// ─── Server start (private helper) ──────────────────────────────────────────
|
|
2485
|
+
async function _startServer(ctx, opts, extraServerOpts) {
|
|
2486
|
+
const host = opts.host ?? "127.0.0.1";
|
|
2487
|
+
const basePort = opts.port ?? 7777;
|
|
2488
|
+
const maxTries = opts.maxPortTries ?? 10;
|
|
2489
|
+
const webRoot = opts.webRoot ?? join(ctx.directory, "..", "..", "web");
|
|
2490
|
+
let port = basePort;
|
|
2491
|
+
let server = null;
|
|
2492
|
+
let lastErr = null;
|
|
2493
|
+
for (let attempt = 0; attempt < maxTries; attempt++) {
|
|
2494
|
+
try {
|
|
2495
|
+
server = createServer();
|
|
2496
|
+
await new Promise((resolve, reject) => {
|
|
2497
|
+
const onError = (err) => {
|
|
2498
|
+
server?.off("listening", onListening);
|
|
2499
|
+
reject(err);
|
|
2500
|
+
};
|
|
2501
|
+
const onListening = () => { server?.off("error", onError); resolve(); };
|
|
2502
|
+
server?.once("error", onError);
|
|
2503
|
+
server?.once("listening", onListening);
|
|
2504
|
+
server?.listen(port, host);
|
|
2505
|
+
});
|
|
2506
|
+
break;
|
|
2507
|
+
}
|
|
2508
|
+
catch (e) {
|
|
2509
|
+
if (e?.code === "EADDRINUSE" || String(e?.message).includes("EADDRINUSE")) {
|
|
2510
|
+
lastErr = e;
|
|
2511
|
+
server?.close();
|
|
2512
|
+
port++;
|
|
2513
|
+
continue;
|
|
2514
|
+
}
|
|
2515
|
+
throw e;
|
|
2516
|
+
}
|
|
2517
|
+
}
|
|
2518
|
+
if (!server)
|
|
2519
|
+
throw lastErr ?? new Error(`Could not bind server after ${maxTries} attempts`);
|
|
2520
|
+
return { server, port, hostname: host };
|
|
2521
|
+
}
|
|
2522
|
+
// ─── PID + lock helpers ──────────────────────────────────────────────────────
|
|
2523
|
+
const PID_FILE = "server.pid";
|
|
2524
|
+
const LOCK_FILE = "server.lock";
|
|
2525
|
+
function readPidFile(dir) {
|
|
2526
|
+
const pidPath = join(dir, PID_FILE);
|
|
2527
|
+
if (!existsSync(pidPath))
|
|
2528
|
+
return null;
|
|
2529
|
+
try {
|
|
2530
|
+
const pid = parseInt(readFileSync(pidPath, "utf-8").trim(), 10);
|
|
2531
|
+
return isNaN(pid) ? null : pid;
|
|
2532
|
+
}
|
|
2533
|
+
catch {
|
|
2534
|
+
return null;
|
|
2535
|
+
}
|
|
2536
|
+
}
|
|
2537
|
+
function writePidFile(dir, pid) {
|
|
2538
|
+
const pidPath = join(dir, PID_FILE);
|
|
2539
|
+
writeFileSync(pidPath, String(pid), "utf-8");
|
|
2540
|
+
}
|
|
2541
|
+
function deletePidFile(dir) {
|
|
2542
|
+
try {
|
|
2543
|
+
const pidPath = join(dir, PID_FILE);
|
|
2544
|
+
if (existsSync(pidPath))
|
|
2545
|
+
unlinkSync(pidPath);
|
|
2546
|
+
}
|
|
2547
|
+
catch { /* ignore */ }
|
|
2548
|
+
}
|
|
2549
|
+
function isPidAlive(pid) {
|
|
2550
|
+
try {
|
|
2551
|
+
process.kill(pid, 0);
|
|
2552
|
+
return true;
|
|
2553
|
+
}
|
|
2554
|
+
catch {
|
|
2555
|
+
return false;
|
|
2556
|
+
}
|
|
2557
|
+
}
|
|
2558
|
+
async function probeServer(host, port) {
|
|
2559
|
+
try {
|
|
2560
|
+
const controller = new AbortController();
|
|
2561
|
+
const timeout = setTimeout(() => controller.abort(), 1000);
|
|
2562
|
+
try {
|
|
2563
|
+
const res = await fetch(`http://${host}:${port}/api/board`, { signal: controller.signal });
|
|
2564
|
+
clearTimeout(timeout);
|
|
2565
|
+
if (!res.ok)
|
|
2566
|
+
return false;
|
|
2567
|
+
const json = await res.json();
|
|
2568
|
+
return !!json.version; // basic validity check
|
|
2569
|
+
}
|
|
2570
|
+
catch {
|
|
2571
|
+
clearTimeout(timeout);
|
|
2572
|
+
return false;
|
|
2573
|
+
}
|
|
2574
|
+
}
|
|
2575
|
+
catch {
|
|
2576
|
+
return false;
|
|
2577
|
+
}
|
|
2578
|
+
}
|
|
2579
|
+
export async function startOrAttach(ctx, opts = {}) {
|
|
2580
|
+
const host = opts.host ?? "127.0.0.1";
|
|
2581
|
+
const basePort = opts.port ?? 7777;
|
|
2582
|
+
const maxTries = opts.maxPortTries ?? 10;
|
|
2583
|
+
const dir = join(ctx.directory, ".ok");
|
|
2584
|
+
// 1. Check if existing server is alive
|
|
2585
|
+
const existingPid = readPidFile(dir);
|
|
2586
|
+
if (existingPid && isPidAlive(existingPid)) {
|
|
2587
|
+
const alive = await probeServer(host, basePort);
|
|
2588
|
+
if (alive) {
|
|
2589
|
+
// Attach to existing server (not primary)
|
|
2590
|
+
runningServer = {
|
|
2591
|
+
port: basePort,
|
|
2592
|
+
hostname: host,
|
|
2593
|
+
url: `http://${host}:${basePort}`,
|
|
2594
|
+
pid: existingPid,
|
|
2595
|
+
isPrimary: false,
|
|
2596
|
+
broadcast,
|
|
2597
|
+
async stop() { },
|
|
2598
|
+
};
|
|
2599
|
+
return runningServer;
|
|
2600
|
+
}
|
|
2601
|
+
}
|
|
2602
|
+
// 2. Try to acquire the lock. We use a "create-if-not-exists" file lock:
|
|
2603
|
+
// writeFileSync with { flag: "wx" } fails if the file already exists.
|
|
2604
|
+
// This is sufficient for local single-user, single-server operation;
|
|
2605
|
+
// not race-free under heavy concurrent access (rare in practice), and
|
|
2606
|
+
// a stale lock is auto-cleared on the next start if the PID is dead.
|
|
2607
|
+
// On systems with `flock(2)` available, we layer it on top for safety.
|
|
2608
|
+
// TODO: switch to flock(2) on Node versions that expose `node:fs.flock`.
|
|
2609
|
+
const lockPath = join(dir, LOCK_FILE);
|
|
2610
|
+
ensureDir(dir);
|
|
2611
|
+
let hasLock = false;
|
|
2612
|
+
let lockFd = null;
|
|
2613
|
+
try {
|
|
2614
|
+
// Clean up a stale lock from a dead PID.
|
|
2615
|
+
const existingPid = readPidFile(dir);
|
|
2616
|
+
if (!existingPid || !isPidAlive(existingPid)) {
|
|
2617
|
+
try {
|
|
2618
|
+
if (existsSync(lockPath))
|
|
2619
|
+
unlinkSync(lockPath);
|
|
2620
|
+
}
|
|
2621
|
+
catch { }
|
|
2622
|
+
}
|
|
2623
|
+
lockFd = openSync(lockPath, "wx"); // "wx" = O_CREAT | O_EXCL, fails if exists
|
|
2624
|
+
hasLock = true;
|
|
2625
|
+
}
|
|
2626
|
+
catch {
|
|
2627
|
+
hasLock = false;
|
|
2628
|
+
}
|
|
2629
|
+
if (!hasLock) {
|
|
2630
|
+
// Could not acquire lock — wait and retry, then give up
|
|
2631
|
+
for (let retry = 0; retry < 10; retry++) {
|
|
2632
|
+
await new Promise(r => setTimeout(r, 200));
|
|
2633
|
+
const pid2 = readPidFile(dir);
|
|
2634
|
+
if (pid2 && isPidAlive(pid2)) {
|
|
2635
|
+
const alive = await probeServer(host, basePort);
|
|
2636
|
+
if (alive) {
|
|
2637
|
+
runningServer = {
|
|
2638
|
+
port: basePort, hostname: host,
|
|
2639
|
+
url: `http://${host}:${basePort}`, pid: pid2, isPrimary: false,
|
|
2640
|
+
broadcast,
|
|
2641
|
+
async stop() { },
|
|
2642
|
+
};
|
|
2643
|
+
return runningServer;
|
|
2644
|
+
}
|
|
2645
|
+
}
|
|
2646
|
+
// Re-attempt the lock; the holder may have died.
|
|
2647
|
+
try {
|
|
2648
|
+
if (existsSync(lockPath))
|
|
2649
|
+
unlinkSync(lockPath);
|
|
2650
|
+
lockFd = openSync(lockPath, "wx");
|
|
2651
|
+
hasLock = true;
|
|
2652
|
+
break;
|
|
2653
|
+
}
|
|
2654
|
+
catch {
|
|
2655
|
+
// still held
|
|
2656
|
+
}
|
|
2657
|
+
}
|
|
2658
|
+
if (!hasLock) {
|
|
2659
|
+
throw new Error("Could not acquire server lock; another process may be starting the server");
|
|
2660
|
+
}
|
|
2661
|
+
}
|
|
2662
|
+
// 3. We have the lock — bind the server
|
|
2663
|
+
const { server, port, hostname } = await _startServer(ctx, { ...opts, host, port: basePort, maxPortTries: maxTries }, { writePidFile: true, lockFd: lockFd ?? undefined });
|
|
2664
|
+
const pid = process.pid;
|
|
2665
|
+
writePidFile(dir, pid);
|
|
2666
|
+
// Resolve and cache webRoot so the request handler can serve static files.
|
|
2667
|
+
// Default: <project>/web (one level up from .ok).
|
|
2668
|
+
webRoot = opts.webRoot ?? join(ctx.directory, "web");
|
|
2669
|
+
// Set up HTTP request handler (full routing)
|
|
2670
|
+
server.on("request", async (req, res) => {
|
|
2671
|
+
try {
|
|
2672
|
+
const request = await toRequest(req);
|
|
2673
|
+
const response = await handleRequest(request);
|
|
2674
|
+
await writeResponse(res, response);
|
|
2675
|
+
}
|
|
2676
|
+
catch (e) {
|
|
2677
|
+
const message = e instanceof Error ? e.message : "Internal server error";
|
|
2678
|
+
const response = requestErrorResponse(await toRequest(req), 500, message);
|
|
2679
|
+
await writeResponse(res, response);
|
|
2680
|
+
}
|
|
2681
|
+
});
|
|
2682
|
+
bizarSocketBridge?.close();
|
|
2683
|
+
bizarSocketBridge = attachBizarWebSocket(server, getActiveProjectRoot);
|
|
2684
|
+
claudeSocketBridge?.close();
|
|
2685
|
+
claudeSocketBridge = attachClaudeWebSocket(server, getActiveProjectRoot);
|
|
2686
|
+
runningServer = {
|
|
2687
|
+
port,
|
|
2688
|
+
hostname,
|
|
2689
|
+
url: `http://${hostname}:${port}`,
|
|
2690
|
+
pid,
|
|
2691
|
+
isPrimary: true,
|
|
2692
|
+
broadcast,
|
|
2693
|
+
async stop() {
|
|
2694
|
+
clearInterval(driftSweepInterval);
|
|
2695
|
+
bizarSocketBridge?.close();
|
|
2696
|
+
bizarSocketBridge = null;
|
|
2697
|
+
claudeSocketBridge?.close();
|
|
2698
|
+
claudeSocketBridge = null;
|
|
2699
|
+
await new Promise((resolve, reject) => {
|
|
2700
|
+
server.close((err) => (err ? reject(err) : resolve()));
|
|
2701
|
+
});
|
|
2702
|
+
deletePidFile(dir);
|
|
2703
|
+
if (lockFd !== null) {
|
|
2704
|
+
try {
|
|
2705
|
+
closeSync(lockFd);
|
|
2706
|
+
}
|
|
2707
|
+
catch { /* ignore */ }
|
|
2708
|
+
try {
|
|
2709
|
+
unlinkSync(lockPath);
|
|
2710
|
+
}
|
|
2711
|
+
catch { /* ignore */ }
|
|
2712
|
+
}
|
|
2713
|
+
watcherHandle?.close();
|
|
2714
|
+
watcherHandle = null;
|
|
2715
|
+
runningServer = null;
|
|
2716
|
+
},
|
|
2717
|
+
};
|
|
2718
|
+
// ─── File watcher (SSE broadcaster) ───────────────────────────────────────
|
|
2719
|
+
// Watch the project root so we also catch changes to source files (e.g. docs/*.mdx)
|
|
2720
|
+
// that are tracked as task sources. Inside .ok/ we filter the engine's own mirror
|
|
2721
|
+
// writes (board.json, board.mdx, tasks.json, per-task task.mdx + comments/inputs/
|
|
2722
|
+
// state.json) to avoid double-broadcasts, but `.ok/tasks/<id>.json` (the
|
|
2723
|
+
// planning-system store populated by the ok CLI) MUST pass through so an
|
|
2724
|
+
// agent's `ok task add` is visible to the dashboard without a restart.
|
|
2725
|
+
const projectRoot = join(dir, "..");
|
|
2726
|
+
watcherHandle = watch({
|
|
2727
|
+
root: projectRoot,
|
|
2728
|
+
ignore: (p) => {
|
|
2729
|
+
const norm = p.replace(/\\/g, "/");
|
|
2730
|
+
if (/server\.(lock|log|pid)$/.test(norm))
|
|
2731
|
+
return true;
|
|
2732
|
+
if (norm.endsWith(".tmp"))
|
|
2733
|
+
return true;
|
|
2734
|
+
if (norm.includes("/.ok/")) {
|
|
2735
|
+
// Engine-owned mirror writes — suppress; selfWriteUntil already
|
|
2736
|
+
// covers them but explicit ignore here removes cross-talk noise.
|
|
2737
|
+
if (norm.endsWith("/.ok/board.json"))
|
|
2738
|
+
return true;
|
|
2739
|
+
if (norm.endsWith("/.ok/board.mdx"))
|
|
2740
|
+
return true;
|
|
2741
|
+
if (norm.endsWith("/.ok/tasks.json"))
|
|
2742
|
+
return true;
|
|
2743
|
+
if (/\/\.ok\/tasks\/[^/]+\/task\.mdx$/.test(norm))
|
|
2744
|
+
return true;
|
|
2745
|
+
if (/\/\.ok\/tasks\/[^/]+\/(comments|inputs|state)\.json$/.test(norm))
|
|
2746
|
+
return true;
|
|
2747
|
+
if (norm.endsWith("/.ok/changelog.jsonl"))
|
|
2748
|
+
return true;
|
|
2749
|
+
return false; // per-task JSONs and config/index files pass through
|
|
2750
|
+
}
|
|
2751
|
+
return false;
|
|
2752
|
+
},
|
|
2753
|
+
});
|
|
2754
|
+
// Periodic drift sweep — every 60 s, re-check all source hashes.
|
|
2755
|
+
const driftSweepInterval = setInterval(() => {
|
|
2756
|
+
sweepSourceDrift(dir).catch(() => { });
|
|
2757
|
+
}, 60_000);
|
|
2758
|
+
(async () => {
|
|
2759
|
+
for await (const ev of watcherHandle.events) {
|
|
2760
|
+
// Skip events caused by server's own writes.
|
|
2761
|
+
if (Date.now() < selfWriteUntil)
|
|
2762
|
+
continue;
|
|
2763
|
+
// Check if this event matches any task's source.path → drift detection
|
|
2764
|
+
let driftedTaskId = null;
|
|
2765
|
+
{
|
|
2766
|
+
const board = await getBoard();
|
|
2767
|
+
for (const task of board.tasks) {
|
|
2768
|
+
if (!task.source)
|
|
2769
|
+
continue;
|
|
2770
|
+
const srcAbs = sourcePathOfTask(task, dir);
|
|
2771
|
+
if (srcAbs === ev.absPath) {
|
|
2772
|
+
driftedTaskId = task.id;
|
|
2773
|
+
break;
|
|
2774
|
+
}
|
|
2775
|
+
}
|
|
2776
|
+
}
|
|
2777
|
+
if (ev.path.endsWith("board.json") || ev.path.replace(/\\/g, "/").endsWith(".ok/board.json")) {
|
|
2778
|
+
broadcast("board.changed", { path: ev.path });
|
|
2779
|
+
}
|
|
2780
|
+
else if (ev.path.endsWith("changelog.jsonl")) {
|
|
2781
|
+
broadcast("changelog.appended", { path: ev.path });
|
|
2782
|
+
}
|
|
2783
|
+
else if (ev.path.includes("/tasks/") && ev.path.endsWith("task.mdx")) {
|
|
2784
|
+
const taskId = ev.path.match(/\/tasks\/([^/]+)\//)?.[1];
|
|
2785
|
+
if (taskId)
|
|
2786
|
+
broadcast("task.mdx.changed", { taskId, path: ev.path });
|
|
2787
|
+
}
|
|
2788
|
+
else if (ev.path.includes("/tasks/") && ev.path.endsWith("comments.json")) {
|
|
2789
|
+
const taskId = ev.path.match(/\/tasks\/([^/]+)\//)?.[1];
|
|
2790
|
+
if (taskId)
|
|
2791
|
+
broadcast("task.comment.added", { taskId });
|
|
2792
|
+
}
|
|
2793
|
+
else if (ev.path.includes("/tasks/") && ev.path.endsWith("inputs.json")) {
|
|
2794
|
+
const taskId = ev.path.match(/\/tasks\/([^/]+)\//)?.[1];
|
|
2795
|
+
if (taskId)
|
|
2796
|
+
broadcast("task.input.asked", { taskId });
|
|
2797
|
+
}
|
|
2798
|
+
else if (ev.path.includes("/tasks/") && ev.path.endsWith("state.json")) {
|
|
2799
|
+
const taskId = ev.path.match(/\/tasks\/([^/]+)\//)?.[1];
|
|
2800
|
+
if (taskId)
|
|
2801
|
+
broadcast("task.state.changed", { taskId });
|
|
2802
|
+
}
|
|
2803
|
+
else if ((ev.path.includes("/.ok/tasks/") || ev.path.startsWith(".ok/tasks/")) && ev.path.endsWith(".json")) {
|
|
2804
|
+
// Planning-system per-task write (e.g. `ok task add`). Reconcile into
|
|
2805
|
+
// the in-memory board when the id is new; mirror-engine no-op when
|
|
2806
|
+
// the task already lives here. Event paths emitted by the watcher
|
|
2807
|
+
// are project-root-relative, so they can start with ".ok/" directly.
|
|
2808
|
+
const okIdMatch = ev.path.match(/(?:\/|^)\.ok\/tasks\/([^/]+)\.json$/);
|
|
2809
|
+
const okTaskId = okIdMatch?.[1];
|
|
2810
|
+
if (okTaskId && /^tsk-[A-Za-z0-9_-]+$/.test(okTaskId)) {
|
|
2811
|
+
try {
|
|
2812
|
+
const inserted = await reconcileOkTask(okTaskId, dir);
|
|
2813
|
+
if (inserted) {
|
|
2814
|
+
broadcast("task.created", inserted);
|
|
2815
|
+
await writeTaskMdx(inserted, dir, await getBoard());
|
|
2816
|
+
}
|
|
2817
|
+
else {
|
|
2818
|
+
// Existing task — treat the planning write as a metadata
|
|
2819
|
+
// nudge. The HTTP PATCH path stays the canonical owner.
|
|
2820
|
+
broadcast("ok.task.synced", { taskId: okTaskId, path: ev.path });
|
|
2821
|
+
}
|
|
2822
|
+
}
|
|
2823
|
+
catch (e) {
|
|
2824
|
+
process.stderr.write(`reconcileOkTask(${okTaskId}) failed: ${e?.message ?? e}\n`);
|
|
2825
|
+
}
|
|
2826
|
+
}
|
|
2827
|
+
}
|
|
2828
|
+
else if (ev.path.match(/\/tasks\/([^/]+)\/images\//)) {
|
|
2829
|
+
const taskId = ev.path.match(/\/tasks\/([^/]+)\/images\//)?.[1];
|
|
2830
|
+
if (taskId)
|
|
2831
|
+
broadcast("task.image.changed", { taskId });
|
|
2832
|
+
}
|
|
2833
|
+
else if (driftedTaskId) {
|
|
2834
|
+
// A tracked source file changed — run drift check for the affected task
|
|
2835
|
+
const task = (await getBoard()).tasks.find(t => t.id === driftedTaskId);
|
|
2836
|
+
if (task) {
|
|
2837
|
+
const isStale = await checkSourceDrift(task, dir);
|
|
2838
|
+
if (isStale !== task.stale) {
|
|
2839
|
+
task.stale = isStale;
|
|
2840
|
+
task.lastSourceCheck = nowIso();
|
|
2841
|
+
await withWrite(async (b) => {
|
|
2842
|
+
const t2 = b.tasks.find(t => t.id === driftedTaskId);
|
|
2843
|
+
if (t2) {
|
|
2844
|
+
t2.stale = isStale;
|
|
2845
|
+
t2.lastSourceCheck = nowIso();
|
|
2846
|
+
}
|
|
2847
|
+
});
|
|
2848
|
+
broadcast("task.updated", task);
|
|
2849
|
+
await writeTaskMdx(task, dir, await getBoard());
|
|
2850
|
+
}
|
|
2851
|
+
}
|
|
2852
|
+
broadcast("task.source-changed", { taskId: driftedTaskId, path: ev.path });
|
|
2853
|
+
}
|
|
2854
|
+
else {
|
|
2855
|
+
// Generic file-change event for any other watched files.
|
|
2856
|
+
broadcast("file.changed", { path: ev.path });
|
|
2857
|
+
}
|
|
2858
|
+
}
|
|
2859
|
+
})();
|
|
2860
|
+
ctx.log("info", `Kanban server started at ${runningServer.url} (primary)`);
|
|
2861
|
+
// Auto-detect projects in the background if no active project is set
|
|
2862
|
+
if (opts._autoDetect !== false && !activeProject()) {
|
|
2863
|
+
(async () => {
|
|
2864
|
+
try {
|
|
2865
|
+
const result = await autoDetectProjects();
|
|
2866
|
+
if (result.discovered.length) {
|
|
2867
|
+
ctx.log("info", `auto-detect: found ${result.discovered.length} new project(s): ${result.discovered.map(p => p.name).join(", ")}`);
|
|
2868
|
+
}
|
|
2869
|
+
else {
|
|
2870
|
+
ctx.log("info", "auto-detect: no new projects found");
|
|
2871
|
+
}
|
|
2872
|
+
}
|
|
2873
|
+
catch (e) {
|
|
2874
|
+
ctx.log("warn", `auto-detect failed: ${e?.message ?? e}`);
|
|
2875
|
+
}
|
|
2876
|
+
})();
|
|
2877
|
+
}
|
|
2878
|
+
return runningServer;
|
|
2879
|
+
}
|
|
2880
|
+
// ─── Public startServer (back-compat, delegates to startOrAttach) ─────────────
|
|
2881
|
+
export function getServer() {
|
|
2882
|
+
return runningServer;
|
|
2883
|
+
}
|
|
2884
|
+
/** @deprecated Use startOrAttach instead. */
|
|
2885
|
+
export async function startServer(ctx, opts = {}) {
|
|
2886
|
+
return startOrAttach(ctx, opts);
|
|
2887
|
+
}
|
|
2888
|
+
// ─── Request router ──────────────────────────────────────────────────────────
|
|
2889
|
+
/**
|
|
2890
|
+
* Attach a small WebSocket bridge to `ws://…/api/claude/ws`. Mirrors
|
|
2891
|
+
* `attachBizarWebSocket` (the legacy `/api/bizar/ws` endpoint) but sources
|
|
2892
|
+
* the snapshot and live updates from `kanban/claude-state.ts` so callers
|
|
2893
|
+
* no longer need the external `bizar` CLI. SSE on `/api/claude/events` is the
|
|
2894
|
+
* preferred transport; this bridge exists for clients that prefer WS.
|
|
2895
|
+
*/
|
|
2896
|
+
export function attachClaudeWebSocket(server, projectRoot) {
|
|
2897
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
2898
|
+
function send(socket, value) {
|
|
2899
|
+
if (socket.readyState === WebSocket.OPEN)
|
|
2900
|
+
socket.send(JSON.stringify(value));
|
|
2901
|
+
}
|
|
2902
|
+
async function snapshot(socket) {
|
|
2903
|
+
try {
|
|
2904
|
+
const data = await claudeState.readSnapshot(projectRoot());
|
|
2905
|
+
send(socket, { type: "snapshot", data });
|
|
2906
|
+
}
|
|
2907
|
+
catch (error) {
|
|
2908
|
+
send(socket, { type: "error", error: error?.message || String(error) });
|
|
2909
|
+
}
|
|
2910
|
+
}
|
|
2911
|
+
const upgrade = (req, socket, head) => {
|
|
2912
|
+
let pathname = "";
|
|
2913
|
+
try {
|
|
2914
|
+
pathname = new URL(req.url || "/", "http://localhost").pathname;
|
|
2915
|
+
}
|
|
2916
|
+
catch { /* invalid */ }
|
|
2917
|
+
if (pathname !== "/api/claude/ws")
|
|
2918
|
+
return;
|
|
2919
|
+
if (!isLoopback(req)) {
|
|
2920
|
+
socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
|
|
2921
|
+
socket.destroy();
|
|
2922
|
+
return;
|
|
2923
|
+
}
|
|
2924
|
+
wss.handleUpgrade(req, socket, head, (client) => wss.emit("connection", client, req));
|
|
2925
|
+
};
|
|
2926
|
+
server.on("upgrade", upgrade);
|
|
2927
|
+
wss.on("connection", (socket) => {
|
|
2928
|
+
void snapshot(socket);
|
|
2929
|
+
socket.on("message", (raw) => {
|
|
2930
|
+
let message;
|
|
2931
|
+
try {
|
|
2932
|
+
message = JSON.parse(raw.toString());
|
|
2933
|
+
}
|
|
2934
|
+
catch {
|
|
2935
|
+
send(socket, { type: "error", error: "Invalid JSON" });
|
|
2936
|
+
return;
|
|
2937
|
+
}
|
|
2938
|
+
if (message.type === "refresh") {
|
|
2939
|
+
void snapshot(socket);
|
|
2940
|
+
return;
|
|
2941
|
+
}
|
|
2942
|
+
send(socket, {
|
|
2943
|
+
type: "error",
|
|
2944
|
+
requestId: message.requestId,
|
|
2945
|
+
error: "Unknown message type",
|
|
2946
|
+
});
|
|
2947
|
+
});
|
|
2948
|
+
});
|
|
2949
|
+
const interval = setInterval(() => {
|
|
2950
|
+
if (wss.clients.size > 0) {
|
|
2951
|
+
for (const client of wss.clients)
|
|
2952
|
+
void snapshot(client);
|
|
2953
|
+
}
|
|
2954
|
+
}, 5_000);
|
|
2955
|
+
interval.unref?.();
|
|
2956
|
+
return {
|
|
2957
|
+
close() {
|
|
2958
|
+
clearInterval(interval);
|
|
2959
|
+
server.off("upgrade", upgrade);
|
|
2960
|
+
for (const client of wss.clients)
|
|
2961
|
+
client.close();
|
|
2962
|
+
wss.close();
|
|
2963
|
+
},
|
|
2964
|
+
};
|
|
2965
|
+
}
|
|
2966
|
+
function isLoopback(req) {
|
|
2967
|
+
const address = req.socket.remoteAddress || "";
|
|
2968
|
+
return address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1";
|
|
2969
|
+
}
|
|
2970
|
+
async function handleRequest(req) {
|
|
2971
|
+
// Resolve and load the active project for this request. The board is cached
|
|
2972
|
+
// in process, so changing a directory alone would leak the previous
|
|
2973
|
+
// project's tasks into the selected project.
|
|
2974
|
+
const projectRoot = getActiveProjectRoot();
|
|
2975
|
+
await ensureBoardForProject(projectBoardContext(projectRoot));
|
|
2976
|
+
const url = new URL(req.url);
|
|
2977
|
+
const path = url.pathname;
|
|
2978
|
+
const rawFlag = url.searchParams.get("raw") === "1";
|
|
2979
|
+
// SSE
|
|
2980
|
+
if (path === "/api/events") {
|
|
2981
|
+
const stream = new ReadableStream({
|
|
2982
|
+
start(ctrl) { sseControllers.add(ctrl); ctrl.enqueue(new TextEncoder().encode("event: server.connected\ndata: {}\n\n")); },
|
|
2983
|
+
cancel(ctrl) { sseControllers.delete(ctrl); },
|
|
2984
|
+
});
|
|
2985
|
+
return new Response(stream, { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive" } });
|
|
2986
|
+
}
|
|
2987
|
+
// Static: any file under webRoot with an allowed extension. webRoot
|
|
2988
|
+
// defaults to `<project>/web`; the caller can override. The whitelist
|
|
2989
|
+
// includes svg so the brand assets (logo, favicon, banners, empty-state
|
|
2990
|
+
// illustrations) can be served directly.
|
|
2991
|
+
if (path === "/" || /\.(html|css|js|json|md|txt|svg)$/.test(path)) {
|
|
2992
|
+
const root = webRoot ?? join(KANBAN_DIR, "..", "web");
|
|
2993
|
+
const pathForStatic = path === "/" ? "/index.html" : path;
|
|
2994
|
+
const sf = serveStatic(root, pathForStatic);
|
|
2995
|
+
if (sf)
|
|
2996
|
+
return new Response(sf.body, { headers: { "Content-Type": sf.contentType } });
|
|
2997
|
+
// If the explicit static match fails, fall through to other handlers (don't 404 here)
|
|
2998
|
+
}
|
|
2999
|
+
// GET /api/board
|
|
3000
|
+
if (path === "/api/board" && req.method === "GET")
|
|
3001
|
+
return apiGetBoard();
|
|
3002
|
+
if (path.startsWith("/api/bizar/")) {
|
|
3003
|
+
return handleBizarRequest(projectRoot, req, path);
|
|
3004
|
+
}
|
|
3005
|
+
if (path.startsWith("/api/claude/")) {
|
|
3006
|
+
return handleClaudeRequest(projectRoot, req, path);
|
|
3007
|
+
}
|
|
3008
|
+
if (path.startsWith("/api/chat/")) {
|
|
3009
|
+
return handleChatRequest(projectRoot, req, path);
|
|
3010
|
+
}
|
|
3011
|
+
// GET /api/goals — PRDs and their durable goals from .ok/prds.
|
|
3012
|
+
if (path === "/api/goals" && req.method === "GET")
|
|
3013
|
+
return apiGetGoals(projectRoot);
|
|
3014
|
+
const goalMatch = path.match(/^\/api\/goals\/(prd-[A-Za-z0-9_-]+)\/(g[0-9]+)$/);
|
|
3015
|
+
if (goalMatch && req.method === "PATCH")
|
|
3016
|
+
return apiPatchGoal(projectRoot, goalMatch[1], goalMatch[2], req);
|
|
3017
|
+
// GET /api/tasks-index
|
|
3018
|
+
if (path === "/api/tasks-index" && req.method === "GET")
|
|
3019
|
+
return apiGetTasksIndex(req);
|
|
3020
|
+
// GET /api/tags
|
|
3021
|
+
if (path === "/api/tags" && req.method === "GET")
|
|
3022
|
+
return apiGetTags();
|
|
3023
|
+
// GET /api/changelog
|
|
3024
|
+
if (path === "/api/changelog" && req.method === "GET")
|
|
3025
|
+
return apiGetChangelog(req);
|
|
3026
|
+
// GET /api/changelog/summary
|
|
3027
|
+
if (path === "/api/changelog/summary" && req.method === "GET")
|
|
3028
|
+
return apiGetChangelogSummary(req);
|
|
3029
|
+
// GET /api/insights/velocity
|
|
3030
|
+
if (path === "/api/insights/velocity" && req.method === "GET")
|
|
3031
|
+
return apiGetInsightsVelocity(req);
|
|
3032
|
+
// GET /api/contributors
|
|
3033
|
+
if (path === "/api/contributors" && req.method === "GET")
|
|
3034
|
+
return apiGetContributors(req);
|
|
3035
|
+
// GET /api/settings
|
|
3036
|
+
if (path === "/api/settings" && req.method === "GET")
|
|
3037
|
+
return apiGetSettings();
|
|
3038
|
+
// PATCH /api/settings
|
|
3039
|
+
if (path === "/api/settings" && req.method === "PATCH")
|
|
3040
|
+
return apiPatchSettings({ directory: KANBAN_DIR, client: null, log: async () => { } }, req);
|
|
3041
|
+
// GET /api/config-sections
|
|
3042
|
+
if (path === "/api/config-sections" && req.method === "GET")
|
|
3043
|
+
return apiGetConfigSections();
|
|
3044
|
+
// PATCH /api/config-sections/:sectionId
|
|
3045
|
+
const configSectionMatch = path.match(/^\/api\/config-sections\/([^/]+)$/);
|
|
3046
|
+
if (configSectionMatch && req.method === "PATCH") {
|
|
3047
|
+
return apiPatchConfigSection({ directory: KANBAN_DIR, client: null, log: async () => { } }, configSectionMatch[1], req);
|
|
3048
|
+
}
|
|
3049
|
+
// POST /api/organize
|
|
3050
|
+
if (path === "/api/organize" && req.method === "POST")
|
|
3051
|
+
return apiOrganize({ directory: KANBAN_DIR, client: null, log: async () => { } }, req);
|
|
3052
|
+
// POST /api/import
|
|
3053
|
+
if (path === "/api/import" && req.method === "POST")
|
|
3054
|
+
return apiImport({ directory: KANBAN_DIR, client: null, log: async () => { } }, req);
|
|
3055
|
+
// GET /api/search
|
|
3056
|
+
if (path === "/api/search" && req.method === "GET")
|
|
3057
|
+
return apiSearch(req);
|
|
3058
|
+
// POST /api/tasks/bulk
|
|
3059
|
+
if (path === "/api/tasks/bulk" && req.method === "POST")
|
|
3060
|
+
return apiBulk({ directory: KANBAN_DIR, client: null, log: async () => { } }, req);
|
|
3061
|
+
// POST /api/projects/:projectId/tasks/move
|
|
3062
|
+
const moveTasksMatch = path.match(/^\/api\/projects\/([^/]+)\/tasks\/move$/);
|
|
3063
|
+
if (moveTasksMatch && req.method === "POST") {
|
|
3064
|
+
return apiMoveTasksToProject(decodeURIComponent(moveTasksMatch[1]), req);
|
|
3065
|
+
}
|
|
3066
|
+
// GET /api/template
|
|
3067
|
+
if (path === "/api/template" && req.method === "GET")
|
|
3068
|
+
return apiGetTemplate();
|
|
3069
|
+
// GET /api/tasks/:id
|
|
3070
|
+
const taskMatch = path.match(/^\/api\/tasks\/([^/]+)$/);
|
|
3071
|
+
if (taskMatch) {
|
|
3072
|
+
const [_, id] = taskMatch;
|
|
3073
|
+
if (req.method === "GET")
|
|
3074
|
+
return apiGetTask(id);
|
|
3075
|
+
if (req.method === "POST")
|
|
3076
|
+
return apiCreateTask({ directory: KANBAN_DIR, client: null, log: async () => { } }, req);
|
|
3077
|
+
if (req.method === "PATCH")
|
|
3078
|
+
return apiUpdateTask({ directory: KANBAN_DIR, client: null, log: async () => { } }, id, req);
|
|
3079
|
+
if (req.method === "DELETE")
|
|
3080
|
+
return apiDeleteTask({ directory: KANBAN_DIR, client: null, log: async () => { } }, id);
|
|
3081
|
+
}
|
|
3082
|
+
// POST /api/tasks/:id/archive
|
|
3083
|
+
const archiveMatch = path.match(/^\/api\/tasks\/([^/]+)\/archive$/);
|
|
3084
|
+
if (archiveMatch && req.method === "POST") {
|
|
3085
|
+
return apiArchiveTask({ directory: KANBAN_DIR, client: null, log: async () => { } }, archiveMatch[1]);
|
|
3086
|
+
}
|
|
3087
|
+
// POST /api/tasks/:id/restore
|
|
3088
|
+
const restoreMatch = path.match(/^\/api\/tasks\/([^/]+)\/restore$/);
|
|
3089
|
+
if (restoreMatch && req.method === "POST") {
|
|
3090
|
+
return apiRestoreTask({ directory: KANBAN_DIR, client: null, log: async () => { } }, restoreMatch[1]);
|
|
3091
|
+
}
|
|
3092
|
+
// GET /api/tasks/:id/contributors
|
|
3093
|
+
const contribMatch = path.match(/^\/api\/tasks\/([^/]+)\/contributors$/);
|
|
3094
|
+
if (contribMatch && req.method === "GET") {
|
|
3095
|
+
return apiGetTaskContributors({ directory: KANBAN_DIR, client: null, log: async () => { } }, contribMatch[1]);
|
|
3096
|
+
}
|
|
3097
|
+
// GET /api/tasks/:id/subtasks
|
|
3098
|
+
const subtasksMatch = path.match(/^\/api\/tasks\/([^/]+)\/subtasks$/);
|
|
3099
|
+
if (subtasksMatch && req.method === "GET") {
|
|
3100
|
+
return apiGetSubtasks(subtasksMatch[1]);
|
|
3101
|
+
}
|
|
3102
|
+
// POST /api/tasks (legacy)
|
|
3103
|
+
if (path === "/api/tasks" && req.method === "POST")
|
|
3104
|
+
return apiCreateTask({ directory: KANBAN_DIR, client: null, log: async () => { } }, req);
|
|
3105
|
+
// /api/tasks/:id/ask
|
|
3106
|
+
const askMatch = path.match(/^\/api\/tasks\/([^/]+)\/ask$/);
|
|
3107
|
+
if (askMatch && req.method === "POST") {
|
|
3108
|
+
const [_, id] = askMatch;
|
|
3109
|
+
return apiAskInput({ directory: KANBAN_DIR, client: null, log: async () => { } }, id, req);
|
|
3110
|
+
}
|
|
3111
|
+
// POST /api/tasks/recheck-stale
|
|
3112
|
+
if (path === "/api/tasks/recheck-stale" && req.method === "POST") {
|
|
3113
|
+
return apiRecheckStale({ directory: KANBAN_DIR, client: null, log: async () => { } }, req);
|
|
3114
|
+
}
|
|
3115
|
+
// /api/tasks/:id/respond
|
|
3116
|
+
const respondMatch = path.match(/^\/api\/tasks\/([^/]+)\/respond$/);
|
|
3117
|
+
if (respondMatch && req.method === "POST") {
|
|
3118
|
+
const [_, id] = respondMatch;
|
|
3119
|
+
return apiRespondInput({ directory: KANBAN_DIR, client: null, log: async () => { } }, id, req);
|
|
3120
|
+
}
|
|
3121
|
+
// /api/tasks/:id/comments
|
|
3122
|
+
const commentsMatch = path.match(/^\/api\/tasks\/([^/]+)\/comments$/);
|
|
3123
|
+
if (commentsMatch) {
|
|
3124
|
+
const [_, id] = commentsMatch;
|
|
3125
|
+
if (req.method === "GET")
|
|
3126
|
+
return apiGetComments({ directory: KANBAN_DIR, client: null, log: async () => { } }, id);
|
|
3127
|
+
if (req.method === "POST")
|
|
3128
|
+
return apiAddComment({ directory: KANBAN_DIR, client: null, log: async () => { } }, id, req);
|
|
3129
|
+
}
|
|
3130
|
+
// /api/tasks/:id/comments/:cid
|
|
3131
|
+
const commentMatch = path.match(/^\/api\/tasks\/([^/]+)\/comments\/([^/]+)$/);
|
|
3132
|
+
if (commentMatch) {
|
|
3133
|
+
const [_, id, cid] = commentMatch;
|
|
3134
|
+
if (req.method === "PATCH")
|
|
3135
|
+
return apiResolveComment({ directory: KANBAN_DIR, client: null, log: async () => { } }, id, cid, req);
|
|
3136
|
+
if (req.method === "DELETE")
|
|
3137
|
+
return apiDeleteComment({ directory: KANBAN_DIR, client: null, log: async () => { } }, id, cid);
|
|
3138
|
+
}
|
|
3139
|
+
// /api/tasks/:id/images
|
|
3140
|
+
const imgCollectionMatch = path.match(/^\/api\/tasks\/([^/]+)\/images$/);
|
|
3141
|
+
if (imgCollectionMatch) {
|
|
3142
|
+
const [_, id] = imgCollectionMatch;
|
|
3143
|
+
if (req.method === "GET")
|
|
3144
|
+
return apiListImages({ directory: KANBAN_DIR, client: null, log: async () => { } }, id);
|
|
3145
|
+
if (req.method === "POST")
|
|
3146
|
+
return apiUploadImage({ directory: KANBAN_DIR, client: null, log: async () => { } }, id, req);
|
|
3147
|
+
}
|
|
3148
|
+
// /api/tasks/:id/images/:name
|
|
3149
|
+
const imgMatch = path.match(/^\/api\/tasks\/([^/]+)\/images\/([^/]+)$/);
|
|
3150
|
+
if (imgMatch) {
|
|
3151
|
+
const [_, id, name] = imgMatch;
|
|
3152
|
+
if (req.method === "GET")
|
|
3153
|
+
return apiGetImage({ directory: KANBAN_DIR, client: null, log: async () => { } }, id, name);
|
|
3154
|
+
if (req.method === "DELETE")
|
|
3155
|
+
return apiDeleteImage({ directory: KANBAN_DIR, client: null, log: async () => { } }, id, name);
|
|
3156
|
+
}
|
|
3157
|
+
// GET /api/me
|
|
3158
|
+
if (path === "/api/me" && req.method === "GET") {
|
|
3159
|
+
return apiGetMe({ directory: KANBAN_DIR, client: null, log: async () => { } });
|
|
3160
|
+
}
|
|
3161
|
+
// GET /api/project
|
|
3162
|
+
if (path === "/api/project" && req.method === "GET") {
|
|
3163
|
+
return apiGetProject();
|
|
3164
|
+
}
|
|
3165
|
+
// GET /api/projects
|
|
3166
|
+
if (path === "/api/projects" && req.method === "GET") {
|
|
3167
|
+
return apiGetProjects();
|
|
3168
|
+
}
|
|
3169
|
+
// POST /api/projects
|
|
3170
|
+
if (path === "/api/projects" && req.method === "POST") {
|
|
3171
|
+
return apiCreateProject(req);
|
|
3172
|
+
}
|
|
3173
|
+
// DELETE /api/projects/:id
|
|
3174
|
+
const deleteProjectMatch = path.match(/^\/api\/projects\/([^/]+)$/);
|
|
3175
|
+
if (deleteProjectMatch && req.method === "DELETE") {
|
|
3176
|
+
return apiDeleteProject(deleteProjectMatch[1]);
|
|
3177
|
+
}
|
|
3178
|
+
// POST /api/projects/auto-detect
|
|
3179
|
+
if (path === "/api/projects/auto-detect" && req.method === "POST") {
|
|
3180
|
+
return apiAutoDetectProjects(req);
|
|
3181
|
+
}
|
|
3182
|
+
// PATCH /api/projects/:id/active
|
|
3183
|
+
const activateProjectMatch = path.match(/^\/api\/projects\/([^/]+)\/active$/);
|
|
3184
|
+
if (activateProjectMatch && req.method === "PATCH") {
|
|
3185
|
+
return apiActivateProject(activateProjectMatch[1]);
|
|
3186
|
+
}
|
|
3187
|
+
// Docs workspace CRUD + configured-agent generation.
|
|
3188
|
+
if (path === "/api/docs/generate" && req.method === "POST")
|
|
3189
|
+
return apiGenerateDoc(req);
|
|
3190
|
+
if (path === "/api/docs/render" && req.method === "POST")
|
|
3191
|
+
return apiRenderDoc(req);
|
|
3192
|
+
const docListMatch = path.match(/^\/api\/docs\/?$/);
|
|
3193
|
+
if (docListMatch && req.method === "GET")
|
|
3194
|
+
return apiGetDocs();
|
|
3195
|
+
const docFileMatch = path.match(/^\/api\/docs\/(.+)$/);
|
|
3196
|
+
if (docFileMatch && req.method === "GET")
|
|
3197
|
+
return apiGetDoc(req, docFileMatch[1]);
|
|
3198
|
+
if (docFileMatch && req.method === "PUT")
|
|
3199
|
+
return apiWriteDoc(req, decodeURIComponent(docFileMatch[1]));
|
|
3200
|
+
if (docFileMatch && req.method === "DELETE")
|
|
3201
|
+
return apiDeleteDoc(decodeURIComponent(docFileMatch[1]));
|
|
3202
|
+
// GET /api/fs — browse filesystem
|
|
3203
|
+
if (path === "/api/fs" && req.method === "GET")
|
|
3204
|
+
return apiFs(req);
|
|
3205
|
+
// GET /api/home — user's home directory
|
|
3206
|
+
if (path === "/api/home" && req.method === "GET")
|
|
3207
|
+
return apiHome();
|
|
3208
|
+
// GET /api/parents — ancestor directories for breadcrumbs
|
|
3209
|
+
if (path === "/api/parents" && req.method === "GET")
|
|
3210
|
+
return apiParents(req);
|
|
3211
|
+
// /api/tasks/:id/mdx-rendered
|
|
3212
|
+
const mdxRenderedMatch = path.match(/^\/api\/tasks\/([^/]+)\/mdx-rendered$/);
|
|
3213
|
+
if (mdxRenderedMatch && req.method === "GET") {
|
|
3214
|
+
return apiGetMdxRendered(mdxRenderedMatch[1]);
|
|
3215
|
+
}
|
|
3216
|
+
// /api/tasks/:id/start | /abort
|
|
3217
|
+
const actionMatch = path.match(/^\/api\/tasks\/([^/]+)\/(start|abort)$/);
|
|
3218
|
+
if (actionMatch && req.method === "POST") {
|
|
3219
|
+
const [_, id, action] = actionMatch;
|
|
3220
|
+
if (action === "start")
|
|
3221
|
+
return apiStartTask(projectRoot, id, req);
|
|
3222
|
+
if (action === "abort")
|
|
3223
|
+
return apiAbortTask(projectRoot, id);
|
|
3224
|
+
}
|
|
3225
|
+
// /api/sessions/:sid/status
|
|
3226
|
+
const sessMatch = path.match(/^\/api\/sessions\/([^/]+)\/status$/);
|
|
3227
|
+
if (sessMatch && req.method === "GET")
|
|
3228
|
+
return apiSessionStatus(sessMatch[1]);
|
|
3229
|
+
// POST /api/preview
|
|
3230
|
+
if (path === "/api/preview" && req.method === "POST")
|
|
3231
|
+
return apiPreview(req);
|
|
3232
|
+
// Artifacts
|
|
3233
|
+
// Theme: ?theme=dark|light|system wins; otherwise script reads localStorage
|
|
3234
|
+
const themeParam = url.searchParams.get("theme") ?? undefined;
|
|
3235
|
+
const cspHeaders = {
|
|
3236
|
+
// unsafe-inline needed for the theme-init script and inline CSS vars
|
|
3237
|
+
"Content-Security-Policy": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; base-uri 'none'; form-action 'none'",
|
|
3238
|
+
};
|
|
3239
|
+
if (path === "/artifacts/board") {
|
|
3240
|
+
try {
|
|
3241
|
+
const { body, contentType } = await renderArtifact(join(KANBAN_DIR, "board.mdx"), rawFlag, themeParam);
|
|
3242
|
+
return new Response(body, { headers: { "Content-Type": contentType, ...cspHeaders } });
|
|
3243
|
+
}
|
|
3244
|
+
catch (e) {
|
|
3245
|
+
const m = e?.message || "Render error";
|
|
3246
|
+
return errorResponse(m, 500);
|
|
3247
|
+
}
|
|
3248
|
+
}
|
|
3249
|
+
const taskArtMatch = path.match(/^\/artifacts\/tasks\/([^/]+)$/);
|
|
3250
|
+
if (taskArtMatch) {
|
|
3251
|
+
const [_, id] = taskArtMatch;
|
|
3252
|
+
try {
|
|
3253
|
+
// Support both flat and per-task layout
|
|
3254
|
+
const flatPath = join(KANBAN_DIR, "tasks", `${id}.mdx`);
|
|
3255
|
+
const perPath = join(KANBAN_DIR, "tasks", id, "task.mdx");
|
|
3256
|
+
const mdxPath = existsSync(perPath) ? perPath : existsSync(flatPath) ? flatPath : null;
|
|
3257
|
+
if (!mdxPath)
|
|
3258
|
+
throw new Error(`Task artifact not found for ${id}`);
|
|
3259
|
+
const { body, contentType } = await renderArtifact(mdxPath, rawFlag, themeParam);
|
|
3260
|
+
return new Response(body, { headers: { "Content-Type": contentType, ...cspHeaders } });
|
|
3261
|
+
}
|
|
3262
|
+
catch (e) {
|
|
3263
|
+
const m = e?.message || "Render error";
|
|
3264
|
+
return errorResponse(m, 500);
|
|
3265
|
+
}
|
|
3266
|
+
}
|
|
3267
|
+
const sessArtMatch = path.match(/^\/artifacts\/sessions\/([^/]+)$/);
|
|
3268
|
+
if (sessArtMatch) {
|
|
3269
|
+
const [_, sid] = sessArtMatch;
|
|
3270
|
+
try {
|
|
3271
|
+
const { body, contentType } = await renderArtifact(join(KANBAN_DIR, "sessions", `${sid}.mdx`), rawFlag, themeParam);
|
|
3272
|
+
return new Response(body, { headers: { "Content-Type": contentType, ...cspHeaders } });
|
|
3273
|
+
}
|
|
3274
|
+
catch (e) {
|
|
3275
|
+
const m = e?.message || "Render error";
|
|
3276
|
+
return errorResponse(m, 500);
|
|
3277
|
+
}
|
|
3278
|
+
}
|
|
3279
|
+
return requestErrorResponse(req, 404, "Not found");
|
|
3280
|
+
}
|
|
3281
|
+
// ─── Event subscription ───────────────────────────────────────────────────────
|
|
3282
|
+
export async function subscribeEvents(ctx, signal) {
|
|
3283
|
+
if (!ctx.client?.event?.subscribe)
|
|
3284
|
+
return;
|
|
3285
|
+
try {
|
|
3286
|
+
for await (const event of ctx.client.event.subscribe({ signal })) {
|
|
3287
|
+
await handleEvent(ctx, event);
|
|
3288
|
+
}
|
|
3289
|
+
}
|
|
3290
|
+
catch (e) {
|
|
3291
|
+
if (e?.name === "AbortError" || signal.aborted)
|
|
3292
|
+
return;
|
|
3293
|
+
const errMsg = e?.message ?? String(e);
|
|
3294
|
+
ctx.log("error", "Event subscription error: " + errMsg);
|
|
3295
|
+
}
|
|
3296
|
+
}
|