@catalyst-cloud/cli 0.16.24 → 0.16.25
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 +4 -0
- package/dist/args.js +2 -1
- package/dist/runner-command.js +8 -3
- package/dist/runner-update-fleet.js +442 -0
- package/dist/runner-update.js +7 -7
- 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,9 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.16.25
|
|
4
|
+
|
|
5
|
+
A self-hosted machine that runs several teams' work from one `compose.json` can now take each approved release by itself. Run `catalyst runner update --fleet --dir <the folder that holds compose.json>`, by hand or on a schedule. It switches only the supervisor to the approved images and leaves work that is already running alone. If the machine's teams are not ready again within four minutes, it puts the previous images back. `--check` shows what would change without changing anything.
|
|
6
|
+
|
|
3
7
|
## 0.16.24
|
|
4
8
|
|
|
5
9
|
One self-hosted machine can now work for several Linear teams. On a machine already set up for one team, run `catalyst setup --runner` with another team selected, and setup adds that team instead of stopping. Each team's own setting still decides whether its work may run there, and the machine asks each of its teams for work in turn. A new machine starts on Catalyst's current approved release, which can serve several teams from its first setup; a machine you already set up gets that release on its next 15-minute update check.
|
package/dist/args.js
CHANGED
|
@@ -25,6 +25,7 @@ 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
29
|
},
|
|
29
30
|
login: {
|
|
30
31
|
"start-replica": {
|
|
@@ -544,7 +545,7 @@ export const FLAG_TABLES = {
|
|
|
544
545
|
};
|
|
545
546
|
export const VERB_USAGE = {
|
|
546
547
|
hosts: "hosts [list] | hosts remove <name-or-id> [--yes] | hosts rename <name-or-id> <new-name> [--json]",
|
|
547
|
-
runner: "runner update [--check] [--dir <runner directory>] | runner auto-update [status|on|off]",
|
|
548
|
+
runner: "runner update [--check] [--dir <runner directory>] [--fleet] | runner auto-update [status|on|off]",
|
|
548
549
|
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)",
|
|
549
550
|
login: "login [--base-url <url>] [--start-replica] (keyless; or --key <personal-key> / CATALYST_CLOUD_TOKEN)",
|
|
550
551
|
onboard: "onboard [--team <ID|key>] [--repo <owner/name>]... [--coding-account <slot>] [--resume-from <step>] [--only <step>] [--local-sync] [--runner|--no-runner] [--no-color] [--yes] [--dry-run] [--json] | onboard --headless [--key-file <path>] [--team ...] [--repo ...]... [--coding-account ...] [--runner yes|no] [--json]",
|
package/dist/runner-command.js
CHANGED
|
@@ -3,7 +3,8 @@ import { flagBool, flagString, positionals } from "./args.js";
|
|
|
3
3
|
import { UsageError } from "./errors.js";
|
|
4
4
|
import { autoUpdateStatus, disableAutoUpdate, enableAutoUpdate, scheduledPath, } from "./runner-auto-update.js";
|
|
5
5
|
import { dockerRunnerUpdateEngine, readReleaseState, runnerDir, runRunnerUpdate, updateResultLines, } from "./runner-update.js";
|
|
6
|
-
|
|
6
|
+
import { dockerFleetUpdateEngine, runFleetUpdate } from "./runner-update-fleet.js";
|
|
7
|
+
const USAGE = "catalyst runner update [--check] [--dir <runner directory>] [--fleet] | runner auto-update [status|on|off]";
|
|
7
8
|
function schedulerDeps(ctx, deps) {
|
|
8
9
|
const cli = deps.scheduler?.cli ?? resolve(process.argv[1] ?? "catalyst");
|
|
9
10
|
return {
|
|
@@ -47,8 +48,12 @@ export async function cmdRunner(args, ctx, deps = {}) {
|
|
|
47
48
|
if (action === "update" && parts.length === 1) {
|
|
48
49
|
const check = flagBool(args, "check");
|
|
49
50
|
const auto = flagBool(args, "auto");
|
|
50
|
-
const
|
|
51
|
-
|
|
51
|
+
const fleet = flagBool(args, "fleet");
|
|
52
|
+
if (fleet && dirFlag === undefined)
|
|
53
|
+
throw new UsageError("--fleet needs --dir, the directory that holds compose.json");
|
|
54
|
+
const result = fleet
|
|
55
|
+
? await runFleetUpdate(deps.fleetEngine ?? dockerFleetUpdateEngine({ env: ctx.env }), { dir: dir(), check })
|
|
56
|
+
: await runRunnerUpdate(deps.engine ?? dockerRunnerUpdateEngine({ env: ctx.env }), { dir: dir(), check });
|
|
52
57
|
if (args.json)
|
|
53
58
|
ctx.stdout(JSON.stringify(result));
|
|
54
59
|
else if (auto)
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
import { existsSync, readFileSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { dockerRunnerEngine, prepareRunnerImages, spawnDocker } from "./onboard-runner.js";
|
|
4
|
+
import { decideRelease, DESIRED_RELEASE_SCRIPT, HOST_ID, isRecord, readReleaseState, releaseLock, takeLock, writePrivateAtomic, writeReleaseState, } from "./runner-update.js";
|
|
5
|
+
// `catalyst runner update --fleet <dir>`: the same approved release, applied to a multi-team
|
|
6
|
+
// supervisor fleet (CTC-6530). One supervisor container serves several scopes from a Compose file
|
|
7
|
+
// written as JSON (`compose.json`), under one operator enrollment, and every host credential on the
|
|
8
|
+
// machine reads the same release. The fleet differs from a single-team runner in three ways:
|
|
9
|
+
//
|
|
10
|
+
// - the images live in `compose.json`, not `.env`: `services.supervisor.image` and its
|
|
11
|
+
// `environment.CATALYST_RUNNER_IMAGE`. Only those two fields change;
|
|
12
|
+
// - phase containers are never waited out. The supervisor is recreated alone and re-adopts the
|
|
13
|
+
// running ones, which keep the image they were granted. So there is no idle gate;
|
|
14
|
+
// - the deadline watchdog is a separate Compose project and is left alone. The release's watchdog
|
|
15
|
+
// image is not applied here.
|
|
16
|
+
//
|
|
17
|
+
// The release is read from a one-off container of the current supervisor image that mounts only the
|
|
18
|
+
// credential, read-only. Readiness is per scope: every scope that logged `supervisor_scope_qualified`
|
|
19
|
+
// in the minutes before the update must log it again from the new container, which must stay up with
|
|
20
|
+
// no restart and log no `host_not_ready` for those scopes. Otherwise the previous `compose.json` comes
|
|
21
|
+
// back and the supervisor is recreated on it. That scope set is saved before the recreate and removed
|
|
22
|
+
// once it qualifies or rolls back, so a run killed in between is finished by the next one.
|
|
23
|
+
export const FLEET_COMPOSE_FILE = "compose.json";
|
|
24
|
+
export const PREVIOUS_COMPOSE_FILE = "compose.release-previous.json";
|
|
25
|
+
const CANDIDATE_COMPOSE_FILE = "compose.release-candidate.json";
|
|
26
|
+
/** The scopes the supervisor served before a recreate, kept until that recreate is qualified or rolled
|
|
27
|
+
* back. A run that was killed in between finds it and qualifies against it, never against the new
|
|
28
|
+
* container's own log, which would read an unqualified supervisor as serving. */
|
|
29
|
+
export const IN_FLIGHT_FILE = "fleet-update-in-flight.json";
|
|
30
|
+
/** How far back a qualification counts as current before the update. The supervisor repeats it
|
|
31
|
+
* on every loop while a scope is ready, so a ready scope logs it many times in this window. */
|
|
32
|
+
const QUALIFIED_LOOKBACK_MS = 5 * 60_000;
|
|
33
|
+
const QUALIFY_MIN_MS = 90_000;
|
|
34
|
+
const QUALIFY_DEADLINE_MS = 240_000;
|
|
35
|
+
const QUALIFY_POLL_MS = 10_000;
|
|
36
|
+
const CHILD_NAME_PREFIX = "catalyst-pa_";
|
|
37
|
+
/** The two image fields in a fleet `compose.json`, or null when the file is not a fleet layout. */
|
|
38
|
+
export function fleetImages(compose) {
|
|
39
|
+
const supervisor = isRecord(compose) && isRecord(compose.services) ? compose.services.supervisor : null;
|
|
40
|
+
if (!isRecord(supervisor) || typeof supervisor.image !== "string" || !isRecord(supervisor.environment))
|
|
41
|
+
return null;
|
|
42
|
+
const runner = supervisor.environment.CATALYST_RUNNER_IMAGE;
|
|
43
|
+
return typeof runner === "string" ? { supervisor: supervisor.image, runner } : null;
|
|
44
|
+
}
|
|
45
|
+
/** `compose` with only the two image fields replaced. */
|
|
46
|
+
export function withFleetImages(compose, images) {
|
|
47
|
+
const next = structuredClone(compose);
|
|
48
|
+
next.services.supervisor.image = images.supervisor;
|
|
49
|
+
next.services.supervisor.environment.CATALYST_RUNNER_IMAGE = images.runner;
|
|
50
|
+
return next;
|
|
51
|
+
}
|
|
52
|
+
const logEvents = (text) => text.split("\n").flatMap((line) => {
|
|
53
|
+
const start = line.indexOf("{");
|
|
54
|
+
if (start === -1)
|
|
55
|
+
return [];
|
|
56
|
+
try {
|
|
57
|
+
const parsed = JSON.parse(line.slice(start));
|
|
58
|
+
return isRecord(parsed) ? [parsed] : [];
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
const scopeKey = (event) => typeof event.tenant === "string" && typeof event.team === "string" ? `${event.tenant}/${event.team}` : null;
|
|
65
|
+
/** The scopes (`tenant/team`) that logged `supervisor_scope_qualified` in this log text. */
|
|
66
|
+
export function qualifiedScopes(text) {
|
|
67
|
+
const scopes = new Set();
|
|
68
|
+
for (const event of logEvents(text)) {
|
|
69
|
+
const key = event.event === "supervisor_scope_qualified" ? scopeKey(event) : null;
|
|
70
|
+
if (key)
|
|
71
|
+
scopes.add(key);
|
|
72
|
+
}
|
|
73
|
+
return scopes;
|
|
74
|
+
}
|
|
75
|
+
/** A `host_not_ready` line for one of `scopes`, or one that names no scope. Other scopes may be
|
|
76
|
+
* unready for their own reasons (a team with no repository yet) and do not stop an update. */
|
|
77
|
+
export function notReadyScope(text, scopes) {
|
|
78
|
+
for (const event of logEvents(text)) {
|
|
79
|
+
if (event.event !== "host_not_ready")
|
|
80
|
+
continue;
|
|
81
|
+
const key = scopeKey(event);
|
|
82
|
+
if (key === null || scopes.has(key))
|
|
83
|
+
return key ?? "unscoped";
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
function readInFlight(dir) {
|
|
88
|
+
try {
|
|
89
|
+
const parsed = JSON.parse(readFileSync(join(dir, IN_FLIGHT_FILE), "utf8"));
|
|
90
|
+
if (!isRecord(parsed) || parsed.version !== 1 || !Array.isArray(parsed.scopes))
|
|
91
|
+
return null;
|
|
92
|
+
const scopes = parsed.scopes.filter((scope) => typeof scope === "string");
|
|
93
|
+
return scopes.length > 0 ? new Set(scopes) : null;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function clearInFlight(dir) {
|
|
100
|
+
try {
|
|
101
|
+
unlinkSync(join(dir, IN_FLIGHT_FILE));
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
// Already gone.
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
export async function runFleetUpdate(engine, options) {
|
|
108
|
+
const { dir } = options;
|
|
109
|
+
const now = options.now ?? Date.now;
|
|
110
|
+
if (!existsSync(join(dir, FLEET_COMPOSE_FILE)))
|
|
111
|
+
return { outcome: "failed", detail: "fleet_not_installed", generation: null, exitCode: 1 };
|
|
112
|
+
const lock = options.check ? null : takeLock(dir, now());
|
|
113
|
+
if (!options.check && lock === null)
|
|
114
|
+
return { outcome: "retained", detail: "update_running", generation: readReleaseState(dir).generation, exitCode: 0 };
|
|
115
|
+
try {
|
|
116
|
+
const result = await update(engine, options, now);
|
|
117
|
+
if (!options.check) {
|
|
118
|
+
const state = readReleaseState(dir);
|
|
119
|
+
writeReleaseState(dir, { ...state, last: { atMs: now(), outcome: result.outcome, detail: result.detail, generation: result.generation } });
|
|
120
|
+
}
|
|
121
|
+
return result;
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
if (lock !== null)
|
|
125
|
+
releaseLock(dir, lock);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
async function update(engine, options, now) {
|
|
129
|
+
const { dir, signal } = options;
|
|
130
|
+
const composePath = join(dir, FLEET_COMPOSE_FILE);
|
|
131
|
+
const state = readReleaseState(dir);
|
|
132
|
+
const done = (outcome, detail, extra = {}) => ({ outcome, detail, generation: state.generation, exitCode: 0, ...extra });
|
|
133
|
+
const composeText = readFileSync(composePath, "utf8");
|
|
134
|
+
let compose;
|
|
135
|
+
try {
|
|
136
|
+
compose = JSON.parse(composeText);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
compose = null;
|
|
140
|
+
}
|
|
141
|
+
const current = fleetImages(compose);
|
|
142
|
+
const project = isRecord(compose) && typeof compose.name === "string" ? compose.name : "";
|
|
143
|
+
if (!isRecord(compose) || current === null || !/^[a-z0-9][a-z0-9_-]*$/.test(project))
|
|
144
|
+
return done("failed", "fleet_compose_unrecognized", { exitCode: 1 });
|
|
145
|
+
const read = await engine.desiredRelease(dir, compose, signal);
|
|
146
|
+
const decision = decideRelease(state.generation, read);
|
|
147
|
+
if (decision.action === "retain")
|
|
148
|
+
return decision.reason === "not_newer"
|
|
149
|
+
? done("current", "not_newer")
|
|
150
|
+
: done("retained", decision.reason, { exitCode: decision.reason === "credential_refused" || decision.reason === "no_credential" ? 1 : 0 });
|
|
151
|
+
const { release } = decision;
|
|
152
|
+
const target = { runner: release.images.runner.reference, supervisor: release.images.supervisor.reference };
|
|
153
|
+
const changed = ["runner", "supervisor"].filter((c) => current[c] !== target[c]);
|
|
154
|
+
const tagged = { releaseId: release.releaseId };
|
|
155
|
+
const live = await engine.supervisor(dir, project, signal);
|
|
156
|
+
if (live === undefined)
|
|
157
|
+
return done("retained", "docker_unavailable", tagged);
|
|
158
|
+
const runs = (images) => !!live?.running && live.image === images.supervisor && live.runnerImage === images.runner;
|
|
159
|
+
const inFlight = readInFlight(dir);
|
|
160
|
+
if (inFlight === null && decision.action === "verify" && changed.length === 0 && runs(target))
|
|
161
|
+
return done("current", "not_newer");
|
|
162
|
+
if (options.check)
|
|
163
|
+
return done("planned", changed.length > 0 ? `would_update:${changed.join(",")}` : "already_configured", { ...tagged, generation: release.generation });
|
|
164
|
+
// The scopes the running supervisor qualifies are what the new one has to qualify again, unless an
|
|
165
|
+
// earlier run stopped mid-recreate: then the scopes it saved are the baseline.
|
|
166
|
+
let scopes = inFlight;
|
|
167
|
+
if (scopes === null) {
|
|
168
|
+
if (!live?.running)
|
|
169
|
+
return done("retained", "supervisor_not_running", { ...tagged, exitCode: 1 });
|
|
170
|
+
const recent = await engine.logs(live.id, new Date(now() - QUALIFIED_LOOKBACK_MS).toISOString(), signal);
|
|
171
|
+
if (recent === null)
|
|
172
|
+
return done("retained", "docker_unavailable", tagged);
|
|
173
|
+
scopes = qualifiedScopes(recent);
|
|
174
|
+
if (scopes.size === 0)
|
|
175
|
+
return done("retained", "no_qualified_scope", { ...tagged, exitCode: 1 });
|
|
176
|
+
}
|
|
177
|
+
const record = (outcome, detail, previous, exitCode = 0) => {
|
|
178
|
+
writeReleaseState(dir, {
|
|
179
|
+
...state,
|
|
180
|
+
generation: release.generation,
|
|
181
|
+
releaseId: release.releaseId,
|
|
182
|
+
appliedAtMs: now(),
|
|
183
|
+
// The watchdog is not this updater's to move, so it records none.
|
|
184
|
+
previous: previous ? { ...previous, watchdog: "" } : state.previous,
|
|
185
|
+
});
|
|
186
|
+
return { outcome, detail, generation: release.generation, exitCode, ...tagged };
|
|
187
|
+
};
|
|
188
|
+
if (inFlight === null && changed.length === 0 && runs(target))
|
|
189
|
+
return record("recorded", "already_running_release", null);
|
|
190
|
+
// What a failure goes back to: today's file, or for a half-applied file the one saved before it.
|
|
191
|
+
let rollbackText = composeText;
|
|
192
|
+
if (changed.length === 0) {
|
|
193
|
+
try {
|
|
194
|
+
rollbackText = readFileSync(join(dir, PREVIOUS_COMPOSE_FILE), "utf8");
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
rollbackText = null;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
let rollbackImages = null;
|
|
201
|
+
try {
|
|
202
|
+
rollbackImages = rollbackText === null ? null : fleetImages(JSON.parse(rollbackText));
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
rollbackImages = null;
|
|
206
|
+
}
|
|
207
|
+
const unprepared = await engine.prepare([target.runner, target.supervisor], signal);
|
|
208
|
+
if (unprepared !== null)
|
|
209
|
+
return done("retained", unprepared, tagged);
|
|
210
|
+
const candidateText = `${JSON.stringify(withFleetImages(compose, target), null, 2)}\n`;
|
|
211
|
+
const candidatePath = join(dir, CANDIDATE_COMPOSE_FILE);
|
|
212
|
+
writePrivateAtomic(candidatePath, candidateText);
|
|
213
|
+
const valid = await engine.configValid(dir, project, candidatePath, signal);
|
|
214
|
+
try {
|
|
215
|
+
unlinkSync(candidatePath);
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
// Already gone.
|
|
219
|
+
}
|
|
220
|
+
if (!valid)
|
|
221
|
+
return done("retained", "compose_config_invalid", { ...tagged, exitCode: 1 });
|
|
222
|
+
const childrenBefore = await engine.children(signal);
|
|
223
|
+
if (childrenBefore === null)
|
|
224
|
+
return done("retained", "docker_unavailable", tagged);
|
|
225
|
+
// From here the machine changes. The previous file is the rollback.
|
|
226
|
+
if (changed.length > 0)
|
|
227
|
+
writePrivateAtomic(join(dir, PREVIOUS_COMPOSE_FILE), composeText);
|
|
228
|
+
writePrivateAtomic(join(dir, IN_FLIGHT_FILE), `${JSON.stringify({ version: 1, scopes: [...scopes].sort(), atMs: now() })}\n`);
|
|
229
|
+
writePrivateAtomic(composePath, candidateText);
|
|
230
|
+
if ((await engine.recreate(dir, project, signal)) && (await qualify(engine, dir, project, target, scopes, options, now))) {
|
|
231
|
+
clearInFlight(dir);
|
|
232
|
+
if (changed.length > 0)
|
|
233
|
+
await engine.removeImages(changed.map((c) => current[c]), signal);
|
|
234
|
+
// The recreate must leave every running phase container as it was: it is re-adopted, not restarted.
|
|
235
|
+
const childrenAfter = await engine.children(signal);
|
|
236
|
+
const disturbed = childrenAfter === null
|
|
237
|
+
? -1
|
|
238
|
+
: Object.entries(childrenBefore).filter(([id, before]) => {
|
|
239
|
+
const after = childrenAfter[id];
|
|
240
|
+
return before.running && after?.running === true && after.fingerprint !== before.fingerprint;
|
|
241
|
+
}).length;
|
|
242
|
+
const detail = changed.length > 0 ? `updated:${changed.join(",")}` : "reconciled";
|
|
243
|
+
const previous = changed.length > 0 ? current : null;
|
|
244
|
+
return disturbed === 0
|
|
245
|
+
? record("updated", detail, previous)
|
|
246
|
+
: record("updated", `${detail};${disturbed < 0 ? "children_unreadable" : `children_changed:${disturbed}`}`, previous, 1);
|
|
247
|
+
}
|
|
248
|
+
if (rollbackText === null || rollbackImages === null)
|
|
249
|
+
return done("failed", "new_supervisor_not_qualified_no_previous", { ...tagged, exitCode: 1 });
|
|
250
|
+
writePrivateAtomic(composePath, rollbackText);
|
|
251
|
+
const restored = (await engine.recreate(dir, project, signal)) &&
|
|
252
|
+
(await qualify(engine, dir, project, rollbackImages, scopes, options, now));
|
|
253
|
+
// A rollback that did not qualify keeps the baseline for the next run.
|
|
254
|
+
if (restored)
|
|
255
|
+
clearInFlight(dir);
|
|
256
|
+
return done(restored ? "rolled_back" : "failed", restored ? "new_supervisor_not_qualified" : "rollback_not_qualified", { ...tagged, exitCode: 1 });
|
|
257
|
+
}
|
|
258
|
+
/** The recreated supervisor runs `images`, stays up with no restart, logs no `host_not_ready` for a
|
|
259
|
+
* scope it must serve, and qualifies every one of `scopes`, held for the minimum window. */
|
|
260
|
+
async function qualify(engine, dir, project, images, scopes, options, now) {
|
|
261
|
+
const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
262
|
+
const start = now();
|
|
263
|
+
const deadline = start + (options.qualifyDeadlineMs ?? QUALIFY_DEADLINE_MS);
|
|
264
|
+
const minimum = start + (options.qualifyMinMs ?? QUALIFY_MIN_MS);
|
|
265
|
+
let id = null;
|
|
266
|
+
for (;;) {
|
|
267
|
+
if (options.signal?.aborted)
|
|
268
|
+
return false;
|
|
269
|
+
const live = await engine.supervisor(dir, project, options.signal);
|
|
270
|
+
if (live) {
|
|
271
|
+
id ??= live.id;
|
|
272
|
+
if (live.id !== id || !live.running || live.restartCount !== 0)
|
|
273
|
+
return false;
|
|
274
|
+
if (live.image !== images.supervisor || live.runnerImage !== images.runner)
|
|
275
|
+
return false;
|
|
276
|
+
const text = await engine.logs(live.id, live.startedAt, options.signal);
|
|
277
|
+
if (text !== null) {
|
|
278
|
+
if (notReadyScope(text, scopes) !== null)
|
|
279
|
+
return false;
|
|
280
|
+
const seen = qualifiedScopes(text);
|
|
281
|
+
if (now() >= minimum && [...scopes].every((scope) => seen.has(scope)))
|
|
282
|
+
return true;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (now() >= deadline)
|
|
286
|
+
return false;
|
|
287
|
+
await sleep(QUALIFY_POLL_MS);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
const CONTAINER_ID = /^[0-9a-f]{12,64}$/;
|
|
291
|
+
/** The one volume or bind in `service.volumes` that holds `file`, as a read-only `--mount` value. */
|
|
292
|
+
export function credentialMount(compose, service, file) {
|
|
293
|
+
const project = typeof compose.name === "string" ? compose.name : "";
|
|
294
|
+
const topLevel = isRecord(compose.volumes) ? compose.volumes : {};
|
|
295
|
+
let best = null;
|
|
296
|
+
for (const entry of Array.isArray(service.volumes) ? service.volumes : []) {
|
|
297
|
+
let source;
|
|
298
|
+
let target;
|
|
299
|
+
let type;
|
|
300
|
+
if (typeof entry === "string") {
|
|
301
|
+
[source, target] = entry.split(":");
|
|
302
|
+
type = typeof source === "string" && source.startsWith("/") ? "bind" : "volume";
|
|
303
|
+
}
|
|
304
|
+
else if (isRecord(entry)) {
|
|
305
|
+
({ source, target, type } = entry);
|
|
306
|
+
}
|
|
307
|
+
if (typeof source !== "string" || typeof target !== "string" || source === "")
|
|
308
|
+
continue;
|
|
309
|
+
if (file !== target && !file.startsWith(`${target.replace(/\/$/, "")}/`))
|
|
310
|
+
continue;
|
|
311
|
+
// The deepest mount holding the file is the one the container reads it from.
|
|
312
|
+
if (best !== null && best.target.length >= target.length)
|
|
313
|
+
continue;
|
|
314
|
+
const declared = topLevel[source];
|
|
315
|
+
const name = isRecord(declared) && typeof declared.name === "string" ? declared.name
|
|
316
|
+
: isRecord(declared) && declared.external === true ? source : `${project}_${source}`;
|
|
317
|
+
const mount = type === "bind" ? `type=bind,src=${source},dst=${target},readonly`
|
|
318
|
+
: type === "volume" && /^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(name) ? `type=volume,src=${name},dst=${target},readonly` : null;
|
|
319
|
+
best = { target, mount };
|
|
320
|
+
}
|
|
321
|
+
return best?.mount ?? null;
|
|
322
|
+
}
|
|
323
|
+
/** The real engine: the docker CLI, and the setup engine's own pull. */
|
|
324
|
+
export function dockerFleetUpdateEngine(deps = {}) {
|
|
325
|
+
const exec = deps.exec ?? spawnDocker;
|
|
326
|
+
const env = Object.fromEntries(Object.entries(deps.env ?? process.env)
|
|
327
|
+
.filter(([key]) => !key.startsWith("CATALYST_") && !key.startsWith("DOCKER_SOCKET_")));
|
|
328
|
+
const setup = deps.setup ?? dockerRunnerEngine({ exec, env: deps.env });
|
|
329
|
+
const docker = async (args, options = {}) => {
|
|
330
|
+
try {
|
|
331
|
+
return await exec(args, { env, ...options });
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
return { code: 1, stdout: "", stderr: "" };
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
const compose = (dir, project, file = join(dir, FLEET_COMPOSE_FILE)) => ["compose", "--project-name", project, "--file", file];
|
|
338
|
+
return {
|
|
339
|
+
// Not `compose run`: that would mount the Docker socket and every scope's state. This container
|
|
340
|
+
// gets the credential alone, read-only, and the two variables the script reads.
|
|
341
|
+
async desiredRelease(_dir, file, signal) {
|
|
342
|
+
const service = isRecord(file.services) && isRecord(file.services.supervisor) ? file.services.supervisor : null;
|
|
343
|
+
const environment = service && isRecord(service.environment) ? service.environment : null;
|
|
344
|
+
const credential = environment?.CATALYST_HOST_CREDENTIAL_FILE;
|
|
345
|
+
const mirror = environment?.CATALYST_MIRROR_URL;
|
|
346
|
+
if (!service || typeof service.image !== "string" || typeof credential !== "string" || typeof mirror !== "string")
|
|
347
|
+
return { kind: "no_credential" };
|
|
348
|
+
const mount = credentialMount(file, service, credential);
|
|
349
|
+
if (!mount)
|
|
350
|
+
return { kind: "no_credential" };
|
|
351
|
+
const user = typeof service.user === "string" && /^[0-9]+(:[0-9]+)?$/.test(service.user) ? ["--user", service.user] : [];
|
|
352
|
+
const read = await docker(["run", "--rm", "--network", "bridge", "--read-only", "--tmpfs", "/tmp", "--cap-drop", "ALL",
|
|
353
|
+
"--security-opt", "no-new-privileges", ...user, "--mount", mount,
|
|
354
|
+
"--env", `CATALYST_HOST_CREDENTIAL_FILE=${credential}`, "--env", `CATALYST_MIRROR_URL=${mirror}`,
|
|
355
|
+
"--entrypoint", "bun", service.image, "-e", DESIRED_RELEASE_SCRIPT], { signal, timeoutMs: 120_000 });
|
|
356
|
+
if (read.code !== 0)
|
|
357
|
+
return { kind: "no_credential" };
|
|
358
|
+
try {
|
|
359
|
+
const parsed = JSON.parse(read.stdout);
|
|
360
|
+
if (!isRecord(parsed))
|
|
361
|
+
return { kind: "no_credential" };
|
|
362
|
+
const hostId = typeof parsed.hostId === "string" && HOST_ID.test(parsed.hostId) ? parsed.hostId : null;
|
|
363
|
+
if (parsed.kind === "answered" && hostId && typeof parsed.status === "number")
|
|
364
|
+
return { kind: "answered", status: parsed.status, body: parsed.body ?? null, hostId };
|
|
365
|
+
if (parsed.kind === "unreachable" && hostId)
|
|
366
|
+
return { kind: "unreachable", detail: String(parsed.detail ?? "fetch failed").slice(0, 80), hostId };
|
|
367
|
+
return { kind: "no_credential" };
|
|
368
|
+
}
|
|
369
|
+
catch {
|
|
370
|
+
return { kind: "no_credential" };
|
|
371
|
+
}
|
|
372
|
+
},
|
|
373
|
+
async supervisor(dir, project, signal) {
|
|
374
|
+
const listed = await docker([...compose(dir, project), "ps", "--all", "--quiet", "supervisor"], { cwd: dir, signal });
|
|
375
|
+
if (listed.code !== 0)
|
|
376
|
+
return undefined;
|
|
377
|
+
const id = listed.stdout.trim();
|
|
378
|
+
if (id === "")
|
|
379
|
+
return null;
|
|
380
|
+
if (!CONTAINER_ID.test(id))
|
|
381
|
+
return undefined;
|
|
382
|
+
const inspected = await docker(["inspect", id], { signal });
|
|
383
|
+
try {
|
|
384
|
+
const [c] = JSON.parse(inspected.stdout);
|
|
385
|
+
const runner = c.Config.Env.find((line) => line.startsWith("CATALYST_RUNNER_IMAGE="));
|
|
386
|
+
return {
|
|
387
|
+
id: c.Id,
|
|
388
|
+
image: c.Config.Image,
|
|
389
|
+
runnerImage: runner ? runner.slice("CATALYST_RUNNER_IMAGE=".length) : "",
|
|
390
|
+
running: c.State.Running,
|
|
391
|
+
restartCount: c.RestartCount,
|
|
392
|
+
startedAt: c.State.StartedAt,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
catch {
|
|
396
|
+
return undefined;
|
|
397
|
+
}
|
|
398
|
+
},
|
|
399
|
+
async logs(id, since, signal) {
|
|
400
|
+
if (!CONTAINER_ID.test(id))
|
|
401
|
+
return null;
|
|
402
|
+
const read = await docker(["logs", "--since", since, id], { signal, timeoutMs: 30_000 });
|
|
403
|
+
return read.code === 0 ? `${read.stdout}\n${read.stderr ?? ""}` : null;
|
|
404
|
+
},
|
|
405
|
+
async children(signal) {
|
|
406
|
+
const listed = await docker(["ps", "--all", "--quiet", "--no-trunc", "--filter", `name=${CHILD_NAME_PREFIX}`], { signal });
|
|
407
|
+
if (listed.code !== 0)
|
|
408
|
+
return null;
|
|
409
|
+
const ids = listed.stdout.split("\n").map((line) => line.trim()).filter((line) => CONTAINER_ID.test(line));
|
|
410
|
+
if (ids.length === 0)
|
|
411
|
+
return {};
|
|
412
|
+
const inspected = await docker(["inspect", ...ids], { signal });
|
|
413
|
+
try {
|
|
414
|
+
const containers = JSON.parse(inspected.stdout);
|
|
415
|
+
return Object.fromEntries(containers.map((c) => [c.Id, {
|
|
416
|
+
running: c.State.Running,
|
|
417
|
+
fingerprint: `${c.State.StartedAt}|${c.State.Pid}|${c.Image}|${c.RestartCount}`,
|
|
418
|
+
}]));
|
|
419
|
+
}
|
|
420
|
+
catch {
|
|
421
|
+
return null;
|
|
422
|
+
}
|
|
423
|
+
},
|
|
424
|
+
async prepare(images, signal) {
|
|
425
|
+
const info = await setup.info(signal);
|
|
426
|
+
if (!info)
|
|
427
|
+
return "docker_unavailable";
|
|
428
|
+
const refused = await prepareRunnerImages(setup, images, info.arch, signal);
|
|
429
|
+
return refused === null ? null : `${refused.reason}${refused.image ? `:${refused.image}` : ""}`;
|
|
430
|
+
},
|
|
431
|
+
async configValid(dir, project, file, signal) {
|
|
432
|
+
return (await docker([...compose(dir, project, file), "config", "--quiet"], { cwd: dirname(file), signal })).code === 0;
|
|
433
|
+
},
|
|
434
|
+
async recreate(dir, project, signal) {
|
|
435
|
+
return (await docker([...compose(dir, project), "up", "--detach", "--no-deps", "--force-recreate", "supervisor"], { cwd: dir, signal, timeoutMs: 120_000 })).code === 0;
|
|
436
|
+
},
|
|
437
|
+
async removeImages(images, signal) {
|
|
438
|
+
for (const ref of images)
|
|
439
|
+
await docker(["image", "rm", ref], { signal, timeoutMs: 120_000 });
|
|
440
|
+
},
|
|
441
|
+
};
|
|
442
|
+
}
|
package/dist/runner-update.js
CHANGED
|
@@ -42,7 +42,7 @@ export const RELEASE_ENV_KEYS = {
|
|
|
42
42
|
supervisor: "CATALYST_SUPERVISOR_IMAGE",
|
|
43
43
|
watchdog: "CATALYST_WATCHDOG_IMAGE",
|
|
44
44
|
};
|
|
45
|
-
const isRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
45
|
+
export const isRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
46
46
|
const posInt = (value) => typeof value === "number" && Number.isSafeInteger(value) && value > 0;
|
|
47
47
|
const DIGEST = /^sha256:[0-9a-f]{64}$/;
|
|
48
48
|
const SHA40 = /^[0-9a-f]{40}$/;
|
|
@@ -122,12 +122,12 @@ export function readReleaseState(dir) {
|
|
|
122
122
|
}
|
|
123
123
|
}
|
|
124
124
|
/** Write a private file by rename, so a reader never sees half of it. */
|
|
125
|
-
function writePrivateAtomic(path, text) {
|
|
125
|
+
export function writePrivateAtomic(path, text) {
|
|
126
126
|
const temp = `${path}.${process.pid}.tmp`;
|
|
127
127
|
writeFileSync(temp, text, { mode: 0o600 });
|
|
128
128
|
renameSync(temp, path);
|
|
129
129
|
}
|
|
130
|
-
function writeReleaseState(dir, state) {
|
|
130
|
+
export function writeReleaseState(dir, state) {
|
|
131
131
|
writePrivateAtomic(join(dir, RELEASE_STATE_FILE), `${JSON.stringify(state, null, 2)}\n`);
|
|
132
132
|
}
|
|
133
133
|
/** `.env` with each `KEY=` line for `values` replaced in place (or appended), every other line kept. */
|
|
@@ -309,7 +309,7 @@ const alive = (pid) => {
|
|
|
309
309
|
};
|
|
310
310
|
/** An exclusive lock file holding the owner's pid. It is taken over only when that process is gone,
|
|
311
311
|
* never by age: one run can legitimately take longer than any fixed limit (three image pulls). */
|
|
312
|
-
function takeLock(dir, nowMs) {
|
|
312
|
+
export function takeLock(dir, nowMs) {
|
|
313
313
|
const path = join(dir, LOCK_FILE);
|
|
314
314
|
const token = `${process.pid} ${nowMs}\n`;
|
|
315
315
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
@@ -333,7 +333,7 @@ function takeLock(dir, nowMs) {
|
|
|
333
333
|
}
|
|
334
334
|
return null;
|
|
335
335
|
}
|
|
336
|
-
function releaseLock(dir, token) {
|
|
336
|
+
export function releaseLock(dir, token) {
|
|
337
337
|
const path = join(dir, LOCK_FILE);
|
|
338
338
|
try {
|
|
339
339
|
if (readFileSync(path, "utf8") === token)
|
|
@@ -345,7 +345,7 @@ function releaseLock(dir, token) {
|
|
|
345
345
|
}
|
|
346
346
|
/** Runs inside the supervisor image with its own environment. Prints one JSON object, never the
|
|
347
347
|
* secret: the bearer is built and sent from inside the container. */
|
|
348
|
-
const DESIRED_RELEASE_SCRIPT = `(async () => {
|
|
348
|
+
export const DESIRED_RELEASE_SCRIPT = `(async () => {
|
|
349
349
|
const out = (v) => process.stdout.write(JSON.stringify(v));
|
|
350
350
|
let state;
|
|
351
351
|
try { state = JSON.parse(require("node:fs").readFileSync(process.env.CATALYST_HOST_CREDENTIAL_FILE, "utf8")); }
|
|
@@ -367,7 +367,7 @@ const DESIRED_RELEASE_SCRIPT = `(async () => {
|
|
|
367
367
|
out({ kind: "unreachable", detail: String((error && error.name) || "fetch failed"), hostId: e.hostId });
|
|
368
368
|
}
|
|
369
369
|
})();`;
|
|
370
|
-
const HOST_ID = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
370
|
+
export const HOST_ID = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
371
371
|
/** The real engine: the docker CLI and the setup engine's own pull, custody and Compose steps. */
|
|
372
372
|
export function dockerRunnerUpdateEngine(deps = {}) {
|
|
373
373
|
const exec = deps.exec ?? spawnDocker;
|
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.25 — 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.25 — 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.25 — 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.25 — 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.25 — 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.25 — 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.25 — 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.25 — written in this repository for customer accounts -->
|
|
8
8
|
|
|
9
9
|
# What's happening
|
|
10
10
|
|