@bassfish/cli 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/LICENSE +21 -0
- package/README.md +153 -0
- package/dist/api.d.ts +365 -0
- package/dist/api.js +113 -0
- package/dist/api.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +217 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +20 -0
- package/dist/config.js +68 -0
- package/dist/config.js.map +1 -0
- package/dist/daemon.d.ts +4 -0
- package/dist/daemon.js +220 -0
- package/dist/daemon.js.map +1 -0
- package/dist/domain.d.ts +393 -0
- package/dist/domain.js +75 -0
- package/dist/domain.js.map +1 -0
- package/dist/export.d.ts +7 -0
- package/dist/export.js +37 -0
- package/dist/export.js.map +1 -0
- package/dist/ipc.d.ts +14 -0
- package/dist/ipc.js +125 -0
- package/dist/ipc.js.map +1 -0
- package/dist/lock.d.ts +2 -0
- package/dist/lock.js +18 -0
- package/dist/lock.js.map +1 -0
- package/dist/mcp.d.ts +16 -0
- package/dist/mcp.js +200 -0
- package/dist/mcp.js.map +1 -0
- package/dist/note-cli.d.ts +28 -0
- package/dist/note-cli.js +204 -0
- package/dist/note-cli.js.map +1 -0
- package/dist/note.d.ts +12 -0
- package/dist/note.js +181 -0
- package/dist/note.js.map +1 -0
- package/dist/repository.d.ts +1 -0
- package/dist/repository.js +21 -0
- package/dist/repository.js.map +1 -0
- package/dist/runtime.d.ts +12 -0
- package/dist/runtime.js +32 -0
- package/dist/runtime.js.map +1 -0
- package/dist/service.d.ts +106 -0
- package/dist/service.js +1185 -0
- package/dist/service.js.map +1 -0
- package/dist/setup.d.ts +21 -0
- package/dist/setup.js +108 -0
- package/dist/setup.js.map +1 -0
- package/dist/sql-worker.d.ts +6 -0
- package/dist/sql-worker.js +76 -0
- package/dist/sql-worker.js.map +1 -0
- package/dist/storage/control.d.ts +10 -0
- package/dist/storage/control.js +160 -0
- package/dist/storage/control.js.map +1 -0
- package/dist/storage/dolt.d.ts +32 -0
- package/dist/storage/dolt.js +494 -0
- package/dist/storage/dolt.js.map +1 -0
- package/dist/storage/search.d.ts +22 -0
- package/dist/storage/search.js +75 -0
- package/dist/storage/search.js.map +1 -0
- package/dist/supervisor.d.ts +11 -0
- package/dist/supervisor.js +97 -0
- package/dist/supervisor.js.map +1 -0
- package/dist/tasks.d.ts +52 -0
- package/dist/tasks.js +33 -0
- package/dist/tasks.js.map +1 -0
- package/dist/thread-cli.d.ts +2 -0
- package/dist/thread-cli.js +64 -0
- package/dist/thread-cli.js.map +1 -0
- package/package.json +64 -0
package/dist/service.js
ADDED
|
@@ -0,0 +1,1185 @@
|
|
|
1
|
+
import { createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
3
|
+
import { BassfishError, activeStates, increment, prepareMutation, requireThat, reservedStates } from './domain.js';
|
|
4
|
+
import { KeyedMutex } from './runtime.js';
|
|
5
|
+
import { schemas, nameSchema } from './api.js';
|
|
6
|
+
import { normalizeLabels, normalizeLinks, outline, prepareNote } from './note.js';
|
|
7
|
+
import { NoteSearchIndex } from './storage/search.js';
|
|
8
|
+
import { exportProject as writeProjectExport } from './export.js';
|
|
9
|
+
import { RE2 } from 're2-wasm';
|
|
10
|
+
export const defaultLimits = { offerMs: 30_000, leaseMs: 30_000, reconnectMs: 30_000, instanceMs: 20_000, queueMs: 3_600_000, retentionMs: 3_600_000, waitMs: 20_000 };
|
|
11
|
+
const values = Object.values;
|
|
12
|
+
const uid = () => randomUUID();
|
|
13
|
+
const iso = (ms) => new Date(ms).toISOString();
|
|
14
|
+
const pendingFor = (state, instanceId) => values(state.requests).filter(r => r.instanceId === instanceId && activeStates.includes(r.state));
|
|
15
|
+
function toMutation(input) {
|
|
16
|
+
return input;
|
|
17
|
+
}
|
|
18
|
+
const threadMutationKinds = ['appendMessage', 'renameThread', 'setThreadDescription', 'archiveThread', 'activateThread', 'deleteThread', 'retractMessage', 'reinstateMessage'];
|
|
19
|
+
const compare = (a, b) => a < b ? -1 : a > b ? 1 : 0;
|
|
20
|
+
const sortThreads = (threads) => [...threads].sort((a, b) => compare(a.createdAt, b.createdAt) || compare(a.id, b.id));
|
|
21
|
+
const encodeCursor = (pair) => Buffer.from(JSON.stringify(pair)).toString('base64url');
|
|
22
|
+
function decodeCursor(cursor) {
|
|
23
|
+
if (!cursor)
|
|
24
|
+
return undefined;
|
|
25
|
+
let value;
|
|
26
|
+
try {
|
|
27
|
+
value = JSON.parse(Buffer.from(String(cursor), 'base64url').toString('utf8'));
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
throw new BassfishError('INVALID_CURSOR', 'The cursor is invalid.');
|
|
31
|
+
}
|
|
32
|
+
requireThat(Array.isArray(value) && value.length === 2 && value.every(item => typeof item === 'string'), 'INVALID_CURSOR', 'The cursor is invalid.');
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
/** Application service: all coordination is persisted by ControlStore; all content uses ContentStore. */
|
|
36
|
+
export class Bassfish {
|
|
37
|
+
control;
|
|
38
|
+
content;
|
|
39
|
+
clock;
|
|
40
|
+
searchIndex;
|
|
41
|
+
dataDir;
|
|
42
|
+
epoch = uid();
|
|
43
|
+
limits;
|
|
44
|
+
writers = new KeyedMutex();
|
|
45
|
+
restoreSecret = randomBytes(32);
|
|
46
|
+
constructor(control, content, clock, limits = {}, searchIndex, dataDir) {
|
|
47
|
+
this.control = control;
|
|
48
|
+
this.content = content;
|
|
49
|
+
this.clock = clock;
|
|
50
|
+
this.searchIndex = searchIndex;
|
|
51
|
+
this.dataDir = dataDir;
|
|
52
|
+
this.limits = { ...defaultLimits, ...limits };
|
|
53
|
+
}
|
|
54
|
+
async initialize() {
|
|
55
|
+
const now = this.clock.now();
|
|
56
|
+
this.control.update(state => {
|
|
57
|
+
for (const instance of values(state.instances))
|
|
58
|
+
instance.active = false;
|
|
59
|
+
for (const request of values(state.requests)) {
|
|
60
|
+
if (request.state === 'QUEUED' || request.state === 'READY')
|
|
61
|
+
request.reconnectUntil = Math.min(request.queueUntil, now + this.limits.reconnectMs);
|
|
62
|
+
if (request.state === 'READY')
|
|
63
|
+
request.state = 'QUEUED';
|
|
64
|
+
if (request.state === 'OFFERED' || request.state === 'HELD')
|
|
65
|
+
this.finish(request, 'EXPIRED', state);
|
|
66
|
+
}
|
|
67
|
+
for (const project of values(state.projects))
|
|
68
|
+
project.recovering = true;
|
|
69
|
+
});
|
|
70
|
+
for (const project of this.control.view(s => values(s.projects))) {
|
|
71
|
+
try {
|
|
72
|
+
await this.content.ensureProject(project.id);
|
|
73
|
+
await this.recover(project.id);
|
|
74
|
+
}
|
|
75
|
+
catch { /* Preserve PROJECT_RECOVERING; metadata diagnostics must remain available. */ }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
async open(commonDir, preferredName) {
|
|
79
|
+
if (preferredName)
|
|
80
|
+
nameSchema.parse(preferredName);
|
|
81
|
+
const project = this.control.update(state => {
|
|
82
|
+
let project = values(state.projects).find(p => p.commonDir === commonDir);
|
|
83
|
+
if (!project) {
|
|
84
|
+
project = { id: `p_${uid().replaceAll('-', '')}`, commonDir, recovering: true };
|
|
85
|
+
state.projects[project.id] = project;
|
|
86
|
+
}
|
|
87
|
+
return project;
|
|
88
|
+
});
|
|
89
|
+
await this.writers.run(project.id, async () => {
|
|
90
|
+
await this.content.ensureProject(project.id);
|
|
91
|
+
await this.recover(project.id);
|
|
92
|
+
});
|
|
93
|
+
this.sweep();
|
|
94
|
+
const handle = uid();
|
|
95
|
+
this.control.update(state => {
|
|
96
|
+
const name = preferredName ?? `Agent_${uid().slice(0, 12).replaceAll('-', '')}`;
|
|
97
|
+
let identity = values(state.identities).find(i => i.projectId === project.id && i.name.toLowerCase() === name.toLowerCase());
|
|
98
|
+
if (!identity) {
|
|
99
|
+
identity = { id: uid(), projectId: project.id, name };
|
|
100
|
+
state.identities[identity.id] = identity;
|
|
101
|
+
}
|
|
102
|
+
requireThat(!values(state.instances).some(i => i.identityId === identity.id && i.active), 'NAME_IN_USE', 'This identity has a live adapter instance.');
|
|
103
|
+
const instance = { id: uid(), projectId: project.id, identityId: identity.id, handle, epoch: this.epoch, active: true, lastSeen: this.clock.now() };
|
|
104
|
+
state.instances[instance.id] = instance;
|
|
105
|
+
this.rebind(state, instance);
|
|
106
|
+
this.promote(state);
|
|
107
|
+
});
|
|
108
|
+
return { agentHandle: handle, session: this.info(handle) };
|
|
109
|
+
}
|
|
110
|
+
actor(state, handle) {
|
|
111
|
+
const instance = values(state.instances).find(i => i.handle === handle && i.active && i.epoch === this.epoch);
|
|
112
|
+
requireThat(instance, 'SESSION_EXPIRED', 'Open a new Bassfish instance; this handle is no longer current.');
|
|
113
|
+
const identity = state.identities[instance.identityId];
|
|
114
|
+
return { projectId: instance.projectId, instanceId: instance.id, identityId: identity.id, name: identity.name };
|
|
115
|
+
}
|
|
116
|
+
ready(state, projectId) {
|
|
117
|
+
requireThat(state.projects[projectId] && !state.projects[projectId].recovering, 'PROJECT_RECOVERING', 'The project has an unresolved storage operation.');
|
|
118
|
+
}
|
|
119
|
+
creationAllowed(state, projectId) {
|
|
120
|
+
this.ready(state, projectId);
|
|
121
|
+
requireThat(!values(state.requests).some(request => request.projectId === projectId && request.resourceType === 'project' && activeStates.includes(request.state)), 'PROJECT_FLOOR_PENDING', 'A project floor is queued or active; create the resource after it finishes.');
|
|
122
|
+
}
|
|
123
|
+
finish(request, state, control) {
|
|
124
|
+
request.state = state;
|
|
125
|
+
request.finishedAt = this.clock.now();
|
|
126
|
+
request.updatedAt = request.finishedAt;
|
|
127
|
+
const task = control && values(control.tasks).find(value => value.requestId === request.id && value.status === 'working');
|
|
128
|
+
if (task) {
|
|
129
|
+
task.updatedAt = request.finishedAt;
|
|
130
|
+
task.discardAt = request.finishedAt + this.limits.retentionMs;
|
|
131
|
+
if (state === 'FAILED') {
|
|
132
|
+
task.status = 'failed';
|
|
133
|
+
task.statusMessage = 'The floor request failed.';
|
|
134
|
+
task.error = { code: -32603, message: 'The floor request failed.' };
|
|
135
|
+
}
|
|
136
|
+
else if (state !== 'COMMITTED') {
|
|
137
|
+
task.status = 'cancelled';
|
|
138
|
+
task.statusMessage = state === 'EXPIRED' ? 'The floor request expired.' : 'The floor request was cancelled.';
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
disconnectIn(state, instance, clean) {
|
|
143
|
+
instance.active = false;
|
|
144
|
+
for (const request of values(state.requests).filter(r => r.instanceId === instance.id)) {
|
|
145
|
+
if (request.state === 'QUEUED') {
|
|
146
|
+
if (clean)
|
|
147
|
+
this.finish(request, 'CANCELLED', state);
|
|
148
|
+
else
|
|
149
|
+
request.reconnectUntil = Math.min(request.queueUntil, this.clock.now() + this.limits.reconnectMs);
|
|
150
|
+
}
|
|
151
|
+
if (request.state === 'READY') {
|
|
152
|
+
if (clean)
|
|
153
|
+
this.finish(request, 'CANCELLED', state);
|
|
154
|
+
else {
|
|
155
|
+
request.state = 'QUEUED';
|
|
156
|
+
request.updatedAt = this.clock.now();
|
|
157
|
+
request.reconnectUntil = Math.min(request.queueUntil, this.clock.now() + this.limits.reconnectMs);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (request.state === 'HELD' || request.state === 'OFFERED')
|
|
161
|
+
this.finish(request, clean ? 'RELEASED' : 'EXPIRED', state);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
disconnect(handle, clean = true) {
|
|
165
|
+
this.control.update(state => {
|
|
166
|
+
const instance = values(state.instances).find(i => i.handle === handle && i.epoch === this.epoch);
|
|
167
|
+
if (instance)
|
|
168
|
+
this.disconnectIn(state, instance, clean);
|
|
169
|
+
this.promote(state);
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
heartbeat(handle) {
|
|
173
|
+
this.sweep();
|
|
174
|
+
this.control.update(state => { const actor = this.actor(state, handle); state.instances[actor.instanceId].lastSeen = this.clock.now(); });
|
|
175
|
+
}
|
|
176
|
+
sweep() {
|
|
177
|
+
const now = this.clock.now();
|
|
178
|
+
const wall = this.clock.wallNow();
|
|
179
|
+
const clockChanged = this.clock.discontinuity();
|
|
180
|
+
this.control.update(state => {
|
|
181
|
+
const discontinuity = clockChanged || (state.wallClockHighWaterMs > 0 && wall + 2_000 < state.wallClockHighWaterMs);
|
|
182
|
+
state.wallClockHighWaterMs = Math.max(state.wallClockHighWaterMs, wall);
|
|
183
|
+
for (const instance of values(state.instances))
|
|
184
|
+
if (instance.active && now - instance.lastSeen >= this.limits.instanceMs)
|
|
185
|
+
this.disconnectIn(state, instance, false);
|
|
186
|
+
for (const request of values(state.requests)) {
|
|
187
|
+
if ((request.state === 'QUEUED' || request.state === 'READY') && (now >= request.queueUntil || (request.reconnectUntil !== undefined && now >= request.reconnectUntil)))
|
|
188
|
+
this.finish(request, 'EXPIRED', state);
|
|
189
|
+
if (request.state === 'READY' && discontinuity)
|
|
190
|
+
this.finish(request, 'EXPIRED', state);
|
|
191
|
+
if (request.state === 'OFFERED' && (discontinuity || now >= request.claimBy))
|
|
192
|
+
this.finish(request, 'EXPIRED', state);
|
|
193
|
+
if (request.state === 'HELD' && (discontinuity || now >= request.expiresAt))
|
|
194
|
+
this.finish(request, 'EXPIRED', state);
|
|
195
|
+
if (request.finishedAt !== undefined && now - request.finishedAt >= this.limits.retentionMs)
|
|
196
|
+
delete state.requests[request.id];
|
|
197
|
+
}
|
|
198
|
+
for (const task of values(state.tasks))
|
|
199
|
+
if (now >= task.discardAt)
|
|
200
|
+
delete state.tasks[task.id];
|
|
201
|
+
this.promote(state);
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
promote(state) {
|
|
205
|
+
for (const project of values(state.projects)) {
|
|
206
|
+
if (project.recovering)
|
|
207
|
+
continue;
|
|
208
|
+
const all = values(state.requests).filter(r => r.projectId === project.id);
|
|
209
|
+
const projects = all.filter(r => r.resourceType === 'project');
|
|
210
|
+
if (projects.some(r => reservedStates.includes(r.state)))
|
|
211
|
+
continue;
|
|
212
|
+
const barrier = projects.filter(r => r.state === 'QUEUED').sort((a, b) => BigInt(a.sequence) < BigInt(b.sequence) ? -1 : 1)[0];
|
|
213
|
+
if (barrier) {
|
|
214
|
+
for (const offered of all.filter(r => r.resourceType !== 'project' && r.state === 'OFFERED')) {
|
|
215
|
+
offered.state = 'QUEUED';
|
|
216
|
+
delete offered.offerId;
|
|
217
|
+
delete offered.claimBy;
|
|
218
|
+
}
|
|
219
|
+
if (all.some(r => r.resourceType !== 'project' && (r.state === 'HELD' || r.state === 'COMMITTING')))
|
|
220
|
+
continue;
|
|
221
|
+
if (state.instances[barrier.instanceId]?.active)
|
|
222
|
+
this.makeAvailable(state, barrier);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
for (const resource of values(state.resources).filter(r => r.projectId === project.id && r.type !== 'project' && r.present)) {
|
|
226
|
+
const requests = all.filter(r => r.resourceId === resource.id);
|
|
227
|
+
if (requests.some(r => reservedStates.includes(r.state)))
|
|
228
|
+
continue;
|
|
229
|
+
const next = requests.filter(r => r.state === 'QUEUED').sort((a, b) => BigInt(a.sequence) < BigInt(b.sequence) ? -1 : 1)[0];
|
|
230
|
+
if (!next || !state.instances[next.instanceId]?.active)
|
|
231
|
+
continue;
|
|
232
|
+
this.makeAvailable(state, next);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
makeAvailable(state, request) {
|
|
237
|
+
request.updatedAt = this.clock.now();
|
|
238
|
+
if (request.deliveryMode === 'task') {
|
|
239
|
+
request.state = 'READY';
|
|
240
|
+
const task = values(state.tasks).find(value => value.requestId === request.id);
|
|
241
|
+
if (task) {
|
|
242
|
+
task.updatedAt = request.updatedAt;
|
|
243
|
+
task.statusMessage = 'Floor ready; poll the task to receive a 30-second offer.';
|
|
244
|
+
}
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
request.state = 'OFFERED';
|
|
248
|
+
request.offerId = uid();
|
|
249
|
+
request.claimBy = request.updatedAt + this.limits.offerMs;
|
|
250
|
+
}
|
|
251
|
+
materializeOffer(state, request) {
|
|
252
|
+
if (request.state !== 'READY')
|
|
253
|
+
return;
|
|
254
|
+
request.state = 'OFFERED';
|
|
255
|
+
request.offerId = uid();
|
|
256
|
+
request.claimBy = this.clock.now() + this.limits.offerMs;
|
|
257
|
+
request.updatedAt = this.clock.now();
|
|
258
|
+
const task = values(state.tasks).find(value => value.requestId === request.id && value.status === 'working');
|
|
259
|
+
if (task) {
|
|
260
|
+
task.status = 'completed';
|
|
261
|
+
task.statusMessage = 'The floor offer is ready to claim.';
|
|
262
|
+
task.updatedAt = request.updatedAt;
|
|
263
|
+
task.discardAt = task.updatedAt + this.limits.retentionMs;
|
|
264
|
+
task.result = this.statusIn(state, request);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
rebind(state, instance) {
|
|
268
|
+
for (const request of values(state.requests)) {
|
|
269
|
+
if (request.identityId === instance.identityId && (request.state === 'QUEUED' || request.state === 'READY') && request.reconnectUntil !== undefined && request.reconnectUntil > this.clock.now()) {
|
|
270
|
+
request.instanceId = instance.id;
|
|
271
|
+
delete request.reconnectUntil;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
info(handle) {
|
|
276
|
+
this.sweep();
|
|
277
|
+
return this.control.view(state => {
|
|
278
|
+
const actor = this.actor(state, handle);
|
|
279
|
+
return { projectId: actor.projectId, identityId: actor.identityId, adapterInstanceId: actor.instanceId, name: actor.name,
|
|
280
|
+
recovering: state.projects[actor.projectId].recovering, pendingRequests: pendingFor(state, actor.instanceId).map(r => this.statusIn(state, r)),
|
|
281
|
+
pendingCommits: values(state.pending).filter(p => p.actor.identityId === actor.identityId).map(p => ({ operationId: p.id, resourceId: p.resourceId })) };
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
requestName(handle, name) {
|
|
285
|
+
nameSchema.parse(name);
|
|
286
|
+
this.sweep();
|
|
287
|
+
this.control.update(state => {
|
|
288
|
+
const actor = this.actor(state, handle);
|
|
289
|
+
requireThat(pendingFor(state, actor.instanceId).length === 0 && !values(state.pending).some(p => p.actor.instanceId === actor.instanceId), 'FLOOR_REQUEST_EXISTS', 'Finish current operations before changing identity.');
|
|
290
|
+
const target = values(state.identities).find(i => i.projectId === actor.projectId && i.name.toLowerCase() === name.toLowerCase());
|
|
291
|
+
if (target && target.id !== actor.identityId) {
|
|
292
|
+
requireThat(!values(state.instances).some(i => i.identityId === target.id && i.active), 'NAME_IN_USE', 'This identity has a live adapter instance.');
|
|
293
|
+
state.instances[actor.instanceId].identityId = target.id;
|
|
294
|
+
this.rebind(state, state.instances[actor.instanceId]);
|
|
295
|
+
}
|
|
296
|
+
else
|
|
297
|
+
state.identities[actor.identityId].name = name;
|
|
298
|
+
this.promote(state);
|
|
299
|
+
});
|
|
300
|
+
return this.info(handle);
|
|
301
|
+
}
|
|
302
|
+
statusIn(state, request) {
|
|
303
|
+
const position = values(state.requests).filter(r => r.resourceId === request.resourceId && r.state === 'QUEUED' && BigInt(r.sequence) <= BigInt(request.sequence)).length;
|
|
304
|
+
const target = request.resourceType === 'project'
|
|
305
|
+
? { type: 'project', purpose: request.purpose }
|
|
306
|
+
: { type: request.resourceType, id: request.resourceId };
|
|
307
|
+
return { state: request.state.toLowerCase(), requestId: request.id, target,
|
|
308
|
+
...(request.state === 'QUEUED' ? { position } : {}),
|
|
309
|
+
...(request.state === 'OFFERED' ? { offerId: request.offerId, claimBy: iso(request.claimBy) } : {}),
|
|
310
|
+
...(request.expiresAt !== undefined ? { expiresAt: iso(request.expiresAt) } : {}),
|
|
311
|
+
...(request.result ? { result: request.result } : {}) };
|
|
312
|
+
}
|
|
313
|
+
ownRequest(state, handle, ticketId) {
|
|
314
|
+
const actor = this.actor(state, handle);
|
|
315
|
+
const request = state.requests[ticketId];
|
|
316
|
+
requireThat(request && request.projectId === actor.projectId && request.identityId === actor.identityId, 'NOT_FLOOR_OWNER', 'This ticket does not belong to the calling identity.');
|
|
317
|
+
return request;
|
|
318
|
+
}
|
|
319
|
+
ownTask(state, handle, taskId) {
|
|
320
|
+
const actor = this.actor(state, handle);
|
|
321
|
+
const task = state.tasks[taskId];
|
|
322
|
+
requireThat(task && task.projectId === actor.projectId && task.identityId === actor.identityId, 'TASK_NOT_FOUND', 'Task not found for this identity.');
|
|
323
|
+
return task;
|
|
324
|
+
}
|
|
325
|
+
taskView(task) {
|
|
326
|
+
return { taskId: task.id, status: task.status, ...(task.statusMessage ? { statusMessage: task.statusMessage } : {}),
|
|
327
|
+
createdAt: iso(task.createdAt), lastUpdatedAt: iso(task.updatedAt), ttlMs: Math.max(0, task.discardAt - task.createdAt), pollIntervalMs: 1_000,
|
|
328
|
+
...(task.status === 'completed' && task.result ? { result: task.result } : {}), ...(task.status === 'failed' && task.error ? { error: task.error } : {}) };
|
|
329
|
+
}
|
|
330
|
+
getTask(handle, taskId, materialize = true) {
|
|
331
|
+
this.sweep();
|
|
332
|
+
return this.control.update(state => {
|
|
333
|
+
const task = this.ownTask(state, handle, taskId);
|
|
334
|
+
const request = state.requests[task.requestId];
|
|
335
|
+
if (materialize && request)
|
|
336
|
+
this.materializeOffer(state, request);
|
|
337
|
+
return this.taskView(task);
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
cancelTask(handle, taskId) {
|
|
341
|
+
this.sweep();
|
|
342
|
+
return this.control.update(state => {
|
|
343
|
+
const task = this.ownTask(state, handle, taskId);
|
|
344
|
+
const request = state.requests[task.requestId];
|
|
345
|
+
if (request && task.status === 'working' && ['QUEUED', 'READY', 'OFFERED'].includes(request.state))
|
|
346
|
+
this.finish(request, 'CANCELLED', state);
|
|
347
|
+
this.promote(state);
|
|
348
|
+
return this.taskView(task);
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
async waitTask(handle, taskId, updatedAfter, timeout, signal) {
|
|
352
|
+
const until = performance.now() + Math.min(timeout, this.limits.waitMs);
|
|
353
|
+
while (true) {
|
|
354
|
+
const task = this.control.view(state => this.taskView(this.ownTask(state, handle, taskId)));
|
|
355
|
+
const updated = Date.parse(String(task.lastUpdatedAt));
|
|
356
|
+
if (updated > updatedAfter || performance.now() >= until)
|
|
357
|
+
return task;
|
|
358
|
+
try {
|
|
359
|
+
await delay(Math.min(100, Math.max(1, until - performance.now())), undefined, { signal });
|
|
360
|
+
}
|
|
361
|
+
catch {
|
|
362
|
+
throw new BassfishError('CANCELLED', 'Task observation was cancelled.');
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
status(handle, ticketId) {
|
|
367
|
+
this.sweep();
|
|
368
|
+
return this.control.update(state => { const request = this.ownRequest(state, handle, ticketId); this.materializeOffer(state, request); return this.statusIn(state, request); });
|
|
369
|
+
}
|
|
370
|
+
held(state, handle, floorId, fence) {
|
|
371
|
+
requireThat(floorId && fence, 'FLOOR_REQUIRED', 'Current floor credentials are required.');
|
|
372
|
+
const actor = this.actor(state, handle);
|
|
373
|
+
const request = values(state.requests).find(r => r.floorId === floorId);
|
|
374
|
+
requireThat(request && request.projectId === actor.projectId && request.identityId === actor.identityId && request.instanceId === actor.instanceId, 'NOT_FLOOR_OWNER', 'The floor is not bound to this adapter instance.');
|
|
375
|
+
requireThat(request.fence === fence && state.resources[request.resourceId]?.fence === fence, 'STALE_FLOOR', 'The fencing token is no longer current.');
|
|
376
|
+
requireThat(request.state !== 'EXPIRED' && this.clock.now() < request.expiresAt, 'FLOOR_EXPIRED', 'The floor deadline has elapsed.');
|
|
377
|
+
requireThat(request.state !== 'COMMITTING', 'COMMIT_IN_PROGRESS', 'This floor has an accepted write in progress.');
|
|
378
|
+
requireThat(request.state === 'HELD', 'STALE_FLOOR', 'The floor has already been released or consumed.');
|
|
379
|
+
this.ready(state, actor.projectId);
|
|
380
|
+
return request;
|
|
381
|
+
}
|
|
382
|
+
async requestResourceFloor(handle, resourceTypeOrId, maybeResourceId, deliveryMode = 'ticket') {
|
|
383
|
+
this.sweep();
|
|
384
|
+
const actor = this.control.view(s => this.actor(s, handle));
|
|
385
|
+
return this.writers.run(actor.projectId, async () => {
|
|
386
|
+
this.control.view(s => this.ready(s, actor.projectId));
|
|
387
|
+
const resourceId = maybeResourceId ?? resourceTypeOrId;
|
|
388
|
+
const requestedType = maybeResourceId === undefined ? undefined : resourceTypeOrId;
|
|
389
|
+
const actualType = await this.content.resourceType(actor.projectId, resourceId);
|
|
390
|
+
requireThat(requestedType === undefined || actualType === requestedType, 'NOT_FOUND', 'Resource type does not match this identifier.');
|
|
391
|
+
const resourceType = actualType;
|
|
392
|
+
const ticketId = this.control.update(state => {
|
|
393
|
+
this.actor(state, handle);
|
|
394
|
+
this.ready(state, actor.projectId);
|
|
395
|
+
requireThat(pendingFor(state, actor.instanceId).length === 0, 'FLOOR_REQUEST_EXISTS', 'This adapter already has a pending floor request.');
|
|
396
|
+
const resource = state.resources[resourceId] ?? { id: resourceId, projectId: actor.projectId, type: resourceType, fence: '0', queueSequence: '0', present: true };
|
|
397
|
+
requireThat(resource.projectId === actor.projectId && resource.present, 'NOT_FOUND', 'Resource not found in this project.');
|
|
398
|
+
state.resources[resourceId] = resource;
|
|
399
|
+
resource.queueSequence = increment(resource.queueSequence);
|
|
400
|
+
const now = this.clock.now();
|
|
401
|
+
const request = { id: uid(), projectId: actor.projectId, resourceId, resourceType, identityId: actor.identityId, instanceId: actor.instanceId,
|
|
402
|
+
sequence: resource.queueSequence, state: 'QUEUED', createdAt: now, updatedAt: now, queueUntil: now + this.limits.queueMs, deliveryMode };
|
|
403
|
+
state.requests[request.id] = request;
|
|
404
|
+
if (deliveryMode === 'task')
|
|
405
|
+
state.tasks[request.id] = { id: request.id, projectId: actor.projectId, identityId: actor.identityId, requestId: request.id,
|
|
406
|
+
status: 'working', statusMessage: 'Waiting for the floor.', createdAt: now, updatedAt: now, discardAt: request.queueUntil };
|
|
407
|
+
this.promote(state);
|
|
408
|
+
if (deliveryMode === 'task' && request.state === 'READY') {
|
|
409
|
+
this.materializeOffer(state, request);
|
|
410
|
+
delete state.tasks[request.id];
|
|
411
|
+
request.deliveryMode = 'ticket';
|
|
412
|
+
}
|
|
413
|
+
return request.id;
|
|
414
|
+
});
|
|
415
|
+
return this.status(handle, ticketId);
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
async requestProjectFloor(handle, purpose, deliveryMode = 'ticket') {
|
|
419
|
+
this.sweep();
|
|
420
|
+
const actor = this.control.view(s => this.actor(s, handle));
|
|
421
|
+
return this.writers.run(actor.projectId, async () => {
|
|
422
|
+
const ticketId = this.control.update(state => {
|
|
423
|
+
this.actor(state, handle);
|
|
424
|
+
this.ready(state, actor.projectId);
|
|
425
|
+
requireThat(pendingFor(state, actor.instanceId).length === 0, 'FLOOR_REQUEST_EXISTS', 'This adapter already has a pending floor request.');
|
|
426
|
+
const resourceId = `project:${actor.projectId}`;
|
|
427
|
+
const resource = state.resources[resourceId] ?? { id: resourceId, projectId: actor.projectId, type: 'project', fence: '0', queueSequence: '0', present: true };
|
|
428
|
+
state.resources[resourceId] = resource;
|
|
429
|
+
resource.queueSequence = increment(resource.queueSequence);
|
|
430
|
+
const now = this.clock.now();
|
|
431
|
+
const request = { id: uid(), projectId: actor.projectId, resourceId, resourceType: 'project', purpose, identityId: actor.identityId,
|
|
432
|
+
instanceId: actor.instanceId, sequence: resource.queueSequence, state: 'QUEUED', createdAt: now, updatedAt: now, queueUntil: now + this.limits.queueMs, deliveryMode };
|
|
433
|
+
state.requests[request.id] = request;
|
|
434
|
+
if (deliveryMode === 'task')
|
|
435
|
+
state.tasks[request.id] = { id: request.id, projectId: actor.projectId, identityId: actor.identityId, requestId: request.id,
|
|
436
|
+
status: 'working', statusMessage: 'Waiting for the project floor.', createdAt: now, updatedAt: now, discardAt: request.queueUntil };
|
|
437
|
+
this.promote(state);
|
|
438
|
+
if (deliveryMode === 'task' && request.state === 'READY') {
|
|
439
|
+
this.materializeOffer(state, request);
|
|
440
|
+
delete state.tasks[request.id];
|
|
441
|
+
request.deliveryMode = 'ticket';
|
|
442
|
+
}
|
|
443
|
+
return request.id;
|
|
444
|
+
});
|
|
445
|
+
return this.status(handle, ticketId);
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
async claimFloor(handle, offerId, limit, noteCursor) {
|
|
449
|
+
this.sweep();
|
|
450
|
+
const actor = this.control.view(s => this.actor(s, handle));
|
|
451
|
+
return this.writers.run(actor.projectId, async () => {
|
|
452
|
+
const offer = () => this.control.view(state => {
|
|
453
|
+
this.actor(state, handle);
|
|
454
|
+
this.ready(state, actor.projectId);
|
|
455
|
+
const request = values(state.requests).find(r => r.offerId === offerId);
|
|
456
|
+
requireThat(request && request.instanceId === actor.instanceId && request.projectId === actor.projectId, 'NOT_FLOOR_OWNER', 'The offer is not bound to this instance.');
|
|
457
|
+
requireThat(request.state !== 'EXPIRED' && this.clock.now() < request.claimBy, 'OFFER_EXPIRED', 'The offer claim window has elapsed.');
|
|
458
|
+
requireThat(request.state === 'OFFERED', 'OFFER_UNAVAILABLE', 'This offer is no longer available.');
|
|
459
|
+
return request;
|
|
460
|
+
});
|
|
461
|
+
const request = offer();
|
|
462
|
+
if (request.resourceType === 'project') {
|
|
463
|
+
const commit = await this.content.head(actor.projectId);
|
|
464
|
+
this.sweep();
|
|
465
|
+
offer();
|
|
466
|
+
const floor = this.control.update(state => {
|
|
467
|
+
const current = state.requests[request.id];
|
|
468
|
+
const resource = state.resources[current.resourceId];
|
|
469
|
+
resource.fence = increment(resource.fence);
|
|
470
|
+
current.state = 'HELD';
|
|
471
|
+
current.floorId = uid();
|
|
472
|
+
current.fence = resource.fence;
|
|
473
|
+
current.baseRevision = '0';
|
|
474
|
+
current.snapshotCommit = commit;
|
|
475
|
+
current.expiresAt = this.clock.now() + this.limits.leaseMs;
|
|
476
|
+
return current;
|
|
477
|
+
});
|
|
478
|
+
return { requestId: floor.id, target: { type: 'project', purpose: floor.purpose },
|
|
479
|
+
floor: { id: floor.floorId, fencingToken: floor.fence, expiresAt: iso(floor.expiresAt) }, snapshot: { commit }, serverTime: iso(this.clock.now()) };
|
|
480
|
+
}
|
|
481
|
+
const snapshot = request.resourceType === 'thread'
|
|
482
|
+
? await this.content.snapshot(actor.projectId, request.resourceId, limit)
|
|
483
|
+
: await this.content.noteSnapshot(actor.projectId, request.resourceId, noteCursor);
|
|
484
|
+
this.sweep();
|
|
485
|
+
offer();
|
|
486
|
+
const floor = this.control.update(state => {
|
|
487
|
+
const current = state.requests[request.id];
|
|
488
|
+
const resource = state.resources[current.resourceId];
|
|
489
|
+
resource.fence = increment(resource.fence);
|
|
490
|
+
current.state = 'HELD';
|
|
491
|
+
current.floorId = uid();
|
|
492
|
+
current.fence = resource.fence;
|
|
493
|
+
current.baseRevision = snapshot.resourceType === 'thread' ? snapshot.thread.revision : snapshot.note.revision;
|
|
494
|
+
current.snapshotCommit = snapshot.commit;
|
|
495
|
+
current.expiresAt = this.clock.now() + this.limits.leaseMs;
|
|
496
|
+
return current;
|
|
497
|
+
});
|
|
498
|
+
this.control.view(s => this.held(s, handle, floor.floorId, floor.fence));
|
|
499
|
+
const page = snapshot.resourceType === 'thread'
|
|
500
|
+
? { type: 'thread', thread: snapshot.thread, messages: snapshot.messages, truncated: snapshot.truncated }
|
|
501
|
+
: { type: 'note', note: this.noteMetadata(snapshot.note), ...snapshot.page };
|
|
502
|
+
return { requestId: floor.id, target: { type: snapshot.resourceType, id: request.resourceId },
|
|
503
|
+
floor: { id: floor.floorId, fencingToken: floor.fence, expiresAt: iso(floor.expiresAt) }, snapshot: { commit: snapshot.commit, revision: floor.baseRevision },
|
|
504
|
+
page, nextCursor: snapshot.resourceType === 'thread' ? snapshot.nextBefore : snapshot.page.nextCursor, serverTime: iso(this.clock.now()) };
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
async read(handle, floorId, fence, limit, before) {
|
|
508
|
+
this.sweep();
|
|
509
|
+
const request = this.control.view(s => this.held(s, handle, floorId, fence));
|
|
510
|
+
requireThat(request.resourceType === 'thread', 'RESOURCE_TYPE_MISMATCH', 'This floor belongs to a note.');
|
|
511
|
+
const snapshot = await this.content.snapshot(request.projectId, request.resourceId, limit, before, request.snapshotCommit);
|
|
512
|
+
this.sweep();
|
|
513
|
+
this.control.view(s => this.held(s, handle, floorId, fence));
|
|
514
|
+
return { target: { type: 'thread', id: request.resourceId }, snapshot: { commit: snapshot.commit, revision: request.baseRevision },
|
|
515
|
+
page: { type: 'thread', messages: snapshot.messages }, nextCursor: snapshot.nextBefore };
|
|
516
|
+
}
|
|
517
|
+
async readNote(handle, floorId, fence, cursor) {
|
|
518
|
+
this.sweep();
|
|
519
|
+
const request = this.control.view(s => this.held(s, handle, floorId, fence));
|
|
520
|
+
requireThat(request.resourceType === 'note', 'RESOURCE_TYPE_MISMATCH', 'This floor belongs to a thread.');
|
|
521
|
+
const snapshot = await this.content.noteSnapshot(request.projectId, request.resourceId, cursor, request.snapshotCommit);
|
|
522
|
+
this.sweep();
|
|
523
|
+
this.control.view(s => this.held(s, handle, floorId, fence));
|
|
524
|
+
return { target: { type: 'note', id: snapshot.note.id }, snapshot: { commit: snapshot.commit, revision: request.baseRevision }, page: { type: 'note', note: this.noteMetadata(snapshot.note), ...snapshot.page }, nextCursor: snapshot.page.nextCursor };
|
|
525
|
+
}
|
|
526
|
+
async readFloor(handle, floorId, fence, cursor) {
|
|
527
|
+
const request = this.control.view(s => this.held(s, handle, floorId, fence));
|
|
528
|
+
if (request.resourceType === 'thread')
|
|
529
|
+
return this.read(handle, floorId, fence, 20, cursor);
|
|
530
|
+
if (request.resourceType === 'note')
|
|
531
|
+
return this.readNote(handle, floorId, fence, cursor);
|
|
532
|
+
throw new BassfishError('RESOURCE_TYPE_MISMATCH', 'Project floors have operation-specific readers.');
|
|
533
|
+
}
|
|
534
|
+
async noteOutline(handle, floorId, fence) {
|
|
535
|
+
this.sweep();
|
|
536
|
+
const request = this.control.view(s => this.held(s, handle, floorId, fence));
|
|
537
|
+
requireThat(request.resourceType === 'note', 'RESOURCE_TYPE_MISMATCH', 'This floor does not belong to a note.');
|
|
538
|
+
const snapshot = await this.content.noteSnapshot(request.projectId, request.resourceId, undefined, request.snapshotCommit);
|
|
539
|
+
this.sweep();
|
|
540
|
+
this.control.view(s => this.held(s, handle, floorId, fence));
|
|
541
|
+
return { noteId: request.resourceId, revision: request.baseRevision, snapshotCommit: request.snapshotCommit, headings: outline(snapshot.note.body) };
|
|
542
|
+
}
|
|
543
|
+
async findNote(handle, floorId, fence, query, mode, limit) {
|
|
544
|
+
this.sweep();
|
|
545
|
+
const request = this.control.view(s => this.held(s, handle, floorId, fence));
|
|
546
|
+
requireThat(request.resourceType === 'note', 'RESOURCE_TYPE_MISMATCH', 'This floor does not belong to a note.');
|
|
547
|
+
const snapshot = await this.content.noteSnapshot(request.projectId, request.resourceId, undefined, request.snapshotCommit);
|
|
548
|
+
const body = snapshot.note.body;
|
|
549
|
+
const ranges = [];
|
|
550
|
+
if (mode === 'literal') {
|
|
551
|
+
let at = 0;
|
|
552
|
+
while (ranges.length < limit && (at = body.indexOf(query, at)) >= 0) {
|
|
553
|
+
ranges.push({ start: at, end: at + query.length });
|
|
554
|
+
at += Math.max(1, query.length);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
else {
|
|
558
|
+
const regex = new RE2(query, 'gu');
|
|
559
|
+
let match;
|
|
560
|
+
while (ranges.length < limit && (match = regex.exec(body))) {
|
|
561
|
+
ranges.push({ start: match.index, end: match.index + match[0].length });
|
|
562
|
+
if (!match[0])
|
|
563
|
+
regex.lastIndex++;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
this.sweep();
|
|
567
|
+
this.control.view(s => this.held(s, handle, floorId, fence));
|
|
568
|
+
return { noteId: request.resourceId, revision: request.baseRevision, snapshotCommit: request.snapshotCommit, matches: ranges.map(range => ({
|
|
569
|
+
startCharacter: range.start, endCharacter: range.end, line: body.slice(0, range.start).split('\n').length,
|
|
570
|
+
snippet: body.slice(Math.max(0, range.start - 128), Math.min(body.length, range.end + 384))
|
|
571
|
+
})) };
|
|
572
|
+
}
|
|
573
|
+
releaseFloor(handle, floorId, fence) {
|
|
574
|
+
this.sweep();
|
|
575
|
+
return this.control.update(state => {
|
|
576
|
+
const request = this.held(state, handle, floorId, fence);
|
|
577
|
+
this.finish(request, 'RELEASED', state);
|
|
578
|
+
this.promote(state);
|
|
579
|
+
return this.statusIn(state, request);
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
cancelFloorRequest(handle, ticketId) {
|
|
583
|
+
this.sweep();
|
|
584
|
+
return this.control.update(state => {
|
|
585
|
+
const actor = this.actor(state, handle);
|
|
586
|
+
const request = this.ownRequest(state, handle, ticketId);
|
|
587
|
+
requireThat(request.instanceId === actor.instanceId, 'NOT_FLOOR_OWNER', 'Rebind this queue request before cancelling it.');
|
|
588
|
+
requireThat(request.state !== 'HELD' && request.state !== 'COMMITTING', 'FLOOR_ALREADY_HELD', 'The floor has already been claimed.');
|
|
589
|
+
if (request.state === 'QUEUED' || request.state === 'READY' || request.state === 'OFFERED')
|
|
590
|
+
this.finish(request, 'CANCELLED', state);
|
|
591
|
+
this.promote(state);
|
|
592
|
+
return this.statusIn(state, request);
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
async waitForFloor(handle, ticketId, timeout, signal) {
|
|
596
|
+
requireThat(timeout <= this.limits.waitMs, 'INVALID_ARGUMENT', 'The wait exceeds the configured maximum.');
|
|
597
|
+
const until = performance.now() + timeout;
|
|
598
|
+
while (true) {
|
|
599
|
+
const status = this.status(handle, ticketId);
|
|
600
|
+
if (status.state !== 'queued' || performance.now() >= until)
|
|
601
|
+
return status;
|
|
602
|
+
try {
|
|
603
|
+
await delay(Math.min(100, Math.max(1, until - performance.now())), undefined, { signal });
|
|
604
|
+
}
|
|
605
|
+
catch {
|
|
606
|
+
try {
|
|
607
|
+
this.cancelFloorRequest(handle, ticketId);
|
|
608
|
+
}
|
|
609
|
+
catch { /* Claim may already have won. */ }
|
|
610
|
+
throw new BassfishError('CANCELLED', 'The wait was cancelled; inspect ticket state.');
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
async createThread(handle, title, description) {
|
|
615
|
+
const actor = this.control.view(s => this.actor(s, handle));
|
|
616
|
+
return this.writers.run(actor.projectId, async () => {
|
|
617
|
+
this.control.view(s => { this.actor(s, handle); this.creationAllowed(s, actor.projectId); });
|
|
618
|
+
const startingHead = await this.content.head(actor.projectId);
|
|
619
|
+
const thread = { id: uid(), title, description, state: 'active', revision: '1', headSequence: '0', creator: actor.identityId, createdAt: iso(this.clock.now()) };
|
|
620
|
+
const operation = { id: uid(), actor, resourceId: thread.id, resourceType: 'thread', at: thread.createdAt, thread, mutation: { kind: 'createThread' } };
|
|
621
|
+
this.control.update(state => {
|
|
622
|
+
this.actor(state, handle);
|
|
623
|
+
this.creationAllowed(state, actor.projectId);
|
|
624
|
+
state.pending[operation.id] = { id: operation.id, projectId: actor.projectId, resourceId: thread.id, resourceType: 'thread', startingHead, kind: 'createThread', actor };
|
|
625
|
+
});
|
|
626
|
+
const result = await this.persist(operation);
|
|
627
|
+
return { threadId: thread.id, title, state: thread.state, ...result };
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
noteMetadata(note) {
|
|
631
|
+
const { body, ...metadata } = note;
|
|
632
|
+
return { ...metadata, contentBytes: Buffer.byteLength(body, 'utf8') };
|
|
633
|
+
}
|
|
634
|
+
async validateLinks(projectId, links) {
|
|
635
|
+
if (!links.length)
|
|
636
|
+
return;
|
|
637
|
+
const snapshot = await this.content.projectSnapshot(projectId);
|
|
638
|
+
const ids = {
|
|
639
|
+
note: new Set(snapshot.notes.map(value => value.id)),
|
|
640
|
+
thread: new Set(snapshot.threads.map(value => value.id)),
|
|
641
|
+
message: new Set(snapshot.messages.map(value => value.id)),
|
|
642
|
+
};
|
|
643
|
+
for (const link of links)
|
|
644
|
+
requireThat(ids[link.targetType].has(link.targetId), 'LINK_TARGET_NOT_FOUND', `The ${link.targetType} link target does not exist in this project.`);
|
|
645
|
+
}
|
|
646
|
+
async createNote(handle, input) {
|
|
647
|
+
const actor = this.control.view(s => this.actor(s, handle));
|
|
648
|
+
return this.writers.run(actor.projectId, async () => {
|
|
649
|
+
this.control.view(s => { this.actor(s, handle); this.creationAllowed(s, actor.projectId); });
|
|
650
|
+
const links = normalizeLinks(input.links.map(link => ({ targetType: link.targetType, targetId: link.targetId })));
|
|
651
|
+
await this.validateLinks(actor.projectId, links);
|
|
652
|
+
const startingHead = await this.content.head(actor.projectId);
|
|
653
|
+
const at = iso(this.clock.now());
|
|
654
|
+
const note = { id: uid(), path: input.path, title: input.title, body: input.body, labels: normalizeLabels(input.labels), kind: input.noteKind,
|
|
655
|
+
state: 'active', revision: '1', creator: actor.identityId, creatorName: actor.name, lastEditor: actor.identityId,
|
|
656
|
+
lastEditorName: actor.name, createdAt: at, updatedAt: at, links };
|
|
657
|
+
const operation = { id: uid(), actor, resourceId: note.id, resourceType: 'note', at, note, mutation: { kind: 'createNote' } };
|
|
658
|
+
this.control.update(state => {
|
|
659
|
+
this.actor(state, handle);
|
|
660
|
+
this.creationAllowed(state, actor.projectId);
|
|
661
|
+
state.pending[operation.id] = {
|
|
662
|
+
id: operation.id, projectId: actor.projectId, resourceId: note.id, resourceType: 'note', startingHead, kind: 'createNote', actor
|
|
663
|
+
};
|
|
664
|
+
});
|
|
665
|
+
const result = await this.persist(operation);
|
|
666
|
+
return { noteId: note.id, path: note.path, title: note.title, state: note.state, ...result };
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
signRestore(payload) {
|
|
670
|
+
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
|
671
|
+
return `${body}.${createHmac('sha256', this.restoreSecret).update(body).digest('base64url')}`;
|
|
672
|
+
}
|
|
673
|
+
verifyRestore(token) {
|
|
674
|
+
const [body, signature, extra] = token.split('.');
|
|
675
|
+
requireThat(body && signature && !extra, 'INVALID_PREVIEW', 'The restore preview token is invalid.');
|
|
676
|
+
const expected = createHmac('sha256', this.restoreSecret).update(body).digest();
|
|
677
|
+
const actual = Buffer.from(signature, 'base64url');
|
|
678
|
+
requireThat(actual.length === expected.length && timingSafeEqual(actual, expected), 'INVALID_PREVIEW', 'The restore preview token is invalid.');
|
|
679
|
+
try {
|
|
680
|
+
return JSON.parse(Buffer.from(body, 'base64url').toString('utf8'));
|
|
681
|
+
}
|
|
682
|
+
catch {
|
|
683
|
+
throw new BassfishError('INVALID_PREVIEW', 'The restore preview token is invalid.');
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
async resourceHistory(handle, floorId, fence, offset, limit) {
|
|
687
|
+
this.sweep();
|
|
688
|
+
const request = this.control.view(s => this.held(s, handle, floorId, fence));
|
|
689
|
+
requireThat(request.resourceType !== 'project', 'RESOURCE_TYPE_MISMATCH', 'Project floors do not have one resource history.');
|
|
690
|
+
const entries = await this.content.history(request.projectId, request.resourceType, request.resourceId);
|
|
691
|
+
this.sweep();
|
|
692
|
+
this.control.view(s => this.held(s, handle, floorId, fence));
|
|
693
|
+
return { target: { type: request.resourceType, id: request.resourceId }, entries: entries.slice(offset, offset + limit), nextOffset: offset + limit < entries.length ? offset + limit : null };
|
|
694
|
+
}
|
|
695
|
+
async resourceAt(handle, floorId, fence, revision, limit, cursor) {
|
|
696
|
+
this.sweep();
|
|
697
|
+
const request = this.control.view(s => this.held(s, handle, floorId, fence));
|
|
698
|
+
requireThat(request.resourceType !== 'project', 'RESOURCE_TYPE_MISMATCH', 'Use a resource floor for historical reads.');
|
|
699
|
+
const commit = await this.content.commitAtRevision(request.projectId, request.resourceType, request.resourceId, revision);
|
|
700
|
+
const snapshot = request.resourceType === 'thread' ? await this.content.snapshot(request.projectId, request.resourceId, limit, undefined, commit)
|
|
701
|
+
: await this.content.noteSnapshot(request.projectId, request.resourceId, cursor, commit);
|
|
702
|
+
this.sweep();
|
|
703
|
+
this.control.view(s => this.held(s, handle, floorId, fence));
|
|
704
|
+
return snapshot.resourceType === 'thread'
|
|
705
|
+
? { target: { type: 'thread', id: request.resourceId }, revision, snapshotCommit: commit, page: { type: 'thread', thread: snapshot.thread, messages: snapshot.messages, truncated: snapshot.truncated }, nextCursor: snapshot.nextBefore }
|
|
706
|
+
: { target: { type: 'note', id: request.resourceId }, revision, snapshotCommit: commit, page: { type: 'note', note: this.noteMetadata(snapshot.note), ...snapshot.page }, nextCursor: snapshot.page.nextCursor };
|
|
707
|
+
}
|
|
708
|
+
async diffResource(handle, floorId, fence, revision) {
|
|
709
|
+
this.sweep();
|
|
710
|
+
const request = this.control.view(s => this.held(s, handle, floorId, fence));
|
|
711
|
+
requireThat(request.resourceType !== 'project', 'RESOURCE_TYPE_MISMATCH', 'Use a resource floor for diffs.');
|
|
712
|
+
const targetCommit = await this.content.commitAtRevision(request.projectId, request.resourceType, request.resourceId, revision);
|
|
713
|
+
if (request.resourceType === 'note') {
|
|
714
|
+
const [target, current] = await Promise.all([this.content.noteSnapshot(request.projectId, request.resourceId, undefined, targetCommit), this.content.noteSnapshot(request.projectId, request.resourceId)]);
|
|
715
|
+
this.sweep();
|
|
716
|
+
this.control.view(s => this.held(s, handle, floorId, fence));
|
|
717
|
+
return { target: { type: 'note', id: request.resourceId }, fromRevision: revision, toRevision: current.note.revision, fromCommit: targetCommit, toCommit: current.commit,
|
|
718
|
+
metadataChanged: JSON.stringify(this.noteMetadata(target.note)) !== JSON.stringify(this.noteMetadata(current.note)), before: target.page, after: current.page };
|
|
719
|
+
}
|
|
720
|
+
const [target, current] = await Promise.all([this.content.snapshot(request.projectId, request.resourceId, 1_000_000, undefined, targetCommit), this.content.snapshot(request.projectId, request.resourceId, 1_000_000)]);
|
|
721
|
+
this.sweep();
|
|
722
|
+
this.control.view(s => this.held(s, handle, floorId, fence));
|
|
723
|
+
const targetIds = new Set(target.messages.filter(message => !message.retracted).map(message => message.id));
|
|
724
|
+
const currentIds = new Set(current.messages.filter(message => !message.retracted).map(message => message.id));
|
|
725
|
+
return { target: { type: 'thread', id: request.resourceId }, fromRevision: revision, toRevision: current.thread.revision, fromCommit: targetCommit, toCommit: current.commit,
|
|
726
|
+
metadataChanged: JSON.stringify(target.thread) !== JSON.stringify(current.thread), messagesAdded: [...currentIds].filter(id => !targetIds.has(id)), messagesRemoved: [...targetIds].filter(id => !currentIds.has(id)) };
|
|
727
|
+
}
|
|
728
|
+
async previewRestore(handle, floorId, fence, revision) {
|
|
729
|
+
this.sweep();
|
|
730
|
+
const request = this.control.view(s => this.held(s, handle, floorId, fence));
|
|
731
|
+
requireThat(request.resourceType !== 'project', 'RESOURCE_TYPE_MISMATCH', 'Use a resource floor for this restore preview.');
|
|
732
|
+
const targetCommit = await this.content.commitAtRevision(request.projectId, request.resourceType, request.resourceId, revision);
|
|
733
|
+
const preview = await this.diffResource(handle, floorId, fence, revision);
|
|
734
|
+
const token = this.signRestore({ floorId, fence, projectId: request.projectId, resourceType: request.resourceType, resourceId: request.resourceId,
|
|
735
|
+
currentRevision: request.baseRevision, targetRevision: revision, targetCommit, expiresAt: request.expiresAt });
|
|
736
|
+
return { ...preview, previewToken: token, expiresAt: iso(request.expiresAt) };
|
|
737
|
+
}
|
|
738
|
+
async restoreRevision(handle, floorId, fence, token) {
|
|
739
|
+
this.sweep();
|
|
740
|
+
const payload = this.verifyRestore(token);
|
|
741
|
+
const actor = this.control.view(s => this.actor(s, handle));
|
|
742
|
+
return this.writers.run(actor.projectId, async () => {
|
|
743
|
+
const request = this.control.view(s => this.held(s, handle, floorId, fence));
|
|
744
|
+
requireThat(payload.floorId === floorId && payload.fence === fence && payload.projectId === actor.projectId && payload.resourceId === request.resourceId &&
|
|
745
|
+
payload.resourceType === request.resourceType && payload.currentRevision === request.baseRevision && Number(payload.expiresAt) > this.clock.now(), 'PREVIEW_STALE', 'The restore preview no longer matches this floor.');
|
|
746
|
+
const at = iso(this.clock.now());
|
|
747
|
+
let operation;
|
|
748
|
+
if (request.resourceType === 'note') {
|
|
749
|
+
const [target, current] = await Promise.all([this.content.noteSnapshot(actor.projectId, request.resourceId, undefined, payload.targetCommit), this.content.noteSnapshot(actor.projectId, request.resourceId)]);
|
|
750
|
+
requireThat(current.note.revision === request.baseRevision, 'REVISION_CHANGED', 'The current note changed after the floor was claimed.');
|
|
751
|
+
const note = { ...target.note, revision: increment(current.note.revision), lastEditor: actor.identityId, lastEditorName: actor.name, updatedAt: at };
|
|
752
|
+
operation = { id: uid(), actor, resourceId: request.resourceId, resourceType: 'note', at, note, mutation: { kind: 'replaceNoteBody', body: note.body } };
|
|
753
|
+
}
|
|
754
|
+
else {
|
|
755
|
+
const [target, current] = await Promise.all([this.content.snapshot(actor.projectId, request.resourceId, 1_000_000, undefined, payload.targetCommit), this.content.snapshot(actor.projectId, request.resourceId, 1_000_000)]);
|
|
756
|
+
requireThat(current.thread.revision === request.baseRevision, 'REVISION_CHANGED', 'The current thread changed after the floor was claimed.');
|
|
757
|
+
const thread = { ...target.thread, revision: increment(current.thread.revision), headSequence: current.thread.headSequence };
|
|
758
|
+
const targetVisibility = new Map(target.messages.map(message => [message.id, !message.retracted]));
|
|
759
|
+
const visibilityChanges = current.messages.filter(message => (!message.retracted) !== (targetVisibility.get(message.id) ?? false)).map(message => ({ messageId: message.id, visible: targetVisibility.get(message.id) ?? false }));
|
|
760
|
+
operation = { id: uid(), actor, resourceId: request.resourceId, resourceType: 'thread', at, thread, mutation: { kind: 'restoreThreadRevision', targetRevision: payload.targetRevision }, visibilityChanges };
|
|
761
|
+
}
|
|
762
|
+
this.control.update(state => {
|
|
763
|
+
const current = this.held(state, handle, floorId, fence);
|
|
764
|
+
current.state = 'COMMITTING';
|
|
765
|
+
state.pending[operation.id] = {
|
|
766
|
+
id: operation.id, projectId: actor.projectId, resourceId: request.resourceId, resourceType: request.resourceType, floorRequestId: request.id,
|
|
767
|
+
startingHead: request.snapshotCommit, kind: 'restoreRevision', actor
|
|
768
|
+
};
|
|
769
|
+
});
|
|
770
|
+
return this.persist(operation);
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
projectHeld(state, handle, floorId, fence, purpose) {
|
|
774
|
+
const request = this.held(state, handle, floorId, fence);
|
|
775
|
+
requireThat(request.resourceType === 'project', 'RESOURCE_TYPE_MISMATCH', 'A project floor is required.');
|
|
776
|
+
requireThat(!purpose || request.purpose === purpose, 'PROJECT_PURPOSE_MISMATCH', 'This project floor was acquired for another purpose.');
|
|
777
|
+
return request;
|
|
778
|
+
}
|
|
779
|
+
async projectSnapshotInfo(handle, floorId, fence) {
|
|
780
|
+
this.sweep();
|
|
781
|
+
const request = this.control.view(s => this.projectHeld(s, handle, floorId, fence, 'snapshot'));
|
|
782
|
+
const snapshot = await this.content.projectSnapshot(request.projectId, request.snapshotCommit);
|
|
783
|
+
this.sweep();
|
|
784
|
+
this.control.view(s => this.projectHeld(s, handle, floorId, fence, 'snapshot'));
|
|
785
|
+
return { snapshotCommit: snapshot.commit, threads: snapshot.threads.map(thread => ({ id: thread.id, title: thread.title, description: thread.description, state: thread.state, revision: thread.revision })),
|
|
786
|
+
notes: snapshot.notes.map(note => this.noteMetadata(note)), messageCount: snapshot.messages.length };
|
|
787
|
+
}
|
|
788
|
+
cursorOffset(token, expected) {
|
|
789
|
+
if (!token)
|
|
790
|
+
return 0;
|
|
791
|
+
const payload = this.verifyRestore(token);
|
|
792
|
+
for (const [key, value] of Object.entries(expected))
|
|
793
|
+
requireThat(payload[key] === value, 'INVALID_CURSOR', 'The cursor does not match this snapshot or query.');
|
|
794
|
+
requireThat(Number.isSafeInteger(payload.offset) && Number(payload.offset) >= 0, 'INVALID_CURSOR', 'The cursor offset is invalid.');
|
|
795
|
+
return Number(payload.offset);
|
|
796
|
+
}
|
|
797
|
+
nextCursor(offset, expected) { return offset === null ? null : this.signRestore({ ...expected, offset }); }
|
|
798
|
+
async projectSearch(handle, floorId, fence, query, states, limit, cursor) {
|
|
799
|
+
this.sweep();
|
|
800
|
+
const request = this.control.view(s => this.projectHeld(s, handle, floorId, fence, 'search'));
|
|
801
|
+
requireThat(this.searchIndex, 'SEARCH_UNAVAILABLE', 'The rebuildable search index is unavailable.');
|
|
802
|
+
const key = { kind: 'currentSearch', snapshotCommit: request.snapshotCommit, query, states: JSON.stringify(states) };
|
|
803
|
+
const offset = this.cursorOffset(cursor, key);
|
|
804
|
+
const snapshot = await this.content.projectSnapshot(request.projectId, request.snapshotCommit);
|
|
805
|
+
this.searchIndex.ensure(request.projectId, snapshot.commit, snapshot.notes);
|
|
806
|
+
const matches = this.searchIndex.search(request.projectId, query, states, limit, offset);
|
|
807
|
+
this.sweep();
|
|
808
|
+
this.control.view(s => this.projectHeld(s, handle, floorId, fence, 'search'));
|
|
809
|
+
return { snapshotCommit: snapshot.commit, matches: matches.map(match => ({ noteId: match.noteId, path: match.path, revision: match.revision, snippet: match.snippet })), nextCursor: this.nextCursor(matches.length === limit ? offset + limit : null, key) };
|
|
810
|
+
}
|
|
811
|
+
async projectHistorySearch(handle, floorId, fence, query, noteId, states, limit, cursor) {
|
|
812
|
+
this.sweep();
|
|
813
|
+
const request = this.control.view(s => this.projectHeld(s, handle, floorId, fence, 'search'));
|
|
814
|
+
requireThat(this.searchIndex, 'SEARCH_UNAVAILABLE', 'The rebuildable search index is unavailable.');
|
|
815
|
+
const key = { kind: 'historySearch', snapshotCommit: request.snapshotCommit, query, noteId: noteId ?? null, states: JSON.stringify(states) };
|
|
816
|
+
const offset = this.cursorOffset(cursor, key);
|
|
817
|
+
const history = await this.content.historicalNotes(request.projectId, request.snapshotCommit);
|
|
818
|
+
this.searchIndex.ensureHistory(request.projectId, request.snapshotCommit, history);
|
|
819
|
+
const matches = this.searchIndex.searchHistory(request.projectId, query, states, noteId, limit, offset);
|
|
820
|
+
this.sweep();
|
|
821
|
+
this.control.view(s => this.projectHeld(s, handle, floorId, fence, 'search'));
|
|
822
|
+
return { snapshotCommit: request.snapshotCommit, matches, nextCursor: this.nextCursor(matches.length === limit ? offset + limit : null, key) };
|
|
823
|
+
}
|
|
824
|
+
async projectHistoryList(handle, floorId, fence, limit, cursor) {
|
|
825
|
+
this.sweep();
|
|
826
|
+
const request = this.control.view(s => this.projectHeld(s, handle, floorId, fence, 'restore'));
|
|
827
|
+
const key = { kind: 'projectHistory', snapshotCommit: request.snapshotCommit };
|
|
828
|
+
const offset = this.cursorOffset(cursor, key);
|
|
829
|
+
const entries = await this.content.projectHistory(request.projectId);
|
|
830
|
+
const page = entries.filter(value => value.doltCommit).slice(offset, offset + limit);
|
|
831
|
+
this.sweep();
|
|
832
|
+
this.control.view(s => this.projectHeld(s, handle, floorId, fence, 'restore'));
|
|
833
|
+
return { snapshotCommit: request.snapshotCommit, entries: page, nextCursor: this.nextCursor(page.length === limit ? offset + limit : null, key) };
|
|
834
|
+
}
|
|
835
|
+
async buildProjectRestore(projectId, actor, current, target) {
|
|
836
|
+
const changes = [];
|
|
837
|
+
const at = iso(this.clock.now());
|
|
838
|
+
const currentThreads = new Map(current.threads.map(value => [value.id, value]));
|
|
839
|
+
const targetThreads = new Map(target.threads.map(value => [value.id, value]));
|
|
840
|
+
const currentNotes = new Map(current.notes.map(value => [value.id, value]));
|
|
841
|
+
const targetNotes = new Map(target.notes.map(value => [value.id, value]));
|
|
842
|
+
const threadShape = (snapshot, id) => ({ thread: snapshot.threads.find(value => value.id === id) ? (() => { const { revision: _r, headSequence: _s, ...rest } = snapshot.threads.find(value => value.id === id); return rest; })() : null,
|
|
843
|
+
messages: snapshot.messages.filter(value => value.threadId === id), visibility: snapshot.visibility.filter(value => value.threadId === id).map(({ threadRevision: _r, ...rest }) => rest) });
|
|
844
|
+
const noteShape = (note) => note ? (() => { const { revision: _r, lastEditor: _e, lastEditorName: _n, updatedAt: _u, ...rest } = note; return rest; })() : null;
|
|
845
|
+
const changedThreads = new Map();
|
|
846
|
+
const changedNotes = new Map();
|
|
847
|
+
for (const id of new Set([...currentThreads.keys(), ...targetThreads.keys()])) {
|
|
848
|
+
const before = currentThreads.get(id);
|
|
849
|
+
const after = targetThreads.get(id);
|
|
850
|
+
if (JSON.stringify(threadShape(current, id)) === JSON.stringify(threadShape(target, id)))
|
|
851
|
+
continue;
|
|
852
|
+
const next = increment(await this.content.maxRevision(projectId, 'thread', id));
|
|
853
|
+
changedThreads.set(id, next);
|
|
854
|
+
changes.push({ resourceType: 'thread', resourceId: id, action: !before ? 'create' : !after ? 'delete' : 'update', beforeRevision: before?.revision ?? '0', afterRevision: next });
|
|
855
|
+
}
|
|
856
|
+
for (const id of new Set([...currentNotes.keys(), ...targetNotes.keys()])) {
|
|
857
|
+
const before = currentNotes.get(id);
|
|
858
|
+
const after = targetNotes.get(id);
|
|
859
|
+
if (JSON.stringify(noteShape(before)) === JSON.stringify(noteShape(after)))
|
|
860
|
+
continue;
|
|
861
|
+
const next = increment(await this.content.maxRevision(projectId, 'note', id));
|
|
862
|
+
changedNotes.set(id, next);
|
|
863
|
+
changes.push({ resourceType: 'note', resourceId: id, action: !before ? 'create' : !after ? 'delete' : 'update', beforeRevision: before?.revision ?? '0', afterRevision: next });
|
|
864
|
+
}
|
|
865
|
+
changes.sort((a, b) => `${a.resourceType}:${a.resourceId}`.localeCompare(`${b.resourceType}:${b.resourceId}`));
|
|
866
|
+
const threads = await Promise.all(target.threads.map(async (thread) => ({ ...thread, revision: changedThreads.get(thread.id) ?? currentThreads.get(thread.id)?.revision ?? thread.revision,
|
|
867
|
+
headSequence: await this.content.maxSequence(projectId, thread.id) })));
|
|
868
|
+
const notes = target.notes.map(note => changedNotes.has(note.id) ? { ...note, revision: changedNotes.get(note.id), lastEditor: actor.identityId, lastEditorName: actor.name, updatedAt: at }
|
|
869
|
+
: { ...note, revision: currentNotes.get(note.id)?.revision ?? note.revision });
|
|
870
|
+
const snapshot = { ...target, threads, notes };
|
|
871
|
+
const summary = {
|
|
872
|
+
threads: { create: changes.filter(c => c.resourceType === 'thread' && c.action === 'create').length, update: changes.filter(c => c.resourceType === 'thread' && c.action === 'update').length, delete: changes.filter(c => c.resourceType === 'thread' && c.action === 'delete').length },
|
|
873
|
+
notes: { create: changes.filter(c => c.resourceType === 'note' && c.action === 'create').length, update: changes.filter(c => c.resourceType === 'note' && c.action === 'update').length, delete: changes.filter(c => c.resourceType === 'note' && c.action === 'delete').length },
|
|
874
|
+
messages: { current: current.messages.length, target: target.messages.length }, links: { current: current.notes.reduce((n, v) => n + v.links.length, 0), target: target.notes.reduce((n, v) => n + v.links.length, 0) },
|
|
875
|
+
visibility: { current: current.visibility.length, target: target.visibility.length },
|
|
876
|
+
};
|
|
877
|
+
const digest = createHmac('sha256', this.restoreSecret).update(JSON.stringify({ current: current.commit, target: target.commit, changes, summary })).digest('base64url');
|
|
878
|
+
return { snapshot, changes, digest, summary };
|
|
879
|
+
}
|
|
880
|
+
async previewProjectRestore(handle, floorId, fence, targetCommit, limit, cursor) {
|
|
881
|
+
this.sweep();
|
|
882
|
+
const request = this.control.view(s => this.projectHeld(s, handle, floorId, fence, 'restore'));
|
|
883
|
+
const actor = this.control.view(s => this.actor(s, handle));
|
|
884
|
+
const [current, target] = await Promise.all([this.content.projectSnapshot(request.projectId, request.snapshotCommit), this.content.projectSnapshot(request.projectId, targetCommit)]);
|
|
885
|
+
const built = await this.buildProjectRestore(request.projectId, actor, current, target);
|
|
886
|
+
const key = { kind: 'restoreChanges', currentCommit: current.commit, targetCommit: target.commit, digest: built.digest };
|
|
887
|
+
const offset = this.cursorOffset(cursor, key);
|
|
888
|
+
const page = built.changes.slice(offset, offset + limit);
|
|
889
|
+
const previewToken = this.signRestore({ kind: 'snapshotRestore', floorId, fence, projectId: request.projectId, currentCommit: current.commit, targetCommit: target.commit, digest: built.digest, expiresAt: request.expiresAt });
|
|
890
|
+
this.sweep();
|
|
891
|
+
this.control.view(s => this.projectHeld(s, handle, floorId, fence, 'restore'));
|
|
892
|
+
return { currentCommit: current.commit, targetCommit: target.commit, summary: built.summary, changes: page, nextCursor: this.nextCursor(page.length === limit ? offset + limit : null, key), previewToken, expiresAt: iso(request.expiresAt) };
|
|
893
|
+
}
|
|
894
|
+
async restoreProject(handle, floorId, fence, token) {
|
|
895
|
+
this.sweep();
|
|
896
|
+
const payload = this.verifyRestore(token);
|
|
897
|
+
const actor = this.control.view(s => this.actor(s, handle));
|
|
898
|
+
return this.writers.run(actor.projectId, async () => {
|
|
899
|
+
const request = this.control.view(s => this.projectHeld(s, handle, floorId, fence, 'restore'));
|
|
900
|
+
requireThat(payload.kind === 'snapshotRestore' && payload.floorId === floorId && payload.fence === fence && payload.projectId === actor.projectId && payload.currentCommit === request.snapshotCommit && Number(payload.expiresAt) > this.clock.now(), 'PREVIEW_STALE', 'The restore preview no longer matches this floor.');
|
|
901
|
+
const [current, target] = await Promise.all([this.content.projectSnapshot(actor.projectId), this.content.projectSnapshot(actor.projectId, String(payload.targetCommit))]);
|
|
902
|
+
requireThat(current.commit === request.snapshotCommit, 'PREVIEW_STALE', 'The project changed after the restore preview.');
|
|
903
|
+
const built = await this.buildProjectRestore(actor.projectId, actor, current, target);
|
|
904
|
+
requireThat(built.digest === payload.digest, 'PREVIEW_STALE', 'The restore change set changed.');
|
|
905
|
+
requireThat(built.changes.length > 0, 'NO_CHANGE', 'The target snapshot already matches visible project content.');
|
|
906
|
+
const operationId = uid();
|
|
907
|
+
const at = iso(this.clock.now());
|
|
908
|
+
const restoredThreadRevisions = new Map(built.changes.filter(change => change.resourceType === 'thread' && change.action !== 'delete').map(change => [change.resourceId, change.afterRevision]));
|
|
909
|
+
const restoredSnapshot = { ...built.snapshot, visibility: built.snapshot.visibility.map(value => restoredThreadRevisions.has(value.threadId)
|
|
910
|
+
? { ...value, threadRevision: restoredThreadRevisions.get(value.threadId), operationId, createdAt: at } : value) };
|
|
911
|
+
const operation = { id: operationId, actor, resourceId: request.resourceId, resourceType: 'project', at, mutation: { kind: 'restoreSnapshot', targetCommit: target.commit }, target: restoredSnapshot, current, changes: built.changes };
|
|
912
|
+
this.control.update(state => { const held = this.projectHeld(state, handle, floorId, fence, 'restore'); held.state = 'COMMITTING'; held.updatedAt = this.clock.now(); state.pending[operation.id] = { id: operation.id, projectId: actor.projectId, resourceId: request.resourceId, resourceType: 'project', floorRequestId: request.id, startingHead: current.commit, kind: 'restoreSnapshot', actor }; });
|
|
913
|
+
return this.persist(operation);
|
|
914
|
+
});
|
|
915
|
+
}
|
|
916
|
+
async exportProject(handle, floorId, fence) {
|
|
917
|
+
this.sweep();
|
|
918
|
+
const request = this.control.view(s => this.projectHeld(s, handle, floorId, fence, 'export'));
|
|
919
|
+
requireThat(this.dataDir, 'EXPORT_UNAVAILABLE', 'Export storage is unavailable.');
|
|
920
|
+
const snapshot = await this.content.projectSnapshot(request.projectId, request.snapshotCommit);
|
|
921
|
+
const result = await writeProjectExport(this.dataDir, request.projectId, snapshot);
|
|
922
|
+
this.sweep();
|
|
923
|
+
this.control.update(s => { const current = this.projectHeld(s, handle, floorId, fence, 'export'); this.finish(current, 'RELEASED', s); this.promote(s); });
|
|
924
|
+
return result;
|
|
925
|
+
}
|
|
926
|
+
async commitFloor(handle, floorId, fence, base, mutation) {
|
|
927
|
+
this.sweep();
|
|
928
|
+
const actor = this.control.view(s => this.actor(s, handle));
|
|
929
|
+
return this.writers.run(actor.projectId, async () => {
|
|
930
|
+
this.sweep();
|
|
931
|
+
const request = this.control.view(s => this.held(s, handle, floorId, fence));
|
|
932
|
+
const snapshot = request.resourceType === 'thread' ? await this.content.snapshot(actor.projectId, request.resourceId, 1) : await this.content.noteSnapshot(actor.projectId, request.resourceId);
|
|
933
|
+
const currentRevision = snapshot.resourceType === 'thread' ? snapshot.thread.revision : snapshot.note.revision;
|
|
934
|
+
requireThat(base === request.baseRevision && base === currentRevision, 'REVISION_CHANGED', 'The mutation base must match the current claimed revision.');
|
|
935
|
+
const at = iso(this.clock.now());
|
|
936
|
+
let operation;
|
|
937
|
+
if (snapshot.resourceType === 'thread') {
|
|
938
|
+
requireThat(threadMutationKinds.includes(mutation.kind), 'RESOURCE_TYPE_MISMATCH', 'This mutation does not apply to a thread.');
|
|
939
|
+
if (mutation.kind === 'retractMessage' || mutation.kind === 'reinstateMessage') {
|
|
940
|
+
const current = await this.content.snapshot(actor.projectId, request.resourceId, 1_000_000);
|
|
941
|
+
const message = current.messages.find(value => value.id === mutation.messageId);
|
|
942
|
+
requireThat(message, 'NOT_FOUND', 'Message not found in this thread.');
|
|
943
|
+
requireThat(mutation.kind === 'retractMessage' ? !message.retracted : message.retracted, 'NO_CHANGE', `The message is already ${mutation.kind === 'retractMessage' ? 'retracted' : 'visible'}.`);
|
|
944
|
+
}
|
|
945
|
+
const thread = prepareMutation(snapshot.thread, mutation);
|
|
946
|
+
operation = { id: uid(), actor, resourceId: request.resourceId, resourceType: 'thread', at, thread, mutation: mutation };
|
|
947
|
+
}
|
|
948
|
+
else {
|
|
949
|
+
requireThat(!threadMutationKinds.includes(mutation.kind), 'RESOURCE_TYPE_MISMATCH', 'This mutation does not apply to a note.');
|
|
950
|
+
const noteMutation = mutation;
|
|
951
|
+
const note = prepareNote(snapshot.note, noteMutation, actor, at);
|
|
952
|
+
await this.validateLinks(actor.projectId, note.links);
|
|
953
|
+
operation = { id: uid(), actor, resourceId: request.resourceId, resourceType: 'note', at, note, mutation: noteMutation };
|
|
954
|
+
}
|
|
955
|
+
this.control.update(state => {
|
|
956
|
+
const current = this.held(state, handle, floorId, fence);
|
|
957
|
+
current.state = 'COMMITTING';
|
|
958
|
+
state.pending[operation.id] = { id: operation.id, projectId: actor.projectId, resourceId: request.resourceId,
|
|
959
|
+
resourceType: request.resourceType, floorRequestId: request.id, startingHead: snapshot.commit, kind: mutation.kind, actor };
|
|
960
|
+
});
|
|
961
|
+
return this.persist(operation);
|
|
962
|
+
});
|
|
963
|
+
}
|
|
964
|
+
finalize(pending, result) {
|
|
965
|
+
this.control.update(state => {
|
|
966
|
+
if (pending.floorRequestId) {
|
|
967
|
+
const request = state.requests[pending.floorRequestId];
|
|
968
|
+
this.finish(request, result ? 'COMMITTED' : 'FAILED', state);
|
|
969
|
+
if (result)
|
|
970
|
+
request.result = result;
|
|
971
|
+
}
|
|
972
|
+
if (result && pending.resourceType !== 'project' && !state.resources[pending.resourceId])
|
|
973
|
+
state.resources[pending.resourceId] = { id: pending.resourceId, projectId: pending.projectId, type: pending.resourceType, fence: '0', queueSequence: '0', present: true };
|
|
974
|
+
if (result && 'changes' in result) {
|
|
975
|
+
for (const change of result.changes) {
|
|
976
|
+
const resource = state.resources[change.resourceId] ?? { id: change.resourceId, projectId: pending.projectId, type: change.resourceType, fence: '0', queueSequence: '0', present: true };
|
|
977
|
+
resource.type = change.resourceType;
|
|
978
|
+
resource.present = change.action !== 'delete';
|
|
979
|
+
state.resources[change.resourceId] = resource;
|
|
980
|
+
if (change.action === 'delete')
|
|
981
|
+
for (const request of values(state.requests).filter(value => value.resourceId === change.resourceId && activeStates.includes(value.state)))
|
|
982
|
+
this.finish(request, 'FAILED', state);
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
delete state.pending[pending.id];
|
|
986
|
+
this.promote(state);
|
|
987
|
+
});
|
|
988
|
+
}
|
|
989
|
+
async persist(operation) {
|
|
990
|
+
const pending = this.control.view(s => s.pending[operation.id]);
|
|
991
|
+
try {
|
|
992
|
+
const result = await this.content.write(operation);
|
|
993
|
+
this.finalize(pending, result);
|
|
994
|
+
return result;
|
|
995
|
+
}
|
|
996
|
+
catch {
|
|
997
|
+
// Never replay a write: inspect its operation marker and committed head instead.
|
|
998
|
+
this.control.update(s => { s.projects[pending.projectId].recovering = true; });
|
|
999
|
+
let resolution;
|
|
1000
|
+
try {
|
|
1001
|
+
resolution = await this.content.resolve(pending);
|
|
1002
|
+
}
|
|
1003
|
+
catch {
|
|
1004
|
+
resolution = { state: 'unknown' };
|
|
1005
|
+
}
|
|
1006
|
+
if (resolution.state === 'committed') {
|
|
1007
|
+
this.finalize(pending, resolution.result);
|
|
1008
|
+
this.control.update(s => { s.projects[pending.projectId].recovering = false; this.promote(s); });
|
|
1009
|
+
return resolution.result;
|
|
1010
|
+
}
|
|
1011
|
+
if (resolution.state === 'absent') {
|
|
1012
|
+
this.finalize(pending);
|
|
1013
|
+
this.control.update(s => { s.projects[pending.projectId].recovering = false; this.promote(s); });
|
|
1014
|
+
throw new BassfishError('WRITE_FAILED', 'The write was proven absent. No content mutation was retried.');
|
|
1015
|
+
}
|
|
1016
|
+
throw new BassfishError('OUTCOME_UNKNOWN', 'Storage outcome is unresolved. The project remains protected.');
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
async recover(projectId) {
|
|
1020
|
+
for (const pending of this.control.view(s => values(s.pending).filter(p => p.projectId === projectId))) {
|
|
1021
|
+
const result = await this.content.resolve(pending);
|
|
1022
|
+
if (result.state === 'unknown')
|
|
1023
|
+
return;
|
|
1024
|
+
this.finalize(pending, result.state === 'committed' ? result.result : undefined);
|
|
1025
|
+
}
|
|
1026
|
+
this.control.update(s => { s.projects[projectId].recovering = false; this.promote(s); });
|
|
1027
|
+
}
|
|
1028
|
+
inspect() { this.sweep(); return this.control.view(s => ({ epoch: this.epoch, projects: values(s.projects), floors: values(s.requests).map(r => ({ ...this.statusIn(s, r), projectId: r.projectId, floorId: r.floorId, identityId: r.identityId, instanceId: r.instanceId })) })); }
|
|
1029
|
+
hasPendingWork() { return this.control.view(s => values(s.requests).some(r => activeStates.includes(r.state)) || values(s.pending).length > 0 || values(s.projects).some(p => p.recovering)); }
|
|
1030
|
+
forceRelease(floorId) {
|
|
1031
|
+
this.sweep();
|
|
1032
|
+
this.control.update(state => {
|
|
1033
|
+
const request = values(state.requests).find(r => r.floorId === floorId);
|
|
1034
|
+
requireThat(request?.state === 'HELD', 'NOT_HELD', 'Only a held floor can be force-released; committing writes are protected.');
|
|
1035
|
+
this.finish(request, 'RELEASED', state);
|
|
1036
|
+
this.promote(state);
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
1039
|
+
async call(handle, name, input, signal, options = {}) {
|
|
1040
|
+
requireThat(Object.hasOwn(schemas, name), 'UNKNOWN_TOOL', 'Unknown Bassfish operation.');
|
|
1041
|
+
const parsed = schemas[name].safeParse(input);
|
|
1042
|
+
requireThat(parsed.success, 'INVALID_ARGUMENT', 'Arguments do not match the operation schema.');
|
|
1043
|
+
this.heartbeat(handle);
|
|
1044
|
+
const args = parsed.data;
|
|
1045
|
+
switch (name) {
|
|
1046
|
+
case 'getSession': return this.info(handle);
|
|
1047
|
+
case 'setAgentName': return this.requestName(handle, args.name);
|
|
1048
|
+
case 'listAgents': return this.control.view(s => { const actor = this.actor(s, handle); return { agents: values(s.identities).filter(i => i.projectId === actor.projectId).map(i => ({ identityId: i.id, name: i.name, active: values(s.instances).some(a => a.identityId === i.id && a.active) })) }; });
|
|
1049
|
+
case 'createThread': return this.createThread(handle, args.title, args.description);
|
|
1050
|
+
case 'listThreads': {
|
|
1051
|
+
const actor = this.control.view(s => { const a = this.actor(s, handle); this.ready(s, a.projectId); return a; });
|
|
1052
|
+
const cursor = decodeCursor(args.cursor);
|
|
1053
|
+
let threads = sortThreads(await this.content.listThreads(actor.projectId)).filter(thread => thread.state === args.state);
|
|
1054
|
+
if (args.creatorIdentityId)
|
|
1055
|
+
threads = threads.filter(thread => thread.creator === args.creatorIdentityId);
|
|
1056
|
+
if (args.titlePrefix)
|
|
1057
|
+
threads = threads.filter(thread => thread.title.startsWith(args.titlePrefix));
|
|
1058
|
+
if (cursor)
|
|
1059
|
+
threads = threads.filter(thread => thread.createdAt > cursor[0] || (thread.createdAt === cursor[0] && thread.id > cursor[1]));
|
|
1060
|
+
const limit = args.limit;
|
|
1061
|
+
const page = threads.slice(0, limit);
|
|
1062
|
+
const last = page.at(-1);
|
|
1063
|
+
return { threads: page, nextCursor: threads.length > limit && last ? encodeCursor([last.createdAt, last.id]) : null };
|
|
1064
|
+
}
|
|
1065
|
+
case 'getThread': {
|
|
1066
|
+
const actor = this.control.view(s => { const a = this.actor(s, handle); this.ready(s, a.projectId); return a; });
|
|
1067
|
+
const thread = (await this.content.listThreads(actor.projectId)).find(value => value.id === args.threadId);
|
|
1068
|
+
requireThat(thread, 'NOT_FOUND', 'Thread not found in this project.');
|
|
1069
|
+
return thread;
|
|
1070
|
+
}
|
|
1071
|
+
case 'searchThreads': {
|
|
1072
|
+
const actor = this.control.view(s => { const a = this.actor(s, handle); this.ready(s, a.projectId); return a; });
|
|
1073
|
+
const query = String(args.query).toLowerCase();
|
|
1074
|
+
const threads = sortThreads(await this.content.listThreads(actor.projectId)).filter(thread => thread.state === args.state && [thread.title, thread.description].some(value => value.toLowerCase().includes(query))).slice(0, args.limit);
|
|
1075
|
+
return { threads };
|
|
1076
|
+
}
|
|
1077
|
+
case 'createNote': return this.createNote(handle, args);
|
|
1078
|
+
case 'listNotes': {
|
|
1079
|
+
const actor = this.control.view(s => { const a = this.actor(s, handle); this.ready(s, a.projectId); return a; });
|
|
1080
|
+
const cursor = decodeCursor(args.cursor);
|
|
1081
|
+
let notes = (await this.content.listNotes(actor.projectId)).filter(note => note.state === args.state);
|
|
1082
|
+
if (args.pathPrefix)
|
|
1083
|
+
notes = notes.filter(note => note.path.startsWith(args.pathPrefix));
|
|
1084
|
+
if (args.label)
|
|
1085
|
+
notes = notes.filter(note => note.labels.includes(args.label));
|
|
1086
|
+
if (args.noteKind)
|
|
1087
|
+
notes = notes.filter(note => note.kind === args.noteKind);
|
|
1088
|
+
if (args.creatorIdentityId)
|
|
1089
|
+
notes = notes.filter(note => note.creator === args.creatorIdentityId);
|
|
1090
|
+
if (cursor)
|
|
1091
|
+
notes = notes.filter(note => note.path > cursor[0] || (note.path === cursor[0] && note.id > cursor[1]));
|
|
1092
|
+
const limit = args.limit;
|
|
1093
|
+
const page = notes.slice(0, limit);
|
|
1094
|
+
const last = page.at(-1);
|
|
1095
|
+
return { notes: page.map(note => this.noteMetadata(note)), nextCursor: notes.length > limit && last ? encodeCursor([last.path, last.id]) : null };
|
|
1096
|
+
}
|
|
1097
|
+
case 'searchNotes': {
|
|
1098
|
+
const actor = this.control.view(s => { const a = this.actor(s, handle); this.ready(s, a.projectId); return a; });
|
|
1099
|
+
const query = String(args.query).toLowerCase();
|
|
1100
|
+
const notes = (await this.content.listNotes(actor.projectId)).filter(note => note.state === args.state && [note.path, note.title, ...note.labels, note.kind ?? ''].some(value => value.toLowerCase().includes(query))).slice(0, args.limit);
|
|
1101
|
+
return { notes: notes.map(note => this.noteMetadata(note)) };
|
|
1102
|
+
}
|
|
1103
|
+
case 'requestFloor': {
|
|
1104
|
+
const target = args.target;
|
|
1105
|
+
const mode = options.taskCapable ? 'task' : 'ticket';
|
|
1106
|
+
const result = target.type === 'project' ? await this.requestProjectFloor(handle, target.purpose, mode) : await this.requestResourceFloor(handle, target.type, target.id, mode);
|
|
1107
|
+
const task = this.control.view(state => values(state.tasks).find(value => value.requestId === result.requestId));
|
|
1108
|
+
return task?.status === 'working' ? { task: this.taskView(task) } : result;
|
|
1109
|
+
}
|
|
1110
|
+
case 'getFloorRequest': return this.status(handle, args.requestId);
|
|
1111
|
+
case 'waitForFloor': return this.waitForFloor(handle, args.requestId, args.timeoutMs, signal);
|
|
1112
|
+
case 'cancelFloorRequest': return this.cancelFloorRequest(handle, args.requestId);
|
|
1113
|
+
case 'claimFloor': return this.claimFloor(handle, args.offerId, 20);
|
|
1114
|
+
case 'readFloor': {
|
|
1115
|
+
const credential = args.floor;
|
|
1116
|
+
return this.readFloor(handle, credential.id, credential.fencingToken, args.cursor);
|
|
1117
|
+
}
|
|
1118
|
+
case 'releaseFloor': {
|
|
1119
|
+
const credential = args.floor;
|
|
1120
|
+
return this.releaseFloor(handle, credential.id, credential.fencingToken);
|
|
1121
|
+
}
|
|
1122
|
+
case 'commitFloor': {
|
|
1123
|
+
const credential = args.floor;
|
|
1124
|
+
return this.commitFloor(handle, credential.id, credential.fencingToken, args.baseRevision, toMutation(args.mutation));
|
|
1125
|
+
}
|
|
1126
|
+
case 'getNoteOutline': {
|
|
1127
|
+
const credential = args.floor;
|
|
1128
|
+
return this.noteOutline(handle, credential.id, credential.fencingToken);
|
|
1129
|
+
}
|
|
1130
|
+
case 'findInNote': {
|
|
1131
|
+
const credential = args.floor;
|
|
1132
|
+
return this.findNote(handle, credential.id, credential.fencingToken, args.query, args.mode, args.limit);
|
|
1133
|
+
}
|
|
1134
|
+
case 'inspectSnapshot': {
|
|
1135
|
+
const credential = args.floor;
|
|
1136
|
+
return this.projectSnapshotInfo(handle, credential.id, credential.fencingToken);
|
|
1137
|
+
}
|
|
1138
|
+
case 'searchProjectNotes': {
|
|
1139
|
+
const credential = args.floor;
|
|
1140
|
+
return this.projectSearch(handle, credential.id, credential.fencingToken, args.query, args.states, args.limit, args.cursor);
|
|
1141
|
+
}
|
|
1142
|
+
case 'searchProjectNoteHistory': {
|
|
1143
|
+
const credential = args.floor;
|
|
1144
|
+
return this.projectHistorySearch(handle, credential.id, credential.fencingToken, args.query, args.noteId, args.states, args.limit, args.cursor);
|
|
1145
|
+
}
|
|
1146
|
+
case 'exportSnapshot': {
|
|
1147
|
+
const credential = args.floor;
|
|
1148
|
+
return this.exportProject(handle, credential.id, credential.fencingToken);
|
|
1149
|
+
}
|
|
1150
|
+
case 'listSnapshotHistory': {
|
|
1151
|
+
const credential = args.floor;
|
|
1152
|
+
return this.projectHistoryList(handle, credential.id, credential.fencingToken, args.limit, args.cursor);
|
|
1153
|
+
}
|
|
1154
|
+
case 'previewSnapshotRestore': {
|
|
1155
|
+
const credential = args.floor;
|
|
1156
|
+
return this.previewProjectRestore(handle, credential.id, credential.fencingToken, args.targetCommit, args.limit, args.cursor);
|
|
1157
|
+
}
|
|
1158
|
+
case 'restoreSnapshot': {
|
|
1159
|
+
const credential = args.floor;
|
|
1160
|
+
return this.restoreProject(handle, credential.id, credential.fencingToken, args.previewToken);
|
|
1161
|
+
}
|
|
1162
|
+
case 'listHistory': {
|
|
1163
|
+
const credential = args.floor;
|
|
1164
|
+
return this.resourceHistory(handle, credential.id, credential.fencingToken, args.offset, args.limit);
|
|
1165
|
+
}
|
|
1166
|
+
case 'readRevision': {
|
|
1167
|
+
const credential = args.floor;
|
|
1168
|
+
return this.resourceAt(handle, credential.id, credential.fencingToken, args.revision, 20, args.cursor);
|
|
1169
|
+
}
|
|
1170
|
+
case 'diffRevision': {
|
|
1171
|
+
const credential = args.floor;
|
|
1172
|
+
return this.diffResource(handle, credential.id, credential.fencingToken, args.revision);
|
|
1173
|
+
}
|
|
1174
|
+
case 'previewRestore': {
|
|
1175
|
+
const credential = args.floor;
|
|
1176
|
+
return this.previewRestore(handle, credential.id, credential.fencingToken, args.revision);
|
|
1177
|
+
}
|
|
1178
|
+
case 'restoreRevision': {
|
|
1179
|
+
const credential = args.floor;
|
|
1180
|
+
return this.restoreRevision(handle, credential.id, credential.fencingToken, args.previewToken);
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
//# sourceMappingURL=service.js.map
|