chatccc 0.2.253 → 0.2.254

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.
@@ -0,0 +1,213 @@
1
+ import { AGENT_TOOL_OPTIONS, isAgentTool } from "../../agent-tool.js";
2
+ import { readUtf8JsonBody } from "../../agent-rpc-body.js";
3
+ import { BoardService } from "../application/board-service.js";
4
+ import { NodeFilesystemBrowser } from "../infrastructure/filesystem-browser.js";
5
+ import { JsonBoardRepository } from "../repositories/json-board-repository.js";
6
+ import { BoardStoreError } from "../repositories/board-repository.js";
7
+ const API_PREFIX = "/api/agent-team";
8
+ const MAX_REQUEST_BYTES = 256 * 1024;
9
+ export function createAgentTeamRequestHandler(options) {
10
+ const defaultWorkspace = options.defaultWorkspace ?? process.cwd();
11
+ const filesystemBrowser = options.filesystemBrowser ?? new NodeFilesystemBrowser({ defaultDirectory: defaultWorkspace });
12
+ const mainAgentService = () => {
13
+ if (typeof options.mainAgentService === "function")
14
+ return options.mainAgentService();
15
+ return options.mainAgentService ?? null;
16
+ };
17
+ return async function handleAgentTeamRequest(req, res) {
18
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
19
+ const pathname = url.pathname;
20
+ const method = req.method ?? "GET";
21
+ if (pathname !== API_PREFIX && !pathname.startsWith(`${API_PREFIX}/`))
22
+ return false;
23
+ try {
24
+ if (pathname === `${API_PREFIX}/workspaces` && method === "GET") {
25
+ jsonReply(res, 200, {
26
+ ok: true,
27
+ defaultWorkspace,
28
+ agentOptions: AGENT_TOOL_OPTIONS,
29
+ workspaces: await options.service.listWorkspaces(),
30
+ });
31
+ return true;
32
+ }
33
+ if (pathname === `${API_PREFIX}/filesystem/locations` && method === "GET") {
34
+ jsonReply(res, 200, { ok: true, locations: await filesystemBrowser.listLocations() });
35
+ return true;
36
+ }
37
+ if (pathname === `${API_PREFIX}/filesystem/directories` && method === "POST") {
38
+ const body = await bodyJson(req);
39
+ const directory = await filesystemBrowser.listDirectories(stringField(body.path, "path"), optionalBooleanField(body.showHidden, "showHidden"));
40
+ jsonReply(res, 200, { ok: true, directory });
41
+ return true;
42
+ }
43
+ if (pathname === `${API_PREFIX}/filesystem/validate-directory` && method === "POST") {
44
+ const body = await bodyJson(req);
45
+ jsonReply(res, 200, { ok: true, path: await filesystemBrowser.validateDirectory(stringField(body.path, "path")) });
46
+ return true;
47
+ }
48
+ if (pathname === `${API_PREFIX}/lookup` && method === "POST") {
49
+ const body = await bodyJson(req);
50
+ const board = await options.service.findWorkspace(stringField(body.workspacePath, "workspacePath"));
51
+ jsonReply(res, 200, board
52
+ ? { ok: true, exists: true, boardId: board.boardId }
53
+ : { ok: true, exists: false });
54
+ return true;
55
+ }
56
+ if (pathname === `${API_PREFIX}/open` && method === "POST") {
57
+ const body = await bodyJson(req);
58
+ const board = await options.service.openWorkspace(stringField(body.workspacePath, "workspacePath"));
59
+ jsonReply(res, 200, { ok: true, board, binding: await mainAgentService()?.getBinding(board.boardId) ?? null });
60
+ return true;
61
+ }
62
+ if (pathname === `${API_PREFIX}/feishu-contact` && method === "GET") {
63
+ const manager = requireMainAgentService(mainAgentService());
64
+ jsonReply(res, 200, { ok: true, contact: await manager.getContact() });
65
+ return true;
66
+ }
67
+ const boardMatch = pathname.match(/^\/api\/agent-team\/boards\/([^/]+)$/);
68
+ if (boardMatch && method === "GET") {
69
+ const boardId = decodeURIComponent(boardMatch[1]);
70
+ jsonReply(res, 200, {
71
+ ok: true,
72
+ board: await options.service.getBoard(boardId),
73
+ binding: await mainAgentService()?.getBinding(boardId) ?? null,
74
+ });
75
+ return true;
76
+ }
77
+ const relinkMatch = pathname.match(/^\/api\/agent-team\/boards\/([^/]+)\/relink$/);
78
+ if (relinkMatch && method === "POST") {
79
+ const body = await bodyJson(req);
80
+ const boardId = decodeURIComponent(relinkMatch[1]);
81
+ const workspacePath = stringField(body.workspacePath, "workspacePath");
82
+ const revision = revisionField(body.expectedRevision);
83
+ const manager = mainAgentService();
84
+ const result = manager
85
+ ? await manager.relinkWorkspace(boardId, workspacePath, revision)
86
+ : { board: await options.service.relinkWorkspace(boardId, workspacePath, revision), binding: null };
87
+ jsonReply(res, 200, { ok: true, ...result });
88
+ return true;
89
+ }
90
+ const mainAgentMatch = pathname.match(/^\/api\/agent-team\/boards\/([^/]+)\/main-agent$/);
91
+ if (mainAgentMatch && method === "POST") {
92
+ const body = await bodyJson(req);
93
+ if (!isAgentTool(body.agentId)) {
94
+ throw new BoardStoreError("invalid_request", `Unsupported primary Agent: ${String(body.agentId)}`, 400);
95
+ }
96
+ const result = await requireMainAgentService(mainAgentService()).setPrimaryAgent(decodeURIComponent(mainAgentMatch[1]), body.agentId, revisionField(body.expectedRevision));
97
+ jsonReply(res, 200, { ok: true, ...result });
98
+ return true;
99
+ }
100
+ const tasksMatch = pathname.match(/^\/api\/agent-team\/boards\/([^/]+)\/tasks$/);
101
+ if (tasksMatch && method === "POST") {
102
+ const body = await bodyJson(req);
103
+ const board = await options.service.createTask(decodeURIComponent(tasksMatch[1]), {
104
+ expectedRevision: revisionField(body.expectedRevision),
105
+ title: stringField(body.title, "title"),
106
+ description: optionalString(body.description, "description"),
107
+ columnId: body.columnId,
108
+ });
109
+ jsonReply(res, 200, { ok: true, board });
110
+ return true;
111
+ }
112
+ const moveMatch = pathname.match(/^\/api\/agent-team\/boards\/([^/]+)\/tasks\/([^/]+)\/move$/);
113
+ if (moveMatch && method === "POST") {
114
+ const body = await bodyJson(req);
115
+ const board = await options.service.moveTask(decodeURIComponent(moveMatch[1]), decodeURIComponent(moveMatch[2]), {
116
+ expectedRevision: revisionField(body.expectedRevision),
117
+ columnId: body.columnId,
118
+ index: integerField(body.index, "index"),
119
+ });
120
+ jsonReply(res, 200, { ok: true, board });
121
+ return true;
122
+ }
123
+ const taskMatch = pathname.match(/^\/api\/agent-team\/boards\/([^/]+)\/tasks\/([^/]+)$/);
124
+ if (taskMatch && method === "PATCH") {
125
+ const body = await bodyJson(req);
126
+ const board = await options.service.updateTask(decodeURIComponent(taskMatch[1]), decodeURIComponent(taskMatch[2]), {
127
+ expectedRevision: revisionField(body.expectedRevision),
128
+ title: stringField(body.title, "title"),
129
+ description: optionalString(body.description, "description"),
130
+ });
131
+ jsonReply(res, 200, { ok: true, board });
132
+ return true;
133
+ }
134
+ if (taskMatch && method === "DELETE") {
135
+ const body = await bodyJson(req);
136
+ const board = await options.service.deleteTask(decodeURIComponent(taskMatch[1]), decodeURIComponent(taskMatch[2]), {
137
+ expectedRevision: revisionField(body.expectedRevision),
138
+ });
139
+ jsonReply(res, 200, { ok: true, board });
140
+ return true;
141
+ }
142
+ jsonReply(res, 404, { ok: false, code: "not_found", error: "Agent Team API route not found" });
143
+ return true;
144
+ }
145
+ catch (err) {
146
+ const error = normalizeError(err);
147
+ jsonReply(res, error.status, { ok: false, code: error.code, error: error.message });
148
+ return true;
149
+ }
150
+ };
151
+ }
152
+ export const defaultAgentTeamBoardService = new BoardService(new JsonBoardRepository());
153
+ let defaultMainAgentService = null;
154
+ export const handleAgentTeamRequest = createAgentTeamRequestHandler({
155
+ service: defaultAgentTeamBoardService,
156
+ mainAgentService: () => defaultMainAgentService,
157
+ });
158
+ export function setDefaultAgentTeamMainAgentService(service) {
159
+ defaultMainAgentService = service;
160
+ }
161
+ function requireMainAgentService(service) {
162
+ if (!service) {
163
+ throw new BoardStoreError("main_agent_unavailable", "飞书主 Agent 服务尚未启动", 503);
164
+ }
165
+ return service;
166
+ }
167
+ async function bodyJson(req) {
168
+ try {
169
+ return await readUtf8JsonBody(req, MAX_REQUEST_BYTES);
170
+ }
171
+ catch (err) {
172
+ throw new BoardStoreError("invalid_request", `Invalid request body: ${err.message}`, 400);
173
+ }
174
+ }
175
+ function jsonReply(res, status, value) {
176
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
177
+ res.end(JSON.stringify(value));
178
+ }
179
+ function normalizeError(err) {
180
+ if (err instanceof BoardStoreError)
181
+ return err;
182
+ return new BoardStoreError("storage_error", err instanceof Error ? err.message : String(err), 500);
183
+ }
184
+ function stringField(value, name) {
185
+ if (typeof value !== "string" || !value.trim())
186
+ throw new BoardStoreError("invalid_request", `${name} must be a non-empty string`, 400);
187
+ return value;
188
+ }
189
+ function optionalString(value, name) {
190
+ if (value === undefined || value === null)
191
+ return "";
192
+ if (typeof value !== "string")
193
+ throw new BoardStoreError("invalid_request", `${name} must be a string`, 400);
194
+ return value;
195
+ }
196
+ function optionalBooleanField(value, name) {
197
+ if (value === undefined || value === null)
198
+ return false;
199
+ if (typeof value !== "boolean")
200
+ throw new BoardStoreError("invalid_request", `${name} must be a boolean`, 400);
201
+ return value;
202
+ }
203
+ function integerField(value, name) {
204
+ if (!Number.isInteger(value))
205
+ throw new BoardStoreError("invalid_request", `${name} must be an integer`, 400);
206
+ return value;
207
+ }
208
+ function revisionField(value) {
209
+ const revision = integerField(value, "expectedRevision");
210
+ if (revision < 0)
211
+ throw new BoardStoreError("invalid_request", "expectedRevision must be non-negative", 400);
212
+ return revision;
213
+ }
@@ -0,0 +1,112 @@
1
+ import { readdir, realpath, stat } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join, parse, resolve } from "node:path";
4
+ import { BoardStoreError } from "../repositories/board-repository.js";
5
+ export class NodeFilesystemBrowser {
6
+ defaultDirectory;
7
+ homeDirectory;
8
+ platform;
9
+ constructor(options = {}) {
10
+ this.defaultDirectory = options.defaultDirectory ?? process.cwd();
11
+ this.homeDirectory = options.homeDirectory ?? homedir();
12
+ this.platform = options.platform ?? process.platform;
13
+ }
14
+ async listLocations() {
15
+ const current = await this.validateDirectory(this.defaultDirectory);
16
+ const home = await this.validateDirectory(this.homeDirectory);
17
+ const locations = [
18
+ { label: "当前工作目录", path: current, kind: "current" },
19
+ { label: "用户目录", path: home, kind: "home" },
20
+ ];
21
+ if (this.platform === "win32") {
22
+ const drives = await availableWindowsDrives();
23
+ for (const drive of drives) {
24
+ if (!locations.some((location) => samePath(location.path, drive, this.platform))) {
25
+ locations.push({ label: drive, path: drive, kind: "drive" });
26
+ }
27
+ }
28
+ }
29
+ else if (!locations.some((location) => location.path === "/")) {
30
+ locations.push({ label: "根目录", path: "/", kind: "root" });
31
+ }
32
+ return locations;
33
+ }
34
+ async listDirectories(inputPath, showHidden) {
35
+ const canonical = await this.validateDirectory(inputPath);
36
+ let children;
37
+ try {
38
+ children = await readdir(canonical, { withFileTypes: true });
39
+ }
40
+ catch (err) {
41
+ throw filesystemError(err, `无法读取目录:${canonical}`);
42
+ }
43
+ const entries = (await Promise.all(children.map(async (child) => {
44
+ const hidden = child.name.startsWith(".");
45
+ if (hidden && !showHidden)
46
+ return null;
47
+ const childPath = join(canonical, child.name);
48
+ if (child.isDirectory())
49
+ return { name: child.name, path: childPath, hidden };
50
+ if (!child.isSymbolicLink())
51
+ return null;
52
+ try {
53
+ return (await stat(childPath)).isDirectory() ? { name: child.name, path: childPath, hidden } : null;
54
+ }
55
+ catch {
56
+ return null;
57
+ }
58
+ }))).filter((entry) => entry !== null);
59
+ entries.sort((a, b) => a.name.localeCompare(b.name, "zh-CN", { numeric: true, sensitivity: "base" }));
60
+ const parent = dirname(canonical);
61
+ return { path: canonical, parentPath: samePath(parent, canonical, this.platform) ? null : parent, entries };
62
+ }
63
+ async validateDirectory(inputPath) {
64
+ if (typeof inputPath !== "string" || !inputPath.trim()) {
65
+ throw new BoardStoreError("invalid_request", "目录路径不能为空", 400);
66
+ }
67
+ const absolute = resolve(inputPath.trim());
68
+ try {
69
+ const info = await stat(absolute);
70
+ if (!info.isDirectory())
71
+ throw new BoardStoreError("invalid_request", `不是目录:${absolute}`, 400);
72
+ return await realpath(absolute);
73
+ }
74
+ catch (err) {
75
+ if (err instanceof BoardStoreError)
76
+ throw err;
77
+ throw filesystemError(err, `目录不存在或无法访问:${absolute}`);
78
+ }
79
+ }
80
+ }
81
+ async function availableWindowsDrives() {
82
+ const candidates = Array.from({ length: 26 }, (_, index) => `${String.fromCharCode(65 + index)}:\\`);
83
+ const results = await Promise.all(candidates.map(async (drive) => await isDriveAvailable(drive) ? drive : null));
84
+ return results.filter((drive) => drive !== null);
85
+ }
86
+ function isDriveAvailable(drive) {
87
+ return new Promise((resolveAvailability) => {
88
+ let settled = false;
89
+ const finish = (available) => {
90
+ if (settled)
91
+ return;
92
+ settled = true;
93
+ clearTimeout(timeout);
94
+ resolveAvailability(available);
95
+ };
96
+ const timeout = setTimeout(() => finish(false), 350);
97
+ stat(drive).then((info) => finish(info.isDirectory())).catch(() => finish(false));
98
+ });
99
+ }
100
+ function samePath(left, right, platform) {
101
+ const normalize = (value) => {
102
+ const root = parse(value).root;
103
+ const trimmed = value === root ? value : value.replace(/[\\/]+$/, "");
104
+ return platform === "win32" ? trimmed.toLocaleLowerCase("en-US") : trimmed;
105
+ };
106
+ return normalize(left) === normalize(right);
107
+ }
108
+ function filesystemError(err, message) {
109
+ const code = err?.code;
110
+ const detail = code === "EACCES" || code === "EPERM" ? "(权限不足)" : "";
111
+ return new BoardStoreError("invalid_request", `${message}${detail}`, 400);
112
+ }
@@ -0,0 +1,30 @@
1
+ import { sessionPrefixForTool, setDefaultCwd } from "../../config.js";
2
+ import { isSessionRunning } from "../../session-chat-binding.js";
3
+ import { initClaudeSession, recordChatPlatform, switchChatBinding, } from "../../session.js";
4
+ export function createMainAgentSessionRuntime(platform) {
5
+ return {
6
+ createSession(agentId, cwd) {
7
+ return initClaudeSession(agentId, cwd);
8
+ },
9
+ isSessionRunning,
10
+ async bindSession(input) {
11
+ const description = `${sessionPrefixForTool(input.agentId)} ${input.newSessionId}`;
12
+ const result = await switchChatBinding({
13
+ chatId: input.chatId,
14
+ chatType: "group",
15
+ oldSessionId: input.oldSessionId,
16
+ newSessionId: input.newSessionId,
17
+ tool: input.agentId,
18
+ chatName: input.chatName,
19
+ namePolicy: "fixed",
20
+ newDescription: description,
21
+ updateChatInfoFn: (chatId, name, nextDescription) => platform.updateChatInfo(chatId, name, nextDescription),
22
+ });
23
+ if (!result.ok)
24
+ throw result.error ?? new Error("Failed to bind main Agent session");
25
+ await setDefaultCwd(input.cwd, input.chatId);
26
+ recordChatPlatform(input.chatId, platform);
27
+ await platform.setChatAvatar(input.chatId, input.agentId, "new").catch(() => { });
28
+ },
29
+ };
30
+ }
@@ -0,0 +1,15 @@
1
+ import { MainAgentService } from "./application/main-agent-service.js";
2
+ import { defaultAgentTeamBoardService, setDefaultAgentTeamMainAgentService, } from "./http/board-routes.js";
3
+ import { createMainAgentSessionRuntime } from "./infrastructure/main-agent-session-runtime.js";
4
+ import { feishuP2pContactStore } from "./repositories/feishu-p2p-contact-store.js";
5
+ import { JsonMainAgentBindingRepository } from "./repositories/main-agent-binding-repository.js";
6
+ /** Wire runtime dependencies only from index.ts, keeping the standalone Web UI import side-effect free. */
7
+ export function configureAgentTeamMainAgent(platform) {
8
+ setDefaultAgentTeamMainAgentService(new MainAgentService({
9
+ boardService: defaultAgentTeamBoardService,
10
+ bindingRepository: new JsonMainAgentBindingRepository(),
11
+ contactStore: feishuP2pContactStore,
12
+ platform,
13
+ runtime: createMainAgentSessionRuntime(platform),
14
+ }));
15
+ }
@@ -0,0 +1,10 @@
1
+ export class BoardStoreError extends Error {
2
+ code;
3
+ status;
4
+ constructor(code, message, status) {
5
+ super(message);
6
+ this.code = code;
7
+ this.status = status;
8
+ this.name = "BoardStoreError";
9
+ }
10
+ }
@@ -0,0 +1,64 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ export class FeishuP2pContactStore {
6
+ filePath;
7
+ operationQueue = Promise.resolve();
8
+ constructor(options = {}) {
9
+ this.filePath = options.filePath ?? join(homedir(), ".chatccc", "state", "last-feishu-p2p-contact.json");
10
+ }
11
+ async get() {
12
+ try {
13
+ return parseContact(JSON.parse(await readFile(this.filePath, "utf8")));
14
+ }
15
+ catch (err) {
16
+ if (err.code === "ENOENT")
17
+ return null;
18
+ throw err;
19
+ }
20
+ }
21
+ async record(contact) {
22
+ const parsed = parseContact(contact);
23
+ await this.exclusive(async () => {
24
+ await writeJsonAtomic(this.filePath, parsed);
25
+ });
26
+ }
27
+ async exclusive(operation) {
28
+ const previous = this.operationQueue;
29
+ let release;
30
+ this.operationQueue = new Promise((resolveQueue) => { release = resolveQueue; });
31
+ await previous;
32
+ try {
33
+ return await operation();
34
+ }
35
+ finally {
36
+ release();
37
+ }
38
+ }
39
+ }
40
+ function parseContact(value) {
41
+ if (!value || typeof value !== "object" || Array.isArray(value))
42
+ throw new Error("Invalid Feishu P2P contact");
43
+ const contact = value;
44
+ if (typeof contact.openId !== "string" || !contact.openId)
45
+ throw new Error("Feishu P2P contact is missing openId");
46
+ if (typeof contact.chatId !== "string" || !contact.chatId)
47
+ throw new Error("Feishu P2P contact is missing chatId");
48
+ if (typeof contact.receivedAt !== "string" || !contact.receivedAt)
49
+ throw new Error("Feishu P2P contact is missing receivedAt");
50
+ return { openId: contact.openId, chatId: contact.chatId, receivedAt: contact.receivedAt };
51
+ }
52
+ async function writeJsonAtomic(path, value) {
53
+ await mkdir(dirname(path), { recursive: true });
54
+ const tempPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
55
+ await writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
56
+ try {
57
+ await rename(tempPath, path);
58
+ }
59
+ catch (err) {
60
+ await unlink(tempPath).catch(() => { });
61
+ throw err;
62
+ }
63
+ }
64
+ export const feishuP2pContactStore = new FeishuP2pContactStore();
@@ -0,0 +1,209 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { existsSync } from "node:fs";
3
+ import { mkdir, readFile, realpath, rename, stat, unlink, writeFile } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { dirname, join, resolve } from "node:path";
6
+ import { createEmptyBoard, parseBoard } from "../domain/board.js";
7
+ import { BoardStoreError } from "./board-repository.js";
8
+ const EMPTY_INDEX = { schemaVersion: 1, workspaces: [] };
9
+ export class JsonBoardRepository {
10
+ rootDir;
11
+ boardsDir;
12
+ indexPath;
13
+ now;
14
+ idFactory;
15
+ operationQueue = Promise.resolve();
16
+ constructor(options = {}) {
17
+ this.rootDir = options.rootDir ?? join(homedir(), ".chatccc", "agent-team");
18
+ this.boardsDir = join(this.rootDir, "boards");
19
+ this.indexPath = join(this.rootDir, "workspaces.json");
20
+ this.now = options.now ?? (() => new Date());
21
+ this.idFactory = options.idFactory ?? randomUUID;
22
+ }
23
+ async findWorkspace(workspacePath) {
24
+ const canonical = await this.canonicalWorkspace(workspacePath);
25
+ const normalizedPath = normalizeWorkspaceKey(canonical);
26
+ const index = await this.readIndex();
27
+ const record = index.workspaces.find((item) => item.normalizedPath === normalizedPath);
28
+ return record ? this.readBoard(record.boardId) : null;
29
+ }
30
+ async openWorkspace(workspacePath) {
31
+ return this.exclusive(async () => {
32
+ const canonical = await this.canonicalWorkspace(workspacePath);
33
+ const normalizedPath = normalizeWorkspaceKey(canonical);
34
+ const index = await this.readIndex();
35
+ let record = index.workspaces.find((item) => item.normalizedPath === normalizedPath);
36
+ const now = this.now().toISOString();
37
+ if (!record) {
38
+ const board = createEmptyBoard({ boardId: this.idFactory(), workspacePath: canonical, now });
39
+ record = { boardId: board.boardId, workspacePath: canonical, normalizedPath, lastOpenedAt: now };
40
+ index.workspaces.unshift(record);
41
+ await this.writeBoard(board);
42
+ await this.writeIndex(index);
43
+ return board;
44
+ }
45
+ record.workspacePath = canonical;
46
+ record.lastOpenedAt = now;
47
+ await this.writeIndex(index);
48
+ const board = await this.readBoard(record.boardId);
49
+ if (board.workspacePath !== canonical) {
50
+ const updated = { ...board, workspacePath: canonical, revision: board.revision + 1, updatedAt: now };
51
+ await this.writeBoard(updated);
52
+ return updated;
53
+ }
54
+ return board;
55
+ });
56
+ }
57
+ async getBoard(boardId) {
58
+ return this.readBoard(boardId);
59
+ }
60
+ async saveBoard(board, expectedRevision) {
61
+ await this.exclusive(async () => {
62
+ const current = await this.readBoard(board.boardId);
63
+ if (current.revision !== expectedRevision)
64
+ throw revisionConflict(current.revision, expectedRevision);
65
+ if (board.revision !== expectedRevision + 1) {
66
+ throw new BoardStoreError("invalid_request", "Board revision must increase by exactly one", 400);
67
+ }
68
+ parseBoard(board);
69
+ await this.writeBoard(board);
70
+ });
71
+ }
72
+ async listWorkspaces() {
73
+ const index = await this.readIndex();
74
+ const sorted = [...index.workspaces].sort((a, b) => b.lastOpenedAt.localeCompare(a.lastOpenedAt)).slice(0, 20);
75
+ return Promise.all(sorted.map(async (record) => ({
76
+ boardId: record.boardId,
77
+ workspacePath: record.workspacePath,
78
+ lastOpenedAt: record.lastOpenedAt,
79
+ exists: await directoryExists(record.workspacePath),
80
+ })));
81
+ }
82
+ async relinkWorkspace(boardId, workspacePath, expectedRevision) {
83
+ return this.exclusive(async () => {
84
+ const canonical = await this.canonicalWorkspace(workspacePath);
85
+ const normalizedPath = normalizeWorkspaceKey(canonical);
86
+ const index = await this.readIndex();
87
+ const collision = index.workspaces.find((item) => item.normalizedPath === normalizedPath && item.boardId !== boardId);
88
+ if (collision) {
89
+ throw new BoardStoreError("workspace_conflict", "The selected directory already has another board", 409);
90
+ }
91
+ const current = await this.readBoard(boardId);
92
+ if (current.revision !== expectedRevision)
93
+ throw revisionConflict(current.revision, expectedRevision);
94
+ const now = this.now().toISOString();
95
+ const record = index.workspaces.find((item) => item.boardId === boardId);
96
+ if (!record)
97
+ throw new BoardStoreError("not_found", `Workspace record not found for board ${boardId}`, 404);
98
+ record.workspacePath = canonical;
99
+ record.normalizedPath = normalizedPath;
100
+ record.lastOpenedAt = now;
101
+ const updated = {
102
+ ...current,
103
+ workspacePath: canonical,
104
+ revision: current.revision + 1,
105
+ updatedAt: now,
106
+ };
107
+ await this.writeBoard(updated);
108
+ await this.writeIndex(index);
109
+ return updated;
110
+ });
111
+ }
112
+ async canonicalWorkspace(input) {
113
+ if (typeof input !== "string" || !input.trim()) {
114
+ throw new BoardStoreError("invalid_request", "workspacePath must be a non-empty string", 400);
115
+ }
116
+ const absolute = resolve(input.trim());
117
+ let info;
118
+ try {
119
+ info = await stat(absolute);
120
+ }
121
+ catch {
122
+ throw new BoardStoreError("invalid_request", `Working directory does not exist: ${absolute}`, 400);
123
+ }
124
+ if (!info.isDirectory())
125
+ throw new BoardStoreError("invalid_request", `Not a directory: ${absolute}`, 400);
126
+ return realpath(absolute);
127
+ }
128
+ boardPath(boardId) {
129
+ if (!/^[a-zA-Z0-9_-]+$/.test(boardId)) {
130
+ throw new BoardStoreError("invalid_request", "Invalid board id", 400);
131
+ }
132
+ return join(this.boardsDir, `${boardId}.json`);
133
+ }
134
+ async readBoard(boardId) {
135
+ const path = this.boardPath(boardId);
136
+ try {
137
+ return parseBoard(JSON.parse(await readFile(path, "utf8")));
138
+ }
139
+ catch (err) {
140
+ if (err.code === "ENOENT") {
141
+ throw new BoardStoreError("not_found", `Board not found: ${boardId}`, 404);
142
+ }
143
+ if (err instanceof BoardStoreError)
144
+ throw err;
145
+ throw new BoardStoreError("storage_error", `Failed to read board ${boardId}: ${err.message}`, 500);
146
+ }
147
+ }
148
+ async readIndex() {
149
+ if (!existsSync(this.indexPath))
150
+ return { ...EMPTY_INDEX, workspaces: [] };
151
+ try {
152
+ const parsed = JSON.parse(await readFile(this.indexPath, "utf8"));
153
+ if (parsed.schemaVersion !== 1 || !Array.isArray(parsed.workspaces))
154
+ throw new Error("Invalid workspace index");
155
+ return { schemaVersion: 1, workspaces: parsed.workspaces };
156
+ }
157
+ catch (err) {
158
+ throw new BoardStoreError("storage_error", `Failed to read workspace index: ${err.message}`, 500);
159
+ }
160
+ }
161
+ async writeBoard(board) {
162
+ await writeJsonAtomic(this.boardPath(board.boardId), board);
163
+ }
164
+ async writeIndex(index) {
165
+ const deduped = new Map();
166
+ for (const record of index.workspaces)
167
+ deduped.set(record.boardId, record);
168
+ await writeJsonAtomic(this.indexPath, { schemaVersion: 1, workspaces: [...deduped.values()] });
169
+ }
170
+ async exclusive(operation) {
171
+ const previous = this.operationQueue;
172
+ let release;
173
+ this.operationQueue = new Promise((resolveQueue) => { release = resolveQueue; });
174
+ await previous;
175
+ try {
176
+ return await operation();
177
+ }
178
+ finally {
179
+ release();
180
+ }
181
+ }
182
+ }
183
+ function normalizeWorkspaceKey(path) {
184
+ const normalized = resolve(path).replace(/[\\/]+$/, "");
185
+ return process.platform === "win32" ? normalized.toLocaleLowerCase("en-US") : normalized;
186
+ }
187
+ async function directoryExists(path) {
188
+ try {
189
+ return (await stat(path)).isDirectory();
190
+ }
191
+ catch {
192
+ return false;
193
+ }
194
+ }
195
+ async function writeJsonAtomic(path, value) {
196
+ await mkdir(dirname(path), { recursive: true });
197
+ const tempPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
198
+ await writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
199
+ try {
200
+ await rename(tempPath, path);
201
+ }
202
+ catch (err) {
203
+ await unlink(tempPath).catch(() => { });
204
+ throw err;
205
+ }
206
+ }
207
+ function revisionConflict(actual, expected) {
208
+ return new BoardStoreError("revision_conflict", `Board changed in another page (expected revision ${expected}, current ${actual})`, 409);
209
+ }