mcp-memory-bucket 0.10.11 → 0.10.13
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/client/assets/index-BuS9zM5d.js +2511 -0
- package/dist/client/index.html +1 -1
- package/dist/src/config.js +9 -4
- package/dist/src/memory/repository.js +89 -21
- package/dist/src/remote/folderfoo-client.js +120 -31
- package/dist/src/remote/remote-sync.js +40 -10
- package/dist/src/remote/shared-items.js +237 -0
- package/dist/src/server.js +14 -3
- package/dist/src/shared/bucket-share-tool.js +119 -0
- package/dist/src/skills/repository.js +61 -17
- package/dist/src/store/db.js +24 -0
- package/dist/src/store/search.js +10 -2
- package/dist/src/web/routes.js +393 -43
- package/package.json +1 -1
- package/dist/client/assets/index-BWDogxb1.js +0 -2306
package/dist/client/index.html
CHANGED
package/dist/src/config.js
CHANGED
|
@@ -13,10 +13,14 @@ function isRemoteEntry(entry) {
|
|
|
13
13
|
* get mistaken for one another (see identity.ts) - a mirror connected while
|
|
14
14
|
* logged into "dev" as "anatoli" lands at
|
|
15
15
|
* .memory-bucket-remote-cache/dev_anatoli/<name>/, distinct from the same
|
|
16
|
-
* folder name connected under "cloud" or a different username.
|
|
16
|
+
* folder name connected under "cloud" or a different username. `owner`, when
|
|
17
|
+
* this source is a folder someone ELSE shared (see RemoteFolder.owner),
|
|
18
|
+
* folds into the identity segment too - otherwise a shared folder connected
|
|
19
|
+
* under the same `name` as one of the caller's own folders would collide on
|
|
20
|
+
* the same mirror directory.
|
|
17
21
|
*/
|
|
18
|
-
export function mirrorDirFor(baseDir, mode, username, name) {
|
|
19
|
-
const identitySegment = sanitizeFolderName(`${mode}_${username}`);
|
|
22
|
+
export function mirrorDirFor(baseDir, mode, username, name, owner) {
|
|
23
|
+
const identitySegment = sanitizeFolderName(owner ? `${mode}_${username}_shared-by-${owner}` : `${mode}_${username}`);
|
|
20
24
|
return path.join(baseDir, '.memory-bucket-remote-cache', identitySegment, sanitizeFolderName(name));
|
|
21
25
|
}
|
|
22
26
|
function memoryDirFlag(argv) {
|
|
@@ -65,7 +69,7 @@ function resolveFolders(entries, baseDir) {
|
|
|
65
69
|
return { name: nameFromPath(entry), path: path.resolve(baseDir, entry) };
|
|
66
70
|
}
|
|
67
71
|
if (isRemoteEntry(entry)) {
|
|
68
|
-
const mirrorDir = mirrorDirFor(baseDir, entry.remote.mode, entry.remote.username, entry.name);
|
|
72
|
+
const mirrorDir = mirrorDirFor(baseDir, entry.remote.mode, entry.remote.username, entry.name, entry.remote.owner);
|
|
69
73
|
remoteFolders.push({ name: entry.name, ...entry.remote, mirrorDir });
|
|
70
74
|
return { name: entry.name, path: mirrorDir };
|
|
71
75
|
}
|
|
@@ -159,6 +163,7 @@ export function saveRemoteFolder(config, kind, entry) {
|
|
|
159
163
|
folderPath: entry.folderPath,
|
|
160
164
|
mode: entry.mode,
|
|
161
165
|
username: entry.username,
|
|
166
|
+
...(entry.owner ? { owner: entry.owner } : {}),
|
|
162
167
|
},
|
|
163
168
|
},
|
|
164
169
|
],
|
|
@@ -84,9 +84,9 @@ export class MemoryRepository {
|
|
|
84
84
|
const remote = this.remoteFor(folderName);
|
|
85
85
|
if (!remote || !this.credentialsBaseDir)
|
|
86
86
|
return;
|
|
87
|
-
await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, folderName);
|
|
87
|
+
await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, folderName, remote.owner);
|
|
88
88
|
const dirRelPath = joinRemoteFolderPath(remote.folderPath, path.relative(remote.mirrorDir, path.dirname(attachmentFilePath)));
|
|
89
|
-
await writeRemoteBinaryFile(remote.server, this.credentialsBaseDir, remote.tenantId, dirRelPath, path.basename(attachmentFilePath), data, mimeType);
|
|
89
|
+
await writeRemoteBinaryFile(remote.server, this.credentialsBaseDir, remote.tenantId, dirRelPath, path.basename(attachmentFilePath), data, mimeType, remote.owner);
|
|
90
90
|
}
|
|
91
91
|
/**
|
|
92
92
|
* Trashes one attachment's remote copy on folderfoo, if `folderName` resolves to a remote source
|
|
@@ -102,9 +102,9 @@ export class MemoryRepository {
|
|
|
102
102
|
const remote = this.remoteFor(folderName);
|
|
103
103
|
if (!remote || !this.credentialsBaseDir)
|
|
104
104
|
return;
|
|
105
|
-
await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, folderName);
|
|
105
|
+
await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, folderName, remote.owner);
|
|
106
106
|
const dirRelPath = joinRemoteFolderPath(remote.folderPath, path.relative(remote.mirrorDir, path.dirname(attachmentFilePath)));
|
|
107
|
-
await trashRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dirRelPath, path.basename(attachmentFilePath));
|
|
107
|
+
await trashRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dirRelPath, path.basename(attachmentFilePath), remote.owner);
|
|
108
108
|
}
|
|
109
109
|
/** Attaches the live chokidar watcher so addFolder/removeFolder can mutate it without a restart. */
|
|
110
110
|
setWatcher(watcher) {
|
|
@@ -190,6 +190,11 @@ export class MemoryRepository {
|
|
|
190
190
|
* perform the initial pull itself — the caller (the web route) does one
|
|
191
191
|
* immediate poll right after this returns, so content shows up without
|
|
192
192
|
* waiting for the first interval tick.
|
|
193
|
+
*
|
|
194
|
+
* `remote.name` (and `remote.mirrorDir`, derived from it) must already be the FINAL, resolved
|
|
195
|
+
* name — see resolveAvailableName, which the web route calls first (before it derives mirrorDir)
|
|
196
|
+
* so a possibly-auto-suffixed name is settled before anything touches disk, rather than this
|
|
197
|
+
* method renaming out from under an already-computed mirrorDir.
|
|
193
198
|
*/
|
|
194
199
|
registerRemoteFolder(remote) {
|
|
195
200
|
if (this.folders.some((f) => f.name === remote.name)) {
|
|
@@ -197,11 +202,61 @@ export class MemoryRepository {
|
|
|
197
202
|
}
|
|
198
203
|
fs.mkdirSync(remote.mirrorDir, { recursive: true });
|
|
199
204
|
this.remoteFolders.push(remote);
|
|
200
|
-
this.addFolder({ name: remote.name, path: remote.mirrorDir });
|
|
205
|
+
this.addFolder({ name: remote.name, path: remote.mirrorDir }, { skipCollisionCheck: true });
|
|
201
206
|
}
|
|
202
|
-
/**
|
|
203
|
-
|
|
204
|
-
|
|
207
|
+
/**
|
|
208
|
+
* `name` must stay globally unique across EVERY identity this process has ever seen, not just
|
|
209
|
+
* currently-visible folders — `folder` is the sole key used everywhere a doc/skill addresses which
|
|
210
|
+
* folder it lives in (the memory_docs.folder/skills.folder DB columns, every `folder = ?` SQL
|
|
211
|
+
* filter, MCP tool `folder` params, the web UI's folder-chip clicks). Two folders sharing a name
|
|
212
|
+
* would make all of those ambiguous the instant both became visible in the same request. Called by
|
|
213
|
+
* the web route BEFORE deriving mirrorDir/calling registerRemoteFolder, so the name is fully
|
|
214
|
+
* settled before anything touches disk or the config file.
|
|
215
|
+
*
|
|
216
|
+
* Two different cases, handled differently:
|
|
217
|
+
* - Collides with a folder the SAME identity already has (own or shared) → a genuine naming
|
|
218
|
+
* mistake (e.g. reusing a name they already picked themselves) → rejected.
|
|
219
|
+
* - Collides with a folder a DIFFERENT identity owns (e.g. two different folderfoo logins each
|
|
220
|
+
* naturally wanting "bbbmemz") → auto-suffixed with " (<connecting username>)" instead of
|
|
221
|
+
* rejected, since forcing every user to invent an arbitrary alternate name for their OWN
|
|
222
|
+
* folder just because someone else's login already used the obvious one is exactly the friction
|
|
223
|
+
* this exists to avoid. The suffixed form is still checked for its own (now vanishingly
|
|
224
|
+
* unlikely, but not impossible — e.g. two different usernames on two different modes) collision
|
|
225
|
+
* and suffixed further with an incrementing counter if needed, so this always terminates with a
|
|
226
|
+
* genuinely available name rather than looping forever or erroring on a contrived edge case.
|
|
227
|
+
*/
|
|
228
|
+
resolveAvailableName(requestedName, connectingUsername) {
|
|
229
|
+
const takenByOwnIdentity = (name) => this.folders.some((f) => f.name === name) && this.isFolderNameVisible(name);
|
|
230
|
+
const taken = (name) => this.folders.some((f) => f.name === name);
|
|
231
|
+
if (!taken(requestedName))
|
|
232
|
+
return requestedName;
|
|
233
|
+
if (takenByOwnIdentity(requestedName)) {
|
|
234
|
+
throw new Error(`a memory folder named "${requestedName}" already exists`);
|
|
235
|
+
}
|
|
236
|
+
// Collides with a DIFFERENT identity's folder — auto-suffix rather than reject (see this
|
|
237
|
+
// method's own doc comment). Re-checks the suffixed candidate too: astronomically unlikely to
|
|
238
|
+
// still collide, but not impossible (e.g. someone literally named a folder "bbbmemz (bbb)"), so
|
|
239
|
+
// this keeps incrementing until it lands on a name nobody's using yet, rather than assuming the
|
|
240
|
+
// first suffix attempt always works.
|
|
241
|
+
let candidate = `${requestedName} (${connectingUsername})`;
|
|
242
|
+
let suffix = 2;
|
|
243
|
+
while (taken(candidate)) {
|
|
244
|
+
if (takenByOwnIdentity(candidate)) {
|
|
245
|
+
throw new Error(`a memory folder named "${candidate}" already exists`);
|
|
246
|
+
}
|
|
247
|
+
candidate = `${requestedName} (${connectingUsername}) ${suffix}`;
|
|
248
|
+
suffix++;
|
|
249
|
+
}
|
|
250
|
+
return candidate;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Registers a new folder: appends it, scans it once, and starts watching it live. A purely LOCAL
|
|
254
|
+
* folder (no folderfoo login involved) has no "different identity" to auto-suffix against — any
|
|
255
|
+
* collision here is unconditionally a plain rejection, unlike registerRemoteFolder's own
|
|
256
|
+
* resolveAvailableName.
|
|
257
|
+
*/
|
|
258
|
+
addFolder(folder, options) {
|
|
259
|
+
if (!options?.skipCollisionCheck && this.folders.some((f) => f.name === folder.name)) {
|
|
205
260
|
throw new Error(`a memory folder named "${folder.name}" already exists`);
|
|
206
261
|
}
|
|
207
262
|
this.folders.push(folder);
|
|
@@ -349,17 +404,30 @@ export class MemoryRepository {
|
|
|
349
404
|
}
|
|
350
405
|
params.push(limit, offset);
|
|
351
406
|
try {
|
|
407
|
+
// shared_items is LEFT JOINed on m.source_path (== mirror_path for an item-level share —
|
|
408
|
+
// see remote/shared-items.ts) to surface owner/role on any hit that came from a share, since
|
|
409
|
+
// a shared item's own `folder` column is always '' (its mirror path is outside every
|
|
410
|
+
// configured source directory, so folderForFile can't attribute it to one) and would
|
|
411
|
+
// otherwise look identical to any other unfoldered local doc.
|
|
352
412
|
const rows = this.db
|
|
353
413
|
.prepare(`SELECT m.source_path, m.key, m.description, m.doc_type, m.folder,
|
|
354
414
|
snippet(search_index, 3, '<<', '>>', '…', 20) AS snippet,
|
|
355
|
-
-bm25(search_index) AS score
|
|
415
|
+
-bm25(search_index) AS score,
|
|
416
|
+
si.owner AS shared_owner,
|
|
417
|
+
si.role AS shared_role
|
|
356
418
|
FROM search_index
|
|
357
419
|
JOIN memory_docs m ON m.source_path = search_index.ref_id
|
|
420
|
+
LEFT JOIN shared_items si ON si.status = 'active' AND si.mirror_path = m.source_path
|
|
358
421
|
WHERE search_index.ref_table = 'memory_docs' AND search_index MATCH ? ${conditions.map((c) => `AND ${c}`).join(' ')}
|
|
359
422
|
ORDER BY bm25(search_index)
|
|
360
423
|
LIMIT ? OFFSET ?`)
|
|
361
424
|
.all(...params);
|
|
362
|
-
return rows.map(({ source_path, ...rest }) => ({
|
|
425
|
+
return rows.map(({ source_path, shared_owner, shared_role, ...rest }) => ({
|
|
426
|
+
...rest,
|
|
427
|
+
filename: path.basename(source_path),
|
|
428
|
+
shared_owner: shared_owner ?? undefined,
|
|
429
|
+
shared_role: shared_role ?? undefined,
|
|
430
|
+
}));
|
|
363
431
|
}
|
|
364
432
|
catch (err) {
|
|
365
433
|
throw new SearchQueryError(query, err);
|
|
@@ -399,7 +467,7 @@ export class MemoryRepository {
|
|
|
399
467
|
// edits). Must parse it exactly like a local file read would.
|
|
400
468
|
let raw;
|
|
401
469
|
try {
|
|
402
|
-
raw = await readRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dir, name);
|
|
470
|
+
raw = await readRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dir, name, remote.owner);
|
|
403
471
|
}
|
|
404
472
|
catch (err) {
|
|
405
473
|
// Also try the LEGACY extensionless remote name — a doc pushed before the
|
|
@@ -409,7 +477,7 @@ export class MemoryRepository {
|
|
|
409
477
|
// FolderfooAuthError or any other failure, which must surface as-is.
|
|
410
478
|
if (!(err instanceof FolderfooRequestError) || err.status !== 404 || !name.endsWith('.md'))
|
|
411
479
|
throw err;
|
|
412
|
-
raw = await readRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dir, name.slice(0, -3));
|
|
480
|
+
raw = await readRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dir, name.slice(0, -3), remote.owner);
|
|
413
481
|
}
|
|
414
482
|
const liveBody = matter(raw).content.trim();
|
|
415
483
|
return { ...doc, body: liveBody };
|
|
@@ -498,7 +566,7 @@ export class MemoryRepository {
|
|
|
498
566
|
// Confirms the remote folder still exists before writing — folderfoo's own save endpoint
|
|
499
567
|
// would otherwise silently recreate a deleted folder rather than failing (see
|
|
500
568
|
// assertRemoteFolderExists's doc comment).
|
|
501
|
-
await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, targetFolder.name);
|
|
569
|
+
await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, targetFolder.name, remote.owner);
|
|
502
570
|
// The remote filename keeps the SAME name (including .md) as the local mirror file — no
|
|
503
571
|
// extension stripping. Historically this stripped ".md" to match the old opaque id (which
|
|
504
572
|
// never carried an extension), which meant a memory doc's OWN remote file was named
|
|
@@ -511,7 +579,7 @@ export class MemoryRepository {
|
|
|
511
579
|
const relPath = path.relative(remote.mirrorDir, filePath);
|
|
512
580
|
const dir = joinRemoteFolderPath(remote.folderPath, path.dirname(relPath));
|
|
513
581
|
const name = path.basename(relPath);
|
|
514
|
-
await writeRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dir, name, fileContents);
|
|
582
|
+
await writeRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dir, name, fileContents, remote.owner);
|
|
515
583
|
}, () => {
|
|
516
584
|
fs.mkdirSync(targetDir, { recursive: true });
|
|
517
585
|
fs.writeFileSync(filePath, fileContents, 'utf-8');
|
|
@@ -556,12 +624,12 @@ export class MemoryRepository {
|
|
|
556
624
|
await writeRemoteThenLocal(async () => {
|
|
557
625
|
if (!remote || !this.credentialsBaseDir)
|
|
558
626
|
return;
|
|
559
|
-
await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, existing.folder);
|
|
627
|
+
await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, existing.folder, remote.owner);
|
|
560
628
|
// No .md stripping — see create()'s comment.
|
|
561
629
|
const relPath = path.relative(remote.mirrorDir, existing.source_path);
|
|
562
630
|
const dir = joinRemoteFolderPath(remote.folderPath, path.dirname(relPath));
|
|
563
631
|
const name = path.basename(relPath);
|
|
564
|
-
await writeRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dir, name, fileContents);
|
|
632
|
+
await writeRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dir, name, fileContents, remote.owner);
|
|
565
633
|
}, () => fs.writeFileSync(existing.source_path, fileContents, 'utf-8'));
|
|
566
634
|
upsertFile(this.db, this.syncSpec, existing.source_path);
|
|
567
635
|
return { ...merged, body: newBody, paused: existingPaused };
|
|
@@ -636,11 +704,11 @@ export class MemoryRepository {
|
|
|
636
704
|
// recoverable if the delete was a mistake.
|
|
637
705
|
const remote = this.remoteFor(existing.folder);
|
|
638
706
|
if (remote && this.credentialsBaseDir) {
|
|
639
|
-
await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, existing.folder);
|
|
707
|
+
await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, existing.folder, remote.owner);
|
|
640
708
|
const relPath = path.relative(remote.mirrorDir, existing.source_path);
|
|
641
709
|
const dir = joinRemoteFolderPath(remote.folderPath, path.dirname(relPath));
|
|
642
710
|
const name = path.basename(relPath);
|
|
643
|
-
await trashRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dir, name);
|
|
711
|
+
await trashRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dir, name, remote.owner);
|
|
644
712
|
}
|
|
645
713
|
// The sibling wrapper directory exists solely to hold attachments/ for this doc (memory docs
|
|
646
714
|
// are otherwise flat files), so remove the whole wrapper — not just attachments/ — to avoid
|
|
@@ -678,13 +746,13 @@ export class MemoryRepository {
|
|
|
678
746
|
await writeRemoteThenLocal(async () => {
|
|
679
747
|
if (!remote || !this.credentialsBaseDir)
|
|
680
748
|
return;
|
|
681
|
-
await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, existing.folder);
|
|
749
|
+
await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, existing.folder, remote.owner);
|
|
682
750
|
const oldRelPath = path.relative(remote.mirrorDir, existing.source_path);
|
|
683
751
|
const dir = joinRemoteFolderPath(remote.folderPath, path.dirname(oldRelPath));
|
|
684
752
|
const oldName = path.basename(oldRelPath);
|
|
685
753
|
const newName = path.basename(newPath);
|
|
686
754
|
try {
|
|
687
|
-
await renameRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dir, oldName, newName);
|
|
755
|
+
await renameRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dir, oldName, newName, remote.owner);
|
|
688
756
|
}
|
|
689
757
|
catch (err) {
|
|
690
758
|
// Same legacy-extensionless fallback as get() above: a doc pushed before the
|
|
@@ -694,7 +762,7 @@ export class MemoryRepository {
|
|
|
694
762
|
// failure, which must surface as-is.
|
|
695
763
|
if (!(err instanceof FolderfooRequestError) || err.status !== 404 || !oldName.endsWith('.md'))
|
|
696
764
|
throw err;
|
|
697
|
-
await renameRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dir, oldName.slice(0, -3), newName);
|
|
765
|
+
await renameRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dir, oldName.slice(0, -3), newName, remote.owner);
|
|
698
766
|
}
|
|
699
767
|
}, () => {
|
|
700
768
|
fs.renameSync(existing.source_path, newPath);
|
|
@@ -96,22 +96,81 @@ function folderQuery(tenantId, folderPath, extra) {
|
|
|
96
96
|
const params = new URLSearchParams({ folderPath, ...extra });
|
|
97
97
|
return params.toString();
|
|
98
98
|
}
|
|
99
|
-
|
|
100
|
-
|
|
99
|
+
/**
|
|
100
|
+
* `owner`, when passed, addresses a folder in someone ELSE's tree via a direct-username share —
|
|
101
|
+
* every folderfoo route below that takes `owner` gates it through the same resolveUserDir/
|
|
102
|
+
* hasAccess check the file-level `owner` param in filenameParam does (see that function's own
|
|
103
|
+
* comment). Omitted entirely (not just falsy) for an own-folder call, matching folderfoo's own
|
|
104
|
+
* `owner ? ... : caller's own dir` branching server-side.
|
|
105
|
+
*/
|
|
106
|
+
export async function getLastChanged(server, baseDir, tenantId, folderPath, owner) {
|
|
107
|
+
return withAuth(server, baseDir, (jwt) => fetch(`${server}/folders/last-changed?${folderQuery(tenantId, folderPath, owner ? { owner } : undefined)}`, {
|
|
101
108
|
headers: { authorization: `Bearer ${jwt}`, 'x-tenant-id': tenantId },
|
|
102
109
|
}), async (res) => (await res.json()).lastChanged);
|
|
103
110
|
}
|
|
104
111
|
/**
|
|
105
|
-
* Lists
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
+
* Lists folderfoo folders via GET /folders — the caller's own when `owner`/`rootFolder` are
|
|
113
|
+
* omitted, or a subtree someone else shared with the caller when both are passed (folderfoo gates
|
|
114
|
+
* this via the same hasAccess check every other shared read uses, requiring `rootFolder` to be a
|
|
115
|
+
* folder actually shared with the caller). Used by the "connect a folderfoo folder" UI's picker.
|
|
116
|
+
*/
|
|
117
|
+
export async function listFolders(server, baseDir, tenantId, options) {
|
|
118
|
+
const params = new URLSearchParams();
|
|
119
|
+
if (options?.owner)
|
|
120
|
+
params.set('owner', options.owner);
|
|
121
|
+
if (options?.rootFolder)
|
|
122
|
+
params.set('rootFolder', options.rootFolder);
|
|
123
|
+
const qs = params.toString();
|
|
124
|
+
return withAuth(server, baseDir, (jwt) => fetch(`${server}/folders${qs ? `?${qs}` : ''}`, { headers: { authorization: `Bearer ${jwt}`, 'x-tenant-id': tenantId } }), (res) => res.json());
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Lists everything (files and folders, from every owner) shared with the
|
|
128
|
+
* current user via folderfoo's direct-username `shares` mechanism — see
|
|
129
|
+
* folderfoo's GET /shared-with-me. Used ONLY by an explicit refresh action
|
|
130
|
+
* (see remote/shared-items.ts's refreshSharedItems) — never polled on a
|
|
131
|
+
* timer, per the settled "refresh is a UI-only concept" design. role/
|
|
132
|
+
* originId/kind are null for a plain (non-mcp-memory-bucket) share, e.g. one
|
|
133
|
+
* created before this feature existed or shared by a different app.
|
|
134
|
+
*/
|
|
135
|
+
export async function getSharedWithMe(server, baseDir, tenantId) {
|
|
136
|
+
return withAuth(server, baseDir, (jwt) => fetch(`${server}/shared-with-me`, { headers: { authorization: `Bearer ${jwt}`, 'x-tenant-id': tenantId } }), (res) => res.json());
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Grants another folderfoo user direct access to one of the caller's own memory docs/skills, via
|
|
140
|
+
* folderfoo's POST /share/:filename — the immediate, no-link-needed half of Phase 4's share UX
|
|
141
|
+
* (paired with createShareLink/createPublicLink below for the out-of-band half). `kind` tags the
|
|
142
|
+
* grant so a recipient's shared_items row (see shared-items.ts) never has to guess memory-vs-skill
|
|
143
|
+
* from content — see folderfoo's shares.js v6->v7 migration for why this exists at all.
|
|
112
144
|
*/
|
|
113
|
-
export async function
|
|
114
|
-
|
|
145
|
+
export async function shareWithUser(server, baseDir, tenantId, folderPath, name, targetUsername, kind, role) {
|
|
146
|
+
await withAuth(server, baseDir, (jwt) => fetch(`${server}/share/${filenameParam(folderPath, name)}`, {
|
|
147
|
+
method: 'POST',
|
|
148
|
+
headers: { authorization: `Bearer ${jwt}`, 'x-tenant-id': tenantId, 'content-type': 'application/json' },
|
|
149
|
+
body: JSON.stringify({ username: targetUsername, kind, role }),
|
|
150
|
+
}), async () => undefined);
|
|
151
|
+
}
|
|
152
|
+
/** Revokes a specific recipient's direct access via folderfoo's DELETE /share/:filename/:username. */
|
|
153
|
+
export async function unshareWithUser(server, baseDir, tenantId, folderPath, name, targetUsername) {
|
|
154
|
+
await withAuth(server, baseDir, (jwt) => fetch(`${server}/share/${filenameParam(folderPath, name)}/${encodeURIComponent(targetUsername)}`, {
|
|
155
|
+
method: 'DELETE',
|
|
156
|
+
headers: { authorization: `Bearer ${jwt}`, 'x-tenant-id': tenantId },
|
|
157
|
+
}), async () => undefined);
|
|
158
|
+
}
|
|
159
|
+
/** Creates an out-of-band, login-required collaborator share link via folderfoo's POST /share-links. */
|
|
160
|
+
export async function createShareLink(server, baseDir, tenantId, folderPath, name, kind) {
|
|
161
|
+
return withAuth(server, baseDir, (jwt) => fetch(`${server}/share-links`, {
|
|
162
|
+
method: 'POST',
|
|
163
|
+
headers: { authorization: `Bearer ${jwt}`, 'x-tenant-id': tenantId, 'content-type': 'application/json' },
|
|
164
|
+
body: JSON.stringify({ path: joinRemoteFolderPath(folderPath, name), type: 'file', kind }),
|
|
165
|
+
}), (res) => res.json());
|
|
166
|
+
}
|
|
167
|
+
/** Creates an anonymous, no-login-required public view link via folderfoo's POST /public-links. */
|
|
168
|
+
export async function createPublicLink(server, baseDir, tenantId, folderPath, name, kind) {
|
|
169
|
+
return withAuth(server, baseDir, (jwt) => fetch(`${server}/public-links`, {
|
|
170
|
+
method: 'POST',
|
|
171
|
+
headers: { authorization: `Bearer ${jwt}`, 'x-tenant-id': tenantId, 'content-type': 'application/json' },
|
|
172
|
+
body: JSON.stringify({ path: joinRemoteFolderPath(folderPath, name), kind }),
|
|
173
|
+
}), (res) => res.json());
|
|
115
174
|
}
|
|
116
175
|
/** Thrown when a write targets a remote folder that no longer exists on folderfoo (deleted server-side since the last connect/UI-open sync). */
|
|
117
176
|
export class RemoteFolderGoneError extends Error {
|
|
@@ -130,16 +189,26 @@ export class RemoteFolderGoneError extends Error {
|
|
|
130
189
|
* folder — the user may have deleted it deliberately. `folderPath === ''` (a source's own root) is
|
|
131
190
|
* never gone (the user's account root always exists), so this only ever checks a non-root path.
|
|
132
191
|
*/
|
|
133
|
-
export async function assertRemoteFolderExists(server, baseDir, tenantId, folderPath, folderName) {
|
|
192
|
+
export async function assertRemoteFolderExists(server, baseDir, tenantId, folderPath, folderName, owner) {
|
|
134
193
|
if (!folderPath)
|
|
135
194
|
return;
|
|
195
|
+
// A shared folder's OWN path never appears in listFolders' own-tree result (it isn't a
|
|
196
|
+
// subdirectory of the caller's root) - list the owner's tree rooted at folderPath instead and
|
|
197
|
+
// check for a non-empty (i.e. accessible) result, same "does this still resolve" intent as the
|
|
198
|
+
// own-folder branch below, just via the owner-aware GET /folders shape (see listFolders' comment).
|
|
199
|
+
if (owner) {
|
|
200
|
+
await listFolders(server, baseDir, tenantId, { owner, rootFolder: folderPath }).catch(() => {
|
|
201
|
+
throw new RemoteFolderGoneError(folderName);
|
|
202
|
+
});
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
136
205
|
const folders = await listFolders(server, baseDir, tenantId);
|
|
137
206
|
if (!folders.some((f) => f.path === folderPath)) {
|
|
138
207
|
throw new RemoteFolderGoneError(folderName);
|
|
139
208
|
}
|
|
140
209
|
}
|
|
141
|
-
export async function getChangedSince(server, baseDir, tenantId, folderPath, since) {
|
|
142
|
-
return withAuth(server, baseDir, (jwt) => fetch(`${server}/folders/changed-since?${folderQuery(tenantId, folderPath, { since: String(since) })}`, {
|
|
210
|
+
export async function getChangedSince(server, baseDir, tenantId, folderPath, since, owner) {
|
|
211
|
+
return withAuth(server, baseDir, (jwt) => fetch(`${server}/folders/changed-since?${folderQuery(tenantId, folderPath, { since: String(since), ...(owner ? { owner } : {}) })}`, {
|
|
143
212
|
headers: { authorization: `Bearer ${jwt}`, 'x-tenant-id': tenantId },
|
|
144
213
|
}), async (res) => (await res.json()).files);
|
|
145
214
|
}
|
|
@@ -151,7 +220,15 @@ export async function getChangedSince(server, baseDir, tenantId, folderPath, sin
|
|
|
151
220
|
// itself uses this same form in its own sharing tests for exactly this
|
|
152
221
|
// reason. Always use it here rather than only for the single-segment case,
|
|
153
222
|
// so this client doesn't need to special-case folder depth.
|
|
154
|
-
|
|
223
|
+
//
|
|
224
|
+
// `owner`, when passed, addresses a path in someone ELSE's directory (a
|
|
225
|
+
// direct-username share, resolved via folderfoo's shares table rather than
|
|
226
|
+
// the caller's own files) - see resolveUserDir on the server side, which
|
|
227
|
+
// treats a bare (owner-less) filename as always the CALLER's own directory.
|
|
228
|
+
// Every other caller of this function addresses its own files and omits it.
|
|
229
|
+
function filenameParam(folderPath, name, owner) {
|
|
230
|
+
if (owner)
|
|
231
|
+
return `${encodeURIComponent(owner)}:${folderPath ? encodeURIComponent(folderPath) : ''}:${name}`;
|
|
155
232
|
return folderPath ? `:${encodeURIComponent(folderPath)}:${name}` : name;
|
|
156
233
|
}
|
|
157
234
|
/**
|
|
@@ -169,13 +246,24 @@ export function joinRemoteFolderPath(remoteFolderPath, mirrorRelativeDir) {
|
|
|
169
246
|
return remoteFolderPath;
|
|
170
247
|
return remoteFolderPath ? `${remoteFolderPath}/${mirrorRelativeDir}` : mirrorRelativeDir;
|
|
171
248
|
}
|
|
172
|
-
/**
|
|
173
|
-
|
|
174
|
-
|
|
249
|
+
/**
|
|
250
|
+
* Reads one file's raw content via GET /data/:filename. `owner`, when passed, reads a file from
|
|
251
|
+
* someone ELSE's directory via a direct-username share (see filenameParam's own comment) - used by
|
|
252
|
+
* shared-items.ts to pull a shared item's content, since the caller here is the share's RECIPIENT,
|
|
253
|
+
* not the file's owner.
|
|
254
|
+
*/
|
|
255
|
+
export async function readFile(server, baseDir, tenantId, folderPath, name, owner) {
|
|
256
|
+
return withAuth(server, baseDir, (jwt) => fetch(`${server}/data/${filenameParam(folderPath, name, owner)}`, { headers: { authorization: `Bearer ${jwt}`, 'x-tenant-id': tenantId } }), (res) => res.text());
|
|
175
257
|
}
|
|
176
|
-
/**
|
|
177
|
-
|
|
178
|
-
|
|
258
|
+
/**
|
|
259
|
+
* Writes one file's raw content via POST /save/:filename. `owner`, when passed, writes into
|
|
260
|
+
* someone ELSE's directory via a direct-username share (see filenameParam's own comment) — this is
|
|
261
|
+
* gated 'editor'-role-only server-side (see folderfoo's resolveUserDir(..., 'editor') on this
|
|
262
|
+
* route), so a 'member' (read-only) shared folder correctly 403s here rather than silently
|
|
263
|
+
* succeeding against the caller's own directory instead.
|
|
264
|
+
*/
|
|
265
|
+
export async function writeFile(server, baseDir, tenantId, folderPath, name, content, owner) {
|
|
266
|
+
await withAuth(server, baseDir, (jwt) => fetch(`${server}/save/${filenameParam(folderPath, name, owner)}`, {
|
|
179
267
|
method: 'POST',
|
|
180
268
|
headers: { authorization: `Bearer ${jwt}`, 'x-tenant-id': tenantId, 'content-type': 'text/markdown' },
|
|
181
269
|
body: content,
|
|
@@ -190,8 +278,8 @@ export async function writeFile(server, baseDir, tenantId, folderPath, name, con
|
|
|
190
278
|
* write those callers already do locally, now also propagated as a real rename remotely, instead
|
|
191
279
|
* of write-new (create) + never delete-old.
|
|
192
280
|
*/
|
|
193
|
-
export async function renameFile(server, baseDir, tenantId, folderPath, name, newName) {
|
|
194
|
-
await withAuth(server, baseDir, (jwt) => fetch(`${server}/rename/${filenameParam(folderPath, name)}`, {
|
|
281
|
+
export async function renameFile(server, baseDir, tenantId, folderPath, name, newName, owner) {
|
|
282
|
+
await withAuth(server, baseDir, (jwt) => fetch(`${server}/rename/${filenameParam(folderPath, name, owner)}`, {
|
|
195
283
|
method: 'POST',
|
|
196
284
|
headers: { authorization: `Bearer ${jwt}`, 'x-tenant-id': tenantId, 'content-type': 'application/json' },
|
|
197
285
|
body: JSON.stringify({ newName }),
|
|
@@ -206,8 +294,8 @@ export async function renameFile(server, baseDir, tenantId, folderPath, name, ne
|
|
|
206
294
|
* remote file was never touched at all, so the NEXT poll would pull it right back in, making a
|
|
207
295
|
* "deleted" doc silently reappear.
|
|
208
296
|
*/
|
|
209
|
-
export async function trashFile(server, baseDir, tenantId, folderPath, name) {
|
|
210
|
-
await withAuth(server, baseDir, (jwt) => fetch(`${server}/trash/${filenameParam(folderPath, name)}`, {
|
|
297
|
+
export async function trashFile(server, baseDir, tenantId, folderPath, name, owner) {
|
|
298
|
+
await withAuth(server, baseDir, (jwt) => fetch(`${server}/trash/${filenameParam(folderPath, name, owner)}`, {
|
|
211
299
|
method: 'POST',
|
|
212
300
|
headers: { authorization: `Bearer ${jwt}`, 'x-tenant-id': tenantId },
|
|
213
301
|
}), async () => undefined);
|
|
@@ -221,8 +309,9 @@ export async function trashFile(server, baseDir, tenantId, folderPath, name) {
|
|
|
221
309
|
* it as a wildcard path segment, unlike every other folderPath-bearing endpoint in this client
|
|
222
310
|
* (which use a `?folderPath=` query param), because folderfoo's own route is DELETE /folders/*.
|
|
223
311
|
*/
|
|
224
|
-
export async function trashFolder(server, baseDir, tenantId, folderPath) {
|
|
225
|
-
|
|
312
|
+
export async function trashFolder(server, baseDir, tenantId, folderPath, owner) {
|
|
313
|
+
const qs = owner ? `?owner=${encodeURIComponent(owner)}` : '';
|
|
314
|
+
await withAuth(server, baseDir, (jwt) => fetch(`${server}/folders/${folderPath.split('/').map(encodeURIComponent).join('/')}${qs}`, {
|
|
226
315
|
method: 'DELETE',
|
|
227
316
|
headers: { authorization: `Bearer ${jwt}`, 'x-tenant-id': tenantId },
|
|
228
317
|
}), async () => undefined);
|
|
@@ -238,11 +327,11 @@ export async function trashFolder(server, baseDir, tenantId, folderPath) {
|
|
|
238
327
|
* "memz/old-skill-name" / "memz/new-skill-name"), sent as JSON body fields per folderfoo's own
|
|
239
328
|
* route (unlike the wildcard-path DELETE /folders/* trashFolder uses).
|
|
240
329
|
*/
|
|
241
|
-
export async function renameFolder(server, baseDir, tenantId, folderPath, newFolderPath) {
|
|
330
|
+
export async function renameFolder(server, baseDir, tenantId, folderPath, newFolderPath, owner) {
|
|
242
331
|
await withAuth(server, baseDir, (jwt) => fetch(`${server}/folders/rename`, {
|
|
243
332
|
method: 'POST',
|
|
244
333
|
headers: { authorization: `Bearer ${jwt}`, 'x-tenant-id': tenantId, 'content-type': 'application/json' },
|
|
245
|
-
body: JSON.stringify({ folderPath, newFolderPath }),
|
|
334
|
+
body: JSON.stringify({ folderPath, newFolderPath, owner }),
|
|
246
335
|
}), async () => undefined);
|
|
247
336
|
}
|
|
248
337
|
/**
|
|
@@ -260,8 +349,8 @@ export async function renameFolder(server, baseDir, tenantId, folderPath, newFol
|
|
|
260
349
|
* (attach a dotted/hyphenated filename to a doc in a remote folder, then check the folderfoo UI
|
|
261
350
|
* shows it unmangled) before relying on this for anything beyond best-effort.
|
|
262
351
|
*/
|
|
263
|
-
export async function writeBinaryFile(server, baseDir, tenantId, folderPath, name, data, mimeType) {
|
|
264
|
-
await withAuth(server, baseDir, (jwt) => fetch(`${server}/save/${filenameParam(folderPath, name)}`, {
|
|
352
|
+
export async function writeBinaryFile(server, baseDir, tenantId, folderPath, name, data, mimeType, owner) {
|
|
353
|
+
await withAuth(server, baseDir, (jwt) => fetch(`${server}/save/${filenameParam(folderPath, name, owner)}`, {
|
|
265
354
|
method: 'POST',
|
|
266
355
|
headers: { authorization: `Bearer ${jwt}`, 'x-tenant-id': tenantId, 'content-type': mimeType },
|
|
267
356
|
body: data,
|
|
@@ -71,7 +71,7 @@ function toMirrorRelativeDir(remoteFolderPath, absoluteFolderPath) {
|
|
|
71
71
|
* missing deletions.
|
|
72
72
|
*/
|
|
73
73
|
async function reconcileDeletions(db, spec, folder, credentialsBaseDir) {
|
|
74
|
-
const remoteFiles = await getChangedSince(folder.server, credentialsBaseDir, folder.tenantId, folder.folderPath, 0);
|
|
74
|
+
const remoteFiles = await getChangedSince(folder.server, credentialsBaseDir, folder.tenantId, folder.folderPath, 0, folder.owner);
|
|
75
75
|
const remoteRelPaths = new Set(remoteFiles.map((f) => {
|
|
76
76
|
const mirrorRelativeDir = toMirrorRelativeDir(folder.folderPath, f.folderPath);
|
|
77
77
|
return mirrorRelativeDir ? path.join(mirrorRelativeDir, f.name) : f.name;
|
|
@@ -169,7 +169,7 @@ async function pullFile(db, spec, folder, credentialsBaseDir, changedFile) {
|
|
|
169
169
|
// correct value to pass straight through to readFile (folderfoo expects
|
|
170
170
|
// that same absolute form for GET /data/:filename), but it must be
|
|
171
171
|
// converted to mirror-relative before joining onto folder.mirrorDir.
|
|
172
|
-
const content = await readFile(folder.server, credentialsBaseDir, folder.tenantId, changedFile.folderPath, changedFile.name);
|
|
172
|
+
const content = await readFile(folder.server, credentialsBaseDir, folder.tenantId, changedFile.folderPath, changedFile.name, folder.owner);
|
|
173
173
|
const mirrorRelativeDir = toMirrorRelativeDir(folder.folderPath, changedFile.folderPath);
|
|
174
174
|
const localFilename = spec.remoteFilename.toLocal(changedFile.name);
|
|
175
175
|
const relPath = mirrorRelativeDir ? path.join(mirrorRelativeDir, localFilename) : localFilename;
|
|
@@ -192,9 +192,21 @@ async function pullFile(db, spec, folder, credentialsBaseDir, changedFile) {
|
|
|
192
192
|
* source's local content cache. All remote sources share one credentials
|
|
193
193
|
* file at credentialsBaseDir, keyed by server URL (see credentials.ts).
|
|
194
194
|
*/
|
|
195
|
-
export async function pollOne(db, spec, folder, credentialsBaseDir, options = {}
|
|
195
|
+
export async function pollOne(db, spec, folder, credentialsBaseDir, options = {},
|
|
196
|
+
// Called when this poll discovers the stored credential is dead (refresh failed, or a second
|
|
197
|
+
// 401 after refresh) — server.ts wires this to identity.clearUsername(). Without it, a dead JWT
|
|
198
|
+
// (e.g. revoked server-side, or expired past its refresh window) leaves IdentityTracker still
|
|
199
|
+
// reporting the OLD username as logged in forever, since nothing previously told it the
|
|
200
|
+
// credential folderfoo-client.ts already gave up on and cleared. That mismatch is exactly what
|
|
201
|
+
// let a remote folder keep showing as visible (isFolderVisible checks identity.current(), not
|
|
202
|
+
// whether the credential still works) even though every real read/write against it was already
|
|
203
|
+
// failing loudly with its own FolderfooAuthError — the folder list and the actual login state
|
|
204
|
+
// silently disagreed until the user manually re-logged-in. Only called once per poll failure,
|
|
205
|
+
// not per retry inside withAuth itself, since pollOne is the layer that already decides to
|
|
206
|
+
// swallow-and-log rather than propagate.
|
|
207
|
+
onAuthExpired) {
|
|
196
208
|
try {
|
|
197
|
-
const lastChanged = await getLastChanged(folder.server, credentialsBaseDir, folder.tenantId, folder.folderPath);
|
|
209
|
+
const lastChanged = await getLastChanged(folder.server, credentialsBaseDir, folder.tenantId, folder.folderPath, folder.owner);
|
|
198
210
|
const localWatermark = readLocalWatermark(folder.mirrorDir);
|
|
199
211
|
// force (manual resync, or rebuild-cache) always does real work,
|
|
200
212
|
// including reconcileDeletions - a user explicitly asking to resync
|
|
@@ -202,7 +214,7 @@ export async function pollOne(db, spec, folder, credentialsBaseDir, options = {}
|
|
|
202
214
|
// to equal what we last saw; the whole point of a manual resync is to
|
|
203
215
|
// re-verify against the live state, not trust the cheap check alone.
|
|
204
216
|
if (!options.force && lastChanged <= localWatermark)
|
|
205
|
-
return; // cheap path: nothing changed since our last sync, no listing call
|
|
217
|
+
return { ok: true }; // cheap path: nothing changed since our last sync, no listing call
|
|
206
218
|
// force must query since=0, not localWatermark - getChangedSince filters strictly by
|
|
207
219
|
// mtime > since, so a file whose mtime happens to equal (or predate) the local watermark -
|
|
208
220
|
// e.g. a rename/move on folderfoo that doesn't bump mtime, or a file that was wrongly deleted
|
|
@@ -210,12 +222,13 @@ export async function pollOne(db, spec, folder, credentialsBaseDir, options = {}
|
|
|
210
222
|
// be silently invisible to EVERY future poll, forced or not, forever. A real "force resync"
|
|
211
223
|
// has to mean "re-verify everything against the live listing," matching what
|
|
212
224
|
// reconcileDeletions already does unconditionally below.
|
|
213
|
-
const changed = await getChangedSince(folder.server, credentialsBaseDir, folder.tenantId, folder.folderPath, options.force ? 0 : localWatermark);
|
|
225
|
+
const changed = await getChangedSince(folder.server, credentialsBaseDir, folder.tenantId, folder.folderPath, options.force ? 0 : localWatermark, folder.owner);
|
|
214
226
|
for (const file of changed) {
|
|
215
227
|
await pullFile(db, spec, folder, credentialsBaseDir, file);
|
|
216
228
|
}
|
|
217
229
|
await reconcileDeletions(db, spec, folder, credentialsBaseDir);
|
|
218
230
|
writeLocalWatermark(folder.mirrorDir, lastChanged);
|
|
231
|
+
return { ok: true };
|
|
219
232
|
}
|
|
220
233
|
catch (err) {
|
|
221
234
|
if (err instanceof FolderfooAuthError) {
|
|
@@ -225,7 +238,14 @@ export async function pollOne(db, spec, folder, credentialsBaseDir, options = {}
|
|
|
225
238
|
// this source will surface its own FolderfooAuthError directly to
|
|
226
239
|
// the caller regardless of poller state).
|
|
227
240
|
console.error(`[memory-bucket] ${err.message}`);
|
|
228
|
-
|
|
241
|
+
onAuthExpired?.();
|
|
242
|
+
// { ok: false } tells pollAndNotify to skip onSynced for this folder this tick — onSynced
|
|
243
|
+
// (server.ts's onAttachmentSync) calls memoryRepo.get()/skillRepo.get(), which are gated by
|
|
244
|
+
// isFolderNameVisible/identity.current(). Calling onAuthExpired above can flip that gate to
|
|
245
|
+
// "invisible" in this exact tick, so firing onSynced right after would make a perfectly
|
|
246
|
+
// real, still-on-disk doc look "not found" (a confusing symptom of the SAME auth failure,
|
|
247
|
+
// not a second bug) rather than the original, more informative FolderfooAuthError.
|
|
248
|
+
return { ok: false };
|
|
229
249
|
}
|
|
230
250
|
throw err;
|
|
231
251
|
}
|
|
@@ -246,11 +266,21 @@ onSynced,
|
|
|
246
266
|
// fetch/ECONNREFUSED (or worse, succeed against a DIFFERENT server that happens to be listening on
|
|
247
267
|
// that host:port right now) for a source nothing can currently see anyway. Defaults to "always
|
|
248
268
|
// visible" so existing callers/tests that don't care about identity keep working unchanged.
|
|
249
|
-
isVisible = () => true
|
|
269
|
+
isVisible = () => true,
|
|
270
|
+
// Forwarded straight through to every pollOne call this handle makes — see pollOne's own doc
|
|
271
|
+
// comment for why this exists. server.ts wires this to identity.clearUsername().
|
|
272
|
+
onAuthExpired) {
|
|
250
273
|
const byName = new Map(remoteFolders.map((f) => [f.name, f]));
|
|
251
274
|
async function pollAndNotify(folder, options) {
|
|
252
|
-
await pollOne(db, spec, folder, credentialsBaseDir, options);
|
|
253
|
-
|
|
275
|
+
const { ok } = await pollOne(db, spec, folder, credentialsBaseDir, options, onAuthExpired);
|
|
276
|
+
// Matches this function's own long-standing doc comment above ("NOT called on a failed poll")
|
|
277
|
+
// — previously not actually enforced, since pollOne swallowed a FolderfooAuthError internally
|
|
278
|
+
// and returned normally either way, so onSynced fired even on a failed poll. Surfaced as a
|
|
279
|
+
// real bug once onAuthExpired started clearing identity mid-tick (see pollOne's { ok: false }
|
|
280
|
+
// comment) — onSynced's memoryRepo.get()/skillRepo.get() calls are gated by that same identity,
|
|
281
|
+
// so firing it right after a just-failed poll could report a perfectly real doc as "not found".
|
|
282
|
+
if (ok)
|
|
283
|
+
await onSynced?.(folder);
|
|
254
284
|
}
|
|
255
285
|
const interval = setInterval(() => {
|
|
256
286
|
for (const folder of remoteFolders) {
|