@rool-dev/sdk 0.11.15 → 0.12.0-dev.2daa829
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 +23 -48
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +2 -6
- package/dist/client.js.map +1 -1
- package/dist/graphql.d.ts +6 -11
- package/dist/graphql.d.ts.map +1 -1
- package/dist/graphql.js +4 -25
- package/dist/graphql.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/space-session.d.ts +17 -252
- package/dist/space-session.d.ts.map +1 -1
- package/dist/space-session.js +53 -758
- package/dist/space-session.js.map +1 -1
- package/dist/space.d.ts +106 -88
- package/dist/space.d.ts.map +1 -1
- package/dist/space.js +524 -216
- package/dist/space.js.map +1 -1
- package/dist/types.d.ts +1 -18
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/space.js
CHANGED
|
@@ -1,188 +1,537 @@
|
|
|
1
|
+
import { EventEmitter } from './event-emitter.js';
|
|
1
2
|
import { SpaceSubscriptionManager } from './subscription.js';
|
|
2
|
-
import {
|
|
3
|
-
import { RoolWebDAV } from './webdav.js';
|
|
4
|
-
import { machinePath } from './path.js';
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
3
|
+
import { ConversationHandle, generateEntityId } from './space-session.js';
|
|
4
|
+
import { RoolWebDAV, WebDAVError } from './webdav.js';
|
|
5
|
+
import { isObjectPath, machinePath, machineUri } from './path.js';
|
|
6
|
+
const GET_OBJECTS_CHUNK_SIZE = 500;
|
|
7
|
+
function objectPath(input) {
|
|
8
|
+
const path = machinePath(input);
|
|
9
|
+
if (!isObjectPath(path)) {
|
|
10
|
+
throw new Error(`Object path must be /space/<collection>/<name>.json without dotfiles: ${input}`);
|
|
11
|
+
}
|
|
12
|
+
return path;
|
|
13
|
+
}
|
|
14
|
+
function collectionPath(name) {
|
|
15
|
+
return machinePath(`/space/${name}`);
|
|
16
|
+
}
|
|
17
|
+
function schemaPath(name) {
|
|
18
|
+
return `${collectionPath(name)}/.schema.json`;
|
|
19
|
+
}
|
|
20
|
+
function objectFromBody(path, body) {
|
|
21
|
+
return { path, body };
|
|
22
|
+
}
|
|
23
|
+
function jsonObject(value, label) {
|
|
24
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
25
|
+
throw new Error(`${label} must be a JSON object`);
|
|
26
|
+
}
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
function patchBody(current, patch) {
|
|
30
|
+
const next = { ...current };
|
|
31
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
32
|
+
if (value === null || value === undefined)
|
|
33
|
+
delete next[key];
|
|
34
|
+
else
|
|
35
|
+
next[key] = value;
|
|
36
|
+
}
|
|
37
|
+
return next;
|
|
38
|
+
}
|
|
39
|
+
function collectionDef(input, options) {
|
|
40
|
+
const base = Array.isArray(input)
|
|
41
|
+
? { fields: input }
|
|
42
|
+
: { fields: input.fields, schemaOrgType: input.schemaOrgType };
|
|
43
|
+
const schemaOrgType = options?.schemaOrgType ?? base.schemaOrgType;
|
|
44
|
+
return schemaOrgType ? { fields: base.fields, schemaOrgType } : { fields: base.fields };
|
|
45
|
+
}
|
|
46
|
+
function attachmentBody(file) {
|
|
47
|
+
if (isFile(file)) {
|
|
48
|
+
return {
|
|
49
|
+
filename: safeAttachmentFilename(file.name, file.type),
|
|
50
|
+
contentType: file.type || 'application/octet-stream',
|
|
51
|
+
body: file,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
if (isBlob(file)) {
|
|
55
|
+
const contentType = file.type || 'application/octet-stream';
|
|
56
|
+
return {
|
|
57
|
+
filename: safeAttachmentFilename('attachment', contentType),
|
|
58
|
+
contentType,
|
|
59
|
+
body: file,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
filename: safeAttachmentFilename(file.filename ?? 'attachment', file.contentType),
|
|
64
|
+
contentType: file.contentType,
|
|
65
|
+
body: base64Body(file.data),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
function isFile(value) {
|
|
69
|
+
return typeof File !== 'undefined' && value instanceof File;
|
|
70
|
+
}
|
|
71
|
+
function isBlob(value) {
|
|
72
|
+
return typeof Blob !== 'undefined' && value instanceof Blob;
|
|
73
|
+
}
|
|
74
|
+
function safeAttachmentFilename(name, contentType) {
|
|
75
|
+
const fallback = `attachment.${extensionForContentType(contentType)}`;
|
|
76
|
+
const leaf = name.split(/[/\\]/).pop() || fallback;
|
|
77
|
+
const cleaned = leaf.replace(/[\x00-\x1f\x7f]/g, '').replace(/\s+/g, '_');
|
|
78
|
+
return cleaned.replace(/[^A-Za-z0-9._-]/g, '_').replace(/^\.+$/, '') || fallback;
|
|
79
|
+
}
|
|
80
|
+
function extensionForContentType(contentType) {
|
|
81
|
+
if (contentType === 'image/png')
|
|
82
|
+
return 'png';
|
|
83
|
+
if (contentType === 'image/jpeg')
|
|
84
|
+
return 'jpg';
|
|
85
|
+
if (contentType === 'image/gif')
|
|
86
|
+
return 'gif';
|
|
87
|
+
if (contentType === 'image/webp')
|
|
88
|
+
return 'webp';
|
|
89
|
+
if (contentType === 'image/svg+xml')
|
|
90
|
+
return 'svg';
|
|
91
|
+
if (contentType === 'application/pdf')
|
|
92
|
+
return 'pdf';
|
|
93
|
+
if (contentType === 'text/markdown')
|
|
94
|
+
return 'md';
|
|
95
|
+
if (contentType === 'text/plain')
|
|
96
|
+
return 'txt';
|
|
97
|
+
if (contentType === 'text/csv')
|
|
98
|
+
return 'csv';
|
|
99
|
+
if (contentType === 'text/html')
|
|
100
|
+
return 'html';
|
|
101
|
+
if (contentType === 'application/json')
|
|
102
|
+
return 'json';
|
|
103
|
+
if (contentType === 'application/xml')
|
|
104
|
+
return 'xml';
|
|
105
|
+
return 'bin';
|
|
106
|
+
}
|
|
107
|
+
function base64Body(data) {
|
|
108
|
+
const clean = data.includes(',') ? data.slice(data.indexOf(',') + 1) : data;
|
|
109
|
+
if (typeof Buffer !== 'undefined') {
|
|
110
|
+
const buffer = Buffer.from(clean, 'base64');
|
|
111
|
+
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
|
|
112
|
+
}
|
|
113
|
+
const binary = atob(clean);
|
|
114
|
+
const bytes = new Uint8Array(binary.length);
|
|
115
|
+
for (let i = 0; i < binary.length; i++)
|
|
116
|
+
bytes[i] = binary.charCodeAt(i);
|
|
117
|
+
return bytes.buffer;
|
|
118
|
+
}
|
|
119
|
+
/** An imperative API handle for one open space. */
|
|
120
|
+
export class RoolSpace extends EventEmitter {
|
|
121
|
+
_id;
|
|
122
|
+
_openSpaceResult;
|
|
123
|
+
_connectionState = 'reconnecting';
|
|
124
|
+
_closed = false;
|
|
125
|
+
_graphqlClient;
|
|
126
|
+
_restClient;
|
|
127
|
+
_webdav;
|
|
128
|
+
_logger;
|
|
15
129
|
authManager;
|
|
16
130
|
router;
|
|
17
131
|
_route;
|
|
18
132
|
onCloseCallback;
|
|
19
133
|
clientInfo;
|
|
20
|
-
// Subscription
|
|
21
134
|
subscriptionManager = null;
|
|
22
|
-
_resyncing = false;
|
|
23
|
-
_resyncPending = false;
|
|
24
|
-
_resyncTimer = null;
|
|
25
|
-
_subscriptionReady = null;
|
|
26
135
|
constructor(config) {
|
|
27
|
-
|
|
28
|
-
|
|
136
|
+
super();
|
|
137
|
+
this._id = config.id;
|
|
138
|
+
this._openSpaceResult = config.openSpaceResult;
|
|
139
|
+
this._graphqlClient = config.graphqlClient;
|
|
140
|
+
this._restClient = config.restClient;
|
|
141
|
+
this._logger = config.logger;
|
|
142
|
+
this._emitterLogger = config.logger;
|
|
143
|
+
this.authManager = config.authManager;
|
|
144
|
+
this.router = config.router;
|
|
145
|
+
this._route = config.initialRoute;
|
|
146
|
+
this.onCloseCallback = config.onClose;
|
|
147
|
+
this.clientInfo = config.clientInfo;
|
|
148
|
+
this._webdav = new RoolWebDAV({
|
|
29
149
|
webdavUrl: config.initialRoute.server,
|
|
30
150
|
spaceId: config.id,
|
|
31
151
|
authManager: config.authManager,
|
|
32
152
|
clientInfo: config.clientInfo,
|
|
33
153
|
onRefused: async () => {
|
|
34
|
-
await
|
|
35
|
-
return
|
|
154
|
+
await this.reroute();
|
|
155
|
+
return this._route.server;
|
|
36
156
|
},
|
|
37
157
|
});
|
|
38
|
-
super({
|
|
39
|
-
id: config.id,
|
|
40
|
-
name: config.name,
|
|
41
|
-
role: config.role,
|
|
42
|
-
userId: config.userId,
|
|
43
|
-
conversationMeta: config.fullData.conversationMeta,
|
|
44
|
-
graphqlClient: config.graphqlClient,
|
|
45
|
-
restClient: config.restClient,
|
|
46
|
-
webdav,
|
|
47
|
-
logger: config.logger,
|
|
48
|
-
onClose: () => { },
|
|
49
|
-
});
|
|
50
|
-
this.clientInfo = config.clientInfo;
|
|
51
|
-
self = this;
|
|
52
|
-
this._memberCount = config.memberCount;
|
|
53
|
-
this.authManager = config.authManager;
|
|
54
|
-
this.router = config.router;
|
|
55
|
-
this._route = config.initialRoute;
|
|
56
|
-
this.onCloseCallback = config.onClose;
|
|
57
158
|
this._graphqlClient.setOnRefused(() => this.reroute());
|
|
58
159
|
this._restClient.setOnRefused(async () => {
|
|
59
160
|
await this.reroute();
|
|
60
161
|
return this._route.server;
|
|
61
162
|
});
|
|
62
|
-
// Start subscription
|
|
63
163
|
this.startSubscription();
|
|
64
164
|
}
|
|
65
165
|
get id() { return this._id; }
|
|
66
|
-
get name() { return this.
|
|
67
|
-
get role() { return this.
|
|
68
|
-
get
|
|
69
|
-
get
|
|
70
|
-
get
|
|
71
|
-
get
|
|
72
|
-
get memberCount() { return this._memberCount; }
|
|
166
|
+
get name() { return this._openSpaceResult.name; }
|
|
167
|
+
get role() { return this._openSpaceResult.role; }
|
|
168
|
+
get isReadOnly() { return this.role === 'viewer'; }
|
|
169
|
+
get memberCount() { return this._openSpaceResult.memberCount; }
|
|
170
|
+
get openSpaceResult() { return this._openSpaceResult; }
|
|
171
|
+
get connectionState() { return this._connectionState; }
|
|
73
172
|
get route() { return this._route; }
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
/** Return file-storage quota usage for this space. */
|
|
79
|
-
async getStorageUsage() {
|
|
80
|
-
return this.webdav.getStorageUsage();
|
|
81
|
-
}
|
|
82
|
-
/** Fetch a user-visible file path through the current space. */
|
|
83
|
-
async fetchPath(path, options) {
|
|
84
|
-
const canonical = machinePath(path);
|
|
85
|
-
if (!canonical.startsWith('/rool-drive/'))
|
|
86
|
-
throw new Error('Path is not a fetchable file');
|
|
87
|
-
return this.webdav.get(canonical, options);
|
|
88
|
-
}
|
|
89
|
-
/** Lightweight conversation roster (no interaction bodies). */
|
|
90
|
-
get conversations() { return this._conversationMeta; }
|
|
91
|
-
startSubscription() {
|
|
92
|
-
let firstProbe = true;
|
|
93
|
-
this.subscriptionManager = new SpaceSubscriptionManager({
|
|
94
|
-
getGraphqlUrl: async () => {
|
|
95
|
-
if (firstProbe) {
|
|
96
|
-
firstProbe = false;
|
|
97
|
-
return this._graphqlClient.graphqlUrl;
|
|
98
|
-
}
|
|
99
|
-
return this.reroute();
|
|
100
|
-
},
|
|
101
|
-
authManager: this.authManager,
|
|
102
|
-
logger: this._logger,
|
|
103
|
-
clientInfo: this.clientInfo,
|
|
104
|
-
spaceId: this._id,
|
|
105
|
-
onEvent: (event) => this.handleSpaceEvent(event),
|
|
106
|
-
onConnectionStateChanged: (state) => {
|
|
107
|
-
this.emit('connectionStateChanged', state);
|
|
108
|
-
},
|
|
109
|
-
onError: (error) => {
|
|
110
|
-
this._logger.error(`[RoolSpace] Space ${this._id} subscription error:`, error);
|
|
111
|
-
},
|
|
112
|
-
});
|
|
113
|
-
this._subscriptionReady = this.subscriptionManager.subscribe();
|
|
114
|
-
// .catch prevents a rejection here from crashing Node before the caller awaits it.
|
|
115
|
-
this._subscriptionReady.catch(() => { });
|
|
173
|
+
get webdav() { return this._webdav; }
|
|
174
|
+
async refresh() {
|
|
175
|
+
this._openSpaceResult = await this._graphqlClient.openSpace(this._id);
|
|
176
|
+
return this._openSpaceResult;
|
|
116
177
|
}
|
|
117
|
-
async
|
|
118
|
-
|
|
119
|
-
this._route = route;
|
|
120
|
-
this._webdav.setWebDAVUrl(route.server);
|
|
121
|
-
this._restClient.setApiUrl(route.server);
|
|
122
|
-
const url = `${route.server.replace(/\/+$/, '')}/graphql`;
|
|
123
|
-
this._graphqlClient.setGraphqlUrl(url);
|
|
124
|
-
return url;
|
|
178
|
+
async listConversations() {
|
|
179
|
+
return (await this.refresh()).conversationMeta;
|
|
125
180
|
}
|
|
126
|
-
/** Get a handle for a conversation in this space. */
|
|
127
181
|
conversation(conversationId) {
|
|
128
182
|
if (!conversationId || conversationId.length > 32 || !/^[a-zA-Z0-9_-]+$/.test(conversationId)) {
|
|
129
183
|
throw new Error('conversationId must be 1–32 characters containing only alphanumeric characters, hyphens, and underscores');
|
|
130
184
|
}
|
|
131
|
-
return
|
|
185
|
+
return new ConversationHandle(this, conversationId);
|
|
186
|
+
}
|
|
187
|
+
async getConversation(conversationId) {
|
|
188
|
+
return this._graphqlClient.getConversation(this._id, conversationId);
|
|
189
|
+
}
|
|
190
|
+
async deleteConversation(conversationId) {
|
|
191
|
+
await this._graphqlClient.deleteConversation(this._id, conversationId);
|
|
192
|
+
}
|
|
193
|
+
async createConversation(agent, visibility) {
|
|
194
|
+
return this._graphqlClient.createConversation(this._id, agent, visibility);
|
|
195
|
+
}
|
|
196
|
+
async listAgents() {
|
|
197
|
+
return this._graphqlClient.listAgents(this._id);
|
|
198
|
+
}
|
|
199
|
+
async deleteAgent(agent) {
|
|
200
|
+
await this._graphqlClient.deleteAgent(this._id, agent);
|
|
201
|
+
}
|
|
202
|
+
async canUndo() {
|
|
203
|
+
return (await this._graphqlClient.checkpointStatus(this._id)).canUndo;
|
|
204
|
+
}
|
|
205
|
+
async canRedo() {
|
|
206
|
+
return (await this._graphqlClient.checkpointStatus(this._id)).canRedo;
|
|
207
|
+
}
|
|
208
|
+
async undo() {
|
|
209
|
+
return (await this._graphqlClient.undo(this._id)).success;
|
|
210
|
+
}
|
|
211
|
+
async redo() {
|
|
212
|
+
return (await this._graphqlClient.redo(this._id)).success;
|
|
213
|
+
}
|
|
214
|
+
davHeaders(interactionId) {
|
|
215
|
+
const headers = new Headers();
|
|
216
|
+
if (interactionId)
|
|
217
|
+
headers.set('X-Rool-Interaction-Id', interactionId);
|
|
218
|
+
return headers;
|
|
219
|
+
}
|
|
220
|
+
async readObject(path) {
|
|
221
|
+
const canonical = objectPath(path);
|
|
222
|
+
try {
|
|
223
|
+
const response = await this._webdav.get(canonical);
|
|
224
|
+
const body = jsonObject(await response.json(), `Object ${canonical}`);
|
|
225
|
+
return { object: objectFromBody(canonical, body), etag: response.headers.get('ETag') };
|
|
226
|
+
}
|
|
227
|
+
catch (error) {
|
|
228
|
+
if (error instanceof WebDAVError && error.status === 404)
|
|
229
|
+
return undefined;
|
|
230
|
+
if (error instanceof SyntaxError)
|
|
231
|
+
throw new Error(`Object ${canonical} did not contain valid JSON`);
|
|
232
|
+
throw error;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
/** Get an object JSON file by machine path. Fetches from the server on each call. */
|
|
236
|
+
async getObject(path) {
|
|
237
|
+
return (await this.readObject(path))?.object;
|
|
238
|
+
}
|
|
239
|
+
/** Get object JSON files by machine path in bulk. Duplicate paths are fetched once. */
|
|
240
|
+
async getObjects(paths) {
|
|
241
|
+
const canonical = [];
|
|
242
|
+
const seen = new Set();
|
|
243
|
+
for (const path of paths) {
|
|
244
|
+
const normalized = objectPath(path);
|
|
245
|
+
if (seen.has(normalized))
|
|
246
|
+
continue;
|
|
247
|
+
seen.add(normalized);
|
|
248
|
+
canonical.push(normalized);
|
|
249
|
+
}
|
|
250
|
+
const result = { objects: [], missing: [] };
|
|
251
|
+
for (let i = 0; i < canonical.length; i += GET_OBJECTS_CHUNK_SIZE) {
|
|
252
|
+
const chunk = canonical.slice(i, i + GET_OBJECTS_CHUNK_SIZE);
|
|
253
|
+
const partial = await this._restClient.getObjects(this._id, chunk);
|
|
254
|
+
result.objects.push(...partial.objects);
|
|
255
|
+
result.missing.push(...partial.missing);
|
|
256
|
+
}
|
|
257
|
+
return result;
|
|
258
|
+
}
|
|
259
|
+
/** Create or replace an object JSON file. */
|
|
260
|
+
async putObject(path, body) {
|
|
261
|
+
const canonical = objectPath(path);
|
|
262
|
+
const optimistic = objectFromBody(canonical, body);
|
|
263
|
+
try {
|
|
264
|
+
const interactionId = generateEntityId();
|
|
265
|
+
await this._webdav.put(canonical, JSON.stringify(body), {
|
|
266
|
+
contentType: 'application/json',
|
|
267
|
+
headers: this.davHeaders(interactionId),
|
|
268
|
+
});
|
|
269
|
+
const fresh = await this.getObject(canonical) ?? optimistic;
|
|
270
|
+
return { object: fresh, message: `Put ${canonical}` };
|
|
271
|
+
}
|
|
272
|
+
catch (error) {
|
|
273
|
+
this._logger.error('[RoolSpace] Failed to put object:', error);
|
|
274
|
+
this.emit('syncError', error instanceof Error ? error : new Error(String(error)));
|
|
275
|
+
throw error;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
/** Patch an existing object JSON file. */
|
|
279
|
+
async patchObject(path, options) {
|
|
280
|
+
const canonical = objectPath(path);
|
|
281
|
+
const data = options.data ?? {};
|
|
282
|
+
const current = await this.readObject(canonical);
|
|
283
|
+
if (!current)
|
|
284
|
+
throw new Error(`Object ${canonical} not found`);
|
|
285
|
+
const body = patchBody(current.object.body, data);
|
|
286
|
+
const optimistic = objectFromBody(canonical, body);
|
|
287
|
+
try {
|
|
288
|
+
const interactionId = generateEntityId();
|
|
289
|
+
await this._webdav.put(canonical, JSON.stringify(body), {
|
|
290
|
+
contentType: 'application/json',
|
|
291
|
+
ifMatch: current.etag ?? undefined,
|
|
292
|
+
headers: this.davHeaders(interactionId),
|
|
293
|
+
});
|
|
294
|
+
const fresh = await this.getObject(canonical) ?? optimistic;
|
|
295
|
+
return { object: fresh, message: `Patched ${canonical}` };
|
|
296
|
+
}
|
|
297
|
+
catch (error) {
|
|
298
|
+
this._logger.error('[RoolSpace] Failed to patch object:', error);
|
|
299
|
+
this.emit('syncError', error instanceof Error ? error : new Error(String(error)));
|
|
300
|
+
throw error;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
/** Move (rename/relocate) an object. */
|
|
304
|
+
async moveObject(from, to, options) {
|
|
305
|
+
const fromPath = objectPath(from);
|
|
306
|
+
const toPath = objectPath(to);
|
|
307
|
+
const optimistic = objectFromBody(toPath, options?.body ?? {});
|
|
308
|
+
try {
|
|
309
|
+
const interactionId = generateEntityId();
|
|
310
|
+
await this._webdav.move(fromPath, toPath, {
|
|
311
|
+
headers: this.davHeaders(interactionId),
|
|
312
|
+
});
|
|
313
|
+
if (options?.body) {
|
|
314
|
+
await this._webdav.put(toPath, JSON.stringify(options.body), {
|
|
315
|
+
contentType: 'application/json',
|
|
316
|
+
headers: this.davHeaders(interactionId),
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
const fresh = await this.getObject(toPath) ?? optimistic;
|
|
320
|
+
return { object: fresh, message: `Moved ${fromPath} to ${toPath}` };
|
|
321
|
+
}
|
|
322
|
+
catch (error) {
|
|
323
|
+
this._logger.error('[RoolSpace] Failed to move object:', error);
|
|
324
|
+
this.emit('syncError', error instanceof Error ? error : new Error(String(error)));
|
|
325
|
+
throw error;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
/** Delete object JSON files by path. */
|
|
329
|
+
async deleteObjects(paths) {
|
|
330
|
+
if (paths.length === 0)
|
|
331
|
+
return;
|
|
332
|
+
const canonical = paths.map(objectPath);
|
|
333
|
+
try {
|
|
334
|
+
const interactionId = generateEntityId();
|
|
335
|
+
for (const path of canonical) {
|
|
336
|
+
await this._webdav.delete(path, {
|
|
337
|
+
headers: this.davHeaders(interactionId),
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
catch (error) {
|
|
342
|
+
this._logger.error('[RoolSpace] Failed to delete paths:', error);
|
|
343
|
+
this.emit('syncError', error instanceof Error ? error : new Error(String(error)));
|
|
344
|
+
throw error;
|
|
345
|
+
}
|
|
132
346
|
}
|
|
133
347
|
/**
|
|
134
|
-
*
|
|
348
|
+
* Read space metadata from `/space/.meta.json`. Returns `{}` when the space has
|
|
349
|
+
* no metadata file yet. Stateless — callers (e.g. a reactive wrapper) cache and
|
|
350
|
+
* re-fetch this on their own schedule, typically when a file-tree sync reports
|
|
351
|
+
* the node changed.
|
|
135
352
|
*/
|
|
136
|
-
async
|
|
137
|
-
|
|
138
|
-
|
|
353
|
+
async readMeta() {
|
|
354
|
+
try {
|
|
355
|
+
const response = await this._webdav.get('/space/.meta.json');
|
|
356
|
+
return jsonObject(await response.json(), 'space meta');
|
|
357
|
+
}
|
|
358
|
+
catch (error) {
|
|
359
|
+
if (error instanceof WebDAVError && error.status === 404)
|
|
360
|
+
return {};
|
|
361
|
+
throw error;
|
|
362
|
+
}
|
|
139
363
|
}
|
|
140
364
|
/**
|
|
141
|
-
*
|
|
365
|
+
* Write the full metadata blob to `/space/.meta.json`, attributed to a
|
|
366
|
+
* conversation. Callers compose the blob (e.g. read-merge-write) — this does no
|
|
367
|
+
* merging.
|
|
142
368
|
*/
|
|
143
|
-
async
|
|
144
|
-
await this.
|
|
369
|
+
async writeMeta(meta) {
|
|
370
|
+
await this._webdav.put('/space/.meta.json', JSON.stringify(meta), {
|
|
371
|
+
contentType: 'application/json',
|
|
372
|
+
headers: this.davHeaders(generateEntityId()),
|
|
373
|
+
});
|
|
145
374
|
}
|
|
146
375
|
/**
|
|
147
|
-
*
|
|
376
|
+
* Read the collection schema: one `/space/<name>/.schema.json` per collection
|
|
377
|
+
* directory under `/space`. Returns `{}` for a space with no collections.
|
|
378
|
+
* Stateless — reactive callers re-fetch when a `.schema.json` node changes.
|
|
148
379
|
*/
|
|
149
|
-
async
|
|
150
|
-
|
|
380
|
+
async readSchema() {
|
|
381
|
+
const listing = await this._webdav.propfind('/space', { depth: '1', props: ['resourcetype'] });
|
|
382
|
+
const collections = listing.responses
|
|
383
|
+
.filter((r) => r.isCollection && r.path !== '/space')
|
|
384
|
+
.map((r) => r.path.split('/').pop());
|
|
385
|
+
const entries = await Promise.all(collections.map(async (name) => {
|
|
386
|
+
try {
|
|
387
|
+
const response = await this._webdav.get(`/space/${name}/.schema.json`);
|
|
388
|
+
return [name, jsonObject(await response.json(), `schema ${name}`)];
|
|
389
|
+
}
|
|
390
|
+
catch (error) {
|
|
391
|
+
if (error instanceof WebDAVError && error.status === 404)
|
|
392
|
+
return null;
|
|
393
|
+
throw error;
|
|
394
|
+
}
|
|
395
|
+
}));
|
|
396
|
+
const schema = {};
|
|
397
|
+
for (const entry of entries)
|
|
398
|
+
if (entry)
|
|
399
|
+
schema[entry[0]] = entry[1];
|
|
400
|
+
return schema;
|
|
401
|
+
}
|
|
402
|
+
/** Create a new collection (MKCOL + schema JSON). */
|
|
403
|
+
async createCollection(name, fields, options) {
|
|
404
|
+
const def = collectionDef(fields, options);
|
|
405
|
+
await this._webdav.mkcol(collectionPath(name), { headers: this.davHeaders(generateEntityId()) });
|
|
406
|
+
await this._webdav.put(schemaPath(name), JSON.stringify(def), {
|
|
407
|
+
contentType: 'application/json',
|
|
408
|
+
headers: this.davHeaders(generateEntityId()),
|
|
409
|
+
});
|
|
410
|
+
return def;
|
|
411
|
+
}
|
|
412
|
+
/** Alter an existing collection's schema JSON. */
|
|
413
|
+
async alterCollection(name, fields, options) {
|
|
414
|
+
const def = collectionDef(fields, options);
|
|
415
|
+
await this._webdav.put(schemaPath(name), JSON.stringify(def), {
|
|
416
|
+
contentType: 'application/json',
|
|
417
|
+
headers: this.davHeaders(generateEntityId()),
|
|
418
|
+
});
|
|
419
|
+
return def;
|
|
420
|
+
}
|
|
421
|
+
/** Drop a collection (DELETE). */
|
|
422
|
+
async dropCollection(name) {
|
|
423
|
+
await this._webdav.delete(collectionPath(name), { collection: true, headers: this.davHeaders(generateEntityId()) });
|
|
424
|
+
}
|
|
425
|
+
/** @internal */
|
|
426
|
+
async _updateConversation(conversationId, options) {
|
|
427
|
+
await this._graphqlClient.updateConversation(this._id, conversationId, options);
|
|
428
|
+
}
|
|
429
|
+
/** @internal */
|
|
430
|
+
async _prompt(prompt, conversationId, options) {
|
|
431
|
+
const { attachments, signal, interactionId = generateEntityId(), parentInteractionId = null, ...rest } = options ?? {};
|
|
432
|
+
let attachmentRefs;
|
|
433
|
+
if (attachments?.length) {
|
|
434
|
+
attachmentRefs = await Promise.all(attachments.map(async (attachment) => {
|
|
435
|
+
const path = typeof attachment === 'string' ? machinePath(attachment) : await this.uploadAttachment(attachment, conversationId);
|
|
436
|
+
return machineUri(path);
|
|
437
|
+
}));
|
|
438
|
+
}
|
|
439
|
+
let onAbort;
|
|
440
|
+
if (signal) {
|
|
441
|
+
if (signal.aborted) {
|
|
442
|
+
this.stopConversation(conversationId).catch(() => { });
|
|
443
|
+
}
|
|
444
|
+
else {
|
|
445
|
+
onAbort = () => {
|
|
446
|
+
this.stopConversation(conversationId).catch(() => { });
|
|
447
|
+
};
|
|
448
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
let result;
|
|
452
|
+
try {
|
|
453
|
+
result = await this._graphqlClient.prompt(this._id, prompt, conversationId, {
|
|
454
|
+
...rest,
|
|
455
|
+
attachmentRefs,
|
|
456
|
+
interactionId,
|
|
457
|
+
parentInteractionId,
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
finally {
|
|
461
|
+
if (onAbort)
|
|
462
|
+
signal.removeEventListener('abort', onAbort);
|
|
463
|
+
}
|
|
464
|
+
const objects = [];
|
|
465
|
+
const fetched = await Promise.all(result.modifiedObjectPaths.map((path) => this.getObject(path)));
|
|
466
|
+
for (const object of fetched) {
|
|
467
|
+
if (object)
|
|
468
|
+
objects.push(object);
|
|
469
|
+
}
|
|
470
|
+
return {
|
|
471
|
+
message: result.message,
|
|
472
|
+
objects,
|
|
473
|
+
creditsUsed: result.creditsUsed,
|
|
474
|
+
};
|
|
151
475
|
}
|
|
152
476
|
/**
|
|
153
|
-
*
|
|
477
|
+
* Stop whatever is running in a conversation. A conversation processes one
|
|
478
|
+
* run at a time, so no interaction handle is needed.
|
|
479
|
+
*
|
|
480
|
+
* Returns whether anything was actually running.
|
|
154
481
|
*/
|
|
155
|
-
async
|
|
156
|
-
return this._graphqlClient.
|
|
482
|
+
async stopConversation(conversationId) {
|
|
483
|
+
return this._graphqlClient.stopConversation(this._id, conversationId);
|
|
157
484
|
}
|
|
158
485
|
/**
|
|
159
|
-
*
|
|
486
|
+
* Fetch an external URL via the server proxy, bypassing CORS restrictions.
|
|
160
487
|
*/
|
|
488
|
+
async fetch(url, init) {
|
|
489
|
+
return this._restClient.proxyFetch(this._id, url, init);
|
|
490
|
+
}
|
|
491
|
+
async uploadAttachment(file, conversationId) {
|
|
492
|
+
const attachment = attachmentBody(file);
|
|
493
|
+
const path = `/rool-drive/attachments/${conversationId}/${attachment.filename}`;
|
|
494
|
+
// createParents avoids racing MKCOLs when multiple attachments upload concurrently.
|
|
495
|
+
await this._webdav.put(path, attachment.body, {
|
|
496
|
+
contentType: attachment.contentType,
|
|
497
|
+
createParents: true,
|
|
498
|
+
});
|
|
499
|
+
return path;
|
|
500
|
+
}
|
|
501
|
+
async getStorageUsage() {
|
|
502
|
+
return this._webdav.getStorageUsage();
|
|
503
|
+
}
|
|
504
|
+
async fetchPath(path, options) {
|
|
505
|
+
const canonical = machinePath(path);
|
|
506
|
+
if (!canonical.startsWith('/rool-drive/'))
|
|
507
|
+
throw new Error('Path is not a fetchable file');
|
|
508
|
+
return this._webdav.get(canonical, options);
|
|
509
|
+
}
|
|
510
|
+
async rename(newName) {
|
|
511
|
+
await this._graphqlClient.renameSpace(this._id, newName);
|
|
512
|
+
this._openSpaceResult = { ...this._openSpaceResult, name: newName };
|
|
513
|
+
}
|
|
514
|
+
async delete() {
|
|
515
|
+
await this._graphqlClient.deleteSpace(this._id);
|
|
516
|
+
}
|
|
517
|
+
async listUsers() {
|
|
518
|
+
return this._graphqlClient.listSpaceUsers(this._id);
|
|
519
|
+
}
|
|
520
|
+
async setUserRole(userId, role) {
|
|
521
|
+
await this._graphqlClient.setSpaceUserRole(this._id, userId, role);
|
|
522
|
+
}
|
|
161
523
|
async removeUser(userId) {
|
|
162
|
-
|
|
524
|
+
await this._graphqlClient.removeSpaceUser(this._id, userId);
|
|
163
525
|
}
|
|
164
|
-
/**
|
|
165
|
-
* Mint an invite link for this space. Requires owner or admin role.
|
|
166
|
-
* With `email` set, the invite is single-use, guarded to that address,
|
|
167
|
-
* and sent to it by mail. The returned `url` contains the secret token
|
|
168
|
-
* and is only available here.
|
|
169
|
-
*/
|
|
170
526
|
async createInvite(role, options) {
|
|
171
527
|
return this._graphqlClient.createSpaceInvite(this._id, role, options);
|
|
172
528
|
}
|
|
173
|
-
/**
|
|
174
|
-
* List this space's currently redeemable invites. Requires owner or admin role.
|
|
175
|
-
*/
|
|
176
529
|
async listInvites() {
|
|
177
530
|
return this._graphqlClient.listSpaceInvites(this._id);
|
|
178
531
|
}
|
|
179
|
-
/**
|
|
180
|
-
* Revoke an invite so its link stops working. Requires owner or admin role.
|
|
181
|
-
*/
|
|
182
532
|
async revokeInvite(inviteId) {
|
|
183
533
|
return this._graphqlClient.revokeSpaceInvite(this._id, inviteId);
|
|
184
534
|
}
|
|
185
|
-
// Targets the owning shard
|
|
186
535
|
async exportArchive() {
|
|
187
536
|
const tokens = await this.authManager.getTokens();
|
|
188
537
|
if (!tokens)
|
|
@@ -204,45 +553,53 @@ export class RoolSpace extends SpaceOperations {
|
|
|
204
553
|
}
|
|
205
554
|
return response.blob();
|
|
206
555
|
}
|
|
207
|
-
/**
|
|
208
|
-
* Refresh space data from the server.
|
|
209
|
-
* Updates name, role, conversation list, and all cached data.
|
|
210
|
-
*/
|
|
211
|
-
async refresh() {
|
|
212
|
-
const data = await this._graphqlClient.openSpaceFull(this._id);
|
|
213
|
-
this.applyFullData(data);
|
|
214
|
-
}
|
|
215
|
-
/**
|
|
216
|
-
* Close the space subscription.
|
|
217
|
-
*/
|
|
218
556
|
close() {
|
|
557
|
+
if (this._closed)
|
|
558
|
+
return;
|
|
219
559
|
this._closed = true;
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
this._resyncTimer = null;
|
|
223
|
-
}
|
|
224
|
-
// Stop subscription
|
|
225
|
-
if (this.subscriptionManager) {
|
|
226
|
-
this.subscriptionManager.destroy();
|
|
227
|
-
this.subscriptionManager = null;
|
|
228
|
-
this._subscriptionReady = null;
|
|
229
|
-
}
|
|
560
|
+
this.subscriptionManager?.destroy();
|
|
561
|
+
this.subscriptionManager = null;
|
|
230
562
|
this.removeAllListeners();
|
|
231
563
|
this.onCloseCallback();
|
|
232
564
|
}
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
565
|
+
startSubscription() {
|
|
566
|
+
let firstProbe = true;
|
|
567
|
+
this.subscriptionManager = new SpaceSubscriptionManager({
|
|
568
|
+
getGraphqlUrl: async () => {
|
|
569
|
+
if (firstProbe) {
|
|
570
|
+
firstProbe = false;
|
|
571
|
+
return this._graphqlClient.graphqlUrl;
|
|
572
|
+
}
|
|
573
|
+
return this.reroute();
|
|
574
|
+
},
|
|
575
|
+
authManager: this.authManager,
|
|
576
|
+
logger: this._logger,
|
|
577
|
+
clientInfo: this.clientInfo,
|
|
578
|
+
spaceId: this._id,
|
|
579
|
+
onEvent: (event) => this.handleSpaceEvent(event),
|
|
580
|
+
onConnectionStateChanged: (state) => {
|
|
581
|
+
this._connectionState = state;
|
|
582
|
+
this.emit('connectionStateChanged', state);
|
|
583
|
+
},
|
|
584
|
+
onError: (error) => this._logger.error(`[RoolSpace] Space ${this._id} subscription error:`, error),
|
|
585
|
+
});
|
|
586
|
+
void this.subscriptionManager.subscribe().catch((error) => {
|
|
587
|
+
if (!this._closed)
|
|
588
|
+
this._logger.error(`[RoolSpace] Space ${this._id} subscription failed:`, error);
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
async reroute() {
|
|
592
|
+
const route = await this.router.resolve(this._id);
|
|
593
|
+
this._route = route;
|
|
594
|
+
this._webdav.setWebDAVUrl(route.server);
|
|
595
|
+
this._restClient.setApiUrl(route.server);
|
|
596
|
+
const url = `${route.server.replace(/\/+$/, '')}/graphql`;
|
|
597
|
+
this._graphqlClient.setGraphqlUrl(url);
|
|
598
|
+
return url;
|
|
599
|
+
}
|
|
241
600
|
handleSpaceEvent(event) {
|
|
242
|
-
if (event.type === 'connected')
|
|
243
|
-
this.handleResync();
|
|
601
|
+
if (this._closed || event.type === 'connected')
|
|
244
602
|
return;
|
|
245
|
-
}
|
|
246
603
|
if (event.type === 'space_files_changed') {
|
|
247
604
|
this.emit('filesChanged', { spaceId: event.spaceId, source: event.source, timestamp: event.timestamp });
|
|
248
605
|
return;
|
|
@@ -251,63 +608,14 @@ export class RoolSpace extends SpaceOperations {
|
|
|
251
608
|
this.emit('filesReset', { spaceId: event.spaceId, source: event.source, timestamp: event.timestamp });
|
|
252
609
|
return;
|
|
253
610
|
}
|
|
254
|
-
|
|
255
|
-
}
|
|
256
|
-
// Reconnect resync: refetch conversation history and apply it. Retries until it
|
|
257
|
-
// lands — a single failure used to leave the client empty until reload.
|
|
258
|
-
// Single-flight: an event arriving mid-resync sets _resyncPending so we re-run
|
|
259
|
-
// once afterward, ensuring the final state reflects the latest server state.
|
|
260
|
-
handleResync() {
|
|
261
|
-
if (this._resyncing) {
|
|
262
|
-
this._resyncPending = true;
|
|
611
|
+
if (event.type !== 'conversation_updated' || !event.conversationId)
|
|
263
612
|
return;
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
this._resyncTimer = null;
|
|
270
|
-
if (this._closed) {
|
|
271
|
-
this._resyncing = false;
|
|
272
|
-
return;
|
|
273
|
-
}
|
|
274
|
-
void this._graphqlClient.openSpaceFull(this._id).then((result) => {
|
|
275
|
-
if (this._closed) {
|
|
276
|
-
this._resyncing = false;
|
|
277
|
-
return;
|
|
278
|
-
}
|
|
279
|
-
this.applyFullData(result);
|
|
280
|
-
this._logger.info(`[RoolSpace] Space ${this._id} resync complete`);
|
|
281
|
-
this._finishResync();
|
|
282
|
-
}).catch((error) => {
|
|
283
|
-
if (this._closed) {
|
|
284
|
-
this._resyncing = false;
|
|
285
|
-
return;
|
|
286
|
-
}
|
|
287
|
-
const ms = Math.min(1000 * 2 ** attempt, 30000);
|
|
288
|
-
this._logger.error(`[RoolSpace] Space ${this._id} resync failed (attempt ${attempt + 1}), retrying in ${ms}ms:`, error);
|
|
289
|
-
this._resyncTimer = setTimeout(() => this._resyncWithRetry(attempt + 1), ms);
|
|
613
|
+
this.emit('conversationUpdated', {
|
|
614
|
+
conversationId: event.conversationId,
|
|
615
|
+
conversation: event.conversation ?? null,
|
|
616
|
+
source: event.source === 'agent' ? 'remote_agent' : 'remote_user',
|
|
617
|
+
timestamp: event.timestamp,
|
|
290
618
|
});
|
|
291
619
|
}
|
|
292
|
-
// Clear in-flight flag; if an event arrived mid-resync, run one more pass.
|
|
293
|
-
_finishResync() {
|
|
294
|
-
this._resyncing = false;
|
|
295
|
-
if (this._resyncPending && !this._closed) {
|
|
296
|
-
this._resyncPending = false;
|
|
297
|
-
this.handleResync();
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
/**
|
|
301
|
-
* Apply resynced space data from the server (reconnect). Refetches the
|
|
302
|
-
* conversation roster and reloads any live conversation handles — we may have
|
|
303
|
-
* missed SSE updates while disconnected.
|
|
304
|
-
*/
|
|
305
|
-
applyFullData(data) {
|
|
306
|
-
this._name = data.name;
|
|
307
|
-
this._role = data.role;
|
|
308
|
-
this._memberCount = data.memberCount;
|
|
309
|
-
this._conversationMeta = data.conversationMeta;
|
|
310
|
-
this.emit('reset', { source: 'system' });
|
|
311
|
-
}
|
|
312
620
|
}
|
|
313
621
|
//# sourceMappingURL=space.js.map
|