@catalyst-cloud/cli 0.14.7 → 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.
Files changed (43) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +2 -2
  3. package/dist/args.js +17 -5
  4. package/dist/browser.js +37 -3
  5. package/dist/cli.js +4 -6
  6. package/dist/config.js +1 -2
  7. package/dist/consent-browser.js +58 -0
  8. package/dist/env/onboard-draft-store.js +10 -3
  9. package/dist/env/settings-authority.js +3 -1
  10. package/dist/events-cloud-tail.js +25 -13
  11. package/dist/events-cloud.js +124 -5
  12. package/dist/events.js +175 -29
  13. package/dist/oauth.js +4 -1
  14. package/dist/onboard-github.js +14 -3
  15. package/dist/onboard-next.js +139 -0
  16. package/dist/onboard-personal.js +14 -3
  17. package/dist/onboard-repositories.js +3 -4
  18. package/dist/onboard-runtime.js +4 -4
  19. package/dist/onboard-ui.js +14 -76
  20. package/dist/onboard-workspace.js +14 -3
  21. package/dist/onboard.js +63 -12
  22. package/dist/pagination.js +13 -1
  23. package/dist/query.js +39 -16
  24. package/dist/repository-id.js +5 -0
  25. package/package.json +1 -1
  26. package/skills/catalyst-github/SKILL.md +3 -1
  27. package/skills/catalyst-github/references/local-replica.md +78 -0
  28. package/skills/catalyst-github/references/reading-from-the-cloud.md +24 -0
  29. package/skills/catalyst-linear/SKILL.md +3 -1
  30. package/skills/catalyst-linear/references/local-replica.md +91 -0
  31. package/skills/catalyst-linear/references/reading-from-the-cloud.md +38 -0
  32. package/skills/catalyst-onboard/SKILL.md +3 -2
  33. package/skills/catalyst-onboard/references/local-replica.md +40 -0
  34. package/skills/catalyst-onboard/references/local-sync.md +5 -3
  35. package/skills/catalyst-onboard/scripts/local-sync.mjs +2 -2
  36. package/skills/catalyst-onboard/scripts/where-am-i.mjs +1 -1
  37. package/skills/run-this-project/SKILL.md +1 -1
  38. package/skills/unstick/SKILL.md +1 -1
  39. package/skills/what-needs-me/SKILL.md +1 -1
  40. package/skills/what-this-repo-needs/SKILL.md +1 -1
  41. package/skills/whats-happening/SKILL.md +3 -1
  42. package/skills/whats-happening/references/local-replica.md +51 -0
  43. package/skills/whats-happening/references/reading-from-the-cloud.md +21 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.14.8
4
+
5
+ - Onboarding on a computer with no browser, such as a Docker container, prints the Catalyst page that finishes each Linear and GitHub approval and keeps waiting, instead of stopping.
6
+ - Onboarding accepts cloud repository IDs such as `tenant-9:owner__repo`, so registering a project no longer fails on them.
7
+ - When the sign-in expires during a provider approval, onboarding stops and says to run `catalyst login`, instead of waiting for the approval to time out.
8
+ - Steps that wait name the next action and the page to use, and the end of a run lists what is left.
9
+ - `catalyst login` says the browser opened only when it did.
10
+ - Onboarding finishes once everything work needs is in place. Repository settings, values and the daily update are listed as next steps instead of blocking.
11
+
3
12
  ## 0.14.7
4
13
 
5
14
  - Setup uses cloud reads by default and offers local sync as an option. Onboarding preserves verified account and repository choices when it resumes.
package/README.md CHANGED
@@ -153,14 +153,14 @@ Nothing, by default. After `login`, every read, write, ask and explanation goes
153
153
  | process | needed for | what it holds on disk | lifetime |
154
154
  | --- | --- | --- | --- |
155
155
  | none | every read, write, ask and explain | `customer.json` and `contract.json` | the default after login |
