@fnndsc/salsa 3.21.2 → 4.0.1

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.
@@ -6,10 +6,15 @@
6
6
  * immutable and whose freshness is visit-driven.
7
7
  *
8
8
  * Freshness model (every visit is a cheap delta, never a re-crawl):
9
- * - ls /proc/jobs → roster delta (feeds newer than the highest known
10
- * id), and a full index walk once the roster is older
11
- * than {@link ROSTER_FULL_WALK_MS} — so a feed shared
12
- * later still appears within that window;
9
+ * - ls /proc/jobs → roster delta: each collection's newest page, read
10
+ * unfiltered, brings in the feeds newer than the
11
+ * highest known id, and its total is compared with
12
+ * the last walk's — a total that moved (a share, an
13
+ * unshare, a deletion) promotes the visit to the
14
+ * full index walk, which also runs once the roster
15
+ * is older than {@link ROSTER_FULL_WALK_MS};
16
+ * - a feed the roster lacks, asked for by name or id, is asked of CUBE live
17
+ * and adopted ({@link procFeed_probe});
13
18
  * - ls /proc/jobs/N → first visit loads the topology once; every later
14
19
  * visit runs {@link feedVisit_sync}: one feed-row
15
20
  * fetch (job counters), then, only when something
@@ -22,6 +27,7 @@
22
27
  *
23
28
  * @module
24
29
  */
30
+ import { type VfsOutcome } from '@fnndsc/fond';
25
31
  import { type ProcRosterSyncKind, ProcCache, ProcFeed, Result } from '@fnndsc/cumin';
26
32
  import { VFSProvider, VFSItem, CpOptions } from '../provider.js';
27
33
  /** Pages of one feed's topology walk fetched at once (PACS plural's precedent: four). */
@@ -32,6 +38,13 @@ export declare const FEED_RECHECK_MS: number;
32
38
  export declare const ROSTER_FULL_WALK_MS: number;
33
39
  /** Visits to one feed closer together than this share the first visit's result. */
34
40
  export declare const FEED_VISIT_COALESCE_MS: number;
41
+ /**
42
+ * Rows of a collection's newest page a roster delta reads. CUBE orders feeds
43
+ * newest first, so the page holds every feed newer than the roster unless
44
+ * more than this many arrived between two visits; it is small because the
45
+ * surfaces re-ask the roster on a timer.
46
+ */
47
+ export declare const ROSTER_DELTA_PAGE: number;
35
48
  /** Lifecycle states for the session's global topology sweep. */
36
49
  export type ProcTopologyState = 'idle' | 'running' | 'complete' | 'failed';
37
50
  /**
@@ -103,6 +116,8 @@ export declare class ProcVfsProvider implements VFSProvider {
103
116
  sort?: 'name' | 'size' | 'date' | 'owner';
104
117
  reverse?: boolean;
105
118
  }): Promise<Result<VFSItem[]>>;
119
+ /** @inheritdoc */
120
+ readBinary(pathStr: string): Promise<VfsOutcome<Buffer>>;
106
121
  /**
107
122
  * Raw bytes exist only under the `data` link (an image in a job's output
108
123
  * space); everything else in `/proc` is synthesized text, served as its
@@ -112,16 +127,22 @@ export declare class ProcVfsProvider implements VFSProvider {
112
127
  * @param pathStr - Absolute `/proc/...` path.
113
128
  * @returns The file's bytes.
114
129
  */
