@commonlyai/cli 0.1.79 → 0.1.80
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 +275 -0
- package/src/index.js +6 -0
- package/src/lib/inbox.js +398 -0
package/package.json
CHANGED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* commonly inbox <subcommand>
|
|
3
|
+
*
|
|
4
|
+
* list — read ONE account's own attention queue, newest first, optionally only what is new
|
|
5
|
+
* ack — acknowledge an item (takes the item's `attentionItemId`)
|
|
6
|
+
* choose — rule a decision card (takes the item's SOURCE id, i.e. the decision id)
|
|
7
|
+
*
|
|
8
|
+
* The account comes from `--token-file <path>` (or COMMONLY_TOKEN_FILE) and from
|
|
9
|
+
* nowhere else — see the header of ../lib/inbox.js for why that is a rule and
|
|
10
|
+
* not a preference. Every run prints the resolved account on its first line.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { resolveInstanceUrl } from '../lib/config.js';
|
|
14
|
+
import {
|
|
15
|
+
InboxRefusal,
|
|
16
|
+
createInboxRequest,
|
|
17
|
+
countsLine,
|
|
18
|
+
fetchQueue,
|
|
19
|
+
filterItems,
|
|
20
|
+
formatAccountLine,
|
|
21
|
+
formatItemLine,
|
|
22
|
+
nextCursorFrom,
|
|
23
|
+
parseCursor,
|
|
24
|
+
readCursorFile,
|
|
25
|
+
readInboxToken,
|
|
26
|
+
resolveAccountLabel,
|
|
27
|
+
resolveTokenFilePath,
|
|
28
|
+
writeCursorFile,
|
|
29
|
+
INBOX_KINDS,
|
|
30
|
+
TOKEN_FILE_ENV,
|
|
31
|
+
} from '../lib/inbox.js';
|
|
32
|
+
|
|
33
|
+
export const INBOX_TOKEN_HELP = `Account token file (or ${TOKEN_FILE_ENV})`;
|
|
34
|
+
|
|
35
|
+
const normalizeKind = (value) => {
|
|
36
|
+
if (value === undefined || value === null || value === '') return null;
|
|
37
|
+
const kind = String(value).trim().toLowerCase();
|
|
38
|
+
if (!INBOX_KINDS.includes(kind)) {
|
|
39
|
+
throw new InboxRefusal(`Unknown --kind ${value}. One of: ${INBOX_KINDS.join(', ')}.`);
|
|
40
|
+
}
|
|
41
|
+
return kind;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Resolve identity and build the request layer. Deliberately the only path to a
|
|
46
|
+
* request: every subcommand goes through it, so no subcommand can grow its own
|
|
47
|
+
* fallback to the saved login.
|
|
48
|
+
*/
|
|
49
|
+
const connect = async (opts, { env, fetchImpl, sleep }) => {
|
|
50
|
+
const tokenFile = resolveTokenFilePath(opts, env);
|
|
51
|
+
if (!tokenFile) {
|
|
52
|
+
throw new InboxRefusal(
|
|
53
|
+
`no token file. Pass --token-file <path> or set ${TOKEN_FILE_ENV}.\n`
|
|
54
|
+
+ 'This command reads ONE account\'s own queue; it never falls back to the saved login, because on a\n'
|
|
55
|
+
+ 'shared operator host that login belongs to someone else (Sam\'s queue is the wrong queue, silently).',
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
const token = readInboxToken(tokenFile);
|
|
59
|
+
const baseUrl = resolveInstanceUrl(opts.instance);
|
|
60
|
+
const request = createInboxRequest({
|
|
61
|
+
baseUrl, token, tokenFile, fetchImpl, sleep,
|
|
62
|
+
});
|
|
63
|
+
const user = await request('/api/auth/user');
|
|
64
|
+
return {
|
|
65
|
+
tokenFile, baseUrl, request, user, account: resolveAccountLabel(user),
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const reportRefusal = (err, error) => {
|
|
70
|
+
error(`refusing: ${err.message}`);
|
|
71
|
+
return 1;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const reportFailure = (err, error) => {
|
|
75
|
+
error(`Failed: ${err.message}`);
|
|
76
|
+
return 1;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export const runInboxList = async (opts = {}, deps = {}) => {
|
|
80
|
+
const {
|
|
81
|
+
env = process.env, fetchImpl = fetch, log = console.log, error = console.error, sleep = undefined,
|
|
82
|
+
} = deps;
|
|
83
|
+
try {
|
|
84
|
+
// Validated before the request: a typo in --kind must not cost a network
|
|
85
|
+
// round trip, and must not read a queue the caller did not ask for.
|
|
86
|
+
const kind = normalizeKind(opts.kind);
|
|
87
|
+
// Read and check the cursor BEFORE connecting: this is a local-file fact, and
|
|
88
|
+
// a misuse of the flags must not cost even the identity request that
|
|
89
|
+
// resolves the account.
|
|
90
|
+
const sinceRaw = opts.since !== undefined ? opts.since : readCursorFile(opts.cursorFile);
|
|
91
|
+
const cursorIn = parseCursor(sinceRaw);
|
|
92
|
+
// A resume cursor is valid only for the query that wrote it. Reading a
|
|
93
|
+
// kind-scoped cursor with a different filter — including no filter at all —
|
|
94
|
+
// would advance past items of other kinds that the earlier read never
|
|
95
|
+
// printed, and no later read could reach them. Only this direction is
|
|
96
|
+
// refused: a BROAD cursor read by a narrower query loses nothing, because
|
|
97
|
+
// that earlier read printed every kind.
|
|
98
|
+
if (cursorIn?.kind && cursorIn.kind !== kind) {
|
|
99
|
+
throw new InboxRefusal(
|
|
100
|
+
`the cursor file was written by a read with --kind ${cursorIn.kind}, and this read is `
|
|
101
|
+
+ `${kind ? `--kind ${kind}` : 'not filtered by kind'}. A cursor records how far THAT query got, so this `
|
|
102
|
+
+ `read would skip items of other kinds that one never printed. Keep one cursor file per kind, e.g. `
|
|
103
|
+
+ `--kind ${cursorIn.kind} --cursor-file <path>.${cursorIn.kind}.`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
const sinceMs = cursorIn ? cursorIn.ms : null;
|
|
107
|
+
const since = cursorIn ? cursorIn.at : null;
|
|
108
|
+
|
|
109
|
+
const {
|
|
110
|
+
baseUrl, request, account,
|
|
111
|
+
} = await connect(opts, { env, fetchImpl, sleep });
|
|
112
|
+
|
|
113
|
+
const { items, meta } = await fetchQueue(request, { podId: opts.pod });
|
|
114
|
+
const selected = filterItems(items, {
|
|
115
|
+
kind,
|
|
116
|
+
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.
|
|
120
|
+
seenIds: cursorIn ? cursorIn.ids : [],
|
|
121
|
+
});
|
|
122
|
+
const cursor = nextCursorFrom(selected, cursorIn, kind);
|
|
123
|
+
|
|
124
|
+
if (opts.json) {
|
|
125
|
+
log(JSON.stringify({
|
|
126
|
+
account,
|
|
127
|
+
instance: baseUrl,
|
|
128
|
+
count: selected.length,
|
|
129
|
+
// The bare timestamp: a caller feeding this back through --since loses
|
|
130
|
+
// the boundary ids, which degrades to re-printing that millisecond
|
|
131
|
+
// rather than to skipping it.
|
|
132
|
+
cursor: cursor ? cursor.at : null,
|
|
133
|
+
countsByKind: meta?.countsByKind || {},
|
|
134
|
+
items: selected,
|
|
135
|
+
}, null, 2));
|
|
136
|
+
} else {
|
|
137
|
+
log(formatAccountLine({ account, baseUrl }));
|
|
138
|
+
const counts = countsLine(meta?.countsByKind);
|
|
139
|
+
log(`open: ${Number.isInteger(meta?.count) ? meta.count : items.length}${counts ? ` (${counts})` : ''}`);
|
|
140
|
+
if (selected.length === 0) {
|
|
141
|
+
log(since ? `(no items newer than ${since})` : '(queue is empty)');
|
|
142
|
+
}
|
|
143
|
+
for (const item of selected) log(formatItemLine(item));
|
|
144
|
+
log(`cursor: ${cursor ? cursor.at : ''}`);
|
|
145
|
+
}
|
|
146
|
+
writeCursorFile(opts.cursorFile, cursor);
|
|
147
|
+
return 0;
|
|
148
|
+
} catch (err) {
|
|
149
|
+
if (err instanceof InboxRefusal) return reportRefusal(err, error);
|
|
150
|
+
return reportFailure(err, error);
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
export const runInboxAck = async (itemId, opts = {}, deps = {}) => {
|
|
155
|
+
const {
|
|
156
|
+
env = process.env, fetchImpl = fetch, log = console.log, error = console.error, sleep = undefined,
|
|
157
|
+
} = deps;
|
|
158
|
+
try {
|
|
159
|
+
if (!itemId) throw new InboxRefusal('an item id is required: commonly inbox ack <attentionItemId>');
|
|
160
|
+
const {
|
|
161
|
+
baseUrl, request, account,
|
|
162
|
+
} = await connect(opts, { env, fetchImpl, sleep });
|
|
163
|
+
log(formatAccountLine({ account, baseUrl }));
|
|
164
|
+
const result = await request(`/api/activity/${encodeURIComponent(itemId)}/acknowledge`, {
|
|
165
|
+
method: 'POST',
|
|
166
|
+
body: {},
|
|
167
|
+
});
|
|
168
|
+
log(`acked ${itemId}${result?.success === false ? ` — server said: ${result.error || 'not acknowledged'}` : ''}`);
|
|
169
|
+
return 0;
|
|
170
|
+
} catch (err) {
|
|
171
|
+
if (err instanceof InboxRefusal) return reportRefusal(err, error);
|
|
172
|
+
if (err?.status === 400) {
|
|
173
|
+
error(`Failed: ${err.message}`);
|
|
174
|
+
error('Acknowledge takes the item\'s attentionItemId — the id printed as `item=…` by `commonly inbox list`'
|
|
175
|
+
+ ' (the unlabelled source id is what `commonly inbox choose` takes).');
|
|
176
|
+
return 1;
|
|
177
|
+
}
|
|
178
|
+
return reportFailure(err, error);
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
export const runInboxChoose = async (decisionId, value, opts = {}, deps = {}) => {
|
|
183
|
+
const {
|
|
184
|
+
env = process.env, fetchImpl = fetch, log = console.log, error = console.error, sleep = undefined,
|
|
185
|
+
} = deps;
|
|
186
|
+
try {
|
|
187
|
+
if (!decisionId) throw new InboxRefusal('a decision id is required: commonly inbox choose <decisionId> <value>');
|
|
188
|
+
if (value === undefined) throw new InboxRefusal('a value is required: commonly inbox choose <decisionId> <value>');
|
|
189
|
+
const {
|
|
190
|
+
baseUrl, request, account,
|
|
191
|
+
} = await connect(opts, { env, fetchImpl, sleep });
|
|
192
|
+
log(formatAccountLine({ account, baseUrl }));
|
|
193
|
+
const result = await request(`/api/activity/decisions/${encodeURIComponent(decisionId)}/choose`, {
|
|
194
|
+
method: 'POST',
|
|
195
|
+
body: { value },
|
|
196
|
+
});
|
|
197
|
+
log(`chose ${decisionId} = ${value}`);
|
|
198
|
+
log(`-> ${JSON.stringify(result)}`);
|
|
199
|
+
return 0;
|
|
200
|
+
} catch (err) {
|
|
201
|
+
if (err instanceof InboxRefusal) return reportRefusal(err, error);
|
|
202
|
+
return reportFailure(err, error);
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
export const registerInbox = (program) => {
|
|
207
|
+
const inbox = program
|
|
208
|
+
.command('inbox')
|
|
209
|
+
.description("Read and act on ONE account's own attention queue");
|
|
210
|
+
|
|
211
|
+
inbox.addHelpText('after', `
|
|
212
|
+
The account is read from a token file and from nowhere else:
|
|
213
|
+
|
|
214
|
+
$ commonly inbox list --token-file ~/.commonly/bin/connector-ops-token
|
|
215
|
+
$ COMMONLY_TOKEN_FILE=~/.commonly/bin/lily-token commonly inbox list
|
|
216
|
+
|
|
217
|
+
There is no fallback to the saved login. On a host where \`commonly login\` was
|
|
218
|
+
run once as somebody else, a fallback would print that person's queue while the
|
|
219
|
+
operator believed they were reading their own. Every run therefore names the
|
|
220
|
+
account it resolved on its first line.
|
|
221
|
+
|
|
222
|
+
With neither --token-file nor COMMONLY_TOKEN_FILE set, the command refuses
|
|
223
|
+
before it makes a request.
|
|
224
|
+
|
|
225
|
+
Examples:
|
|
226
|
+
$ commonly inbox list --json | jq '.items[].title'
|
|
227
|
+
$ commonly inbox list --kind decision --pod <podId>
|
|
228
|
+
$ commonly inbox list --cursor-file ~/.commonly/inbox/connector-ops.cursor # only what is new
|
|
229
|
+
$ commonly inbox ack <attentionItemId>
|
|
230
|
+
$ commonly inbox choose <decisionId> 2
|
|
231
|
+
|
|
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
|
|
234
|
+
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.
|
|
239
|
+
|
|
240
|
+
\`ack\` deliberately ships WITHOUT an \`--all\`: a burst of acks shares that same
|
|
241
|
+
per-IP budget, so a paced loop is the caller's decision and a bulk
|
|
242
|
+
\`ids[]\`-accepting route (one request) is the kernel-side answer.
|
|
243
|
+
|
|
244
|
+
Ids: \`list\` prints both ids an item has. \`ack\` takes \`item=…\`
|
|
245
|
+
(attentionItemId); \`choose\` takes the item's source id, which for a decision is
|
|
246
|
+
the decision id. Agent runtime tokens (cm_agent_…) are out of scope — they get
|
|
247
|
+
their attention as runtime events, not from this queue.
|
|
248
|
+
`);
|
|
249
|
+
|
|
250
|
+
inbox
|
|
251
|
+
.command('list')
|
|
252
|
+
.description("List the account's open attention queue, newest first")
|
|
253
|
+
.option('--token-file <path>', INBOX_TOKEN_HELP)
|
|
254
|
+
.option('--since <cursor>', 'ISO-8601 timestamp; only items created after it')
|
|
255
|
+
.option('--cursor-file <path>', 'Read the cursor from this file and write the advanced cursor back')
|
|
256
|
+
.option('--kind <kind>', `Only this kind: ${INBOX_KINDS.join('|')}`)
|
|
257
|
+
.option('--pod <podId>', 'Only this pod')
|
|
258
|
+
.option('--json', 'Emit one JSON object (with the resolved account and the cursor)')
|
|
259
|
+
.option('--instance <url>', 'Target Commonly instance')
|
|
260
|
+
.action(async (opts) => { process.exit(await runInboxList(opts)); });
|
|
261
|
+
|
|
262
|
+
inbox
|
|
263
|
+
.command('ack <itemId>')
|
|
264
|
+
.description('Acknowledge an attention item (the item= id from `list`)')
|
|
265
|
+
.option('--token-file <path>', INBOX_TOKEN_HELP)
|
|
266
|
+
.option('--instance <url>', 'Target Commonly instance')
|
|
267
|
+
.action(async (itemId, opts) => { process.exit(await runInboxAck(itemId, opts)); });
|
|
268
|
+
|
|
269
|
+
inbox
|
|
270
|
+
.command('choose <decisionId> <value>')
|
|
271
|
+
.description('Rule a decision card (the source= id from `list`)')
|
|
272
|
+
.option('--token-file <path>', INBOX_TOKEN_HELP)
|
|
273
|
+
.option('--instance <url>', 'Target Commonly instance')
|
|
274
|
+
.action(async (decisionId, value, opts) => { process.exit(await runInboxChoose(decisionId, value, opts)); });
|
|
275
|
+
};
|
package/src/index.js
CHANGED
|
@@ -15,6 +15,7 @@ import { join, dirname } from 'path';
|
|
|
15
15
|
import { fileURLToPath } from 'url';
|
|
16
16
|
|
|
17
17
|
import { registerLogin, registerWhoami } from './commands/login.js';
|
|
18
|
+
import { registerInbox } from './commands/inbox.js';
|
|
18
19
|
import { registerAgent } from './commands/agent.js';
|
|
19
20
|
import { registerDaemon } from './commands/daemon.js';
|
|
20
21
|
import { registerPod } from './commands/pod.js';
|
|
@@ -35,6 +36,10 @@ program
|
|
|
35
36
|
registerLogin(program);
|
|
36
37
|
registerWhoami(program);
|
|
37
38
|
|
|
39
|
+
// An operator account's own attention queue (explicit token file, never the
|
|
40
|
+
// saved login — see cli/src/lib/inbox.js)
|
|
41
|
+
registerInbox(program);
|
|
42
|
+
|
|
38
43
|
// Agent management
|
|
39
44
|
registerAgent(program);
|
|
40
45
|
|
|
@@ -59,6 +64,7 @@ Quick start:
|
|
|
59
64
|
$ commonly agent detach my-claude # clean uninstall
|
|
60
65
|
$ commonly daemon register --name "My MacBook"
|
|
61
66
|
$ commonly daemon status
|
|
67
|
+
$ commonly inbox list --token-file ~/.commonly/bin/ops-token # an operator's own queue
|
|
62
68
|
|
|
63
69
|
Custom Python agent:
|
|
64
70
|
$ commonly agent init --language python --name research-bot --pod <podId>
|
package/src/lib/inbox.js
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operator inbox — ONE account's attention queue, chosen explicitly.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists: an operator account (connector-ops, lily-shen, gtm-ops) is a
|
|
5
|
+
* human-class recipient, so the kernel already materializes AttentionItems for
|
|
6
|
+
* it — mentions via `recordMentionedUsers`, decisions and handoffs via the
|
|
7
|
+
* `currentHumanMembers` fan-out. What was missing was a consumer. The queue is
|
|
8
|
+
* real: 576 open items for connector-ops on 2026-09-28, none of them ever read.
|
|
9
|
+
*
|
|
10
|
+
* THE IDENTITY RULE IS THE POINT OF THIS MODULE, not a detail in it. Every host
|
|
11
|
+
* helper built on the CLI's saved login read SAM's queue, because the saved
|
|
12
|
+
* login on a shared operator host is Sam's. So this command refuses to run
|
|
13
|
+
* without an explicit token file (`--token-file`, or `COMMONLY_TOKEN_FILE`) and
|
|
14
|
+
* never consults the saved login or `COMMONLY_TOKEN`. A reader can therefore
|
|
15
|
+
* trust the first line of every run: it names whose queue was read, resolved
|
|
16
|
+
* from `GET /api/auth/user` with that token.
|
|
17
|
+
*
|
|
18
|
+
* Two ids exist on a queue item and different commands consume them:
|
|
19
|
+
* `attentionItemId` — what `POST /api/activity/:id/acknowledge` wants
|
|
20
|
+
* (`attentionItemService.acknowledgeAttention` matches
|
|
21
|
+
* `_id` and refuses a non-24-hex value)
|
|
22
|
+
* `id` — the SOURCE row: for a decision that is the decision id
|
|
23
|
+
* that `POST /api/activity/decisions/:id/choose` wants
|
|
24
|
+
* `list` prints both, labelled, so neither command has to be discovered by
|
|
25
|
+
* failing first.
|
|
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),
|
|
30
|
+
* so an offset cursor would skip rows; `createdAt` cannot.
|
|
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.
|
|
41
|
+
*
|
|
42
|
+
* THE GENERAL RULE, which both cursor bugs above are instances of: a resume
|
|
43
|
+
* cursor is valid only for the query that wrote it. `--kind` is a DIFFERENT
|
|
44
|
+
* query over a subset of the same queue, so a kind-filtered read advances the
|
|
45
|
+
* high-water mark past items of every other kind — items that read never printed,
|
|
46
|
+
* and that no later read can reach, because the mark has already moved beyond
|
|
47
|
+
* them. A cursor file therefore records the kind filter (`kind` in the JSON) and
|
|
48
|
+
* a read whose filter differs from the recorded one refuses before it makes a
|
|
49
|
+
* request, rather than silently skipping. One cursor file per kind is the
|
|
50
|
+
* remedy, and the message names it.
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs';
|
|
54
|
+
import { dirname } from 'path';
|
|
55
|
+
|
|
56
|
+
export const INBOX_KINDS = ['mention', 'decision', 'handoff', 'approval'];
|
|
57
|
+
export const TOKEN_FILE_ENV = 'COMMONLY_TOKEN_FILE';
|
|
58
|
+
export const API_PAGE_LIMIT = 50;
|
|
59
|
+
|
|
60
|
+
const TOKEN_FIELDS = ['token', 'runtimeToken', 'accessToken', 'access_token'];
|
|
61
|
+
const DETAIL_CHARS = 160;
|
|
62
|
+
|
|
63
|
+
/** A refusal the operator can fix by re-reading the message. Never a server fault. */
|
|
64
|
+
export class InboxRefusal extends Error {}
|
|
65
|
+
|
|
66
|
+
/** A server-side failure. `status` is the HTTP status. */
|
|
67
|
+
export class InboxRequestError extends Error {
|
|
68
|
+
constructor(message, status) {
|
|
69
|
+
super(message);
|
|
70
|
+
this.name = 'InboxRequestError';
|
|
71
|
+
this.status = status;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const resolveTokenFilePath = (opts = {}, env = process.env) => {
|
|
76
|
+
const flag = typeof opts.tokenFile === 'string' ? opts.tokenFile.trim() : '';
|
|
77
|
+
if (flag) return flag;
|
|
78
|
+
const fromEnv = typeof env?.[TOKEN_FILE_ENV] === 'string' ? env[TOKEN_FILE_ENV].trim() : '';
|
|
79
|
+
return fromEnv || null;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Pull a token out of a file body. A raw token is the documented shape (that is
|
|
84
|
+
* what `~/.commonly/bin/<account>-token` holds today); JSON is tolerated because
|
|
85
|
+
* the CLI's own token files are JSON, and a caller that reaches for one should
|
|
86
|
+
* get a named answer rather than a confusing 401.
|
|
87
|
+
*/
|
|
88
|
+
export const extractToken = (raw) => {
|
|
89
|
+
const text = String(raw ?? '').trim();
|
|
90
|
+
if (!text) return null;
|
|
91
|
+
if (text.startsWith('{')) {
|
|
92
|
+
let parsed;
|
|
93
|
+
try {
|
|
94
|
+
parsed = JSON.parse(text);
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
for (const field of TOKEN_FIELDS) {
|
|
99
|
+
const value = parsed?.[field];
|
|
100
|
+
if (typeof value === 'string' && value.trim()) return value.trim();
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
return text.replace(/^Bearer\s+/i, '').trim() || null;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Read the operator's token. Every failure here is a refusal with the PATH in
|
|
109
|
+
* it — never the token, and never a hint to run `commonly login`, which would
|
|
110
|
+
* point the account back at the saved login this command exists to avoid.
|
|
111
|
+
*/
|
|
112
|
+
export const readInboxToken = (filePath) => {
|
|
113
|
+
let raw;
|
|
114
|
+
try {
|
|
115
|
+
raw = readFileSync(filePath, 'utf8');
|
|
116
|
+
} catch (err) {
|
|
117
|
+
throw new InboxRefusal(
|
|
118
|
+
`Cannot read the token file ${filePath} (${err?.code || err?.message}).`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
const token = extractToken(raw);
|
|
122
|
+
if (!token) {
|
|
123
|
+
throw new InboxRefusal(
|
|
124
|
+
`No token in ${filePath}. Expected a raw token, or JSON carrying one of: ${TOKEN_FIELDS.join(', ')}.`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
if (token.startsWith('cm_agent_')) {
|
|
128
|
+
throw new InboxRefusal(
|
|
129
|
+
`${filePath} holds an AGENT runtime token (cm_agent_…). This command reads a HUMAN account's own queue; `
|
|
130
|
+
+ 'agent attention arrives as runtime events, not here. Pass that account\'s own token file.',
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
return token;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/** `--since` / a cursor file holds an ISO-8601 timestamp; null means "everything". */
|
|
137
|
+
export const parseCursor = (value) => {
|
|
138
|
+
const text = value === undefined || value === null ? '' : String(value).trim();
|
|
139
|
+
if (!text) return null;
|
|
140
|
+
// Two accepted shapes. The JSON object is what `list` writes (a timestamp plus
|
|
141
|
+
// the ids delivered at it); a bare ISO timestamp is what an operator copies off
|
|
142
|
+
// the `cursor:` line, and what older versions wrote — it parses with an empty id
|
|
143
|
+
// set, so the boundary items re-print instead of disappearing.
|
|
144
|
+
let at = text;
|
|
145
|
+
let ids = [];
|
|
146
|
+
let kind = null;
|
|
147
|
+
if (text.startsWith('{')) {
|
|
148
|
+
let parsed;
|
|
149
|
+
try {
|
|
150
|
+
parsed = JSON.parse(text);
|
|
151
|
+
} catch {
|
|
152
|
+
throw new InboxRefusal(`Not a cursor: ${text}`);
|
|
153
|
+
}
|
|
154
|
+
at = parsed?.at;
|
|
155
|
+
ids = Array.isArray(parsed?.ids) ? parsed.ids.filter((id) => typeof id === 'string' && id) : [];
|
|
156
|
+
// Which kind filter the read that wrote this cursor used, so the next read
|
|
157
|
+
// can tell whether it is asking the same question — see the rule at the top
|
|
158
|
+
// of this file. Absent on a bare timestamp and on a file written by an
|
|
159
|
+
// unfiltered read, which is the direction that cannot lose a row.
|
|
160
|
+
kind = typeof parsed?.kind === 'string' && parsed.kind ? parsed.kind : null;
|
|
161
|
+
}
|
|
162
|
+
const ms = Date.parse(String(at));
|
|
163
|
+
if (!Number.isFinite(ms)) {
|
|
164
|
+
throw new InboxRefusal(`Not an ISO-8601 timestamp: ${at}`);
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
at: new Date(ms).toISOString(), ms, ids, kind,
|
|
168
|
+
};
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
/** A missing cursor file is "nothing seen yet", not an error. */
|
|
172
|
+
export const readCursorFile = (filePath) => {
|
|
173
|
+
if (!filePath) return null;
|
|
174
|
+
try {
|
|
175
|
+
return readFileSync(filePath, 'utf8').trim() || null;
|
|
176
|
+
} catch (err) {
|
|
177
|
+
if (err?.code === 'ENOENT') return null;
|
|
178
|
+
throw new InboxRefusal(`Cannot read the cursor file ${filePath} (${err?.code || err?.message}).`);
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
/** The cursor as it goes on disk: `{"at":"<iso>","ids":[...]}` on one line. */
|
|
183
|
+
export const formatCursor = (cursor) => {
|
|
184
|
+
if (!cursor) return null;
|
|
185
|
+
if (typeof cursor === 'string') return cursor;
|
|
186
|
+
const at = cursor.at || (Number.isFinite(cursor.ms) ? new Date(cursor.ms).toISOString() : null);
|
|
187
|
+
if (!at) return null;
|
|
188
|
+
const ids = Array.isArray(cursor.ids) ? cursor.ids : [];
|
|
189
|
+
// `kind` is written only when the read was scoped to one, so an unfiltered
|
|
190
|
+
// cursor keeps the exact shape it had before and neither gains nor loses
|
|
191
|
+
// meaning.
|
|
192
|
+
return JSON.stringify(cursor.kind ? { at, ids, kind: cursor.kind } : { at, ids });
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Written temp-then-rename so a watcher reading this file mid-write sees either
|
|
197
|
+
* the previous cursor or the new one, never a half line. A failed write must not
|
|
198
|
+
* fail the run whose items were already printed, so the caller decides.
|
|
199
|
+
*/
|
|
200
|
+
export const writeCursorFile = (filePath, cursor) => {
|
|
201
|
+
const text = formatCursor(cursor);
|
|
202
|
+
if (!filePath || !text) return;
|
|
203
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
204
|
+
const tmp = `${filePath}.tmp`;
|
|
205
|
+
writeFileSync(tmp, `${text}\n`, 'utf8');
|
|
206
|
+
renameSync(tmp, filePath);
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
export const itemCreatedAtMs = (item) => {
|
|
210
|
+
const ms = Date.parse(String(item?.createdAt ?? ''));
|
|
211
|
+
return Number.isFinite(ms) ? ms : null;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
/** The identity an item is remembered by between runs: the id `ack` consumes. */
|
|
215
|
+
export const cursorKeyOf = (item) => String(item?.attentionItemId || item?.id || '');
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* An item with an unparseable `createdAt` is KEPT under a cursor: dropping it
|
|
219
|
+
* would lose the row silently, and re-printing is the recoverable error. The same
|
|
220
|
+
* argument decides the millisecond boundary — see the module header.
|
|
221
|
+
*/
|
|
222
|
+
export const filterItems = (items, { kind = null, sinceMs = null, seenIds = [] } = {}) => {
|
|
223
|
+
const delivered = seenIds instanceof Set ? seenIds : new Set(seenIds);
|
|
224
|
+
return (items || []).filter((item) => {
|
|
225
|
+
if (kind && item?.kind !== kind) return false;
|
|
226
|
+
if (sinceMs === null) return true;
|
|
227
|
+
const ms = itemCreatedAtMs(item);
|
|
228
|
+
if (ms === null) return true;
|
|
229
|
+
if (ms > sinceMs) return true;
|
|
230
|
+
return ms === sinceMs && !delivered.has(cursorKeyOf(item));
|
|
231
|
+
});
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
/**
|
|
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.
|
|
240
|
+
*/
|
|
241
|
+
export const nextCursorFrom = (items, fallback = null, kind = null) => {
|
|
242
|
+
let max = null;
|
|
243
|
+
for (const item of items || []) {
|
|
244
|
+
const ms = itemCreatedAtMs(item);
|
|
245
|
+
if (ms === null) continue;
|
|
246
|
+
if (max === null || ms > max) max = ms;
|
|
247
|
+
}
|
|
248
|
+
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 || []) : [];
|
|
253
|
+
// The kind is the CURRENT read's, never inherited: the cursor describes the
|
|
254
|
+
// query that just ran, and a mismatch with the file's own kind is what the
|
|
255
|
+
// command refuses on before it makes a request.
|
|
256
|
+
return {
|
|
257
|
+
at, ms: max, ids: [...new Set([...carried, ...boundary])], kind: kind || null,
|
|
258
|
+
};
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
const oneLine = (value) => String(value ?? '').replace(/\s+/g, ' ').trim();
|
|
262
|
+
|
|
263
|
+
export const formatItemLine = (item) => {
|
|
264
|
+
const id = String(item?.attentionItemId || item?.id || '?');
|
|
265
|
+
const source = String(item?.id || '');
|
|
266
|
+
const sourceLabel = source && source !== id ? ` source=${source}` : '';
|
|
267
|
+
const detail = oneLine(item?.title || item?.detail || '').slice(0, DETAIL_CHARS);
|
|
268
|
+
const pod = oneLine(item?.podName || item?.podId || '');
|
|
269
|
+
const actor = item?.actorName ? ` — ${oneLine(item.actorName)}` : '';
|
|
270
|
+
return `${String(item?.kind || '?').padEnd(8)} item=${id}${sourceLabel} [${pod}] ${detail}${actor}`;
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
export const countsLine = (countsByKind = {}) => {
|
|
274
|
+
const parts = Object.entries(countsByKind)
|
|
275
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
276
|
+
.map(([kind, count]) => `${kind} ${count}`);
|
|
277
|
+
return parts.join(', ');
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Never echo the token back, whatever the server decides to say — but only when
|
|
282
|
+
* there is something distinctive to remove. A needle that short shreds the
|
|
283
|
+
* message it is meant to clean: with a one-character token, "Attention item not
|
|
284
|
+
* found" loses every `t` and reads as a different error. Real tokens are JWTs or
|
|
285
|
+
* `cm_*` strings, so the floor is far below anything real and still above any
|
|
286
|
+
* string that could appear inside an ordinary sentence.
|
|
287
|
+
*/
|
|
288
|
+
export const MIN_SCRUB_CHARS = 16;
|
|
289
|
+
|
|
290
|
+
export const createScrubber = (token) => {
|
|
291
|
+
const needle = typeof token === 'string' && token.length >= MIN_SCRUB_CHARS ? token : '';
|
|
292
|
+
return (value) => {
|
|
293
|
+
const text = String(value ?? '');
|
|
294
|
+
return needle ? text.split(needle).join('[redacted]') : text;
|
|
295
|
+
};
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* The `/api/activity` limiter answers 429 with `{code:'rate_limited'}` and
|
|
300
|
+
* `Retry-After` (it sets `standardHeaders`). A read that pages a 550-item queue
|
|
301
|
+
* is 11 requests against a limit of **60 a minute keyed on the caller's IP**
|
|
302
|
+
* (`backend/routes/activity.ts:43-53`) — shared by every session on one host,
|
|
303
|
+
* including the operator's browser — so a 429 is an ordinary busy signal here,
|
|
304
|
+
* not a fault. Back off and continue; fail only when the budget is still gone
|
|
305
|
+
* after the retries.
|
|
306
|
+
*/
|
|
307
|
+
export const MAX_RATE_LIMIT_RETRIES = 2;
|
|
308
|
+
export const DEFAULT_BACKOFF_MS = 5_000;
|
|
309
|
+
export const MAX_BACKOFF_MS = 60_000;
|
|
310
|
+
|
|
311
|
+
export const retryDelayMs = (response, fallback = DEFAULT_BACKOFF_MS) => {
|
|
312
|
+
const header = Number(response?.headers?.get?.('retry-after'));
|
|
313
|
+
if (Number.isFinite(header) && header > 0) return Math.min(header * 1_000, MAX_BACKOFF_MS);
|
|
314
|
+
return fallback;
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Minimal request layer, deliberately NOT `lib/api.js`'s `createClient`.
|
|
319
|
+
* That client answers a 401 with "run `commonly login`", which is the one
|
|
320
|
+
* instruction this command must never give: the token here comes from a file
|
|
321
|
+
* that `login` does not write. `fetchImpl` is injectable so the identity rule
|
|
322
|
+
* is testable without a server.
|
|
323
|
+
*/
|
|
324
|
+
export const createInboxRequest = ({
|
|
325
|
+
baseUrl, token, tokenFile, fetchImpl = fetch, sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); }),
|
|
326
|
+
}) => {
|
|
327
|
+
const scrub = createScrubber(token);
|
|
328
|
+
return async (path, { method = 'GET', params = {}, body } = {}) => {
|
|
329
|
+
const url = new URL(`${baseUrl}${path}`);
|
|
330
|
+
for (const [key, value] of Object.entries(params)) {
|
|
331
|
+
if (value !== undefined && value !== null) url.searchParams.set(key, String(value));
|
|
332
|
+
}
|
|
333
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
334
|
+
// eslint-disable-next-line no-await-in-loop
|
|
335
|
+
const res = await fetchImpl(url.toString(), {
|
|
336
|
+
method,
|
|
337
|
+
headers: {
|
|
338
|
+
Authorization: `Bearer ${token}`,
|
|
339
|
+
'Content-Type': 'application/json',
|
|
340
|
+
'User-Agent': 'commonly-cli/0.1',
|
|
341
|
+
},
|
|
342
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
343
|
+
});
|
|
344
|
+
// eslint-disable-next-line no-await-in-loop
|
|
345
|
+
const text = await res.text();
|
|
346
|
+
let parsed;
|
|
347
|
+
try {
|
|
348
|
+
parsed = JSON.parse(text);
|
|
349
|
+
} catch {
|
|
350
|
+
parsed = { message: text };
|
|
351
|
+
}
|
|
352
|
+
if (res.ok) return parsed;
|
|
353
|
+
if (res.status === 429 && attempt < MAX_RATE_LIMIT_RETRIES) {
|
|
354
|
+
// eslint-disable-next-line no-await-in-loop
|
|
355
|
+
await sleep(retryDelayMs(res));
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
const detail = parsed?.error || parsed?.message || parsed?.msg || parsed?.code || `HTTP ${res.status}`;
|
|
359
|
+
const hint = res.status === 401
|
|
360
|
+
? ` — the token in ${tokenFile} was rejected. This command never uses the saved login.`
|
|
361
|
+
: res.status === 429
|
|
362
|
+
? ' — the /api/activity rate limit (60 a minute, keyed on the caller IP, so every session on this host shares it). Wait and retry; one page per 50 items is already the minimum this read can make.'
|
|
363
|
+
: '';
|
|
364
|
+
throw new InboxRequestError(scrub(`${detail}${hint}`), res.status);
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Page the queue through `hasMore`. Two independent stops, because a server
|
|
371
|
+
* that reports `hasMore: true` beside an empty page would otherwise spin
|
|
372
|
+
* forever, and one that lies about `count` would page past the end.
|
|
373
|
+
*/
|
|
374
|
+
export const fetchQueue = async (request, { podId } = {}) => {
|
|
375
|
+
const items = [];
|
|
376
|
+
let meta = {};
|
|
377
|
+
let offset = 0;
|
|
378
|
+
for (;;) {
|
|
379
|
+
// eslint-disable-next-line no-await-in-loop
|
|
380
|
+
const page = await request('/api/activity/decision-queue', {
|
|
381
|
+
params: { limit: API_PAGE_LIMIT, offset, ...(podId ? { podId } : {}) },
|
|
382
|
+
});
|
|
383
|
+
const batch = Array.isArray(page?.items) ? page.items : [];
|
|
384
|
+
items.push(...batch);
|
|
385
|
+
meta = page || {};
|
|
386
|
+
if (!meta.hasMore) break;
|
|
387
|
+
if (batch.length === 0) break;
|
|
388
|
+
if (Number.isInteger(meta.count) && items.length >= meta.count) break;
|
|
389
|
+
offset += batch.length;
|
|
390
|
+
}
|
|
391
|
+
return { items, meta };
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
export const resolveAccountLabel = (user) => String(
|
|
395
|
+
user?.username || user?.email || user?._id || 'unknown',
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
export const formatAccountLine = ({ account, baseUrl }) => `account: ${account} @ ${baseUrl}`;
|