@catalyst-cloud/catalyst-skills 0.4.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -0
- package/README.md +19 -12
- package/dist/args.js +34 -4
- package/dist/ask.js +6 -3
- package/dist/cli.js +19 -5
- package/dist/config.js +4 -0
- package/dist/contract.js +6 -0
- package/dist/environment.js +207 -0
- package/dist/events.js +106 -0
- package/dist/execution.js +146 -18
- package/dist/pagination.js +76 -0
- package/dist/published.js +75 -0
- package/dist/query.js +41 -18
- package/dist/ready.js +110 -19
- package/dist/release.js +142 -0
- package/dist/replica.js +296 -30
- package/dist/semver.js +18 -0
- package/dist/skill-shape.js +10 -0
- package/dist/skills.js +52 -1
- package/dist/transport.js +5 -2
- package/dist/ts-deps-loader.js +46 -1
- package/package.json +7 -4
- package/skills/catalyst-github/SKILL.md +1 -1
- package/skills/catalyst-github/scripts/lib/cli.mjs +3 -2
- package/skills/catalyst-github/scripts/lib/credential.mjs +29 -0
- package/skills/catalyst-linear/SKILL.md +1 -1
- package/skills/catalyst-linear/references/reading-a-ticket.md +2 -0
- package/skills/catalyst-linear/scripts/lib/cli.mjs +4 -3
- package/skills/catalyst-linear/scripts/lib/credential.mjs +29 -0
- package/skills/catalyst-onboard/SKILL.md +49 -0
- package/skills/catalyst-onboard/agents/openai.yaml +6 -0
- package/skills/catalyst-onboard/agents/portability.yaml +5 -0
- package/skills/catalyst-onboard/references/the-one-path.md +143 -0
- package/skills/catalyst-onboard/references/what-the-browser-owns.md +50 -0
- package/skills/catalyst-onboard/references/who-fixes-what.md +41 -0
- package/skills/catalyst-onboard/scripts/lib/cli.mjs +117 -0
- package/skills/catalyst-onboard/scripts/lib/credential.mjs +29 -0
- package/skills/catalyst-onboard/scripts/where-am-i.mjs +245 -0
- package/skills/catalyst-setup/SKILL.md +3 -3
- package/skills/catalyst-setup/references/what-each-check-means.md +22 -6
- package/skills/catalyst-setup/scripts/lib/cli.mjs +3 -2
- package/skills/catalyst-setup/scripts/lib/credential.mjs +29 -0
- package/skills/catalyst-setup/scripts/replica-status.mjs +1 -1
- package/skills/connect-me/SKILL.md +1 -1
- package/skills/connect-me/scripts/lib/cli.mjs +3 -2
- package/skills/connect-me/scripts/lib/credential.mjs +29 -0
- package/skills/how-catalyst-works/SKILL.md +1 -1
- package/skills/how-catalyst-works/references/stages-and-mapping.md +2 -2
- package/skills/how-catalyst-works/references/what-runs-next.md +1 -1
- package/skills/how-catalyst-works/references/when-a-phase-fails.md +4 -4
- package/skills/how-catalyst-works/scripts/lib/cli.mjs +4 -3
- package/skills/how-catalyst-works/scripts/lib/credential.mjs +29 -0
- package/skills/run-this-project/SKILL.md +2 -2
- package/skills/run-this-project/references/making-work-ready.md +1 -1
- package/skills/run-this-project/references/stalls-and-escalation.md +2 -2
- package/skills/run-this-project/scripts/lib/cli.mjs +3 -2
- package/skills/run-this-project/scripts/lib/credential.mjs +29 -0
- package/skills/unstick/SKILL.md +41 -0
- package/skills/unstick/agents/openai.yaml +6 -0
- package/skills/unstick/agents/portability.yaml +5 -0
- package/skills/unstick/references/playbook.md +51 -0
- package/skills/unstick/scripts/lib/cli.mjs +135 -0
- package/skills/unstick/scripts/lib/credential.mjs +29 -0
- package/skills/unstick/scripts/unstick.mjs +57 -0
- package/skills/what-needs-me/SKILL.md +1 -1
- package/skills/what-needs-me/references/reading-the-inbox.md +1 -1
- package/skills/what-needs-me/scripts/lib/cli.mjs +3 -2
- package/skills/what-needs-me/scripts/lib/credential.mjs +29 -0
- package/skills/whats-happening/SKILL.md +2 -2
- package/skills/whats-happening/references/why-is-it-stuck.md +9 -4
- package/skills/whats-happening/scripts/lib/cli.mjs +3 -2
- package/skills/whats-happening/scripts/lib/credential.mjs +29 -0
- package/skills/whats-happening/scripts/snapshot.mjs +4 -0
package/dist/events.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { flagInt, flagString, positionals } from "./args.js";
|
|
2
|
+
import { apiBase, requireConfig } from "./config.js";
|
|
3
|
+
import { CliError, UsageError } from "./errors.js";
|
|
4
|
+
import { authStrategyFor } from "./oauth.js";
|
|
5
|
+
const eventsModule = "@catalyst-cloud/sdk/events";
|
|
6
|
+
export const loadEventsSdk = () => import(eventsModule);
|
|
7
|
+
export async function createEventSync(ctx, deps = {}) {
|
|
8
|
+
const cfg = requireConfig(ctx);
|
|
9
|
+
const sdk = await (deps.loadSdk ?? loadEventsSdk)();
|
|
10
|
+
const auth = authStrategyFor(ctx, cfg);
|
|
11
|
+
if (auth.kind === "cookie")
|
|
12
|
+
throw new CliError("event sync requires a token or OAuth session", "events-auth");
|
|
13
|
+
return new sdk.CatalystEventSync({
|
|
14
|
+
baseUrl: apiBase(cfg),
|
|
15
|
+
auth,
|
|
16
|
+
tenantId: cfg.account,
|
|
17
|
+
fetch: ctx.fetch,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
export async function cmdEvents(args, ctx, deps = {}) {
|
|
21
|
+
const [sub] = positionals(args);
|
|
22
|
+
if (!sub)
|
|
23
|
+
throw new UsageError("events needs a subcommand: tail | wait-for | query");
|
|
24
|
+
if (!["tail", "wait-for", "query"].includes(sub))
|
|
25
|
+
throw new UsageError(`unknown events subcommand: ${sub}`);
|
|
26
|
+
const cfg = requireConfig(ctx);
|
|
27
|
+
const sdk = await (deps.loadSdk ?? loadEventsSdk)();
|
|
28
|
+
const directory = flagString(args, "directory") ??
|
|
29
|
+
sdk.defaultEventCacheDirectory(cfg.account);
|
|
30
|
+
const after = startingCursor(args, sub === "query");
|
|
31
|
+
const matches = matcher(args);
|
|
32
|
+
if (sub === "query") {
|
|
33
|
+
const events = (await sdk.readCachedEvents({ tenantId: cfg.account, directory, after }))
|
|
34
|
+
.filter(matches)
|
|
35
|
+
.slice(-flagInt(args, "limit", 50));
|
|
36
|
+
for (const event of events)
|
|
37
|
+
ctx.stdout(JSON.stringify(event));
|
|
38
|
+
return 0;
|
|
39
|
+
}
|
|
40
|
+
const controller = new AbortController();
|
|
41
|
+
const abort = () => controller.abort(deps.signal?.reason);
|
|
42
|
+
if (deps.signal?.aborted)
|
|
43
|
+
abort();
|
|
44
|
+
else
|
|
45
|
+
deps.signal?.addEventListener("abort", abort, { once: true });
|
|
46
|
+
const timer = sub === "wait-for"
|
|
47
|
+
? setTimeout(() => controller.abort(new Error("event wait timed out")), flagInt(args, "timeout", 300) * 1_000)
|
|
48
|
+
: undefined;
|
|
49
|
+
try {
|
|
50
|
+
for await (const event of sdk.tailCachedEvents({
|
|
51
|
+
tenantId: cfg.account,
|
|
52
|
+
directory,
|
|
53
|
+
after,
|
|
54
|
+
signal: controller.signal,
|
|
55
|
+
})) {
|
|
56
|
+
if (!matches(event))
|
|
57
|
+
continue;
|
|
58
|
+
ctx.stdout(JSON.stringify(event));
|
|
59
|
+
if (sub === "wait-for")
|
|
60
|
+
return 0;
|
|
61
|
+
}
|
|
62
|
+
return sub === "wait-for" ? 1 : 0;
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
if (controller.signal.aborted)
|
|
66
|
+
return sub === "wait-for" ? 1 : 0;
|
|
67
|
+
if (error.code === "ENOENT")
|
|
68
|
+
throw new CliError(`event cache is absent at ${directory} — start it with: catalyst-skills replica start --detach`, "events-absent", 3);
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
if (timer)
|
|
73
|
+
clearTimeout(timer);
|
|
74
|
+
deps.signal?.removeEventListener("abort", abort);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function startingCursor(args, history) {
|
|
78
|
+
const requested = flagString(args, "after");
|
|
79
|
+
if (requested !== undefined) {
|
|
80
|
+
const parsed = Number(requested);
|
|
81
|
+
if (!Number.isSafeInteger(parsed) || parsed < 0)
|
|
82
|
+
throw new UsageError("--after must be a non-negative event sequence");
|
|
83
|
+
return parsed;
|
|
84
|
+
}
|
|
85
|
+
if (history)
|
|
86
|
+
return undefined;
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
function matcher(args) {
|
|
90
|
+
const type = flagString(args, "type");
|
|
91
|
+
const ticket = flagString(args, "ticket")?.toUpperCase();
|
|
92
|
+
return (event) => (!type || event.type === type) &&
|
|
93
|
+
(!ticket || payloadReferences(event.payload, ticket));
|
|
94
|
+
}
|
|
95
|
+
function payloadReferences(value, ticket) {
|
|
96
|
+
if (typeof value === "string")
|
|
97
|
+
return value.toUpperCase() === ticket;
|
|
98
|
+
if (Array.isArray(value))
|
|
99
|
+
return value.some((item) => payloadReferences(item, ticket));
|
|
100
|
+
if (!value || typeof value !== "object")
|
|
101
|
+
return false;
|
|
102
|
+
return Object.entries(value).some(([key, item]) => ["ticket", "identifier", "workItemKey", "issueIdentifier"].includes(key) &&
|
|
103
|
+
typeof item === "string"
|
|
104
|
+
? item.toUpperCase() === ticket
|
|
105
|
+
: payloadReferences(item, ticket));
|
|
106
|
+
}
|
package/dist/execution.js
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
// exclusion reason and unknown the cloud names; an unlisted reason prints its raw string, never nothing.
|
|
4
4
|
import { flagBool, flagString, positionals } from "./args.js";
|
|
5
5
|
import { normalizeBaseUrl, requireConfig } from "./config.js";
|
|
6
|
-
import { loadContract } from "./contract.js";
|
|
7
|
-
import { CliError, UsageError } from "./errors.js";
|
|
6
|
+
import { findTeamByKey, loadContract } from "./contract.js";
|
|
7
|
+
import { CliError, MeError, UsageError } from "./errors.js";
|
|
8
8
|
import { apiClient } from "./transport.js";
|
|
9
9
|
/** Every exclusion reason the eligibility evaluator names, in plain English. */
|
|
10
10
|
export const EXCLUSION_REASONS = {
|
|
@@ -37,13 +37,20 @@ export const EXCLUSION_REASONS = {
|
|
|
37
37
|
retry_backoff: "the failed phase is retrying in place and waiting out its backoff",
|
|
38
38
|
routing_unavailable: "the unit was claimed and refused at kickoff (no route, no eligible coding-account slot, or the provider is unavailable)",
|
|
39
39
|
repo_paused: "an operator paused the repository",
|
|
40
|
-
remediate_parked: "the remediate phase is parked, so the failing phase has nowhere to be repaired",
|
|
40
|
+
remediate_parked: "the remediate phase is parked, so the failing phase has nowhere to be repaired; `catalyst-skills release <ticket>` releases the park once its cause is fixed",
|
|
41
|
+
phase_parked: "the offered phase is parked after repeated failures or the repair-round cap; `catalyst-skills release <ticket>` releases it once its cause is fixed",
|
|
42
|
+
later_phase_lease_held: "an earlier phase is offered while a live container still holds a later phase of this ticket",
|
|
43
|
+
human_owned_pr: "a person's own pull request holds this ticket; it releases itself when that PR closes or merges",
|
|
44
|
+
review_not_converging: "review and repair kept finding new problems without converging; a person reads the findings and comments on the ticket to resume",
|
|
45
|
+
round_threshold: "the ticket spent its lifetime repair budget; answering its ask or pushing a fix buys one more cycle",
|
|
46
|
+
claim_storm: "this unit was claimed too many times in the last hour, so it waits out the hour; nothing to release",
|
|
47
|
+
repo_at_capacity: "the repository's runner seats are all in use; it starts when one frees",
|
|
41
48
|
};
|
|
42
49
|
/** Every fail-closed unknown the evaluator names. */
|
|
43
50
|
export const UNKNOWN_REASONS = {
|
|
44
|
-
ordering_never_published: "the dispatch order has never been published for this team",
|
|
51
|
+
ordering_never_published: "the dispatch order has never been published for this team — usually because the team has no saved stage mapping, which does not clear by itself",
|
|
45
52
|
ordering_stale: "the dispatch order is stale",
|
|
46
|
-
workflow_mapping_unknown: "the team
|
|
53
|
+
workflow_mapping_unknown: "the team has no saved stage mapping for dispatch, pr, done and canceled (or a mapped stage was deleted); it does not clear by itself — a tenant owner or admin maps the team in Settings → Linear teams",
|
|
47
54
|
ticket_unknown: "the ticket is not in the mirror",
|
|
48
55
|
dependency_snapshot_unknown: "the dependency snapshot has never completed",
|
|
49
56
|
blocker_unknown: "a blocking relation could not be resolved",
|
|
@@ -61,7 +68,41 @@ export function describeReason(reason) {
|
|
|
61
68
|
return "no reason was given";
|
|
62
69
|
return EXCLUSION_REASONS[reason] ?? UNKNOWN_REASONS[reason] ?? `reason "${reason}" (not in this bundle's table; read it as the cloud spelled it)`;
|
|
63
70
|
}
|
|
64
|
-
|
|
71
|
+
/** The contract spells the same concept `status`; normalize onto the `cause` the renderer uses.
|
|
72
|
+
* `"open"` and an absent/shapeless gate both mean "nothing to report" — null. An unrecognized
|
|
73
|
+
* status is passed through as `cause`, never swallowed (D2/D3 — CTC-2208). */
|
|
74
|
+
export function gateFromContract(g) {
|
|
75
|
+
if (!g || typeof g.status !== "string")
|
|
76
|
+
return null;
|
|
77
|
+
if (g.status === "open")
|
|
78
|
+
return null;
|
|
79
|
+
return { cause: g.status, missingSlots: g.missingSlots, remedy: g.remedy, source: "contract" };
|
|
80
|
+
}
|
|
81
|
+
/** True when the live eligibility read failed in a way a cached gate may stand in for: the read was
|
|
82
|
+
* UNAVAILABLE (network/timeout/non-JSON, or a 404/5xx). A cloud that ANSWERED — 401/403/400 — is
|
|
83
|
+
* refusing, not silent, and a cached gate must never hide that refusal behind an unrelated mapping
|
|
84
|
+
* story (D4 — CTC-2208). */
|
|
85
|
+
export function liveReadMayFallBack(err) {
|
|
86
|
+
if (err instanceof MeError)
|
|
87
|
+
return true;
|
|
88
|
+
if (err instanceof CliError)
|
|
89
|
+
return err.status === undefined || err.status === 404 || err.status >= 500;
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
export function renderDispatchGate(ticket, team, gate) {
|
|
93
|
+
const slots = (gate.missingSlots ?? []).join(", ");
|
|
94
|
+
const why = gate.cause === "mapping_state_unresolved"
|
|
95
|
+
? `the stage team ${team} mapped for ${slots} no longer exists in Linear.`
|
|
96
|
+
: gate.cause === "mapping_missing"
|
|
97
|
+
? `team ${team} has no saved stage mapping for ${slots}.`
|
|
98
|
+
: `team ${team} cannot be dispatched (${gate.cause}).`;
|
|
99
|
+
return [`${ticket} cannot start: ${why}`, gate.remedy].filter(Boolean).join(" ");
|
|
100
|
+
}
|
|
101
|
+
export function renderExplain(ticket, row, team, knownState, gate) {
|
|
102
|
+
// An unmapped team has no queue and so no rows; its gate is the whole answer, and it outranks the
|
|
103
|
+
// Backlog probe below, which would otherwise call the team's own Todo "not a dispatch state".
|
|
104
|
+
if (gate)
|
|
105
|
+
return renderDispatchGate(ticket, team, gate);
|
|
65
106
|
if (!row) {
|
|
66
107
|
// A null dispatch row is not proof the ticket does not exist — the explainer is a dispatch scan. When
|
|
67
108
|
// `knownState` is set (the mirror answered GET /issues/:id) the ticket is real; it just sits outside a
|
|
@@ -116,25 +157,90 @@ export async function cmdExplain(args, ctx) {
|
|
|
116
157
|
throw new UsageError(`"${ticket}" is not a ticket identifier (expected KEY-123)`);
|
|
117
158
|
const team = ticket.slice(0, dash).toUpperCase();
|
|
118
159
|
const api = apiClient(cfg, ctx);
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const row = rows.find((r) => String(r.ticket ?? "").toUpperCase() === ticket.toUpperCase()) ?? null;
|
|
124
|
-
// No dispatch row is not proof of non-existence: probe the mirror so a Backlog ticket reads as known.
|
|
125
|
-
// The mirror compares identifiers exactly, so normalize (uppercase) as the row lookup above does —
|
|
126
|
-
// otherwise `explain eng-7` probes a lowercase id, 404s, and reports an existing ENG-7 as unknown.
|
|
160
|
+
// The cached contract's own gate for this team. Read BEFORE the live call so it can stand in when
|
|
161
|
+
// that call cannot be made at all. A team absent from the contract simply has none.
|
|
162
|
+
const cachedGate = gateFromContract(findTeamByKey(doc, team)?.dispatchGate);
|
|
163
|
+
let row = null;
|
|
127
164
|
let knownState = null;
|
|
165
|
+
let liveGate = null;
|
|
166
|
+
let liveFailure = null;
|
|
167
|
+
let liveGateRaw = null;
|
|
168
|
+
// This `try` wraps the eligibility read ALONE. It used to span the mirror probe below as well,
|
|
169
|
+
// which broke both reads at once: a probe outage was reported as "the live eligibility read was
|
|
170
|
+
// unavailable", and an eligibility outage skipped the probe entirely so `knownState` stayed null
|
|
171
|
+
// and a mistyped id inherited the team-wide gate — the very bug 4defc34 was written to fix.
|
|
172
|
+
try {
|
|
173
|
+
const res = await api.getJson("/api/v1/work-eligibility", { query: { team, capabilities: doc.ladder.phases.join(",") } });
|
|
174
|
+
const rows = res.body.eligibility?.rows ?? res.body.rows ?? [];
|
|
175
|
+
row = rows.find((r) => String(r.ticket ?? "").toUpperCase() === ticket.toUpperCase()) ?? null;
|
|
176
|
+
liveGateRaw = res.body.eligibility?.dispatchGate ?? null;
|
|
177
|
+
}
|
|
178
|
+
catch (err) {
|
|
179
|
+
// A cached gate is an answer the live read cannot give right now — but ONLY for a failure that
|
|
180
|
+
// means "unavailable". A 401/403/400 is the cloud answering, and must still refuse (CTC-2208 D4).
|
|
181
|
+
if (!cachedGate || !liveReadMayFallBack(err))
|
|
182
|
+
throw err;
|
|
183
|
+
liveFailure = err instanceof Error ? err : new Error(String(err));
|
|
184
|
+
}
|
|
185
|
+
// No dispatch row is not proof of non-existence: probe the mirror so a Backlog ticket reads as
|
|
186
|
+
// known. The mirror compares identifiers exactly, so normalize as the row lookup above does —
|
|
187
|
+
// otherwise `explain eng-7` probes a lowercase id, 404s, and reports an existing ENG-7 unknown.
|
|
188
|
+
// Deliberately OUTSIDE the `try`: the probe is the ONLY read that can prove this id exists, so it
|
|
189
|
+
// must still run when the eligibility read was unavailable, and its own failure must surface as
|
|
190
|
+
// itself — a refusal naming the /issues/ read — never as a gate paragraph or as an eligibility
|
|
191
|
+
// failure. That is the pre-4defc34 behaviour for this call and it is restored unchanged.
|
|
128
192
|
if (!row) {
|
|
129
193
|
const probe = await api.getJson(`/api/v1/issues/${encodeURIComponent(ticket.toUpperCase())}`, { accept: [404] });
|
|
130
194
|
if (probe.status !== 404)
|
|
131
195
|
knownState = typeof probe.body?.state === "string" ? probe.body.state : "unknown";
|
|
132
196
|
}
|
|
197
|
+
// The gate is TEAM-wide: it can say why a real ticket in that team cannot start, never that this
|
|
198
|
+
// id exists. Applied only when the probe found the ticket; a 404 keeps the unknown wording.
|
|
199
|
+
liveGate = !row && knownState && liveGateRaw ? { ...liveGateRaw, source: "live" } : null;
|
|
200
|
+
// The cached gate is team-wide too, so it answers on exactly the same condition as the live one:
|
|
201
|
+
// the mirror found the ticket and no row explains it. An unavailable eligibility read licenses the
|
|
202
|
+
// FALLBACK, never the claim that this id exists.
|
|
203
|
+
const usingCached = !liveGate && !row && knownState !== null;
|
|
204
|
+
const gate = liveGate ?? (usingCached ? cachedGate : null);
|
|
205
|
+
// Both sides spoke and disagreed: the live read wins, and the paragraph says the cache disagreed.
|
|
206
|
+
// Computed together with its sentence so the "live is non-null here" fact never needs re-asserting.
|
|
207
|
+
let overridden = null;
|
|
208
|
+
let overriddenNote = null;
|
|
209
|
+
if (liveGate && cachedGate && liveGate.cause !== cachedGate.cause) {
|
|
210
|
+
overridden = cachedGate;
|
|
211
|
+
overriddenNote = `The cached tenant contract says ${cachedGate.cause}; this live eligibility read says ${liveGate.cause} and wins.`;
|
|
212
|
+
}
|
|
213
|
+
// A live read that produced a row contradicts a cached "nothing in this team can start".
|
|
214
|
+
const contradictedByRow = !liveFailure && row && cachedGate ? cachedGate : null;
|
|
215
|
+
const parts = [renderExplain(ticket, row, team, knownState, gate)];
|
|
216
|
+
if (gate && gate === cachedGate) {
|
|
217
|
+
parts.push(liveFailure
|
|
218
|
+
? `Read from the cached tenant contract: the live eligibility read was unavailable (${liveFailure.message}).`
|
|
219
|
+
: "Read from the cached tenant contract: this cloud's eligibility read sent no dispatch gate.");
|
|
220
|
+
}
|
|
221
|
+
if (overriddenNote)
|
|
222
|
+
parts.push(overriddenNote);
|
|
223
|
+
// The mirror answered and the eligibility read did not: say so, rather than let a network outage
|
|
224
|
+
// read as a confident verdict from an explainer this run never actually reached.
|
|
225
|
+
if (liveFailure && gate !== cachedGate) {
|
|
226
|
+
parts.push(`The live eligibility read was unavailable (${liveFailure.message}); only the mirror answered.`);
|
|
227
|
+
}
|
|
228
|
+
if (contradictedByRow) {
|
|
229
|
+
parts.push(`The cached tenant contract says ${contradictedByRow.cause} for team ${team}; this live eligibility read has a row for ${ticket} and wins.`);
|
|
230
|
+
}
|
|
231
|
+
const explanation = parts.join(" ");
|
|
133
232
|
if (args.json) {
|
|
134
|
-
ctx.stdout(JSON.stringify({
|
|
233
|
+
ctx.stdout(JSON.stringify({
|
|
234
|
+
ticket,
|
|
235
|
+
row,
|
|
236
|
+
...(gate ? { dispatchGate: gate } : {}),
|
|
237
|
+
...(overridden || contradictedByRow ? { dispatchGateOverridden: overridden ?? contradictedByRow } : {}),
|
|
238
|
+
...(liveFailure ? { liveReadFailed: liveFailure.message } : {}),
|
|
239
|
+
explanation,
|
|
240
|
+
}));
|
|
135
241
|
}
|
|
136
242
|
else {
|
|
137
|
-
ctx.stdout(
|
|
243
|
+
ctx.stdout(explanation);
|
|
138
244
|
}
|
|
139
245
|
return 0;
|
|
140
246
|
}
|
|
@@ -237,7 +343,7 @@ export function renderAccount(a) {
|
|
|
237
343
|
* ahead of some tenants' deployed mirror; a 404 there means "your cloud is older than this bundle",
|
|
238
344
|
* which is a different fact from "you have no coding accounts" and must never print as the latter.
|
|
239
345
|
*/
|
|
240
|
-
function needsNewerCloud(what, cfg) {
|
|
346
|
+
export function needsNewerCloud(what, cfg) {
|
|
241
347
|
return new CliError(`${what} needs a newer Catalyst Cloud than ${normalizeBaseUrl(cfg.baseUrl)} is running — the route answered 404. Nothing is wrong with your tenant; ask your operator when the mirror last deployed.`, "route-not-deployed", 3, 404);
|
|
242
348
|
}
|
|
243
349
|
export async function cmdAccounts(args, ctx) {
|
|
@@ -310,7 +416,29 @@ export function renderHistory(ticket, doc) {
|
|
|
310
416
|
lines.push(`Remediate rounds dispatched: ${doc.remediate.roundsDispatched}${typeof doc.remediate.cap === "number" ? ` (cap ${doc.remediate.cap})` : ""}`);
|
|
311
417
|
}
|
|
312
418
|
if (doc.park) {
|
|
313
|
-
lines.push(`Parked at ${doc.park.phase ?? "?"} (${doc.park.sentinel}): ${doc.park.selfReleases ? "releases itself" : "
|
|
419
|
+
lines.push(`Parked at ${doc.park.phase ?? "?"} (${doc.park.sentinel}): ${doc.park.selfReleases ? "releases itself" : "does not release itself"} — ${doc.park.releasedBy}`);
|
|
420
|
+
}
|
|
421
|
+
// Absent (an older cloud) prints nothing; `null` is a table that could not be read.
|
|
422
|
+
if (doc.governors === null) {
|
|
423
|
+
lines.push("Held by: could not be read (unreadable, not empty)");
|
|
424
|
+
}
|
|
425
|
+
else if (doc.governors !== undefined && doc.governors.length > 0) {
|
|
426
|
+
lines.push("Held by:");
|
|
427
|
+
for (const g of doc.governors) {
|
|
428
|
+
lines.push(` ${g.kind}${g.phase ? ` at ${g.phase}` : ""}${g.sentinel ? ` (${g.sentinel})` : ""} — ${g.release ?? "no release named"}`);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
if (doc.releases === null) {
|
|
432
|
+
lines.push("Releases: could not be read (unreadable, not empty)");
|
|
433
|
+
}
|
|
434
|
+
else if (doc.releases !== undefined && doc.releases.length > 0) {
|
|
435
|
+
lines.push("Releases (newest first):");
|
|
436
|
+
for (const r of doc.releases) {
|
|
437
|
+
const what = r.outcome === "released"
|
|
438
|
+
? `released: ${(r.released ?? []).map((a) => `${a.op}${a.phase ? ` ${a.phase}` : ""}`).join(", ")}`
|
|
439
|
+
: `refused: ${(r.refused ?? []).map((x) => x.code).join(", ")}`;
|
|
440
|
+
lines.push(` ${new Date(r.atMs).toISOString()} ${r.outcome} by ${r.actor.kind}:${r.actor.id} — "${r.because}" — ${what}`);
|
|
441
|
+
}
|
|
314
442
|
}
|
|
315
443
|
for (const l of doc.lease ?? [])
|
|
316
444
|
lines.push(`Live lease: ${l.phase} held by ${l.holder ?? "?"} until ${new Date(l.deadlineMs).toISOString()}`);
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// pagination.ts — the mirror's keyset contract, followed. `/issues` and `/pulls` answer a page plus
|
|
2
|
+
// three headers: X-Mirror-Cursor (the head seq the page is consistent with), X-Mirror-Total (the
|
|
3
|
+
// WHOLE scope's count, independent of the page) and X-Mirror-Next-Cursor — present ONLY while rows
|
|
4
|
+
// remain, ABSENT on the last page. A caller that ignores them gets a page and believes it is the
|
|
5
|
+
// answer, which is the whole of CTC-2010.
|
|
6
|
+
//
|
|
7
|
+
// The SDK's createTenantClient parses these same three headers into a typed KeysetPageMeta, and this
|
|
8
|
+
// module deliberately does not use it: that client takes a STATIC `key`, while this bundle resolves
|
|
9
|
+
// its bearer fresh per request through `bearerFor` (an OAuth session refreshes mid-walk), and `ask`
|
|
10
|
+
// must keep working on a runtime where the SDK cannot load (src/sdk.ts). Adopting it is the CTC-2004
|
|
11
|
+
// Tier 2 migration that also deletes transport.ts — not this change.
|
|
12
|
+
import { CliError } from "./errors.js";
|
|
13
|
+
export const NEXT_CURSOR_HEADER = "x-mirror-next-cursor";
|
|
14
|
+
export const TOTAL_HEADER = "x-mirror-total";
|
|
15
|
+
/** The server clamps every page to this regardless of the requested `limit` (MAX_VIEW_LIMIT). */
|
|
16
|
+
export const SERVER_PAGE_CAP = 500;
|
|
17
|
+
/** 100 pages × 500 rows = 50,000. Reaching it is a refusal, not an ending. */
|
|
18
|
+
export const MAX_PAGES = 100;
|
|
19
|
+
/** The API answers a list route with either a bare array or `{rows|items|issues|...: []}`. */
|
|
20
|
+
export function rowsOf(body) {
|
|
21
|
+
if (Array.isArray(body))
|
|
22
|
+
return body;
|
|
23
|
+
if (body && typeof body === "object") {
|
|
24
|
+
for (const key of ["rows", "items", "issues", "pulls", "projects", "cycles", "results", "changes", "data"]) {
|
|
25
|
+
const v = body[key];
|
|
26
|
+
if (Array.isArray(v))
|
|
27
|
+
return v;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
function readTotal(headers) {
|
|
33
|
+
const raw = headers.get(TOTAL_HEADER);
|
|
34
|
+
if (raw === null)
|
|
35
|
+
return null;
|
|
36
|
+
const n = Number(raw);
|
|
37
|
+
return Number.isInteger(n) && n >= 0 ? n : null;
|
|
38
|
+
}
|
|
39
|
+
export async function fetchPage(api, path, query) {
|
|
40
|
+
const res = await api.getJson(path, { query });
|
|
41
|
+
const next = res.headers.get(NEXT_CURSOR_HEADER);
|
|
42
|
+
return {
|
|
43
|
+
rows: rowsOf(res.body),
|
|
44
|
+
nextCursor: next === null || next === "" ? null : next,
|
|
45
|
+
total: readTotal(res.headers),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
export async function fetchAllPages(api, path, query) {
|
|
49
|
+
const rows = [];
|
|
50
|
+
const seen = new Set();
|
|
51
|
+
let after;
|
|
52
|
+
let total = null;
|
|
53
|
+
for (let pages = 1; pages <= MAX_PAGES; pages++) {
|
|
54
|
+
const page = await fetchPage(api, path, { ...query, limit: SERVER_PAGE_CAP, after });
|
|
55
|
+
rows.push(...page.rows);
|
|
56
|
+
if (page.total !== null)
|
|
57
|
+
total = page.total;
|
|
58
|
+
if (page.nextCursor === null)
|
|
59
|
+
return { rows, total, pages };
|
|
60
|
+
// ⛔ A cursor that does not advance is an infinite loop, not a long read.
|
|
61
|
+
if (seen.has(page.nextCursor)) {
|
|
62
|
+
throw new CliError(`GET ${path} repeated a page cursor after ${rows.length} rows — the cloud is not advancing the keyset, so --all cannot finish`, "pagination-stalled", 1);
|
|
63
|
+
}
|
|
64
|
+
seen.add(page.nextCursor);
|
|
65
|
+
after = page.nextCursor;
|
|
66
|
+
}
|
|
67
|
+
throw new CliError(`GET ${path} still had pages after ${MAX_PAGES} of them (${rows.length} rows) — narrow the scope with --team/--project/--state, or read this through the replica`, "pagination-overrun", 1);
|
|
68
|
+
}
|
|
69
|
+
/** ⛔ N IS WHAT THE SERVER SENT, not what a client-side filter left. `--team ZZZ` over a capped page
|
|
70
|
+
* would otherwise read "truncated at 0 of 120", which names the filter, not the truncation. */
|
|
71
|
+
export function truncationNotice(page) {
|
|
72
|
+
if (page.nextCursor === null)
|
|
73
|
+
return null;
|
|
74
|
+
const of = page.total === null ? `an unknown total (the cloud sent no ${TOTAL_HEADER})` : String(page.total);
|
|
75
|
+
return `truncated at ${page.rows.length} of ${of} — re-run with --all to read the whole scope`;
|
|
76
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// published.ts — "what is the newest release of this package?", read from the npm registry. This is
|
|
2
|
+
// the ONLY non-tenant host this package contacts. `ready` has always been an offline command, so the
|
|
3
|
+
// answer is cached for six hours, the request is capped at 1500ms, and EVERY failure returns a
|
|
4
|
+
// reason instead of throwing: a customer who cannot reach npm must still get a verdict (CTC-2160).
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { dirname } from "node:path";
|
|
7
|
+
import { PACKAGE_NAME, publishedCachePathFor } from "./config.js";
|
|
8
|
+
export const REGISTRY_DIST_TAGS_URL = `https://registry.npmjs.org/-/package/${encodeURIComponent(PACKAGE_NAME)}/dist-tags`;
|
|
9
|
+
export const PUBLISHED_TTL_SECONDS = 6 * 60 * 60;
|
|
10
|
+
export const PUBLISHED_TIMEOUT_MS = 1500;
|
|
11
|
+
function readPublishedCache(home) {
|
|
12
|
+
const path = publishedCachePathFor(home);
|
|
13
|
+
if (!existsSync(path))
|
|
14
|
+
return null;
|
|
15
|
+
try {
|
|
16
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
17
|
+
if (typeof parsed.fetchedAt !== "string" || typeof parsed.latest !== "string")
|
|
18
|
+
return null;
|
|
19
|
+
return parsed;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** A write failure (read-only home) must not break the lookup — best-effort only. */
|
|
26
|
+
function writePublishedCache(home, cache) {
|
|
27
|
+
try {
|
|
28
|
+
const path = publishedCachePathFor(home);
|
|
29
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
30
|
+
const tmp = `${path}.tmp`;
|
|
31
|
+
writeFileSync(tmp, JSON.stringify(cache, null, 2) + "\n", { mode: 0o600 });
|
|
32
|
+
renameSync(tmp, path);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// best-effort: a stale or missing cache next time is not a new failure mode
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function fallback(cached, reason) {
|
|
39
|
+
return cached ? { latest: cached.latest, reason: null, source: "stale-cache" } : { latest: null, reason, source: "none" };
|
|
40
|
+
}
|
|
41
|
+
export async function latestPublishedVersion(ctx, opts = {}) {
|
|
42
|
+
const cached = readPublishedCache(ctx.home);
|
|
43
|
+
const ageSeconds = cached ? Math.max(0, Math.floor((ctx.now().getTime() - Date.parse(cached.fetchedAt)) / 1000)) : Infinity;
|
|
44
|
+
if (cached && ageSeconds < PUBLISHED_TTL_SECONDS) {
|
|
45
|
+
return { latest: cached.latest, reason: null, source: "cache" };
|
|
46
|
+
}
|
|
47
|
+
if (opts.offline) {
|
|
48
|
+
return cached ? { latest: cached.latest, reason: null, source: "cache" } : { latest: null, reason: "offline", source: "none" };
|
|
49
|
+
}
|
|
50
|
+
let res;
|
|
51
|
+
try {
|
|
52
|
+
res = await ctx.fetch(REGISTRY_DIST_TAGS_URL, {
|
|
53
|
+
signal: AbortSignal.timeout(PUBLISHED_TIMEOUT_MS),
|
|
54
|
+
headers: { accept: "application/json" },
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
const reason = err instanceof Error && err.name === "TimeoutError" ? "timed out" : err instanceof Error ? err.message : String(err);
|
|
59
|
+
return fallback(cached, reason);
|
|
60
|
+
}
|
|
61
|
+
if (!res.ok)
|
|
62
|
+
return fallback(cached, `registry answered ${res.status}`);
|
|
63
|
+
let body;
|
|
64
|
+
try {
|
|
65
|
+
body = await res.json();
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return fallback(cached, "the registry sent an unreadable body");
|
|
69
|
+
}
|
|
70
|
+
const latest = body?.latest;
|
|
71
|
+
if (typeof latest !== "string")
|
|
72
|
+
return fallback(cached, "the registry sent no dist-tags.latest");
|
|
73
|
+
writePublishedCache(ctx.home, { fetchedAt: ctx.now().toISOString(), latest });
|
|
74
|
+
return { latest, reason: null, source: "network" };
|
|
75
|
+
}
|
package/dist/query.js
CHANGED
|
@@ -2,13 +2,18 @@
|
|
|
2
2
|
// fresh, else the API, with the source named on stderr every time. Output shape is the read-model
|
|
3
3
|
// view either way. `pull`, `cycles`, `search` and `changes` are API-only (the SDK wraps no view for
|
|
4
4
|
// them and the read-model package is not a declared dependency of this bundle).
|
|
5
|
-
import { flagInt, flagString, positionals } from "./args.js";
|
|
5
|
+
import { flagBool, flagInt, flagString, positionals } from "./args.js";
|
|
6
6
|
import { requireConfig, replicaDbPath } from "./config.js";
|
|
7
7
|
import { CliError, UsageError } from "./errors.js";
|
|
8
8
|
import { apiClient } from "./transport.js";
|
|
9
|
+
import { fetchAllPages, fetchPage, rowsOf, truncationNotice } from "./pagination.js";
|
|
9
10
|
import { engineFor, replicaStatus } from "./replica.js";
|
|
10
11
|
import { loadSdk } from "./sdk.js";
|
|
12
|
+
export { rowsOf };
|
|
11
13
|
const REPLICA_CAPABLE = new Set(["issues", "issue", "pulls", "projects"]);
|
|
14
|
+
/** The two keyset routes `--all` can follow. `projects`/`cycles`/`search` are offset-paged or
|
|
15
|
+
* unpaginated and send no cursor header. */
|
|
16
|
+
const PAGINATED = new Set(["issues", "pulls"]);
|
|
12
17
|
export async function cmdQuery(args, ctx, deps = {}) {
|
|
13
18
|
const [sub, ...rest] = positionals(args);
|
|
14
19
|
if (!sub)
|
|
@@ -19,10 +24,20 @@ export async function cmdQuery(args, ctx, deps = {}) {
|
|
|
19
24
|
project: flagString(args, "project"),
|
|
20
25
|
state: flagString(args, "state"),
|
|
21
26
|
limit: flagInt(args, "limit", 50),
|
|
27
|
+
all: flagBool(args, "all"),
|
|
22
28
|
};
|
|
23
29
|
const forced = flagString(args, "source");
|
|
24
30
|
if (forced && forced !== "replica" && forced !== "api")
|
|
25
31
|
throw new UsageError("--source must be replica or api");
|
|
32
|
+
if (filters.all && !PAGINATED.has(sub)) {
|
|
33
|
+
throw new UsageError(`--all follows the cloud's keyset pages, which only ${[...PAGINATED].join(" and ")} have`);
|
|
34
|
+
}
|
|
35
|
+
if (filters.all && forced === "replica") {
|
|
36
|
+
throw new UsageError("--all follows the cloud's page cursor; --source replica reads a local mirror that has none. Pick one.");
|
|
37
|
+
}
|
|
38
|
+
if (filters.all && flagString(args, "limit") !== undefined) {
|
|
39
|
+
ctx.stderr("--all reads the whole scope, so --limit is ignored");
|
|
40
|
+
}
|
|
26
41
|
const status = replicaStatus(ctx, cfg);
|
|
27
42
|
let source;
|
|
28
43
|
let why;
|
|
@@ -34,6 +49,10 @@ export async function cmdQuery(args, ctx, deps = {}) {
|
|
|
34
49
|
source = forced;
|
|
35
50
|
why = `--source ${forced}`;
|
|
36
51
|
}
|
|
52
|
+
else if (filters.all) {
|
|
53
|
+
source = "api";
|
|
54
|
+
why = "--all follows the cloud's pages";
|
|
55
|
+
}
|
|
37
56
|
else if (status.verdict === "fresh") {
|
|
38
57
|
source = "replica";
|
|
39
58
|
why = `cursor ${status.cursor}`;
|
|
@@ -90,8 +109,16 @@ async function fromApi(ctx, cfg, sub, rest, f, args) {
|
|
|
90
109
|
const common = { team: f.team, project: f.project, state: f.state, limit: f.limit };
|
|
91
110
|
switch (sub) {
|
|
92
111
|
case "issues": {
|
|
93
|
-
|
|
94
|
-
|
|
112
|
+
if (f.all) {
|
|
113
|
+
const { rows } = await fetchAllPages(api, "/api/v1/issues", { team: f.team, project: f.project, state: f.state });
|
|
114
|
+
return applyFilters(rows, f); // no .slice — --all means the whole scope
|
|
115
|
+
}
|
|
116
|
+
const page = await fetchPage(api, "/api/v1/issues", common);
|
|
117
|
+
const rows = applyFilters(page.rows, f).slice(0, f.limit);
|
|
118
|
+
const notice = truncationNotice(page);
|
|
119
|
+
if (notice)
|
|
120
|
+
ctx.stderr(notice);
|
|
121
|
+
return rows;
|
|
95
122
|
}
|
|
96
123
|
case "issue": {
|
|
97
124
|
const id = needArg(rest, "issue <identifier>");
|
|
@@ -99,8 +126,17 @@ async function fromApi(ctx, cfg, sub, rest, f, args) {
|
|
|
99
126
|
return res.status === 404 ? null : res.body;
|
|
100
127
|
}
|
|
101
128
|
case "pulls": {
|
|
102
|
-
const
|
|
103
|
-
|
|
129
|
+
const ticket = flagString(args, "ticket");
|
|
130
|
+
if (f.all) {
|
|
131
|
+
const { rows } = await fetchAllPages(api, "/api/v1/pulls", { ticket });
|
|
132
|
+
return rows;
|
|
133
|
+
}
|
|
134
|
+
const page = await fetchPage(api, "/api/v1/pulls", { limit: f.limit, ticket });
|
|
135
|
+
const rows = page.rows.slice(0, f.limit);
|
|
136
|
+
const notice = truncationNotice(page);
|
|
137
|
+
if (notice)
|
|
138
|
+
ctx.stderr(notice);
|
|
139
|
+
return rows;
|
|
104
140
|
}
|
|
105
141
|
case "pull": {
|
|
106
142
|
const id = needArg(rest, "pull <node id>");
|
|
@@ -177,19 +213,6 @@ function needArg(rest, usage) {
|
|
|
177
213
|
throw new UsageError(`query ${usage}`);
|
|
178
214
|
return v;
|
|
179
215
|
}
|
|
180
|
-
/** The API answers a list route with either a bare array or `{rows|items|issues|...: []}`. */
|
|
181
|
-
export function rowsOf(body) {
|
|
182
|
-
if (Array.isArray(body))
|
|
183
|
-
return body;
|
|
184
|
-
if (body && typeof body === "object") {
|
|
185
|
-
for (const key of ["rows", "items", "issues", "pulls", "projects", "cycles", "results", "changes", "data"]) {
|
|
186
|
-
const v = body[key];
|
|
187
|
-
if (Array.isArray(v))
|
|
188
|
-
return v;
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
return [];
|
|
192
|
-
}
|
|
193
216
|
export function applyFilters(rows, f) {
|
|
194
217
|
return rows.filter((r) => {
|
|
195
218
|
if (f.team && !(String(r.identifier ?? "").toUpperCase().startsWith(`${f.team.toUpperCase()}-`) || r.team_id === f.team || r.team_key === f.team))
|