115
- readBinary(pathStr: string): Promise<Result<Buffer>>;
116
- read(pathStr: string): Promise<Result<string>>;
117
- rm(pathStr: string, _options?: {
130
+ private bytes_read;
131
+ /** @inheritdoc */
132
+ read(pathStr: string): Promise<VfsOutcome<string>>;
133
+ private text_read;
134
+ feedJobs_cancel(pathStr: string, _options?: {
118
135
  recursive?: boolean;
119
136
  force?: boolean;
120
137
  }): Promise<boolean>;
121
- cp(_src: string, _dst: string, _options?: CpOptions): Promise<boolean>;
138
+ /** @inheritdoc */
139
+ cp(_src: string, _dst: string, _options?: CpOptions): Promise<VfsOutcome>;
140
+ private copy_run;
122
141
  mv(_src: string, _dst: string): Promise<boolean>;
123
142
  touch(_pathStr: string): Promise<boolean>;
124
143
  upload(_localPath: string, _remotePath: string): Promise<boolean>;
144
+ /** @inheritdoc */
145
+ write(pathStr: string, content: string): Promise<VfsOutcome>;
125
146
  /**
126
147
  * Writes a projected file. One file under /proc takes writes: a feed's
127
148
  * note (`/proc/jobs/feed_N/note`), whose content is replaced whole; its
@@ -131,7 +152,7 @@ export declare class ProcVfsProvider implements VFSProvider {
131
152
  * @param content - The new content, whole.
132
153
  * @returns True when it was written.
133
154
  */
134
- write(pathStr: string, content: string): Promise<boolean>;
155
+ private file_write;
135
156
  }
136
157
  /**
137
158
  * Ensures instance topology is loaded for a feed without clearing existing data.
@@ -157,6 +178,20 @@ export declare function procFeed_ensureLoaded(feedID: number): Promise<void>;
157
178
  * @param feedID - Feed whose metadata to ensure.
158
179
  */
159
180
  export declare function feedMeta_ensure(feedID: number): Promise<void>;
181
+ /**
182
+ * Asks CUBE for one feed, live, and adopts it into the roster: the way a
183
+ * feed shared to this identity, or made public, since the roster was
184
+ * walked is seen when it is asked for by name or id. A feed the roster
185
+ * already holds has its row refreshed. The yardsticks move with an
186
+ * adoption, so the next delta does not walk again for what this ask found.
187
+ *
188
+ * @param feedID - The feed to ask for.
189
+ * @returns The feed as the roster now holds it, or null when CUBE lists it
190
+ * in neither collection for this identity — not shared, or shared to a
191
+ * group the identity is not in.
192
+ * @throws {Error} When not connected.
193
+ */
194
+ export declare function procFeed_probe(feedID: number): Promise<ProcFeed | null>;
160
195
  export declare function feedStatus_refresh(feedID: number): Promise<void>;
161
196
  /**
162
197
  * Whether every cached instance of a feed carries a terminal status. A fully
@@ -250,6 +285,46 @@ export declare function procTopologyCatchup_status(): {
250
285
  * @returns Resolves when no walk is queued or running.
251
286
  */
252
287
  export declare function procTopologyCatchup_await(): Promise<void>;
288
+ /**
289
+ * One place a jump can land: a feed, or a node within one.
290
+ *
291
+ * @property kind - A feed, or a node (plugin instance).
292
+ * @property feedId - The feed, or the node's feed.
293
+ * @property nodeId - The node, for a node.
294
+ * @property name - The `/proc` name: `feed_4389`, or `pl-dicom_repack_639204`.
295
+ * @property title - The feed's title.
296
+ */
297
+ export interface ProcLocation {
298
+ kind: 'feed' | 'node';
299
+ feedId: number;
300
+ nodeId?: number;
301
+ name: string;
302
+ title: string;
303
+ }
304
+ /**
305
+ * What a jump target names: a feed only (`feed_4389`), a node only
306
+ * (`pl-dicom_repack_639204`, or `_639204`), or a bare number that may be
307
+ * either. Null for anything else.
308
+ *
309
+ * @param target - What the operator typed.
310
+ * @returns The id and which kinds it may name, or null.
311
+ */
312
+ export declare function procTarget_parse(target: string): {
313
+ id: number;
314
+ feed: boolean;
315
+ node: boolean;
316
+ } | null;
317
+ /**
318
+ * Finds where a jump target lands, asking CUBE live for what the roster
319
+ * and the index lack: a feed shared since the roster was walked, or a node
320
+ * in one. A node's feed is adopted into the roster on the way, so the
321
+ * place the answer names can be entered at once. A bare number that is
322
+ * both a feed and a node answers both; the caller asks which.
323
+ *
324
+ * @param target - A feed id, a node id, or a `feed_N` / `<plugin>_N` name.
325
+ * @returns Every place it names that this identity can see; empty for none.
326
+ */
327
+ export declare function procTarget_locate(target: string): Promise<ProcLocation[]>;
253
328
  /**
254
329
  * Forgets all visit bookkeeping (tests, and a cache cleared underneath us).
255
330
  */
@@ -7,10 +7,15 @@
7
7
  * immutable and whose freshness is visit-driven.
8
8
  *
9
9
  * Freshness model (every visit is a cheap delta, never a re-crawl):
10
- * - ls /proc/jobs → roster delta (feeds newer than the highest known
11
- * id), and a full index walk once the roster is older
12
- * than {@link ROSTER_FULL_WALK_MS} — so a feed shared
13
- * later still appears within that window;
10
+ * - ls /proc/jobs → roster delta: each collection's newest page, read
11
+ * unfiltered, brings in the feeds newer than the
12
+ * highest known id, and its total is compared with
13
+ * the last walk's — a total that moved (a share, an
14
+ * unshare, a deletion) promotes the visit to the
15
+ * full index walk, which also runs once the roster
16
+ * is older than {@link ROSTER_FULL_WALK_MS};
17
+ * - a feed the roster lacks, asked for by name or id, is asked of CUBE live
18
+ * and adopted ({@link procFeed_probe});
14
19
  * - ls /proc/jobs/N → first visit loads the topology once; every later
