bareloop 0.46.0 → 0.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/README.md +2 -0
- package/bareloop.context.md +38 -17
- package/package.json +1 -1
- package/src/authorreadout.js +47 -12
- package/src/cli.js +29 -45
- package/src/launcher.js +409 -0
- package/src/learn.js +24 -16
- package/src/learnstore.js +9 -17
- package/src/opener.js +30 -0
- package/src/panel/authorroutes.js +4 -3
- package/src/panel/authorsession.js +5 -7
- package/src/panel/index.html +13 -1
- package/src/panel/schedulestart.js +5 -0
- package/src/panel/server.js +57 -11
- package/src/panel/settingsroutes.js +3 -17
- package/src/panel/status.js +11 -0
- package/src/stagewords.js +18 -17
- package/src/tty-colour.js +27 -0
- package/src/userrun.js +2 -2
- package/types/authorreadout.d.ts +7 -0
- package/types/cli.d.ts +2 -1
- package/types/launcher.d.ts +234 -0
- package/types/learn.d.ts +8 -7
- package/types/learnstore.d.ts +4 -5
- package/types/opener.d.ts +12 -0
- package/types/panel/authorroutes.d.ts +1 -0
- package/types/panel/server.d.ts +24 -0
- package/types/panel/status.d.ts +8 -0
- package/types/stagewords.d.ts +16 -15
- package/types/tty-colour.d.ts +33 -0
package/src/launcher.js
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
// L (PANEL-BUILD.md addendum 2026-10-24) — the `bareloop` launcher: the bare-`bareloop` menu and `bareloop start | stop | status | install`.
|
|
2
|
+
//
|
|
3
|
+
// No pid file: the panel is FOUND by asking 127.0.0.1:4700..4709 for `GET /api/whoami` (src/panel/server.js), several at once, 300 ms
|
|
4
|
+
// each; a reply with `app === "bareloop"` is a panel. Every call prints the same two header lines first, from {@link buildHeader}.
|
|
5
|
+
// The run counts are the panel's own (`runsListForCount`: the `listRuns` rows' `status.key` and `needs`, plus the schedules that gave up) — never a second spelling of "running" or "needs you".
|
|
6
|
+
// This file owns no arbiter state: it starts/stops the panel process and reads the run list at $0. Every OS touch (spawn, kill, open,
|
|
7
|
+
// port bind, clock, stdin) comes through `deps` so tests inject all of them and never see a real panel.
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
existsSync, statSync, mkdirSync, chmodSync, openSync, closeSync,
|
|
11
|
+
} from 'node:fs';
|
|
12
|
+
import { createServer } from 'node:net';
|
|
13
|
+
import { spawn as realSpawn } from 'node:child_process';
|
|
14
|
+
import { dirname, join } from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import { readConfig, updateConfig, ConfigError } from './config.js';
|
|
17
|
+
import { keysHome, keysFilePath, ensureKeysFile } from './keysfile.js';
|
|
18
|
+
import { openInOs } from './opener.js';
|
|
19
|
+
import { PLAIN } from './tty-colour.js';
|
|
20
|
+
import { runsListForCount, packageVersion, panelUrl } from './panel/server.js';
|
|
21
|
+
|
|
22
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
/** the first port tried, and how many in a row (4700..4709) */
|
|
24
|
+
export const PORT_START = 4700;
|
|
25
|
+
export const PORT_COUNT = 10;
|
|
26
|
+
const PROBE_TIMEOUT_MS = 300;
|
|
27
|
+
const WAIT_DEADLINE_MS = 10_000;
|
|
28
|
+
const POLL_MS = 100;
|
|
29
|
+
const MIN_NODE_MAJOR = 22;
|
|
30
|
+
const RULE_WIDTH = 56;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @typedef {object} Panel one answered probe
|
|
34
|
+
* @property {number} port @property {number} pid @property {string} version @property {number} drafts @property {number} schedules
|
|
35
|
+
* @property {string} url
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @typedef {object} LauncherDeps every OS touch, injectable (a test passes all of them)
|
|
40
|
+
* @property {string} [home] the bareloop home (`~/.config/bareloop`); undefined = the real one
|
|
41
|
+
* @property {number} [portStart] @property {number} [portCount]
|
|
42
|
+
* @property {number[]} [ports] an explicit port list (tests); wins over portStart/portCount
|
|
43
|
+
* @property {number} [timeoutMs] probe timeout per port
|
|
44
|
+
* @property {number} [deadlineMs] start/stop wait deadline
|
|
45
|
+
* @property {string} [nodeVersion] default process.versions.node
|
|
46
|
+
* @property {typeof fetch} [fetchImpl]
|
|
47
|
+
* @property {(cmd: string, args: string[], o: object) => any} [spawnFn] default node:child_process spawn
|
|
48
|
+
* @property {(pid: number, sig: string) => void} [kill] default process.kill
|
|
49
|
+
* @property {((target: string) => Promise<boolean>)} [open] default the shared OS opener
|
|
50
|
+
* @property {(port: number) => Promise<boolean>} [portFree] default: try to bind 127.0.0.1:port
|
|
51
|
+
* @property {(ms: number) => Promise<void>} [sleep]
|
|
52
|
+
* @property {(question: string) => Promise<string>} [ask] the y/N and menu reader
|
|
53
|
+
* @property {string} [bareloopBin] default bin/bareloop.mjs of this package
|
|
54
|
+
* @property {import('./tty-colour.js').Paint} [paint] colour for stdout lines (default none); {@link makePaint} decides TTY / NO_COLOR / TERM
|
|
55
|
+
* @property {import('./tty-colour.js').Paint} [paintErr] colour for stderr lines (default: `paint`)
|
|
56
|
+
* @property {number} [now] ms clock for the run list's age rules (default: real time); tests pass their own
|
|
57
|
+
* @property {string} [platform]
|
|
58
|
+
* @property {Record<string, string|undefined>} [env] raw env handed to the panel child (default process.env)
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The run counts, from the panel's own `listRuns` rows. `status.key === 'running'` is the panel's word; `needs` is the one tag
|
|
63
|
+
* {@link import('./panel/server.js').needsStateFor} decides. A part that is 0 still shows.
|
|
64
|
+
* @param {any[]} rows
|
|
65
|
+
* @param {number} [gaveUp] schedules that gave up: the panel's Needs-you holds them too
|
|
66
|
+
* @returns {{running: number, needYou: number, total: number}}
|
|
67
|
+
*/
|
|
68
|
+
export function countRuns(rows, gaveUp = 0) {
|
|
69
|
+
let running = 0;
|
|
70
|
+
let needYou = gaveUp;
|
|
71
|
+
for (const r of rows) {
|
|
72
|
+
if (r && r.status && r.status.key === 'running') running += 1;
|
|
73
|
+
if (r && typeof r.needs === 'string' && r.needs !== '') needYou += 1;
|
|
74
|
+
}
|
|
75
|
+
return { running, needYou, total: rows.length };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* "installed ✓" = the folder exists, config.json is there and reads with no problem, `.env` exists with mode 600, node >= 22.
|
|
80
|
+
* Otherwise the FIRST missing thing in plain words.
|
|
81
|
+
* @param {{home?: string, nodeVersion?: string}} o
|
|
82
|
+
* @returns {{ok: boolean, problem: string|null}}
|
|
83
|
+
*/
|
|
84
|
+
export function installState(o) {
|
|
85
|
+
const dir = keysHome(o.home);
|
|
86
|
+
if (!existsSync(dir)) return { ok: false, problem: 'no bareloop folder yet' };
|
|
87
|
+
const cfg = readConfig({ home: o.home });
|
|
88
|
+
if (cfg.problem) return { ok: false, problem: 'config.json cannot be read' };
|
|
89
|
+
if (!cfg.exists) return { ok: false, problem: 'no settings file yet' };
|
|
90
|
+
const env = keysFilePath(o.home);
|
|
91
|
+
if (!existsSync(env)) return { ok: false, problem: 'no keys file yet' };
|
|
92
|
+
try {
|
|
93
|
+
if ((statSync(env).mode & 0o077) !== 0) return { ok: false, problem: 'the keys file is readable by other users' };
|
|
94
|
+
} catch { return { ok: false, problem: 'the keys file cannot be read' }; }
|
|
95
|
+
const major = Number((o.nodeVersion ?? process.versions.node).split('.')[0]);
|
|
96
|
+
if (!(major >= MIN_NODE_MAJOR)) return { ok: false, problem: `node ${MIN_NODE_MAJOR} or newer is needed` };
|
|
97
|
+
return { ok: true, problem: null };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Ask 127.0.0.1 across the ports in parallel; a reply with `app === "bareloop"` is a panel. Never throws.
|
|
102
|
+
* @param {{ports: number[], timeoutMs?: number, fetchImpl?: typeof fetch}} o
|
|
103
|
+
* @returns {Promise<Panel[]>} in port order
|
|
104
|
+
*/
|
|
105
|
+
export async function probePanels(o) {
|
|
106
|
+
const f = o.fetchImpl ?? fetch;
|
|
107
|
+
const ms = o.timeoutMs ?? PROBE_TIMEOUT_MS;
|
|
108
|
+
const answers = await Promise.all(o.ports.map(async (port) => {
|
|
109
|
+
try {
|
|
110
|
+
const res = await f(`${panelUrl(port)}/api/whoami`, { signal: AbortSignal.timeout(ms) });
|
|
111
|
+
if (!res.ok) return null;
|
|
112
|
+
const j = /** @type {any} */ (await res.json());
|
|
113
|
+
if (!j || j.app !== 'bareloop') return null;
|
|
114
|
+
return /** @type {Panel} */ ({
|
|
115
|
+
port,
|
|
116
|
+
pid: Number(j.pid),
|
|
117
|
+
version: String(j.version ?? ''),
|
|
118
|
+
drafts: Number(j.drafts) || 0,
|
|
119
|
+
schedules: Number(j.schedules) || 0,
|
|
120
|
+
url: panelUrl(port),
|
|
121
|
+
});
|
|
122
|
+
} catch { return null; }
|
|
123
|
+
}));
|
|
124
|
+
return /** @type {Panel[]} */ (answers.filter((a) => a !== null));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** @param {LauncherDeps} d @returns {number[]} */
|
|
128
|
+
function portsOf(d) {
|
|
129
|
+
if (d.ports) return d.ports;
|
|
130
|
+
const start = d.portStart ?? PORT_START;
|
|
131
|
+
return Array.from({ length: d.portCount ?? PORT_COUNT }, (_, i) => start + i);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** @param {LauncherDeps} d @returns {Promise<Panel[]>} */
|
|
135
|
+
function probeAll(d) {
|
|
136
|
+
return probePanels({ ports: portsOf(d), timeoutMs: d.timeoutMs, fetchImpl: d.fetchImpl });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The header, ONCE per call: the two lines every `bareloop` command prints first, plus the panels the probe found (the commands
|
|
141
|
+
* reuse them, so nothing is probed twice).
|
|
142
|
+
* @param {LauncherDeps & {version?: string}} d
|
|
143
|
+
* @returns {Promise<{lines: string[], panels: Panel[]}>}
|
|
144
|
+
*/
|
|
145
|
+
export async function buildHeader(d) {
|
|
146
|
+
const version = d.version ?? packageVersion();
|
|
147
|
+
const panels = await probeAll(d);
|
|
148
|
+
const inst = installState({ home: d.home, nodeVersion: d.nodeVersion });
|
|
149
|
+
const P = d.paint ?? PLAIN;
|
|
150
|
+
const sep = P.grey(' · ');
|
|
151
|
+
const where = panels.length === 0
|
|
152
|
+
? `panel ${P.grey('not running')}`
|
|
153
|
+
: `panel${panels.length > 1 ? 's' : ''} ${P.green('running')} at ${panels.map((p) => P.cyan(p.url) + (p.version !== version ? P.yellow(` (version ${p.version} — stop and start to use ${version})`) : '')).join(', ')}`;
|
|
154
|
+
const state = inst.ok ? P.green('installed ✓') : `${P.red('not installed')} — ${inst.problem}, pick ${P.boldCyan('4')} install`;
|
|
155
|
+
const first = `${P.boldCyan('bareloop')} ${P.grey(version)}${sep}${state}${sep}${where}`;
|
|
156
|
+
let second;
|
|
157
|
+
try {
|
|
158
|
+
const l = runsListForCount({ home: d.home, ...(typeof d.now === 'number' ? { now: d.now } : {}) });
|
|
159
|
+
const c = countRuns(l.runs, l.gaveUp.length);
|
|
160
|
+
second = `${P.grey('runs:')} ${c.running > 0 ? P.green(`${c.running} running`) : P.grey(`${c.running} running`)}${sep}`
|
|
161
|
+
+ `${c.needYou > 0 ? P.yellow(`${c.needYou} need you`) : P.grey(`${c.needYou} need you`)}${sep}${c.total} in all`;
|
|
162
|
+
} catch {
|
|
163
|
+
second = `${P.grey('runs:')} the run list could not be read`;
|
|
164
|
+
}
|
|
165
|
+
// the thin rule frames the header only on a colour terminal (plain output keeps exactly the two lines)
|
|
166
|
+
const rule = P.on ? [P.grey('─'.repeat(RULE_WIDTH))] : [];
|
|
167
|
+
return { lines: [...rule, first, second, ...rule], panels };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* @param {LauncherDeps & {version?: string}} d
|
|
172
|
+
* @returns {Promise<string[]>} the two header lines
|
|
173
|
+
*/
|
|
174
|
+
export async function headerLines(d) {
|
|
175
|
+
return (await buildHeader(d)).lines;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Print the header unless the caller (the menu) already did; hand back the panels either way.
|
|
180
|
+
* @param {{out: (s: string) => void}} io @param {LauncherDeps & {version?: string}} d @param {{lines: string[], panels: Panel[]}} [shown]
|
|
181
|
+
*/
|
|
182
|
+
async function headerOnce(io, d, shown) {
|
|
183
|
+
if (shown) return shown;
|
|
184
|
+
const h = await buildHeader(d);
|
|
185
|
+
for (const l of h.lines) io.out(l);
|
|
186
|
+
return h;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** stderr line, red on a colour terminal @param {{err: (s: string) => void}} io @param {LauncherDeps} d @param {string} text */
|
|
190
|
+
const fail = (io, d, text) => io.err((d.paintErr ?? d.paint ?? PLAIN).red(text));
|
|
191
|
+
|
|
192
|
+
/** @param {number} n @param {string} one @param {string} many */
|
|
193
|
+
const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
|
|
194
|
+
|
|
195
|
+
/** @param {LauncherDeps} d */
|
|
196
|
+
const sleepOf = (d) => d.sleep ?? ((/** @type {number} */ ms) => new Promise((r) => { setTimeout(r, ms); }));
|
|
197
|
+
|
|
198
|
+
/** @param {number} port @returns {Promise<boolean>} true when nothing holds 127.0.0.1:port */
|
|
199
|
+
function bindFree(port) {
|
|
200
|
+
return new Promise((resolve) => {
|
|
201
|
+
const s = createServer();
|
|
202
|
+
s.once('error', () => resolve(false));
|
|
203
|
+
s.listen(port, '127.0.0.1', () => { s.close(() => resolve(true)); });
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* `bareloop install` — creates only what is missing, never reads a key value.
|
|
209
|
+
* @param {{out: (s: string) => void, err: (s: string) => void}} io @param {LauncherDeps & {version?: string}} d @param {{lines: string[], panels: Panel[]}} [shown]
|
|
210
|
+
* @returns {Promise<number>}
|
|
211
|
+
*/
|
|
212
|
+
export async function installCmd(io, d, shown) {
|
|
213
|
+
await headerOnce(io, d, shown);
|
|
214
|
+
const major = Number((d.nodeVersion ?? process.versions.node).split('.')[0]);
|
|
215
|
+
if (!(major >= MIN_NODE_MAJOR)) {
|
|
216
|
+
fail(io, d, `bareloop needs node ${MIN_NODE_MAJOR} or newer; this is node ${d.nodeVersion ?? process.versions.node}. Nothing was changed.`);
|
|
217
|
+
return 1;
|
|
218
|
+
}
|
|
219
|
+
const dir = keysHome(d.home);
|
|
220
|
+
/** @type {string[]} */
|
|
221
|
+
const made = [];
|
|
222
|
+
if (!existsSync(dir)) { mkdirSync(dir, { recursive: true, mode: 0o700 }); made.push(`${dir} (only you can open it)`); }
|
|
223
|
+
const envFile = keysFilePath(d.home);
|
|
224
|
+
if (ensureKeysFile(d.home)) made.push(`${envFile} (the keys file, only you can read it)`);
|
|
225
|
+
else {
|
|
226
|
+
try {
|
|
227
|
+
if ((statSync(envFile).mode & 0o077) !== 0) { chmodSync(envFile, 0o600); made.push(`${envFile} set so only you can read it`); }
|
|
228
|
+
} catch { /* unreadable: the header says so */ }
|
|
229
|
+
}
|
|
230
|
+
const cfg = readConfig({ home: d.home });
|
|
231
|
+
if (cfg.problem) {
|
|
232
|
+
fail(io, d, `${cfg.problem}. Install did not touch it.`);
|
|
233
|
+
return 1;
|
|
234
|
+
}
|
|
235
|
+
if (!cfg.exists) {
|
|
236
|
+
try { updateConfig({}, { home: d.home }); made.push(`${join(dir, 'config.json')} (your settings)`); } catch (e) {
|
|
237
|
+
if (!(e instanceof ConfigError)) throw e;
|
|
238
|
+
fail(io, d, `${e.message}. Install did not touch it.`);
|
|
239
|
+
return 1;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (made.length === 0) { io.out('Already installed, nothing to do.'); return 0; }
|
|
243
|
+
io.out('Installed. Created:');
|
|
244
|
+
for (const m of made) io.out(` ${m}`);
|
|
245
|
+
return 0;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* `bareloop status` — the header, then one line per panel (or `Panel not running.`). Exit 0 running, 3 not running; 0 when the menu ran it.
|
|
250
|
+
* @param {{out: (s: string) => void}} io @param {LauncherDeps & {version?: string}} d @param {{lines: string[], panels: Panel[]}} [shown] header the menu already printed
|
|
251
|
+
* @returns {Promise<number>}
|
|
252
|
+
*/
|
|
253
|
+
export async function statusCmd(io, d, shown) {
|
|
254
|
+
const h = await headerOnce(io, d, shown);
|
|
255
|
+
if (h.panels.length === 0) {
|
|
256
|
+
io.out('Panel not running.');
|
|
257
|
+
// the sub-command's exit says "not running" (3); from the menu the menu itself worked (0)
|
|
258
|
+
return shown ? 0 : 3;
|
|
259
|
+
}
|
|
260
|
+
for (const p of h.panels) {
|
|
261
|
+
const body = `pid ${p.pid} · version ${p.version} · ${plural(p.drafts, 'draft', 'drafts')} open · ${plural(p.schedules, 'schedule', 'schedules')} on`;
|
|
262
|
+
io.out(h.panels.length > 1 ? `${p.url} · ${body}` : body);
|
|
263
|
+
}
|
|
264
|
+
return 0;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Wait until `port` stops (gone=true) or starts (gone=false) answering whoami as bareloop.
|
|
269
|
+
* @param {LauncherDeps} d @param {number} port @param {boolean} gone
|
|
270
|
+
* @returns {Promise<boolean>} false when the deadline passed first
|
|
271
|
+
*/
|
|
272
|
+
async function waitFor(d, port, gone) {
|
|
273
|
+
const sleep = sleepOf(d);
|
|
274
|
+
const deadline = d.deadlineMs ?? WAIT_DEADLINE_MS;
|
|
275
|
+
for (let waited = 0; waited <= deadline; waited += POLL_MS) {
|
|
276
|
+
const found = await probePanels({ ports: [port], timeoutMs: d.timeoutMs, fetchImpl: d.fetchImpl });
|
|
277
|
+
if (gone ? found.length === 0 : found.length > 0) return true;
|
|
278
|
+
await sleep(POLL_MS);
|
|
279
|
+
}
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* `bareloop start` — find a running panel first; else start one detached on the first free port 4700..4709, wait until it answers,
|
|
285
|
+
* open the page.
|
|
286
|
+
* @param {{out: (s: string) => void, err: (s: string) => void}} io @param {LauncherDeps & {version?: string}} d @param {{lines: string[], panels: Panel[]}} [shown]
|
|
287
|
+
* @returns {Promise<number>}
|
|
288
|
+
*/
|
|
289
|
+
export async function startCmd(io, d, shown) {
|
|
290
|
+
const h = await headerOnce(io, d, shown);
|
|
291
|
+
const P = d.paint ?? PLAIN;
|
|
292
|
+
const open = d.open ?? ((/** @type {string} */ t) => openInOs(t, { platform: d.platform, underTest: false }));
|
|
293
|
+
/** @param {string} url @param {string} said */
|
|
294
|
+
const finish = async (url, said) => {
|
|
295
|
+
const opened = await open(url);
|
|
296
|
+
io.out(opened ? `${said} — opening the page.` : `${said} — open it in your browser.`);
|
|
297
|
+
return 0;
|
|
298
|
+
};
|
|
299
|
+
if (h.panels.length > 0) {
|
|
300
|
+
const rc = await finish(h.panels[0].url, `Panel already running at ${P.cyan(h.panels[0].url)}`);
|
|
301
|
+
const mine = d.version ?? packageVersion();
|
|
302
|
+
if (h.panels[0].version !== mine) io.out(P.yellow(`It is version ${h.panels[0].version}; this bareloop is ${mine}. Run bareloop stop, then bareloop start, to use ${mine}.`));
|
|
303
|
+
return rc;
|
|
304
|
+
}
|
|
305
|
+
const free = d.portFree ?? bindFree;
|
|
306
|
+
const ports = portsOf(d);
|
|
307
|
+
/** @type {number|null} */
|
|
308
|
+
let chosen = null;
|
|
309
|
+
for (const p of ports) { if (await free(p)) { chosen = p; break; } }
|
|
310
|
+
if (chosen === null) {
|
|
311
|
+
fail(io, d, `Every port from ${ports[0]} to ${ports[ports.length - 1]} is taken by something else, so the panel was not started.`);
|
|
312
|
+
return 1;
|
|
313
|
+
}
|
|
314
|
+
if (chosen !== ports[0]) io.out(`${ports[0]} is taken, using ${chosen}.`);
|
|
315
|
+
const dir = keysHome(d.home);
|
|
316
|
+
try { mkdirSync(dir, { recursive: true, mode: 0o700 }); } catch { /* the log open below reports it */ }
|
|
317
|
+
const logFile = join(dir, 'panel.log');
|
|
318
|
+
/** @type {number|null} */
|
|
319
|
+
let logFd = null;
|
|
320
|
+
try { logFd = openSync(logFile, 'a'); } catch { logFd = null; }
|
|
321
|
+
const bin = d.bareloopBin ?? join(HERE, '..', 'bin', 'bareloop.mjs');
|
|
322
|
+
const platform = d.platform ?? process.platform;
|
|
323
|
+
const argv = [process.execPath, bin, 'panel', '--port', String(chosen)];
|
|
324
|
+
const spawnFn = d.spawnFn ?? realSpawn;
|
|
325
|
+
const [cmd, args] = platform === 'linux' ? ['setsid', argv] : [argv[0], argv.slice(1)];
|
|
326
|
+
try {
|
|
327
|
+
const child = spawnFn(cmd, args, { detached: true, stdio: ['ignore', logFd ?? 'ignore', logFd ?? 'ignore'], env: d.env ?? process.env });
|
|
328
|
+
if (child && typeof child.once === 'function') child.once('error', () => { /* seen as "never answered" below */ });
|
|
329
|
+
if (child && typeof child.unref === 'function') child.unref();
|
|
330
|
+
} catch (e) {
|
|
331
|
+
fail(io, d, `Could not start the panel: ${/** @type {Error} */ (e).message}`);
|
|
332
|
+
return 1;
|
|
333
|
+
} finally {
|
|
334
|
+
if (logFd !== null) { try { closeSync(logFd); } catch { /* the child holds its own copy */ } }
|
|
335
|
+
}
|
|
336
|
+
if (!(await waitFor(d, chosen, false))) {
|
|
337
|
+
fail(io, d, `The panel did not answer within ${(d.deadlineMs ?? WAIT_DEADLINE_MS) / 1000} seconds. See ${logFile}.`);
|
|
338
|
+
return 1;
|
|
339
|
+
}
|
|
340
|
+
return finish(panelUrl(chosen), `${P.green('Panel started')} at ${P.cyan(panelUrl(chosen))}`);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* `bareloop stop [--yes]` — SIGTERM the pid of EVERY panel the probe finds (one panel at a time is the rule; no "which one"), wait until each
|
|
345
|
+
* port stops answering. Drafts are in memory: warn (summed over all found panels) and ask y/N unless `--yes`.
|
|
346
|
+
* @param {string[]} args
|
|
347
|
+
* @param {{out: (s: string) => void, err: (s: string) => void}} io @param {LauncherDeps & {version?: string}} d @param {{lines: string[], panels: Panel[]}} [shown]
|
|
348
|
+
* @returns {Promise<number>}
|
|
349
|
+
*/
|
|
350
|
+
export async function stopCmd(args, io, d, shown) {
|
|
351
|
+
const bad = args.find((a) => a !== '--yes');
|
|
352
|
+
if (bad !== undefined) {
|
|
353
|
+
fail(io, d, `Unknown option ${JSON.stringify(bad)} — bareloop stop takes only --yes. It stops every panel it finds.`);
|
|
354
|
+
return 1;
|
|
355
|
+
}
|
|
356
|
+
const h = await headerOnce(io, d, shown);
|
|
357
|
+
const yes = args.includes('--yes');
|
|
358
|
+
const panels = h.panels;
|
|
359
|
+
if (panels.length === 0) { io.out('Panel not running.'); return 0; }
|
|
360
|
+
const ask = d.ask;
|
|
361
|
+
const drafts = panels.reduce((n, p) => n + p.drafts, 0);
|
|
362
|
+
if (drafts > 0 && !yes) {
|
|
363
|
+
io.out(`${drafts === 1 ? '1 draft is' : `${drafts} drafts are`} open in the panel and will be lost.`);
|
|
364
|
+
io.out('Runs keep going. Scheduled runs wait until the panel is started again.');
|
|
365
|
+
const a = ask ? (await ask('Stop the panel? [y/N] ')).trim().toLowerCase() : '';
|
|
366
|
+
if (a !== 'y' && a !== 'yes') { io.out('Not stopped.'); return 0; }
|
|
367
|
+
}
|
|
368
|
+
const kill = d.kill ?? ((/** @type {number} */ pid, /** @type {string} */ sig) => process.kill(pid, /** @type {NodeJS.Signals} */ (sig)));
|
|
369
|
+
for (const p of panels) {
|
|
370
|
+
if (!Number.isInteger(p.pid) || p.pid <= 1) { fail(io, d, `The panel at ${p.url} reported no usable pid, so it was not stopped.`); return 1; }
|
|
371
|
+
try { kill(p.pid, 'SIGTERM'); } catch (e) {
|
|
372
|
+
fail(io, d, `Could not stop the panel at ${p.url} (pid ${p.pid}): ${/** @type {Error} */ (e).message}`);
|
|
373
|
+
return 1;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
for (const p of panels) {
|
|
377
|
+
if (!(await waitFor(d, p.port, true))) {
|
|
378
|
+
fail(io, d, `The panel at ${p.url} still answers after ${(d.deadlineMs ?? WAIT_DEADLINE_MS) / 1000} seconds (pid ${p.pid}).`);
|
|
379
|
+
return 1;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
io.out((d.paint ?? PLAIN).green(panels.length > 1 ? `${panels.length} panels stopped.` : 'Panel stopped.'));
|
|
383
|
+
return 0;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* The bare `bareloop` menu: header, the four choices, one question. Runs the chosen command and returns its exit code.
|
|
388
|
+
* @param {{out: (s: string) => void, err: (s: string) => void}} io @param {LauncherDeps & {version?: string}} d
|
|
389
|
+
* @returns {Promise<number>}
|
|
390
|
+
*/
|
|
391
|
+
export async function menuCmd(io, d) {
|
|
392
|
+
const ask = d.ask;
|
|
393
|
+
const h = await buildHeader(d);
|
|
394
|
+
for (const l of h.lines) io.out(l);
|
|
395
|
+
const P = d.paint ?? PLAIN;
|
|
396
|
+
if (!P.on) io.out('');
|
|
397
|
+
const n = (/** @type {string} */ k) => P.boldCyan(k);
|
|
398
|
+
const items = `${n('1')} start ${n('2')} stop ${n('3')} status ${n('4')} install ${n('q')} quit`;
|
|
399
|
+
io.out(P.on ? ` ${items}` : items);
|
|
400
|
+
const choice = ask ? (await ask(`${P.cyan('>')} `)).trim().toLowerCase() : 'q';
|
|
401
|
+
// the header is already on screen: the chosen command reuses it instead of printing it a second time
|
|
402
|
+
if (choice === '1' || choice === 'start') return startCmd(io, d, h);
|
|
403
|
+
if (choice === '2' || choice === 'stop') return stopCmd([], io, d, h);
|
|
404
|
+
if (choice === '3' || choice === 'status') return statusCmd(io, d, h);
|
|
405
|
+
if (choice === '4' || choice === 'install') return installCmd(io, d, h);
|
|
406
|
+
if (choice === '' || choice === 'q' || choice === 'quit') return 0;
|
|
407
|
+
fail(io, d, `unknown choice ${JSON.stringify(choice)} — one of: 1, 2, 3, 4, q`);
|
|
408
|
+
return 1;
|
|
409
|
+
}
|
package/src/learn.js
CHANGED
|
@@ -567,7 +567,7 @@ export function foldCards(cards) {
|
|
|
567
567
|
* @typedef {object} Ledger
|
|
568
568
|
* @property {1} version
|
|
569
569
|
* @property {Row[]} rows
|
|
570
|
-
* @property {Pending[]} pending rubric
|
|
570
|
+
* @property {Pending[]} pending holds written by v0.46.x for rubric runs, released by the human Accept (NOT counted, NOT readable); new runs never write one
|
|
571
571
|
* @property {Card[]} suspect CHECK-SUSPECT cards, listed, newest last
|
|
572
572
|
* @property {Record<string, {at: string, noise: string|null, cards: number, suspect: number, thin: number}>} health per run (idempotent by run id)
|
|
573
573
|
*/
|
|
@@ -622,8 +622,9 @@ const byRank = (/** @type {Row} */ a, /** @type {Row} */ b) => b.count - a.count
|
|
|
622
622
|
* Fold ONE run's cards into the ledger. Pure: returns a new ledger.
|
|
623
623
|
*
|
|
624
624
|
* +1 per RUN per row: the run's own cards are folded into lessons first, and a row whose `runIds` already holds the
|
|
625
|
-
* run id is never counted twice (a resumed run, one id, never double counts)
|
|
626
|
-
* sample,
|
|
625
|
+
* run id is never counted twice (a resumed run, one id, never double counts) but IS refreshed: the resumed leg's card
|
|
626
|
+
* replaces the sample, lesson, key, lastSeen, hash and place (a leg-1 NEVER FIXED row becomes the leg-2 FIXED one). A
|
|
627
|
+
* matching lesson replaces the row's sample, bumps the model count and appends the run id (a row last seen 56+ days ago counts +1 and is read again);
|
|
627
628
|
* otherwise it opens a new row at count 1. NEVER FIXED cards count like FIXED ones (RULING 3). A workflow row of
|
|
628
629
|
* another signed hash is never matched here: the new version opens its own rows. Nothing is ever deleted or stamped.
|
|
629
630
|
* @param {Ledger} ledger
|
|
@@ -636,16 +637,23 @@ export function countLedger(ledger, { runid, cards, workflow, place, specHash, t
|
|
|
636
637
|
const rep = group.reduce((a, c) => (c.at >= a.at ? c : a));
|
|
637
638
|
const hit = [...rows].sort(byRank).find((r) => r.workflow === workflow && !otherJobVersion(r, workflow, specHash) && cardsMatch(rep, r.sample).match);
|
|
638
639
|
if (hit) {
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
640
|
+
// A row already holding this run id is the run's OWN row (a resumed leg re-reads the whole spine): refresh it from
|
|
641
|
+
// the new card, but the count, the model tally and the run id stay one per run.
|
|
642
|
+
const own = hit.runIds.includes(runid);
|
|
643
|
+
if (!own) {
|
|
644
|
+
hit.count += 1;
|
|
645
|
+
hit.models[rep.model ?? 'unknown'] = (hit.models[rep.model ?? 'unknown'] ?? 0) + 1;
|
|
646
|
+
hit.runIds.push(runid);
|
|
647
|
+
}
|
|
648
|
+
// a refresh never steps the row back: re-folding an older run's cards must not replace a newer run's sample
|
|
649
|
+
if (!own || rep.at >= hit.lastSeen) {
|
|
650
|
+
hit.sample = rep;
|
|
651
|
+
hit.lesson = lessonOf(rep);
|
|
652
|
+
hit.key = keyOfCard(rep);
|
|
653
|
+
hit.lastSeen = rep.at;
|
|
654
|
+
hit.specHash = specHash;
|
|
655
|
+
hit.place = place ?? hit.place;
|
|
656
|
+
}
|
|
649
657
|
} else {
|
|
650
658
|
let id = rowId(rep);
|
|
651
659
|
for (let n = 2; rows.some((r) => r.id === id); n += 1) id = `${rowId(rep)}-${n}`;
|
|
@@ -712,8 +720,8 @@ export function climb(rows) {
|
|
|
712
720
|
}
|
|
713
721
|
|
|
714
722
|
/**
|
|
715
|
-
*
|
|
716
|
-
*
|
|
723
|
+
* Hold a run's cards until a human Accept. NO LONGER CALLED for new runs (v1.112: a rubric run's lessons count at run
|
|
724
|
+
* end); kept so a hold already in a ledger file still has a writer for tests and a reader in {@link releasePending}. Pure.
|
|
717
725
|
* @param {Ledger} ledger
|
|
718
726
|
* @param {{runid: string, cards: Card[], workflow: string, place: string|null, specHash: string|null, at: string}} o
|
|
719
727
|
* @param {Date} now @returns {Ledger}
|
|
@@ -725,7 +733,7 @@ export function holdPending(ledger, { runid, cards, workflow, place, specHash, a
|
|
|
725
733
|
}
|
|
726
734
|
|
|
727
735
|
/**
|
|
728
|
-
* The human Accept releases
|
|
736
|
+
* The human Accept releases an OLD hold (written by v0.46.x; new runs write none): its cards count now (trust `rubric`). Only forward, only here. A hold
|
|
729
737
|
* older than 60 days is dropped uncounted. Idempotent (no hold = nothing to do; run ids stop a double count).
|
|
730
738
|
* @param {Ledger} ledger @param {string} runid @param {Date} now
|
|
731
739
|
* @returns {{ledger: Ledger, released: number}}
|
package/src/learnstore.js
CHANGED
|
@@ -9,7 +9,7 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFil
|
|
|
9
9
|
import { join, dirname } from 'node:path';
|
|
10
10
|
import { keysHome } from './keysfile.js';
|
|
11
11
|
import { readConfig } from './config.js';
|
|
12
|
-
import { emptyLedger, redactDeep, countLedger,
|
|
12
|
+
import { emptyLedger, redactDeep, countLedger, releasePending, recordHealth, buildCards, placeOf, renderHints } from './learn.js';
|
|
13
13
|
import { stageQuestionsByName } from './stagewords.js';
|
|
14
14
|
|
|
15
15
|
/**
|
|
@@ -57,9 +57,8 @@ export function writeLedger(ledger, home) {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
/**
|
|
60
|
-
* Fold ONE finished run into the ledger on disk:
|
|
61
|
-
*
|
|
62
|
-
* either way. Never throws on a bad ledger file: it reports `problem` and writes nothing.
|
|
60
|
+
* Fold ONE finished run into the ledger on disk: every run's cards COUNT now, rubric jobs too (trust kept on new rows;
|
|
61
|
+
* v1.112). `held` is always 0 (kept in the shape for callers). The run's health line is recorded. Never throws on a bad ledger file: it reports `problem` and writes nothing.
|
|
63
62
|
* @param {{home?: string, runid: string, build: import('./learn.js').CardBuild, workflow: string, place: string|null, specHash: string|null, trust: 'deterministic'|'rubric', at: string, now?: Date}} o
|
|
64
63
|
* @returns {{wrote: boolean, counted: number, held: number, problem: string|null}}
|
|
65
64
|
*/
|
|
@@ -67,23 +66,16 @@ export function foldRunIntoLedger({ home, runid, build, workflow, place, specHas
|
|
|
67
66
|
const cur = readLedger(home);
|
|
68
67
|
if (cur.problem) return { wrote: false, counted: 0, held: 0, problem: cur.problem };
|
|
69
68
|
let L = cur.ledger;
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
L = holdPending(L, { runid, cards: build.cards, workflow, place, specHash, at }, now);
|
|
74
|
-
held = build.cards.length;
|
|
75
|
-
} else {
|
|
76
|
-
const before = L.rows.reduce((n, r) => n + r.runIds.filter((x) => x === runid).length, 0);
|
|
77
|
-
L = countLedger(L, { runid, cards: build.cards, workflow, place, specHash, trust, now });
|
|
78
|
-
counted = L.rows.reduce((n, r) => n + r.runIds.filter((x) => x === runid).length, 0) - before;
|
|
79
|
-
}
|
|
69
|
+
const before = L.rows.reduce((n, r) => n + r.runIds.filter((x) => x === runid).length, 0);
|
|
70
|
+
L = countLedger(L, { runid, cards: build.cards, workflow, place, specHash, trust, now });
|
|
71
|
+
const counted = L.rows.reduce((n, r) => n + r.runIds.filter((x) => x === runid).length, 0) - before;
|
|
80
72
|
L = recordHealth(L, runid, build, at, now);
|
|
81
73
|
writeLedger(L, home);
|
|
82
|
-
return { wrote: true, counted, held, problem: null };
|
|
74
|
+
return { wrote: true, counted, held: 0, problem: null };
|
|
83
75
|
}
|
|
84
76
|
|
|
85
77
|
/**
|
|
86
|
-
* The human Accept at the review door: release the run
|
|
78
|
+
* The human Accept at the review door: release a hold an older version wrote for the run so it counts (new runs write none; only forward, only here).
|
|
87
79
|
* @param {{home?: string, runid: string, now?: Date}} o
|
|
88
80
|
* @returns {{released: number, problem: string|null}}
|
|
89
81
|
*/
|
|
@@ -123,7 +115,7 @@ const realOr = (p) => { try { return realpathSync(p); } catch { return p; } };
|
|
|
123
115
|
|
|
124
116
|
/**
|
|
125
117
|
* THE RUN-END TALLY ($0, code only, no model): turn the finished run's whole record into cards and fold them into the
|
|
126
|
-
* ledger (a rubric job's
|
|
118
|
+
* ledger (a rubric job's count too, trust kept). Called once from the one engine's run-end block (src/userrun.js
|
|
127
119
|
* `execute`, shared by the panel, the CLI and the bundle runner), inside its own try/catch: it is a record, never a verdict,
|
|
128
120
|
* and can never change a run's outcome, exit code or spend. It runs whether the hints setting is on or off.
|
|
129
121
|
* @param {{home?: string, runid: string, events: any[], auditFiles?: string[], spec: any, specHash: string|null, workdir: string|null, trust: 'deterministic'|'rubric', now?: Date}} o
|
package/src/opener.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// The ONE "open this in the OS" helper: `xdg-open` (linux) / `open` (macOS), argv array, no shell, detached.
|
|
2
|
+
// Settings' "Open keys folder" and the `bareloop start` launcher both use it. Another OS or a failed spawn
|
|
3
|
+
// resolves `false` (the caller then shows the path/URL as text); it never throws.
|
|
4
|
+
import { spawn as realSpawn } from 'node:child_process';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {string} target a folder path or a URL
|
|
8
|
+
* @param {{ platform?: string, spawnFn?: ((cmd: string, args: string[], o: object) => any)|null, underTest?: boolean }} [opts]
|
|
9
|
+
* `spawnFn: null` = no opener at all (resolves false); undefined = the real spawn, unless `underTest`, which spawns nothing
|
|
10
|
+
* unless a test injected one
|
|
11
|
+
* @returns {Promise<boolean>} true once the opener process has started
|
|
12
|
+
*/
|
|
13
|
+
export function openInOs(target, opts = {}) {
|
|
14
|
+
const platform = opts.platform ?? process.platform;
|
|
15
|
+
const cmd = platform === 'linux' ? 'xdg-open' : (platform === 'darwin' ? 'open' : null);
|
|
16
|
+
const opener = opts.spawnFn === undefined ? (opts.underTest ? null : realSpawn) : opts.spawnFn;
|
|
17
|
+
if (!cmd || !opener) return Promise.resolve(false);
|
|
18
|
+
return new Promise((resolve) => {
|
|
19
|
+
let child;
|
|
20
|
+
try { child = opener(cmd, [target], { detached: true, stdio: 'ignore' }); } catch { resolve(false); return; }
|
|
21
|
+
let settled = false;
|
|
22
|
+
child.once('error', () => { if (!settled) { settled = true; resolve(false); } });
|
|
23
|
+
child.once('spawn', () => {
|
|
24
|
+
if (settled) return;
|
|
25
|
+
settled = true;
|
|
26
|
+
if (typeof child.unref === 'function') child.unref();
|
|
27
|
+
resolve(true);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
}
|
|
@@ -25,6 +25,7 @@ import { readFileSync } from 'node:fs';
|
|
|
25
25
|
import { planMatchesClose } from '../rubricstep.js';
|
|
26
26
|
import { createSession, validateJobCard, validateReuseCard, jobBoxesChanged, buildReuseSpec, cardFields, nextFreeJobName, startDestinationProblem, STEP_LABELS } from './authorsession.js';
|
|
27
27
|
import { parseInputs } from '../jobblock.js';
|
|
28
|
+
import { isReusableGreen } from './status.js';
|
|
28
29
|
import { proveInputFiles, looksLikeRubricInput, webFetchState } from '../source.js';
|
|
29
30
|
import { validateJob } from '../job.js';
|
|
30
31
|
|
|
@@ -282,7 +283,7 @@ export function createAuthorRoutes(opts) {
|
|
|
282
283
|
ok: true, origin: pre.origin, card, from: pre.from, note: pre.note,
|
|
283
284
|
// ONE door (v1.101): `green` = the origin earned a real green (an imported job counts), so an unchanged job reuses the signed plan;
|
|
284
285
|
// `nextName` = the Job name box once a job box changes (the box shows the origin's name until then). Nothing is locked.
|
|
285
|
-
green: pre.origin.importId !== undefined || pre.outcome
|
|
286
|
+
green: pre.origin.importId !== undefined || isReusableGreen(pre.outcome),
|
|
286
287
|
nextName: nextFreeJobName(pre.origin.job, opts.runJobNames ? opts.runJobNames() : [], { jobsDir: opts.jobsDir }),
|
|
287
288
|
specHash: pre.specHash, workflowKey: pre.workflowKey, trackRecord: pre.trackRecord,
|
|
288
289
|
line: pre.line, specNamesModel: pre.specNamesModel, specWorker: pre.specWorker,
|
|
@@ -357,7 +358,7 @@ export function createAuthorRoutes(opts) {
|
|
|
357
358
|
// a scheduled run's green waits for the person's Accept (quarantine): its plan is neither reused nor re-drafted until then
|
|
358
359
|
if (pre.outcome === 'green-held') { send(409, { ok: false, error: 'This run was started by a schedule and is waiting for your Accept. Accept it first, then it can be reused.' }); return true; }
|
|
359
360
|
card.jobName = pre.origin.job; // an unchanged job keeps its name; a typed one is ignored
|
|
360
|
-
if (fromImport !== null || pre.outcome
|
|
361
|
+
if (fromImport !== null || isReusableGreen(pre.outcome)) {
|
|
361
362
|
const rv = validateReuseCard(card, { rows });
|
|
362
363
|
if (!rv.ok) { send(400, { ok: false, error: rv.error }); return true; }
|
|
363
364
|
const spec = buildReuseSpec(pre.spec, card, rows);
|
|
@@ -431,7 +432,7 @@ export function createAuthorRoutes(opts) {
|
|
|
431
432
|
}
|
|
432
433
|
|
|
433
434
|
return {
|
|
434
|
-
handle, sessions, headless, startSession, envNow, spawnFn, bareloopBin,
|
|
435
|
+
handle, sessions, headless, openDrafts: () => [...sessions.values()].filter((s) => !TERMINAL_PHASES.has(s.state.phase)).length, startSession, envNow, spawnFn, bareloopBin,
|
|
435
436
|
/** a headless session the starter has removed from disk leaves its map too @param {string} id */
|
|
436
437
|
forgetHeadless: (id) => { headless.delete(id); },
|
|
437
438
|
};
|
|
@@ -1146,13 +1146,11 @@ export function createSession(card, deps = {}) {
|
|
|
1146
1146
|
const no = state.steps.findLastIndex((/** @type {any} */ x) => x.status === 'running');
|
|
1147
1147
|
appendDraftLog(outDir, { kind: 'step-reds', no: no === -1 ? state.steps.length - 1 : no, stop: authored.stop ?? 'authoring-failed', reds: redsRecord(stopReds), at: new Date().toISOString() });
|
|
1148
1148
|
}
|
|
1149
|
-
refuse(
|
|
1150
|
-
|
|
1151
|
-
:
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
wholeJob: RUBRIC_FILES,
|
|
1155
|
-
}), authored.stop === 'confirm-abandoned' ? 'abandoned' : 'refused');
|
|
1149
|
+
refuse(draftStopText({
|
|
1150
|
+
stop: String(authored.stop ?? 'authoring-failed'), reds: stopReds, refusal: authored.refusal,
|
|
1151
|
+
spend: { knownUsd: state.draftSpentUsd, spendComplete: state.draftSpendComplete },
|
|
1152
|
+
wholeJob: RUBRIC_FILES,
|
|
1153
|
+
}), authored.stop === 'confirm-abandoned' ? 'abandoned' : 'refused');
|
|
1156
1154
|
return;
|
|
1157
1155
|
}
|
|
1158
1156
|
|