@catalyst-cloud/cli 0.16.29 → 0.16.31
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 +10 -0
- package/README.md +7 -3
- package/bin/launch.js +28 -0
- package/dist/approved-release.js +210 -0
- package/dist/args.js +21 -0
- package/dist/capabilities.js +5 -0
- package/dist/cli-update-notice.js +7 -4
- package/dist/cli.js +19 -0
- package/dist/lease.js +302 -0
- package/dist/onboard-existing.js +12 -3
- package/dist/onboard-next.js +14 -0
- package/dist/onboard-projects.js +3 -3
- package/dist/onboard-repositories.js +10 -3
- package/dist/onboard-ui.js +5 -1
- package/dist/onboard.js +3 -0
- package/dist/published.js +15 -0
- package/dist/ready.js +11 -5
- package/dist/replica.js +43 -6
- package/dist/runner-auto-update.js +3 -1
- package/dist/runner-command.js +92 -11
- package/dist/runner-update.js +72 -16
- package/dist/setup-onboard-copy.js +41 -2
- package/dist/update-cli.js +234 -0
- package/dist/update-command.js +151 -0
- package/dist/update-inventory.js +96 -0
- package/dist/update-plugin.js +99 -0
- package/dist/update-policy.js +194 -0
- package/dist/update-replica.js +120 -0
- package/dist/update-sdk.js +262 -0
- package/dist/update-skills.js +64 -0
- package/dist/update.js +586 -0
- 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/catalyst-onboard/references/skill-sources.md +8 -0
- package/skills/run-this-project/SKILL.md +1 -1
- package/skills/run-this-project/references/stalls-and-escalation.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/skills/whats-happening/references/what-runs-next.md +1 -1
- package/skills/whats-happening/references/why-is-it-stuck.md +1 -1
package/dist/lease.js
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
// lease.ts — `catalyst lease` and `catalyst work available` (CTC-6400): a seat or one-shot session
|
|
2
|
+
// holds the ticket it works through a ticket lease, never a label, and sees what work is free.
|
|
3
|
+
//
|
|
4
|
+
// lease take <TICKET> --agent <name> [--ttl <minutes>] POST /api/v1/agent/ticket-lease/take
|
|
5
|
+
// lease renew <TICKET> --commit|--artifact|--finding|--pr <ref> [--note <text>]
|
|
6
|
+
// POST /api/v1/agent/ticket-lease/renew
|
|
7
|
+
// lease release <TICKET> POST /api/v1/agent/ticket-lease/release
|
|
8
|
+
// lease show <TICKET> GET /api/v1/agent/ticket-lease?ticket=
|
|
9
|
+
// work available [--team K] GET /api/v1/work-eligibility?team=
|
|
10
|
+
//
|
|
11
|
+
// ⛔ THE KEY IS THE CREDENTIAL. The cloud mints no lease credential (CTC-6398): it derives the holder
|
|
12
|
+
// `seat:<keyId>:<agent>` from the login on every call. What this machine keeps per lease is only what
|
|
13
|
+
// the next call must name: the seat name and the generation the take returned, in a 0600 file under
|
|
14
|
+
// the config directory. Renew and release read it, so a seat names the ticket and its progress.
|
|
15
|
+
//
|
|
16
|
+
// ⭐ A REFUSAL IS A 200. The store answers `outcome: "refused"` with a reason (`no_new_progress`,
|
|
17
|
+
// `ticket_lease_held`, `not_holder`, …); this module prints it and exits 1, so a script can stop on
|
|
18
|
+
// it. A malformed request (400), a seat that does not reach the ticket's team (403) and an exhausted
|
|
19
|
+
// budget (429) exit 1 the same way, with the server's reason.
|
|
20
|
+
import { mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync, chmodSync } from "node:fs";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
import { flagString, positionals } from "./args.js";
|
|
23
|
+
import { CONFIG_MODE, configDirFor, requireConfig } from "./config.js";
|
|
24
|
+
import { loadContract } from "./contract.js";
|
|
25
|
+
import { UsageError } from "./errors.js";
|
|
26
|
+
import { apiClient } from "./transport.js";
|
|
27
|
+
const ROUTE = "/api/v1/agent/ticket-lease";
|
|
28
|
+
const TICKET = /^[A-Za-z][A-Za-z0-9]*-[1-9][0-9]*$/;
|
|
29
|
+
const AGENT = /^[a-z0-9-]{1,40}$/;
|
|
30
|
+
const ACCEPT = [400, 403, 404, 409, 429];
|
|
31
|
+
function object(value) {
|
|
32
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
33
|
+
}
|
|
34
|
+
function text(value) {
|
|
35
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
36
|
+
}
|
|
37
|
+
function leaseRow(value) {
|
|
38
|
+
return object(value) && typeof value.generation === "number" && typeof value.deadlineMs === "number";
|
|
39
|
+
}
|
|
40
|
+
function leaseDir(ctx) {
|
|
41
|
+
return join(configDirFor(ctx.home), "leases");
|
|
42
|
+
}
|
|
43
|
+
function leasePath(ctx, ticket, agent) {
|
|
44
|
+
return join(leaseDir(ctx), `${ticket}--${agent}.json`);
|
|
45
|
+
}
|
|
46
|
+
function saveLease(ctx, lease) {
|
|
47
|
+
mkdirSync(leaseDir(ctx), { recursive: true });
|
|
48
|
+
const path = leasePath(ctx, lease.ticket, lease.agent);
|
|
49
|
+
writeFileSync(path, JSON.stringify(lease) + "\n", { mode: CONFIG_MODE });
|
|
50
|
+
chmodSync(path, CONFIG_MODE);
|
|
51
|
+
}
|
|
52
|
+
function forgetLease(ctx, ticket, agent) {
|
|
53
|
+
rmSync(leasePath(ctx, ticket, agent), { force: true });
|
|
54
|
+
}
|
|
55
|
+
/** Refusals that mean this seat's lease is gone: it lapsed, or a later take replaced it. */
|
|
56
|
+
const GONE = new Set(["expired", "not_holder", "stale_generation"]);
|
|
57
|
+
/** Forget a stored lease the cloud says this seat no longer holds, so a later seat on the same
|
|
58
|
+
* ticket is not asked to name one with --agent. The exit stays 1: the renew or release did not happen. */
|
|
59
|
+
function forgetIfGone(ctx, ticket, agent, body) {
|
|
60
|
+
if (!object(body) || !object(body.refusal) || !GONE.has(String(body.refusal.reason)))
|
|
61
|
+
return;
|
|
62
|
+
forgetLease(ctx, ticket, agent);
|
|
63
|
+
ctx.stderr(`[catalyst] ${agent} no longer holds ${ticket}; forgot its stored lease`);
|
|
64
|
+
}
|
|
65
|
+
/** The leases this machine holds on a ticket, one per seat that took it. */
|
|
66
|
+
function storedLeases(ctx, ticket) {
|
|
67
|
+
let names;
|
|
68
|
+
try {
|
|
69
|
+
names = readdirSync(leaseDir(ctx));
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
const found = [];
|
|
75
|
+
for (const name of names.filter((n) => n.startsWith(`${ticket}--`) && n.endsWith(".json"))) {
|
|
76
|
+
try {
|
|
77
|
+
const value = JSON.parse(readFileSync(join(leaseDir(ctx), name), "utf8"));
|
|
78
|
+
if (object(value) && value.ticket === ticket && text(value.agent) && typeof value.generation === "number")
|
|
79
|
+
found.push({ ticket, agent: value.agent, generation: value.generation, holder: text(value.holder) ? value.holder : null });
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// An unreadable file is not a lease this machine can name; --agent and --generation still can.
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return found;
|
|
86
|
+
}
|
|
87
|
+
function parseTicket(raw, verb) {
|
|
88
|
+
if (!raw || !TICKET.test(raw))
|
|
89
|
+
throw new UsageError(`lease ${verb} needs one ticket such as ENG-123`);
|
|
90
|
+
return raw.toUpperCase();
|
|
91
|
+
}
|
|
92
|
+
function parseAgent(raw) {
|
|
93
|
+
if (raw === undefined)
|
|
94
|
+
return undefined;
|
|
95
|
+
if (!AGENT.test(raw))
|
|
96
|
+
throw new UsageError("--agent is the seat's name: 1 to 40 of a-z, 0-9 and -");
|
|
97
|
+
return raw;
|
|
98
|
+
}
|
|
99
|
+
/** The seat and generation a renew or release names: the flags, else the one lease this machine
|
|
100
|
+
* stored for the ticket. Two stored seats need --agent to choose. */
|
|
101
|
+
function heldLease(args, ctx, ticket) {
|
|
102
|
+
const agent = parseAgent(flagString(args, "agent"));
|
|
103
|
+
const rawGeneration = flagString(args, "generation");
|
|
104
|
+
let generation;
|
|
105
|
+
if (rawGeneration !== undefined) {
|
|
106
|
+
generation = Number(rawGeneration);
|
|
107
|
+
if (!Number.isSafeInteger(generation) || generation < 1)
|
|
108
|
+
throw new UsageError("--generation is the positive integer the take printed");
|
|
109
|
+
}
|
|
110
|
+
const stored = storedLeases(ctx, ticket).filter((s) => agent === undefined || s.agent === agent);
|
|
111
|
+
if (agent !== undefined && generation !== undefined)
|
|
112
|
+
return { agent, generation };
|
|
113
|
+
if (stored.length === 1)
|
|
114
|
+
return { agent: stored[0].agent, generation: generation ?? stored[0].generation };
|
|
115
|
+
if (stored.length > 1)
|
|
116
|
+
throw new UsageError(`this machine holds ${ticket} for ${stored.map((s) => s.agent).join(", ")}; name one with --agent`);
|
|
117
|
+
throw new UsageError(`this machine holds no lease on ${ticket}${agent ? ` for ${agent}` : ""}; take it first, or pass --agent and --generation`);
|
|
118
|
+
}
|
|
119
|
+
/** The one progress fact a renewal asserts, from exactly one of the four flags. */
|
|
120
|
+
function parseProgress(args) {
|
|
121
|
+
const given = ["commit", "artifact", "finding", "pr"]
|
|
122
|
+
.map((kind) => ({ kind, ref: flagString(args, kind) }))
|
|
123
|
+
.filter((p) => p.ref !== undefined);
|
|
124
|
+
if (given.length !== 1)
|
|
125
|
+
throw new UsageError("lease renew needs exactly one of --commit <sha>, --artifact <path or id>, --finding <id> or --pr <number>");
|
|
126
|
+
const { kind } = given[0];
|
|
127
|
+
const ref = given[0].ref.trim();
|
|
128
|
+
if (kind === "commit" && !/^[0-9a-f]{40}$/.test(ref))
|
|
129
|
+
throw new UsageError("--commit is the full 40-character lowercase sha");
|
|
130
|
+
if (kind === "pr" && !/^[1-9][0-9]{0,9}$/.test(ref))
|
|
131
|
+
throw new UsageError("--pr is the pull request's number");
|
|
132
|
+
if ((kind === "artifact" || kind === "finding") && (ref === "" || /\s/.test(ref)))
|
|
133
|
+
throw new UsageError(`--${kind} is one token with no spaces`);
|
|
134
|
+
const note = flagString(args, "note");
|
|
135
|
+
return { kind, ref, ...(note !== undefined && note.trim() !== "" ? { note } : {}) };
|
|
136
|
+
}
|
|
137
|
+
function parseTtlMs(args) {
|
|
138
|
+
const raw = flagString(args, "ttl");
|
|
139
|
+
if (raw === undefined)
|
|
140
|
+
return {};
|
|
141
|
+
const minutes = Number(raw);
|
|
142
|
+
if (!Number.isFinite(minutes) || minutes <= 0)
|
|
143
|
+
throw new UsageError("--ttl is a number of minutes (the cloud keeps it between 5 and 60)");
|
|
144
|
+
return { ttlMs: Math.round(minutes * 60_000) };
|
|
145
|
+
}
|
|
146
|
+
function when(ms) {
|
|
147
|
+
return new Date(ms).toISOString();
|
|
148
|
+
}
|
|
149
|
+
function detail(body) {
|
|
150
|
+
if (!object(body))
|
|
151
|
+
return "";
|
|
152
|
+
const parts = [body.reason, body.error, body.message].filter(text);
|
|
153
|
+
return parts.length ? `: ${[...new Set(parts)].join(": ")}` : "";
|
|
154
|
+
}
|
|
155
|
+
/** Print a refusal or an HTTP failure and return the exit code 1. */
|
|
156
|
+
function refuse(args, ctx, status, body, verb) {
|
|
157
|
+
if (args.json) {
|
|
158
|
+
ctx.stdout(JSON.stringify(object(body) ? { status, ...body } : { status }));
|
|
159
|
+
return 1;
|
|
160
|
+
}
|
|
161
|
+
if (status === 200 && object(body) && object(body.refusal)) {
|
|
162
|
+
const r = body.refusal;
|
|
163
|
+
const current = leaseRow(r.current) ? r.current : null;
|
|
164
|
+
const parts = [`refused: ${text(r.reason) ? r.reason : "unknown"}`];
|
|
165
|
+
if (current?.holder)
|
|
166
|
+
parts.push(`held by ${current.holder} until ${when(current.deadlineMs)}`);
|
|
167
|
+
if (typeof r.retryAtMs === "number")
|
|
168
|
+
parts.push(`retry at ${when(r.retryAtMs)}`);
|
|
169
|
+
ctx.stdout(parts.join("; "));
|
|
170
|
+
return 1;
|
|
171
|
+
}
|
|
172
|
+
const meaning = status === 404 ? `this Catalyst Cloud does not serve lease ${verb} yet (needs a newer cloud)`
|
|
173
|
+
: status === 403 ? "your login does not reach this ticket's team"
|
|
174
|
+
: status === 429 ? "your write budget is spent for now"
|
|
175
|
+
: `lease ${verb} failed`;
|
|
176
|
+
ctx.stdout(`refused (${status}): ${meaning}${detail(body)}`);
|
|
177
|
+
return 1;
|
|
178
|
+
}
|
|
179
|
+
function printLease(ctx, ticket, lease) {
|
|
180
|
+
ctx.stdout(`${ticket}: held by ${lease.holder ?? "nobody"} until ${when(lease.deadlineMs)}, generation ${lease.generation}`);
|
|
181
|
+
if (text(lease.lastProgress))
|
|
182
|
+
ctx.stdout(` last progress: ${lease.lastProgress}`);
|
|
183
|
+
}
|
|
184
|
+
export async function cmdLease(args, ctx) {
|
|
185
|
+
const [action, rawTicket, ...extra] = positionals(args);
|
|
186
|
+
if (action !== "take" && action !== "renew" && action !== "release" && action !== "show")
|
|
187
|
+
throw new UsageError("lease needs take, renew, release or show");
|
|
188
|
+
if (extra.length)
|
|
189
|
+
throw new UsageError(`lease ${action} takes one ticket`);
|
|
190
|
+
const ticket = parseTicket(rawTicket, action);
|
|
191
|
+
const client = apiClient(requireConfig(ctx), ctx);
|
|
192
|
+
if (action === "show") {
|
|
193
|
+
const res = await client.getJson(ROUTE, { query: { ticket }, accept: ACCEPT, parseAccepted: true });
|
|
194
|
+
if (res.status >= 400)
|
|
195
|
+
return refuse(args, ctx, res.status, res.body, action);
|
|
196
|
+
if (args.json) {
|
|
197
|
+
ctx.stdout(JSON.stringify(res.body));
|
|
198
|
+
return 0;
|
|
199
|
+
}
|
|
200
|
+
const lease = object(res.body) && leaseRow(res.body.lease) ? res.body.lease : null;
|
|
201
|
+
if (lease === null)
|
|
202
|
+
ctx.stdout(`${ticket}: no ticket lease`);
|
|
203
|
+
else if (lease.live === false) {
|
|
204
|
+
ctx.stdout(`${ticket}: free; last held by ${lease.lastHolder ?? lease.holder ?? "nobody"}, generation ${lease.generation}`);
|
|
205
|
+
if (text(lease.lastProgress))
|
|
206
|
+
ctx.stdout(` last progress: ${lease.lastProgress}`);
|
|
207
|
+
}
|
|
208
|
+
else
|
|
209
|
+
printLease(ctx, ticket, lease);
|
|
210
|
+
return 0;
|
|
211
|
+
}
|
|
212
|
+
if (action === "take") {
|
|
213
|
+
const agent = parseAgent(flagString(args, "agent"));
|
|
214
|
+
if (agent === undefined)
|
|
215
|
+
throw new UsageError("lease take needs --agent <seat name>");
|
|
216
|
+
const res = await client.postJson(`${ROUTE}/take`, { ticket, agent, ...parseTtlMs(args) }, { accept: ACCEPT });
|
|
217
|
+
if (res.status !== 200 || !object(res.body) || res.body.outcome !== "taken" || !leaseRow(res.body.lease))
|
|
218
|
+
return refuse(args, ctx, res.status, res.body, action);
|
|
219
|
+
const lease = res.body.lease;
|
|
220
|
+
saveLease(ctx, { ticket, agent, generation: lease.generation, holder: lease.holder });
|
|
221
|
+
if (args.json)
|
|
222
|
+
ctx.stdout(JSON.stringify(res.body));
|
|
223
|
+
else
|
|
224
|
+
printLease(ctx, ticket, lease);
|
|
225
|
+
return 0;
|
|
226
|
+
}
|
|
227
|
+
if (action === "renew") {
|
|
228
|
+
const progress = parseProgress(args);
|
|
229
|
+
const held = heldLease(args, ctx, ticket);
|
|
230
|
+
const res = await client.postJson(`${ROUTE}/renew`, { ticket, ...held, progress, ...parseTtlMs(args) }, { accept: ACCEPT });
|
|
231
|
+
if (res.status !== 200 || !object(res.body) || res.body.outcome !== "renewed" || !leaseRow(res.body.lease)) {
|
|
232
|
+
forgetIfGone(ctx, ticket, held.agent, res.body);
|
|
233
|
+
return refuse(args, ctx, res.status, res.body, action);
|
|
234
|
+
}
|
|
235
|
+
if (args.json)
|
|
236
|
+
ctx.stdout(JSON.stringify(res.body));
|
|
237
|
+
else
|
|
238
|
+
printLease(ctx, ticket, res.body.lease);
|
|
239
|
+
return 0;
|
|
240
|
+
}
|
|
241
|
+
const held = heldLease(args, ctx, ticket);
|
|
242
|
+
const res = await client.postJson(`${ROUTE}/release`, { ticket, ...held }, { accept: ACCEPT });
|
|
243
|
+
if (res.status !== 200 || !object(res.body) || res.body.outcome !== "released") {
|
|
244
|
+
forgetIfGone(ctx, ticket, held.agent, res.body);
|
|
245
|
+
return refuse(args, ctx, res.status, res.body, action);
|
|
246
|
+
}
|
|
247
|
+
forgetLease(ctx, ticket, held.agent);
|
|
248
|
+
if (args.json)
|
|
249
|
+
ctx.stdout(JSON.stringify(res.body));
|
|
250
|
+
else
|
|
251
|
+
ctx.stdout(`${ticket}: released by ${held.agent}`);
|
|
252
|
+
return 0;
|
|
253
|
+
}
|
|
254
|
+
/** The free tickets in one team's rows: offered, or offered with no capable runner (a seat can still
|
|
255
|
+
* take it), with no live holder named on them. */
|
|
256
|
+
function freeTickets(rows) {
|
|
257
|
+
return rows.filter((r) => text(r.ticket) && (r.status === "offered" || r.status === "capability_mismatch") && !text(r.holder));
|
|
258
|
+
}
|
|
259
|
+
/** `work available`: per team, the tickets nobody holds (with the last holder and progress when a
|
|
260
|
+
* lease lapsed) and the open pull requests no live lease covers, drafts included. */
|
|
261
|
+
export async function cmdWork(args, ctx) {
|
|
262
|
+
const [action, ...extra] = positionals(args);
|
|
263
|
+
if (action !== "available" || extra.length)
|
|
264
|
+
throw new UsageError("work needs available");
|
|
265
|
+
const cfg = requireConfig(ctx);
|
|
266
|
+
const client = apiClient(cfg, ctx);
|
|
267
|
+
const named = flagString(args, "team");
|
|
268
|
+
let teams;
|
|
269
|
+
if (named !== undefined)
|
|
270
|
+
teams = [named.toUpperCase()];
|
|
271
|
+
else {
|
|
272
|
+
const { doc } = await loadContract(ctx, cfg);
|
|
273
|
+
teams = doc.teams.map((t) => t.key).filter((k) => typeof k === "string" && k !== "");
|
|
274
|
+
if (teams.length === 0)
|
|
275
|
+
throw new UsageError("work available needs --team: the workspace contract names no team");
|
|
276
|
+
}
|
|
277
|
+
const results = {};
|
|
278
|
+
for (const team of teams) {
|
|
279
|
+
const res = await client.getJson("/api/v1/work-eligibility", { query: { team } });
|
|
280
|
+
const rows = Array.isArray(res.body.eligibility?.rows) ? res.body.eligibility.rows : [];
|
|
281
|
+
const pulls = Array.isArray(res.body.openPulls) ? res.body.openPulls : [];
|
|
282
|
+
results[team] = {
|
|
283
|
+
tickets: freeTickets(rows),
|
|
284
|
+
unownedPulls: pulls.filter((p) => object(p) && typeof p.number === "number" && p.owner === "unowned"),
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
if (args.json) {
|
|
288
|
+
ctx.stdout(JSON.stringify(named !== undefined ? results[teams[0]] : results));
|
|
289
|
+
return 0;
|
|
290
|
+
}
|
|
291
|
+
for (const team of teams) {
|
|
292
|
+
const { tickets, unownedPulls } = results[team];
|
|
293
|
+
ctx.stdout(`${team}: ${tickets.length} free ${tickets.length === 1 ? "ticket" : "tickets"}, ${unownedPulls.length} unowned open ${unownedPulls.length === 1 ? "pull request" : "pull requests"}`);
|
|
294
|
+
for (const row of tickets) {
|
|
295
|
+
const last = text(row.lastHolder) ? `; last held by ${row.lastHolder}${text(row.lastProgress) ? `, last progress ${row.lastProgress}` : ""}` : "";
|
|
296
|
+
ctx.stdout(` ${row.ticket}${last}`);
|
|
297
|
+
}
|
|
298
|
+
for (const pull of unownedPulls)
|
|
299
|
+
ctx.stdout(` #${pull.number}${pull.draft ? " (draft)" : ""}${text(pull.ticket) ? ` ${pull.ticket}` : ""}: unowned${text(pull.title) ? `, ${pull.title}` : ""}`);
|
|
300
|
+
}
|
|
301
|
+
return 0;
|
|
302
|
+
}
|
package/dist/onboard-existing.js
CHANGED
|
@@ -7,6 +7,14 @@ const waiting = (reason) => ({
|
|
|
7
7
|
state: "waiting",
|
|
8
8
|
reason,
|
|
9
9
|
});
|
|
10
|
+
/**
|
|
11
|
+
* CTC-5932: at most 20 choices, so the advice stays one readable line, and the full count, so the
|
|
12
|
+
* advice never reports the cap as the workspace's total.
|
|
13
|
+
*/
|
|
14
|
+
export const choiceEvidence = (values) => {
|
|
15
|
+
const unique = [...new Set(values)].sort();
|
|
16
|
+
return { choices: unique.slice(0, 20).join(","), choiceTotal: unique.length };
|
|
17
|
+
};
|
|
10
18
|
const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
|
|
11
19
|
? value
|
|
12
20
|
: null;
|
|
@@ -175,7 +183,8 @@ export function existingLinearAdapters(args, choose, nameNewTeam, message = () =
|
|
|
175
183
|
evidence: { teamKey: uncertain.key },
|
|
176
184
|
}
|
|
177
185
|
: undefined;
|
|
178
|
-
const stopTeam = (reason) => uncertainResult() ??
|
|
186
|
+
const stopTeam = (reason, teams) => uncertainResult() ??
|
|
187
|
+
(teams?.length ? { ...waiting(reason), evidence: choiceEvidence(teams.map((team) => team.key)) } : waiting(reason));
|
|
179
188
|
const requested = (teams, journal) => {
|
|
180
189
|
const explicit = args.flags.team;
|
|
181
190
|
if (typeof explicit === "string") {
|
|
@@ -210,7 +219,7 @@ export function existingLinearAdapters(args, choose, nameNewTeam, message = () =
|
|
|
210
219
|
return stopTeam("team_selection_unverified");
|
|
211
220
|
return choose
|
|
212
221
|
? (uncertainResult("pending") ?? { state: "pending" })
|
|
213
|
-
: stopTeam("team_choice_required");
|
|
222
|
+
: stopTeam("team_choice_required", list.teams);
|
|
214
223
|
}
|
|
215
224
|
const result = await observe(ctx, team.id, signal);
|
|
216
225
|
if (!result || result.checks.get("team_visible") !== "pass")
|
|
@@ -257,7 +266,7 @@ export function existingLinearAdapters(args, choose, nameNewTeam, message = () =
|
|
|
257
266
|
if ("reason" in list)
|
|
258
267
|
return stopTeam(list.reason);
|
|
259
268
|
if (!choose)
|
|
260
|
-
return stopTeam(list.teams.length ? "team_choice_required" : "team_inventory_empty");
|
|
269
|
+
return stopTeam(list.teams.length ? "team_choice_required" : "team_inventory_empty", list.teams);
|
|
261
270
|
let offer = nameNewTeam && typeof args.flags.team !== "string"
|
|
262
271
|
? await teamCreateOffer(ctx, journal, signal)
|
|
263
272
|
: undefined;
|
package/dist/onboard-next.js
CHANGED
|
@@ -325,9 +325,23 @@ function firstTicketText(reason, key, stage) {
|
|
|
325
325
|
}
|
|
326
326
|
return undefined;
|
|
327
327
|
}
|
|
328
|
+
/** CTC-5932: the choices a step recorded, for advice that names real flag values. */
|
|
329
|
+
function recordedChoices(step) {
|
|
330
|
+
const value = step.evidence?.choices;
|
|
331
|
+
return typeof value === "string" ? value.split(",").filter(Boolean) : [];
|
|
332
|
+
}
|
|
328
333
|
/** One step's next action. `waitsFor` names the unfinished step a prerequisite wait is blocked on. */
|
|
329
334
|
export function onboardReasonText(step, context = {}) {
|
|
330
335
|
const reason = step.reason ?? "";
|
|
336
|
+
const choices = recordedChoices(step);
|
|
337
|
+
const recordedTotal = step.evidence?.choiceTotal;
|
|
338
|
+
const total = typeof recordedTotal === "number" && recordedTotal > choices.length ? recordedTotal : choices.length;
|
|
339
|
+
// The list is capped at 20, so a longer inventory says which part it shows.
|
|
340
|
+
const shown = total > choices.length ? `, the first ${choices.length} of them` : "";
|
|
341
|
+
if (reason === "team_choice_required" && choices.length)
|
|
342
|
+
return `This workspace has ${total} Linear ${total === 1 ? "team" : "teams"}${shown}: ${choices.join(", ")}. Add --team with one of them to the same command, for example --team ${choices[0]}.`;
|
|
343
|
+
if (reason === "repository_choice_required" && choices.length)
|
|
344
|
+
return `${total === 1 ? "One repository is" : `${total} repositories are`} available${shown}: ${choices.join(", ")}. Add --repo <owner/name> to the same command, for example --repo ${choices[0]}. Repeat --repo to choose more than one.`;
|
|
331
345
|
if ([
|
|
332
346
|
"workspace_approval_required",
|
|
333
347
|
"personal_approval_required",
|
package/dist/onboard-projects.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { verifyOnboardRoutes } from "./onboard-capabilities.js";
|
|
2
2
|
import { loadConfig, normalizeBaseUrl } from "./config.js";
|
|
3
3
|
import { readExistingOnboardJson, selectedOnboardTeam, } from "./onboard-existing.js";
|
|
4
|
-
import { existingRepositoryAdapter, } from "./onboard-repositories.js";
|
|
4
|
+
import { existingRepositoryAdapter, repositoryChoiceWait, } from "./onboard-repositories.js";
|
|
5
5
|
const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
|
|
6
6
|
? value
|
|
7
7
|
: null;
|
|
@@ -211,7 +211,7 @@ export function firstProjectAdapters(args, input = {}) {
|
|
|
211
211
|
: waiting("repository_selection_unverified");
|
|
212
212
|
return input.chooseFirst && list.rows.length
|
|
213
213
|
? { state: "pending" }
|
|
214
|
-
:
|
|
214
|
+
: repositoryChoiceWait(list.rows);
|
|
215
215
|
},
|
|
216
216
|
act: async (ctx, journal, signal) => {
|
|
217
217
|
const live = await existing.check(ctx, journal, signal);
|
|
@@ -221,7 +221,7 @@ export function firstProjectAdapters(args, input = {}) {
|
|
|
221
221
|
if ("reason" in list)
|
|
222
222
|
return waiting(list.reason);
|
|
223
223
|
if (!input.chooseFirst || !list.rows.length)
|
|
224
|
-
return
|
|
224
|
+
return repositoryChoiceWait(list.rows);
|
|
225
225
|
if (list.truncated)
|
|
226
226
|
input.message?.("GitHub returned part of its repository list. Choose a displayed repository, or stop and adjust the App access before resuming.");
|
|
227
227
|
let remove = () => { };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { isRepositoryId } from "./repository-id.js";
|
|
2
2
|
import { loadConfig, normalizeBaseUrl, readManifest, } from "./config.js";
|
|
3
3
|
import { contractVersionInRange } from "./contract.js";
|
|
4
|
-
import { readExistingOnboardJson, selectedOnboardTeam, } from "./onboard-existing.js";
|
|
4
|
+
import { choiceEvidence, readExistingOnboardJson, selectedOnboardTeam, } from "./onboard-existing.js";
|
|
5
5
|
const part = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$/;
|
|
6
6
|
const id = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
|
|
7
7
|
const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
|
|
@@ -12,6 +12,13 @@ const waiting = (reason) => ({
|
|
|
12
12
|
state: "waiting",
|
|
13
13
|
reason,
|
|
14
14
|
});
|
|
15
|
+
/** CTC-5932: a choice no one was there to make, with the repositories it could have been. */
|
|
16
|
+
export const repositoryChoiceWait = (rows) => rows.length
|
|
17
|
+
? {
|
|
18
|
+
...waiting("repository_choice_required"),
|
|
19
|
+
evidence: choiceEvidence(rows.map((row) => `${row.owner}/${row.name}`)),
|
|
20
|
+
}
|
|
21
|
+
: waiting("repository_choice_required");
|
|
15
22
|
const valid = (value) => {
|
|
16
23
|
const row = object(value);
|
|
17
24
|
return (!!row &&
|
|
@@ -381,7 +388,7 @@ export function existingRepositoryAdapter(args, choose) {
|
|
|
381
388
|
? waiting("repository_selection_unverified")
|
|
382
389
|
: choose
|
|
383
390
|
? { state: "pending" }
|
|
384
|
-
:
|
|
391
|
+
: repositoryChoiceWait(list.repositories);
|
|
385
392
|
return {
|
|
386
393
|
state: "done",
|
|
387
394
|
evidence: {
|
|
@@ -400,7 +407,7 @@ export function existingRepositoryAdapter(args, choose) {
|
|
|
400
407
|
if (!list.repositories.length)
|
|
401
408
|
return waiting("repository_inventory_empty");
|
|
402
409
|
if (!choose)
|
|
403
|
-
return
|
|
410
|
+
return repositoryChoiceWait(list.repositories);
|
|
404
411
|
const teamId = list.repositories[0].teamId;
|
|
405
412
|
const offers = list.canRegister
|
|
406
413
|
? await offeredRepositories(ctx, journal, teamId, list.repositories, signal)
|
package/dist/onboard-ui.js
CHANGED
|
@@ -2,7 +2,7 @@ import { GITHUB_CONFIRM_ACCESS } from "./onboard-github-copy.js";
|
|
|
2
2
|
import { COMPUTER_CHECKS, standalonePlan, standalonePlanNotes, pendingContinuation, } from "./onboard-standalone-copy.js";
|
|
3
3
|
import { trackedPrompt } from "./setup-prompt-frame.js";
|
|
4
4
|
import { createOnboardInterrupts } from "./onboard-interrupts.js";
|
|
5
|
-
import { CLOUD_WORK, setupStepView, CONNECTION_REPAIR_REASONS, setupFinalScreen, setupBrowserInstruction, setupCommandWords as say, setupWaitsOnAdmin, TIMED_OUT_REASONS, setupPartHeading, setupPartProgress, setupTrackerRows, SETUP_PART_NUMBERS, SETUP_PART_OF, SETUP_PARTS, } from "./setup-onboard-copy.js";
|
|
5
|
+
import { CLOUD_WORK, setupStepView, CONNECTION_REPAIR_REASONS, setupFinalScreen, setupBrowserInstruction, setupCommandWords as say, setupWaitsOnAdmin, TIMED_OUT_REASONS, setupPartHeading, setupPartProgress, setupTrackerRows, unattendedSkipLines, SETUP_PART_NUMBERS, SETUP_PART_OF, SETUP_PARTS, } from "./setup-onboard-copy.js";
|
|
6
6
|
import { createOnboardProgress, } from "./onboard-progress.js";
|
|
7
7
|
import { CREATE_TEAM_CHOICE, suggestTeamKey, teamKeyProblem, teamNameProblem, } from "./onboard-team-create.js";
|
|
8
8
|
import { FIRST_TICKET_SAMPLE, FIRST_TICKET_SKIP, } from "./onboard-first-ticket.js";
|
|
@@ -297,6 +297,10 @@ export function createClackOnboardUi(prompts, streams, deps = {}) {
|
|
|
297
297
|
if (screen.paused)
|
|
298
298
|
renderer.line("Your progress is saved.");
|
|
299
299
|
renderer.tracker(setupTrackerRows(setupPartProgress(journal, planned.scope), "end"));
|
|
300
|
+
// CTC-5932: with no one to ask, setup says what it left out instead of letting it pass unsaid.
|
|
301
|
+
if (!interactive && !screen.paused)
|
|
302
|
+
for (const line of unattendedSkipLines(journal, planned))
|
|
303
|
+
renderer.line(line);
|
|
300
304
|
if (screen.readinessCause)
|
|
301
305
|
renderer.line(say(screen.readinessCause));
|
|
302
306
|
if (mine.length) {
|
package/dist/onboard.js
CHANGED
|
@@ -298,6 +298,9 @@ function normalizeStep(value, fallbackAt) {
|
|
|
298
298
|
"passed",
|
|
299
299
|
"team",
|
|
300
300
|
"teamKey",
|
|
301
|
+
// CTC-5932: the comma-joined choices a run with no one to ask names in its flag advice.
|
|
302
|
+
"choices",
|
|
303
|
+
"choiceTotal",
|
|
301
304
|
"selected",
|
|
302
305
|
"hostName",
|
|
303
306
|
"supervisorImage",
|
package/dist/published.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// reason instead of throwing: a customer who cannot reach npm must still get a verdict (CTC-2160).
|
|
5
5
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { dirname } from "node:path";
|
|
7
|
+
import { readApprovalCache, supportedReleaseLine } from "./approved-release.js";
|
|
7
8
|
import { LEGACY_PACKAGE_NAME, publishedCachePathFor } from "./config.js";
|
|
8
9
|
/** CTC-3479 — asks about the forwarder package whichever package is installed. The release publishes
|
|
9
10
|
* @catalyst-cloud/cli first and the forwarder last, at one version, so the forwarder's `latest`
|
|
@@ -77,3 +78,17 @@ export async function latestPublishedVersion(ctx, opts = {}) {
|
|
|
77
78
|
writePublishedCache(ctx.home, { fetchedAt: ctx.now().toISOString(), latest });
|
|
78
79
|
return { latest, reason: null, source: "network" };
|
|
79
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* CTC-6708 — the CLI's "newest" answer is the approved release once this install has read one: every
|
|
83
|
+
* applying release check records it, the 15-minute schedule included. Reading it is local, so this
|
|
84
|
+
* adds no network call. An install that has never read an approved release, or read one on another
|
|
85
|
+
* train, falls back to the npm lookup above, and its answer stays labelled the latest published version.
|
|
86
|
+
*/
|
|
87
|
+
export async function newestCliVersion(ctx, opts = {}) {
|
|
88
|
+
const approved = readApprovalCache(ctx.home)?.release;
|
|
89
|
+
const cli = approved?.members.cli;
|
|
90
|
+
// CTC-6741: a release on another train is never applied, so it is never the answer either.
|
|
91
|
+
if (approved && cli && approved.releaseLine === supportedReleaseLine())
|
|
92
|
+
return { latest: cli, reason: null, source: "approved", ...(approved.skills.cloud ? { skills: approved.skills.cloud.version } : {}) };
|
|
93
|
+
return latestPublishedVersion(ctx, opts);
|
|
94
|
+
}
|
package/dist/ready.js
CHANGED
|
@@ -6,7 +6,7 @@ import { basename, delimiter, dirname, join } from "node:path";
|
|
|
6
6
|
import { DEFAULT_BASE_URL, LEGACY_PACKAGE_NAME, PACKAGE_NAME, defaultSkillsDirFor, loadConfig, normalizeBaseUrl, readManifest, upgradeCommand, } from "./config.js";
|
|
7
7
|
import { contractVersionInRange, readContractCache } from "./contract.js";
|
|
8
8
|
import { CliError } from "./errors.js";
|
|
9
|
-
import {
|
|
9
|
+
import { newestCliVersion } from "./published.js";
|
|
10
10
|
import { replicaStatus, writerIsRunning, } from "./replica.js";
|
|
11
11
|
import { loadHttpSdk } from "./sdk.js";
|
|
12
12
|
import { FIX_COMMAND, detectRuntime, runtimeVerdict, supportedRangeText, } from "./runtime.js";
|
|
@@ -234,7 +234,7 @@ export async function readyReport(ctx, deps) {
|
|
|
234
234
|
const skillsDir = cfg?.skillsDir ?? defaultSkillsDirFor(ctx.home);
|
|
235
235
|
const offline = deps.offline === true || ctx.env.CATALYST_SKILLS_OFFLINE === "1";
|
|
236
236
|
if (!offline) {
|
|
237
|
-
const pub = await (deps.fetchLatestRelease ?? (() =>
|
|
237
|
+
const pub = await (deps.fetchLatestRelease ?? (() => newestCliVersion(ctx)))();
|
|
238
238
|
if (pub.latest === null) {
|
|
239
239
|
checks.push({
|
|
240
240
|
id: "cliRelease",
|
|
@@ -252,7 +252,10 @@ export async function readyReport(ctx, deps) {
|
|
|
252
252
|
id: "cliRelease",
|
|
253
253
|
ok: false,
|
|
254
254
|
note: true,
|
|
255
|
-
|
|
255
|
+
// CTC-6708 — once this install has read an approved release, that is the answer.
|
|
256
|
+
line: pub.source === "approved"
|
|
257
|
+
? `cliRelease: ${installedCli} installed is older than the approved ${pub.latest} — update: catalyst update`
|
|
258
|
+
: `cliRelease: ${installedCli} installed is older than the latest published ${pub.latest} — upgrade: ${upgradeCommand()}`,
|
|
256
259
|
});
|
|
257
260
|
}
|
|
258
261
|
// clause 1 — the skill files on disk, read independently of whichever CLI is answering. Both
|
|
@@ -262,7 +265,8 @@ export async function readyReport(ctx, deps) {
|
|
|
262
265
|
// older than any stamp, which is why the stamped half says "oldest STAMPED" and the unstamped
|
|
263
266
|
// names are always printed rather than hidden behind whichever branch happened to win.
|
|
264
267
|
const found = installedBundleVersion(skillsDir, deps.skillNames);
|
|
265
|
-
const
|
|
268
|
+
const skillsTarget = pub.source === "approved" ? (pub.skills ?? pub.latest) : pub.latest;
|
|
269
|
+
const stampBehind = found.version !== null && semverOlder(found.version, skillsTarget);
|
|
266
270
|
const unstampedBehind = found.unstamped.length > 0 &&
|
|
267
271
|
!semverOlder(pub.latest, FIRST_STAMPED_VERSION);
|
|
268
272
|
if (stampBehind || unstampedBehind) {
|
|
@@ -277,7 +281,9 @@ export async function readyReport(ctx, deps) {
|
|
|
277
281
|
id: "skillsRelease",
|
|
278
282
|
ok: false,
|
|
279
283
|
note: true,
|
|
280
|
-
line:
|
|
284
|
+
line: pub.source === "approved"
|
|
285
|
+
? `skillsRelease: in ${skillsDir} ${facts.join(", and ")}, while the approved bundle is ${skillsTarget} — update: catalyst update`
|
|
286
|
+
: `skillsRelease: in ${skillsDir} ${facts.join(", and ")}, while the published bundle is ${pub.latest} — update: npx skills update -y`,
|
|
281
287
|
});
|
|
282
288
|
}
|
|
283
289
|
}
|
package/dist/replica.js
CHANGED
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
// `status` is the check every skill runs first and it needs no network and no SDK: the pidfile, the
|
|
4
4
|
// writer-lock heartbeat, and the `sync_meta.cursor` row (read through node:sqlite read-only). Exit
|
|
5
5
|
// 0 fresh, 1 present but stale, 2 not configured, 3 absent. `--probe` adds the one network call.
|
|
6
|
-
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { existsSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
|
7
7
|
import { createRequire } from "node:module";
|
|
8
|
+
import { join } from "node:path";
|
|
8
9
|
import { flagBool, flagInt, flagString, positionals } from "./args.js";
|
|
9
|
-
import { apiBase, loadConfig, replicaDbPath, replicaOptedIn } from "./config.js";
|
|
10
|
+
import { apiBase, configDirFor, loadConfig, readManifest, replicaDbPath, replicaOptedIn } from "./config.js";
|
|
10
11
|
import { detachSelf } from "./detach.js";
|
|
11
12
|
import { CliError, UsageError } from "./errors.js";
|
|
12
13
|
import { apiClient } from "./transport.js";
|
|
@@ -62,7 +63,8 @@ export function readWriterState(dbPath) {
|
|
|
62
63
|
typeof rec.consecutiveFailures !== "number" ||
|
|
63
64
|
!(rec.lastError === null || typeof rec.lastError === "string") ||
|
|
64
65
|
!(rec.lastFailureAt === null || typeof rec.lastFailureAt === "number") ||
|
|
65
|
-
!stoppedOk
|
|
66
|
+
!stoppedOk ||
|
|
67
|
+
!(rec.version === undefined || typeof rec.version === "string")) {
|
|
66
68
|
return null;
|
|
67
69
|
}
|
|
68
70
|
return rec;
|
|
@@ -72,7 +74,7 @@ export function readWriterState(dbPath) {
|
|
|
72
74
|
}
|
|
73
75
|
}
|
|
74
76
|
export function writeWriterState(dbPath, s, nowMs) {
|
|
75
|
-
writeFileSync(writerStatePath(dbPath), JSON.stringify({ ...s, updatedAt: nowMs }));
|
|
77
|
+
writeFileSync(writerStatePath(dbPath), JSON.stringify({ ...s, version: readManifest().version, updatedAt: nowMs }));
|
|
76
78
|
}
|
|
77
79
|
/** Remove the writer state sidecar; a no-op (never throws) when it is already gone. */
|
|
78
80
|
export function clearWriterState(dbPath) {
|
|
@@ -83,6 +85,25 @@ export function clearWriterState(dbPath) {
|
|
|
83
85
|
// already gone
|
|
84
86
|
}
|
|
85
87
|
}
|
|
88
|
+
/** A restart a release check began and has not finished (update-replica.ts, CTC-6708). The owner's
|
|
89
|
+
* own `replica stop` clears it, so their stop is never undone. */
|
|
90
|
+
export function pendingRestartPathFor(home) {
|
|
91
|
+
return join(configDirFor(home), "replica-restart.json");
|
|
92
|
+
}
|
|
93
|
+
/** The restart a release check began for `dbPath` and has not confirmed, with the CLI version it
|
|
94
|
+
* expects the new writer to run when the record names one. */
|
|
95
|
+
export function pendingRestart(home, dbPath) {
|
|
96
|
+
try {
|
|
97
|
+
const raw = JSON.parse(readFileSync(pendingRestartPathFor(home), "utf8"));
|
|
98
|
+
if (typeof raw !== "object" || raw === null || raw.dbPath !== dbPath)
|
|
99
|
+
return null;
|
|
100
|
+
const target = raw.target;
|
|
101
|
+
return { dbPath, target: typeof target === "string" ? target : null };
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
86
107
|
export function pidAlive(pid) {
|
|
87
108
|
if (pid === null || !Number.isInteger(pid) || pid <= 0)
|
|
88
109
|
return false;
|
|
@@ -348,7 +369,15 @@ async function cmdStatus(args, ctx) {
|
|
|
348
369
|
status.lag = status.cursor === null ? head.body.cursor : head.body.cursor - status.cursor;
|
|
349
370
|
}
|
|
350
371
|
}
|
|
351
|
-
|
|
372
|
+
const restart = status.dbPath !== null && !writerIsRunning(status) ? pendingRestart(ctx.home, status.dbPath) : null;
|
|
373
|
+
if (args.json)
|
|
374
|
+
ctx.stdout(JSON.stringify(restart ? { ...status, pendingRestart: restart } : status));
|
|
375
|
+
else {
|
|
376
|
+
ctx.stdout(statusLine(status));
|
|
377
|
+
// CTC-6743: a writer a release check stopped and restarted is down for that reason, not its owner's.
|
|
378
|
+
if (restart)
|
|
379
|
+
ctx.stdout(` a release check restarted it${restart.target ? ` on CLI ${restart.target}` : ""} and the new writer is not running; its synced data is kept at ${status.dbPath}, and the next release check starts it again`);
|
|
380
|
+
}
|
|
352
381
|
return status.exitCode;
|
|
353
382
|
}
|
|
354
383
|
async function cmdStart(args, ctx, cfg, dbPath, deps) {
|
|
@@ -382,7 +411,9 @@ async function cmdStart(args, ctx, cfg, dbPath, deps) {
|
|
|
382
411
|
if (recordOwned)
|
|
383
412
|
return;
|
|
384
413
|
recordOwned = true;
|
|
385
|
-
|
|
414
|
+
// CTC-6743: a fresh, healthy record rather than none, so a release check can read the CLI
|
|
415
|
+
// version this writer runs without waiting for a failed pull to write one.
|
|
416
|
+
writeWriterState(dbPath, { pid: process.pid, consecutiveFailures: 0, lastError: null, lastFailureAt: null, stopped: null }, ctx.now().getTime());
|
|
386
417
|
};
|
|
387
418
|
const resetFailures = () => {
|
|
388
419
|
if (failures === 0)
|
|
@@ -413,6 +444,11 @@ async function cmdStart(args, ctx, cfg, dbPath, deps) {
|
|
|
413
444
|
log: (level, msg, extra) => ctx.stderr(`[replica ${level}] ${msg}${extra ? ` ${safeJson(extra)}` : ""}`),
|
|
414
445
|
onStatus: (s) => {
|
|
415
446
|
ctx.stderr(`[replica] status=${s}`);
|
|
447
|
+
// CTC-6743: a status means start() already holds the writer lock, so this run owns the
|
|
448
|
+
// record. Taking it here, not after a long first seed, means a release check never reads a
|
|
449
|
+
// seeding writer as one with no version and restarts it every interval.
|
|
450
|
+
if (s === "resyncing" || s === "connecting")
|
|
451
|
+
takeOwnershipOfRecord();
|
|
416
452
|
// A "live" that does NOT follow a re-seed is just the socket coming up — the warm failing
|
|
417
453
|
// loop passes through it every cycle, so it must not reset the count (CTC-2499).
|
|
418
454
|
if (s === "resyncing") {
|
|
@@ -544,6 +580,7 @@ async function cmdStart(args, ctx, cfg, dbPath, deps) {
|
|
|
544
580
|
return 0;
|
|
545
581
|
}
|
|
546
582
|
function cmdStop(ctx, dbPath) {
|
|
583
|
+
rmSync(pendingRestartPathFor(ctx.home), { force: true });
|
|
547
584
|
const pid = readPidfile(dbPath);
|
|
548
585
|
if (pid === null) {
|
|
549
586
|
ctx.stdout(`no pidfile at ${pidfilePath(dbPath)} — nothing to stop (a foreground writer is stopped with Ctrl-C)`);
|