@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/dist/space.js CHANGED
@@ -1,188 +1,537 @@
1
+ import { EventEmitter } from './event-emitter.js';
1
2
  import { SpaceSubscriptionManager } from './subscription.js';
2
- import { SpaceOperations } from './space-session.js';
3
- import { RoolWebDAV } from './webdav.js';
4
- import { machinePath } from './path.js';
5
- /**
6
- * A space is a container for objects, schema, metadata, and conversations.
7
- *
8
- * RoolSpace owns the real-time SSE subscription for the space and exposes
9
- * space-level object, schema, metadata, and AI operations.
10
- *
11
- * Call close() when done to stop the subscription.
12
- */
13
- export class RoolSpace extends SpaceOperations {
14
- _memberCount;
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
- let self;
28
- const webdav = new RoolWebDAV({
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 self.reroute();
35
- return self._route.server;
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._name; }
67
- get role() { return this._role; }
68
- get userId() { return this._userId; }
69
- get spaceId() { return this._id; }
70
- get spaceName() { return this._name; }
71
- get isReadOnly() { return this._role === 'viewer'; }
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
- /** WebDAV client for this space's user-visible files and object filesystem. */
75
- get webdav() {
76
- return this._webdav;
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 reroute() {
118
- const route = await this.router.resolve(this._id);
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 super.conversation(conversationId);
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
- * Rename this space.
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 rename(newName) {
137
- await this._graphqlClient.renameSpace(this._id, newName);
138
- this._name = newName;
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
- * Delete this space permanently. Cannot be undone.
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 delete() {
144
- await this._graphqlClient.deleteSpace(this._id);
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
- * List users with access to this space.
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 listUsers() {
150
- return this._graphqlClient.listSpaceUsers(this._id);
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
- * Change an existing member's role. New members join via invites.
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 setUserRole(userId, role) {
156
- return this._graphqlClient.setSpaceUserRole(this._id, userId, role);
482
+ async stopConversation(conversationId) {
483
+ return this._graphqlClient.stopConversation(this._id, conversationId);
157
484
  }
158
485
  /**
159
- * Remove a user from this space.
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
- return this._graphqlClient.removeSpaceUser(this._id, userId);
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
- if (this._resyncTimer) {
221
- clearTimeout(this._resyncTimer);
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
- * Handle a space event from the SSE subscription.
235
- *
236
- * Only a reconnect resyncs conversation history (we may have missed
237
- * `conversation_updated` events while disconnected). File changes are not a
238
- * conversation signal — they just notify file/WebDAV consumers to sync, which
239
- * the reactive file tree does at path granularity via `sync-collection`.
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
- this._handleEvent(event);
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
- this._resyncing = true;
266
- this._resyncWithRetry(0);
267
- }
268
- _resyncWithRetry(attempt) {
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