@commonlyai/cli 0.1.80 → 0.1.81
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/package.json +1 -1
- package/src/commands/inbox.js +31 -14
- package/src/lib/inbox.js +103 -29
package/package.json
CHANGED
package/src/commands/inbox.js
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
formatItemLine,
|
|
22
22
|
nextCursorFrom,
|
|
23
23
|
parseCursor,
|
|
24
|
+
parseWindowMs,
|
|
24
25
|
readCursorFile,
|
|
25
26
|
readInboxToken,
|
|
26
27
|
resolveAccountLabel,
|
|
@@ -84,6 +85,9 @@ export const runInboxList = async (opts = {}, deps = {}) => {
|
|
|
84
85
|
// Validated before the request: a typo in --kind must not cost a network
|
|
85
86
|
// round trip, and must not read a queue the caller did not ask for.
|
|
86
87
|
const kind = normalizeKind(opts.kind);
|
|
88
|
+
// Validated here with --kind, for the same reason: a typo must not cost a
|
|
89
|
+
// request, and a caller who asked for a window must not silently get another.
|
|
90
|
+
const windowMs = parseWindowMs(opts.window);
|
|
87
91
|
// Read and check the cursor BEFORE connecting: this is a local-file fact, and
|
|
88
92
|
// a misuse of the flags must not cost even the identity request that
|
|
89
93
|
// resolves the account.
|
|
@@ -103,23 +107,28 @@ export const runInboxList = async (opts = {}, deps = {}) => {
|
|
|
103
107
|
+ `--kind ${cursorIn.kind} --cursor-file <path>.${cursorIn.kind}.`,
|
|
104
108
|
);
|
|
105
109
|
}
|
|
106
|
-
|
|
107
|
-
|
|
110
|
+
// The window: W behind the cursor's own stamp, not at it. A row stamped before
|
|
111
|
+
// the cursor can commit after the read that wrote it, and it is that read's
|
|
112
|
+
// `since` — not the row — that made it invisible; a read starting W earlier
|
|
113
|
+
// sees it. The ids already delivered inside the window are what keep the
|
|
114
|
+
// re-read from printing the old rows again (see lib/inbox.js).
|
|
115
|
+
const sinceMs = cursorIn ? cursorIn.ms - windowMs : null;
|
|
116
|
+
const since = sinceMs === null ? null : new Date(sinceMs).toISOString();
|
|
108
117
|
|
|
109
118
|
const {
|
|
110
119
|
baseUrl, request, account,
|
|
111
120
|
} = await connect(opts, { env, fetchImpl, sleep });
|
|
112
121
|
|
|
113
|
-
const { items, meta } = await fetchQueue(request, { podId: opts.pod });
|
|
122
|
+
const { items, meta } = await fetchQueue(request, { podId: opts.pod, since });
|
|
114
123
|
const selected = filterItems(items, {
|
|
115
124
|
kind,
|
|
116
125
|
sinceMs,
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
126
|
+
// Everything already delivered inside the window. An item of the same id in
|
|
127
|
+
// the response is a re-delivery, not news; an item that merely shares its
|
|
128
|
+
// instant with one is news, which is why this dedupes by id and not by time.
|
|
120
129
|
seenIds: cursorIn ? cursorIn.ids : [],
|
|
121
130
|
});
|
|
122
|
-
const cursor = nextCursorFrom(selected, cursorIn, kind);
|
|
131
|
+
const cursor = nextCursorFrom(selected, cursorIn, kind, windowMs);
|
|
123
132
|
|
|
124
133
|
if (opts.json) {
|
|
125
134
|
log(JSON.stringify({
|
|
@@ -138,7 +147,7 @@ export const runInboxList = async (opts = {}, deps = {}) => {
|
|
|
138
147
|
const counts = countsLine(meta?.countsByKind);
|
|
139
148
|
log(`open: ${Number.isInteger(meta?.count) ? meta.count : items.length}${counts ? ` (${counts})` : ''}`);
|
|
140
149
|
if (selected.length === 0) {
|
|
141
|
-
log(since ? `(
|
|
150
|
+
log(since ? `(nothing new; the window since ${since} was re-read)` : '(queue is empty)');
|
|
142
151
|
}
|
|
143
152
|
for (const item of selected) log(formatItemLine(item));
|
|
144
153
|
log(`cursor: ${cursor ? cursor.at : ''}`);
|
|
@@ -226,16 +235,23 @@ Examples:
|
|
|
226
235
|
$ commonly inbox list --json | jq '.items[].title'
|
|
227
236
|
$ commonly inbox list --kind decision --pod <podId>
|
|
228
237
|
$ commonly inbox list --cursor-file ~/.commonly/inbox/connector-ops.cursor # only what is new
|
|
238
|
+
$ commonly inbox list --cursor-file ~/.commonly/inbox/c.cursor --window 300 # widen the re-read to 5m
|
|
229
239
|
$ commonly inbox ack <attentionItemId>
|
|
230
240
|
$ commonly inbox choose <decisionId> 2
|
|
231
241
|
|
|
232
|
-
Cost: \`list\` reads the WHOLE queue, one request per 50 items —
|
|
233
|
-
items is 11 requests
|
|
242
|
+
Cost: with no cursor, \`list\` reads the WHOLE queue, one request per 50 items —
|
|
243
|
+
550 open items is 11 requests. /api/activity is limited to 60 a minute **keyed on
|
|
234
244
|
the caller's IP**, so every session on one host (and the operator's browser)
|
|
235
|
-
shares that budget
|
|
236
|
-
\`Retry-After\`, then reported.
|
|
237
|
-
|
|
238
|
-
|
|
245
|
+
shares that budget; a 429 is backed off and retried twice on the server's own
|
|
246
|
+
\`Retry-After\`, then reported.
|
|
247
|
+
|
|
248
|
+
With \`--cursor-file\`, the read instead asks for \`createdAt >= cursor - W\`
|
|
249
|
+
(\`--window\`, default 60s), so a watcher tick is ONE request. W is the margin
|
|
250
|
+
over write-commit latency plus the writer's clock offset — a row is stamped before
|
|
251
|
+
its write commits, so resuming exactly at the cursor would lose it. Rows already
|
|
252
|
+
delivered inside W are dropped by id, which is the re-delivery the window is for.
|
|
253
|
+
The counts printed in the header still describe the whole open set; only the rows
|
|
254
|
+
inside the window are printed.
|
|
239
255
|
|
|
240
256
|
\`ack\` deliberately ships WITHOUT an \`--all\`: a burst of acks shares that same
|
|
241
257
|
per-IP budget, so a paced loop is the caller's decision and a bulk
|
|
@@ -253,6 +269,7 @@ their attention as runtime events, not from this queue.
|
|
|
253
269
|
.option('--token-file <path>', INBOX_TOKEN_HELP)
|
|
254
270
|
.option('--since <cursor>', 'ISO-8601 timestamp; only items created after it')
|
|
255
271
|
.option('--cursor-file <path>', 'Read the cursor from this file and write the advanced cursor back')
|
|
272
|
+
.option('--window <seconds>', 'Re-read this far behind the cursor, dropping ids already delivered (default 60; 0 = exact cursor)')
|
|
256
273
|
.option('--kind <kind>', `Only this kind: ${INBOX_KINDS.join('|')}`)
|
|
257
274
|
.option('--pod <podId>', 'Only this pod')
|
|
258
275
|
.option('--json', 'Emit one JSON object (with the resolved account and the cursor)')
|
package/src/lib/inbox.js
CHANGED
|
@@ -24,20 +24,30 @@
|
|
|
24
24
|
* `list` prints both, labelled, so neither command has to be discovered by
|
|
25
25
|
* failing first.
|
|
26
26
|
*
|
|
27
|
-
* The cursor is a TIMESTAMP PLUS the ids delivered
|
|
28
|
-
* compared against `createdAt` — the only field on an item that never moves.
|
|
29
|
-
* queue's ORDER moves as items are acknowledged (priority, then newest first),
|
|
27
|
+
* The cursor is a TIMESTAMP PLUS the ids delivered INSIDE A WINDOW that ends at
|
|
28
|
+
* it, compared against `createdAt` — the only field on an item that never moves.
|
|
29
|
+
* The queue's ORDER moves as items are acknowledged (priority, then newest first),
|
|
30
30
|
* so an offset cursor would skip rows; `createdAt` cannot.
|
|
31
31
|
*
|
|
32
|
-
* The
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
32
|
+
* The window is what makes the cursor safe, and the ids are not decoration. A row
|
|
33
|
+
* is stamped from the WRITER's clock at the moment of the write and becomes
|
|
34
|
+
* visible only when that write commits, so a row can carry a stamp before the
|
|
35
|
+
* cursor and appear after the read that wrote it — and it is not late, it is
|
|
36
|
+
* simply invisible to a reader who resumes at the cursor. Starting a read W
|
|
37
|
+
* behind the cursor (`--window`, default 60s) makes that row visible on the next
|
|
38
|
+
* tick; the ids already delivered inside that window are then dropped, so the
|
|
39
|
+
* re-read prints only what the caller has not seen. A bare `ms > since` cursor
|
|
40
|
+
* loses such a row silently and forever — the one failure this queue (576 open
|
|
41
|
+
* items, none ever read) cannot afford, which is why the recoverable direction
|
|
42
|
+
* (re-printing, deduped by `attentionItemId`) is always the one taken here. A
|
|
43
|
+
* legacy plain-timestamp cursor parses with an EMPTY set, which degrades toward
|
|
44
|
+
* re-printing rather than skipping.
|
|
45
|
+
*
|
|
46
|
+
* The window is NOT a guess about clock skew alone: W must exceed the write's
|
|
47
|
+
* commit latency PLUS the writer's clock offset, and a caller that sets it to 0
|
|
48
|
+
* gets the exact-cursor behaviour (resume at the cursor, dedupe only the ids
|
|
49
|
+
* stamped with it). The ids are pruned to the window on every write, so the
|
|
50
|
+
* cursor file stays the size of one window of deliveries instead of growing.
|
|
41
51
|
*
|
|
42
52
|
* THE GENERAL RULE, which both cursor bugs above are instances of: a resume
|
|
43
53
|
* cursor is valid only for the query that wrote it. `--kind` is a DIFFERENT
|
|
@@ -168,6 +178,24 @@ export const parseCursor = (value) => {
|
|
|
168
178
|
};
|
|
169
179
|
};
|
|
170
180
|
|
|
181
|
+
/**
|
|
182
|
+
* `--window <seconds>`: how far behind the cursor a read starts. The default is
|
|
183
|
+
* the spec's 60s — the margin over in-cluster write latency and clock offset —
|
|
184
|
+
* and 0 restores the exact-cursor behaviour. Refused rather than clamped: a
|
|
185
|
+
* negative or unreadable window would silently change which rows a read can see,
|
|
186
|
+
* and that is the failure this whole module is built to avoid.
|
|
187
|
+
*/
|
|
188
|
+
export const DEFAULT_WINDOW_MS = 60 * 1000;
|
|
189
|
+
|
|
190
|
+
export const parseWindowMs = (value, fallback = DEFAULT_WINDOW_MS) => {
|
|
191
|
+
if (value === undefined || value === null || value === '') return fallback;
|
|
192
|
+
const seconds = Number(String(value).trim());
|
|
193
|
+
if (!Number.isFinite(seconds) || seconds < 0) {
|
|
194
|
+
throw new InboxRefusal(`--window must be a number of seconds >= 0, not ${value}.`);
|
|
195
|
+
}
|
|
196
|
+
return Math.round(seconds * 1000);
|
|
197
|
+
};
|
|
198
|
+
|
|
171
199
|
/** A missing cursor file is "nothing seen yet", not an error. */
|
|
172
200
|
export const readCursorFile = (filePath) => {
|
|
173
201
|
if (!filePath) return null;
|
|
@@ -217,28 +245,46 @@ export const cursorKeyOf = (item) => String(item?.attentionItemId || item?.id ||
|
|
|
217
245
|
/**
|
|
218
246
|
* An item with an unparseable `createdAt` is KEPT under a cursor: dropping it
|
|
219
247
|
* would lose the row silently, and re-printing is the recoverable error. The same
|
|
220
|
-
* argument decides the
|
|
248
|
+
* argument decides the boundary — see the module header.
|
|
249
|
+
*
|
|
250
|
+
* `seenIds` is the set delivered inside the window and is dropped BY ID, whatever
|
|
251
|
+
* the item's timestamp: the window exists to re-serve those rows to the server
|
|
252
|
+
* call, and the id is the only thing that can say "the caller already saw this
|
|
253
|
+
* one". `sinceMs` is the window's own start and is inclusive, matching the
|
|
254
|
+
* route's bound, so a row stamped exactly at it still prints.
|
|
221
255
|
*/
|
|
222
256
|
export const filterItems = (items, { kind = null, sinceMs = null, seenIds = [] } = {}) => {
|
|
223
257
|
const delivered = seenIds instanceof Set ? seenIds : new Set(seenIds);
|
|
224
258
|
return (items || []).filter((item) => {
|
|
225
259
|
if (kind && item?.kind !== kind) return false;
|
|
260
|
+
if (delivered.has(cursorKeyOf(item))) return false;
|
|
226
261
|
if (sinceMs === null) return true;
|
|
227
262
|
const ms = itemCreatedAtMs(item);
|
|
228
263
|
if (ms === null) return true;
|
|
229
|
-
|
|
230
|
-
return ms === sinceMs && !delivered.has(cursorKeyOf(item));
|
|
264
|
+
return ms >= sinceMs;
|
|
231
265
|
});
|
|
232
266
|
};
|
|
233
267
|
|
|
234
268
|
/**
|
|
235
|
-
* Newest timestamp
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
269
|
+
* Newest timestamp this query has covered — the previous cursor's stamp or the
|
|
270
|
+
* newest item printed, whichever is later — so the next call resumes from W behind
|
|
271
|
+
* it, plus every id delivered at or after that window's start, which is what the
|
|
272
|
+
* next read's window will re-serve and must therefore not print twice.
|
|
273
|
+
*
|
|
274
|
+
* The max is taken over the PRINTED items, not the server's `nextSince`, because
|
|
275
|
+
* `--kind` is filtered on this side: the server's window may hold rows of other
|
|
276
|
+
* kinds that this read will never print, and advancing the cursor onto them would
|
|
277
|
+
* move the high-water mark past a row of THIS kind that a later page has not
|
|
278
|
+
* delivered yet. When nothing printed, the cursor stays where it was — the
|
|
279
|
+
* fallback — so a quiet tick does not move the mark at all.
|
|
280
|
+
*
|
|
281
|
+
* The set is recomputed from the window plus the ids the previous cursor had not
|
|
282
|
+
* yet aged out of it, and both halves are load-bearing: this read's items are
|
|
283
|
+
* post-dedupe, so a row it dropped is absent from them and would be forgotten
|
|
284
|
+
* without the carry. The prune is what bounds the file at one window of
|
|
285
|
+
* deliveries instead of a list of everything ever printed.
|
|
240
286
|
*/
|
|
241
|
-
export const nextCursorFrom = (items, fallback = null, kind = null) => {
|
|
287
|
+
export const nextCursorFrom = (items, fallback = null, kind = null, windowMs = DEFAULT_WINDOW_MS) => {
|
|
242
288
|
let max = null;
|
|
243
289
|
for (const item of items || []) {
|
|
244
290
|
const ms = itemCreatedAtMs(item);
|
|
@@ -246,15 +292,33 @@ export const nextCursorFrom = (items, fallback = null, kind = null) => {
|
|
|
246
292
|
if (max === null || ms > max) max = ms;
|
|
247
293
|
}
|
|
248
294
|
if (max === null) return fallback;
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
295
|
+
// A high-water mark: the newest item printed, or the cursor that came in,
|
|
296
|
+
// whichever is later. A row printed from inside the window is older than the
|
|
297
|
+
// mark that delimited that window, and letting it drag the mark back would move
|
|
298
|
+
// the next read's `since` back with it — reopening ground this query has already
|
|
299
|
+
// covered and moving the prune cutoff that bounds the id set.
|
|
300
|
+
const mark = fallback && Number.isFinite(fallback.ms) && fallback.ms > max ? fallback.ms : max;
|
|
301
|
+
const at = new Date(mark).toISOString();
|
|
302
|
+
const cutoff = mark - windowMs;
|
|
303
|
+
const inWindow = (items || []).filter((item) => {
|
|
304
|
+
const ms = itemCreatedAtMs(item);
|
|
305
|
+
return ms !== null && ms >= cutoff;
|
|
306
|
+
}).map(cursorKeyOf).filter(Boolean);
|
|
307
|
+
// The ids from the previous cursor are NOT dead weight, and dropping them is a
|
|
308
|
+
// measurable bug: `items` is what this read SELECTED, so a row delivered last
|
|
309
|
+
// tick has already been filtered out of it, and the set built from `items` alone
|
|
310
|
+
// would forget it — then the read after that would print it again, and the
|
|
311
|
+
// dedupe would alternate every tick. They are carried only while the previous
|
|
312
|
+
// cursor's own stamp is still inside this window; past that the next read's
|
|
313
|
+
// `since` is later than every one of them, the server can never re-serve them,
|
|
314
|
+
// and keeping them would grow the file for nothing.
|
|
315
|
+
const carried = fallback && Number.isFinite(fallback.ms) && fallback.ms >= cutoff
|
|
316
|
+
? (fallback.ids || []) : [];
|
|
253
317
|
// The kind is the CURRENT read's, never inherited: the cursor describes the
|
|
254
318
|
// query that just ran, and a mismatch with the file's own kind is what the
|
|
255
319
|
// command refuses on before it makes a request.
|
|
256
320
|
return {
|
|
257
|
-
at, ms:
|
|
321
|
+
at, ms: mark, ids: [...new Set([...carried, ...inWindow])], kind: kind || null,
|
|
258
322
|
};
|
|
259
323
|
};
|
|
260
324
|
|
|
@@ -371,21 +435,31 @@ export const createInboxRequest = ({
|
|
|
371
435
|
* that reports `hasMore: true` beside an empty page would otherwise spin
|
|
372
436
|
* forever, and one that lies about `count` would page past the end.
|
|
373
437
|
*/
|
|
374
|
-
export const fetchQueue = async (request, { podId } = {}) => {
|
|
438
|
+
export const fetchQueue = async (request, { podId, since } = {}) => {
|
|
375
439
|
const items = [];
|
|
376
440
|
let meta = {};
|
|
377
441
|
let offset = 0;
|
|
378
442
|
for (;;) {
|
|
379
443
|
// eslint-disable-next-line no-await-in-loop
|
|
380
444
|
const page = await request('/api/activity/decision-queue', {
|
|
381
|
-
params: {
|
|
445
|
+
params: {
|
|
446
|
+
limit: API_PAGE_LIMIT,
|
|
447
|
+
offset,
|
|
448
|
+
...(podId ? { podId } : {}),
|
|
449
|
+
// Only sent when there is a cursor: a first read is asking for everything
|
|
450
|
+
// the account has, and a bound there would silently hide its older half.
|
|
451
|
+
...(since ? { since } : {}),
|
|
452
|
+
},
|
|
382
453
|
});
|
|
383
454
|
const batch = Array.isArray(page?.items) ? page.items : [];
|
|
384
455
|
items.push(...batch);
|
|
385
456
|
meta = page || {};
|
|
386
457
|
if (!meta.hasMore) break;
|
|
387
458
|
if (batch.length === 0) break;
|
|
388
|
-
|
|
459
|
+
// A bounded call reports the WHOLE open set in `count` and the window in
|
|
460
|
+
// `windowCount`, so stopping on `count` would page past the window's end.
|
|
461
|
+
const reported = since && Number.isInteger(meta.windowCount) ? meta.windowCount : meta.count;
|
|
462
|
+
if (Number.isInteger(reported) && items.length >= reported) break;
|
|
389
463
|
offset += batch.length;
|
|
390
464
|
}
|
|
391
465
|
return { items, meta };
|