156
- | `catalyst replica start` | local SQL, cheap repeated reads, and local event `tail`, `wait-for`, and `query` | one SQLite file plus a bounded event cache under `$XDG_STATE_HOME/catalyst/events/` (fallback `~/.local/state/catalyst/events/`) | one long-running process; foreground by default, `--detach` writes a pidfile beside the database and returns; `login --start-replica` does the same at the end of login |
156
+ | `catalyst replica start` | local SQL, cheap repeated reads, and a shared local event cache read with `events ... --from-cache` | one SQLite file plus a bounded event cache under `$XDG_STATE_HOME/catalyst/events/` (fallback `~/.local/state/catalyst/events/`) | one long-running process; foreground by default, `--detach` writes a pidfile beside the database and returns; `login --start-replica` does the same at the end of login |
157
157
  | `catalyst watch` | a project owner reacting to its scope | one cursor file (`~/.config/catalyst-cloud/watch-cursor.json`) stamped with the account | lives inside the session that armed it; exits with it |
158
158
 
159
159
  CLI and skill reads use the cloud API by default, including on a machine with a fresh local replica. No background writer or local database is needed. `catalyst query ... --source replica` explicitly chooses the optional local view; the first stderr line names the source. Local sync does not promise offline browsing.
160
160
 
161
161
  For a local view, `catalyst replica status` checks the writer, heartbeat and cursor. It exits `0` for fresh, `1` for present but stale, `2` for not connected, `3` for absent. `replica status --probe` compares its cursor with the cloud head. These diagnostics do not change the default read source, and an absent replica does not make `catalyst ready` fail.
162
162
 
163
- `catalyst events status --probe` checks the event cache's own cursor and compares it with the cloud head; `replica status --probe` checks the separate entity-replica cursor. A fresh replica does not prove event freshness. `events tail` follows new cached events, `events wait-for --ticket ... --timeout ...` performs a bounded wait, and `events query` reads retained history. Those three commands only read the cache; `events status --probe` makes the cloud comparison. The replica process is the single writer and reports an event-sync failure without terminating a healthy entity replica. The event cache keeps closed daily segments for at most seven days or 256 MiB per account and reports an explicit gap when a requested sequence has retired.
163
+ `catalyst events tail`, `wait-for`, `query` and `status` read from the cloud by default, with no local file. `events tail` prints one JSON line per new event and stays in the foreground; `events wait-for --ticket ... --timeout ...` prints the first match and exits 0, or exits 1 when none arrives in time; `events query` reads one page of filtered history (`--ticket`, `--type`, `--limit` up to 200, `--order desc|asc`, `--before`, `--after`); `events status` names the cloud head. Add `--from-cache` to read this machine's local event cache instead, which pays off when several agents on one machine share one cloud connection. `catalyst events status --from-cache --probe` checks the cache's own cursor against the cloud head; `replica status --probe` checks the separate entity-replica cursor. A fresh replica does not prove event freshness. The replica process is the single writer and reports an event-sync failure without terminating a healthy entity replica. The event cache keeps closed daily segments for at most seven days or 256 MiB per account and reports an explicit gap when a requested sequence has retired.
164
164
 
165
165
  The replica is a Node process, not a service: the supported path is the plain command, and `skills/catalyst-onboard/references/local-sync.md` gives launchd and systemd examples for people who want the writer to survive a reboot.
166
166
 
package/dist/args.js CHANGED
@@ -107,27 +107,39 @@ export const FLAG_TABLES = {
107
107
  },
108
108
  },
109
109
  events: {
110
+ "from-cache": {
111
+ value: false,
112
+ help: "read the local event cache instead of the cloud (opt-in local sync)",
113
+ },
110
114
  probe: {
111
115
  value: false,
112
- help: "status: compare the local event cursor with the cloud event head",
116
+ help: "status --from-cache: compare the local event cursor with the cloud event head",
113
117
  },
114
118
  type: { value: true, help: "exact event type" },
115
119
  ticket: {
116
120
  value: true,
117
- help: "ticket identifier found in the event payload",
121
+ help: "ticket identifier found in the event",
118
122
  },
119
123
  after: {
120
124
  value: true,
121
- help: "event sequence to read after (tail/wait default to local head)",
125
+ help: "event sequence to read after (tail and wait-for default to new events only)",
126
+ },
127
+ before: {
128
+ value: true,
129
+ help: "query: event sequence to read before (newest-first paging)",
130
+ },
131
+ order: { value: true, help: "query: desc (newest first, default) or asc" },
132
+ limit: {
133
+ value: true,
134
+ help: "query: maximum matching events (default 50; at most 200 from the cloud)",
122
135
  },
123
- limit: { value: true, help: "query: maximum matching events (default 50)" },
124
136
  timeout: {
125
137
  value: true,
126
138
  help: "wait-for: bounded wait in seconds (default 300)",
127
139
  },
128
140
  directory: {
129
141
  value: true,
130
- help: "event cache directory (default: SDK XDG path)",
142
+ help: "with --from-cache: event cache directory (default: SDK XDG path)",
131
143
  },
132
144
  },
