ctxr-cli 0.38.0 → 0.39.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/dist/version.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Kept in sync with package.json's "version" field. */
|
|
2
|
-
export declare const CLI_VERSION = "0.
|
|
2
|
+
export declare const CLI_VERSION = "0.39.0";
|
|
3
3
|
/**
|
|
4
4
|
* The npm package this executable is published as. Bound here, beside the
|
|
5
5
|
* version, because the release check resolves the latest published release by
|
package/dist/version.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Kept in sync with package.json's "version" field. */
|
|
2
|
-
export const CLI_VERSION = '0.
|
|
2
|
+
export const CLI_VERSION = '0.39.0';
|
|
3
3
|
/**
|
|
4
4
|
* The npm package this executable is published as. Bound here, beside the
|
|
5
5
|
* version, because the release check resolves the latest published release by
|
package/package.json
CHANGED
|
@@ -12,6 +12,13 @@
|
|
|
12
12
|
// Two facts about `cb` shape this file: search results carry no tags (every `tags` field is empty), so
|
|
13
13
|
// tags come from `cb meetings read`; and a meeting older than the plan's history window reads back with
|
|
14
14
|
// a notice in place of its notes, which is how a locked meeting is recognised.
|
|
15
|
+
//
|
|
16
|
+
// Circleback's API documentation limits each account to 3 requests a second and 20 a minute on the Free plan
|
|
17
|
+
// (20 and 300 on paid plans), counting every API key and CLI token of the account together; `cb` reports a
|
|
18
|
+
// refusal as `rate_limited` but passes on no Retry-After. So every `cb` call is spaced to stay under the Free
|
|
19
|
+
// plan's 20 a minute, which also keeps it under 3 a second; a refused call is retried after waiting out a
|
|
20
|
+
// one-minute window; and if the limit still holds, the meetings not yet read are reported as deferred, keeping
|
|
21
|
+
// what was already written: the next run picks them up.
|
|
15
22
|
|
|
16
23
|
import { execFileSync } from 'node:child_process';
|
|
17
24
|
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
@@ -27,6 +34,32 @@ const PAGE_SIZE = 20;
|
|
|
27
34
|
const READ_BATCH = 50;
|
|
28
35
|
const BRANCH = 'circleback-capture';
|
|
29
36
|
const LOCKED_NOTICE = 'outside the history available';
|
|
37
|
+
// The Free plan's limits. The runner cannot tell which plan the account is on, so it keeps to the lowest.
|
|
38
|
+
const REQUESTS_PER_MINUTE = 20;
|
|
39
|
+
// A little under the limit, leaving room for whatever else uses the account and for a `cb` call that makes
|
|
40
|
+
// more than one request: about 17 calls a minute.
|
|
41
|
+
const CALL_SPACING_MS = Math.ceil(60_000 / REQUESTS_PER_MINUTE) + 500;
|
|
42
|
+
// The minute window refills within 60 seconds, so the first retry waits half of it and the rest a whole one.
|
|
43
|
+
const RETRY_DELAYS_MS = [30_000, 60_000, 60_000];
|
|
44
|
+
const RATE_LIMITED = /"reason"\s*:\s*"rate_limited"/;
|
|
45
|
+
// Every wait is multiplied by this. Only the tests set it, to 0; a store never does.
|
|
46
|
+
const WAIT_SCALE = Number.isFinite(Number(process.env.CTXR_CIRCLEBACK_WAIT_SCALE)) ? Number(process.env.CTXR_CIRCLEBACK_WAIT_SCALE) : 1;
|
|
47
|
+
|
|
48
|
+
/** Thrown when `cb` is still rate-limited after every retry. */
|
|
49
|
+
class RateLimited extends Error {}
|
|
50
|
+
|
|
51
|
+
function pause(ms) {
|
|
52
|
+
const wait = ms * WAIT_SCALE;
|
|
53
|
+
if (wait > 0) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, wait);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let lastCbCall = -Infinity;
|
|
57
|
+
|
|
58
|
+
/** Waits until CALL_SPACING_MS has passed since the previous `cb` call, retries included. */
|
|
59
|
+
function spaceCbCall() {
|
|
60
|
+
pause(Math.max(0, lastCbCall + CALL_SPACING_MS - Date.now()));
|
|
61
|
+
lastCbCall = Date.now();
|
|
62
|
+
}
|
|
30
63
|
|
|
31
64
|
function run(command, args, { cwd, check = true } = {}) {
|
|
32
65
|
try {
|
|
@@ -34,12 +67,32 @@ function run(command, args, { cwd, check = true } = {}) {
|
|
|
34
67
|
} catch (error) {
|
|
35
68
|
if (!check) return error.stdout ?? '';
|
|
36
69
|
const detail = String(error.stderr || error.message).trim().slice(0, 300);
|
|
37
|
-
throw new Error(`${command} ${args.slice(0, 2).join(' ')} failed: ${detail}`)
|
|
70
|
+
throw Object.assign(new Error(`${command} ${args.slice(0, 2).join(' ')} failed: ${detail}`), {
|
|
71
|
+
output: `${error.stdout ?? ''}\n${error.stderr ?? ''}`,
|
|
72
|
+
});
|
|
38
73
|
}
|
|
39
74
|
}
|
|
40
75
|
|
|
76
|
+
/** One `cb` call, retried with backoff while Circleback reports a rate limit. */
|
|
41
77
|
function cb(args) {
|
|
42
|
-
|
|
78
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
79
|
+
let output;
|
|
80
|
+
spaceCbCall();
|
|
81
|
+
try {
|
|
82
|
+
output = run('cb', [...args, '--json']);
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (!RATE_LIMITED.test(error.output ?? '')) throw error;
|
|
85
|
+
if (attempt >= RETRY_DELAYS_MS.length) throw new RateLimited(error.message);
|
|
86
|
+
pause(RETRY_DELAYS_MS[attempt]);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
const result = JSON.parse(output);
|
|
90
|
+
if (result?.status !== 'error') return result;
|
|
91
|
+
// An error printed as the result, with a zero exit: treat it the same as a failed call.
|
|
92
|
+
if (!RATE_LIMITED.test(output)) throw new Error(`cb ${args.slice(0, 2).join(' ')} failed: ${output.trim().slice(0, 300)}`);
|
|
93
|
+
if (attempt >= RETRY_DELAYS_MS.length) throw new RateLimited(`cb ${args.slice(0, 2).join(' ')} failed: ${output.trim().slice(0, 300)}`);
|
|
94
|
+
pause(RETRY_DELAYS_MS[attempt]);
|
|
95
|
+
}
|
|
43
96
|
}
|
|
44
97
|
|
|
45
98
|
function storeRoot() {
|
|
@@ -232,12 +285,26 @@ function main() {
|
|
|
232
285
|
mkdirSync(inbox, { recursive: true });
|
|
233
286
|
report.push(`\nWORKTREE: ${worktree} (branch \`${BRANCH}\`, ${continuing ? `continuing pull request #${continuing}` : `fresh from origin/${settings.default_branch}`})`);
|
|
234
287
|
report.push('RETRIEVED (verbatim records in the inbox; continue with ctxr-capture from step 2):');
|
|
288
|
+
const deferred = [];
|
|
235
289
|
for (const meeting of owed) {
|
|
236
|
-
|
|
290
|
+
// Once the limit has outlasted every retry, read nothing more this run.
|
|
291
|
+
if (deferred.length > 0) {
|
|
292
|
+
deferred.push(line(meeting));
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
let transcript;
|
|
296
|
+
try {
|
|
297
|
+
transcript = cb(['transcripts', 'read', meeting.id]);
|
|
298
|
+
} catch (error) {
|
|
299
|
+
if (!(error instanceof RateLimited)) throw error;
|
|
300
|
+
deferred.push(line(meeting));
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
237
303
|
const file = path.join(inbox, captureFileName(meeting));
|
|
238
304
|
writeFileSync(file, renderCapture(meeting, transcript, settings));
|
|
239
305
|
report.push(file);
|
|
240
306
|
}
|
|
307
|
+
report.push(...section('DEFERRED — owed, but Circleback is rate-limiting transcript reads; the next run retrieves them', deferred));
|
|
241
308
|
}
|
|
242
309
|
report.push(
|
|
243
310
|
...section('UNPUSHED — captured in a local worktree but not on the default branch or in an open pull request; land that work', unpushed),
|
|
@@ -25,6 +25,12 @@ writes the record into `__INBOX_PATH__` there: the meeting's metadata and attend
|
|
|
25
25
|
summary (marked as the service's derivation), and the transcript verbatim, with `source_type` and `source_id`
|
|
26
26
|
stamped. It never writes the recording URL, which is a signed, expiring link and so a credential.
|
|
27
27
|
|
|
28
|
+
Circleback's free plan allows an account 20 requests a minute, counting every other use of the same account. The
|
|
29
|
+
runner spaces its calls to stay under that, so a large backlog takes a few minutes, and waits and retries when it is
|
|
30
|
+
refused anyway. If the limit still holds, it keeps the records it already wrote and lists the meetings it could not
|
|
31
|
+
read under DEFERRED; the next run retrieves them. That is a partial result, not a failure: carry on with what was
|
|
32
|
+
retrieved.
|
|
33
|
+
|
|
28
34
|
The report is authoritative. Do not work the list out again yourself, and do not rebuild the files it wrote. If it
|
|
29
35
|
prints `COLLECTION FAILED`, report that line and stop.
|
|
30
36
|
|
|
@@ -53,6 +59,7 @@ Give the counts (retrieved, already present), the pull request link, and the run
|
|
|
53
59
|
they are:
|
|
54
60
|
|
|
55
61
|
- **LOCKED**: tagged for this store but no longer readable in full, so it cannot be captured.
|
|
62
|
+
- **DEFERRED**: owed, but not read this run because of the rate limit. The next run retrieves them.
|
|
56
63
|
- **UNPUSHED**: captured in a local worktree but not yet on the default branch or in a pull request. That work
|
|
57
64
|
needs landing; nothing here captures it again.
|
|
58
65
|
- **UNTAGGED**, **TAGGED FOR MORE THAN ONE STORE** and **DECLINED IN THE LAST 8 DAYS**, when the runner prints them.
|