15
20
  * visit runs {@link feedVisit_sync}: one feed-row
16
21
  * fetch (job counters), then, only when something
@@ -24,7 +29,7 @@
24
29
  * @module
25
30
  */
26
31
  Object.defineProperty(exports, "__esModule", { value: true });
27
- exports.ProcVfsProvider = exports.FEED_VISIT_COALESCE_MS = exports.ROSTER_FULL_WALK_MS = exports.FEED_RECHECK_MS = exports.FEED_WALK_IN_FLIGHT = void 0;
32
+ exports.ProcVfsProvider = exports.ROSTER_DELTA_PAGE = exports.FEED_VISIT_COALESCE_MS = exports.ROSTER_FULL_WALK_MS = exports.FEED_RECHECK_MS = exports.FEED_WALK_IN_FLIGHT = void 0;
28
33
  exports.feedInstances_ensureStarted = feedInstances_ensureStarted;
29
34
  exports.procFeed_refreshStart = procFeed_refreshStart;
30
35
  exports.feedInstances_ensureLoaded = feedInstances_ensureLoaded;
@@ -32,6 +37,7 @@ exports.feedStatus_derive = feedStatus_derive;
32
37
  exports.procPath_parse = procPath_parse;
33
38
  exports.procFeed_ensureLoaded = procFeed_ensureLoaded;
34
39
  exports.feedMeta_ensure = feedMeta_ensure;
40
+ exports.procFeed_probe = procFeed_probe;
35
41
  exports.feedStatus_refresh = feedStatus_refresh;
36
42
  exports.feedCached_isSettled = feedCached_isSettled;
37
43
  exports.feedVisit_sync = feedVisit_sync;
@@ -41,6 +47,8 @@ exports.procRoster_bootSync = procRoster_bootSync;
41
47
  exports.procTopologyCatchup_enqueue = procTopologyCatchup_enqueue;
42
48
  exports.procTopologyCatchup_status = procTopologyCatchup_status;
43
49
  exports.procTopologyCatchup_await = procTopologyCatchup_await;
50
+ exports.procTarget_parse = procTarget_parse;
51
+ exports.procTarget_locate = procTarget_locate;
44
52
  exports.procVisitState_reset = procVisitState_reset;
45
53
  exports.procDataFacts_start = procDataFacts_start;
46
54
  exports.procTopology_warmup = procTopology_warmup;
@@ -49,6 +57,7 @@ exports.procTopology_retry = procTopology_retry;
49
57
  exports.procTopology_status = procTopology_status;
50
58
  exports.procTopology_await = procTopology_await;
51
59
  exports.procCache_refresh = procCache_refresh;
60
+ const fond_1 = require("@fnndsc/fond");
52
61
  const cumin_1 = require("@fnndsc/cumin");
53
62
  const index_js_1 = require("../../jobs/index.js");
54
63
  const feedJoins_js_1 = require("../../dag/feedJoins.js");
@@ -68,10 +77,18 @@ exports.FEED_RECHECK_MS = 10 * 60 * 1000;
68
77
  exports.ROSTER_FULL_WALK_MS = 10 * 60 * 1000;
69
78
  /** Visits to one feed closer together than this share the first visit's result. */
70
79
  exports.FEED_VISIT_COALESCE_MS = 1000;
80
+ /**
81
+ * Rows of a collection's newest page a roster delta reads. CUBE orders feeds
82
+ * newest first, so the page holds every feed newer than the roster unless
83
+ * more than this many arrived between two visits; it is small because the
84
+ * surfaces re-ask the roster on a timer.
85
+ */
86
+ exports.ROSTER_DELTA_PAGE = 20;
71
87
  const feedVisits = new Map();
72
88
  const feedVisitInflight = new Map();
73
89
  let rosterWalkedAt = 0;
74
90
  let rosterSyncInflight = null;
91
+ let rosterTotals = { owned: null, public: null };
75
92
  let procTopologyPromise = null;
76
93
  let procTopologyFailure;
77
94
  let procTopologyResumeState = null;
@@ -92,14 +109,23 @@ function procFeed_create(feed) {
92
109
  createdJobs: Number(feed.created_jobs ?? 0),
93
110
  };
94
111
  }