133
145
  explain: {
package/dist/browser.js CHANGED
@@ -3,12 +3,15 @@
3
3
  // this never throws into the login flow — the caller wraps it, and a spawn failure is swallowed here.
4
4
  import { spawn } from "node:child_process";
5
5
  /** The platform's "open this URL" command. `open` on macOS, `start` via cmd on Windows, else xdg-open. */
6
- export function openBrowser(url) {
7
- const [cmd, args] = process.platform === "darwin"
6
+ function opener(url, platform = process.platform) {
7
+ return platform === "darwin"
8
8
  ? ["open", [url]]
9
- : process.platform === "win32"
9
+ : platform === "win32"
10
10
  ? ["cmd", ["/c", "start", "", url]]
11
11
  : ["xdg-open", [url]];
12
+ }
13
+ export function openBrowser(url) {
14
+ const [cmd, args] = opener(url);
12
15
  try {
13
16
  const child = spawn(cmd, [...args], { stdio: "ignore", detached: true });
14
17
  child.on("error", () => { }); // no opener installed — the printed URL is the fallback
@@ -18,3 +21,34 @@ export function openBrowser(url) {
18
21
  // spawn itself threw (unusual) — the code and URL on screen are the fallback
19
22
  }
20
23
  }
24
+ /** Login's opener: rejects when no opener ran (none installed, or it exited non-zero, as xdg-open does
25
+ * with no browser), so login claims an opened browser only when one did. An opener still running
26
+ * after `waitMs` launched something and counts as opened. */
27
+ export function openBrowserChecked(url, options = {}) {
28
+ const [cmd, args] = opener(url, options.platform);
29
+ const launch = options.launch ??
30
+ ((command, list) => spawn(command, [...list], { stdio: "ignore", detached: true }));
31
+ return new Promise((resolve, reject) => {
32
+ const fail = () => reject(new Error("browser-unavailable"));
33
+ let child;
34
+ try {
35
+ child = launch(cmd, args);
36
+ }
37
+ catch {
38
+ return fail();
39
+ }
40
+ child.unref();
41
+ const timer = setTimeout(resolve, options.waitMs ?? 3_000);
42
+ child.once("error", () => {
43
+ clearTimeout(timer);
44
+ fail();
45
+ });
46
+ child.once("exit", (code) => {
47
+ clearTimeout(timer);
48
+ if (code === 0)
49
+ resolve();
50
+ else
51
+ fail();
52
+ });
53
+ });
54
+ }
package/dist/cli.js CHANGED
@@ -8,7 +8,7 @@ import { loadContract, pickPath } from "./contract.js";
8
8
  import { CliError, MeError, UsageError } from "./errors.js";
9
9
  import { fetchMe } from "./transport.js";
10
10
  import { bearerFor, deviceFlowLogin } from "./oauth.js";
11
- import { openBrowser as defaultOpenBrowser } from "./browser.js";
11
+ import { openBrowserChecked as defaultOpenBrowser } from "./browser.js";
12
12
  import { cmdAccounts, cmdExplain, cmdHistory, cmdQueue, cmdRunning, } from "./execution.js";
13
13
  import { cmdQuery } from "./query.js";
14
14
  import { cmdReady } from "./ready.js";
@@ -38,6 +38,7 @@ import { cmdConnections } from "./connections.js";
38
38
  import { cmdOnboard, onboardErrorJournal } from "./onboard.js";
39
39
  import { createClackOnboardUi, shouldUseOnboardUi, } from "./onboard-ui.js";
40
40
  import { createOnboardRuntime } from "./onboard-runtime.js";
41
+ import { savedOnboardBaseUrl } from "./onboard-next.js";
41
42
  import { onboardingReadyReport, observeCloudOnboarding, } from "./onboard-ready.js";
42
43
  import { selectedOnboardTeam } from "./onboard-existing.js";
43
44
  export { CONFIG_MODE, DEFAULT_BASE_URL, FIRST_STAMPED_VERSION, LEGACY_PACKAGE_NAME, PACKAGE_NAME, PROVENANCE_MARKER, CliError, MeError, UsageError, configPathFor, contractPathFor, defaultCtx, defaultReplicaDbFor, defaultSkillsDirFor, fetchMe, formatMode, installedBundleVersion, installSkills, loadConfig, modernCliPath, normalizeBaseUrl, parseArgs, parseChangelogEntry, parseProvenanceVersion, readChangelog, readManifest, resolveSkillsDir, saveConfig, skillsSourceDir, updateNoticeLine, writeConfig, };
@@ -72,7 +73,7 @@ export function usageText() {
72
73
  " catalyst query <issues|issue <id>|pulls|pull <id>|projects|cycles|search <terms>|changes --since <cursor|head>>",
73
74
  ' catalyst replica <start [--detach]|stop|status [--probe]|sql "<select>"|schema [table]>',
74
75
  " catalyst runtime <status [--json]|install|path|uninstall> (a pinned Node this CLI manages itself)",
75
- " catalyst events <tail|wait-for|query|status [--probe]> [--type NAME] [--ticket CTC-N] [--after SEQUENCE]",
76
+ " catalyst events <tail|wait-for|query|status> [--type NAME] [--ticket CTC-N] [--after SEQUENCE] [--from-cache]",
76
77
  " catalyst explain <ticket> | history <ticket> | running [--ticket T --phase P] | queue [--team K]",
77
78
  " catalyst watch [--team K] [--ticket T]... [--project P] [--exec CMD]",
78
79
  " catalyst write <comment|state|label|create|reaction|attachment|session> ...",
@@ -253,10 +254,7 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
253
254
  }
254
255
  if (input.isTTY) {
255
256
  try {
256
- ui = createClackOnboardUi(await import("@clack/prompts"), {
257
- input,
258
- output: process.stdout,
259
- });
257
+ ui = createClackOnboardUi(await import("@clack/prompts"), { input, output: process.stdout }, { baseUrl: () => savedOnboardBaseUrl(ctx.home) });
260
258
  }
261
259
  catch (error) {
262
260
  terminal?.destroy();
package/dist/config.js CHANGED
@@ -1,5 +1,3 @@
1
- // config.ts — ~/.config/catalyst-cloud/customer.json and its siblings. The ONLY place the base URL
2
- // and the `/api/v1` prefix are joined: the SDK wants the base with the prefix, GET /me without.
3
1
  import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
4
2
  import { randomBytes } from "node:crypto";
5
3
  import { dirname, join } from "node:path";
@@ -22,6 +20,7 @@ export function defaultCtx() {
22
20
  env: process.env,
23
21
  home: process.env.CATALYST_SKILLS_HOME ?? process.env.HOME ?? "/",
24
22
  stdout: (line) => console.log(line),
23
+ stdoutStream: process.stdout,
25
24
  stderr: (line) => console.error(line),
26
25
  fetch: fetch,
27
26
  now: () => new Date(),
@@ -0,0 +1,58 @@
1
+ import { spawn } from "node:child_process";
2
+ /** Consent links are credentials. Keep them out of errors and join the opener before continuing. */
3
+ export function createConsentBrowserOpener(options = {}) {
4
+ const platform = options.platform ?? process.platform;
5
+ const timeoutMs = options.timeoutMs ?? 5_000;
6
+ const killGraceMs = options.killGraceMs ?? 500;
7
+ const launch = options.launch ?? ((command, args) => spawn(command, [...args], { stdio: "ignore", detached: false }));
8
+ return async (url, signal) => {
9
+ if (signal?.aborted)
10
+ throw new DOMException("Browser opening interrupted", "AbortError");
11
+ const [command, args] = platform === "darwin"
12
+ ? ["open", [url]]
13
+ : platform === "win32"
14
+ ? ["cmd", ["/c", "start", "", url]]
15
+ : ["xdg-open", [url]];
16
+ let child;
17
+ try {
18
+ child = launch(command, args);
19
+ }
20
+ catch {
21
+ throw new Error("Browser could not open");
22
+ }
23
+ await new Promise((resolve, reject) => {
24
+ let interrupted = false;
25
+ let expired = false;
26
+ let failed = false;
27
+ let killTimer;
28
+ const stop = () => {
29
+ child.kill("SIGTERM");
30
+ killTimer ??= setTimeout(() => { child.kill("SIGKILL"); }, killGraceMs);
31
+ };
32
+ const abort = () => { interrupted = true; stop(); };
33
+ const timer = setTimeout(() => { expired = true; stop(); }, timeoutMs);
34
+ child.once("error", () => { failed = true; });
35
+ child.once("close", (code) => {
36
+ clearTimeout(timer);
37
+ if (killTimer)
38
+ clearTimeout(killTimer);
39
+ signal?.removeEventListener("abort", abort);
40
+ if (interrupted)
41
+ reject(new DOMException("Browser opening interrupted", "AbortError"));
42
+ else if (failed || expired || code !== 0)
43
+ reject(new Error("Browser could not open"));
44
+ else
45
+ resolve();
46
+ });
47
+ signal?.addEventListener("abort", abort, { once: true });
48
+ if (signal?.aborted)
49
+ abort();
50
+ });
51
+ };
52
+ }
53
+ export const openConsentBrowser = createConsentBrowserOpener();
54
+ /** The web page that finishes a consent step when no browser opens here. The signed link stays private. */
55
+ export function finishOnTheWeb(baseUrl, page, action) {
56
+ const origin = baseUrl.replace(/\/+$/, "");
57
+ return `No browser opened on this computer. In a browser signed in to Catalyst, open ${origin}/settings/${page} and ${action}. Setup keeps waiting here.`;
58
+ }
@@ -1,3 +1,5 @@
1
+ import { createHash } from "node:crypto";
2
+ import { isRepositoryId } from "../repository-id.js";
1
3
  import { constants } from "node:fs";
2
4
  import { lstat, mkdir, open, unlink } from "node:fs/promises";
3
5
  import { isAbsolute, join, parse, resolve, sep } from "node:path";
@@ -18,8 +20,7 @@ export async function storeOnboardDraft(input, fsOps = defaultFs) {
18
20
  const signal = input?.signal;
19
21
  if (typeof runId !== "string" ||
20
22
  !OPAQUE_ID_RE.test(runId) ||
21
- typeof repoId !== "string" ||
22
- !OPAQUE_ID_RE.test(repoId) ||
23
+ !isRepositoryId(repoId) ||
23
24
  typeof home !== "string" ||
24
25
  !isAbsolute(home) ||
25
26
  typeof toml !== "string" ||
@@ -36,7 +37,13 @@ export async function storeOnboardDraft(input, fsOps = defaultFs) {
36
37
  if (stateRoot === parse(stateRoot).root || stateRoot === resolve(home))
37
38
  return { state: "rejected" };
38
39
  runDir = join(stateRoot, "install", "drafts", runId);
39
- destination = join(runDir, `${repoId}.toml`);
40
+ // Keep earlier slug filenames resumable. New cloud IDs use a portable bounded filename;
41
+ // punctuation normalization would collide and raw IDs can exceed NAME_MAX.
42
+ // The @ namespace cannot collide with any earlier slug filename.
43
+ const filename = OPAQUE_ID_RE.test(repoId)
44
+ ? `${repoId}.toml`
45
+ : `repo@${createHash("sha256").update(repoId, "utf8").digest("hex")}.toml`;
46
+ destination = join(runDir, filename);
40
47
  }
41
48
  catch {
42
49
  return { state: "rejected" };
@@ -1,3 +1,4 @@
1
+ import { isRepositoryId } from "../repository-id.js";
1
2
  import { createHash } from "node:crypto";
2
3
  import { constants } from "node:fs";
3
4
  import { lstat, open, realpath } from "node:fs/promises";
@@ -12,7 +13,8 @@ const refused = () => ({
12
13
  const opaque = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/;
13
14
  function snapshotContext(raw) {
14
15
  if (!raw ||
15
- ![raw.accountId, raw.personId, raw.teamId, raw.repoId].every((id) => typeof id === "string" && opaque.test(id)))
16
+ !isRepositoryId(raw.repoId) ||
17
+ ![raw.accountId, raw.personId, raw.teamId].every((id) => typeof id === "string" && opaque.test(id)))
16
18
  return null;
17
19
  if (!["owner", "admin", "member"].includes(raw.role) ||
18
20
  !/^[A-Z][A-Z0-9]{0,15}$/.test(raw.teamKey))
@@ -1,20 +1,32 @@
1
1
  import { cloudEvents } from "./events-cloud.js";
2
- /** The same matching rules as the local tail, so a filter means one thing from either source. */
2
+ // A lease causation names its ticket: `lease:<ticket>/<phase>#<nonce>` (event-index/fields.ts).
3
+ const CAUSED_BY_LEASE = /^lease:([^/]+)\/[^#]+#/;
4
+ // The index stores at most this many UTF-8 bytes of ticket; a longer value is not indexed.
5
+ const MAX_INDEXED_TICKET_BYTES = 64;
6
+ function boundedTicket(value) {
7
+ if (typeof value !== "string" || value.length === 0)
8
+ return null;
9
+ return new TextEncoder().encode(value).length > MAX_INDEXED_TICKET_BYTES ? null : value;
10
+ }
11
+ /** The ticket an event is about, resolved exactly as the cloud's event index resolves it
12
+ * (catalyst-cloud apps/mirror/src/event-index/fields.ts): a `ticket` entity's id, then
13
+ * `payload.ticket`, then the lease that caused it, each at most 64 bytes. A body-unavailable stub
14
+ * carries the index's own `ticket`. Nothing else counts, so `--from-cache` matches exactly what the
15
+ * cloud path returns. */
16
+ export function eventTicket(event) {
17
+ const e = event;
18
+ if (e.bodyUnavailable === true)
19
+ return boundedTicket(e.ticket);
20
+ const fromEntity = e.entity?.type === "ticket" ? boundedTicket(e.entity.id) : null;
21
+ const payload = e.payload && typeof e.payload === "object" ? e.payload : undefined;
22
+ const lease = typeof e.causationId === "string" ? CAUSED_BY_LEASE.exec(e.causationId) : null;
23
+ return fromEntity ?? boundedTicket(payload?.ticket) ?? boundedTicket(lease?.[1]);
24
+ }
25
+ /** The type and ticket filter, the same on the cloud and the local cache paths. */
3
26
  export function eventMatches(filter) {
4
27
  const ticket = filter.ticket?.toUpperCase();
5
28
  return (event) => (!filter.type || event.type === filter.type) &&
6
- (!ticket || payloadReferences(event.payload, ticket));
7
- }
8
- function payloadReferences(value, ticket) {
9
- if (typeof value === "string")
10
- return value.toUpperCase() === ticket;
11
- if (Array.isArray(value))
12
- return value.some((item) => payloadReferences(item, ticket));
13
- if (!value || typeof value !== "object")
14
- return false;
15
- return Object.entries(value).some(([key, item]) => ["ticket", "identifier", "workItemKey", "issueIdentifier"].includes(key) && typeof item === "string"
16
- ? item.toUpperCase() === ticket
17
- : payloadReferences(item, ticket));
29
+ (!ticket || eventTicket(event)?.toUpperCase() === ticket);
18
30
  }
19
31
  function pipeClosed(error) {
20
32
  const code = error?.code;
@@ -11,6 +11,22 @@ const maxLagPoll = 8_000;
11
11
  // CTC-4562 — once the cloud pushes events, the replay is only a safety net for a silent socket.
12
12
  const pushedFallback = 600_000;
13
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
+ }
14
30
  function sequence(value) {
15
31
  return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
16
32
  }
@@ -79,17 +95,32 @@ async function send(ctx, url, accept, allowed, signal) {
79
95
  throw new CliError("cloud event access refused; check catalyst status and sign in again", "events-auth", 2, response.status);
80
96
  }
81
97
  if (response.status !== 200 && !allowed.includes(response.status)) {
82
- await response.body?.cancel();
83
98
  if (response.status >= 500 || response.status === 429) {
99
+ await response.body?.cancel();
84
100
  const raw = response.headers.get("retry-after");
85
101
  const seconds = raw === null ? NaN : Number(raw);
86
102
  const delay = Number.isFinite(seconds) ? seconds * 1_000 : raw ? Date.parse(raw) - ctx.now().getTime() : 0;
87
103
  throw new RetryRequest(Number.isFinite(delay) ? Math.min(300_000, Math.max(0, delay)) : 0);
88
104
  }
89
- throw new CliError(`cloud event request refused (HTTP ${response.status})`, "events-http", 2, response.status);
105
+ throw new CliError(`cloud event request refused (HTTP ${response.status}${await refusalReason(response)})`, "events-http", 2, response.status);
90
106
  }
91
107
  return response;
92
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
+ }
93
124
  /** Probe the head without downloading the backlog. An ahead-of-head cursor is deliberate. */
94
125
  export async function cloudEventHead(ctx, signal = AbortSignal.timeout(15_000)) {
95
126
  const response = await request(ctx, Number.MAX_SAFE_INTEGER, signal);
@@ -104,12 +135,76 @@ export async function cloudEventHead(ctx, signal = AbortSignal.timeout(15_000))
104
135
  await response.body?.cancel();
105
136
  }
106
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
+ }
107
196
  function validEvent(ctx, value, cursor) {
108
197
  if (!value || typeof value !== "object")
109
198
  return false;
110
199
  const event = value;
111
- return event.tenantId === requireConfig(ctx).account && sequence(event.sequence) && event.sequence > cursor &&
112
- typeof event.eventId === "string" && typeof event.type === "string" && typeof event.recordedAt === "string" && "payload" in event;
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;
113
208
  }
114
209
  // A page closed early by its consumer ends with undefined.
115
210
  async function* replay(ctx, after, signal) {
@@ -188,11 +283,24 @@ async function* replay(ctx, after, signal) {
188
283
  // `coverage.indexedToSeq` may trail the head; the reader polls until it catches up.
189
284
  async function* queryPage(ctx, after, filter, signal) {
190
285
  const url = eventsUrl(ctx, "query", { order: "asc", afterSeq: String(after), limit: String(queryLimit), type: filter.type, ticket: filter.ticket });
191
- const response = await send(ctx, url, "application/json", [404], signal);
286
+ const response = await send(ctx, url, "application/json", [404, 413], signal);
192
287
  if (response.status === 404) {
193
288
  await response.body?.cancel();
194
289
  return "absent";
195
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
+ }
196
304
  const text = await response.text();
197
305
  if (text.length > maxQueryBody)
198
306
  throw protocol("cloud event query response exceeds its size limit");
@@ -281,6 +389,9 @@ export async function* cloudEvents(ctx, options, deps = {}) {
281
389
  // A filter reads the server's index; a server without the route gets the backbone for the whole run.
282
390
  let indexed = Boolean(filter.type || filter.ticket);
283
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;
284
395
  while (!signal.aborted) {
285
396
  let socket;
286
397
  let heartbeat;
@@ -457,6 +568,11 @@ export async function* cloudEvents(ctx, options, deps = {}) {
457
568
  if (!end)
458
569
  return;
459
570
  backoff = 1_000;
571
+ if (failures > 0) {
572
+ failures = 0;
573
+ failingSince = undefined;
574
+ deps.onHealthy?.();
575
+ }
460
576
  if (failure)
461
577
  throw failure;
462
578
  cursor = Math.max(cursor, end.through);
@@ -483,6 +599,9 @@ export async function* cloudEvents(ctx, options, deps = {}) {
483
599
  if (error instanceof RetryRequest)
484
600
  retryDelay = error.delayMs;
485
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 });
486
605
  }
487
606
  finally {
488
607
  clearInterval(heartbeat);