@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.
- package/CHANGELOG.md +9 -0
- package/README.md +2 -2
- package/dist/args.js +17 -5
- package/dist/browser.js +37 -3
- package/dist/cli.js +4 -6
- package/dist/config.js +1 -2
- package/dist/consent-browser.js +58 -0
- package/dist/env/onboard-draft-store.js +10 -3
- package/dist/env/settings-authority.js +3 -1
- package/dist/events-cloud-tail.js +25 -13
- package/dist/events-cloud.js +124 -5
- package/dist/events.js +175 -29
- package/dist/oauth.js +4 -1
- package/dist/onboard-github.js +14 -3
- package/dist/onboard-next.js +139 -0
- package/dist/onboard-personal.js +14 -3
- package/dist/onboard-repositories.js +3 -4
- package/dist/onboard-runtime.js +4 -4
- package/dist/onboard-ui.js +14 -76
- package/dist/onboard-workspace.js +14 -3
- package/dist/onboard.js +63 -12
- package/dist/pagination.js +13 -1
- package/dist/query.js +39 -16
- package/dist/repository-id.js +5 -0
- package/package.json +1 -1
- package/skills/catalyst-github/SKILL.md +3 -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-linear/SKILL.md +3 -1
- package/skills/catalyst-linear/references/local-replica.md +91 -0
- package/skills/catalyst-linear/references/reading-from-the-cloud.md +38 -0
- package/skills/catalyst-onboard/SKILL.md +3 -2
- package/skills/catalyst-onboard/references/local-replica.md +40 -0
- package/skills/catalyst-onboard/references/local-sync.md +5 -3
- 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 +3 -1
- package/skills/whats-happening/references/local-replica.md +51 -0
- 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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
7
|
-
|
|
6
|
+
function opener(url, platform = process.platform) {
|
|
7
|
+
return platform === "darwin"
|
|
8
8
|
? ["open", [url]]
|
|
9
|
-
:
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
!
|
|
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
|
-
|
|
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 ||
|
|
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;
|
package/dist/events-cloud.js
CHANGED
|
@@ -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
|
-
|
|
112
|
-
typeof event.eventId
|
|
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);
|