@pellux/goodvibes-daemon 1.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +383 -0
- package/LICENSE +21 -0
- package/README.md +125 -0
- package/bin/goodvibes-daemon +100 -0
- package/bin/launcher-support.js +226 -0
- package/package.json +96 -0
- package/scripts/check-bun.sh +20 -0
- package/scripts/postinstall.js +244 -0
- package/src/cli/command-catalog.ts +828 -0
- package/src/cli/completion.ts +299 -0
- package/src/cli/help.ts +167 -0
- package/src/cli/index.ts +21 -0
- package/src/cli/parser.ts +55 -0
- package/src/cli/surface-catalog.ts +26 -0
- package/src/cli/types.ts +63 -0
- package/src/cluster/daemon-ws-call.ts +235 -0
- package/src/cluster/raw-reply-route.ts +111 -0
- package/src/config/checkpoint-settings.ts +113 -0
- package/src/config/run-daemon-config-migration.ts +47 -0
- package/src/config/secret-config.ts +175 -0
- package/src/config/secrets.ts +71 -0
- package/src/config/surface.ts +24 -0
- package/src/core/pairing-banner.ts +82 -0
- package/src/daemon/cli.ts +878 -0
- package/src/daemon/config-command.ts +281 -0
- package/src/daemon/handlers/context.ts +29 -0
- package/src/daemon/handlers/contracts.ts +43 -0
- package/src/daemon/handlers/credentials.ts +139 -0
- package/src/daemon/handlers/drafts/draft-store.ts +427 -0
- package/src/daemon/handlers/drafts/index.ts +17 -0
- package/src/daemon/handlers/drafts/register.ts +331 -0
- package/src/daemon/handlers/errors.ts +18 -0
- package/src/daemon/handlers/inbox/aggregator.ts +375 -0
- package/src/daemon/handlers/inbox/cursor-store.ts +512 -0
- package/src/daemon/handlers/inbox/index.ts +221 -0
- package/src/daemon/handlers/inbox/mapping.ts +192 -0
- package/src/daemon/handlers/inbox/poller.ts +239 -0
- package/src/daemon/handlers/inbox/provider-adapter.ts +171 -0
- package/src/daemon/handlers/inbox/providers/discord.ts +276 -0
- package/src/daemon/handlers/inbox/providers/email.ts +176 -0
- package/src/daemon/handlers/inbox/providers/imap-client.ts +300 -0
- package/src/daemon/handlers/inbox/providers/route-util.ts +24 -0
- package/src/daemon/handlers/inbox/providers/slack.ts +287 -0
- package/src/daemon/handlers/index.ts +117 -0
- package/src/daemon/handlers/register.ts +180 -0
- package/src/daemon/handlers/remote/backends/cloud-terminal.ts +143 -0
- package/src/daemon/handlers/remote/backends/docker.ts +79 -0
- package/src/daemon/handlers/remote/backends/index.ts +40 -0
- package/src/daemon/handlers/remote/backends/local-process.ts +113 -0
- package/src/daemon/handlers/remote/backends/process-runner.ts +127 -0
- package/src/daemon/handlers/remote/backends/ssh.ts +126 -0
- package/src/daemon/handlers/remote/backends/types.ts +97 -0
- package/src/daemon/handlers/remote/dispatcher.ts +181 -0
- package/src/daemon/handlers/remote/index.ts +120 -0
- package/src/daemon/handlers/remote/peer-registry.ts +357 -0
- package/src/daemon/handlers/remote/service.ts +191 -0
- package/src/daemon/handlers/routing/inbox-bridge.ts +71 -0
- package/src/daemon/handlers/routing/index.ts +261 -0
- package/src/daemon/handlers/routing/route-store.ts +319 -0
- package/src/daemon/handlers/routing/routing-resolver.ts +75 -0
- package/src/daemon/handlers/sqlite-store.ts +303 -0
- package/src/daemon/handlers/triage/index.ts +57 -0
- package/src/daemon/handlers/triage/integration.ts +213 -0
- package/src/daemon/handlers/triage/pipeline.ts +274 -0
- package/src/daemon/handlers/triage/scorer.ts +287 -0
- package/src/daemon/handlers/triage/tagger/discord.ts +187 -0
- package/src/daemon/handlers/triage/tagger/imap.ts +384 -0
- package/src/daemon/handlers/triage/tagger/index.ts +184 -0
- package/src/daemon/handlers/triage/tagger/shared.ts +70 -0
- package/src/daemon/handlers/triage/tagger/slack.ts +69 -0
- package/src/daemon/handlers/triage/types.ts +50 -0
- package/src/daemon/lifecycle.ts +41 -0
- package/src/daemon/local-daemon-state.ts +233 -0
- package/src/daemon/pair-command.ts +301 -0
- package/src/daemon/provision-wake-model.ts +81 -0
- package/src/daemon/send/channels.ts +200 -0
- package/src/daemon/send/command.ts +333 -0
- package/src/daemon/send/composition.ts +100 -0
- package/src/daemon/send/failure-text.ts +93 -0
- package/src/daemon/send/inert-text.ts +225 -0
- package/src/daemon/send/stdin.ts +24 -0
- package/src/daemon/service-commands.ts +530 -0
- package/src/daemon/sessions-command.ts +209 -0
- package/src/daemon/status-command.ts +481 -0
- package/src/daemon/webui-command.ts +339 -0
- package/src/runtime/boot-tasks.ts +110 -0
- package/src/runtime/cluster-composition.ts +124 -0
- package/src/runtime/cluster-group-composition.ts +284 -0
- package/src/runtime/conversation-rewind-port.ts +171 -0
- package/src/runtime/credential-composition.ts +54 -0
- package/src/runtime/daemon-handler-composition.ts +76 -0
- package/src/runtime/device-posture-composition.ts +115 -0
- package/src/runtime/disposal-wiring.ts +101 -0
- package/src/runtime/fleet-needs-input-push.ts +61 -0
- package/src/runtime/fleet-services.ts +41 -0
- package/src/runtime/hosted-session-composition.ts +128 -0
- package/src/runtime/index.ts +100 -0
- package/src/runtime/knowledge-services.ts +101 -0
- package/src/runtime/legacy-daemon-migration.ts +605 -0
- package/src/runtime/legacy-daemon-reconcile.ts +448 -0
- package/src/runtime/mail-composition.ts +65 -0
- package/src/runtime/notification-dispatch.ts +86 -0
- package/src/runtime/plugin-composition.ts +111 -0
- package/src/runtime/runtime-services-types.ts +268 -0
- package/src/runtime/services.ts +756 -0
- package/src/runtime/trigger-services.ts +62 -0
- package/src/runtime/trust/checkpoint-eligibility.ts +138 -0
- package/src/runtime/trust/trust-gated-approvals.ts +169 -0
- package/src/runtime/update-check.ts +61 -0
- package/src/runtime/workspace-checkpointing.ts +116 -0
- package/src/testing/daemon-fixture.ts +276 -0
- package/src/testing/hosted-session-failures.ts +92 -0
- package/src/version.ts +26 -0
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// The provider-inbound aggregator behind `channels.inbox.list`.
|
|
3
|
+
//
|
|
4
|
+
// ── What it reads, and why that is the live answer ─────────────────────────
|
|
5
|
+
//
|
|
6
|
+
// It serves the daemon's SYNCED MIRROR (cursor-store.ts), not a fresh remote
|
|
7
|
+
// fetch per call. That is a deliberate design decision, not a shortcut:
|
|
8
|
+
//
|
|
9
|
+
// * The mirror is already live. Each adapter polls its provider on that
|
|
10
|
+
// provider's own cadence (Slack/Discord 30s, email 60s) and writes what it
|
|
11
|
+
// pulls straight into the store — that is what the triage pipeline scores.
|
|
12
|
+
// Reading it IS reading what has arrived.
|
|
13
|
+
// * A fetch-per-call would put a third-party rate limit behind a read verb
|
|
14
|
+
// any client may call at any rate, so one impatient UI could get every
|
|
15
|
+
// other consumer throttled by Slack.
|
|
16
|
+
// * The cluster hands FETCHING for each inbox account to one elected node
|
|
17
|
+
// (runtime/cluster-composition.ts). A read that fetched would make every
|
|
18
|
+
// standby node fetch too, which is exactly the double-read the election
|
|
19
|
+
// exists to prevent — while the READ is deliberately ungated so a standby
|
|
20
|
+
// still answers.
|
|
21
|
+
// * Triage scores are applied as items are persisted. Items fetched inline
|
|
22
|
+
// would arrive unscored, so the verb would answer two different shapes
|
|
23
|
+
// depending on when you called it.
|
|
24
|
+
// * A provider outage would turn a read into a hang or a 500 instead of a
|
|
25
|
+
// partial answer with a named cause.
|
|
26
|
+
//
|
|
27
|
+
// The cost of serving a mirror is that its age is not visible in the items. So
|
|
28
|
+
// the aggregator does not leave it implicit: `providers` reports every provider
|
|
29
|
+
// this node knows about on EVERY call — its state, when it last synced, how
|
|
30
|
+
// much of the mirror is its, and whether this node is the one fetching it.
|
|
31
|
+
//
|
|
32
|
+
// ── Honest partial results ────────────────────────────────────────────────
|
|
33
|
+
//
|
|
34
|
+
// A provider whose last sync failed contributes no items AND says so, and the
|
|
35
|
+
// answer's `partial` flag is true. The failure mode this rules out is the one
|
|
36
|
+
// where a UI renders four Slack messages and no mail, and nobody can tell
|
|
37
|
+
// whether the mailbox is quiet or broken.
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
import type { InboxCursorStore, InboxPosition } from './cursor-store.ts';
|
|
41
|
+
import type { InboundPoller, ProviderStatus } from './poller.ts';
|
|
42
|
+
import type { InboundChannelItem } from './provider-adapter.ts';
|
|
43
|
+
import { HandlerError } from '../errors.ts';
|
|
44
|
+
|
|
45
|
+
export const DEFAULT_LIMIT = 50;
|
|
46
|
+
export const MAX_LIMIT = 500;
|
|
47
|
+
|
|
48
|
+
/** SDK `channels.inbox.list` input. */
|
|
49
|
+
export interface InboxListInput {
|
|
50
|
+
provider?: string;
|
|
51
|
+
limit?: number;
|
|
52
|
+
since?: number;
|
|
53
|
+
cursor?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** One item in the SDK CHANNEL_INBOX_ITEM_SCHEMA wire shape. */
|
|
57
|
+
export interface ChannelInboxItem {
|
|
58
|
+
id: string;
|
|
59
|
+
provider: string;
|
|
60
|
+
kind: string;
|
|
61
|
+
/** Redacted sender token (sha256First, 16 hex). Never the raw id. */
|
|
62
|
+
from: string;
|
|
63
|
+
subject?: string;
|
|
64
|
+
bodyPreview: string;
|
|
65
|
+
receivedAt: number;
|
|
66
|
+
unread: boolean;
|
|
67
|
+
routeId?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Per-provider standing, one entry per provider this node knows about, on every
|
|
72
|
+
* call. See CHANNEL_INBOX_PROVIDER_STATUS_SCHEMA in the SDK catalog for the
|
|
73
|
+
* meaning of each `state`.
|
|
74
|
+
*/
|
|
75
|
+
export interface ChannelInboxProviderStatus {
|
|
76
|
+
provider: string;
|
|
77
|
+
state: 'ready' | 'empty' | 'unconfigured' | 'error' | 'pending';
|
|
78
|
+
/** Items this provider contributed to the page being returned. */
|
|
79
|
+
itemCount: number;
|
|
80
|
+
/** Items this provider holds in the mirror under the same filter. */
|
|
81
|
+
storedCount: number;
|
|
82
|
+
configured?: boolean;
|
|
83
|
+
lastSyncAt?: number;
|
|
84
|
+
/** Whether THIS node is currently fetching this provider. */
|
|
85
|
+
syncing?: boolean;
|
|
86
|
+
error?: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** SDK `channels.inbox.list` output. */
|
|
90
|
+
export interface InboxListOutput {
|
|
91
|
+
items: ChannelInboxItem[];
|
|
92
|
+
total: number;
|
|
93
|
+
truncated: boolean;
|
|
94
|
+
hasMore: boolean;
|
|
95
|
+
cursor?: string;
|
|
96
|
+
nextCursor?: string;
|
|
97
|
+
providers: ChannelInboxProviderStatus[];
|
|
98
|
+
partial: boolean;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The normalized read the aggregator performs. */
|
|
102
|
+
export interface InboxListQuery {
|
|
103
|
+
providers?: string[];
|
|
104
|
+
limit: number;
|
|
105
|
+
since?: number;
|
|
106
|
+
after?: InboxPosition;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Everything the aggregator reads from. Both are already-built collaborators. */
|
|
110
|
+
export interface InboxAggregatorSources {
|
|
111
|
+
readonly store: InboxCursorStore;
|
|
112
|
+
readonly poller: InboundPoller;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// Page cursors
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* A page cursor is the position of the last item handed out, base64url-encoded
|
|
121
|
+
* so it reads as opaque and callers do not build one by hand. It is NOT the
|
|
122
|
+
* `cursor` field in the answer — that one is the freshness watermark a caller
|
|
123
|
+
* feeds back as `since`. Two different questions ("what is new" vs "the next
|
|
124
|
+
* page down"), so two different values; collapsing them is how a paging client
|
|
125
|
+
* ends up silently re-reading page one forever.
|
|
126
|
+
*/
|
|
127
|
+
export function encodePageCursor(position: InboxPosition): string {
|
|
128
|
+
return Buffer.from(`${position.receivedAt}:${position.id}`, 'utf8').toString('base64url');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Decode a page cursor, or refuse. A malformed cursor is a 400 naming the
|
|
133
|
+
* field, never a silently-ignored one: quietly restarting from the top would
|
|
134
|
+
* hand a paging caller the same first page forever and look like data loss
|
|
135
|
+
* further down.
|
|
136
|
+
*/
|
|
137
|
+
export function decodePageCursor(raw: string): InboxPosition {
|
|
138
|
+
let decoded = '';
|
|
139
|
+
try {
|
|
140
|
+
decoded = Buffer.from(raw, 'base64url').toString('utf8');
|
|
141
|
+
} catch {
|
|
142
|
+
throw invalidCursor();
|
|
143
|
+
}
|
|
144
|
+
const split = decoded.indexOf(':');
|
|
145
|
+
if (split <= 0) throw invalidCursor();
|
|
146
|
+
const receivedAt = Number(decoded.slice(0, split));
|
|
147
|
+
const id = decoded.slice(split + 1);
|
|
148
|
+
if (!Number.isFinite(receivedAt) || receivedAt < 0 || id.length === 0) throw invalidCursor();
|
|
149
|
+
return { receivedAt: Math.floor(receivedAt), id };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function invalidCursor(): HandlerError {
|
|
153
|
+
return new HandlerError(
|
|
154
|
+
'cursor is not a cursor this method issued; pass back a nextCursor value verbatim, or omit it to start from the newest item',
|
|
155
|
+
'INVALID_ARGUMENT',
|
|
156
|
+
400,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
// Input
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Normalize the invocation's params.
|
|
166
|
+
*
|
|
167
|
+
* Both sources are read because both are real: the advertised REST path is a
|
|
168
|
+
* GET, so `?limit=10` arrives as a query STRING, while a methodId invoke can
|
|
169
|
+
* carry the same field as a number in the body. A handler that read only the
|
|
170
|
+
* body would answer the default page to every plain-REST caller and look like
|
|
171
|
+
* it was ignoring them.
|
|
172
|
+
*/
|
|
173
|
+
export function normalizeInboxQuery(
|
|
174
|
+
body: unknown,
|
|
175
|
+
query: Readonly<Record<string, string>> = {},
|
|
176
|
+
): InboxListQuery {
|
|
177
|
+
const fromBody = (body ?? {}) as InboxListInput;
|
|
178
|
+
const provider = firstString(query.provider, fromBody.provider);
|
|
179
|
+
const limitRaw = firstNumber(query.limit, fromBody.limit);
|
|
180
|
+
const sinceRaw = firstNumber(query.since, fromBody.since);
|
|
181
|
+
const cursor = firstString(query.cursor, fromBody.cursor);
|
|
182
|
+
|
|
183
|
+
const limit = limitRaw === undefined
|
|
184
|
+
? DEFAULT_LIMIT
|
|
185
|
+
: Math.min(Math.max(1, Math.floor(limitRaw)), MAX_LIMIT);
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
...(provider ? { providers: [provider] } : {}),
|
|
189
|
+
limit,
|
|
190
|
+
...(sinceRaw !== undefined && sinceRaw >= 0 ? { since: Math.floor(sinceRaw) } : {}),
|
|
191
|
+
...(cursor ? { after: decodePageCursor(cursor) } : {}),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function firstString(...candidates: (string | undefined)[]): string | undefined {
|
|
196
|
+
for (const candidate of candidates) {
|
|
197
|
+
if (typeof candidate === 'string' && candidate.length > 0) return candidate;
|
|
198
|
+
}
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function firstNumber(...candidates: (string | number | undefined)[]): number | undefined {
|
|
203
|
+
for (const candidate of candidates) {
|
|
204
|
+
if (candidate === undefined || candidate === null || candidate === '') continue;
|
|
205
|
+
const value = Number(candidate);
|
|
206
|
+
if (Number.isFinite(value)) return value;
|
|
207
|
+
}
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
// The aggregate read
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
/** Map a daemon-internal item onto the SDK CHANNEL_INBOX_ITEM_SCHEMA wire shape. */
|
|
216
|
+
export function toWireItem(item: InboundChannelItem): ChannelInboxItem {
|
|
217
|
+
const wire: ChannelInboxItem = {
|
|
218
|
+
id: item.id,
|
|
219
|
+
provider: item.provider,
|
|
220
|
+
kind: item.kind,
|
|
221
|
+
from: item.fromDigest,
|
|
222
|
+
bodyPreview: item.bodyPreview,
|
|
223
|
+
receivedAt: item.receivedAt,
|
|
224
|
+
unread: item.unread,
|
|
225
|
+
};
|
|
226
|
+
if (item.subjectPreview.length > 0) wire.subject = item.subjectPreview;
|
|
227
|
+
if (item.routeId != null) wire.routeId = item.routeId;
|
|
228
|
+
return wire;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Merge the mirror into one bounded, paginated page and attach every provider's
|
|
233
|
+
* standing to it.
|
|
234
|
+
*
|
|
235
|
+
* The merge itself is the store's ordering (receivedAt DESC, id ASC across all
|
|
236
|
+
* providers), so items interleave by arrival rather than being grouped by
|
|
237
|
+
* provider — an inbox is a timeline, and each item carries its own `provider`
|
|
238
|
+
* so attribution survives the merge.
|
|
239
|
+
*/
|
|
240
|
+
export function aggregateInbox(
|
|
241
|
+
sources: InboxAggregatorSources,
|
|
242
|
+
query: InboxListQuery,
|
|
243
|
+
): InboxListOutput {
|
|
244
|
+
const { store, poller } = sources;
|
|
245
|
+
const providers = query.providers;
|
|
246
|
+
|
|
247
|
+
// One row past the page. `hasMore` then reports what the store actually
|
|
248
|
+
// holds rather than being inferred from "the page came back full", which is
|
|
249
|
+
// wrong exactly when the last page is full.
|
|
250
|
+
const fetched = store.listItems({
|
|
251
|
+
...(providers ? { providers } : {}),
|
|
252
|
+
...(query.since !== undefined ? { since: query.since } : {}),
|
|
253
|
+
...(query.after ? { after: query.after } : {}),
|
|
254
|
+
limit: query.limit + 1,
|
|
255
|
+
});
|
|
256
|
+
const hasMore = fetched.length > query.limit;
|
|
257
|
+
const pageItems = hasMore ? fetched.slice(0, query.limit) : fetched;
|
|
258
|
+
const items = pageItems.map(toWireItem);
|
|
259
|
+
|
|
260
|
+
const total = store.countItems(providers, query.since);
|
|
261
|
+
const storedByProvider = store.countItemsByProvider(providers, query.since);
|
|
262
|
+
const pageByProvider = new Map<string, number>();
|
|
263
|
+
for (const item of pageItems) {
|
|
264
|
+
pageByProvider.set(item.provider, (pageByProvider.get(item.provider) ?? 0) + 1);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const statuses = describeProviders({
|
|
268
|
+
poller,
|
|
269
|
+
requested: providers,
|
|
270
|
+
storedByProvider,
|
|
271
|
+
pageByProvider,
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const output: InboxListOutput = {
|
|
275
|
+
items,
|
|
276
|
+
total,
|
|
277
|
+
// Retained spelling of hasMore. Both are set and always agree; the old name
|
|
278
|
+
// is what the already-shipped agent-side reader looks at.
|
|
279
|
+
truncated: hasMore,
|
|
280
|
+
hasMore,
|
|
281
|
+
providers: statuses,
|
|
282
|
+
// Only a CONFIGURED provider's failure makes the answer partial. An
|
|
283
|
+
// unconfigured one is not hiding anything.
|
|
284
|
+
partial: statuses.some((status) => status.state === 'error'),
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
const watermark = store.maxReceivedAt(providers);
|
|
288
|
+
const nextSince = Math.max(query.since ?? 0, watermark);
|
|
289
|
+
if (nextSince > 0) output.cursor = String(nextSince);
|
|
290
|
+
|
|
291
|
+
const last = pageItems[pageItems.length - 1];
|
|
292
|
+
if (hasMore && last) {
|
|
293
|
+
output.nextCursor = encodePageCursor({ receivedAt: last.receivedAt, id: last.id });
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return output;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Turn the poller's per-provider record into the wire statuses.
|
|
301
|
+
*
|
|
302
|
+
* Every provider the node has an adapter for appears, including ones that
|
|
303
|
+
* contributed nothing and ones nobody has configured — a provider missing from
|
|
304
|
+
* this list would be a hole the caller could not even see.
|
|
305
|
+
*/
|
|
306
|
+
function describeProviders(input: {
|
|
307
|
+
poller: InboundPoller;
|
|
308
|
+
requested: readonly string[] | undefined;
|
|
309
|
+
storedByProvider: Map<string, number>;
|
|
310
|
+
pageByProvider: Map<string, number>;
|
|
311
|
+
}): ChannelInboxProviderStatus[] {
|
|
312
|
+
const { poller, requested, storedByProvider, pageByProvider } = input;
|
|
313
|
+
const known = poller.snapshotStatuses(requested);
|
|
314
|
+
|
|
315
|
+
// A `?provider=` filter naming something this node has no adapter for gets an
|
|
316
|
+
// empty list and no status, which reads as "we have nothing" — so it is
|
|
317
|
+
// reported explicitly instead.
|
|
318
|
+
const reported = new Set(known.map((status) => status.id));
|
|
319
|
+
const unknownRequested = (requested ?? []).filter((id) => !reported.has(id));
|
|
320
|
+
|
|
321
|
+
const out: ChannelInboxProviderStatus[] = known.map((status) => {
|
|
322
|
+
const storedCount = storedByProvider.get(status.id) ?? 0;
|
|
323
|
+
const wire: ChannelInboxProviderStatus = {
|
|
324
|
+
provider: status.id,
|
|
325
|
+
state: wireState(status, storedCount),
|
|
326
|
+
itemCount: pageByProvider.get(status.id) ?? 0,
|
|
327
|
+
storedCount,
|
|
328
|
+
syncing: poller.isProviderRunning(status.id),
|
|
329
|
+
};
|
|
330
|
+
if (status.configured !== undefined) wire.configured = status.configured;
|
|
331
|
+
if (status.lastPolledAt !== undefined) wire.lastSyncAt = status.lastPolledAt;
|
|
332
|
+
// The error belongs to the `error` state only. Carrying the "missing
|
|
333
|
+
// credential" text on an `unconfigured` entry would read as a fault, and
|
|
334
|
+
// it is not one.
|
|
335
|
+
if (wire.state === 'error' && status.error !== undefined) wire.error = status.error;
|
|
336
|
+
return wire;
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
for (const id of unknownRequested) {
|
|
340
|
+
out.push({
|
|
341
|
+
provider: id,
|
|
342
|
+
state: 'unconfigured',
|
|
343
|
+
itemCount: 0,
|
|
344
|
+
storedCount: storedByProvider.get(id) ?? 0,
|
|
345
|
+
configured: false,
|
|
346
|
+
syncing: false,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return out;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Map the poller's internal state onto the wire vocabulary.
|
|
355
|
+
*
|
|
356
|
+
* The internal 'unavailable' covers two situations the wire deliberately keeps
|
|
357
|
+
* apart — nothing configured, versus configured and failing — because a caller
|
|
358
|
+
* does something different about each.
|
|
359
|
+
*
|
|
360
|
+
* ready-vs-empty is decided by the MIRROR, not by the last poll. The poller's
|
|
361
|
+
* 'ready' means "that fetch brought something in", which goes back to 'empty'
|
|
362
|
+
* on the next quiet tick even though the items are still sitting there; a
|
|
363
|
+
* reader of this verb is asking whether this provider has anything to show,
|
|
364
|
+
* so the stored count is what answers it.
|
|
365
|
+
*/
|
|
366
|
+
function wireState(
|
|
367
|
+
status: ProviderStatus,
|
|
368
|
+
storedCount: number,
|
|
369
|
+
): ChannelInboxProviderStatus['state'] {
|
|
370
|
+
if (!status.polled) return 'pending';
|
|
371
|
+
if (status.state === 'unavailable') {
|
|
372
|
+
return status.configured === false ? 'unconfigured' : 'error';
|
|
373
|
+
}
|
|
374
|
+
return storedCount > 0 ? 'ready' : 'empty';
|
|
375
|
+
}
|