95
- /** Indexes one complete paginated CUBE feed collection into the shared cache. */
112
+ /**
113
+ * Indexes one complete paginated CUBE feed collection into the shared cache.
114
+ *
115
+ * @param page_fetch - Fetches one page of the collection.
116
+ * @param indexed - The feeds indexed so far, keyed by id; this walk adds to it.
117
+ * @returns The collection's total as the walk latched it, or null when no page carried one.
118
+ */
96
119
  async function procFeeds_index(page_fetch, indexed) {
120
+ let total = null;
97
121
  for await (const step of (0, cumin_1.listPages_walk)((offset, limit) => page_fetch({ limit, offset }), { pageSize: PAGE })) {
122
+ total = step.total ?? total;
98
123
  for (const feed of step.items) {
99
124
  const procFeed = procFeed_create(feed);
100
125
  indexed.set(procFeed.id, procFeed);
101
126
  }
102
127
  }
128
+ return total;
103
129
  }
104
130
  // ── Cache build ────────────────────────────────────────────────────────────
105
131
  /**
@@ -114,11 +140,11 @@ async function procCache_build() {
114
140
  throw new Error('not connected');
115
141
  }
116
142
  const indexed = new Map();
117
- await procFeeds_index((params) => (0, cumin_1.feedsPage_get)(client, params), indexed);
143
+ const ownedTotal = await procFeeds_index((params) => (0, cumin_1.feedsPage_get)(client, params), indexed);
118
144
  // Older CUBEs lack a public-feeds endpoint: the contract answers null
119
145
  // without a wire call, and an empty first page ends the index loop.
120
146
  const publicBefore = indexed.size;
121
- await procFeeds_index(async (params) => (await (0, cumin_1.publicFeedsPage_get)(client, params)) ?? { data: [], totalCount: 0 }, indexed);
147
+ let publicTotal = await procFeeds_index(async (params) => (await (0, cumin_1.publicFeedsPage_get)(client, params)) ?? { data: [], totalCount: 0 }, indexed);
122
148
  // An empty public walk while the cache already holds public feeds is a
123
149
  // failed SOURCE, not an authoritative absence: reconciling against it
124
150
  // would amputate every public feed from the roster (and the checkpoint
@@ -129,8 +155,11 @@ async function procCache_build() {
129
155
  cumin_1.errorStack.stack_push('warning', `proc roster: public-feeds source returned nothing while ${knownPublic.length} public feeds are known; keeping them`);
130
156
  for (const feed of knownPublic)
131
157
  indexed.set(feed.id, feed);
158
+ // No yardstick from a failed source: the next delta takes what it reads.
159
+ publicTotal = null;
132
160
  }
133
161
  }
162
+ rosterTotals = { owned: ownedTotal, public: publicTotal };
134
163
  const knownBefore = new Set(cache.feedIDs_get());
135
164
  const reconciliationTargets = cache.feeds_reconcile(Array.from(indexed.values()));
136
165
  for (const feedID of reconciliationTargets)
@@ -294,11 +323,9 @@ async function procFeed_evict(feedID) {
294
323
  const client = await cumin_1.chrisConnection.client_get();
295
324
  if (!client)
296
325
  return;
297
- const own = await (0, cumin_1.feedsPage_get)(client, { id: feedID, limit: 1, offset: 0 });
298
- const row = own.data[0]
299
- ?? ((await (0, cumin_1.publicFeedsPage_get)(client, { id: feedID, limit: 1, offset: 0 })) ?? { data: [], totalCount: 0 }).data[0];
326
+ const row = await feedRow_fetch(client, feedID);
300
327
  if (row)
301
- cache.feed_add(procFeed_create(row));
328
+ cache.feed_add(procFeed_create(row.feed));
302
329
  }
303
330
  /**
304
331
  * The operator's `proc refresh <feed>`: evicts the feed and STARTS its
@@ -544,6 +571,11 @@ class ProcVfsProvider {
544
571
  }
545
572
  // /proc/jobs/feed_N — list root instances + feed virtual files
546
573
  if (feedID !== null && instanceID === null) {
574
+ // A feed the roster lacks is asked of CUBE first: shared to this
575
+ // identity, or made public, since the walk, it is adopted here and
576
+ // listed as any other; a feed CUBE does not list is an empty listing.
577
+ if (cache.feed_get(feedID) === undefined && (await procFeedAbsent_probe(feedID)) === null)
578
+ return (0, cumin_1.Ok)([]);
547
579
  const wasLoaded = cache.topologyLoaded_has(feedID);
548
580
  await feedInstances_ensureLoaded(feedID);
549
581
  const feed = cache.feed_get(feedID);
@@ -614,6 +646,10 @@ class ProcVfsProvider {
614
646
  }
615
647
  return (0, cumin_1.Ok)([]);
616
648
  }
649
+ /** @inheritdoc */
650
+ async readBinary(pathStr) {
651
+ return (0, fond_1.vfsOutcome_ofResult)(await this.bytes_read(pathStr), 'ENOENT');
652
+ }
617
653
  /**
618
654
  * Raw bytes exist only under the `data` link (an image in a job's output
619
655
  * space); everything else in `/proc` is synthesized text, served as its
@@ -623,7 +659,7 @@ class ProcVfsProvider {
623
659
  * @param pathStr - Absolute `/proc/...` path.
624
660
  * @returns The file's bytes.
625
661
  */
