@join-pantheon/cli 0.33.5 → 0.33.6-rc.1

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/dist/tray.js CHANGED
@@ -11,6 +11,8 @@ import { plistPath, serviceInstalled, whichPantheon } from "./service.js";
11
11
  import { VERSION } from "./version.js";
12
12
  import { LIMITS } from "./protocol.js";
13
13
  import { signedRead } from "./reads.js";
14
+ import { clearRemoval, recordRemoval, removalFor, removalHolds, removalRefusal } from "./removed.js";
15
+ import { pickupEstimate } from "./pickup.js";
14
16
  import { allDecisionsPrompt, decisionPrompt, decisionTitle, owedPrompt, stripCeremony, tidyIds, tidyLead, waitingPrompt, workerSummary } from "./decision-ui.js";
15
17
  import { loadProfile, SURFACE_BUNDLE_IDS } from "./profile.js";
16
18
  const HERE = dirname(fileURLToPath(import.meta.url));
@@ -116,7 +118,8 @@ function workerState(home = CONFIG_DIR) {
116
118
  const raw = JSON.parse(readFileSync(join(home, "worker-state.json"), "utf8"));
117
119
  if (!raw.at || Date.now() - raw.at > 10 * 60_000)
118
120
  return undefined;
119
- return raw.detail ? `${raw.state} — ${raw.detail}` : raw.state;
121
+ const line = (x) => String(x ?? "").replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, " ").replace(/\s+/g, " ").trim().slice(0, 160);
122
+ return raw.detail ? `${line(raw.state)} — ${line(raw.detail)}` : line(raw.state);
120
123
  }
121
124
  catch {
122
125
  return undefined;
@@ -136,13 +139,29 @@ async function getJson(url) {
136
139
  clearTimeout(t);
137
140
  }
138
141
  }
