@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonlyai/cli",
3
- "version": "0.1.80",
3
+ "version": "0.1.81",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI — connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -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
- const sinceMs = cursorIn ? cursorIn.ms : null;
107
- const since = cursorIn ? cursorIn.at : null;
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
- // The ids already delivered at the cursor's own millisecond. An item that
118
- // shares that millisecond but is not among them arrived after the cursor
119
- // was written, and is exactly the row a bare `ms > since` loses.
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 ? `(no items newer than ${since})` : '(queue is empty)');
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 — 550 open
233
- items is 11 requests — and /api/activity is limited to 60 a minute **keyed on
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. A 429 is backed off and retried twice on the server's own
236
- \`Retry-After\`, then reported. A cursor narrows what is PRINTED, not what is
237
- read: the route has no created-since filter yet, so \`--pod\` is the one scope
238
- that narrows the request itself.
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 at exactly that timestamp,
28
- * compared against `createdAt` — the only field on an item that never moves. The
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 ids are not decoration. A bare `ms > since` cursor loses any row that
33
- * shares its millisecond with the cursor and was inserted after the cursor was
34
- * written: the next run compares `ms > since` (false), nothing prints it, and no
35
- * later run can see it either, because the cursor never moves. That row is gone
36
- * silently and forever — the one failure this queue (576 open items, none ever
37
- * read) cannot afford. So the cursor carries the ids it already delivered at its
38
- * own millisecond and re-prints only the ones it did not: `ms > sinceMs ||
39
- * (ms === sinceMs && !delivered.has(id))`. A legacy plain-timestamp cursor parses
40
- * with an EMPTY set, which degrades toward re-printing rather than skipping.
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 millisecond boundary — see the module header.
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
- if (ms > sinceMs) return true;
230
- return ms === sinceMs && !delivered.has(cursorKeyOf(item));
264
+ return ms >= sinceMs;
231
265
  });
232
266
  };
233
267
 
234
268
  /**
235
- * Newest timestamp delivered, so the next call resumes exactly where this one
236
- * ended — plus the ids delivered AT that timestamp, which are what make the
237
- * boundary safe to resume from. Those ids are carried forward only while the
238
- * timestamp stays put; once it moves, they can never match again (the check needs
239
- * `ms === sinceMs`) and carrying them would grow the file for nothing.
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
- const at = new Date(max).toISOString();
250
- const boundary = (items || []).filter((item) => itemCreatedAtMs(item) === max)
251
- .map(cursorKeyOf).filter(Boolean);
252
- const carried = fallback && fallback.ms === max ? (fallback.ids || []) : [];
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: max, ids: [...new Set([...carried, ...boundary])], kind: kind || null,
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: { limit: API_PAGE_LIMIT, offset, ...(podId ? { podId } : {}) },
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
- if (Number.isInteger(meta.count) && items.length >= meta.count) break;
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 };