@catalyst-cloud/cli 0.8.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 +75 -0
- package/LICENSE +21 -0
- package/README.md +205 -0
- package/bin/catalyst-skills.js +8 -0
- package/bin/catalyst.js +5 -0
- package/bin/launch.js +154 -0
- package/dist/args.js +280 -0
- package/dist/ask.js +161 -0
- package/dist/browser.js +20 -0
- package/dist/cli.js +397 -0
- package/dist/config.js +241 -0
- package/dist/contract-types.js +4 -0
- package/dist/contract.js +184 -0
- package/dist/detach.js +10 -0
- package/dist/environment.js +207 -0
- package/dist/errors.js +27 -0
- package/dist/events.js +106 -0
- package/dist/execution.js +451 -0
- package/dist/oauth.js +300 -0
- package/dist/pagination.js +76 -0
- package/dist/prompt.js +35 -0
- package/dist/published.js +79 -0
- package/dist/query.js +248 -0
- package/dist/ready.js +380 -0
- package/dist/release.js +142 -0
- package/dist/replica.js +614 -0
- package/dist/runtime-store.js +135 -0
- package/dist/runtime-verb.js +66 -0
- package/dist/runtime.js +87 -0
- package/dist/sdk.js +29 -0
- package/dist/secret.js +190 -0
- package/dist/semver.js +18 -0
- package/dist/skill-shape.js +189 -0
- package/dist/skills.js +129 -0
- package/dist/transport.js +205 -0
- package/dist/ts-deps-loader.js +113 -0
- package/dist/watch/consumer.js +141 -0
- package/dist/watch/cursor-file.js +62 -0
- package/dist/watch.js +175 -0
- package/dist/write.js +224 -0
- package/package.json +60 -0
- package/skills/catalyst-github/SKILL.md +35 -0
- package/skills/catalyst-github/agents/openai.yaml +6 -0
- package/skills/catalyst-github/agents/portability.yaml +4 -0
- package/skills/catalyst-github/references/is-it-mergeable.md +57 -0
- package/skills/catalyst-github/references/what-a-pr-accumulates.md +61 -0
- package/skills/catalyst-github/scripts/is-it-mergeable.mjs +124 -0
- package/skills/catalyst-github/scripts/lib/cli.mjs +103 -0
- package/skills/catalyst-github/scripts/lib/credential.mjs +29 -0
- package/skills/catalyst-github/scripts/lib/pull.mjs +82 -0
- package/skills/catalyst-github/scripts/read-pr.mjs +97 -0
- package/skills/catalyst-linear/SKILL.md +43 -0
- package/skills/catalyst-linear/agents/openai.yaml +6 -0
- package/skills/catalyst-linear/agents/portability.yaml +5 -0
- package/skills/catalyst-linear/references/reading-a-ticket.md +52 -0
- package/skills/catalyst-linear/references/what-a-ticket-accumulates.md +53 -0
- package/skills/catalyst-linear/references/writing-to-linear.md +43 -0
- package/skills/catalyst-linear/scripts/comment.mjs +59 -0
- package/skills/catalyst-linear/scripts/create-ticket.mjs +44 -0
- package/skills/catalyst-linear/scripts/label.mjs +48 -0
- package/skills/catalyst-linear/scripts/lib/cli.mjs +164 -0
- package/skills/catalyst-linear/scripts/lib/credential.mjs +29 -0
- package/skills/catalyst-linear/scripts/move.mjs +41 -0
- package/skills/catalyst-linear/scripts/read-ticket.mjs +93 -0
- package/skills/catalyst-linear/scripts/search.mjs +49 -0
- package/skills/catalyst-onboard/SKILL.md +57 -0
- package/skills/catalyst-onboard/agents/openai.yaml +6 -0
- package/skills/catalyst-onboard/agents/portability.yaml +5 -0
- package/skills/catalyst-onboard/references/declaring-a-repository.md +23 -0
- package/skills/catalyst-onboard/references/skill-sources.md +35 -0
- package/skills/catalyst-onboard/references/the-one-path.md +149 -0
- package/skills/catalyst-onboard/references/what-a-phase-needs.md +46 -0
- package/skills/catalyst-onboard/references/what-the-browser-owns.md +50 -0
- package/skills/catalyst-onboard/references/who-fixes-what.md +44 -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 +345 -0
- package/skills/catalyst-setup/SKILL.md +36 -0
- package/skills/catalyst-setup/agents/openai.yaml +6 -0
- package/skills/catalyst-setup/agents/portability.yaml +4 -0
- package/skills/catalyst-setup/references/what-each-check-means.md +88 -0
- package/skills/catalyst-setup/scripts/check.mjs +75 -0
- package/skills/catalyst-setup/scripts/lib/cli.mjs +103 -0
- package/skills/catalyst-setup/scripts/lib/credential.mjs +29 -0
- package/skills/catalyst-setup/scripts/replica-status.mjs +46 -0
- package/skills/connect-me/SKILL.md +63 -0
- package/skills/connect-me/agents/openai.yaml +6 -0
- package/skills/connect-me/agents/portability.yaml +5 -0
- package/skills/connect-me/references/keeping-the-replica-running.md +88 -0
- package/skills/connect-me/scripts/lib/cli.mjs +185 -0
- package/skills/connect-me/scripts/lib/credential.mjs +29 -0
- package/skills/connect-me/scripts/verify-connection.mjs +68 -0
- package/skills/how-catalyst-works/SKILL.md +43 -0
- package/skills/how-catalyst-works/agents/openai.yaml +6 -0
- package/skills/how-catalyst-works/agents/portability.yaml +4 -0
- package/skills/how-catalyst-works/references/coding-accounts.md +51 -0
- package/skills/how-catalyst-works/references/stages-and-mapping.md +56 -0
- package/skills/how-catalyst-works/references/the-ladder.md +41 -0
- package/skills/how-catalyst-works/references/what-catalyst-is.md +30 -0
- package/skills/how-catalyst-works/references/what-runs-next.md +77 -0
- package/skills/how-catalyst-works/references/when-a-phase-fails.md +57 -0
- package/skills/how-catalyst-works/scripts/explain-ticket.mjs +41 -0
- package/skills/how-catalyst-works/scripts/lib/cli.mjs +164 -0
- package/skills/how-catalyst-works/scripts/lib/credential.mjs +29 -0
- package/skills/how-catalyst-works/scripts/show-my-map.mjs +94 -0
- package/skills/how-catalyst-works/scripts/whats-running.mjs +65 -0
- package/skills/run-this-project/SKILL.md +45 -0
- package/skills/run-this-project/agents/openai.yaml +6 -0
- package/skills/run-this-project/agents/portability.yaml +5 -0
- package/skills/run-this-project/assets/stall-policy.json +15 -0
- package/skills/run-this-project/references/making-work-ready.md +60 -0
- package/skills/run-this-project/references/reacting-to-events.md +76 -0
- package/skills/run-this-project/references/stalls-and-escalation.md +63 -0
- package/skills/run-this-project/scripts/lib/cli.mjs +185 -0
- package/skills/run-this-project/scripts/lib/credential.mjs +29 -0
- package/skills/run-this-project/scripts/make-ready.mjs +64 -0
- package/skills/run-this-project/scripts/scope-status.mjs +0 -0
- package/skills/run-this-project/scripts/watch-scope.mjs +61 -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 +41 -0
- package/skills/what-needs-me/agents/openai.yaml +6 -0
- package/skills/what-needs-me/agents/portability.yaml +5 -0
- package/skills/what-needs-me/references/raising-a-decision.md +41 -0
- package/skills/what-needs-me/references/reading-the-inbox.md +38 -0
- package/skills/what-needs-me/references/settling-an-answer.md +37 -0
- package/skills/what-needs-me/scripts/inbox.mjs +56 -0
- package/skills/what-needs-me/scripts/lib/cli.mjs +135 -0
- package/skills/what-needs-me/scripts/lib/credential.mjs +29 -0
- package/skills/what-needs-me/scripts/raise.mjs +53 -0
- package/skills/what-needs-me/scripts/settle.mjs +73 -0
- package/skills/whats-happening/SKILL.md +43 -0
- package/skills/whats-happening/agents/openai.yaml +6 -0
- package/skills/whats-happening/agents/portability.yaml +4 -0
- package/skills/whats-happening/assets/status-reply.json +77 -0
- package/skills/whats-happening/references/reading-the-board.md +43 -0
- package/skills/whats-happening/references/reprioritising.md +37 -0
- package/skills/whats-happening/references/routing-work.md +36 -0
- package/skills/whats-happening/references/status-reply.md +34 -0
- package/skills/whats-happening/references/why-is-it-stuck.md +62 -0
- package/skills/whats-happening/scripts/explain.mjs +28 -0
- package/skills/whats-happening/scripts/lib/cli.mjs +135 -0
- package/skills/whats-happening/scripts/lib/credential.mjs +29 -0
- package/skills/whats-happening/scripts/snapshot.mjs +149 -0
- package/vendor/README.md +9 -0
- package/vendor/paths/index.d.ts +85 -0
- package/vendor/paths/index.js +148 -0
- package/vendor/paths/legacy-installer.d.ts +36 -0
- package/vendor/paths/legacy-installer.js +154 -0
- package/vendor/paths/node.d.ts +18 -0
- package/vendor/paths/node.js +102 -0
- package/vendor/paths/provenance.json +17 -0
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
// execution.ts — what Catalyst is doing: `explain <ticket>`, `running`, `queue`, `accounts`.
|
|
2
|
+
// `explain` turns one work-eligibility row into a paragraph a human can act on, translating every
|
|
3
|
+
// exclusion reason and unknown the cloud names; an unlisted reason prints its raw string, never nothing.
|
|
4
|
+
import { flagBool, flagString, positionals } from "./args.js";
|
|
5
|
+
import { normalizeBaseUrl, requireConfig } from "./config.js";
|
|
6
|
+
import { findTeamByKey, loadContract } from "./contract.js";
|
|
7
|
+
import { CliError, MeError, UsageError } from "./errors.js";
|
|
8
|
+
import { apiClient } from "./transport.js";
|
|
9
|
+
/** Every exclusion reason the eligibility evaluator names, in plain English. */
|
|
10
|
+
export const EXCLUSION_REASONS = {
|
|
11
|
+
ticket_terminal: "the ticket is in a done or canceled state, so there is nothing left to run",
|
|
12
|
+
pipeline_complete: "every relay phase has already completed",
|
|
13
|
+
not_at_dispatch_stage: "the card is not in the team's dispatch column, so nothing is offered until it is moved there",
|
|
14
|
+
not_at_pr_stage: "merge is next but the card is not in the PR column",
|
|
15
|
+
blocked: "a live blocking relation holds it — the blocker has to close first",
|
|
16
|
+
cooling_down: "the offered phase is parked; a callback or an operator releases it, not a clock",
|
|
17
|
+
lease_held: "a live container already holds the lease for the offered phase",
|
|
18
|
+
intake_lease_held: "a later phase is offered while an intake container still holds this ticket",
|
|
19
|
+
ask_ticket: "it carries an ask label, and a question is never work",
|
|
20
|
+
ask_shape_suspected: "its own text reads as a decision request; a human releases it with the not-an-ask label",
|
|
21
|
+
externally_claimed: "a worker outside the cloud has claimed it",
|
|
22
|
+
environment_check_required: "the repository's environment check has not been run",
|
|
23
|
+
environment_check_running: "the repository's environment check is in flight",
|
|
24
|
+
environment_check_failed: "the repository's environment check failed",
|
|
25
|
+
environment_check_expired: "the repository's environment check verdict aged out",
|
|
26
|
+
environment_check_hash_mismatch: "the environment changed since its recorded verdict",
|
|
27
|
+
scope_overlap: "its declared file scope intersects a ticket already in flight",
|
|
28
|
+
waiting_on: "a merge-gate failure with no remediable cause holds the card at PR",
|
|
29
|
+
branch_missing: "the ticket branch has never been seen, so a branch-dependent phase has nothing to clone",
|
|
30
|
+
branch_gone: "the ticket branch was deleted after it existed",
|
|
31
|
+
pr_merged: "its PR has merged and no other PR is open — nothing is left to clone or re-merge",
|
|
32
|
+
no_change_hold: "a remediate round changed nothing; a human comment or a new push releases it",
|
|
33
|
+
validate_class_spent: "this validate failure already spent its one repair round in this episode",
|
|
34
|
+
stale_failure_episode: "the ladder advanced after the recorded failure, so the round would repair a phase already passed",
|
|
35
|
+
runner_image_breaker: "fleet-wide: the live runner image is failing every phase at startup; dispatch resumes when the pin moves",
|
|
36
|
+
no_branch_to_remediate: "a remediate round is queued on a ticket with no recorded branch",
|
|
37
|
+
retry_backoff: "the failed phase is retrying in place and waiting out its backoff",
|
|
38
|
+
routing_unavailable: "the unit was claimed and refused at kickoff (no route, no eligible coding-account slot, or the provider is unavailable)",
|
|
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; `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",
|
|
48
|
+
};
|
|
49
|
+
/** Every fail-closed unknown the evaluator names. */
|
|
50
|
+
export const UNKNOWN_REASONS = {
|
|
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",
|
|
52
|
+
ordering_stale: "the dispatch order is stale",
|
|
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",
|
|
54
|
+
ticket_unknown: "the ticket is not in the mirror",
|
|
55
|
+
dependency_snapshot_unknown: "the dependency snapshot has never completed",
|
|
56
|
+
blocker_unknown: "a blocking relation could not be resolved",
|
|
57
|
+
label_snapshot_unknown: "the labels poll has never completed, so ask labels cannot be read",
|
|
58
|
+
prior_artifact_unknown: "a prior phase artifact could not be read",
|
|
59
|
+
scope_unknown: "the ticket declared no scope, or the declaration was truncated",
|
|
60
|
+
scope_occupancy_unknown: "an in-flight ticket's state could not be resolved",
|
|
61
|
+
branch_snapshot_unknown: "the pull-request poll has never completed, so branch evidence cannot be read",
|
|
62
|
+
};
|
|
63
|
+
export const ADVISORIES = {
|
|
64
|
+
human_addressed_unlabeled_ask_suspect: "assigned to a human with no delegate: this may be an unlabelled ask",
|
|
65
|
+
};
|
|
66
|
+
export function describeReason(reason) {
|
|
67
|
+
if (!reason)
|
|
68
|
+
return "no reason was given";
|
|
69
|
+
return EXCLUSION_REASONS[reason] ?? UNKNOWN_REASONS[reason] ?? `reason "${reason}" (not in this bundle's table; read it as the cloud spelled it)`;
|
|
70
|
+
}
|
|
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);
|
|
106
|
+
if (!row) {
|
|
107
|
+
// A null dispatch row is not proof the ticket does not exist — the explainer is a dispatch scan. When
|
|
108
|
+
// `knownState` is set (the mirror answered GET /issues/:id) the ticket is real; it just sits outside a
|
|
109
|
+
// dispatch column. Only a 404 (knownState null/absent) is truly unknown to the mirror.
|
|
110
|
+
if (knownState)
|
|
111
|
+
return `${ticket}: known to the mirror; state ${knownState} is not a dispatch state.`;
|
|
112
|
+
return `${ticket}: not in the ${team} eligibility explainer — the ticket is unknown to the mirror, terminal, or on another team.`;
|
|
113
|
+
}
|
|
114
|
+
const parts = [];
|
|
115
|
+
const pos = typeof row.position === "number" ? `position ${row.position}` : "no queue position";
|
|
116
|
+
const status = row.status ?? "unknown";
|
|
117
|
+
if (status === "offered" || status === "eligible") {
|
|
118
|
+
parts.push(`${ticket} is ${status} (${pos})${row.phase ? ` for phase ${row.phase}` : ""}.`);
|
|
119
|
+
}
|
|
120
|
+
else if (status === "unknown") {
|
|
121
|
+
parts.push(`${ticket} cannot be judged (${pos}): ${describeReason(row.unknown ?? row.reason)}.`);
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
parts.push(`${ticket} is ${status} (${pos}): ${describeReason(row.reason)}.`);
|
|
125
|
+
}
|
|
126
|
+
if (row.detail)
|
|
127
|
+
parts.push(`Detail: ${row.detail}.`);
|
|
128
|
+
if (row.marker)
|
|
129
|
+
parts.push(`Marker: ${row.marker}.`);
|
|
130
|
+
if (row.nextPhase)
|
|
131
|
+
parts.push(`Next phase would be ${row.nextPhase}.`);
|
|
132
|
+
if (row.release)
|
|
133
|
+
parts.push(`Release: ${row.release}.`);
|
|
134
|
+
if (row.failure && typeof row.failure === "object") {
|
|
135
|
+
const f = row.failure;
|
|
136
|
+
const bits = ["phase", "class", "attempts", "consecutive", "lastFailedAt", "message", "summary"]
|
|
137
|
+
.filter((k) => f[k] !== undefined && f[k] !== null)
|
|
138
|
+
.map((k) => `${k}=${typeof f[k] === "string" ? f[k] : JSON.stringify(f[k])}`);
|
|
139
|
+
parts.push(`Last failure: ${bits.length ? bits.join(", ") : JSON.stringify(f)}.`);
|
|
140
|
+
}
|
|
141
|
+
if (row.advisories && row.advisories.length > 0) {
|
|
142
|
+
parts.push(`Advisories: ${row.advisories.map((a) => ADVISORIES[a] ?? a).join("; ")}.`);
|
|
143
|
+
}
|
|
144
|
+
return parts.join(" ");
|
|
145
|
+
}
|
|
146
|
+
export async function cmdExplain(args, ctx) {
|
|
147
|
+
const [ticket] = positionals(args);
|
|
148
|
+
if (!ticket)
|
|
149
|
+
throw new UsageError("explain needs a ticket: explain <ticket>");
|
|
150
|
+
const cfg = requireConfig(ctx);
|
|
151
|
+
// `explain --history` is the same read as `history <ticket>`; both go to CTC-1954's route.
|
|
152
|
+
if (flagBool(args, "history"))
|
|
153
|
+
return await cmdHistory(args, ctx, ticket);
|
|
154
|
+
const { doc } = await loadContract(ctx, cfg);
|
|
155
|
+
const dash = ticket.indexOf("-");
|
|
156
|
+
if (dash <= 0)
|
|
157
|
+
throw new UsageError(`"${ticket}" is not a ticket identifier (expected KEY-123)`);
|
|
158
|
+
const team = ticket.slice(0, dash).toUpperCase();
|
|
159
|
+
const api = apiClient(cfg, ctx);
|
|
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;
|
|
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.
|
|
192
|
+
if (!row) {
|
|
193
|
+
const probe = await api.getJson(`/api/v1/issues/${encodeURIComponent(ticket.toUpperCase())}`, { accept: [404] });
|
|
194
|
+
if (probe.status !== 404)
|
|
195
|
+
knownState = typeof probe.body?.state === "string" ? probe.body.state : "unknown";
|
|
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(" ");
|
|
232
|
+
if (args.json) {
|
|
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
|
+
}));
|
|
241
|
+
}
|
|
242
|
+
else {
|
|
243
|
+
ctx.stdout(explanation);
|
|
244
|
+
}
|
|
245
|
+
return 0;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* `running` — the fleet-wide "what is Catalyst doing right now?".
|
|
249
|
+
*
|
|
250
|
+
* ⛔ `/api/v1/lease/attributions` IS NOT A FLEET-WIDE ROUTE. It answers "who wrote this transition,
|
|
251
|
+
* holding which lease?" for ONE (ticket, phase) pair and 400s `invalid_field` without both — 0.2.0
|
|
252
|
+
* called it bare on every `running`, so the headline verb (and `whats-running.mjs`, and
|
|
253
|
+
* `snapshot.mjs`) failed for every tenant. The tenant-wide question is answered by
|
|
254
|
+
* `/fleet-activity/current` (what is executing) and `/agent-roster/current` (who is coordinating),
|
|
255
|
+
* neither of which takes coordinates. Lease attributions stay available, behind the coordinates the
|
|
256
|
+
* route requires.
|
|
257
|
+
*/
|
|
258
|
+
export async function cmdRunning(args, ctx) {
|
|
259
|
+
const cfg = requireConfig(ctx);
|
|
260
|
+
const ticket = flagString(args, "ticket");
|
|
261
|
+
const phase = flagString(args, "phase");
|
|
262
|
+
if ((ticket === undefined) !== (phase === undefined)) {
|
|
263
|
+
throw new UsageError("running: --ticket and --phase go together — lease attributions are recorded per (ticket, phase), and the route refuses either one alone");
|
|
264
|
+
}
|
|
265
|
+
const api = apiClient(cfg, ctx);
|
|
266
|
+
const [activity, roster] = await Promise.all([
|
|
267
|
+
api.getJson("/api/v1/fleet-activity/current"),
|
|
268
|
+
api.getJson("/api/v1/agent-roster/current"),
|
|
269
|
+
]);
|
|
270
|
+
const out = { fleetActivity: activity.body, agentRoster: roster.body };
|
|
271
|
+
if (ticket !== undefined && phase !== undefined) {
|
|
272
|
+
const leases = await api.getJson("/api/v1/lease/attributions", { query: { ticket, phase } });
|
|
273
|
+
out.leaseAttributions = leases.body;
|
|
274
|
+
}
|
|
275
|
+
if (args.json) {
|
|
276
|
+
ctx.stdout(JSON.stringify(out));
|
|
277
|
+
return 0;
|
|
278
|
+
}
|
|
279
|
+
ctx.stdout(`fleet activity: ${JSON.stringify(out.fleetActivity)}`);
|
|
280
|
+
ctx.stdout(`agent roster: ${JSON.stringify(out.agentRoster)}`);
|
|
281
|
+
if (out.leaseAttributions !== undefined) {
|
|
282
|
+
ctx.stdout(`lease attributions (${ticket} ${phase}): ${JSON.stringify(out.leaseAttributions)}`);
|
|
283
|
+
}
|
|
284
|
+
return 0;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* `queue` — the dispatch order. The route is per-team and REQUIRES `?team=` ("bad team", 400), but
|
|
288
|
+
* a customer asking "what is next?" rarely means one team, so an omitted `--team` reads every team
|
|
289
|
+
* the tenant contract names rather than sending a call the route will refuse.
|
|
290
|
+
*/
|
|
291
|
+
export async function cmdQueue(args, ctx) {
|
|
292
|
+
const cfg = requireConfig(ctx);
|
|
293
|
+
const api = apiClient(cfg, ctx);
|
|
294
|
+
const named = flagString(args, "team");
|
|
295
|
+
let teams;
|
|
296
|
+
if (named !== undefined) {
|
|
297
|
+
teams = [named];
|
|
298
|
+
}
|
|
299
|
+
else {
|
|
300
|
+
const { doc } = await loadContract(ctx, cfg);
|
|
301
|
+
teams = doc.teams.map((t) => t.key).filter((k) => typeof k === "string" && k !== "");
|
|
302
|
+
if (teams.length === 0) {
|
|
303
|
+
throw new UsageError("queue needs --team: the tenant contract names no team to read a queue for");
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
const bodies = await Promise.all(teams.map(async (team) => [team, (await api.getJson("/api/v1/dispatch-queue/current", { query: { team } })).body]));
|
|
307
|
+
// One team asked for → its envelope, unwrapped, exactly as before. Several → keyed by team, so a
|
|
308
|
+
// caller can tell whose queue a row belongs to.
|
|
309
|
+
const out = bodies.length === 1 && named !== undefined ? bodies[0][1] : Object.fromEntries(bodies);
|
|
310
|
+
ctx.stdout(args.json ? JSON.stringify(out) : JSON.stringify(out, null, 2));
|
|
311
|
+
return 0;
|
|
312
|
+
}
|
|
313
|
+
/** The five status words `/api/v1/coding-accounts` reports, in the words a customer reads. */
|
|
314
|
+
export const ACCOUNT_STATUS = {
|
|
315
|
+
"expired-or-revoked": "expired or revoked — re-enrol it before it can take work",
|
|
316
|
+
walled: "walled — the provider's usage limit is spent for now",
|
|
317
|
+
active: "active — observed working",
|
|
318
|
+
attested: "attested — healthy at last check, no work observed since",
|
|
319
|
+
unobserved: "unobserved — enrolled, but nothing has been seen from it yet",
|
|
320
|
+
};
|
|
321
|
+
/** One slot as a line a human reads: who it is, what state it is in, and what it is spending on. */
|
|
322
|
+
export function renderAccount(a) {
|
|
323
|
+
const name = a.label ? `${a.accountSlot ?? "?"} (${a.label})` : (a.accountSlot ?? "?");
|
|
324
|
+
const harness = a.harness ? `/${a.harness}` : "";
|
|
325
|
+
const status = a.status ? (ACCOUNT_STATUS[a.status] ?? a.status) : "status unknown";
|
|
326
|
+
const bits = [`${name} ${a.provider ?? "?"}${harness} ${status}`];
|
|
327
|
+
if (typeof a.bindingUsedPercent === "number") {
|
|
328
|
+
const resets = typeof a.bindingResetsAtMs === "number" ? `, resets ${new Date(a.bindingResetsAtMs).toISOString()}` : "";
|
|
329
|
+
bits.push(`usage ${a.bindingUsedPercent}% of the ${a.bindingWindow ?? "binding"} window${resets}`);
|
|
330
|
+
}
|
|
331
|
+
if (a.quarantined)
|
|
332
|
+
bits.push(`quarantined${a.quarantineReason ? `: ${a.quarantineReason}` : ""}`);
|
|
333
|
+
const holds = a.liveHolds ?? [];
|
|
334
|
+
if (holds.length > 0)
|
|
335
|
+
bits.push(`holding ${holds.map((h) => `${h.ticket}/${h.phase}`).join(", ")}`);
|
|
336
|
+
else if (a.liveHoldsCount)
|
|
337
|
+
bits.push(`${a.liveHoldsCount} live hold(s)`);
|
|
338
|
+
return bits.join(" · ");
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* ⛔ A ROUTE THIS TENANT'S CLOUD DOES NOT SERVE IS SAID OUT LOUD, never rendered as an empty success.
|
|
342
|
+
* `GET /api/v1/coding-accounts` (CTC-1953) and `GET /api/v1/issues/:id/execution` (CTC-1954) ship
|
|
343
|
+
* ahead of some tenants' deployed mirror; a 404 there means "your cloud is older than this bundle",
|
|
344
|
+
* which is a different fact from "you have no coding accounts" and must never print as the latter.
|
|
345
|
+
*/
|
|
346
|
+
export function needsNewerCloud(what, cfg) {
|
|
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);
|
|
348
|
+
}
|
|
349
|
+
export async function cmdAccounts(args, ctx) {
|
|
350
|
+
const cfg = requireConfig(ctx);
|
|
351
|
+
const api = apiClient(cfg, ctx);
|
|
352
|
+
const res = await api.getJson("/api/v1/coding-accounts", {
|
|
353
|
+
accept: [404],
|
|
354
|
+
});
|
|
355
|
+
if (res.status === 404)
|
|
356
|
+
throw needsNewerCloud("coding-account status", cfg);
|
|
357
|
+
const accounts = Array.isArray(res.body) ? res.body : (res.body?.accounts ?? []);
|
|
358
|
+
if (args.json) {
|
|
359
|
+
ctx.stdout(JSON.stringify(res.body));
|
|
360
|
+
return 0;
|
|
361
|
+
}
|
|
362
|
+
if (accounts.length === 0) {
|
|
363
|
+
ctx.stdout(`No coding accounts are enrolled on this tenant — enrol one at ${normalizeBaseUrl(cfg.baseUrl)}/settings/coding-accounts`);
|
|
364
|
+
return 0;
|
|
365
|
+
}
|
|
366
|
+
for (const a of accounts)
|
|
367
|
+
ctx.stdout(renderAccount(a));
|
|
368
|
+
return 0;
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* `history <ticket>` (and `explain --history`) — the ticket's execution history: per-phase attempts
|
|
372
|
+
* and outcomes, remediate rounds against the cap, the park sentinel and what releases it, the live
|
|
373
|
+
* lease, the last advance. Reads `GET /api/v1/issues/:identifier/execution` (CTC-1954).
|
|
374
|
+
*/
|
|
375
|
+
export async function cmdHistory(args, ctx, ticketArg) {
|
|
376
|
+
const ticket = ticketArg ?? positionals(args)[0];
|
|
377
|
+
if (!ticket)
|
|
378
|
+
throw new UsageError("history needs a ticket: history <ticket>");
|
|
379
|
+
const cfg = requireConfig(ctx);
|
|
380
|
+
const api = apiClient(cfg, ctx);
|
|
381
|
+
const res = await api.getJson(`/api/v1/issues/${encodeURIComponent(ticket)}/execution`, {
|
|
382
|
+
accept: [404],
|
|
383
|
+
});
|
|
384
|
+
if (res.status === 404)
|
|
385
|
+
throw needsNewerCloud(`execution history for ${ticket}`, cfg);
|
|
386
|
+
if (args.json) {
|
|
387
|
+
ctx.stdout(JSON.stringify(res.body));
|
|
388
|
+
return 0;
|
|
389
|
+
}
|
|
390
|
+
for (const line of renderHistory(ticket, res.body))
|
|
391
|
+
ctx.stdout(line);
|
|
392
|
+
return 0;
|
|
393
|
+
}
|
|
394
|
+
/** ⛔ `null` is UNREADABLE, never "nothing happened" — the report's own contract, kept in the prose. */
|
|
395
|
+
export function renderHistory(ticket, doc) {
|
|
396
|
+
const lines = [];
|
|
397
|
+
if (doc.hasLadderHistory === false) {
|
|
398
|
+
lines.push(`${ticket}: no ladder history recorded${doc.note ? ` — ${doc.note}` : ""}.`);
|
|
399
|
+
}
|
|
400
|
+
const phases = doc.phases;
|
|
401
|
+
if (phases === null || phases === undefined) {
|
|
402
|
+
lines.push(`${ticket}: the per-phase table could not be read (unreadable, not empty).`);
|
|
403
|
+
}
|
|
404
|
+
else {
|
|
405
|
+
lines.push(`${ticket} phases (${doc.attemptHistory ?? "latest-per-phase"}):`);
|
|
406
|
+
for (const p of phases) {
|
|
407
|
+
const fail = p.lastFailureClass ? `, last failure ${p.lastFailureClass}${p.consecutiveFailures ? ` ×${p.consecutiveFailures}` : ""}` : "";
|
|
408
|
+
lines.push(` ${p.phase}: ${p.status ?? "?"} (attempt ${p.attempt ?? "?"})${fail}`);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
if (doc.failure) {
|
|
412
|
+
const f = doc.failure;
|
|
413
|
+
lines.push(`Last failure: ${f.phase} — ${f.failureMode}${f.failureDetail ? ` (${f.failureDetail})` : ""}${f.summary ? `: ${f.summary}` : ""}`);
|
|
414
|
+
}
|
|
415
|
+
if (doc.remediate && typeof doc.remediate.roundsDispatched === "number") {
|
|
416
|
+
lines.push(`Remediate rounds dispatched: ${doc.remediate.roundsDispatched}${typeof doc.remediate.cap === "number" ? ` (cap ${doc.remediate.cap})` : ""}`);
|
|
417
|
+
}
|
|
418
|
+
if (doc.park) {
|
|
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
|
+
}
|
|
442
|
+
}
|
|
443
|
+
for (const l of doc.lease ?? [])
|
|
444
|
+
lines.push(`Live lease: ${l.phase} held by ${l.holder ?? "?"} until ${new Date(l.deadlineMs).toISOString()}`);
|
|
445
|
+
if (doc.lastAdvance) {
|
|
446
|
+
lines.push(`Last advance: ${doc.lastAdvance.phase} → ${doc.lastAdvance.toSlot ?? "?"} (${doc.lastAdvance.landed ? "landed" : "not landed"})`);
|
|
447
|
+
}
|
|
448
|
+
for (const u of doc.unreadable ?? [])
|
|
449
|
+
lines.push(`Unreadable: ${u.table} (${u.error}) — absent from this report, not absent from the ticket.`);
|
|
450
|
+
return lines;
|
|
451
|
+
}
|