139
- async function signedObligations(cfg) {
142
+ async function signedObligations(cfg, removed = null) {
140
143
  try {
141
144
  const res = await signedRead(cfg, "/obligations", {}, { timeoutMs: LIMITS.pendingTimeoutMs });
142
- return res.ok ? await res.json() : null;
145
+ let refusedAsRemoved = false;
146
+ if (removed) {
147
+ const team = { space: cfg.space, spaceId: cfg.spaceId ?? null, relay: cfg.relay, member: cfg.member };
148
+ try {
149
+ if (res.status === 403) {
150
+ const why = await res.clone().json().catch(() => null);
151
+ if (why?.code === "REVOKED") {
152
+ refusedAsRemoved = true;
153
+ recordRemoval(team, removalRefusal(`REVOKED: ${why.error ?? ""} Only a member`)?.by ?? removed.by);
154
+ }
155
+ }
156
+ else if (res.ok)
157
+ clearRemoval(team);
158
+ }
159
+ catch { }
160
+ }
161
+ return { body: res.ok ? await res.json() : null, refusedAsRemoved };
143
162
  }
144
163
  catch {
145
- return null;
164
+ return { body: null, refusedAsRemoved: false };
146
165
  }
147
166
  }
148
167
  export async function trayStatus(cfg = load(), f = loadAll()) {
@@ -160,6 +179,7 @@ export async function trayStatus(cfg = load(), f = loadAll()) {
160
179
  relay_ok: ok.every((p) => p.relay_ok),
161
180
  decisions_ok: ok.every((p) => p.decisions_ok),
162
181
  unavailable_spaces: ok.flatMap((p) => p.unavailable_spaces ?? []),
182
+ removed_spaces: ok.flatMap((p) => p.removed_spaces ?? []),
163
183
  online: ok.reduce((n, p) => n + (p.online ?? 0), 0),
164
184
  members: ok.reduce((n, p) => n + (p.members ?? 0), 0),
165
185
  decisions: ok.flatMap((p) => (p.decisions ?? []).map((d) => ({ ...d, body: `[${p.space}] ${d.body}` }))),
@@ -175,10 +195,24 @@ async function trayStatusOne(cfg) {
175
195
  if (!cfg)
176
196
  return { configured: false, version: VERSION };
177
197
  const base = cfg.relay.replace(/^ws/, "http").replace(/\/+$/, "");
178
- const [health, obligations] = await Promise.all([
198
+ const record = (() => { try {
199
+ return removalFor({ space: cfg.space, spaceId: cfg.spaceId ?? null, relay: cfg.relay, member: cfg.member });
200
+ }
201
+ catch {
202
+ return null;
203
+ } })();
204
+ const removed = removalHolds(record);
205
+ const [health, probe] = await Promise.all([
179
206
  getJson(`${base}/health?space=${encodeURIComponent(cfg.space)}`),
180
- signedObligations(cfg),
207
+ removed ? Promise.resolve({ body: null, refusedAsRemoved: false }) : signedObligations(cfg, record),
181
208
  ]);
209
+ const obligations = probe.body;
210
+ const rosterRaw = obligations?.roster;
211
+ const roster = Array.isArray(rosterRaw) ? rosterRaw : null;
212
+ const membersRaw = obligations?.members;
213
+ const myMayWake = Array.isArray(membersRaw) ? membersRaw.find((m) => m.member === cfg.member)?.may_wake : undefined;
214
+ const relayAt = obligations?.now;
215
+ const relayNow = typeof relayAt === "number" && Number.isFinite(relayAt) ? relayAt : Date.now();
182
216
  const space = health
183
217
  ?.spaces?.find((s) => s.name === cfg.space);
184
218
  const pending = obligations?.obligations;
@@ -213,7 +247,10 @@ async function trayStatusOne(cfg) {
213
247
  ...owed.map((o) => row(o, "owed", "you", owedPrompt(decisionTitle(o.body), o.goal))),
214
248
  ...waiting.map((o) => {
215
249
  const who = o.owner.replace(/^human:/, "");
216
- const r = row(o, "waiting", who, waitingPrompt(who, decisionTitle(o.body), o.goal));
250
+ const r0 = row(o, "waiting", who, waitingPrompt(who, decisionTitle(o.body), o.goal));
251
+ const r = roster && !o.owner.startsWith("human:") && o.kind !== "DELIVER"
252
+ ? { ...r0, pickup: pickupEstimate(roster.find((m) => m.id === who) ?? { id: who, sessions: [] }, { now: relayNow, about: { ref: o.ref ?? null, goal: o.goal ?? null }, ...(myMayWake !== undefined ? { mayWake: myMayWake } : {}) }).says }
253
+ : r0;
217
254
  return o.owner.startsWith("human:")
218
255
  ? { ...r, title: `${who} decides: ${r.title}`, headline: `${who} decides: ${r.headline}`, decider: who }
219
256
  : r;
@@ -235,6 +272,7 @@ async function trayStatusOne(cfg) {
235
272
  relay_ok: !!space,
236
273
  decisions_ok: decisionsOk,
237
274
  unavailable_spaces: !space || !decisionsOk ? [cfg.space] : [],
275
+ ...(removed || probe.refusedAsRemoved ? { removed_spaces: [cfg.space] } : {}),
238
276
  online: space?.online ?? 0,
239
277
  members: space?.members ?? 0,
240
278
  worker_running: workerRunning,
package/dist/usage.js ADDED
@@ -0,0 +1,97 @@
1
+ export const USAGE_KEEP_DAYS = 400;
2
+ export const USAGE_SHOW_DAYS = 30;
3
+ const DAY_MS = 24 * 60 * 60 * 1_000;
4
+ export const emptyUsage = (space) => ({ v: 1, ...(space ? { space } : {}), days: {} });
5
+ const emptyDay = () => ({ acts: {}, by_worker: 0, by_person: 0, goals_opened: 0, goals_closed: 0 });
6
+ export const dayKey = (ts) => new Date(ts).toISOString().slice(0, 10);
7
+ const count = (n) => (typeof n === "number" && Number.isFinite(n) && n >= 0 ? Math.floor(n) : 0);
8
+ const ACT_NAME = /^[A-Z_]{1,32}$/;
9
+ const DAY_NAME = /^\d{4}-\d{2}-\d{2}$/;
10
+ export function parseUsage(raw) {
11
+ const book = emptyUsage();
12
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
13
+ return book;
14
+ const r = raw;
15
+ if (typeof r.space === "string" && r.space)
16
+ book.space = r.space;
17
+ if (!r.days || typeof r.days !== "object" || Array.isArray(r.days))
18
+ return book;
19
+ for (const [day, v] of Object.entries(r.days)) {
20
+ if (!DAY_NAME.test(day) || !v || typeof v !== "object")
21
+ continue;
22
+ const d = v;
23
+ const acts = {};
24
+ if (d.acts && typeof d.acts === "object" && !Array.isArray(d.acts)) {
25
+ for (const [a, n] of Object.entries(d.acts))
26
+ if (ACT_NAME.test(a) && count(n))
27
+ acts[a] = count(n);
28
+ }
29
+ book.days[day] = { acts, by_worker: count(d.by_worker), by_person: count(d.by_person), goals_opened: count(d.goals_opened), goals_closed: count(d.goals_closed) };
30
+ }
31
+ return book;
32
+ }
33
+ export function pruneUsage(book, now = Date.now()) {
34
+ const oldest = dayKey(now - (USAGE_KEEP_DAYS - 1) * DAY_MS);
35
+ for (const day of Object.keys(book.days))
36
+ if (day < oldest)
37
+ delete book.days[day];
38
+ }
39
+ function today(book, now) {
40
+ const k = dayKey(now);
41
+ let d = book.days[k];
42
+ if (!d) {
43
+ d = book.days[k] = emptyDay();
44
+ pruneUsage(book, now);
45
+ }
46
+ return d;
47
+ }
48
+ export function countMessage(book, act, role, now = Date.now()) {
49
+ const d = today(book, now);
50
+ d.acts[act] = (d.acts[act] ?? 0) + 1;
51
+ if (role === "worker")
52
+ d.by_worker++;
53
+ else
54
+ d.by_person++;
55
+ }
56
+ export function countGoal(book, what, now = Date.now()) {
57
+ const d = today(book, now);
58
+ if (what === "opened")
59
+ d.goals_opened++;
60
+ else
61
+ d.goals_closed++;
62
+ }
63
+ export function usageRows(book, days = USAGE_SHOW_DAYS, now = Date.now()) {
64
+ const n = Math.max(1, Math.min(USAGE_KEEP_DAYS, Math.floor(days) || USAGE_SHOW_DAYS));
65
+ const oldest = dayKey(now - (n - 1) * DAY_MS);
66
+ return Object.entries(book.days)
67
+ .filter(([day]) => day >= oldest && day <= dayKey(now))
68
+ .sort(([a], [b]) => (a < b ? 1 : a > b ? -1 : 0))
69
+ .map(([day, d]) => ({ day, ...d, acts: { ...d.acts }, messages: Object.values(d.acts).reduce((s, x) => s + x, 0) }));
70
+ }
71
+ export function usageAnswer(space, book, days = USAGE_SHOW_DAYS, now = Date.now()) {
72
+ return { space, days: usageRows(book, days, now), kept_days: USAGE_KEEP_DAYS };
73
+ }
74
+ export function usageTable(space, rows, shownDays = USAGE_SHOW_DAYS) {
75
+ const out = [];
76
+ out.push(`Usage of "${space}" — the last ${shownDays} days (UTC), counted by the relay. Counts only: no message content, nothing per person.`);
77
+ if (!rows.length) {
78
+ out.push("", "Nothing counted in that time. (Counting started when the relay was upgraded to 0.33.6.)");
79
+ return out.join("\n");
80
+ }
81
+ const head = ["day", "messages", "by runs", "by people", "goals opened", "goals closed"];
82
+ const lines = rows.map((r) => [r.day, String(r.messages), String(r.by_worker), String(r.by_person), String(r.goals_opened), String(r.goals_closed)]);
83
+ const sum = (k) => rows.reduce((s, r) => s + r[k], 0);
84
+ const total = ["total", String(sum("messages")), String(sum("by_worker")), String(sum("by_person")), String(sum("goals_opened")), String(sum("goals_closed"))];
85
+ const width = head.map((h, i) => Math.max(h.length, ...lines.map((l) => l[i].length), total[i].length));
86
+ const fmt = (cells) => cells.map((c, i) => (i === 0 ? c.padEnd(width[i]) : c.padStart(width[i]))).join(" ");
87
+ out.push("", fmt(head), ...lines.map(fmt), fmt(width.map((w) => "-".repeat(w))), fmt(total));
88
+ const byAct = {};
89
+ for (const r of rows)
90
+ for (const [a, n] of Object.entries(r.acts))
91
+ byAct[a] = (byAct[a] ?? 0) + n;
92
+ const kinds = Object.entries(byAct).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
93
+ if (kinds.length)
94
+ out.push("", `messages by type: ${kinds.map(([a, n]) => `${a} ${n}`).join(", ")}`);
95
+ return out.join("\n");
96
+ }
97
+ //# sourceMappingURL=usage.js.map
package/dist/which.js CHANGED
@@ -83,6 +83,7 @@ function posixOnPath(name, env, ok = runnable) {
83
83
  }
84
84
  return null;
85
85
  }
86
+ export const loginShellRunner = (timeoutMs) => (script, args) => runLoginShell(script, args, timeoutMs);
86
87
  export function loginShellLookup(name, run = runLoginShell) {
87
88
  if (!name)
88
89
  return null;
@@ -108,9 +109,9 @@ export function parseLoginAnswer(out, mark) {
108
109
  const found = said.length === 1 && said[0].startsWith("/") ? said[0] : null;
109
110
  return { found, path };
110
111
  }
111
- function runLoginShell(script, args) {
112
+ function runLoginShell(script, args, timeoutMs = 15_000) {
112
113
  try {
113
- return execFileSync("sh", ["-lc", script, "sh", ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 15_000 });
114
+ return execFileSync("sh", ["-lc", script, "sh", ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: timeoutMs, killSignal: "SIGKILL" });
114
115
  }
115
116
  catch (e) {
116
117
  const out = e.stdout;
@@ -4,6 +4,7 @@ import { holdsRepo, repoInside } from "./repo-find.js";
4
4
  import { CONFIG_DIR, configFor, loadAll, repoInfo } from "./config.js";
5
5
  import { Worker, workspaceOf } from "./worker.js";
6
6
  import { folderReaderLine } from "./agents.js";
7
+ import { removalFor, removalHolds, removalWords, removedLine, removedNotice, takeTold } from "./removed.js";
7
8
  export class WorkerFleet {
8
9
  opts;
9
10
  dupSaid = new Set();
@@ -57,10 +58,12 @@ export class WorkerFleet {
57
58
  this.stopKeeper();
58
59
  else
59
60
  this.startKeeper(f);
60
- this.alertChecks(f, Object.values(f.spaces ?? {}).filter((m) => (idCount.get(m.id) ?? 0) === 1 && !wanted.has(m.id) && this.keeper?.id !== m.id));
61
+ this.alertChecks(f, Object.values(f.spaces ?? {}).filter((m) => (idCount.get(m.id) ?? 0) === 1 && !wanted.has(m.id) && this.keeper?.id !== m.id && !this.removedFrom(configFor(f, m, null))));
61
62
  for (const [id, w] of wanted) {
62
63
  if (this.slots.has(id))
63
64
  continue;
65
+ if (this.removedFrom(w.cfg))
66
+ continue;
64
67
  const home = this.opts.home ?? CONFIG_DIR;
65
68
  const { pollMs: _p, readConfig: _r, makeWorker: _m, home: _h, alertsEveryMs: _a, ...rest } = this.opts;
66
69
  const wopts = {
@@ -90,9 +93,36 @@ export class WorkerFleet {
90
93
  }
91
94
  return { started, stopped };
92
95
  }
96
+ removedFrom(cfg) {
97
+ let r = null;
98
+ const team = { space: cfg.space, spaceId: cfg.spaceId ?? null, relay: cfg.relay, member: cfg.member };
99
+ try {
100
+ r = removalFor(team);
101
+ }
102
+ catch {
103
+ return false;
104
+ }
105
+ if (!removalHolds(r)) {
106
+ if (r)
107
+ this.removedSaid.delete(r.team);
108
+ return false;
109
+ }
110
+ if (!this.removedSaid.has(r.team)) {
111
+ this.removedSaid.add(r.team);
112
+ const words = removalWords(team, r.by);
113
+ this.opts.log(`fleet: [${cfg.space}] ${removedLine(words)}`);
114
+ try {
115
+ if (this.opts.notify && takeTold(team))
116
+ this.opts.notify(removedNotice(words));
117
+ }
118
+ catch { }
119
+ }
120
+ return true;
121
+ }
122
+ removedSaid = new Set();
93
123
  startKeeper(f) {
94
- const teams = Object.values(f.spaces ?? {});
95
- const m = (f.default_space ? f.spaces?.[f.default_space] : undefined) ?? teams[0];
124
+ const teams = Object.values(f.spaces ?? {}).filter((t) => !this.removedFrom(configFor(f, t, null)));
125
+ const m = (f.default_space ? teams.find((t) => t.id === f.default_space) : undefined) ?? teams[0];
96
126
  if (!m) {
97
127
  this.stopKeeper();
98
128
  return;
@@ -4,6 +4,53 @@ import { CONFIG_PATH } from "./config.js";
4
4
  import { trustWrite } from "./home-guard.js";
5
5
  import { parseJsonText } from "./text-file.js";
6
6
  import { compareVersions, isVersion } from "./installer.js";
7
+ export const IDLE_SECONDS = { default: 120, min: 0, max: 900 };
8
+ export function parseIdleSeconds(raw) {
9
+ if (raw === undefined || raw === null)
10
+ return null;
11
+ const s = String(raw).trim();
12
+ if (!/^\d+$/.test(s))
13
+ return null;
14
+ const n = Number(s);
15
+ return n >= IDLE_SECONDS.min && n <= IDLE_SECONDS.max ? n : null;
16
+ }
17
+ export function idleSecondsProblem(raw) {
18
+ return `"${raw ?? ""}" is not an idle wait — give a whole number of seconds from ${IDLE_SECONDS.min} to ${IDLE_SECONDS.max} (the default is ${IDLE_SECONDS.default}; back to it: pantheon policy set idle-seconds default)`;
19
+ }
20
+ export function effectiveIdleSeconds(o) {
21
+ if (o.flag !== undefined)
22
+ return { seconds: o.flag, from: "flag" };
23
+ if (o.policy?.idleSeconds !== undefined)
24
+ return { seconds: o.policy.idleSeconds, from: "policy" };
25
+ return { seconds: IDLE_SECONDS.default, from: "default" };
26
+ }
27
+ export function describeIdleSeconds(s) {
28
+ return s === 0 ? "at once" : s % 60 === 0 ? `${s / 60} minute${s === 60 ? "" : "s"}` : `${s} seconds`;
29
+ }
30
+ export const RUN_MINUTES = { default: 60, min: 5, max: 180 };
31
+ export function parseRunMinutes(raw) {
32
+ if (raw === undefined || raw === null)
33
+ return null;
34
+ const s = String(raw).trim();
35
+ if (!/^\d+$/.test(s))
36
+ return null;
37
+ const n = Number(s);
38
+ return n >= RUN_MINUTES.min && n <= RUN_MINUTES.max ? n : null;
39
+ }
40
+ export function runMinutesProblem(raw) {
41
+ return `"${raw ?? ""}" is not a run limit — give a whole number of minutes from ${RUN_MINUTES.min} to ${RUN_MINUTES.max} (the default is ${RUN_MINUTES.default}; back to it: pantheon policy set run-minutes default)`;
42
+ }
43
+ export function effectiveRunMinutes(o) {
44
+ const machine = o.flag !== undefined ? { minutes: o.flag, from: "flag" }
45
+ : o.policy?.runMinutes !== undefined ? { minutes: o.policy.runMinutes, from: "policy" }
46
+ : { minutes: RUN_MINUTES.default, from: "default" };
47
+ return o.persona !== undefined && o.persona < machine.minutes ? { minutes: o.persona, from: "persona" } : machine;
48
+ }
49
+ export function describeRunMinutes(r, persona) {
50
+ const why = r.from === "default" ? "the default" : r.from === "policy" ? "set on this machine"
51
+ : r.from === "flag" ? "this worker's --run-minutes" : `lowered by persona ${persona ?? ""}`.trim();
52
+ return `${r.minutes} minutes per run (${why})`;
53
+ }
7
54
  export const WAKE_NO_MS = 30 * 24 * 60 * 60 * 1_000;
8
55
  export const wakeKey = (team, member) => `${team}|${member}`;
9
56
  export function mayAskWake(p, teams, member, now = Date.now()) {
@@ -41,6 +88,15 @@ export function policyHome(home) {
41
88
  export function policyPath(home) {
42
89
  return join(policyHome(home), "worker-policy.json");
43
90
  }
91
+ export function readinessRecheckPath(home) {
92
+ return join(policyHome(home), "readiness-recheck.stamp");
93
+ }
94
+ export function requestReadinessRecheck(home) {
95
+ try {
96
+ writeFileSync(readinessRecheckPath(home), `${Date.now()}\n`, { mode: 0o600 });
97
+ }
98
+ catch { }
99
+ }
44
100
  const isRecord = (x) => !!x && typeof x === "object" && !Array.isArray(x);
45
101
  function readWakers(x) {
46
102
  if (!isRecord(x))
@@ -71,6 +127,8 @@ export function loadPolicy(home) {
71
127
  ...(isRecord(raw.wakeTeamFlag) ? { wakeTeamFlag: Object.fromEntries(Object.entries(raw.wakeTeamFlag).filter(([, v]) => typeof v === "number")) } : {}),
72
128
  ...(isRecord(raw.upgradeHeld) && typeof raw.upgradeHeld.version === "string" && typeof raw.upgradeHeld.node === "string"
73
129
  ? { upgradeHeld: { version: raw.upgradeHeld.version, node: raw.upgradeHeld.node, at: Number(raw.upgradeHeld.at) || 0, why: String(raw.upgradeHeld.why ?? "") } } : {}),
130
+ ...(typeof raw.runMinutes === "number" && parseRunMinutes(raw.runMinutes) !== null ? { runMinutes: raw.runMinutes } : {}),
131
+ ...(typeof raw.idleSeconds === "number" && parseIdleSeconds(raw.idleSeconds) !== null ? { idleSeconds: raw.idleSeconds } : {}),
74
132
  };
75
133
  }
76
134
  catch {