626
- async readBinary(pathStr) {
662
+ async bytes_read(pathStr) {
627
663
  await cache_ensure();
628
664
  const clean = pathStr.replace(/\/$/, '');
629
665
  const { instanceID, virtualFile, dataRemainder } = procPath_parse(clean);
@@ -641,7 +677,11 @@ class ProcVfsProvider {
641
677
  const text = await this.read(pathStr);
642
678
  return text.ok ? (0, cumin_1.Ok)(Buffer.from(text.value, 'utf8')) : text;
643
679
  }
680
+ /** @inheritdoc */
644
681
  async read(pathStr) {
682
+ return (0, fond_1.vfsOutcome_ofResult)(await this.text_read(pathStr), 'ENOENT');
683
+ }
684
+ async text_read(pathStr) {
645
685
  await cache_ensure();
646
686
  const cache = (0, cumin_1.procCache_get)();
647
687
  const clean = pathStr.replace(/\/$/, '');
@@ -667,7 +707,7 @@ class ProcVfsProvider {
667
707
  const note = await (0, cumin_1.feedNote_get)(feedID);
668
708
  return note.ok ? (0, cumin_1.Ok)(note.value.content) : (0, cumin_1.Err)();
669
709
  }
670
- return (0, cumin_1.Ok)('');
710
+ return (0, cumin_1.Err)();
671
711
  }
672
712
  // Instance-level virtual files — all live or cached-on-first-read
673
713
  if (instanceID !== null && virtualFile !== null) {
@@ -709,9 +749,11 @@ class ProcVfsProvider {
709
749
  return (0, cumin_1.Ok)(logResult.ok ? logResult.value : '(log unavailable)');
710
750
  }
711
751
  }
712
- return (0, cumin_1.Ok)('');
752
+ // Anything else is no file /proc reads: a folder (the dispatcher then
753
+ // says it is a directory) or nothing there.
754
+ return (0, cumin_1.Err)();
713
755
  }
714
- async rm(pathStr, _options) {
756
+ async feedJobs_cancel(pathStr, _options) {
715
757
  await cache_ensure();
716
758
  const cache = (0, cumin_1.procCache_get)();
717
759
  const clean = pathStr.replace(/\/$/, '');
@@ -754,10 +796,18 @@ class ProcVfsProvider {
754
796
  }
755
797
  return false;
756
798
  }
757
- async cp(_src, _dst, _options) { return false; }
799
+ /** @inheritdoc */
800
+ async cp(_src, _dst, _options) {
801
+ return (0, fond_1.vfsOutcome_ofBoolean)(await this.copy_run(_src, _dst, _options), 'EROFS');
802
+ }
803
+ async copy_run(_src, _dst, _options) { return false; }
758
804
  async mv(_src, _dst) { return false; }
759
805
  async touch(_pathStr) { return false; }
760
806
  async upload(_localPath, _remotePath) { return false; }
807
+ /** @inheritdoc */
808
+ async write(pathStr, content) {
809
+ return (0, fond_1.vfsOutcome_ofBoolean)(await this.file_write(pathStr, content), 'EROFS');
810
+ }
761
811
  /**
762
812
  * Writes a projected file. One file under /proc takes writes: a feed's
763
813
  * note (`/proc/jobs/feed_N/note`), whose content is replaced whole; its
@@ -767,7 +817,7 @@ class ProcVfsProvider {
767
817
  * @param content - The new content, whole.
768
818
  * @returns True when it was written.
769
819
  */
770
- async write(pathStr, content) {
820
+ async file_write(pathStr, content) {
771
821
  const { feedID, instanceID, virtualFile } = procPath_parse(pathStr.replace(/\/$/, ''));
772
822
  if (feedID !== null && instanceID === null && virtualFile === 'note') {
773
823
  const done = await (0, cumin_1.feedNote_update)(feedID, { content });
@@ -829,14 +879,81 @@ async function feedMeta_ensure(feedID) {
829
879
  const existing = cache.feed_get(feedID);
830
880
  if (existing && existing.creationDate !== '')
831
881
  return;
882
+ await procFeed_probe(feedID);
883
+ }
884
+ /**
885
+ * Reads one feed's row, from the owned-and-shared collection first and the
886
+ * public one after.
887
+ *
888
+ * @param client - Connected chrisapi client.
889
+ * @param feedID - The feed to read.
890
+ * @returns The row and its collection, or null when neither lists the feed for this identity.
891
+ */
892
+ async function feedRow_fetch(client, feedID) {
893
+ const own = await (0, cumin_1.feedsPage_get)(client, { id: feedID, limit: 1, offset: 0 });
894
+ const ownRow = own.data[0];
895
+ if (ownRow)
896
+ return { feed: ownRow, collection: 'owned' };
897
+ const pub = (await (0, cumin_1.publicFeedsPage_get)(client, { id: feedID, limit: 1, offset: 0 })) ?? { data: [], totalCount: 0 };
898
+ const pubRow = pub.data[0];
899
+ return pubRow ? { feed: pubRow, collection: 'public' } : null;
900
+ }
901
+ /**
902
+ * Asks CUBE for one feed, live, and adopts it into the roster: the way a
903
+ * feed shared to this identity, or made public, since the roster was
904
+ * walked is seen when it is asked for by name or id. A feed the roster
905
+ * already holds has its row refreshed. The yardsticks move with an
906
+ * adoption, so the next delta does not walk again for what this ask found.
907
+ *
908
+ * @param feedID - The feed to ask for.
909
+ * @returns The feed as the roster now holds it, or null when CUBE lists it
910
+ * in neither collection for this identity — not shared, or shared to a
911
+ * group the identity is not in.
912
+ * @throws {Error} When not connected.
913
+ */
914
+ async function procFeed_probe(feedID) {
915
+ const cache = (0, cumin_1.procCache_get)();
832
916
  const client = await cumin_1.chrisConnection.client_get();
833
917
  if (!client)
834
918
  throw new Error('not connected');
835
- const page = await (0, cumin_1.feedsPage_get)(client, { id: feedID, limit: 1, offset: 0 });
836
- const f = page.data[0];
837
- if (!f)
838
- return;
839
- cache.feed_add(procFeed_create(f));
919
+ const row = await feedRow_fetch(client, feedID);
920
+ if (row === null)
921
+ return null;
922
+ const feed = procFeed_create(row.feed);
923
+ const known = cache.feed_get(feedID) !== undefined;
924
+ cache.feed_add(feed);
925
+ if (!known) {
926
+ rosterTotal_bump(row.collection);
927
+ // A public feed is in the public collection whichever one listed it first.
928
+ if (feed.public && row.collection === 'owned')
929
+ rosterTotal_bump('public');
930
+ cache.arrivals_note([feedID]);
931
+ procTopologyCatchup_enqueue([feedID]);
932
+ }
933
+ return feed;
934
+ }
935
+ /**
936
+ * Asks CUBE for a feed the roster lacks, on a visit to its path, and says
937
+ * on the error stack when the ask itself failed; an absent feed is a null.
938
+ *
939
+ * @param feedID - The feed to ask for.
940
+ * @returns The adopted feed, or null.
941
+ */
942
+ async function procFeedAbsent_probe(feedID) {
943
+ try {
944
+ return await procFeed_probe(feedID);
945
+ }
946
+ catch (error) {
947
+ const msg = error instanceof Error ? error.message : String(error);
948
+ cumin_1.errorStack.stack_push('warning', `proc: asking CUBE for feed_${feedID} failed (${msg})`);
949
+ return null;
950
+ }
951
+ }
952
+ /** Moves one collection's yardstick by one adopted feed; an unknown yardstick stays unknown. */
953
+ function rosterTotal_bump(collection) {
954
+ const total = rosterTotals[collection];
955
+ if (total !== null)
956
+ rosterTotals[collection] = total + 1;
840
957
  }
841
958
  async function feedStatus_refresh(feedID) {
842
959
  const cache = (0, cumin_1.procCache_get)();
@@ -1046,19 +1163,72 @@ async function procRoster_run(force) {
1046
1163
  return procRosterDelta_run(cache, client);
1047
1164
  }
1048
1165
  /**
1049
- * Walks the feeds newer than the highest known id, in both collections,
1050
- * and adds them to the roster.
1166
+ * One collection's newest page, read unfiltered. CUBE orders feeds newest
1167
+ * first, so the page carries every feed newer than the roster's highest id
1168
+ * unless more than a page arrived, and its total is the collection's whole
1169
+ * count — the yardstick a share, an unshare or a deletion moves.
1170
+ *
1171
+ * @param page_fetch - Fetches one page of the collection.
1172
+ * @param maxKnown - The roster's highest feed id (0 for an empty roster).
1173
+ * @returns The newer feeds, the total, and whether the page overflowed.
1174
+ */
1175
+ async function rosterCollection_peek(page_fetch, maxKnown) {
1176
+ const page = await page_fetch({ limit: exports.ROSTER_DELTA_PAGE, offset: 0 });
1177
+ const rows = page.data.map(procFeed_create);
1178
+ const newer = rows.filter((feed) => feed.id > maxKnown);
1179
+ const overflow = rows.length > 0 && newer.length === rows.length
1180
+ && page.totalCount !== null && page.totalCount > rows.length;
1181
+ return { newer, total: page.totalCount, overflow };
1182
+ }
1183
+ /**
1184
+ * Whether a collection's total moved by more than its newer rows account
1185
+ * for: a feed arrived or left under the newest page, which only the full
1186
+ * walk can place. An overflowed page, or a collection without a yardstick,
1187
+ * cannot say.
1188
+ *
1189
+ * @param collection - The collection peeked.
1190
+ * @param peek - What its newest page read.
1191
+ * @returns True when the total moved.
1192
+ */
1193
+ function rosterTotal_moved(collection, peek) {
1194
+ const expected = rosterTotals[collection];
1195
+ if (expected === null || peek.total === null || peek.overflow)
1196
+ return false;
1197
+ return peek.total !== expected + peek.newer.length;
1198
+ }
1199
+ /**
1200
+ * Brings the roster up to date without walking the whole index: each
1201
+ * collection's newest page is read, its feeds newer than the roster are
1202
+ * adopted, and its total is held against the last walk's. A total that
1203
+ * moved otherwise — a feed shared to this identity, one unshared, one
1204
+ * deleted — promotes the visit to the full walk, which places it. A page
1205
+ * that overflowed is followed by the walk of everything newer than the
1206
+ * roster, by id.
1051
1207
  *
1052
1208
  * @param cache - The process cache.
1053
1209
  * @param client - Connected chrisapi client.
1054
- * @returns The feed IDs added.
1210
+ * @returns The feed IDs added; after a promotion, the full walk's reconciliation targets.
1055
1211
  */
1056
1212
  async function procRosterDelta_run(cache, client) {
1057
1213
  const knownIDs = cache.feedIDs_get();
1058
- const minID = (knownIDs.length > 0 ? Math.max(...knownIDs) : 0) + 1;
1214
+ const maxKnown = knownIDs.length > 0 ? Math.max(...knownIDs) : 0;
1215
+ const ownedFetch = (params) => (0, cumin_1.feedsPage_get)(client, params);
1216
+ const publicFetch = async (params) => (await (0, cumin_1.publicFeedsPage_get)(client, params)) ?? { data: [], totalCount: 0 };
1217
+ const owned = await rosterCollection_peek(ownedFetch, maxKnown);
1218
+ const pub = await rosterCollection_peek(publicFetch, maxKnown);
1219
+ if (rosterTotal_moved('owned', owned) || rosterTotal_moved('public', pub)) {
1220
+ cache.rosterSync_progress('full');
1221
+ return procCache_build();
1222
+ }
1059
1223
  const fresh = new Map();
1060
- await procFeeds_index((params) => (0, cumin_1.feedsPage_get)(client, { ...params, min_id: minID }), fresh);
1061
- await procFeeds_index(async (params) => (await (0, cumin_1.publicFeedsPage_get)(client, { ...params, min_id: minID })) ?? { data: [], totalCount: 0 }, fresh);
1224
+ for (const feed of [...owned.newer, ...pub.newer])
1225
+ fresh.set(feed.id, feed);
1226
+ if (owned.overflow || pub.overflow) {
1227
+ const minID = maxKnown + 1;
1228
+ await procFeeds_index((params) => ownedFetch({ ...params, min_id: minID }), fresh);
1229
+ await procFeeds_index((params) => publicFetch({ ...params, min_id: minID }), fresh);
1230
+ }
1231
+ rosterTotals = { owned: owned.total, public: pub.total };
1062
1232
  for (const feed of fresh.values())
1063
1233
  cache.feed_add(feed);
1064
1234
  cache.arrivals_note(Array.from(fresh.keys()));
@@ -1183,6 +1353,82 @@ function procTopologyCatchup_reset() {
1183
1353
  function procTopologyCatchup_roster(cache) {
1184
1354
  return procTopologyCatchup_enqueue(cache.feedIDs_get());
1185
1355
  }
1356
+ /**
1357
+ * What a jump target names: a feed only (`feed_4389`), a node only
1358
+ * (`pl-dicom_repack_639204`, or `_639204`), or a bare number that may be
1359
+ * either. Null for anything else.
1360
+ *
1361
+ * @param target - What the operator typed.
1362
+ * @returns The id and which kinds it may name, or null.
1363
+ */
1364
+ function procTarget_parse(target) {
1365
+ const text = target.trim();
1366
+ const feed = /^feed_(\d+)$/.exec(text);
1367
+ if (feed !== null)
1368
+ return { id: Number(feed[1]), feed: true, node: false };
1369
+ const node = /^[\w.-]*_(\d+)$/.exec(text);
1370
+ if (node !== null)
1371
+ return { id: Number(node[1]), feed: false, node: true };
1372
+ if (/^\d+$/.test(text))
1373
+ return { id: Number(text), feed: true, node: true };
1374
+ return null;
1375
+ }
1376
+ /**
1377
+ * Finds where a jump target lands, asking CUBE live for what the roster
1378
+ * and the index lack: a feed shared since the roster was walked, or a node
1379
+ * in one. A node's feed is adopted into the roster on the way, so the
1380
+ * place the answer names can be entered at once. A bare number that is
1381
+ * both a feed and a node answers both; the caller asks which.
1382
+ *
1383
+ * @param target - A feed id, a node id, or a `feed_N` / `<plugin>_N` name.
1384
+ * @returns Every place it names that this identity can see; empty for none.
1385
+ */
1386
+ async function procTarget_locate(target) {
1387
+ const parsed = procTarget_parse(target);
1388
+ if (parsed === null)
1389
+ return [];
1390
+ const cache = (0, cumin_1.procCache_get)();
1391
+ const found = [];
1392
+ if (parsed.feed) {
1393
+ const feed = cache.feed_get(parsed.id) ?? (await procFeedAbsent_probe(parsed.id));
1394
+ if (feed !== null)
1395
+ found.push({ kind: 'feed', feedId: feed.id, name: `feed_${feed.id}`, title: feed.title });
1396
+ }
1397
+ if (parsed.node) {
1398
+ const node = await procNode_find(parsed.id);
1399
+ if (node !== null) {
1400
+ const feed = cache.feed_get(node.feedId) ?? (await procFeedAbsent_probe(node.feedId));
1401
+ if (feed !== null) {
1402
+ found.push({ kind: 'node', feedId: node.feedId, nodeId: parsed.id, name: `${node.pluginName}_${parsed.id}`, title: feed.title });
1403
+ }
1404
+ }
1405
+ }
1406
+ return found;
1407
+ }
1408
+ /**
1409
+ * A node's feed and plugin, from the index or, when the index lacks it,
1410
+ * from CUBE.
1411
+ *
1412
+ * @param nodeId - The plugin instance id.
1413
+ * @returns Its feed and plugin name, or null when CUBE shows no such node to this identity.
1414
+ */
1415
+ async function procNode_find(nodeId) {
1416
+ const known = (0, cumin_1.procCache_get)().instance_get(nodeId);
1417
+ if (known !== undefined)
1418
+ return { feedId: known.feedID, pluginName: known.pluginName };
1419
+ const client = await cumin_1.chrisConnection.client_get();
1420
+ if (!client)
1421
+ return null;
1422
+ try {
1423
+ const handle = await (0, cumin_1.pluginInstance_get)(client, nodeId);
1424
+ const feedId = Number(handle?.data?.feed_id);
1425
+ const pluginName = String(handle?.data?.plugin_name ?? '');
1426
+ return Number.isFinite(feedId) && feedId > 0 && pluginName !== '' ? { feedId, pluginName } : null;
1427
+ }
1428
+ catch {
1429
+ return null;
1430
+ }
1431
+ }
1186
1432
  /**
1187
1433
  * Forgets all visit bookkeeping (tests, and a cache cleared underneath us).
1188
1434
  */
@@ -1191,6 +1437,7 @@ function procVisitState_reset() {
1191
1437
  feedVisitInflight.clear();
1192
1438
  rosterWalkedAt = 0;
1193
1439
  rosterSyncInflight = null;
1440
+ rosterTotals = { owned: null, public: null };
1194
1441
  procTopologyCatchup_reset();
1195
1442
  }
1196
1443
  /**