@catalyst-cloud/cli 0.16.39 → 0.16.40
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 +8 -0
- package/README.md +26 -0
- package/dist/agent-repoint.js +1 -6
- package/dist/args.js +9 -2
- package/dist/capabilities.js +1 -0
- package/dist/cli.js +5 -0
- package/dist/flow.js +104 -0
- package/dist/runner-auto-update.js +50 -14
- package/dist/runner-command.js +35 -6
- package/dist/runner-update-fleet.js +249 -4
- package/dist/runner-update.js +1 -1
- package/package.json +1 -1
- package/skills/catalyst-github/SKILL.md +1 -1
- package/skills/catalyst-linear/SKILL.md +1 -1
- package/skills/catalyst-onboard/SKILL.md +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 +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.16.40
|
|
4
|
+
|
|
5
|
+
`catalyst flow --repo <owner/name> --period 2w --granularity day` prints a repository's open pull request counts by the day, or by the hour with `--granularity hour`. `--json` prints the same buckets the SDK's `flow.series()` returns.
|
|
6
|
+
|
|
7
|
+
A supervisor fleet can now update itself on a schedule: `catalyst runner auto-update on --fleet --dir <fleet directory>` installs a timer that runs `catalyst runner update --fleet` for that directory, and `catalyst runner auto-update status` shows that it is a fleet schedule and which directory it updates.
|
|
8
|
+
|
|
9
|
+
After `catalyst runner update --fleet` applies a release, it checks the fleet again 10 minutes later. If the supervisor is not running, or the release was granted no work while three requests for work got no response, it restores the previous release and does not apply that release again until a newer one is approved. An idle fleet with nothing to do keeps the new release. `catalyst runner update --check --fleet` names the last check's result.
|
|
10
|
+
|
|
3
11
|
## 0.16.39
|
|
4
12
|
|
|
5
13
|
`catalyst query pulls` now sends `--state`, `--repo` and `--draft` to Catalyst, so `--state open --all` reads only the workspace's open pull requests instead of paging through every pull request it has ever had. `--state` takes `open`, `closed` or `merged`, and `--draft` takes `draft` or `ready`. Any other value is refused before a request is made. `open` and `closed` leave out merged pull requests, and the repository and state match in any letter case. Against an older Catalyst that ignores these filters, the CLI applies them to the rows it gets back, so the result is the same. A filtered read of the local replica now checks every pull request it holds, so an older pull request linked to a ticket is still found.
|
package/README.md
CHANGED
|
@@ -240,6 +240,32 @@ Paused rows show who paused the repository (their email, or the key or operator
|
|
|
240
240
|
|
|
241
241
|
`--json` preserves the list response for status and prints the refreshed repository row for a write. An older cloud reports that repository status is not served yet and needs a newer cloud. Agents can use `catalyst capabilities --json` to check route availability against the cached cloud contract.
|
|
242
242
|
|
|
243
|
+
## Open pull requests over time
|
|
244
|
+
|
|
245
|
+
Print how many pull requests a repository had open, one row per day or per hour:
|
|
246
|
+
|
|
247
|
+
```sh
|
|
248
|
+
catalyst flow --repo acme/app --period 2w --granularity day
|
|
249
|
+
catalyst flow --repo acme/app --period 24h --granularity hour --json
|
|
250
|
+
catalyst flow
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
```text
|
|
254
|
+
$ catalyst flow --repo acme/app --period 2w --granularity day
|
|
255
|
+
2026-10-12 27
|
|
256
|
+
2026-10-13 31
|
|
257
|
+
...
|
|
258
|
+
2026-10-25 24 (now)
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
Each row is one bucket: its start, then the number of pull requests open at its end, drafts included. Daily rows show the date; hourly rows show the time with its UTC offset. Days start at midnight in your workspace's time zone, as the cloud reports it. The last row ends now and is marked `(now)`. A row reads `unavailable` when your workspace cannot attribute pull requests to that repository.
|
|
262
|
+
|
|
263
|
+
`--period` is one of `24h`, `2d`, `3d`, `1w`, `2w`, `4w`, `12w` or `26w`, and defaults to two weeks. `--granularity` is `hour` or `day`; left out, the period picks the width: one, two or three hours for `24h`, `2d` and `3d`, and a day for the week periods. Hourly rows over a long period can be too much for one answer; the command then exits 2 and suggests daily rows or a shorter period.
|
|
264
|
+
|
|
265
|
+
Without `--repo`, the command reads this checkout's GitHub origin and uses it when it is a repository of your workspace. Anywhere else it exits 2 and asks for `--repo owner/name`. Any workspace member can read it.
|
|
266
|
+
|
|
267
|
+
`--json` prints one JSON array with one entry per bucket, exactly as the SDK's `flow.series()` returns them in `series.columns`: `startMs` and `endMs` in epoch milliseconds, `label`, `partial`, and `pr` with `open` and the bucket's other pull request counts.
|
|
268
|
+
|
|
243
269
|
## What has to be running
|
|
244
270
|
|
|
245
271
|
Nothing, by default. After `login`, every read, write, ask and explanation goes to the cloud's origin-fresh API with the config file and the cached contract on disk. Two optional processes exist for people who want them:
|
package/dist/agent-repoint.js
CHANGED
|
@@ -13,14 +13,13 @@ import { dirname, join } from "node:path";
|
|
|
13
13
|
import { fileURLToPath } from "node:url";
|
|
14
14
|
import { compareVersions, exactVersion } from "./approved-release.js";
|
|
15
15
|
import { LEGACY_PACKAGE_NAME, readManifest } from "./config.js";
|
|
16
|
-
import { AUTO_UPDATE_LABEL, custodyNode, defaultRun, enableAutoUpdate, launchAgentPath, scheduledPath, systemdUnitPaths, writeFileAtomic, } from "./runner-auto-update.js";
|
|
16
|
+
import { AUTO_UPDATE_LABEL, custodyNode, defaultRun, enableAutoUpdate, launchAgentPath, scheduledPath, systemdUnitPaths, unitWords, unxml, writeFileAtomic, } from "./runner-auto-update.js";
|
|
17
17
|
import { CLAUDE_PATH_ENV, resolveClaude } from "./update-plugin.js";
|
|
18
18
|
/** Long enough for an in-place update (under a minute on a Mac measured 2026-10-24), well inside the
|
|
19
19
|
* installer's 600-second budget for the whole npm install. */
|
|
20
20
|
export const BUSY_WAIT_MS = 240_000;
|
|
21
21
|
const BUSY_POLL_MS = 5_000;
|
|
22
22
|
const blockingSleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
23
|
-
const unxml = (value) => value.replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/&/g, "&");
|
|
24
23
|
/** The agent's command, from a plist this CLI wrote (launchAgentPlist). Anything else is unrecognized. */
|
|
25
24
|
function plistProgram(text) {
|
|
26
25
|
const array = /<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/.exec(text)?.[1];
|
|
@@ -33,10 +32,6 @@ function plistProgram(text) {
|
|
|
33
32
|
};
|
|
34
33
|
return programOf(args, env("PATH"), env(CLAUDE_PATH_ENV));
|
|
35
34
|
}
|
|
36
|
-
/** systemd's quoted words, as systemdUnits writes them. */
|
|
37
|
-
function unitWords(line) {
|
|
38
|
-
return [...line.matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((m) => (m[1] ?? "").replace(/\\(.)/g, "$1").replace(/%%/g, "%"));
|
|
39
|
-
}
|
|
40
35
|
function unitProgram(text) {
|
|
41
36
|
const lines = text.split("\n");
|
|
42
37
|
const exec = lines.find((l) => l.startsWith("ExecStart="));
|
package/dist/args.js
CHANGED
|
@@ -25,7 +25,8 @@ export const FLAG_TABLES = {
|
|
|
25
25
|
check: { value: false, help: "read the approved release and say what would change, without changing it" },
|
|
26
26
|
auto: { value: false, help: "one-line output for the scheduled run" },
|
|
27
27
|
dir: { value: true, help: "the runner directory (default: the one setup created)" },
|
|
28
|
-
fleet: { value: false, help: "update a multi-team supervisor fleet's compose.json in --dir" },
|
|
28
|
+
fleet: { value: false, help: "update a multi-team supervisor fleet's compose.json in --dir (with auto-update on: schedule that update)" },
|
|
29
|
+
"post-roll-check": { value: false, help: "with --fleet and --dir: the check a fleet update arms for 10 minutes after it applies (run by that timer)" },
|
|
29
30
|
yes: { value: false, help: "uninstall: confirm without a terminal" },
|
|
30
31
|
"delete-checkouts": { value: false, help: "uninstall: also delete the repository and thoughts checkouts" },
|
|
31
32
|
},
|
|
@@ -440,6 +441,11 @@ export const FLAG_TABLES = {
|
|
|
440
441
|
help: "import: only send these names (repeatable or comma-separated)",
|
|
441
442
|
},
|
|
442
443
|
},
|
|
444
|
+
flow: {
|
|
445
|
+
repo: { value: true, help: "the repository (owner/name); default: this checkout's GitHub origin, when it is a repository of your workspace" },
|
|
446
|
+
period: { value: true, help: "24h | 2d | 3d | 1w | 2w | 4w | 12w | 26w (default: 2w)" },
|
|
447
|
+
granularity: { value: true, help: "hour | day (default: the period picks it: 1h for 24h, 2h for 2d, 3h for 3d, a day for weeks)" },
|
|
448
|
+
},
|
|
443
449
|
team: {
|
|
444
450
|
all: { value: false, help: "check every team, one request at a time" },
|
|
445
451
|
stage: {
|
|
@@ -613,7 +619,7 @@ export const FLAG_TABLES = {
|
|
|
613
619
|
};
|
|
614
620
|
export const VERB_USAGE = {
|
|
615
621
|
hosts: "hosts [list] | hosts remove <name-or-id> [--yes] | hosts rename <name-or-id> <new-name> [--json]",
|
|
616
|
-
runner: "runner [status] | runner stop | runner start | runner uninstall [--yes] [--delete-checkouts] | runner update [--check] [--dir <runner directory>] [--fleet] | runner auto-update [status|on|off] [--json]",
|
|
622
|
+
runner: "runner [status] | runner stop | runner start | runner uninstall [--yes] [--delete-checkouts] | runner update [--check] [--dir <runner directory>] [--fleet] | runner auto-update [status|on|off] [--fleet --dir <fleet directory>] [--json]",
|
|
617
623
|
update: "update [--check] [--project <absolute path>] [--dir <runner directory>] | update sdk --project <absolute path> | update policy [<component> on|off] | update policy sdk on|off --project <absolute path> | update policy windows <HH:MM,...>|default [--time-zone <zone>] [--json] (components: runner, supervisor, watchdog, cli, skills-cloud, skills-dev, replica)",
|
|
618
624
|
setup: "setup [onboard options] (sets up Catalyst step by step; same as catalyst onboard) | setup --engine <file> --engine-sha256 <hex> [-- <install options>] (run by install.sh; install.sh --help lists the options)",
|
|
619
625
|
login: "login [--base-url <url>] [--start-replica] (keyless; or --key <personal-key> / CATALYST_CLOUD_TOKEN)",
|
|
@@ -646,6 +652,7 @@ export const VERB_USAGE = {
|
|
|
646
652
|
identity: "identity linear <status|options|set> [<linearUserId>] [--json]",
|
|
647
653
|
messages: "messages read --ticket CTC-N [--since SEQUENCE] [--json]",
|
|
648
654
|
capabilities: "capabilities [--json]",
|
|
655
|
+
flow: "flow [--repo <owner/name>] [--period 24h|2d|3d|1w|2w|4w|12w|26w] [--granularity hour|day] [--json] (a repository's open pull requests over time, one row per hour or day; needs login)",
|
|
649
656
|
project: "project <list [--json] | wip-limit <get|set <n>|set default> [--team K] [--json]>",
|
|
650
657
|
lease: "lease <take <ticket> --agent <seat> [--ttl <minutes>] | renew <ticket> (--commit <sha>|--artifact <path or id>|--finding <id>|--pr <number>) [--note <text>] [--agent <seat>] [--generation <n>] | release <ticket> [--agent <seat>] [--generation <n>] | show <ticket>> [--json] (a seat's ticket lease: take, renew with new progress, release, read; needs login)",
|
|
651
658
|
"codex-env": "codex-env --print [--codex-home <dir>] | --apply | --refresh [--json] (the telemetry attributes naming the coding account and machine a Codex launch runs as; only --refresh waits on the network)",
|
package/dist/capabilities.js
CHANGED
|
@@ -35,6 +35,7 @@ export const CAPABILITIES = [
|
|
|
35
35
|
{ verb: "project wip-limit set", does: "set a project's new-start WIP limit (0 to 9999, or back to the workspace default)", needs: "admin", routes: [agent("POST", "team-wip-limit")], since: "0.13.1" },
|
|
36
36
|
{ verb: "repo status", does: "list visible repositories with their state, pause details and projects", needs: "member", routes: [agent("GET", "repos")], since: "0.16.27" },
|
|
37
37
|
{ verb: "repo pause|resume", does: "pause or resume a repository as a workspace owner or admin", needs: "admin", routes: [agent("GET", "repos"), agent("POST", "repos/pause"), agent("POST", "repos/resume")], since: "0.16.27" },
|
|
38
|
+
{ verb: "flow", does: "print a repository's open pull request counts over time, one row per hour or per day", needs: "member", routes: [], since: "0.16.40" },
|
|
38
39
|
{ verb: "lease take|renew|release|show", does: "hold the ticket a seat works through a ticket lease that lapses without new progress, instead of a label", needs: "member", routes: [agent("POST", "ticket-lease/take"), agent("POST", "ticket-lease/renew"), agent("POST", "ticket-lease/release"), agent("GET", "ticket-lease")], since: "0.16.30" },
|
|
39
40
|
{ verb: "work available", does: "list tickets nobody holds, with the last holder and progress, and open pull requests no lease covers", needs: "member", routes: [], since: "0.16.30" },
|
|
40
41
|
{ verb: "repo agents-block", does: "add or refresh the one Catalyst block in a checkout's AGENTS.md, in the working tree, for the person to commit and open as a pull request", needs: "member", routes: [], since: "0.13.1" },
|
package/dist/cli.js
CHANGED
|
@@ -53,6 +53,7 @@ import { cmdLegacy } from "./legacy.js";
|
|
|
53
53
|
import { cmdCodexEnv } from "./codex-env.js";
|
|
54
54
|
import { codexAttributionAfterSetup, codexAttributionStatusLine, onlyCodexAttribution, removeCodexAttribution, } from "./codex-attribution.js";
|
|
55
55
|
import { cmdIdentity } from "./identity.js";
|
|
56
|
+
import { cmdFlow } from "./flow.js";
|
|
56
57
|
import { cmdMessages } from "./messages.js";
|
|
57
58
|
import { cmdConnections } from "./connections.js";
|
|
58
59
|
import { cmdOnboard, onboardErrorJournal, onboardStatePath, readOnboardJournal, } from "./onboard.js";
|
|
@@ -120,6 +121,7 @@ export function usageText() {
|
|
|
120
121
|
" catalyst project wip-limit <get|set <n>|set default> [--team K] (a project's new-start WIP limit; members read, owners and admins set)",
|
|
121
122
|
" catalyst repo pause <owner/name> --reason <text> | repo resume <owner/name> [--reason <text>] (owner or admin; reason: 1 to 500 trimmed characters)",
|
|
122
123
|
" catalyst repo status [--json] (visible workspace repositories and their paused state; needs login)",
|
|
124
|
+
" catalyst flow [--repo <owner/name>] [--period 2w] [--granularity hour|day] [--json] (a repository's open pull requests over time; needs login)",
|
|
123
125
|
" catalyst repo <agents-block <path> [--write]|agent-setup <path> [--apply] [--with-check]> (a checkout's AGENTS.md block and portable agent layout; working tree only)",
|
|
124
126
|
" catalyst lease <take <ticket> --agent <seat> [--ttl <minutes>]|renew <ticket> --commit|--artifact|--finding|--pr <ref> [--note <text>]|release <ticket>|show <ticket>> [--json] (a seat's ticket lease; needs login)",
|
|
125
127
|
" catalyst work available [--team K] [--json] (tickets nobody holds and open pull requests no lease covers)",
|
|
@@ -582,6 +584,8 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
|
|
|
582
584
|
return await cmdUpdate(args, ctx, deps.update);
|
|
583
585
|
case "connections":
|
|
584
586
|
return await cmdConnections(args, ctx, deps.connections ?? {});
|
|
587
|
+
case "flow":
|
|
588
|
+
return await cmdFlow(args, ctx, deps.flow ?? {});
|
|
585
589
|
default:
|
|
586
590
|
ctx.stderr(`unknown command: ${args.command}`);
|
|
587
591
|
ctx.stderr(usageText());
|
|
@@ -646,6 +650,7 @@ const VERB_HELP_KNOWN = Object.fromEntries([
|
|
|
646
650
|
"capabilities",
|
|
647
651
|
"project",
|
|
648
652
|
"repo",
|
|
653
|
+
"flow",
|
|
649
654
|
"lease",
|
|
650
655
|
"work",
|
|
651
656
|
"legacy",
|
package/dist/flow.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { flagString, positionals } from "./args.js";
|
|
3
|
+
import { requireConfig } from "./config.js";
|
|
4
|
+
import { loadContract } from "./contract.js";
|
|
5
|
+
import { CliError, UsageError } from "./errors.js";
|
|
6
|
+
import { readLocalRepositoryName } from "./onboard-settings-context.js";
|
|
7
|
+
import { bearerFor } from "./oauth.js";
|
|
8
|
+
import { loadHttpSdk } from "./sdk.js";
|
|
9
|
+
const REPO_RE = /^[^/\s]+\/[^/\s]+$/;
|
|
10
|
+
function repoRequired(why) {
|
|
11
|
+
return new CliError(`flow needs a repository: pass --repo owner/name (${why})`, "repo-required", 2);
|
|
12
|
+
}
|
|
13
|
+
/** The zone the cloud cut the days in, from the answer's `boundary`; UTC when it names none. */
|
|
14
|
+
function zoneOf(series) {
|
|
15
|
+
const b = series["boundary"];
|
|
16
|
+
if (b !== null && typeof b === "object" && "kind" in b) {
|
|
17
|
+
if (b.kind === "iana" && "timeZone" in b && typeof b.timeZone === "string") {
|
|
18
|
+
try {
|
|
19
|
+
new Intl.DateTimeFormat("en-CA", { timeZone: b.timeZone });
|
|
20
|
+
return { timeZone: b.timeZone };
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
// An unknown zone name prints in UTC rather than failing the whole read.
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (b.kind === "fixed-offset" && "offsetMinutes" in b && typeof b.offsetMinutes === "number" && Number.isFinite(b.offsetMinutes)) {
|
|
27
|
+
return { offsetMinutes: b.offsetMinutes };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return { timeZone: "UTC" };
|
|
31
|
+
}
|
|
32
|
+
/** One wall-clock formatter per series; a fixed-offset zone reads through shifted UTC. */
|
|
33
|
+
function formatterFor(zone) {
|
|
34
|
+
return new Intl.DateTimeFormat("en-CA", {
|
|
35
|
+
timeZone: "timeZone" in zone ? zone.timeZone : "UTC",
|
|
36
|
+
year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hourCycle: "h23",
|
|
37
|
+
timeZoneName: "longOffset",
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
/** `±HH:MM` for a fixed offset's minutes. */
|
|
41
|
+
function offsetText(minutes) {
|
|
42
|
+
const abs = Math.abs(minutes);
|
|
43
|
+
return `${minutes < 0 ? "-" : "+"}${String(Math.floor(abs / 60)).padStart(2, "0")}:${String(abs % 60).padStart(2, "0")}`;
|
|
44
|
+
}
|
|
45
|
+
function wallClock(ms, zone, fmt) {
|
|
46
|
+
const shifted = "offsetMinutes" in zone ? ms + zone.offsetMinutes * 60_000 : ms;
|
|
47
|
+
const parts = fmt.formatToParts(shifted);
|
|
48
|
+
const part = (type) => parts.find((p) => p.type === type)?.value ?? "";
|
|
49
|
+
const offset = "offsetMinutes" in zone
|
|
50
|
+
? offsetText(zone.offsetMinutes)
|
|
51
|
+
: part("timeZoneName").replace(/^GMT$/, "GMT+00:00").replace(/^GMT/, "");
|
|
52
|
+
return { date: `${part("year")}-${part("month")}-${part("day")}`, time: `${part("hour")}:${part("minute")}`, offset };
|
|
53
|
+
}
|
|
54
|
+
/** One line per bucket: the start in the boundary's zone (a date when every bucket starts at local
|
|
55
|
+
* midnight, a local time with its offset otherwise), the open count, and `(now)` on the partial one. */
|
|
56
|
+
function rows(series) {
|
|
57
|
+
const zone = zoneOf(series);
|
|
58
|
+
const fmt = formatterFor(zone);
|
|
59
|
+
const starts = series.columns.map((c) => wallClock(c.startMs, zone, fmt));
|
|
60
|
+
const daily = starts.every((w) => w.time === "00:00");
|
|
61
|
+
const when = starts.map((w) => (daily ? w.date : `${w.date}T${w.time}${w.offset}`));
|
|
62
|
+
const counts = series.columns.map((c) => (c.pr === null ? "unavailable" : String(c.pr.open)));
|
|
63
|
+
const width = Math.max(0, ...counts.map((c) => c.length));
|
|
64
|
+
return series.columns.map((c, i) => `${when[i]} ${counts[i].padStart(width)}${c.partial ? " (now)" : ""}`);
|
|
65
|
+
}
|
|
66
|
+
export async function cmdFlow(args, ctx, deps = {}) {
|
|
67
|
+
if (positionals(args).length > 0)
|
|
68
|
+
throw new UsageError("flow takes no positional arguments; name the repository with --repo owner/name");
|
|
69
|
+
const sdk = await loadHttpSdk();
|
|
70
|
+
const rawPeriod = flagString(args, "period");
|
|
71
|
+
const period = rawPeriod === undefined ? undefined : sdk.FLOW_PERIODS.find((p) => p === rawPeriod);
|
|
72
|
+
if (rawPeriod !== undefined && period === undefined)
|
|
73
|
+
throw new UsageError(`--period must be one of ${sdk.FLOW_PERIODS.join(", ")} (got "${rawPeriod}")`);
|
|
74
|
+
const rawGranularity = flagString(args, "granularity");
|
|
75
|
+
const granularity = rawGranularity === undefined ? undefined : sdk.FLOW_GRANULARITIES.find((g) => g === rawGranularity);
|
|
76
|
+
if (rawGranularity !== undefined && granularity === undefined)
|
|
77
|
+
throw new UsageError(`--granularity must be ${sdk.FLOW_GRANULARITIES.join(" or ")} (got "${rawGranularity}")`);
|
|
78
|
+
const named = flagString(args, "repo");
|
|
79
|
+
if (named !== undefined && !REPO_RE.test(named))
|
|
80
|
+
throw new UsageError(`--repo must be owner/name (got "${named}")`);
|
|
81
|
+
const cfg = requireConfig(ctx);
|
|
82
|
+
let repo = named;
|
|
83
|
+
if (repo === undefined) {
|
|
84
|
+
const local = await (deps.repoName ?? readLocalRepositoryName)(resolve("."));
|
|
85
|
+
if (local === null)
|
|
86
|
+
throw repoRequired("this directory is not a checkout with a GitHub origin");
|
|
87
|
+
const repositories = await (deps.repositories ?? (async () => (await loadContract(ctx, cfg)).doc.merge.repositories))();
|
|
88
|
+
const match = repositories.find((r) => `${r.owner}/${r.name}`.toLowerCase() === local);
|
|
89
|
+
if (match === undefined)
|
|
90
|
+
throw repoRequired(`this checkout's origin, ${local}, is not a repository of this workspace`);
|
|
91
|
+
repo = `${match.owner}/${match.name}`;
|
|
92
|
+
}
|
|
93
|
+
const createClient = deps.createClient ?? ((options) => sdk.createTenantClient(options));
|
|
94
|
+
const client = await createClient({ key: await bearerFor(ctx, cfg), baseUrl: cfg.baseUrl, fetch: ctx.fetch });
|
|
95
|
+
const result = await client.flow.series({ repo, period, granularity });
|
|
96
|
+
if (result.outcome !== "ok")
|
|
97
|
+
throw new CliError(`flow for ${repo}: ${result.reason}`, result.outcome, 2, "status" in result ? result.status : undefined);
|
|
98
|
+
if (args.json)
|
|
99
|
+
ctx.stdout(JSON.stringify(result.series.columns));
|
|
100
|
+
else
|
|
101
|
+
for (const line of rows(result.series))
|
|
102
|
+
ctx.stdout(line);
|
|
103
|
+
return 0;
|
|
104
|
+
}
|
|
@@ -25,8 +25,16 @@ export const defaultRun = (cmd, args) => {
|
|
|
25
25
|
return { status: r.status ?? 1, stdout: r.stdout ?? "", stderr: r.error ? r.error.message : (r.stderr ?? "") };
|
|
26
26
|
};
|
|
27
27
|
export const xml = (value) => value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
28
|
+
export const unxml = (value) => value.replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/&/g, "&");
|
|
28
29
|
/** systemd's own quoting for one ExecStart/Environment word. */
|
|
29
30
|
const unitWord = (value) => `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/%/g, "%%")}"`;
|
|
31
|
+
/** systemd's quoted words, as systemdUnits writes them. */
|
|
32
|
+
export function unitWords(line) {
|
|
33
|
+
return [...line.matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((m) => (m[1] ?? "").replace(/\\(.)/g, "$1").replace(/%%/g, "%"));
|
|
34
|
+
}
|
|
35
|
+
/** The command the schedule runs. CTC-6384: a supervisor fleet's schedule adds --fleet, so each run
|
|
36
|
+
* updates the fleet's compose.json in `dir` instead of a single runner's .env. */
|
|
37
|
+
const updateArgs = (dir, fleet) => ["runner", "update", "--auto", ...(fleet ? ["--fleet"] : []), "--dir", dir];
|
|
30
38
|
export function launchAgentPath(home) {
|
|
31
39
|
return join(home, "Library", "LaunchAgents", `${AUTO_UPDATE_LABEL}.plist`);
|
|
32
40
|
}
|
|
@@ -34,8 +42,8 @@ export function systemdUnitPaths(home) {
|
|
|
34
42
|
const base = join(home, ".config", "systemd", "user");
|
|
35
43
|
return { service: join(base, `${AUTO_UPDATE_UNIT}.service`), timer: join(base, `${AUTO_UPDATE_UNIT}.timer`) };
|
|
36
44
|
}
|
|
37
|
-
export function launchAgentPlist(deps, dir) {
|
|
38
|
-
const args = [...(deps.node ? [deps.node] : []), deps.cli,
|
|
45
|
+
export function launchAgentPlist(deps, dir, fleet = false) {
|
|
46
|
+
const args = [...(deps.node ? [deps.node] : []), deps.cli, ...updateArgs(dir, fleet)];
|
|
39
47
|
return [
|
|
40
48
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
41
49
|
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
@@ -54,7 +62,7 @@ export function launchAgentPlist(deps, dir) {
|
|
|
54
62
|
"",
|
|
55
63
|
].join("\n");
|
|
56
64
|
}
|
|
57
|
-
export function systemdUnits(deps, dir) {
|
|
65
|
+
export function systemdUnits(deps, dir, fleet = false) {
|
|
58
66
|
return {
|
|
59
67
|
service: [
|
|
60
68
|
"[Unit]",
|
|
@@ -64,7 +72,7 @@ export function systemdUnits(deps, dir) {
|
|
|
64
72
|
"Type=oneshot",
|
|
65
73
|
`Environment=${unitWord(`PATH=${deps.path}`)}`,
|
|
66
74
|
...(deps.claude ? [`Environment=${unitWord(`${CLAUDE_PATH_ENV}=${deps.claude}`)}`] : []),
|
|
67
|
-
`ExecStart=${[deps.cli,
|
|
75
|
+
`ExecStart=${[deps.cli, ...updateArgs(dir, fleet)].map(unitWord).join(" ")}`,
|
|
68
76
|
"",
|
|
69
77
|
].join("\n"),
|
|
70
78
|
timer: [
|
|
@@ -107,15 +115,41 @@ export function writeFileAtomic(path, text) {
|
|
|
107
115
|
writeFileSync(temp, text, { mode: 0o644 });
|
|
108
116
|
renameSync(temp, path);
|
|
109
117
|
}
|
|
118
|
+
/** The installed schedule's own command, read back from the file this CLI wrote. Null when the file
|
|
119
|
+
* is missing or names no update directory. */
|
|
120
|
+
function installedTarget(platform, home) {
|
|
121
|
+
let args;
|
|
122
|
+
try {
|
|
123
|
+
if (platform === "darwin") {
|
|
124
|
+
const array = /<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/.exec(readFileSync(launchAgentPath(home), "utf8"))?.[1];
|
|
125
|
+
args = array === undefined ? [] : [...array.matchAll(/<string>([\s\S]*?)<\/string>/g)].map((m) => unxml(m[1] ?? ""));
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
const exec = readFileSync(systemdUnitPaths(home).service, "utf8").split("\n").find((l) => l.startsWith("ExecStart="));
|
|
129
|
+
args = exec === undefined ? [] : unitWords(exec);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
const at = args.indexOf("--dir");
|
|
136
|
+
const dir = at === -1 ? undefined : args[at + 1];
|
|
137
|
+
if (!args.includes("update") || dir === undefined || !dir.startsWith("/"))
|
|
138
|
+
return null;
|
|
139
|
+
return { fleet: args.includes("--fleet"), dir };
|
|
140
|
+
}
|
|
110
141
|
export function autoUpdateStatus(deps) {
|
|
111
142
|
const platform = deps.platform ?? process.platform;
|
|
112
143
|
const run = deps.run ?? defaultRun;
|
|
144
|
+
const target = () => installedTarget(platform, deps.home) ?? {};
|
|
113
145
|
if (platform === "darwin") {
|
|
114
146
|
const path = launchAgentPath(deps.home);
|
|
115
147
|
if (!existsSync(path))
|
|
116
148
|
return { state: "off", detail: "no_launch_agent" };
|
|
117
149
|
const loaded = run("launchctl", ["print", `gui/${deps.uid ?? process.getuid?.() ?? 0}/${AUTO_UPDATE_LABEL}`]).status === 0;
|
|
118
|
-
return loaded
|
|
150
|
+
return loaded
|
|
151
|
+
? { state: "on", detail: "launch_agent_loaded", path, ...target() }
|
|
152
|
+
: { state: "off", detail: "launch_agent_not_loaded", ...target() };
|
|
119
153
|
}
|
|
120
154
|
if (platform === "linux") {
|
|
121
155
|
const { timer } = systemdUnitPaths(deps.home);
|
|
@@ -123,9 +157,9 @@ export function autoUpdateStatus(deps) {
|
|
|
123
157
|
return { state: "off", detail: "no_timer" };
|
|
124
158
|
const enabled = run("systemctl", ["--user", "is-enabled", `${AUTO_UPDATE_UNIT}.timer`]);
|
|
125
159
|
if (enabled.status !== 0)
|
|
126
|
-
return { state: "off", detail: "timer_not_enabled" };
|
|
160
|
+
return { state: "off", detail: "timer_not_enabled", ...target() };
|
|
127
161
|
const linger = lingering(run, deps.uid ?? process.getuid?.() ?? 0);
|
|
128
|
-
return { state: "on", detail: linger ? "timer_enabled" : "timer_enabled_without_linger", path: timer, linger };
|
|
162
|
+
return { state: "on", detail: linger ? "timer_enabled" : "timer_enabled_without_linger", path: timer, linger, ...target() };
|
|
129
163
|
}
|
|
130
164
|
return { state: "unsupported", detail: `platform_${platform}` };
|
|
131
165
|
}
|
|
@@ -146,32 +180,34 @@ export function custodyNode(dir) {
|
|
|
146
180
|
return null;
|
|
147
181
|
}
|
|
148
182
|
}
|
|
149
|
-
/** Install and start the schedule. Rewrites the files every time, so a moved CLI or PATH is picked up.
|
|
150
|
-
|
|
183
|
+
/** Install and start the schedule. Rewrites the files every time, so a moved CLI or PATH is picked up.
|
|
184
|
+
* `fleet` schedules a supervisor fleet's update of the compose.json in `dir` (CTC-6384). */
|
|
185
|
+
export function enableAutoUpdate(deps, dir, { fleet = false } = {}) {
|
|
151
186
|
const platform = deps.platform ?? process.platform;
|
|
152
187
|
const run = deps.run ?? defaultRun;
|
|
188
|
+
const target = { fleet, dir };
|
|
153
189
|
if (platform === "darwin") {
|
|
154
190
|
const uid = deps.uid ?? process.getuid?.() ?? 0;
|
|
155
191
|
const path = launchAgentPath(deps.home);
|
|
156
|
-
const text = launchAgentPlist({ ...deps, node: deps.node === undefined ? custodyNode(dir) : deps.node }, dir);
|
|
192
|
+
const text = launchAgentPlist({ ...deps, node: deps.node === undefined ? custodyNode(dir) : deps.node }, dir, fleet);
|
|
157
193
|
const unchanged = existsSync(path) && readFileSync(path, "utf8") === text;
|
|
158
194
|
const domain = `gui/${uid}`;
|
|
159
195
|
const loaded = run("launchctl", ["print", `${domain}/${AUTO_UPDATE_LABEL}`]).status === 0;
|
|
160
196
|
if (unchanged && loaded)
|
|
161
|
-
return { state: "on", detail: "launch_agent_loaded", path };
|
|
197
|
+
return { state: "on", detail: "launch_agent_loaded", path, ...target };
|
|
162
198
|
if (loaded)
|
|
163
199
|
run("launchctl", ["bootout", `${domain}/${AUTO_UPDATE_LABEL}`]);
|
|
164
200
|
writeFileAtomic(path, text);
|
|
165
201
|
const boot = run("launchctl", ["bootstrap", domain, path]);
|
|
166
202
|
return boot.status === 0
|
|
167
|
-
? { state: "on", detail: "launch_agent_loaded", path }
|
|
203
|
+
? { state: "on", detail: "launch_agent_loaded", path, ...target }
|
|
168
204
|
: { state: "off", detail: `launchctl_bootstrap_failed:${boot.stderr.trim().slice(0, 160)}` };
|
|
169
205
|
}
|
|
170
206
|
if (platform === "linux") {
|
|
171
207
|
if (run("systemctl", ["--user", "show-environment"]).status !== 0)
|
|
172
208
|
return { state: "unsupported", detail: "no_systemd_user_manager" };
|
|
173
209
|
const paths = systemdUnitPaths(deps.home);
|
|
174
|
-
const units = systemdUnits(deps, dir);
|
|
210
|
+
const units = systemdUnits(deps, dir, fleet);
|
|
175
211
|
writeFileAtomic(paths.service, units.service);
|
|
176
212
|
writeFileAtomic(paths.timer, units.timer);
|
|
177
213
|
run("systemctl", ["--user", "daemon-reload"]);
|
|
@@ -180,7 +216,7 @@ export function enableAutoUpdate(deps, dir) {
|
|
|
180
216
|
return { state: "off", detail: `systemctl_enable_failed:${enabled.stderr.trim().slice(0, 160)}` };
|
|
181
217
|
const { linger, turnedOn } = ensureLingering(run, deps.uid ?? process.getuid?.() ?? 0);
|
|
182
218
|
return { state: "on", detail: linger ? "timer_enabled" : "timer_enabled_without_linger", path: paths.timer, linger,
|
|
183
|
-
...(turnedOn ? { lingerTurnedOn: true } : {}) };
|
|
219
|
+
...(turnedOn ? { lingerTurnedOn: true } : {}), ...target };
|
|
184
220
|
}
|
|
185
221
|
return { state: "unsupported", detail: `platform_${platform}` };
|
|
186
222
|
}
|
package/dist/runner-command.js
CHANGED
|
@@ -5,7 +5,7 @@ import { flagBool, flagString, positionals } from "./args.js";
|
|
|
5
5
|
import { CliError, UsageError } from "./errors.js";
|
|
6
6
|
import { autoUpdateStatus, custodyNode, disableAutoUpdate, enableAutoUpdate, scheduledPath, } from "./runner-auto-update.js";
|
|
7
7
|
import { dockerRunnerUpdateEngine, readReleaseState, RELEASE_COMPONENTS, runnerDir, runRunnerUpdate, updateResultLines, lastProblem, lastProblemLines, } from "./runner-update.js";
|
|
8
|
-
import { dockerFleetUpdateEngine, runFleetUpdate } from "./runner-update-fleet.js";
|
|
8
|
+
import { dockerFleetUpdateEngine, postRollLines, readPostRollResult, runFleetPostRollCheck, runFleetUpdate } from "./runner-update-fleet.js";
|
|
9
9
|
import { runUpdate, sharedReleaseRead, updateReportJson, updateReportLines } from "./update.js";
|
|
10
10
|
import { effectivePolicy, readUpdatePolicy, setComponentPolicy } from "./update-policy.js";
|
|
11
11
|
import { resolveClaude } from "./update-plugin.js";
|
|
@@ -37,8 +37,8 @@ function custodyRerun(ctx, deps, dir) {
|
|
|
37
37
|
const run = deps.run ?? ((command, args, env) => spawnSync(command, [...args], { stdio: "inherit", env }).status ?? 1);
|
|
38
38
|
return run(node, [script, ...rest], { ...ctx.env, [CUSTODY_RERUN_ENV]: "1" });
|
|
39
39
|
}
|
|
40
|
-
const USAGE = "catalyst runner [status] | stop | start | uninstall [--yes] [--delete-checkouts] | runner update [--check] [--dir <runner directory>] [--fleet] | runner auto-update [status|on|off]";
|
|
41
|
-
const UPDATE_FLAGS = ["check", "auto", "dir", "fleet"];
|
|
40
|
+
const USAGE = "catalyst runner [status] | stop | start | uninstall [--yes] [--delete-checkouts] | runner update [--check] [--dir <runner directory>] [--fleet] | runner auto-update [status|on|off] [--fleet --dir <fleet directory>]";
|
|
41
|
+
const UPDATE_FLAGS = ["check", "auto", "dir", "fleet", "post-roll-check"];
|
|
42
42
|
function schedulerDeps(ctx, deps) {
|
|
43
43
|
const cli = deps.scheduler?.cli ?? resolve(process.argv[1] ?? "catalyst");
|
|
44
44
|
return {
|
|
@@ -89,7 +89,7 @@ export async function cmdRunner(args, ctx, deps = {}) {
|
|
|
89
89
|
const action = parts[0];
|
|
90
90
|
if (action === undefined || RUNNER_LIFECYCLE_ACTIONS.includes(action)) {
|
|
91
91
|
if (UPDATE_FLAGS.some(flag => args.flags[flag] !== undefined))
|
|
92
|
-
throw new UsageError("--check, --auto, --dir and --
|
|
92
|
+
throw new UsageError("--check, --auto, --dir, --fleet and --post-roll-check are only valid with runner update or auto-update");
|
|
93
93
|
return cmdRunnerLifecycle(args, ctx, deps);
|
|
94
94
|
}
|
|
95
95
|
if (args.flags.yes !== undefined || args.flags["delete-checkouts"] !== undefined)
|
|
@@ -104,6 +104,18 @@ export async function cmdRunner(args, ctx, deps = {}) {
|
|
|
104
104
|
const fleet = flagBool(args, "fleet");
|
|
105
105
|
if (fleet && dirFlag === undefined)
|
|
106
106
|
throw new UsageError("--fleet needs --dir, the directory that holds compose.json");
|
|
107
|
+
// The check a fleet update arms on the host 10 minutes after it applies (CTC-6564); only that timer passes it.
|
|
108
|
+
if (flagBool(args, "post-roll-check")) {
|
|
109
|
+
if (!fleet || dirFlag === undefined || check || auto)
|
|
110
|
+
throw new UsageError("--post-roll-check is run by the timer a fleet update arms: runner update --fleet --dir <dir> --post-roll-check");
|
|
111
|
+
const outcome = await runFleetPostRollCheck(deps.fleetEngine ?? dockerFleetUpdateEngine({ env: ctx.env }), { dir: dirFlag });
|
|
112
|
+
if (args.json)
|
|
113
|
+
ctx.stdout(JSON.stringify(outcome));
|
|
114
|
+
else
|
|
115
|
+
for (const line of outcome.result ? postRollLines(outcome.result) : [`Post-roll check: ${outcome.detail}.`])
|
|
116
|
+
ctx.stdout(line);
|
|
117
|
+
return outcome.exitCode;
|
|
118
|
+
}
|
|
107
119
|
if (!fleet && !check) {
|
|
108
120
|
const rerun = custodyRerun(ctx, deps.custody ?? {}, dir());
|
|
109
121
|
if (rerun !== null)
|
|
@@ -184,14 +196,27 @@ export async function cmdRunner(args, ctx, deps = {}) {
|
|
|
184
196
|
ctx.stdout(line);
|
|
185
197
|
return result.exitCode;
|
|
186
198
|
}
|
|
199
|
+
// A fleet check also names the last post-roll verdict (CTC-6564).
|
|
200
|
+
if (check && fleet && !auto) {
|
|
201
|
+
const verdict = readPostRollResult(dir());
|
|
202
|
+
if (args.json)
|
|
203
|
+
ctx.stdout(JSON.stringify({ ...result, lastPostRoll: verdict }));
|
|
204
|
+
else
|
|
205
|
+
for (const line of [...updateResultLines(result), ...postRollLines(verdict)])
|
|
206
|
+
ctx.stdout(line);
|
|
207
|
+
return result.exitCode;
|
|
208
|
+
}
|
|
187
209
|
return print(result, await components(result));
|
|
188
210
|
}
|
|
189
211
|
if (action === "auto-update" && parts.length <= 2) {
|
|
190
212
|
const mode = parts[1] ?? "status";
|
|
213
|
+
const fleet = flagBool(args, "fleet");
|
|
214
|
+
if (mode === "on" && fleet && dirFlag === undefined)
|
|
215
|
+
throw new UsageError("--fleet needs --dir, the directory that holds compose.json");
|
|
191
216
|
const scheduler = schedulerDeps(ctx, deps);
|
|
192
217
|
let status;
|
|
193
218
|
if (mode === "on")
|
|
194
|
-
status = enableAutoUpdate(scheduler, dir());
|
|
219
|
+
status = enableAutoUpdate(scheduler, dir(), { fleet });
|
|
195
220
|
else if (mode === "off")
|
|
196
221
|
status = disableAutoUpdate(scheduler);
|
|
197
222
|
else if (mode === "status")
|
|
@@ -207,8 +232,12 @@ export async function cmdRunner(args, ctx, deps = {}) {
|
|
|
207
232
|
const state = mode === "status" && dirFlag !== undefined ? readReleaseState(dirFlag) : undefined;
|
|
208
233
|
ctx.stdout(JSON.stringify({ ...status, ...(state ? { release: state } : {}) }));
|
|
209
234
|
}
|
|
210
|
-
else
|
|
235
|
+
else {
|
|
211
236
|
ctx.stdout(statusLine(status));
|
|
237
|
+
// A single runner's schedule prints what it always has; a fleet's says so, and for which directory.
|
|
238
|
+
if (status.state === "on" && status.fleet)
|
|
239
|
+
ctx.stdout(`This is a supervisor fleet's schedule: it updates the compose.json in ${status.dir}.`);
|
|
240
|
+
}
|
|
212
241
|
return status.state === "on" || mode !== "on" ? 0 : 1;
|
|
213
242
|
}
|
|
214
243
|
throw new UsageError(USAGE);
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { existsSync, readFileSync, unlinkSync } from "node:fs";
|
|
2
|
-
import { dirname, join } from "node:path";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
4
|
import { dockerRunnerEngine, prepareRunnerImages, spawnDocker } from "./onboard-runner.js";
|
|
5
|
+
import { defaultRun } from "./runner-auto-update.js";
|
|
4
6
|
import { decideRelease, DESIRED_RELEASE_SCRIPT, HOST_ID, isRecord, readReleaseState, releaseLock, takeLock, writePrivateAtomic, writeReleaseState, } from "./runner-update.js";
|
|
5
7
|
// `catalyst runner update --fleet <dir>`: the same approved release, applied to a multi-team
|
|
6
8
|
// supervisor fleet (CTC-6530). One supervisor container serves several scopes from a Compose file
|
|
@@ -20,6 +22,12 @@ import { decideRelease, DESIRED_RELEASE_SCRIPT, HOST_ID, isRecord, readReleaseSt
|
|
|
20
22
|
// no restart and log no `host_not_ready` for those scopes. Otherwise the previous `compose.json` comes
|
|
21
23
|
// back and the supervisor is recreated on it. That scope set is saved before the recreate and removed
|
|
22
24
|
// once it qualifies or rolls back, so a run killed in between is finished by the next one.
|
|
25
|
+
//
|
|
26
|
+
// A release can qualify and then stop receiving work minutes later (CTC-6564). A successful apply
|
|
27
|
+
// arms a check on the host, outside the calling process, for 10 minutes later: it reads the supervisor's
|
|
28
|
+
// log since the roll began and, when the release was granted no work and the fleet's acquires got no
|
|
29
|
+
// answer, restores the saved previous `compose.json`. `runner update --fleet --post-roll-check` is
|
|
30
|
+
// that check; only the armed timer runs it.
|
|
23
31
|
export const FLEET_COMPOSE_FILE = "compose.json";
|
|
24
32
|
export const PREVIOUS_COMPOSE_FILE = "compose.release-previous.json";
|
|
25
33
|
const CANDIDATE_COMPOSE_FILE = "compose.release-candidate.json";
|
|
@@ -34,6 +42,14 @@ const QUALIFY_MIN_MS = 90_000;
|
|
|
34
42
|
const QUALIFY_DEADLINE_MS = 240_000;
|
|
35
43
|
const QUALIFY_POLL_MS = 10_000;
|
|
36
44
|
const CHILD_NAME_PREFIX = "catalyst-pa_";
|
|
45
|
+
/** What a successful apply leaves for the check: the bytes it wrote and when the roll began. */
|
|
46
|
+
export const POST_ROLL_ARMED_FILE = "post-roll-armed.json";
|
|
47
|
+
/** The check's last verdict, beside `release-state.json`. */
|
|
48
|
+
export const POST_ROLL_RESULT_FILE = "post-roll-result.json";
|
|
49
|
+
export const POST_ROLL_DELAY_MS = 10 * 60_000;
|
|
50
|
+
const POST_ROLL_UNIT = "catalyst-fleet-post-roll";
|
|
51
|
+
/** No-response acquire retries, with no grants, that count a release as failing. */
|
|
52
|
+
const POST_ROLL_MAX_NO_RESPONSE = 3;
|
|
37
53
|
/** The two image fields in a fleet `compose.json`, or null when the file is not a fleet layout. */
|
|
38
54
|
export function fleetImages(compose) {
|
|
39
55
|
const supervisor = isRecord(compose) && isRecord(compose.services) ? compose.services.supervisor : null;
|
|
@@ -104,6 +120,112 @@ function clearInFlight(dir) {
|
|
|
104
120
|
// Already gone.
|
|
105
121
|
}
|
|
106
122
|
}
|
|
123
|
+
const removeFile = (path) => {
|
|
124
|
+
try {
|
|
125
|
+
unlinkSync(path);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// Already gone.
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
const sha256 = (text) => createHash("sha256").update(text).digest("hex");
|
|
132
|
+
const POST_ROLL_VERDICTS = ["PASS", "STANDS_IDLE", "ROLLED_BACK", "ROLLBACK_REFUSED", "ROLLBACK_FAILED"];
|
|
133
|
+
const numberOrNull = (value) => (typeof value === "number" && Number.isSafeInteger(value) ? value : null);
|
|
134
|
+
const stringOrNull = (value) => (typeof value === "string" ? value : null);
|
|
135
|
+
function readPostRollArmed(dir) {
|
|
136
|
+
try {
|
|
137
|
+
const parsed = JSON.parse(readFileSync(join(dir, POST_ROLL_ARMED_FILE), "utf8"));
|
|
138
|
+
if (!isRecord(parsed) || parsed.version !== 1)
|
|
139
|
+
return null;
|
|
140
|
+
const sinceMs = numberOrNull(parsed.sinceMs);
|
|
141
|
+
if (sinceMs === null || typeof parsed.composeSha256 !== "string")
|
|
142
|
+
return null;
|
|
143
|
+
return {
|
|
144
|
+
version: 1,
|
|
145
|
+
sinceMs,
|
|
146
|
+
composeSha256: parsed.composeSha256,
|
|
147
|
+
generation: numberOrNull(parsed.generation),
|
|
148
|
+
releaseId: stringOrNull(parsed.releaseId),
|
|
149
|
+
fromGeneration: numberOrNull(parsed.fromGeneration),
|
|
150
|
+
armedAtMs: numberOrNull(parsed.armedAtMs) ?? sinceMs,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/** The last post-roll check's record, or null when there is none. */
|
|
158
|
+
export function readPostRollResult(dir) {
|
|
159
|
+
try {
|
|
160
|
+
const parsed = JSON.parse(readFileSync(join(dir, POST_ROLL_RESULT_FILE), "utf8"));
|
|
161
|
+
if (!isRecord(parsed) || parsed.version !== 1 || typeof parsed.verdict !== "string" || !POST_ROLL_VERDICTS.includes(parsed.verdict))
|
|
162
|
+
return null;
|
|
163
|
+
if (typeof parsed.at !== "string" || typeof parsed.since !== "string")
|
|
164
|
+
return null;
|
|
165
|
+
return {
|
|
166
|
+
version: 1,
|
|
167
|
+
at: parsed.at,
|
|
168
|
+
since: parsed.since,
|
|
169
|
+
granted: numberOrNull(parsed.granted) ?? 0,
|
|
170
|
+
noResponse: numberOrNull(parsed.noResponse) ?? 0,
|
|
171
|
+
answered: numberOrNull(parsed.answered) ?? 0,
|
|
172
|
+
verdict: parsed.verdict,
|
|
173
|
+
generation: numberOrNull(parsed.generation),
|
|
174
|
+
releaseId: stringOrNull(parsed.releaseId),
|
|
175
|
+
...(parsed.rolledBackFrom !== undefined ? { rolledBackFrom: numberOrNull(parsed.rolledBackFrom) } : {}),
|
|
176
|
+
...(parsed.rolledBackTo !== undefined ? { rolledBackTo: numberOrNull(parsed.rolledBackTo) } : {}),
|
|
177
|
+
...(typeof parsed.reason === "string" ? { reason: parsed.reason } : {}),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const VERDICT_TEXT = {
|
|
185
|
+
PASS: "passed: the release was granted work",
|
|
186
|
+
STANDS_IDLE: "left the release in place: no work was granted and nothing showed a failure",
|
|
187
|
+
ROLLED_BACK: "rolled the release back: it was granted no work or its supervisor was not running",
|
|
188
|
+
ROLLBACK_REFUSED: "found no work granted and did not roll back",
|
|
189
|
+
ROLLBACK_FAILED: "tried to roll back and could not confirm the supervisor",
|
|
190
|
+
};
|
|
191
|
+
/** One line naming the last post-roll verdict, for `runner update --check --fleet`. */
|
|
192
|
+
export function postRollLines(result) {
|
|
193
|
+
if (result === null)
|
|
194
|
+
return [];
|
|
195
|
+
const detail = [
|
|
196
|
+
...(result.verdict === "ROLLED_BACK" ? [`back to generation ${result.rolledBackTo ?? "none"}`] : result.reason ? [result.reason] : []),
|
|
197
|
+
`generation ${result.generation ?? "none"}`,
|
|
198
|
+
`since ${result.since}: ${result.granted} granted, ${result.noResponse} no response, ${result.answered} answered`,
|
|
199
|
+
];
|
|
200
|
+
return [`The last post-roll check, at ${result.at}, ${VERDICT_TEXT[result.verdict]} (${detail.join("; ")}).`];
|
|
201
|
+
}
|
|
202
|
+
const eventLines = (text, event) => text.split("\n").filter((line) => new RegExp(`"event"\\s*:\\s*"${event}"`).test(line));
|
|
203
|
+
/** The counts the post-roll rule reads from the supervisor's log since the roll began. `answered` is
|
|
204
|
+
* informational: an idle supervisor whose acquire gets an empty queue may log nothing at all. */
|
|
205
|
+
export function postRollCounts(text) {
|
|
206
|
+
const granted = eventLines(text, "work_granted").length;
|
|
207
|
+
const refused = eventLines(text, "acquire_refused").length;
|
|
208
|
+
const none = eventLines(text, "acquire_none").length;
|
|
209
|
+
const noResponse = text.split("\n").filter((line) => line.includes("acquire_retry") && line.includes("(no response)")).length;
|
|
210
|
+
return { granted, noResponse, answered: granted + refused + none };
|
|
211
|
+
}
|
|
212
|
+
/** Whether a release with these counts stopped receiving work: nothing granted since the roll began and
|
|
213
|
+
* at least three acquire retries that got no response. Silence alone is not a failure, because a healthy
|
|
214
|
+
* idle host logs nothing; a supervisor that is not running is judged separately, from the container. */
|
|
215
|
+
export const postRollFailing = (counts) => counts.granted === 0 && counts.noResponse >= POST_ROLL_MAX_NO_RESPONSE;
|
|
216
|
+
/** Record what the apply wrote and ask the engine for the check. The note joins the result's detail
|
|
217
|
+
* when the check could not be armed; the apply itself still succeeded. */
|
|
218
|
+
async function armPostRollCheck(engine, dir, armed, signal) {
|
|
219
|
+
try {
|
|
220
|
+
writePrivateAtomic(join(dir, POST_ROLL_ARMED_FILE), `${JSON.stringify(armed, null, 2)}\n`);
|
|
221
|
+
// The verdict on an earlier release says nothing about this one.
|
|
222
|
+
removeFile(join(dir, POST_ROLL_RESULT_FILE));
|
|
223
|
+
return (await engine.armPostRollCheck(dir, POST_ROLL_DELAY_MS, signal)) ? "" : ";post_roll_check_not_armed";
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
return ";post_roll_check_not_armed";
|
|
227
|
+
}
|
|
228
|
+
}
|
|
107
229
|
export async function runFleetUpdate(engine, options) {
|
|
108
230
|
const { dir } = options;
|
|
109
231
|
const now = options.now ?? Date.now;
|
|
@@ -152,6 +274,11 @@ async function update(engine, options, now) {
|
|
|
152
274
|
const target = { runner: release.images.runner.reference, supervisor: release.images.supervisor.reference };
|
|
153
275
|
const changed = ["runner", "supervisor"].filter((c) => current[c] !== target[c]);
|
|
154
276
|
const tagged = { releaseId: release.releaseId };
|
|
277
|
+
// A release the post-roll check rolled back stays rolled back while it is the approved one; a newer
|
|
278
|
+
// generation is applied normally (CTC-6564).
|
|
279
|
+
const rolled = readPostRollResult(dir);
|
|
280
|
+
if (changed.length > 0 && rolled?.verdict === "ROLLED_BACK" && rolled.releaseId === release.releaseId && rolled.generation === release.generation)
|
|
281
|
+
return done("rolled_back", `post_roll_rolled_back:granted=${rolled.granted},no_response=${rolled.noResponse},answered=${rolled.answered}`, { ...tagged, generation: release.generation, exitCode: 1 });
|
|
155
282
|
const live = await engine.supervisor(dir, project, signal);
|
|
156
283
|
if (live === undefined)
|
|
157
284
|
return done("retained", "docker_unavailable", tagged);
|
|
@@ -223,6 +350,7 @@ async function update(engine, options, now) {
|
|
|
223
350
|
if (childrenBefore === null)
|
|
224
351
|
return done("retained", "docker_unavailable", tagged);
|
|
225
352
|
// From here the machine changes. The previous file is the rollback.
|
|
353
|
+
const rollStartMs = now();
|
|
226
354
|
if (changed.length > 0)
|
|
227
355
|
writePrivateAtomic(join(dir, PREVIOUS_COMPOSE_FILE), composeText);
|
|
228
356
|
writePrivateAtomic(join(dir, IN_FLIGHT_FILE), `${JSON.stringify({ version: 1, scopes: [...scopes].sort(), atMs: now() })}\n`);
|
|
@@ -239,7 +367,11 @@ async function update(engine, options, now) {
|
|
|
239
367
|
const after = childrenAfter[id];
|
|
240
368
|
return before.running && after?.running === true && after.fingerprint !== before.fingerprint;
|
|
241
369
|
}).length;
|
|
242
|
-
const
|
|
370
|
+
const armed = {
|
|
371
|
+
version: 1, sinceMs: rollStartMs, composeSha256: sha256(candidateText), generation: release.generation,
|
|
372
|
+
releaseId: release.releaseId, fromGeneration: state.generation, armedAtMs: now(),
|
|
373
|
+
};
|
|
374
|
+
const detail = (changed.length > 0 ? `updated:${changed.join(",")}` : "reconciled") + await armPostRollCheck(engine, dir, armed, signal);
|
|
243
375
|
const previous = changed.length > 0 ? current : null;
|
|
244
376
|
return disturbed === 0
|
|
245
377
|
? record("updated", detail, previous)
|
|
@@ -247,14 +379,104 @@ async function update(engine, options, now) {
|
|
|
247
379
|
}
|
|
248
380
|
if (rollbackText === null || rollbackImages === null)
|
|
249
381
|
return done("failed", "new_supervisor_not_qualified_no_previous", { ...tagged, exitCode: 1 });
|
|
250
|
-
|
|
251
|
-
const restored = (await engine.recreate(dir, project, signal)) &&
|
|
382
|
+
const restored = (await restoreCompose(engine, dir, project, rollbackText, signal)) &&
|
|
252
383
|
(await qualify(engine, dir, project, rollbackImages, scopes, options, now));
|
|
253
384
|
// A rollback that did not qualify keeps the baseline for the next run.
|
|
254
385
|
if (restored)
|
|
255
386
|
clearInFlight(dir);
|
|
256
387
|
return done(restored ? "rolled_back" : "failed", restored ? "new_supervisor_not_qualified" : "rollback_not_qualified", { ...tagged, exitCode: 1 });
|
|
257
388
|
}
|
|
389
|
+
/** Put `text` back as `compose.json` and recreate the supervisor alone on it. */
|
|
390
|
+
async function restoreCompose(engine, dir, project, text, signal) {
|
|
391
|
+
writePrivateAtomic(join(dir, FLEET_COMPOSE_FILE), text);
|
|
392
|
+
return engine.recreate(dir, project, signal);
|
|
393
|
+
}
|
|
394
|
+
/** The check a successful apply armed, run on the host 10 minutes later (CTC-6564). A release whose
|
|
395
|
+
* supervisor is not running, or that was granted no work while three acquire retries got no response,
|
|
396
|
+
* goes back to the saved previous `compose.json`, but only if `compose.json` still holds the bytes the
|
|
397
|
+
* apply wrote. A running supervisor with no grants and fewer no-response retries stands. */
|
|
398
|
+
export async function runFleetPostRollCheck(engine, options) {
|
|
399
|
+
const { dir, signal } = options;
|
|
400
|
+
const now = options.now ?? Date.now;
|
|
401
|
+
const stay = (detail, exitCode = 0) => ({ detail, result: null, exitCode });
|
|
402
|
+
if (!existsSync(join(dir, FLEET_COMPOSE_FILE)))
|
|
403
|
+
return stay("fleet_not_installed", 1);
|
|
404
|
+
const armed = readPostRollArmed(dir);
|
|
405
|
+
if (armed === null)
|
|
406
|
+
return stay("no_post_roll_check_armed");
|
|
407
|
+
const lock = takeLock(dir, now());
|
|
408
|
+
if (lock === null)
|
|
409
|
+
return stay("update_running");
|
|
410
|
+
try {
|
|
411
|
+
const composeText = readFileSync(join(dir, FLEET_COMPOSE_FILE), "utf8");
|
|
412
|
+
let compose;
|
|
413
|
+
try {
|
|
414
|
+
compose = JSON.parse(composeText);
|
|
415
|
+
}
|
|
416
|
+
catch {
|
|
417
|
+
compose = null;
|
|
418
|
+
}
|
|
419
|
+
const project = isRecord(compose) && typeof compose.name === "string" ? compose.name : "";
|
|
420
|
+
if (!/^[a-z0-9][a-z0-9_-]*$/.test(project))
|
|
421
|
+
return stay("fleet_compose_unrecognized", 1);
|
|
422
|
+
const live = await engine.supervisor(dir, project, signal);
|
|
423
|
+
if (live === undefined)
|
|
424
|
+
return stay("docker_unavailable", 1);
|
|
425
|
+
const since = new Date(armed.sinceMs).toISOString();
|
|
426
|
+
// A missing or stopped supervisor is a failure on its own; its log is not read.
|
|
427
|
+
const notRunning = live === null || !live.running;
|
|
428
|
+
let counts = { granted: 0, noResponse: 0, answered: 0 };
|
|
429
|
+
if (!notRunning) {
|
|
430
|
+
const text = await engine.logs(live.id, since, signal);
|
|
431
|
+
if (text === null)
|
|
432
|
+
return stay("docker_unavailable", 1);
|
|
433
|
+
counts = postRollCounts(text);
|
|
434
|
+
}
|
|
435
|
+
const finish = (verdict, extra = {}, exitCode = 0) => {
|
|
436
|
+
const result = {
|
|
437
|
+
version: 1, at: new Date(now()).toISOString(), since, ...counts, verdict,
|
|
438
|
+
generation: armed.generation, releaseId: armed.releaseId, ...extra,
|
|
439
|
+
};
|
|
440
|
+
writePrivateAtomic(join(dir, POST_ROLL_RESULT_FILE), `${JSON.stringify(result, null, 2)}\n`);
|
|
441
|
+
removeFile(join(dir, POST_ROLL_ARMED_FILE));
|
|
442
|
+
return { detail: verdict, result, exitCode };
|
|
443
|
+
};
|
|
444
|
+
if (!notRunning) {
|
|
445
|
+
if (counts.granted > 0)
|
|
446
|
+
return finish("PASS");
|
|
447
|
+
if (!postRollFailing(counts))
|
|
448
|
+
return finish("STANDS_IDLE");
|
|
449
|
+
}
|
|
450
|
+
const refuse = (reason) => finish("ROLLBACK_REFUSED", { reason }, 1);
|
|
451
|
+
if (sha256(composeText) !== armed.composeSha256)
|
|
452
|
+
return refuse("compose_changed_since_roll");
|
|
453
|
+
let previousText;
|
|
454
|
+
let previous;
|
|
455
|
+
try {
|
|
456
|
+
previousText = readFileSync(join(dir, PREVIOUS_COMPOSE_FILE), "utf8");
|
|
457
|
+
previous = fleetImages(JSON.parse(previousText));
|
|
458
|
+
}
|
|
459
|
+
catch {
|
|
460
|
+
return refuse("no_previous_compose");
|
|
461
|
+
}
|
|
462
|
+
if (previous === null)
|
|
463
|
+
return refuse("no_previous_compose");
|
|
464
|
+
// The apply removed the images it replaced, so the previous ones may need pulling again.
|
|
465
|
+
const unprepared = await engine.prepare([previous.runner, previous.supervisor], signal);
|
|
466
|
+
if (unprepared !== null)
|
|
467
|
+
return finish("ROLLBACK_FAILED", { reason: unprepared }, 1);
|
|
468
|
+
const restored = await restoreCompose(engine, dir, project, previousText, signal);
|
|
469
|
+
const after = restored ? await engine.supervisor(dir, project, signal) : null;
|
|
470
|
+
const running = !!after && after.running && after.image === previous.supervisor && after.runnerImage === previous.runner;
|
|
471
|
+
const moved = { rolledBackFrom: armed.generation, rolledBackTo: armed.fromGeneration };
|
|
472
|
+
return running
|
|
473
|
+
? finish("ROLLED_BACK", { ...moved, reason: notRunning ? "supervisor_not_running" : "no_work_granted" }, 1)
|
|
474
|
+
: finish("ROLLBACK_FAILED", { ...moved, reason: restored ? "supervisor_not_running" : "recreate_failed" }, 1);
|
|
475
|
+
}
|
|
476
|
+
finally {
|
|
477
|
+
releaseLock(dir, lock);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
258
480
|
/** The recreated supervisor runs `images`, stays up with no restart, logs no `host_not_ready` for a
|
|
259
481
|
* scope it must serve, and qualifies every one of `scopes`, held for the minimum window. */
|
|
260
482
|
async function qualify(engine, dir, project, images, scopes, options, now) {
|
|
@@ -438,5 +660,28 @@ export function dockerFleetUpdateEngine(deps = {}) {
|
|
|
438
660
|
for (const ref of images)
|
|
439
661
|
await docker(["image", "rm", ref], { signal, timeoutMs: 120_000 });
|
|
440
662
|
},
|
|
663
|
+
// A transient systemd timer outlives this process and the operator's session. Root's runs under the
|
|
664
|
+
// system manager; anyone else's runs under their user manager, which needs lingering to outlive a logout.
|
|
665
|
+
async armPostRollCheck(dir, delayMs) {
|
|
666
|
+
const timer = deps.timer ?? {};
|
|
667
|
+
if ((timer.platform ?? process.platform) !== "linux")
|
|
668
|
+
return false;
|
|
669
|
+
const command = timer.command ?? (process.argv[1] ? [process.execPath, resolve(process.argv[1])] : null);
|
|
670
|
+
if (command === null)
|
|
671
|
+
return false;
|
|
672
|
+
const run = timer.run ?? defaultRun;
|
|
673
|
+
const manager = (timer.uid ?? process.getuid?.()) === 0 ? [] : ["--user"];
|
|
674
|
+
const source = deps.env ?? process.env;
|
|
675
|
+
const carried = ["PATH", "HOME"].flatMap((key) => (source[key] ? [`--setenv=${key}=${source[key]}`] : []));
|
|
676
|
+
try {
|
|
677
|
+
// A timer armed by an earlier apply is replaced, not duplicated.
|
|
678
|
+
run("systemctl", [...manager, "stop", `${POST_ROLL_UNIT}.timer`, `${POST_ROLL_UNIT}.service`]);
|
|
679
|
+
return run("systemd-run", [...manager, "--quiet", "--collect", `--unit=${POST_ROLL_UNIT}`, `--on-active=${Math.ceil(delayMs / 1000)}s`,
|
|
680
|
+
"--description=Catalyst fleet post-roll check", ...carried, "--", ...command, "runner", "update", "--fleet", "--dir", dir, "--post-roll-check"]).status === 0;
|
|
681
|
+
}
|
|
682
|
+
catch {
|
|
683
|
+
return false;
|
|
684
|
+
}
|
|
685
|
+
},
|
|
441
686
|
};
|
|
442
687
|
}
|
package/dist/runner-update.js
CHANGED
|
@@ -601,7 +601,7 @@ const OUTCOME_TEXT = {
|
|
|
601
601
|
deferred_busy: "A phase is running here, so the update waits for the next check.",
|
|
602
602
|
waiting_window: "An approved release waits for this machine's next update window.",
|
|
603
603
|
retained: "Kept the current images.",
|
|
604
|
-
rolled_back: "The new
|
|
604
|
+
rolled_back: "The new release was rolled back, so the previous images were restored.",
|
|
605
605
|
failed: "The update could not finish. Look at the runner with docker compose ps.",
|
|
606
606
|
};
|
|
607
607
|
/** The last run's record when it ended badly: a failed or rolled-back update a person should know
|
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@ description: >-
|
|
|
4
4
|
Catalyst's GitHub: show a ticket's pull request with its checks, reviews and review threads, say whether it is mergeable under this repository's policy, and explain what a PR accumulates as the ticket moves (the branch, the draft, the rewrite, the force-pushes, the labels, the queue). Use when someone asks "show me the PR", "what are the checks saying", "why hasn't it merged", "what does the queue need", "what does that label mean", or wants the branch and merge conventions Catalyst follows.
|
|
5
5
|
allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
|
|
6
6
|
---
|
|
7
|
-
<!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.
|
|
7
|
+
<!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.40 — written in this repository for customer accounts -->
|
|
8
8
|
|
|
9
9
|
# Catalyst's GitHub
|
|
10
10
|
|
|
@@ -4,7 +4,7 @@ description: >-
|
|
|
4
4
|
Catalyst's view of Linear on the customer's own cloud account. Reads a ticket with its comments, relations, labels, linked pull requests and agent sessions inline, from the cloud API by default, with local replica reads available as an explicit opt-in, always naming the source; searches tickets, PRs, projects and initiatives; writes comments, card moves, labels and new tickets through the account's agent proxy as the app actor, with every route, stage id, label id and marker read from the account's contract. Knows what a ticket accumulates as Catalyst works it (phase-outcome comments, the document per phase, the agent session, the labels, the single blocks relation, the bookkeeping marker, the eyes acknowledgement). Use when a person says "show me the ticket", "what did Catalyst write on it", "comment on it", "move it", "label it" or "file a ticket". Not for raising a decision for a human (what-needs-me) and not for pull requests (catalyst-github).
|
|
5
5
|
allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
|
|
6
6
|
---
|
|
7
|
-
<!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.
|
|
7
|
+
<!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.40 — written in this repository for customer accounts -->
|
|
8
8
|
|
|
9
9
|
# Catalyst Linear
|
|
10
10
|
|
|
@@ -4,7 +4,7 @@ description: >-
|
|
|
4
4
|
Set up Catalyst Cloud and say whether setup is working. Walks a person from nothing to their first ticket running, one step at a time: the coding account, the project, the integrations and connected accounts, each repository's settings file, then one real ticket moving. Also logs this machine in, reads the readiness verdict with who can fix each failure, and checks the optional local replica. Use when someone says "set me up", "onboard me", "I just signed up", "get me started", "what do I do first", "log me in", "which account is this machine on", "am I set up", "what is missing", "why does nothing happen" or "is the replica running", when a login expired, or when a skill script exits 2 saying this machine is not connected. Does every step a key can do through the catalyst CLI, hands over the exact page for the steps only a browser can do, and never claims a step it did not watch succeed.
|
|
5
5
|
allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
|
|
6
6
|
---
|
|
7
|
-
<!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.
|
|
7
|
+
<!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.40 — written in this repository for customer accounts -->
|
|
8
8
|
|
|
9
9
|
# Onboard me
|
|
10
10
|
|
|
@@ -4,7 +4,7 @@ description: >-
|
|
|
4
4
|
Own one Catalyst Cloud project end to end until it closes. Use when the person says "run this project for me", "own this until it ships", "keep this moving", or hands you a project id or a set of tickets to drive. Subscribes to the account's event stream for the scope through the catalyst CLI, reacts to each change in the same turn, makes tickets ready and moves them to dispatch, parks what should stop, chases stalls, escalates inward, and keeps one status summary current. Writes to Linear as the app actor; never polls.
|
|
5
5
|
allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
|
|
6
6
|
---
|
|
7
|
-
<!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.
|
|
7
|
+
<!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.40 — written in this repository for customer accounts -->
|
|
8
8
|
|
|
9
9
|
# Run this project
|
|
10
10
|
|
package/skills/unstick/SKILL.md
CHANGED
|
@@ -4,7 +4,7 @@ description: >-
|
|
|
4
4
|
Get a stuck Catalyst Cloud ticket moving again. Use when the person asks "why is this parked and can you release it?", "unpark this", "get things flowing again", "the outage is over, retry what failed", or hands you a ticket that is not moving. Reads why nothing runs and what holds the ticket through the catalyst CLI, decides whether the recorded cause is fixed, previews the release, releases every governor holding the ticket the right way (or one failure class across a team), and raises an ask only for what a person has to do. Never releases a cause it cannot show changed without saying so.
|
|
5
5
|
allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
|
|
6
6
|
---
|
|
7
|
-
<!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.
|
|
7
|
+
<!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.40 — written in this repository for customer accounts -->
|
|
8
8
|
|
|
9
9
|
# Unstick
|
|
10
10
|
|
|
@@ -4,7 +4,7 @@ description: >-
|
|
|
4
4
|
The human's decision inbox on Catalyst Cloud, and the one way an agent raises a decision on their behalf. Use when the person asks "what needs me?", "what am I blocking?", or when active work is gated on a choice only they can make. Lists open asks ranked by how much open work each one holds, files an ask through the cloud's ask route with the account's own template, and records the answer so the held work releases. Never answers as the human, never duplicates an open ask.
|
|
5
5
|
allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
|
|
6
6
|
---
|
|
7
|
-
<!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.
|
|
7
|
+
<!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.40 — written in this repository for customer accounts -->
|
|
8
8
|
|
|
9
9
|
# What needs me
|
|
10
10
|
|
|
@@ -4,7 +4,7 @@ description: >-
|
|
|
4
4
|
What environment variable names does this repository need, and where does each one come from? Scans the repository offline (no login, no network) and lists the names in three groups (build/test, deploy-only, bindings), each with where it was found, what uses it, and where a local value would come from. Never reads a value or prints one. Also checks the environment variable table in `.catalyst/catalyst.toml` offline. Use when someone asks "what does catalyst.env.json mean", "what env vars does this repo need", "why does the container need this", or before reviewing or writing the declaration.
|
|
5
5
|
allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
|
|
6
6
|
---
|
|
7
|
-
<!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.
|
|
7
|
+
<!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.40 — written in this repository for customer accounts -->
|
|
8
8
|
|
|
9
9
|
# What this repository needs
|
|
10
10
|
|
|
@@ -4,7 +4,7 @@ description: >-
|
|
|
4
4
|
Use for "what's happening?", "what machines do we have?", "how much capacity do we have?", "where are we?", "why is that stuck?", "what closed?", "what's next?", "how does this work?", "why did it do that?", "how does it prioritise?" and "what does this setting do, where is it set?" in Catalyst Cloud, or asks for something to be done rather than known. Reads the contract via catalyst CLI. Read-only: route work/decisions, never answer as the human.
|
|
5
5
|
allowed-tools: Bash(catalyst:*) Bash(npx -p @catalyst-cloud/cli catalyst:*)
|
|
6
6
|
---
|
|
7
|
-
<!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.
|
|
7
|
+
<!-- vendored-from: @catalyst-cloud/catalyst-skills@0.16.40 — written in this repository for customer accounts -->
|
|
8
8
|
|
|
9
9
|
# What's happening
|
|
10
10
|
|