@catalyst-cloud/cli 0.14.6 → 0.14.8
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 +14 -0
- package/README.md +7 -5
- package/dist/args.js +68 -18
- package/dist/browser.js +37 -3
- package/dist/cli.js +58 -12
- package/dist/config.js +17 -5
- package/dist/consent-browser.js +58 -0
- package/dist/daemon-authority-lifetime.js +171 -0
- package/dist/daemon-authority.js +216 -0
- package/dist/daemon-cache.js +348 -0
- package/dist/daemon-store.js +370 -0
- package/dist/daemon-supervisor.js +460 -0
- package/dist/env/onboard-draft-store.js +250 -0
- package/dist/env/onboard-draft.js +33 -0
- package/dist/env/selected-import-writer.js +163 -0
- package/dist/env/settings-authority.js +205 -0
- package/dist/events-cloud-tail.js +146 -0
- package/dist/events-cloud.js +619 -0
- package/dist/events.js +186 -31
- package/dist/oauth.js +129 -34
- package/dist/onboard-accounts.js +259 -0
- package/dist/onboard-automation-management.js +15 -0
- package/dist/onboard-bootstrap.js +162 -0
- package/dist/onboard-capabilities.js +15 -7
- package/dist/onboard-capacity.js +226 -0
- package/dist/onboard-existing.js +106 -32
- package/dist/onboard-file-snapshot.js +45 -0
- package/dist/onboard-github.js +14 -3
- package/dist/onboard-login-candidate.js +261 -0
- package/dist/onboard-next.js +139 -0
- package/dist/onboard-personal.js +14 -3
- package/dist/onboard-projects.js +350 -0
- package/dist/onboard-repositories.js +146 -37
- package/dist/onboard-runtime.js +108 -13
- package/dist/onboard-settings-context.js +141 -0
- package/dist/onboard-settings-import.js +127 -0
- package/dist/onboard-settings.js +234 -0
- package/dist/onboard-ui.js +85 -59
- package/dist/onboard-workflow.js +316 -0
- package/dist/onboard-workspace.js +14 -3
- package/dist/onboard.js +422 -39
- package/dist/owned-fetch.js +182 -0
- package/dist/pagination.js +13 -1
- package/dist/query.js +104 -31
- package/dist/ready.js +143 -35
- package/dist/replica.js +11 -4
- package/dist/repository-id.js +5 -0
- package/dist/sdk.js +33 -0
- package/dist/watch.js +2 -2
- package/package.json +3 -2
- package/skills/catalyst-github/SKILL.md +4 -2
- package/skills/catalyst-github/references/is-it-mergeable.md +1 -1
- package/skills/catalyst-github/references/local-replica.md +78 -0
- package/skills/catalyst-github/references/reading-from-the-cloud.md +24 -0
- package/skills/catalyst-github/references/what-a-pr-accumulates.md +1 -1
- package/skills/catalyst-github/scripts/is-it-mergeable.mjs +210 -45
- package/skills/catalyst-github/scripts/read-pr.mjs +51 -15
- package/skills/catalyst-linear/SKILL.md +5 -3
- package/skills/catalyst-linear/references/local-replica.md +91 -0
- package/skills/catalyst-linear/references/reading-a-ticket.md +3 -3
- package/skills/catalyst-linear/references/reading-from-the-cloud.md +38 -0
- package/skills/catalyst-linear/scripts/read-ticket.mjs +37 -12
- package/skills/catalyst-onboard/SKILL.md +6 -4
- package/skills/catalyst-onboard/references/local-replica.md +40 -0
- package/skills/catalyst-onboard/references/local-sync.md +8 -4
- package/skills/catalyst-onboard/scripts/local-sync.mjs +2 -2
- package/skills/catalyst-onboard/scripts/where-am-i.mjs +1 -1
- package/skills/run-this-project/SKILL.md +1 -1
- package/skills/unstick/SKILL.md +1 -1
- package/skills/what-needs-me/SKILL.md +1 -1
- package/skills/what-this-repo-needs/SKILL.md +1 -1
- package/skills/whats-happening/SKILL.md +4 -2
- package/skills/whats-happening/references/local-replica.md +51 -0
- package/skills/whats-happening/references/reading-from-the-cloud.md +21 -0
- package/skills/whats-happening/references/status-reply.md +1 -1
- package/skills/whats-happening/scripts/snapshot.mjs +104 -26
- package/vendor/settings/index.d.ts +4 -0
- package/vendor/settings/index.js +2937 -0
- package/vendor/settings/provenance.json +148 -0
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
// The backbone cursor belongs to tenant_events, never the replica's change_log.
|
|
2
|
+
import { normalizeBaseUrl, requireConfig } from "./config.js";
|
|
3
|
+
import { CliError } from "./errors.js";
|
|
4
|
+
import { bearerFor } from "./oauth.js";
|
|
5
|
+
const headHeader = "x-catalyst-event-backbone-head-seq";
|
|
6
|
+
const maxLine = 1_048_576;
|
|
7
|
+
// A filtered page is JSON, sized by the server to about 1 MiB; anything far past that is not ours.
|
|
8
|
+
const maxQueryBody = 16 * maxLine;
|
|
9
|
+
const queryLimit = 200;
|
|
10
|
+
const maxLagPoll = 8_000;
|
|
11
|
+
// CTC-4562 — once the cloud pushes events, the replay is only a safety net for a silent socket.
|
|
12
|
+
const pushedFallback = 600_000;
|
|
13
|
+
const polledFallback = 30_000;
|
|
14
|
+
/** A short, credential-free name for why the cloud could not be reached. */
|
|
15
|
+
export function unreachableKind(error) {
|
|
16
|
+
if (error instanceof RetryRequest)
|
|
17
|
+
return "service unavailable";
|
|
18
|
+
const name = error?.name;
|
|
19
|
+
if (name === "TimeoutError" || name === "AbortError")
|
|
20
|
+
return "timed out";
|
|
21
|
+
if (error instanceof TypeError)
|
|
22
|
+
return "network error";
|
|
23
|
+
return "service unavailable";
|
|
24
|
+
}
|
|
25
|
+
/** CTC-4554 — an unreachable cloud is a named failure with its own exit code, never a crash. The
|
|
26
|
+
* message names only the kind: a transport error can carry a URL with a credential in it. */
|
|
27
|
+
export function unreachableError(error) {
|
|
28
|
+
return new CliError(`the cloud event service is unreachable (${unreachableKind(error)}); try again shortly`, "events-unreachable", 4);
|
|
29
|
+
}
|
|
30
|
+
function sequence(value) {
|
|
31
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
32
|
+
}
|
|
33
|
+
function protocol(message) {
|
|
34
|
+
return new CliError(message, "events-protocol");
|
|
35
|
+
}
|
|
36
|
+
class RetryRequest extends Error {
|
|
37
|
+
delayMs;
|
|
38
|
+
constructor(delayMs) {
|
|
39
|
+
super("cloud event service unavailable");
|
|
40
|
+
this.delayMs = delayMs;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// Cancel this reader's wait without cancelling a refresh shared with another command.
|
|
44
|
+
function eventBearer(ctx, signal) {
|
|
45
|
+
signal.throwIfAborted();
|
|
46
|
+
return new Promise((resolve, reject) => {
|
|
47
|
+
const abort = () => reject(signal.reason);
|
|
48
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
49
|
+
let authStatus;
|
|
50
|
+
const authCtx = { ...ctx, fetch: async (input, init) => {
|
|
51
|
+
const response = await ctx.fetch(input, init);
|
|
52
|
+
authStatus = response.status;
|
|
53
|
+
return response;
|
|
54
|
+
} };
|
|
55
|
+
bearerFor(authCtx, requireConfig(ctx)).then(resolve, (error) => {
|
|
56
|
+
// The shared helper has no typed retry status. Unknown shared failures may retry;
|
|
57
|
+
// a refresh we own records its actual response, including terminal 4xx refusals.
|
|
58
|
+
const transient = error instanceof CliError && (error.code === "network" ||
|
|
59
|
+
authStatus === 429 || (authStatus !== undefined && authStatus >= 500) ||
|
|
60
|
+
(error.code === "session-refresh-failed" && authStatus === undefined));
|
|
61
|
+
reject(error instanceof CliError && !transient
|
|
62
|
+
? new CliError("cloud event authentication failed; run catalyst login", "events-auth")
|
|
63
|
+
: new Error("cloud event authentication temporarily unavailable"));
|
|
64
|
+
}).finally(() => signal.removeEventListener("abort", abort));
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
function responseHead(response) {
|
|
68
|
+
const raw = response.headers.get(headHeader);
|
|
69
|
+
const head = raw === null || raw.trim() === "" ? NaN : Number(raw);
|
|
70
|
+
if (!sequence(head))
|
|
71
|
+
throw protocol("cloud event head could not be verified");
|
|
72
|
+
return head;
|
|
73
|
+
}
|
|
74
|
+
function eventsUrl(ctx, route, params) {
|
|
75
|
+
const url = new URL(`${normalizeBaseUrl(requireConfig(ctx).baseUrl)}/api/v1/events/${route}`);
|
|
76
|
+
for (const [name, value] of Object.entries(params))
|
|
77
|
+
if (value)
|
|
78
|
+
url.searchParams.set(name, value);
|
|
79
|
+
return url;
|
|
80
|
+
}
|
|
81
|
+
async function request(ctx, since, signal) {
|
|
82
|
+
return send(ctx, eventsUrl(ctx, "backbone", { since: String(since) }), "application/x-ndjson", [409], signal);
|
|
83
|
+
}
|
|
84
|
+
async function send(ctx, url, accept, allowed, signal) {
|
|
85
|
+
const bounded = AbortSignal.any([signal, AbortSignal.timeout(15_000)]);
|
|
86
|
+
const token = await eventBearer(ctx, bounded);
|
|
87
|
+
bounded.throwIfAborted();
|
|
88
|
+
const response = await ctx.fetch(url, {
|
|
89
|
+
headers: { authorization: `Bearer ${token}`, accept },
|
|
90
|
+
signal: bounded,
|
|
91
|
+
redirect: "error",
|
|
92
|
+
});
|
|
93
|
+
if (response.status === 401 || response.status === 403) {
|
|
94
|
+
await response.body?.cancel();
|
|
95
|
+
throw new CliError("cloud event access refused; check catalyst status and sign in again", "events-auth", 2, response.status);
|
|
96
|
+
}
|
|
97
|
+
if (response.status !== 200 && !allowed.includes(response.status)) {
|
|
98
|
+
if (response.status >= 500 || response.status === 429) {
|
|
99
|
+
await response.body?.cancel();
|
|
100
|
+
const raw = response.headers.get("retry-after");
|
|
101
|
+
const seconds = raw === null ? NaN : Number(raw);
|
|
102
|
+
const delay = Number.isFinite(seconds) ? seconds * 1_000 : raw ? Date.parse(raw) - ctx.now().getTime() : 0;
|
|
103
|
+
throw new RetryRequest(Number.isFinite(delay) ? Math.min(300_000, Math.max(0, delay)) : 0);
|
|
104
|
+
}
|
|
105
|
+
throw new CliError(`cloud event request refused (HTTP ${response.status}${await refusalReason(response)})`, "events-http", 2, response.status);
|
|
106
|
+
}
|
|
107
|
+
return response;
|
|
108
|
+
}
|
|
109
|
+
/** The server's own reason for a refusal, e.g. `: unknown_event_type (nope)`; empty when it gave none. */
|
|
110
|
+
async function refusalReason(response) {
|
|
111
|
+
try {
|
|
112
|
+
const text = await response.text();
|
|
113
|
+
if (text.length > maxLine)
|
|
114
|
+
return "";
|
|
115
|
+
const body = JSON.parse(text);
|
|
116
|
+
if (typeof body.error !== "string")
|
|
117
|
+
return "";
|
|
118
|
+
return `: ${body.error}${Array.isArray(body.types) ? ` (${body.types.join(", ")})` : ""}`;
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return "";
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/** Probe the head without downloading the backlog. An ahead-of-head cursor is deliberate. */
|
|
125
|
+
export async function cloudEventHead(ctx, signal = AbortSignal.timeout(15_000)) {
|
|
126
|
+
const response = await request(ctx, Number.MAX_SAFE_INTEGER, signal);
|
|
127
|
+
try {
|
|
128
|
+
const head = responseHead(response);
|
|
129
|
+
// A successful empty response is possible only at the maximum safe sequence.
|
|
130
|
+
if (response.status !== 409 && head !== Number.MAX_SAFE_INTEGER)
|
|
131
|
+
throw protocol("cloud event head probe did not return an ahead-of-head response");
|
|
132
|
+
return head;
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
await response.body?.cancel();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/** CTC-4554 — read one page of `GET /api/v1/events/query`. A refusal names the server's reason. */
|
|
139
|
+
export async function queryCloudEvents(ctx, query, signal = AbortSignal.timeout(30_000)) {
|
|
140
|
+
const url = eventsUrl(ctx, "query", {
|
|
141
|
+
order: query.order,
|
|
142
|
+
limit: String(query.limit),
|
|
143
|
+
type: query.type,
|
|
144
|
+
ticket: query.ticket,
|
|
145
|
+
afterSeq: query.afterSeq === undefined ? undefined : String(query.afterSeq),
|
|
146
|
+
beforeSeq: query.beforeSeq === undefined ? undefined : String(query.beforeSeq),
|
|
147
|
+
});
|
|
148
|
+
let response;
|
|
149
|
+
try {
|
|
150
|
+
response = await send(ctx, url, "application/json", [400, 404, 413], signal);
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
if (error instanceof CliError)
|
|
154
|
+
throw error;
|
|
155
|
+
throw unreachableError(error);
|
|
156
|
+
}
|
|
157
|
+
if (response.status === 404) {
|
|
158
|
+
await response.body?.cancel();
|
|
159
|
+
throw new CliError("this cloud does not serve the events query yet; read the local cache with --from-cache", "events-unavailable", 2, 404);
|
|
160
|
+
}
|
|
161
|
+
const text = await response.text();
|
|
162
|
+
if (text.length > maxQueryBody)
|
|
163
|
+
throw protocol("cloud event query response exceeds its size limit");
|
|
164
|
+
let body;
|
|
165
|
+
try {
|
|
166
|
+
body = JSON.parse(text);
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
throw protocol("cloud event query returned invalid JSON");
|
|
170
|
+
}
|
|
171
|
+
const page = (body && typeof body === "object" ? body : {});
|
|
172
|
+
if (response.status !== 200) {
|
|
173
|
+
const reason = typeof page.error === "string" ? page.error : "no reason given";
|
|
174
|
+
const detail = Array.isArray(page.types) ? ` (${page.types.join(", ")})` : "";
|
|
175
|
+
throw new CliError(`the cloud refused the events query: ${reason}${detail} (HTTP ${response.status})`, "events-http", 2, response.status);
|
|
176
|
+
}
|
|
177
|
+
const account = requireConfig(ctx).account;
|
|
178
|
+
if (!Array.isArray(page.events))
|
|
179
|
+
throw protocol("cloud event query returned an invalid page");
|
|
180
|
+
for (const value of page.events) {
|
|
181
|
+
const event = (value && typeof value === "object" ? value : {});
|
|
182
|
+
if (event.tenantId !== account || !sequence(event.sequence) || typeof event.eventId !== "string" || typeof event.type !== "string")
|
|
183
|
+
throw protocol("cloud event query contains an invalid tenant or event");
|
|
184
|
+
}
|
|
185
|
+
const next = page.next;
|
|
186
|
+
if (next !== null && !(next && (next.param === "beforeSeq" || next.param === "afterSeq") && sequence(next.value)))
|
|
187
|
+
throw protocol("cloud event query returned an invalid next cursor");
|
|
188
|
+
const coverage = (page.coverage ?? {});
|
|
189
|
+
const bound = (v) => (sequence(v) ? v : null);
|
|
190
|
+
return {
|
|
191
|
+
events: page.events,
|
|
192
|
+
next: next === null ? null : { param: next.param, value: next.value },
|
|
193
|
+
coverage: { indexedFromSeq: bound(coverage.indexedFromSeq), indexedToSeq: bound(coverage.indexedToSeq) },
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
function validEvent(ctx, value, cursor) {
|
|
197
|
+
if (!value || typeof value !== "object")
|
|
198
|
+
return false;
|
|
199
|
+
const event = value;
|
|
200
|
+
if (event.tenantId !== requireConfig(ctx).account || !sequence(event.sequence) || event.sequence <= cursor ||
|
|
201
|
+
typeof event.eventId !== "string" || typeof event.type !== "string")
|
|
202
|
+
return false;
|
|
203
|
+
// CTC-4554 — an event whose archived body is unavailable arrives as a stub (tenantId, sequence,
|
|
204
|
+
// eventId, type, ticket, bodyUnavailable, reason). It is printed as is, and the cursor moves past it.
|
|
205
|
+
if (event.bodyUnavailable === true)
|
|
206
|
+
return true;
|
|
207
|
+
return typeof event.recordedAt === "string" && "payload" in event;
|
|
208
|
+
}
|
|
209
|
+
// A page closed early by its consumer ends with undefined.
|
|
210
|
+
async function* replay(ctx, after, signal) {
|
|
211
|
+
const response = await request(ctx, after, signal);
|
|
212
|
+
let reader;
|
|
213
|
+
try {
|
|
214
|
+
const head = responseHead(response);
|
|
215
|
+
if (response.status === 409)
|
|
216
|
+
throw new CliError(`cloud refused event cursor ${after} (head ${head}); history may be missing or the cursor may be ahead. Inspect events status before choosing a new --after cursor`, "events-gap", 2, 409);
|
|
217
|
+
if (head < after)
|
|
218
|
+
throw protocol("cloud event head moved behind the requested cursor");
|
|
219
|
+
if (!response.body)
|
|
220
|
+
throw protocol("cloud event replay body is absent");
|
|
221
|
+
reader = response.body.getReader();
|
|
222
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
223
|
+
let buffer = "";
|
|
224
|
+
let cursor = after;
|
|
225
|
+
const parse = (line) => {
|
|
226
|
+
let value;
|
|
227
|
+
try {
|
|
228
|
+
value = JSON.parse(line);
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
throw protocol("cloud event replay contains invalid JSON");
|
|
232
|
+
}
|
|
233
|
+
if (!value || typeof value !== "object")
|
|
234
|
+
throw protocol("cloud event replay contains an invalid event");
|
|
235
|
+
if (!validEvent(ctx, value, cursor))
|
|
236
|
+
throw protocol("cloud event replay contains an invalid tenant or event sequence");
|
|
237
|
+
cursor = value.sequence;
|
|
238
|
+
return value;
|
|
239
|
+
};
|
|
240
|
+
while (true) {
|
|
241
|
+
signal.throwIfAborted();
|
|
242
|
+
const { done, value } = await reader.read();
|
|
243
|
+
try {
|
|
244
|
+
buffer += decoder.decode(value, { stream: !done });
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
throw protocol("cloud event replay contains invalid UTF-8");
|
|
248
|
+
}
|
|
249
|
+
let newline;
|
|
250
|
+
while ((newline = buffer.indexOf("\n")) >= 0) {
|
|
251
|
+
if (newline > maxLine)
|
|
252
|
+
throw protocol("cloud event exceeds the replay line limit");
|
|
253
|
+
const line = buffer.slice(0, newline).trim();
|
|
254
|
+
buffer = buffer.slice(newline + 1);
|
|
255
|
+
if (line) {
|
|
256
|
+
signal.throwIfAborted();
|
|
257
|
+
yield parse(line);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (buffer.length > maxLine)
|
|
261
|
+
throw protocol("cloud event exceeds the replay line limit");
|
|
262
|
+
if (done)
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
if (buffer.trim()) {
|
|
266
|
+
signal.throwIfAborted();
|
|
267
|
+
yield parse(buffer.trim());
|
|
268
|
+
}
|
|
269
|
+
if (cursor === after && cursor < head)
|
|
270
|
+
throw protocol("cloud event replay made no progress toward its head");
|
|
271
|
+
return { through: cursor, more: cursor < head, head };
|
|
272
|
+
}
|
|
273
|
+
finally {
|
|
274
|
+
if (reader) {
|
|
275
|
+
await reader.cancel().catch(() => { });
|
|
276
|
+
reader.releaseLock();
|
|
277
|
+
}
|
|
278
|
+
else
|
|
279
|
+
await response.body?.cancel();
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
// One page of the indexed, filtered query (CTC-4549). The index fills after the archive write, so
|
|
283
|
+
// `coverage.indexedToSeq` may trail the head; the reader polls until it catches up.
|
|
284
|
+
async function* queryPage(ctx, after, filter, signal) {
|
|
285
|
+
const url = eventsUrl(ctx, "query", { order: "asc", afterSeq: String(after), limit: String(queryLimit), type: filter.type, ticket: filter.ticket });
|
|
286
|
+
const response = await send(ctx, url, "application/json", [404, 413], signal);
|
|
287
|
+
if (response.status === 404) {
|
|
288
|
+
await response.body?.cancel();
|
|
289
|
+
return "absent";
|
|
290
|
+
}
|
|
291
|
+
if (response.status === 413) {
|
|
292
|
+
// CTC-4554 — one event over the 1 MiB page limit: skip it with a warning and move past it,
|
|
293
|
+
// rather than failing the tail and hitting the same event on every resume.
|
|
294
|
+
let skipped = {};
|
|
295
|
+
try {
|
|
296
|
+
skipped = JSON.parse(await response.text());
|
|
297
|
+
}
|
|
298
|
+
catch { /* named below */ }
|
|
299
|
+
if (!sequence(skipped.sequence) || skipped.sequence <= after)
|
|
300
|
+
throw protocol("cloud event query refused an over-limit event without naming it");
|
|
301
|
+
ctx.stderr(`events: skipped event ${skipped.sequence}: it is larger than the 1 MiB page limit${sequence(skipped.eventBytes) ? ` (${skipped.eventBytes} bytes)` : ""}`);
|
|
302
|
+
return { through: skipped.sequence, more: true };
|
|
303
|
+
}
|
|
304
|
+
const text = await response.text();
|
|
305
|
+
if (text.length > maxQueryBody)
|
|
306
|
+
throw protocol("cloud event query response exceeds its size limit");
|
|
307
|
+
let body;
|
|
308
|
+
try {
|
|
309
|
+
body = JSON.parse(text);
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
throw protocol("cloud event query returned invalid JSON");
|
|
313
|
+
}
|
|
314
|
+
const page = (body && typeof body === "object" ? body : {});
|
|
315
|
+
if (!Array.isArray(page.events) || !("next" in page))
|
|
316
|
+
throw protocol("cloud event query returned an invalid page");
|
|
317
|
+
const from = page.coverage?.indexedFromSeq;
|
|
318
|
+
const to = page.coverage?.indexedToSeq;
|
|
319
|
+
// Nothing indexed yet, or the cursor sits below the index floor: only the backbone has that range.
|
|
320
|
+
if (from === null || to === null || from === undefined || to === undefined)
|
|
321
|
+
return "uncovered";
|
|
322
|
+
if (!sequence(from) || !sequence(to))
|
|
323
|
+
throw protocol("cloud event query coverage is invalid");
|
|
324
|
+
if (after + 1 < from)
|
|
325
|
+
return "uncovered";
|
|
326
|
+
let cursor = after;
|
|
327
|
+
const events = [];
|
|
328
|
+
for (const value of page.events) {
|
|
329
|
+
if (!validEvent(ctx, value, cursor))
|
|
330
|
+
throw protocol("cloud event query contains an invalid tenant or event sequence");
|
|
331
|
+
cursor = value.sequence;
|
|
332
|
+
events.push(value);
|
|
333
|
+
}
|
|
334
|
+
for (const event of events) {
|
|
335
|
+
signal.throwIfAborted();
|
|
336
|
+
yield event;
|
|
337
|
+
}
|
|
338
|
+
if (page.next !== null) {
|
|
339
|
+
if (cursor === after)
|
|
340
|
+
throw protocol("cloud event query made no progress toward its next page");
|
|
341
|
+
return { through: cursor, more: true };
|
|
342
|
+
}
|
|
343
|
+
return { through: Math.max(cursor, to), more: false };
|
|
344
|
+
}
|
|
345
|
+
function pushFrame(ctx, raw) {
|
|
346
|
+
const { after, through } = raw;
|
|
347
|
+
if (!sequence(after) || !sequence(through) || through < after || !Array.isArray(raw.events))
|
|
348
|
+
throw protocol("cloud events frame has invalid bounds");
|
|
349
|
+
let cursor = after;
|
|
350
|
+
let gap = raw.gap === true;
|
|
351
|
+
const events = [];
|
|
352
|
+
for (const value of raw.events) {
|
|
353
|
+
// An event too large to push arrives as a summary; the catch-up read returns it whole.
|
|
354
|
+
if (value && typeof value === "object" && value.payloadOmitted === true) {
|
|
355
|
+
gap = true;
|
|
356
|
+
const seq = value.sequence;
|
|
357
|
+
if (sequence(seq) && seq > cursor)
|
|
358
|
+
cursor = seq;
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
if (!validEvent(ctx, value, cursor) || value.sequence > through)
|
|
362
|
+
throw protocol("cloud events frame contains an invalid tenant or event sequence");
|
|
363
|
+
cursor = value.sequence;
|
|
364
|
+
events.push(value);
|
|
365
|
+
}
|
|
366
|
+
return { after, through, gap, events };
|
|
367
|
+
}
|
|
368
|
+
function pause(ms, signal, wakes) {
|
|
369
|
+
if (signal.aborted)
|
|
370
|
+
return Promise.resolve();
|
|
371
|
+
return new Promise((resolve) => {
|
|
372
|
+
const done = () => {
|
|
373
|
+
clearTimeout(timer);
|
|
374
|
+
signal.removeEventListener("abort", done);
|
|
375
|
+
wakes?.removeEventListener("wake", done);
|
|
376
|
+
resolve();
|
|
377
|
+
};
|
|
378
|
+
const timer = setTimeout(done, ms);
|
|
379
|
+
signal.addEventListener("abort", done, { once: true });
|
|
380
|
+
wakes?.addEventListener("wake", done, { once: true });
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
/** Foreground push subscription with durable replay, reconnecting from the last yielded cursor. */
|
|
384
|
+
export async function* cloudEvents(ctx, options, deps = {}) {
|
|
385
|
+
const { signal } = options;
|
|
386
|
+
const filter = options.filter ?? {};
|
|
387
|
+
let cursor = options.after;
|
|
388
|
+
let backoff = 1_000;
|
|
389
|
+
// A filter reads the server's index; a server without the route gets the backbone for the whole run.
|
|
390
|
+
let indexed = Boolean(filter.type || filter.ticket);
|
|
391
|
+
let lagPoll = 1_000;
|
|
392
|
+
// CTC-4554 — consecutive failed attempts, so a caller can tell an outage from a quiet stream.
|
|
393
|
+
let failures = 0;
|
|
394
|
+
let failingSince;
|
|
395
|
+
while (!signal.aborted) {
|
|
396
|
+
let socket;
|
|
397
|
+
let heartbeat;
|
|
398
|
+
let openTimer;
|
|
399
|
+
const wakes = new EventTarget();
|
|
400
|
+
let failure;
|
|
401
|
+
let retryDelay = 0;
|
|
402
|
+
let opened = false;
|
|
403
|
+
let notifiedHead = 0;
|
|
404
|
+
// CTC-4562 — the cloud acknowledged the events channel, and the frames it has pushed since.
|
|
405
|
+
let pushing = false;
|
|
406
|
+
const pushed = [];
|
|
407
|
+
let woken = false;
|
|
408
|
+
const wake = () => { woken = true; wakes.dispatchEvent(new Event("wake")); };
|
|
409
|
+
const stop = () => { socket?.close(); wake(); };
|
|
410
|
+
const failed = () => { failure ??= new Error("cloud event connection interrupted"); wake(); };
|
|
411
|
+
const closed = () => {
|
|
412
|
+
// 4401 is routine authorization expiry. The next authenticated probe refreshes it.
|
|
413
|
+
failure ??= new Error("cloud event connection closed");
|
|
414
|
+
wake();
|
|
415
|
+
};
|
|
416
|
+
const message = (event) => {
|
|
417
|
+
if (!("data" in event) || typeof event.data !== "string" || event.data.length > maxLine)
|
|
418
|
+
return;
|
|
419
|
+
let frame;
|
|
420
|
+
try {
|
|
421
|
+
frame = JSON.parse(event.data);
|
|
422
|
+
}
|
|
423
|
+
catch {
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
if (!frame || typeof frame !== "object")
|
|
427
|
+
return;
|
|
428
|
+
const head = frame;
|
|
429
|
+
if (head.type === "channels") {
|
|
430
|
+
pushing = Array.isArray(head.channels) && head.channels.includes("events");
|
|
431
|
+
wake();
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
if (head.type === "events") {
|
|
435
|
+
try {
|
|
436
|
+
pushed.push(pushFrame(ctx, frame));
|
|
437
|
+
}
|
|
438
|
+
catch (error) {
|
|
439
|
+
failure = error instanceof Error ? error : protocol("cloud events frame is invalid");
|
|
440
|
+
}
|
|
441
|
+
wake();
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
if (head.type !== "event-backbone-head")
|
|
445
|
+
return;
|
|
446
|
+
if (head.accountId !== requireConfig(ctx).account || !sequence(head.seq)) {
|
|
447
|
+
failure = protocol("cloud event notification has an invalid tenant or head");
|
|
448
|
+
wake();
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
notifiedHead = Math.max(notifiedHead, head.seq);
|
|
452
|
+
wake();
|
|
453
|
+
};
|
|
454
|
+
const open = () => { opened = true; wake(); };
|
|
455
|
+
try {
|
|
456
|
+
// Capture the future-only start before connecting, then replay to close the handshake race.
|
|
457
|
+
const head = await cloudEventHead(ctx, signal);
|
|
458
|
+
let knownHead = head;
|
|
459
|
+
cursor ??= head;
|
|
460
|
+
if (cursor > head)
|
|
461
|
+
throw new CliError(`cloud refused event cursor ${cursor} (head ${head}); inspect events status before choosing a new --after cursor`, "events-gap", 2, 409);
|
|
462
|
+
if (signal.aborted)
|
|
463
|
+
break;
|
|
464
|
+
const cfg = requireConfig(ctx);
|
|
465
|
+
const url = new URL(`${normalizeBaseUrl(cfg.baseUrl)}/api/v1/connect`);
|
|
466
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
467
|
+
url.searchParams.set("account", cfg.account);
|
|
468
|
+
// CTC-4562 — ask to be pushed the matching events, whole, instead of the change feed. A cloud
|
|
469
|
+
// without the channel ignores these and the reader keeps its nudge-and-replay loop.
|
|
470
|
+
url.searchParams.set("channels", "events");
|
|
471
|
+
if (filter.type)
|
|
472
|
+
url.searchParams.set("type", filter.type);
|
|
473
|
+
if (filter.ticket)
|
|
474
|
+
url.searchParams.set("ticket", filter.ticket);
|
|
475
|
+
url.searchParams.set("fields", "full");
|
|
476
|
+
url.searchParams.set("token", await eventBearer(ctx, AbortSignal.any([signal, AbortSignal.timeout(15_000)])));
|
|
477
|
+
signal.throwIfAborted();
|
|
478
|
+
socket = (deps.socket ?? ((address) => new WebSocket(address)))(url.toString());
|
|
479
|
+
socket.addEventListener("open", open);
|
|
480
|
+
socket.addEventListener("message", message);
|
|
481
|
+
socket.addEventListener("close", closed);
|
|
482
|
+
socket.addEventListener("error", failed);
|
|
483
|
+
signal.addEventListener("abort", stop, { once: true });
|
|
484
|
+
openTimer = setTimeout(failed, 15_000);
|
|
485
|
+
while (!opened && !failure && !signal.aborted)
|
|
486
|
+
await pause(15_000, signal, wakes);
|
|
487
|
+
clearTimeout(openTimer);
|
|
488
|
+
if (failure)
|
|
489
|
+
throw failure;
|
|
490
|
+
if (signal.aborted)
|
|
491
|
+
break;
|
|
492
|
+
heartbeat = setInterval(() => {
|
|
493
|
+
try {
|
|
494
|
+
socket?.send(JSON.stringify({ type: "ping" }));
|
|
495
|
+
}
|
|
496
|
+
catch {
|
|
497
|
+
failed();
|
|
498
|
+
}
|
|
499
|
+
}, 10_000);
|
|
500
|
+
const follow = async function* (page) {
|
|
501
|
+
let result = await page.next();
|
|
502
|
+
try {
|
|
503
|
+
while (!result.done) {
|
|
504
|
+
if (failure)
|
|
505
|
+
throw failure;
|
|
506
|
+
signal.throwIfAborted();
|
|
507
|
+
cursor = result.value.sequence;
|
|
508
|
+
yield result.value;
|
|
509
|
+
if (failure)
|
|
510
|
+
throw failure;
|
|
511
|
+
signal.throwIfAborted();
|
|
512
|
+
result = await page.next();
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
finally {
|
|
516
|
+
await page.return(undefined);
|
|
517
|
+
}
|
|
518
|
+
return result.value;
|
|
519
|
+
};
|
|
520
|
+
let catchUp = true;
|
|
521
|
+
while (!signal.aborted) {
|
|
522
|
+
if (failure)
|
|
523
|
+
throw failure;
|
|
524
|
+
if (!catchUp) {
|
|
525
|
+
// Caught up: apply pushed frames in order. One that starts past the cursor, or carries a
|
|
526
|
+
// gap, sends the reader back to the catch-up read; overlap with it is dropped by sequence.
|
|
527
|
+
while (pushed.length > 0 && !catchUp) {
|
|
528
|
+
const next = pushed.shift();
|
|
529
|
+
knownHead = Math.max(knownHead, next.through);
|
|
530
|
+
if (next.through <= cursor)
|
|
531
|
+
continue;
|
|
532
|
+
if (next.gap || next.after > cursor) {
|
|
533
|
+
catchUp = true;
|
|
534
|
+
break;
|
|
535
|
+
}
|
|
536
|
+
for (const event of next.events) {
|
|
537
|
+
if (event.sequence <= cursor)
|
|
538
|
+
continue;
|
|
539
|
+
if (failure)
|
|
540
|
+
throw failure;
|
|
541
|
+
signal.throwIfAborted();
|
|
542
|
+
cursor = event.sequence;
|
|
543
|
+
yield event;
|
|
544
|
+
}
|
|
545
|
+
cursor = Math.max(cursor, next.through);
|
|
546
|
+
}
|
|
547
|
+
if (failure)
|
|
548
|
+
throw failure;
|
|
549
|
+
if (catchUp || cursor < Math.max(knownHead, notifiedHead)) {
|
|
550
|
+
catchUp = true;
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
woken = false;
|
|
554
|
+
await pause(pushing ? pushedFallback : polledFallback, signal, wakes);
|
|
555
|
+
// A wake with frames is applied without a read; a timeout, or a nudge with no push channel,
|
|
556
|
+
// replays (a bounded replay also catches a lost notification or a silent half-open socket).
|
|
557
|
+
if (!woken || !pushing)
|
|
558
|
+
catchUp = true;
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
const start = cursor;
|
|
562
|
+
let end = indexed ? yield* follow(queryPage(ctx, start, filter, signal)) : yield* follow(replay(ctx, start, signal));
|
|
563
|
+
const queried = indexed && end !== "absent" && end !== "uncovered";
|
|
564
|
+
if (end === "absent")
|
|
565
|
+
indexed = false;
|
|
566
|
+
if (end === "absent" || end === "uncovered")
|
|
567
|
+
end = yield* follow(replay(ctx, cursor, signal));
|
|
568
|
+
if (!end)
|
|
569
|
+
return;
|
|
570
|
+
backoff = 1_000;
|
|
571
|
+
if (failures > 0) {
|
|
572
|
+
failures = 0;
|
|
573
|
+
failingSince = undefined;
|
|
574
|
+
deps.onHealthy?.();
|
|
575
|
+
}
|
|
576
|
+
if (failure)
|
|
577
|
+
throw failure;
|
|
578
|
+
cursor = Math.max(cursor, end.through);
|
|
579
|
+
knownHead = Math.max(knownHead, end.head ?? 0, notifiedHead);
|
|
580
|
+
if (end.more)
|
|
581
|
+
continue;
|
|
582
|
+
if (queried && cursor < knownHead) {
|
|
583
|
+
// The index trails the archive write; poll it instead of spinning or waiting for a push.
|
|
584
|
+
lagPoll = cursor > start ? 1_000 : Math.min(lagPoll * 2, maxLagPoll);
|
|
585
|
+
await pause(lagPoll, signal);
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
lagPoll = 1_000;
|
|
589
|
+
if (cursor < knownHead)
|
|
590
|
+
continue;
|
|
591
|
+
catchUp = false;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
catch (error) {
|
|
595
|
+
if (signal.aborted)
|
|
596
|
+
return;
|
|
597
|
+
if (error instanceof CliError)
|
|
598
|
+
throw error;
|
|
599
|
+
if (error instanceof RetryRequest)
|
|
600
|
+
retryDelay = error.delayMs;
|
|
601
|
+
// Never propagate socket URLs or transport exceptions: they can contain bearer credentials.
|
|
602
|
+
failures += 1;
|
|
603
|
+
failingSince ??= ctx.now().getTime();
|
|
604
|
+
deps.onTrouble?.({ failures, sinceMs: failingSince });
|
|
605
|
+
}
|
|
606
|
+
finally {
|
|
607
|
+
clearInterval(heartbeat);
|
|
608
|
+
clearTimeout(openTimer);
|
|
609
|
+
signal.removeEventListener("abort", stop);
|
|
610
|
+
socket?.removeEventListener("open", open);
|
|
611
|
+
socket?.removeEventListener("message", message);
|
|
612
|
+
socket?.removeEventListener("close", closed);
|
|
613
|
+
socket?.removeEventListener("error", failed);
|
|
614
|
+
socket?.close();
|
|
615
|
+
}
|
|
616
|
+
await pause(Math.max(backoff, retryDelay), signal);
|
|
617
|
+
backoff = Math.min(backoff * 2, 30_000);
|
|
618
|
+
}
|
|
619
|
+
}
|