@listen1954/board 0.1.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/README.md +29 -0
- package/client.d.ts +2 -0
- package/cordis.patch.yml +3 -0
- package/lib/client.js +2374 -0
- package/lib/domain.d.ts +213 -0
- package/lib/domain.js +669 -0
- package/lib/index.d.ts +13 -0
- package/lib/index.js +56 -0
- package/lib/prompt.d.ts +2 -0
- package/lib/prompt.js +18 -0
- package/lib/rpc.d.ts +21 -0
- package/lib/rpc.js +101 -0
- package/lib/service.d.ts +74 -0
- package/lib/service.js +417 -0
- package/lib/store.d.ts +22 -0
- package/lib/store.js +178 -0
- package/lib/tools.d.ts +23 -0
- package/lib/tools.js +594 -0
- package/package.json +63 -0
package/lib/service.js
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
var __runInitializers = (this && this.__runInitializers) || function (thisArg, initializers, value) {
|
|
2
|
+
var useValue = arguments.length > 2;
|
|
3
|
+
for (var i = 0; i < initializers.length; i++) {
|
|
4
|
+
value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
|
|
5
|
+
}
|
|
6
|
+
return useValue ? value : void 0;
|
|
7
|
+
};
|
|
8
|
+
var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
|
|
9
|
+
function accept(f) { if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected"); return f; }
|
|
10
|
+
var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
|
|
11
|
+
var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
|
|
12
|
+
var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
|
|
13
|
+
var _, done = false;
|
|
14
|
+
for (var i = decorators.length - 1; i >= 0; i--) {
|
|
15
|
+
var context = {};
|
|
16
|
+
for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
|
|
17
|
+
for (var p in contextIn.access) context.access[p] = contextIn.access[p];
|
|
18
|
+
context.addInitializer = function (f) { if (done) throw new TypeError("Cannot add initializers after decoration has completed"); extraInitializers.push(accept(f || null)); };
|
|
19
|
+
var result = (0, decorators[i])(kind === "accessor" ? { get: descriptor.get, set: descriptor.set } : descriptor[key], context);
|
|
20
|
+
if (kind === "accessor") {
|
|
21
|
+
if (result === void 0) continue;
|
|
22
|
+
if (result === null || typeof result !== "object") throw new TypeError("Object expected");
|
|
23
|
+
if (_ = accept(result.get)) descriptor.get = _;
|
|
24
|
+
if (_ = accept(result.set)) descriptor.set = _;
|
|
25
|
+
if (_ = accept(result.init)) initializers.unshift(_);
|
|
26
|
+
}
|
|
27
|
+
else if (_ = accept(result)) {
|
|
28
|
+
if (kind === "field") initializers.unshift(_);
|
|
29
|
+
else descriptor[key] = _;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (target) Object.defineProperty(target, contextIn.name, descriptor);
|
|
33
|
+
done = true;
|
|
34
|
+
};
|
|
35
|
+
import { spawn } from 'node:child_process';
|
|
36
|
+
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
|
|
37
|
+
import { applyMutate, assertSessionId, BoardNotFoundError, BoardOpError, BoardRevisionError, fireDueReminders, newId, summarizeBoard, } from './domain.js';
|
|
38
|
+
/** Store-backed board API. Tests construct this directly (no Cordis). */
|
|
39
|
+
export class BoardModel {
|
|
40
|
+
store;
|
|
41
|
+
wakeOnGlobalReminder;
|
|
42
|
+
now;
|
|
43
|
+
createId;
|
|
44
|
+
emitChange;
|
|
45
|
+
openFile;
|
|
46
|
+
mutationChain = Promise.resolve();
|
|
47
|
+
migrationPromise = null;
|
|
48
|
+
migrationChecked = false;
|
|
49
|
+
constructor(store, options = {}) {
|
|
50
|
+
this.store = store;
|
|
51
|
+
this.wakeOnGlobalReminder = options.wakeOnGlobalReminder ?? false;
|
|
52
|
+
this.now = options.now ?? (() => new Date());
|
|
53
|
+
this.createId = options.newId ?? newId;
|
|
54
|
+
this.emitChange = options.emit ?? (() => undefined);
|
|
55
|
+
this.openFile = options.openFile ?? openWithSystem;
|
|
56
|
+
}
|
|
57
|
+
async list(callerSessionId) {
|
|
58
|
+
const global = await this.loadUnifiedGlobal();
|
|
59
|
+
const session = projectSessionBoard(global, callerSessionId);
|
|
60
|
+
return [summarizeBoard(global), summarizeBoard(session)];
|
|
61
|
+
}
|
|
62
|
+
async getBoard(callerSessionId, scope, defaultColumns) {
|
|
63
|
+
const global = await this.loadUnifiedGlobal(defaultColumns);
|
|
64
|
+
return scope === 'global' ? global : projectSessionBoard(global, callerSessionId);
|
|
65
|
+
}
|
|
66
|
+
async getCard(callerSessionId, scope, cardId) {
|
|
67
|
+
const board = await this.getBoard(callerSessionId, scope);
|
|
68
|
+
const card = board.cards.find((item) => item.id === cardId);
|
|
69
|
+
if (card === undefined)
|
|
70
|
+
throw new BoardNotFoundError();
|
|
71
|
+
return card;
|
|
72
|
+
}
|
|
73
|
+
async mutate(callerSessionId, scope, op, revision) {
|
|
74
|
+
return this.enqueueMutation(async () => {
|
|
75
|
+
const currentGlobal = await this.loadUnifiedGlobal();
|
|
76
|
+
const currentView = scope === 'global' ? currentGlobal : projectSessionBoard(currentGlobal, callerSessionId);
|
|
77
|
+
if (revision !== undefined && currentView.revision !== revision) {
|
|
78
|
+
throw new BoardRevisionError(revision, currentView.revision);
|
|
79
|
+
}
|
|
80
|
+
const nextGlobal = scope === 'global'
|
|
81
|
+
? applyMutate(currentGlobal, op, this.now(), this.createId)
|
|
82
|
+
: applySessionMutation(currentGlobal, callerSessionId, op, this.now(), this.createId);
|
|
83
|
+
await this.store.write(nextGlobal);
|
|
84
|
+
await this.removeDeletedAttachments(currentGlobal, nextGlobal);
|
|
85
|
+
this.emitBoard(nextGlobal);
|
|
86
|
+
return scope === 'global' ? nextGlobal : projectSessionBoard(nextGlobal, callerSessionId);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
async addLog(callerSessionId, scope, cardId, text, attachments, revision) {
|
|
90
|
+
return this.enqueueMutation(async () => {
|
|
91
|
+
const currentGlobal = await this.loadUnifiedGlobal();
|
|
92
|
+
const currentView = scope === 'global' ? currentGlobal : projectSessionBoard(currentGlobal, callerSessionId);
|
|
93
|
+
if (currentView.revision !== revision)
|
|
94
|
+
throw new BoardRevisionError(revision, currentView.revision);
|
|
95
|
+
const files = prepareAttachments(attachments, this.createId);
|
|
96
|
+
const op = { type: 'addLog', cardId, text, attachments: files.map((file) => file.meta) };
|
|
97
|
+
const nextGlobal = scope === 'global'
|
|
98
|
+
? applyMutate(currentGlobal, op, this.now(), this.createId)
|
|
99
|
+
: applySessionMutation(currentGlobal, callerSessionId, op, this.now(), this.createId);
|
|
100
|
+
const written = [];
|
|
101
|
+
try {
|
|
102
|
+
for (const file of files) {
|
|
103
|
+
await this.store.writeAttachment(file.meta.id, file.data);
|
|
104
|
+
written.push(file.meta.id);
|
|
105
|
+
}
|
|
106
|
+
await this.store.write(nextGlobal);
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
for (const id of written)
|
|
110
|
+
await this.store.removeAttachment(id);
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
this.emitBoard(nextGlobal);
|
|
114
|
+
return scope === 'global' ? nextGlobal : projectSessionBoard(nextGlobal, callerSessionId);
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
async getAttachment(callerSessionId, scope, cardId, attachmentId) {
|
|
118
|
+
const card = await this.getCard(callerSessionId, scope, cardId);
|
|
119
|
+
const attachment = card.logs.flatMap((log) => log.attachments).find((item) => item.id === attachmentId);
|
|
120
|
+
if (attachment === undefined)
|
|
121
|
+
throw new BoardNotFoundError();
|
|
122
|
+
const data = await this.store.readAttachment(attachment.id);
|
|
123
|
+
return { name: attachment.name, type: attachment.type, data: data.toString('base64') };
|
|
124
|
+
}
|
|
125
|
+
async openAttachment(callerSessionId, scope, cardId, attachmentId) {
|
|
126
|
+
const card = await this.getCard(callerSessionId, scope, cardId);
|
|
127
|
+
const attachment = card.logs.flatMap((log) => log.attachments).find((item) => item.id === attachmentId);
|
|
128
|
+
if (attachment === undefined)
|
|
129
|
+
throw new BoardNotFoundError();
|
|
130
|
+
const path = await this.store.materializeAttachment(attachment.id, attachment.name);
|
|
131
|
+
await this.openFile(path);
|
|
132
|
+
return { opened: true };
|
|
133
|
+
}
|
|
134
|
+
async fireDue() {
|
|
135
|
+
return this.enqueueMutation(async () => {
|
|
136
|
+
const events = [];
|
|
137
|
+
if (!await this.store.exists('global'))
|
|
138
|
+
return events;
|
|
139
|
+
const current = await this.loadUnifiedGlobal();
|
|
140
|
+
const result = fireDueReminders(current, this.now());
|
|
141
|
+
if (result.fired.length === 0)
|
|
142
|
+
return events;
|
|
143
|
+
await this.store.write(result.board);
|
|
144
|
+
this.emitBoard(result.board);
|
|
145
|
+
events.push({ scope: 'global', board: result.board, fired: result.fired });
|
|
146
|
+
return events;
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
async loadUnifiedGlobal(defaultColumns) {
|
|
150
|
+
await this.ensureLegacyMigration(defaultColumns);
|
|
151
|
+
const board = await this.store.read('global', undefined, defaultColumns);
|
|
152
|
+
if (board.scope !== 'global')
|
|
153
|
+
throw new BoardNotFoundError();
|
|
154
|
+
return board;
|
|
155
|
+
}
|
|
156
|
+
async ensureLegacyMigration(defaultColumns) {
|
|
157
|
+
if (this.migrationChecked)
|
|
158
|
+
return;
|
|
159
|
+
if (this.migrationPromise === null) {
|
|
160
|
+
this.migrationPromise = this.migrateLegacySessionBoards(defaultColumns).finally(() => { this.migrationPromise = null; });
|
|
161
|
+
}
|
|
162
|
+
await this.migrationPromise;
|
|
163
|
+
}
|
|
164
|
+
async migrateLegacySessionBoards(defaultColumns) {
|
|
165
|
+
const boards = await this.store.readAll(defaultColumns);
|
|
166
|
+
const global = boards.find((board) => board.scope === 'global');
|
|
167
|
+
if (global === undefined)
|
|
168
|
+
throw new BoardNotFoundError();
|
|
169
|
+
if (global.sessionColumnsMigrated === true) {
|
|
170
|
+
this.migrationChecked = true;
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const legacy = boards.filter((board) => board.scope === 'session' && board.sessionId !== undefined);
|
|
174
|
+
if (legacy.length === 0) {
|
|
175
|
+
this.migrationChecked = true;
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const migrated = mergeLegacySessionBoards(global, legacy, this.now(), this.createId);
|
|
179
|
+
await this.store.write(migrated);
|
|
180
|
+
this.emitBoard(migrated);
|
|
181
|
+
this.migrationChecked = true;
|
|
182
|
+
}
|
|
183
|
+
emitBoard(board) {
|
|
184
|
+
this.emitChange({
|
|
185
|
+
scope: board.scope,
|
|
186
|
+
...(board.sessionId === undefined ? {} : { sessionId: board.sessionId }),
|
|
187
|
+
board,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
async removeDeletedAttachments(previous, next) {
|
|
191
|
+
const remaining = new Set(next.cards.flatMap((card) => card.logs.flatMap((log) => log.attachments.map((attachment) => attachment.id))));
|
|
192
|
+
const removed = previous.cards.flatMap((card) => card.logs.flatMap((log) => log.attachments)).filter((attachment) => !remaining.has(attachment.id));
|
|
193
|
+
for (const attachment of removed)
|
|
194
|
+
await this.store.removeAttachment(attachment.id, attachment.name);
|
|
195
|
+
}
|
|
196
|
+
enqueueMutation(work) {
|
|
197
|
+
const run = this.mutationChain.then(work, work);
|
|
198
|
+
this.mutationChain = run.then(() => undefined, () => undefined);
|
|
199
|
+
return run;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
function projectSessionBoard(global, sessionId) {
|
|
203
|
+
assertSessionId(sessionId);
|
|
204
|
+
const column = global.columns.find((item) => item.sessionId === sessionId);
|
|
205
|
+
return {
|
|
206
|
+
schemaVersion: 1,
|
|
207
|
+
id: `session:${sessionId}`,
|
|
208
|
+
scope: 'session',
|
|
209
|
+
sessionId,
|
|
210
|
+
title: 'Session',
|
|
211
|
+
columns: column === undefined ? [] : [{ ...structuredClone(column), order: 0 }],
|
|
212
|
+
cards: column === undefined ? [] : global.cards.filter((card) => card.columnId === column.id).map((card) => structuredClone(card)),
|
|
213
|
+
revision: global.revision,
|
|
214
|
+
updatedAt: global.updatedAt,
|
|
215
|
+
sessionColumnsMigrated: true,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
function applySessionMutation(global, sessionId, op, now, createId) {
|
|
219
|
+
const ownedColumn = global.columns.find((column) => column.sessionId === sessionId);
|
|
220
|
+
if (op.type === 'addColumn') {
|
|
221
|
+
if (ownedColumn !== undefined)
|
|
222
|
+
throw new BoardOpError('this session already has its task column');
|
|
223
|
+
const existing = new Set(global.columns.map((column) => column.id));
|
|
224
|
+
const next = applyMutate(global, op, now, createId);
|
|
225
|
+
const created = next.columns.find((column) => !existing.has(column.id));
|
|
226
|
+
if (created === undefined)
|
|
227
|
+
throw new BoardOpError('failed to create session task column');
|
|
228
|
+
created.sessionId = sessionId;
|
|
229
|
+
return next;
|
|
230
|
+
}
|
|
231
|
+
if (ownedColumn === undefined)
|
|
232
|
+
throw new BoardOpError('this session has no task column; addColumn first');
|
|
233
|
+
const ownColumn = (columnId) => {
|
|
234
|
+
if (columnId !== ownedColumn.id)
|
|
235
|
+
throw new BoardOpError('session operations can only target this session task column');
|
|
236
|
+
};
|
|
237
|
+
const ownCard = (cardId) => {
|
|
238
|
+
const card = global.cards.find((item) => item.id === cardId);
|
|
239
|
+
if (card === undefined || card.columnId !== ownedColumn.id)
|
|
240
|
+
throw new BoardNotFoundError();
|
|
241
|
+
};
|
|
242
|
+
switch (op.type) {
|
|
243
|
+
case 'renameColumn':
|
|
244
|
+
ownColumn(op.columnId);
|
|
245
|
+
break;
|
|
246
|
+
case 'deleteColumn':
|
|
247
|
+
ownColumn(op.columnId);
|
|
248
|
+
break;
|
|
249
|
+
case 'reorderColumns': throw new BoardOpError('a session board has one fixed task column');
|
|
250
|
+
case 'addCard':
|
|
251
|
+
ownColumn(op.columnId);
|
|
252
|
+
break;
|
|
253
|
+
case 'updateCard':
|
|
254
|
+
ownCard(op.cardId);
|
|
255
|
+
break;
|
|
256
|
+
case 'addLog':
|
|
257
|
+
ownCard(op.cardId);
|
|
258
|
+
break;
|
|
259
|
+
case 'moveCard':
|
|
260
|
+
ownCard(op.cardId);
|
|
261
|
+
ownColumn(op.columnId);
|
|
262
|
+
break;
|
|
263
|
+
case 'archiveCard':
|
|
264
|
+
ownCard(op.cardId);
|
|
265
|
+
break;
|
|
266
|
+
case 'restoreCard':
|
|
267
|
+
ownCard(op.cardId);
|
|
268
|
+
ownColumn(op.columnId);
|
|
269
|
+
break;
|
|
270
|
+
case 'restoreCards':
|
|
271
|
+
for (const item of op.cards) {
|
|
272
|
+
ownCard(item.cardId);
|
|
273
|
+
ownColumn(item.columnId);
|
|
274
|
+
}
|
|
275
|
+
break;
|
|
276
|
+
case 'deleteCard':
|
|
277
|
+
ownCard(op.cardId);
|
|
278
|
+
break;
|
|
279
|
+
case 'deleteCards':
|
|
280
|
+
for (const cardId of op.cardIds)
|
|
281
|
+
ownCard(cardId);
|
|
282
|
+
break;
|
|
283
|
+
case 'setReminder':
|
|
284
|
+
ownCard(op.cardId);
|
|
285
|
+
break;
|
|
286
|
+
case 'clearReminder':
|
|
287
|
+
ownCard(op.cardId);
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
return applyMutate(global, op, now, createId);
|
|
291
|
+
}
|
|
292
|
+
function mergeLegacySessionBoards(global, legacy, now, createId) {
|
|
293
|
+
const next = structuredClone(global);
|
|
294
|
+
const columnIds = new Set(next.columns.map((column) => column.id));
|
|
295
|
+
const cardIds = new Set(next.cards.map((card) => card.id));
|
|
296
|
+
const importedSessions = new Set(next.columns.flatMap((column) => column.sessionId === undefined ? [] : [column.sessionId]));
|
|
297
|
+
for (const session of [...legacy].sort((a, b) => a.sessionId.localeCompare(b.sessionId))) {
|
|
298
|
+
if (importedSessions.has(session.sessionId))
|
|
299
|
+
continue;
|
|
300
|
+
const columnId = nextUniqueId('col', columnIds, createId);
|
|
301
|
+
next.columns.push({ id: columnId, title: sessionColumnTitle(session.sessionId), order: next.columns.length, sessionId: session.sessionId });
|
|
302
|
+
importedSessions.add(session.sessionId);
|
|
303
|
+
const columnOrder = new Map(session.columns.map((column) => [column.id, column.order]));
|
|
304
|
+
const cards = [...session.cards].sort((a, b) => (columnOrder.get(a.columnId) ?? 0) - (columnOrder.get(b.columnId) ?? 0) || a.order - b.order);
|
|
305
|
+
cards.forEach((card, index) => {
|
|
306
|
+
const copy = structuredClone(card);
|
|
307
|
+
copy.id = nextUniqueId('card', cardIds, createId, copy.id);
|
|
308
|
+
copy.columnId = columnId;
|
|
309
|
+
copy.order = index;
|
|
310
|
+
next.cards.push(copy);
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
next.sessionColumnsMigrated = true;
|
|
314
|
+
next.revision += 1;
|
|
315
|
+
next.updatedAt = now.toISOString();
|
|
316
|
+
return next;
|
|
317
|
+
}
|
|
318
|
+
function nextUniqueId(kind, used, createId, preferred) {
|
|
319
|
+
let candidate = preferred ?? createId(kind);
|
|
320
|
+
if (used.has(candidate)) {
|
|
321
|
+
const base = createId(kind);
|
|
322
|
+
candidate = base;
|
|
323
|
+
let suffix = 2;
|
|
324
|
+
while (used.has(candidate))
|
|
325
|
+
candidate = `${base}_${String(suffix++)}`;
|
|
326
|
+
}
|
|
327
|
+
used.add(candidate);
|
|
328
|
+
return candidate;
|
|
329
|
+
}
|
|
330
|
+
function sessionColumnTitle(sessionId) {
|
|
331
|
+
const compact = sessionId.replace(/^session-/, '').slice(0, 8);
|
|
332
|
+
return `Session · ${compact}`;
|
|
333
|
+
}
|
|
334
|
+
/** Host service: `ctx.board` plus Typert SRC Remote (`board/getBoard`, `board/mutate`). */
|
|
335
|
+
let BoardService = (() => {
|
|
336
|
+
let _classSuper = TypertRemoteService;
|
|
337
|
+
let _instanceExtraInitializers = [];
|
|
338
|
+
let _getBoard_decorators;
|
|
339
|
+
let _mutate_decorators;
|
|
340
|
+
return class BoardService extends _classSuper {
|
|
341
|
+
static {
|
|
342
|
+
const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
|
|
343
|
+
_getBoard_decorators = [Remote];
|
|
344
|
+
_mutate_decorators = [Remote];
|
|
345
|
+
__esDecorate(this, null, _getBoard_decorators, { kind: "method", name: "getBoard", static: false, private: false, access: { has: obj => "getBoard" in obj, get: obj => obj.getBoard }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
346
|
+
__esDecorate(this, null, _mutate_decorators, { kind: "method", name: "mutate", static: false, private: false, access: { has: obj => "mutate" in obj, get: obj => obj.mutate }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
347
|
+
if (_metadata) Object.defineProperty(this, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
|
|
348
|
+
}
|
|
349
|
+
model = __runInitializers(this, _instanceExtraInitializers);
|
|
350
|
+
constructor(ctx, store, options = {}) {
|
|
351
|
+
super(ctx, 'board');
|
|
352
|
+
this.model = new BoardModel(store, {
|
|
353
|
+
...options,
|
|
354
|
+
emit: (event) => {
|
|
355
|
+
ctx.emit('board/changed', event);
|
|
356
|
+
options.emit?.(event);
|
|
357
|
+
},
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
get wakeOnGlobalReminder() {
|
|
361
|
+
return this.model.wakeOnGlobalReminder;
|
|
362
|
+
}
|
|
363
|
+
list(callerSessionId) {
|
|
364
|
+
return this.model.list(callerSessionId);
|
|
365
|
+
}
|
|
366
|
+
getBoard(sessionId, scope, defaultColumns) {
|
|
367
|
+
return this.model.getBoard(sessionId, scope, defaultColumns);
|
|
368
|
+
}
|
|
369
|
+
getCard(sessionId, scope, cardId) {
|
|
370
|
+
return this.model.getCard(sessionId, scope, cardId);
|
|
371
|
+
}
|
|
372
|
+
mutate(sessionId, scope, op, revision) {
|
|
373
|
+
return this.model.mutate(sessionId, scope, op, revision);
|
|
374
|
+
}
|
|
375
|
+
addLog(sessionId, scope, cardId, text, attachments, revision) {
|
|
376
|
+
return this.model.addLog(sessionId, scope, cardId, text, attachments, revision);
|
|
377
|
+
}
|
|
378
|
+
getAttachment(sessionId, scope, cardId, attachmentId) {
|
|
379
|
+
return this.model.getAttachment(sessionId, scope, cardId, attachmentId);
|
|
380
|
+
}
|
|
381
|
+
openAttachment(sessionId, scope, cardId, attachmentId) {
|
|
382
|
+
return this.model.openAttachment(sessionId, scope, cardId, attachmentId);
|
|
383
|
+
}
|
|
384
|
+
fireDue() {
|
|
385
|
+
return this.model.fireDue();
|
|
386
|
+
}
|
|
387
|
+
};
|
|
388
|
+
})();
|
|
389
|
+
export { BoardService };
|
|
390
|
+
function prepareAttachments(uploads, createId) {
|
|
391
|
+
if (uploads.length > 10)
|
|
392
|
+
throw new BoardOpError('a log supports at most 10 attachments');
|
|
393
|
+
return uploads.map((upload) => {
|
|
394
|
+
const name = upload.name.trim();
|
|
395
|
+
if (name.length === 0 || name.length > 255)
|
|
396
|
+
throw new BoardOpError('invalid attachment name');
|
|
397
|
+
if (typeof upload.type !== 'string' || typeof upload.data !== 'string')
|
|
398
|
+
throw new BoardOpError('invalid attachment');
|
|
399
|
+
const data = Buffer.from(upload.data, 'base64');
|
|
400
|
+
if (data.length > 50 * 1024 * 1024)
|
|
401
|
+
throw new BoardOpError('each attachment must be 50 MB or smaller');
|
|
402
|
+
if (upload.preview !== undefined && (!/^data:image\/(?:jpeg|png|webp);base64,/.test(upload.preview) || Buffer.byteLength(upload.preview, 'utf8') > 350 * 1024))
|
|
403
|
+
throw new BoardOpError('invalid attachment preview');
|
|
404
|
+
return { meta: { id: createId('attachment'), name, type: upload.type, size: data.length, ...(upload.preview === undefined ? {} : { preview: upload.preview }) }, data };
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
async function openWithSystem(path) {
|
|
408
|
+
const command = process.platform === 'win32' ? 'explorer.exe' : process.platform === 'darwin' ? 'open' : 'xdg-open';
|
|
409
|
+
await new Promise((resolve, reject) => {
|
|
410
|
+
const child = spawn(command, [path], { detached: true, stdio: 'ignore' });
|
|
411
|
+
child.once('error', reject);
|
|
412
|
+
child.once('spawn', () => { child.unref(); resolve(); });
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
export function createBoardService(ctx, store, options = {}) {
|
|
416
|
+
return new BoardService(ctx, store, options);
|
|
417
|
+
}
|
package/lib/store.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Board, BoardScope } from './domain.js';
|
|
2
|
+
export declare function resolveDshHome(home?: string | undefined): string;
|
|
3
|
+
export declare function resolveBoardRoot(home?: string | undefined): string;
|
|
4
|
+
export declare class BoardStore {
|
|
5
|
+
readonly root: string;
|
|
6
|
+
private readonly chains;
|
|
7
|
+
private initialGlobalPromise;
|
|
8
|
+
constructor(root: string);
|
|
9
|
+
pathFor(scope: BoardScope, sessionId?: string): string;
|
|
10
|
+
read(scope: BoardScope, sessionId?: string, defaultColumns?: string[]): Promise<Board>;
|
|
11
|
+
writeAttachment(id: string, data: Buffer): Promise<void>;
|
|
12
|
+
readAttachment(id: string): Promise<Buffer>;
|
|
13
|
+
removeAttachment(id: string, name?: string): Promise<void>;
|
|
14
|
+
materializeAttachment(id: string, name: string): Promise<string>;
|
|
15
|
+
private attachmentPath;
|
|
16
|
+
private openAttachmentPath;
|
|
17
|
+
exists(scope: BoardScope, sessionId?: string): Promise<boolean>;
|
|
18
|
+
private initializeGlobal;
|
|
19
|
+
write(board: Board): Promise<void>;
|
|
20
|
+
readAll(defaultColumns?: string[]): Promise<Board[]>;
|
|
21
|
+
private enqueue;
|
|
22
|
+
}
|
package/lib/store.js
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { access, copyFile, mkdir, open, readFile, readdir, rename, rm } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, extname, join } from 'node:path';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { assertSessionId, defaultGlobalBoard, emptyBoard, parseBoard } from './domain.js';
|
|
6
|
+
export function resolveDshHome(home = process.env.DSH_HOME) {
|
|
7
|
+
if (home !== undefined && home.length > 0)
|
|
8
|
+
return home;
|
|
9
|
+
return join(homedir(), '.dsh');
|
|
10
|
+
}
|
|
11
|
+
export function resolveBoardRoot(home = process.env.DSH_HOME) {
|
|
12
|
+
return join(resolveDshHome(home), 'storages', 'board');
|
|
13
|
+
}
|
|
14
|
+
export class BoardStore {
|
|
15
|
+
root;
|
|
16
|
+
chains = new Map();
|
|
17
|
+
initialGlobalPromise = null;
|
|
18
|
+
constructor(root) {
|
|
19
|
+
this.root = root;
|
|
20
|
+
}
|
|
21
|
+
pathFor(scope, sessionId) {
|
|
22
|
+
if (scope === 'global')
|
|
23
|
+
return join(this.root, 'global.json');
|
|
24
|
+
if (sessionId === undefined)
|
|
25
|
+
throw new Error('session board path requires a session id');
|
|
26
|
+
assertSessionId(sessionId);
|
|
27
|
+
return join(this.root, 'sessions', `${sessionId}.json`);
|
|
28
|
+
}
|
|
29
|
+
async read(scope, sessionId, defaultColumns) {
|
|
30
|
+
const path = this.pathFor(scope, sessionId);
|
|
31
|
+
let text;
|
|
32
|
+
try {
|
|
33
|
+
text = await readFile(path, 'utf8');
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
if (error.code === 'ENOENT') {
|
|
37
|
+
if (scope === 'global')
|
|
38
|
+
return this.initializeGlobal(defaultColumns);
|
|
39
|
+
return emptyBoard(scope, sessionId);
|
|
40
|
+
}
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
return parseBoard(JSON.parse(text));
|
|
44
|
+
}
|
|
45
|
+
async writeAttachment(id, data) {
|
|
46
|
+
const path = this.attachmentPath(id);
|
|
47
|
+
await this.enqueue(path, async () => {
|
|
48
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
49
|
+
await writeAtomic(path, data);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
async readAttachment(id) {
|
|
53
|
+
return readFile(this.attachmentPath(id));
|
|
54
|
+
}
|
|
55
|
+
async removeAttachment(id, name) {
|
|
56
|
+
await rm(this.attachmentPath(id), { force: true });
|
|
57
|
+
if (name !== undefined)
|
|
58
|
+
await rm(this.openAttachmentPath(id, name), { force: true });
|
|
59
|
+
}
|
|
60
|
+
async materializeAttachment(id, name) {
|
|
61
|
+
const path = this.openAttachmentPath(id, name);
|
|
62
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
63
|
+
await copyFile(this.attachmentPath(id), path);
|
|
64
|
+
return path;
|
|
65
|
+
}
|
|
66
|
+
attachmentPath(id) {
|
|
67
|
+
if (!/^attachment_[A-Za-z0-9_-]+$/.test(id))
|
|
68
|
+
throw new Error('invalid attachment id');
|
|
69
|
+
return join(this.root, 'attachments', id);
|
|
70
|
+
}
|
|
71
|
+
openAttachmentPath(id, name) {
|
|
72
|
+
this.attachmentPath(id);
|
|
73
|
+
const extension = /^\.[A-Za-z0-9]{1,12}$/.test(extname(name)) ? extname(name) : '';
|
|
74
|
+
return join(this.root, 'open', `${id}${extension}`);
|
|
75
|
+
}
|
|
76
|
+
async exists(scope, sessionId) {
|
|
77
|
+
try {
|
|
78
|
+
await access(this.pathFor(scope, sessionId));
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
initializeGlobal(defaultColumns) {
|
|
86
|
+
if (this.initialGlobalPromise === null) {
|
|
87
|
+
this.initialGlobalPromise = (async () => {
|
|
88
|
+
const board = defaultGlobalBoard(new Date(), undefined, defaultColumns);
|
|
89
|
+
await this.write(board);
|
|
90
|
+
return board;
|
|
91
|
+
})().finally(() => { this.initialGlobalPromise = null; });
|
|
92
|
+
}
|
|
93
|
+
return this.initialGlobalPromise;
|
|
94
|
+
}
|
|
95
|
+
async write(board) {
|
|
96
|
+
const path = this.pathFor(board.scope, board.sessionId);
|
|
97
|
+
await this.enqueue(path, async () => {
|
|
98
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
99
|
+
await chmodBestEffort(dirname(path), 0o700);
|
|
100
|
+
if (board.scope === 'session') {
|
|
101
|
+
await chmodBestEffort(this.root, 0o700);
|
|
102
|
+
}
|
|
103
|
+
await writeAtomic(path, `${JSON.stringify(board, null, 2)}\n`);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
async readAll(defaultColumns) {
|
|
107
|
+
const boards = [await this.read('global', undefined, defaultColumns)];
|
|
108
|
+
let entries;
|
|
109
|
+
try {
|
|
110
|
+
entries = await readdir(join(this.root, 'sessions'));
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
if (error.code === 'ENOENT')
|
|
114
|
+
return boards;
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
const sessionIds = entries.filter((name) => name.endsWith('.json')).map((name) => name.slice(0, -5));
|
|
118
|
+
for (const sessionId of sessionIds) {
|
|
119
|
+
try {
|
|
120
|
+
assertSessionId(sessionId);
|
|
121
|
+
boards.push(await this.read('session', sessionId));
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
// Ignore unrelated or invalid files in the private storage directory.
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return boards;
|
|
128
|
+
}
|
|
129
|
+
enqueue(key, work) {
|
|
130
|
+
const previous = this.chains.get(key) ?? Promise.resolve();
|
|
131
|
+
const next = previous.then(work, work);
|
|
132
|
+
this.chains.set(key, next.catch(() => undefined));
|
|
133
|
+
return next;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
async function writeAtomic(path, data) {
|
|
137
|
+
const tmp = join(dirname(path), `.${randomUUID()}.tmp`);
|
|
138
|
+
try {
|
|
139
|
+
const handle = await open(tmp, 'wx', 0o600);
|
|
140
|
+
try {
|
|
141
|
+
if (typeof data === 'string')
|
|
142
|
+
await handle.writeFile(data, 'utf8');
|
|
143
|
+
else
|
|
144
|
+
await handle.writeFile(data);
|
|
145
|
+
await handle.sync();
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
await handle.close();
|
|
149
|
+
}
|
|
150
|
+
await rename(tmp, path);
|
|
151
|
+
await chmodBestEffort(path, 0o600);
|
|
152
|
+
await fsyncDirectory(dirname(path));
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
await rm(tmp, { force: true });
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
async function fsyncDirectory(path) {
|
|
160
|
+
if (process.platform === 'win32')
|
|
161
|
+
return;
|
|
162
|
+
const handle = await open(path, 'r');
|
|
163
|
+
try {
|
|
164
|
+
await handle.sync();
|
|
165
|
+
}
|
|
166
|
+
finally {
|
|
167
|
+
await handle.close();
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
async function chmodBestEffort(path, mode) {
|
|
171
|
+
try {
|
|
172
|
+
const { chmod } = await import('node:fs/promises');
|
|
173
|
+
await chmod(path, mode);
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
// Windows and some filesystems ignore POSIX modes.
|
|
177
|
+
}
|
|
178
|
+
}
|
package/lib/tools.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
2
|
+
import type { ToolRunContext } from '@deepseek-ai/dsh-tools';
|
|
3
|
+
import type { Board } from './domain.js';
|
|
4
|
+
export declare function registerBoardTools(ctx: Context): void;
|
|
5
|
+
type SearchStatus = 'all' | 'active' | 'archived';
|
|
6
|
+
type MatchField = 'title' | 'body' | 'log' | 'attachment' | 'column';
|
|
7
|
+
export declare function searchBoard(board: Board, rawQuery: string, status?: SearchStatus, limit?: number): {
|
|
8
|
+
query: string;
|
|
9
|
+
total: number;
|
|
10
|
+
results: {
|
|
11
|
+
status: "active" | "archived";
|
|
12
|
+
archivedAt: string | null;
|
|
13
|
+
matchedIn: MatchField[];
|
|
14
|
+
preview: string;
|
|
15
|
+
sessionId?: string | undefined;
|
|
16
|
+
cardId: string;
|
|
17
|
+
title: string;
|
|
18
|
+
columnId: string;
|
|
19
|
+
columnTitle: string;
|
|
20
|
+
}[];
|
|
21
|
+
};
|
|
22
|
+
export declare function requireCallerSession(exec: ToolRunContext): string;
|
|
23
|
+
export {};
|