sign-off-axi 0.4.6 → 0.4.8
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/README.md +4 -1
- package/dist/cli.js +235 -44
- package/dist/contract.js +32 -9
- package/dist/evidence-text.js +67 -11
- package/dist/stills-worker.js +12 -0
- package/dist/store.js +14 -3
- package/fixtures/contract/plan.v1/page/index.html +4 -22
- package/fixtures/contract/plan.v1/valid-full.json +69 -16
- package/fixtures/contract/report.v2/valid-full.json +1 -1
- package/package.json +1 -1
- package/web/demo/seed.json +14 -1
- package/web/index.html +765 -318
package/README.md
CHANGED
|
@@ -172,6 +172,8 @@ the new page on a phone." \
|
|
|
172
172
|
|
|
173
173
|
*Proof of a fix, with two captures. Pass, Fail, or Needs work.*
|
|
174
174
|
|
|
175
|
+
Evidence lists passed proof only: an update whose linked run passed. A plain update like this links no run, so it waits in **Waiting on you** instead, as does a failed or unchecked run, each with its honest result and its verdicts and Dismiss, so the agent still gets an answer. Passed updates are grouped by the project named in the run's proof line, `verify-<project>: ...` (trimmed, compared without case); a proof line with no such prefix goes under **Other**.
|
|
176
|
+
|
|
175
177
|
### Explainer video, with chapters
|
|
176
178
|
|
|
177
179
|
```sh
|
|
@@ -354,7 +356,8 @@ cannot block can be woken instead: an `on_answer:` line in the
|
|
|
354
356
|
`config.yaml` beside the board names a command the board runs for every
|
|
355
357
|
answer. A `notify:` block in that config can send an HTTP POST when a card
|
|
356
358
|
enters Waiting on you, with its bearer token read from a named environment
|
|
357
|
-
variable
|
|
359
|
+
variable, or, with `receiver: muxr`, from the muxr host's `notify-token`
|
|
360
|
+
file; `sign-off-axi notify test` sends a test notice. The full reference, with the worked example, is in
|
|
358
361
|
[docs/commands.md](docs/commands.md).
|
|
359
362
|
|
|
360
363
|
Every command takes `--help` and prints compact output with next-step hints.
|
package/dist/cli.js
CHANGED
|
@@ -5,14 +5,15 @@ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
|
5
5
|
import { spawn, spawnSync } from 'node:child_process';
|
|
6
6
|
import { accessSync, closeSync, constants, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, watch, writeFileSync } from 'node:fs';
|
|
7
7
|
import { lookup } from 'node:dns/promises';
|
|
8
|
-
import { homedir, tmpdir, userInfo } from 'node:os';
|
|
8
|
+
import { availableParallelism, homedir, tmpdir, userInfo } from 'node:os';
|
|
9
|
+
import { Worker } from 'node:worker_threads';
|
|
9
10
|
import { basename, delimiter, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
10
11
|
import { parseArgs } from 'node:util';
|
|
11
12
|
import { encode } from '@toon-format/toon';
|
|
12
13
|
import { AxiError, runAxiCli } from 'axi-sdk-js';
|
|
13
14
|
import { CHAPTERS_MAX, SIGNED_IN, CLEARED, InputError, MEDIA_MAX, NOTE_MAX, REPLY, answersPath, appendAnnotation, appendAnswer, appendDismissed, appendSendBack, appendDesignAnswer, appendMessage, appendRound, awaitsVerdict, isKey, loadDecisions, markDelivered, markSeen, messagesPath, readAnnotations, readAnswers, readDismissed, readItems, readMessages, readRounds, roundsPath, seenBy, seenPath, sentIds, unreadable as reasonOf, writeItems } from './store.js';
|
|
14
15
|
import { ContractError, KINDS, MEDIA_BYTES, REPORT_INIT, checkMessage, mediaFiles, parseMessage, reportPage } from './contract.js';
|
|
15
|
-
import { faults } from './evidence-text.js';
|
|
16
|
+
import { faults, isStill } from './evidence-text.js';
|
|
16
17
|
import { deliverVault, errors, readVaultExpiry, sweepVault, takeVaultExpiries, VaultError } from './vault.js';
|
|
17
18
|
const FORMAT = 'https://github.com/umeranjum17/sign-off/blob/main/docs/file-formats.md';
|
|
18
19
|
const CONTRACT = 'https://github.com/umeranjum17/sign-off/blob/main/docs/contract.md';
|
|
@@ -27,8 +28,8 @@ const AGENTS = {
|
|
|
27
28
|
'gemini-cli': { bin: 'gemini', dir: '.agents/skills', home: join(homedir(), '.gemini', 'skills') },
|
|
28
29
|
};
|
|
29
30
|
const TOP_HELP = `usage: sign-off-axi [command] [board.json] [flags]
|
|
30
|
-
commands[
|
|
31
|
-
(none)=list, raise, post, answers, wait, poll, open, status, stop, serve, export, init, report, signin, doctor, validate, help
|
|
31
|
+
commands[18]:
|
|
32
|
+
(none)=list, raise, post, answers, wait, poll, open, status, stop, serve, export, init, report, signin, doctor, notify, validate, help
|
|
32
33
|
board: the nearest .sign-off/ board (the walk's bounds and SIGN_OFF_HOME: README, "Test runs never touch your board"), else ~/.sign-off/decisions.json; a board file given always wins
|
|
33
34
|
flags: --full and --json on list, answers and wait, --events on wait, --json on status, --filer on post, raise and wait, --help on every command, -v/--version
|
|
34
35
|
examples:
|
|
@@ -61,7 +62,7 @@ examples:
|
|
|
61
62
|
sign-off-axi
|
|
62
63
|
sign-off-axi list ./decisions.json --full
|
|
63
64
|
`,
|
|
64
|
-
raise: `usage: sign-off-axi raise [board.json] --title "<question>" [--key <key>] [--about <text>] [--detail <text>] --why <text> [--option <value>=<label>]... --outcome <value>=<text>... --recommend <value> --recommend-reason <text> --default <value> [--silent-by <ISO time>] [--merge <key>]... [--close done|release] [--filer <id>]
|
|
65
|
+
raise: `usage: sign-off-axi raise [board.json] --title "<question>" [--key <key>] [--about <text>] [--detail <text>] --why <text> [--option <value>=<label>]... --outcome <value>=<text>... --recommend <value> --recommend-reason <text> --default <value> [--silent-by <ISO time>] [--merge <key>]... [--proof <key>] [--close done|release] [--filer <id>]
|
|
65
66
|
sign-off-axi raise [board.json] --from <decisions.json> [--filer <id>]
|
|
66
67
|
Adds questions to the board: yes/no, or 2 to 6 choices with one --option each. --key defaults to a slug of the title.
|
|
67
68
|
raise is the alias for post --type decision: same flags, same output.
|
|
@@ -75,6 +76,7 @@ Every question meets the question standard (docs/contract.md, "Asking a good que
|
|
|
75
76
|
yes and no are labelled Yes and No; a question never takes Done, OK or Not now buttons.
|
|
76
77
|
One decision is one card: look at the open cards first. --merge <key> (up to 10) names other cards on the board that ask the same thing and offer every option of this one:
|
|
77
78
|
they are hidden while this one waits, and its answer closes them too, with the same value and a note naming this key. A merged card merges nothing itself, a card is named by one ask until that ask is answered, and an answered or dismissed card cannot be merged.
|
|
79
|
+
--proof <key> names an evidence update already on the board that this ask rests on: Decide shows its capture and result at the top.
|
|
78
80
|
The user answers with one tap and can undo it for a few seconds.
|
|
79
81
|
--from adds every decision in a decisions file: ${FORMAT}
|
|
80
82
|
Raising the same question again is a no-op that reports its status; a different question under a used key is refused.
|
|
@@ -285,6 +287,19 @@ It opens from disk with no server and makes no network request. --out defaults t
|
|
|
285
287
|
examples:
|
|
286
288
|
sign-off-axi export
|
|
287
289
|
sign-off-axi export ./decisions.json --out ~/board-12-oct.html
|
|
290
|
+
`,
|
|
291
|
+
notify: `usage: sign-off-axi notify [check|test] [dir]
|
|
292
|
+
Device notifications are off until the config.yaml beside the board has a notify: block. For the muxr receiver it is:
|
|
293
|
+
notify:
|
|
294
|
+
receiver: muxr
|
|
295
|
+
link_base: https://my-machine.my-tailnet.ts.net:4410/ (https or a tailnet address, so the link opens on the iPad)
|
|
296
|
+
url: http://127.0.0.1:8793/v1/notify (optional: this is the default)
|
|
297
|
+
The token is read at send time from the file <MUXR_HOME>/notify-token (default ~/.muxr/notify-token), never kept in the config.
|
|
298
|
+
check (the default): says whether notifications are off, misconfigured, waiting on the token file, or ready. It sends nothing.
|
|
299
|
+
test: sends one real "sign-off test" notice now and says plainly what happened (sent, no token file, token refused, receiver not running).
|
|
300
|
+
examples:
|
|
301
|
+
sign-off-axi notify
|
|
302
|
+
sign-off-axi notify test
|
|
288
303
|
`,
|
|
289
304
|
doctor: `usage: sign-off-axi doctor [dir]
|
|
290
305
|
Checks the repo setup init wrote: node version, config, board file, answer files, a lock a crashed command left behind, gitignore lines, folders, whether the dashboard port is free,
|
|
@@ -322,12 +337,14 @@ function fits(title, key) {
|
|
|
322
337
|
throw fail(`${key ? `key ${key}: ` : ''}title is ${n} characters, over ${TITLE_MAX}`, 'VALIDATION_ERROR', `Shorten the title to ${TITLE_MAX} characters and put the rest in about`);
|
|
323
338
|
}
|
|
324
339
|
/** The question standard's flags: a decision's why, each option's outcome, the reason for the recommendation, when silence decides, the cards it merges. */
|
|
325
|
-
const ASK_FLAGS = ['why', 'outcome', 'recommend-reason', 'silent-by', 'merge'];
|
|
340
|
+
const ASK_FLAGS = ['why', 'outcome', 'recommend-reason', 'silent-by', 'merge', 'proof'];
|
|
326
341
|
const MANY = ['option', 'outcome', 'merge'];
|
|
327
342
|
/** Board types that are contract kinds: a decision (the default type) is an ask. */
|
|
328
343
|
const CONTRACT_TYPES = ['decision', 'ask', 'evidence', 'report', 'plan', 'design'];
|
|
329
|
-
/** The stored item as the message it came from: the board's own `filed` stamp is not part of a message that names none. */
|
|
330
|
-
const asSent = ({ filed: _filed, ...rest }) => rest;
|
|
344
|
+
/** The stored item as the message it came from: the board's own `filed` stamp is not part of a message that names none, and the notice record (`announced`) is the board's too. */
|
|
345
|
+
const asSent = ({ filed: _filed, announced: _announced, ...rest }) => rest;
|
|
346
|
+
/** A report names its own `filed` time, so only the notice record goes. */
|
|
347
|
+
const asReport = ({ announced: _announced, ...rest }) => rest;
|
|
331
348
|
/** Items of a --from file that carry contract or kind are contract messages: each is checked against its kind's rules before anything is stored.
|
|
332
349
|
* A new item of a contract kind must carry them (contract section 2). A --replace feed replaces the stored item, so an item under an existing key
|
|
333
350
|
* that changed is checked again; an unchanged item, a board-format one and every item outside a --replace feed are not checked again. */
|
|
@@ -349,7 +366,7 @@ function contractItems(items, from, file, replace = false) {
|
|
|
349
366
|
// item, a board-format one and every item outside a --replace feed are not checked again.
|
|
350
367
|
const asMessage = (x) => {
|
|
351
368
|
if (m?.kind !== 'evidence')
|
|
352
|
-
return m?.kind === 'report' ? x : asSent(x);
|
|
369
|
+
return m?.kind === 'report' ? asReport(x) : asSent(x);
|
|
353
370
|
const { type: _type, number: _number, media, evidence: run, ...message } = asSent(x);
|
|
354
371
|
return { ...message, ...(media !== undefined && { media: Array.isArray(media) ? media.map((r) => r?.path) : media }), ...(run && { run }) };
|
|
355
372
|
};
|
|
@@ -919,7 +936,7 @@ function postSignin(file, named, on, values, filer) {
|
|
|
919
936
|
let status = same ? (done ? 'already done' : 'already waiting') : had ? 'new link' : 'posted';
|
|
920
937
|
if (!same) {
|
|
921
938
|
fits(d.title, d.key);
|
|
922
|
-
const next = { ...item, filed: new Date().toISOString().replace(/\.\d+Z$/, 'Z') };
|
|
939
|
+
const next = { ...item, ...(had?.announced && { announced: had.announced }), filed: new Date().toISOString().replace(/\.\d+Z$/, 'Z') };
|
|
923
940
|
if (had)
|
|
924
941
|
items[at] = next;
|
|
925
942
|
else
|
|
@@ -990,7 +1007,9 @@ function replace(file, named, on, from, filer) {
|
|
|
990
1007
|
}
|
|
991
1008
|
}
|
|
992
1009
|
const old = existsSync(file) ? readItems(file) : undefined;
|
|
993
|
-
|
|
1010
|
+
// The notice record belongs to the card key, so a feed that rewrites a card keeps it: the receiver was told once.
|
|
1011
|
+
const told = new Map((old?.items ?? []).filter((i) => i?.key && i.announced).map((i) => [i.key, i.announced]));
|
|
1012
|
+
let items = feed.map((i) => (told.has(i?.key) ? { ...i, announced: told.get(i.key) } : i)), extra = fed.extra, resulting = incoming;
|
|
994
1013
|
if (filer) {
|
|
995
1014
|
// The feed's items first: the board shows the file's last item first, so the other filers' items keep showing on top.
|
|
996
1015
|
const fedKeys = new Set(feed.map((i) => i.key));
|
|
@@ -1067,7 +1086,7 @@ function postMedia(file, named, on, values, type, filer, message) {
|
|
|
1067
1086
|
return path;
|
|
1068
1087
|
});
|
|
1069
1088
|
// A still that is not a picture of the product (text, colour bars or a placeholder) is measured before anything is copied.
|
|
1070
|
-
const [unfit] = explainer ? [] : sources.flatMap((src) =>
|
|
1089
|
+
const [unfit] = explainer ? [] : sources.flatMap((src) => isStill(src) ? faults(src).map((f) => ({ src, f })) : []);
|
|
1071
1090
|
if (unfit)
|
|
1072
1091
|
throw fail(UNFIT[unfit.f](tilde(unfit.src)), 'VALIDATION_ERROR', 'Post a real capture of the running product instead: a screenshot or recording of the screen the user sees');
|
|
1073
1092
|
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
@@ -1077,8 +1096,8 @@ function postMedia(file, named, on, values, type, filer, message) {
|
|
|
1077
1096
|
const current = board(file), store = join(dirname(file), 'evidence'), taken = current.map((d) => Number(/^update-(\d+)$/.exec(d.key)?.[1] ?? 0));
|
|
1078
1097
|
const own = message ? message.key : explainer && (values.key ?? slug(values.title)), had = message && own && items.find((x) => x?.key === own);
|
|
1079
1098
|
if (had) {
|
|
1080
|
-
// The stored item, read back as the message it came from: its run, and each media file by hash.
|
|
1081
|
-
const asMessage = ({ type: _t, number: _n, filed: _f, evidence: run, media: kept, ...rest }) => canon({ ...rest, run, media: kept?.map((x) => x.sha256) });
|
|
1099
|
+
// The stored item, read back as the message it came from: its run, and each media file by hash. The notice record is the board's, not the message's.
|
|
1100
|
+
const asMessage = ({ type: _t, number: _n, filed: _f, announced: _a, evidence: run, media: kept, ...rest }) => canon({ ...rest, run, media: kept?.map((x) => x.sha256) });
|
|
1082
1101
|
if (asMessage(had) !== canon({ ...message, media: sources.length ? sources.map((src) => sha256(readFileSync(src))) : undefined })) {
|
|
1083
1102
|
throw fail(`key ${own} already holds a different message on ${tilde(file)}`, 'CONFLICT', 'Post it under a new key');
|
|
1084
1103
|
}
|
|
@@ -1299,7 +1318,7 @@ function buildIncoming(values, extra, filer, file) {
|
|
|
1299
1318
|
try {
|
|
1300
1319
|
const { title, key = slug(title), about, detail, why, recommend: recommend_value, default: default_if_silent, close } = values;
|
|
1301
1320
|
const ask = !Object.hasOwn(extra, 'type'), options = ask ? outcomesOf(values) : optionsOf(values);
|
|
1302
|
-
const told = ask && { why, recommend_reason: values['recommend-reason'], silent_by: values['silent-by'], merges: values.merge };
|
|
1321
|
+
const told = ask && { why, recommend_reason: values['recommend-reason'], silent_by: values['silent-by'], merges: values.merge, proof: values.proof };
|
|
1303
1322
|
writeFileSync(source, JSON.stringify({ schema: 2, items: [{ key, title, about, detail, ...told, options, recommend_value, default_if_silent, close, ...extra, filer }] }));
|
|
1304
1323
|
const incoming = loadDecisions(source, board), raw = readItems(source).items;
|
|
1305
1324
|
if (!ask)
|
|
@@ -1325,6 +1344,9 @@ const canon = (v) => JSON.stringify(v, (_, x) => (x && typeof x === 'object' &&
|
|
|
1325
1344
|
/** A merge names cards on the board that take this card's answer: each is on the board, offers every option of this card, and merges nothing itself.
|
|
1326
1345
|
* A card is named by one ask until that ask is answered, dismissed or not. A merge new to the board also names a card still open. */
|
|
1327
1346
|
function checkMerges(incoming, resulting, current) {
|
|
1347
|
+
for (const d of incoming)
|
|
1348
|
+
if (d.proof && resulting.find((x) => x.key === d.proof)?.type !== 'evidence')
|
|
1349
|
+
throw fail(`key ${d.key} rests on ${d.proof}, which is not an evidence update on the board`, 'VALIDATION_ERROR', `Post the evidence update first, or leave out --proof`);
|
|
1328
1350
|
const byKey = new Map(resulting.map((d) => [d.key, d])), before = new Map(current.map((c) => [c.key, c]));
|
|
1329
1351
|
const closed = (key) => !!(before.get(key)?.answer || before.get(key)?.dismissed);
|
|
1330
1352
|
const holder = (key, self) => resulting.find((x) => x.key !== self && !before.get(x.key)?.answer && x.merges?.includes(key));
|
|
@@ -1372,7 +1394,7 @@ function storeIncoming(file, named, on, { incoming, raw }, filer, exact = false)
|
|
|
1372
1394
|
// The same question from another filer, or at another time, is the same question: it keeps its first filer and time.
|
|
1373
1395
|
const same = (x) => JSON.stringify({ ...x, answer: undefined, dismissed: undefined, filer: undefined, filed: undefined, options: [...x.options].sort((a, b) => a.value.localeCompare(b.value)) });
|
|
1374
1396
|
// A message that names no filed time matches the one the board stamped.
|
|
1375
|
-
const differs = () => exact ? canon({ ...items.find((x) => x?.key === d.key), ...(raw[i].filed === undefined && { filed: undefined }) }) !== canon(raw[i]) : same(had) !== same(d);
|
|
1397
|
+
const differs = () => exact ? canon({ ...items.find((x) => x?.key === d.key), ...(raw[i].filed === undefined && { filed: undefined }), announced: undefined }) !== canon(raw[i]) : same(had) !== same(d);
|
|
1376
1398
|
if (had && differs())
|
|
1377
1399
|
throw refuse(`already holds a different ${contract ? 'message' : 'question'} on ${tilde(file)}`);
|
|
1378
1400
|
// Each new item records when it was raised, unless it names its own time.
|
|
@@ -1612,11 +1634,11 @@ function readSetup(so) {
|
|
|
1612
1634
|
readFileSync(join(so, 'config.yaml'), 'utf8').split(/\r?\n/).forEach((l, n) => {
|
|
1613
1635
|
// The notify block's indented lines keep their # , and } characters: only a # after a space starts a comment.
|
|
1614
1636
|
if (inNotify && (!l.trim() || /^\s/.test(l))) {
|
|
1615
|
-
const text = l.replace(/(^|\s+)#.*$/, '').trim(), field = text.match(/^(url|token_env|link_base)\s*:\s*(.+)$/);
|
|
1637
|
+
const text = l.replace(/(^|\s+)#.*$/, '').trim(), field = text.match(/^(url|token_env|link_base|receiver)\s*:\s*(.+)$/);
|
|
1616
1638
|
if (field)
|
|
1617
1639
|
notify[field[1]] = field[2].replace(/^(['"])(.*)\1$/, '$2');
|
|
1618
1640
|
else if (text)
|
|
1619
|
-
problems.push(`line ${n + 1} is not one "url", "token_env" or "link_base" line under notify`);
|
|
1641
|
+
problems.push(`line ${n + 1} is not one "receiver", "url", "token_env" or "link_base" line under notify`);
|
|
1620
1642
|
return;
|
|
1621
1643
|
}
|
|
1622
1644
|
inNotify = false;
|
|
@@ -1656,15 +1678,36 @@ function readSetup(so) {
|
|
|
1656
1678
|
catch {
|
|
1657
1679
|
return undefined;
|
|
1658
1680
|
} };
|
|
1681
|
+
const muxr = notify.receiver?.toLowerCase() === 'muxr';
|
|
1682
|
+
if (notifyBlock && notify.receiver && !muxr)
|
|
1683
|
+
problems.push('notify receiver must be muxr, or leave the receiver line out');
|
|
1684
|
+
if (muxr)
|
|
1685
|
+
notify.url ??= MUXR_URL;
|
|
1659
1686
|
const sendTo = parsed(notify.url), base = parsed(notify.link_base);
|
|
1660
1687
|
const usable = !!sendTo && !!base && (sendTo.protocol === 'https:' || (sendTo.protocol === 'http:' && (loopback(sendTo.hostname.replace(/^\[|\]$/g, '')) || sendTo.hostname === 'localhost')))
|
|
1661
1688
|
&& ['http:', 'https:'].includes(base.protocol) && (notify.token_env === undefined || /^[A-Za-z_]\w*$/.test(notify.token_env));
|
|
1662
1689
|
if (notifyBlock && !usable)
|
|
1663
1690
|
problems.push('notify needs url (https://, or http:// on this machine) and link_base (http:// or https://) lines, and token_env must name an environment variable');
|
|
1691
|
+
// A phone or iPad opens the link, so a muxr notice's link is https or a tailnet address: 127.0.0.1 or a LAN name would be dead there.
|
|
1692
|
+
const reachable = !!base && (base.protocol === 'https:' || /\.ts\.net$/i.test(base.hostname) || (isIP(base.hostname) === 4 && /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(base.hostname)));
|
|
1693
|
+
if (notifyBlock && usable && muxr && !reachable)
|
|
1694
|
+
problems.push('notify link_base must be https:// or a tailnet address (a .ts.net name or 100.x address) so the link opens on the iPad');
|
|
1664
1695
|
return { board: join(so, goodBoard ? config.board : 'decisions.json'), port: goodPort ? Number(port) : 4410, bind, hosts, onAnswer, goodBoard, problems,
|
|
1665
|
-
notify: notifyBlock && usable ? { url: notify.url, token_env: notify.token_env, link_base: notify.link_base } : undefined };
|
|
1696
|
+
notify: notifyBlock && usable && (!muxr || reachable) ? { url: notify.url, token_env: notify.token_env, link_base: notify.link_base, muxr } : undefined };
|
|
1666
1697
|
}
|
|
1667
|
-
const
|
|
1698
|
+
const MUXR_URL = 'http://127.0.0.1:8793/v1/notify';
|
|
1699
|
+
/** The muxr receiver's token file, read at send time and never stored: <MUXR_HOME>/notify-token, MUXR_HOME defaulting to ~/.muxr. */
|
|
1700
|
+
const muxrTokenFile = () => join(process.env.MUXR_HOME || join(homedir(), '.muxr'), 'notify-token');
|
|
1701
|
+
/** Why a notice cannot be sent yet, in plain words; undefined when its token is there. */
|
|
1702
|
+
const tokenMissing = (n) => n.muxr
|
|
1703
|
+
? (() => { try {
|
|
1704
|
+
return readFileSync(muxrTokenFile(), 'utf8').trim() ? undefined : `${tilde(muxrTokenFile())} is empty`;
|
|
1705
|
+
}
|
|
1706
|
+
catch {
|
|
1707
|
+
return `${tilde(muxrTokenFile())} is not there yet (the muxr host writes it when it updates)`;
|
|
1708
|
+
} })()
|
|
1709
|
+
: n.token_env && !process.env[n.token_env] ? `${n.token_env} is not set` : undefined;
|
|
1710
|
+
const NOTIFY_SENDER = `void (async()=>{const delays=[2000,10000,60000],f=process.env.SIGN_OFF_NOTIFY_TOKEN_FILE;for(let i=0;i<4;i++){try{const t=f?require('node:fs').readFileSync(f,'utf8').trim():process.env.SIGN_OFF_NOTIFY_TOKEN;if((await fetch(process.env.SIGN_OFF_NOTIFY_URL,{method:'POST',headers:{'content-type':'application/json',...(t&&{authorization:'Bearer '+t})},body:process.env.SIGN_OFF_NOTIFY_BODY,signal:AbortSignal.timeout(10000)})).ok)return}catch{}if(i<3)await new Promise(r=>setTimeout(r,delays[i]))}require('node:fs').appendFileSync(process.env.SIGN_OFF_NOTIFY_LOG,new Date().toISOString()+' notification send failed for card '+process.env.SIGN_OFF_NOTIFY_ID+'\\n')})();`;
|
|
1668
1711
|
/** The cards waiting on the user before a write: a card waiting after the write but not before it has entered Waiting on you. */
|
|
1669
1712
|
function waitingKeys(file) {
|
|
1670
1713
|
try {
|
|
@@ -1674,24 +1717,39 @@ function waitingKeys(file) {
|
|
|
1674
1717
|
return undefined;
|
|
1675
1718
|
}
|
|
1676
1719
|
}
|
|
1677
|
-
/** Announces each card that entered Waiting on you with a write. The card is already stored, so nothing here fails the command.
|
|
1720
|
+
/** Announces each card that entered Waiting on you with a write. The card is already stored, so nothing here fails the command.
|
|
1721
|
+
* A card is announced once: the card keeps the record, so one that leaves Waiting and comes back (a new sign-in link) stays quiet,
|
|
1722
|
+
* the same silent return an Undo gets. */
|
|
1678
1723
|
function notifyEntered(file, named, before) {
|
|
1679
1724
|
try {
|
|
1680
1725
|
const so = dirname(named), notify = before ? readSetup(so).notify : undefined;
|
|
1681
|
-
if (!before || !notify || (notify
|
|
1726
|
+
if (!before || !notify || tokenMissing(notify))
|
|
1727
|
+
return;
|
|
1728
|
+
const { items } = readItems(file), fresh = board(file, () => { }).filter((d) => waits(d) && !before.has(d.key) && !items.some((i) => i?.key === d.key && i.announced));
|
|
1729
|
+
if (!fresh.length)
|
|
1682
1730
|
return;
|
|
1683
|
-
|
|
1731
|
+
const at = new Date().toISOString().replace(/\.\d+Z$/, 'Z');
|
|
1732
|
+
for (const i of items)
|
|
1733
|
+
if (fresh.some((d) => d.key === i?.key))
|
|
1734
|
+
i.announced = at;
|
|
1735
|
+
writeItems(file, items);
|
|
1736
|
+
for (const d of fresh)
|
|
1684
1737
|
sendNotice(so, notify, d);
|
|
1685
1738
|
}
|
|
1686
1739
|
catch { }
|
|
1687
1740
|
}
|
|
1741
|
+
/** The JSON one card sends: sign-off's own shape, or muxr's {title (1-80 chars), body, url, id}. */
|
|
1742
|
+
function noticeBody(notify, d) {
|
|
1743
|
+
const kind = d.kind ?? { decision: 'ask', explainer: d.options.length ? 'ask' : 'report' }[d.type] ?? d.type;
|
|
1744
|
+
const text = [...(d.about ?? d.detail ?? d.why ?? d.title).replace(/[\r\n]+/g, ' ')].slice(0, 200).join(''), url = `${notify.link_base.replace(/\/$/, '')}#waiting/${encodeURIComponent(d.key)}`;
|
|
1745
|
+
if (notify.muxr)
|
|
1746
|
+
return { title: [...d.title].slice(0, 80).join('') || d.key, body: text, url, id: d.key };
|
|
1747
|
+
return { v: 1, source: 'sign-off', id: d.key, kind: ['ask', 'plan', 'design', 'evidence', 'report'].includes(kind) ? kind : 'ask',
|
|
1748
|
+
title: [...d.title].slice(0, 120).join(''), text, url, at: d.filed ?? new Date().toISOString() };
|
|
1749
|
+
}
|
|
1688
1750
|
function sendNotice(so, notify, d) {
|
|
1689
1751
|
const token = notify.token_env ? process.env[notify.token_env] : undefined;
|
|
1690
|
-
const
|
|
1691
|
-
const body = JSON.stringify({ v: 1, source: 'sign-off', id: d.key, kind: ['ask', 'plan', 'design', 'evidence', 'report'].includes(kind) ? kind : 'ask',
|
|
1692
|
-
title: [...d.title].slice(0, 120).join(''), text: [...(d.about ?? d.detail ?? d.why ?? d.title).replace(/[\r\n]+/g, ' ')].slice(0, 200).join(''),
|
|
1693
|
-
url: `${notify.link_base.replace(/\/$/, '')}#waiting/${encodeURIComponent(d.key)}`, at: d.filed ?? new Date().toISOString() });
|
|
1694
|
-
const child = spawn(process.execPath, ['-e', NOTIFY_SENDER], { detached: true, stdio: 'ignore', env: { ...process.env, SIGN_OFF_NOTIFY_URL: notify.url, SIGN_OFF_NOTIFY_BODY: body, SIGN_OFF_NOTIFY_TOKEN: token ?? '', SIGN_OFF_NOTIFY_ID: d.key, SIGN_OFF_NOTIFY_LOG: join(so, 'notify.log') } });
|
|
1752
|
+
const child = spawn(process.execPath, ['-e', NOTIFY_SENDER], { detached: true, stdio: 'ignore', env: { ...process.env, SIGN_OFF_NOTIFY_URL: notify.url, SIGN_OFF_NOTIFY_BODY: JSON.stringify(noticeBody(notify, d)), SIGN_OFF_NOTIFY_TOKEN: notify.muxr ? '' : token ?? '', SIGN_OFF_NOTIFY_TOKEN_FILE: notify.muxr ? muxrTokenFile() : '', SIGN_OFF_NOTIFY_ID: d.key, SIGN_OFF_NOTIFY_LOG: join(so, 'notify.log') } });
|
|
1695
1753
|
child.on('error', () => warn(`notification sender could not start for card ${d.key}`));
|
|
1696
1754
|
child.unref();
|
|
1697
1755
|
}
|
|
@@ -1707,7 +1765,7 @@ function init(args) {
|
|
|
1707
1765
|
}
|
|
1708
1766
|
catch { }
|
|
1709
1767
|
const files = [
|
|
1710
|
-
[join(so, 'config.yaml'), `# sign-off repo setup. board: path under .sign-off/, port: dashboard port, agent: auto or the coding agents to install the skill for.\n# bind: 127.0.0.1 keeps the dashboard on this machine; a private address such as its VPN address lets a phone open it.\n# hosts: (optional) private DNS names that lead to that address, such as its mesh VPN name, commas between them.\n# on_answer: (optional) a command run for each answer, with the answer as one JSON line on its stdin; no shell, so | > && do nothing: on_answer: node hook.mjs\nboard: decisions.json\nport: 4410\nbind: 127.0.0.1\nagent: ${agent}\n`],
|
|
1768
|
+
[join(so, 'config.yaml'), `# sign-off repo setup. board: path under .sign-off/, port: dashboard port, agent: auto or the coding agents to install the skill for.\n# bind: 127.0.0.1 keeps the dashboard on this machine; a private address such as its VPN address lets a phone open it.\n# hosts: (optional) private DNS names that lead to that address, such as its mesh VPN name, commas between them.\n# on_answer: (optional) a command run for each answer, with the answer as one JSON line on its stdin; no shell, so | > && do nothing: on_answer: node hook.mjs\n# name: (optional) your name, shown on the board. leash: how much agents may do without asking: ask, matters (the default) or act; the board's Leash control sets it.\nboard: decisions.json\nport: 4410\nbind: 127.0.0.1\nagent: ${agent}\n`],
|
|
1711
1769
|
[board, `{"schema": 2, "items": []}\n`],
|
|
1712
1770
|
[join(so, '.gitignore'), GITIGNORE],
|
|
1713
1771
|
];
|
|
@@ -1779,6 +1837,43 @@ function report(args) {
|
|
|
1779
1837
|
: `Run \`sign-off-axi post <board.json> --type report --title "<the answer>" --about "<why it matters>" --view <path>/index.html --preview <path>/preview.svg\`, both paths from the board file's folder, which must hold this folder`,
|
|
1780
1838
|
]);
|
|
1781
1839
|
}
|
|
1840
|
+
/** `notify check|test`: whether device notifications can work from this setup, and one real test notice. Never prints the token. */
|
|
1841
|
+
async function notify(args) {
|
|
1842
|
+
const mode = ['check', 'test'].includes(args[0]) ? args.shift() : 'check';
|
|
1843
|
+
const { dir: folder, given } = repoDir('notify', args), dir = given ? folder : setupFolderHere() ?? folder, so = join(dir, '.sign-off');
|
|
1844
|
+
let setup;
|
|
1845
|
+
try {
|
|
1846
|
+
setup = readSetup(so);
|
|
1847
|
+
}
|
|
1848
|
+
catch {
|
|
1849
|
+
throw fail(`no readable config.yaml in ${tilde(so)}`, 'NOT_FOUND', `Run \`sign-off-axi init ${shellPath(dir)}\``);
|
|
1850
|
+
}
|
|
1851
|
+
const config = shellPath(join(so, 'config.yaml'));
|
|
1852
|
+
if (setup.problems.some((p) => p.startsWith('notify')))
|
|
1853
|
+
throw fail(`the notify: block in ${tilde(join(so, 'config.yaml'))} cannot be used: ${setup.problems.filter((p) => p.startsWith('notify')).join('; ')}`, 'VALIDATION_ERROR', `Edit ${config}, then run \`sign-off-axi notify\` again`);
|
|
1854
|
+
const n = setup.notify;
|
|
1855
|
+
if (!n)
|
|
1856
|
+
return toon({ notifications: 'off' }, [`Ask the user once whether they want device notifications; if yes, add \`notify:\`, \` receiver: muxr\` and \` link_base: <the board's https or tailnet address>\` lines to ${config}`]);
|
|
1857
|
+
const why = tokenMissing(n);
|
|
1858
|
+
if (why)
|
|
1859
|
+
throw fail(`notifications are on but cannot send: ${why}`, 'NOT_FOUND', n.muxr ? 'Update the muxr host so it writes its notify-token file, then run `sign-off-axi notify test`' : `Export ${n.token_env} with the receiver's token`);
|
|
1860
|
+
if (mode === 'check')
|
|
1861
|
+
return toon({ notifications: 'on', receiver: n.muxr ? 'muxr' : 'webhook', url: n.url, link_base: n.link_base, token: n.muxr ? tilde(muxrTokenFile()) : `$${n.token_env ?? '(none)'}` }, ['Run `sign-off-axi notify test` to send one real test notice']);
|
|
1862
|
+
const probe = { key: 'sign-off-test', title: 'sign-off test', about: 'Device notifications from sign-off work.', type: 'decision', options: [] };
|
|
1863
|
+
let status;
|
|
1864
|
+
try {
|
|
1865
|
+
const token = n.muxr ? readFileSync(muxrTokenFile(), 'utf8').trim() : n.token_env ? process.env[n.token_env] : undefined;
|
|
1866
|
+
status = (await fetch(n.url, { method: 'POST', headers: { 'content-type': 'application/json', ...(token && { authorization: `Bearer ${token}` }) }, body: JSON.stringify(noticeBody(n, probe)), signal: AbortSignal.timeout(10000) })).status;
|
|
1867
|
+
}
|
|
1868
|
+
catch {
|
|
1869
|
+
throw fail(`nothing answered at ${n.url}`, 'NOT_FOUND', n.muxr ? 'Start the muxr host on this machine, then run `sign-off-axi notify test` again' : 'Check the url: line, then run `sign-off-axi notify test` again');
|
|
1870
|
+
}
|
|
1871
|
+
if ([401, 403].includes(status))
|
|
1872
|
+
throw fail(`the receiver refused the token (HTTP ${status})`, 'VALIDATION_ERROR', n.muxr ? `Check that ${tilde(muxrTokenFile())} holds the token the muxr host expects` : `Check ${n.token_env}`);
|
|
1873
|
+
if (status >= 300)
|
|
1874
|
+
throw fail(`the receiver answered HTTP ${status}, so the test notice was not accepted`, 'VALIDATION_ERROR', 'Check the receiver is the notification endpoint');
|
|
1875
|
+
return toon({ notifications: 'on', test: 'sent', http: status }, ['Check the device for the "sign-off test" notice']);
|
|
1876
|
+
}
|
|
1782
1877
|
async function doctor(args) {
|
|
1783
1878
|
const { dir: folder, given } = repoDir('doctor', args), dir = given ? folder : setupFolderHere() ?? folder, so = join(dir, '.sign-off'), rerun = `sign-off-axi init ${shellPath(dir)}`;
|
|
1784
1879
|
const checks = [];
|
|
@@ -1846,8 +1941,9 @@ async function doctor(args) {
|
|
|
1846
1941
|
check('config', `board ${basename(board)}, port ${port}`);
|
|
1847
1942
|
else
|
|
1848
1943
|
check('config', `${basename(config)}: ${setup.problems.join('; ')}`, `Edit ${shellPath(config)} to hold "board: <name>.json", "port: <0-65535>" and "bind: <127.0.0.1 or a private address>" lines, such as board: decisions.json, port: 4410 and bind: 127.0.0.1, and hosts: only DNS names${setup.goodBoard ? '' : `, or delete it and run \`${rerun}\``}`);
|
|
1849
|
-
|
|
1850
|
-
|
|
1944
|
+
const why = setup.notify && tokenMissing(setup.notify);
|
|
1945
|
+
if (setup.notify && why)
|
|
1946
|
+
check('notify', `${why}, so no notice is sent`, setup.notify.muxr ? 'Update the muxr host so it writes its notify-token file, then run `sign-off-axi notify test`' : `Export ${setup.notify.token_env} with the receiver's token`);
|
|
1851
1947
|
// A hosts: name is only of use when it leads to the bind address.
|
|
1852
1948
|
const astray = [];
|
|
1853
1949
|
for (const h of setup.hosts) {
|
|
@@ -1972,11 +2068,12 @@ async function doctor(args) {
|
|
|
1972
2068
|
throw fail(`${bad.length} of ${checks.length} checks need a fix in ${tilde(dir)}`, 'NOT_FOUND', ...bad.map((c) => `${c.name}: ${c.ok}. ${c.fix}`));
|
|
1973
2069
|
return toon({ dir: tilde(dir), checks: checks.map(({ name, ok }) => ({ name, ok })) }, [`Run \`sign-off-axi open ${shellPath(board)}\` to show the board to the user`]);
|
|
1974
2070
|
}
|
|
1975
|
-
let file = '', evidenceRoot = '', bind = '127.0.0.1', hosts = [], onAnswer = [];
|
|
2071
|
+
let file = '', config = '', evidenceRoot = '', bind = '127.0.0.1', hosts = [], onAnswer = [];
|
|
1976
2072
|
async function serve(args) {
|
|
1977
2073
|
const parsed = parse('serve', args, BOARD_FLAGS), port = boardPort(parsed.named, parsed.values.port);
|
|
1978
2074
|
ready(parsed.file, parsed.on, parsed.values['evidence-root']);
|
|
1979
2075
|
bind = bindOf(parsed.named, parsed.values.bind);
|
|
2076
|
+
config = join(dirname(parsed.named), 'config.yaml');
|
|
1980
2077
|
({ hosts = [], onAnswer = [] } = setupOf(parsed.named) ?? {});
|
|
1981
2078
|
let warned = false;
|
|
1982
2079
|
const sweep = async (phase) => {
|
|
@@ -1994,6 +2091,7 @@ async function serve(args) {
|
|
|
1994
2091
|
tokenOf(parsed.file, true);
|
|
1995
2092
|
await sweep('startup');
|
|
1996
2093
|
[file, evidenceRoot] = [parsed.file, parsed.values['evidence-root'] ? resolve(parsed.values['evidence-root']) : ''];
|
|
2094
|
+
guardReady = warmGuard();
|
|
1997
2095
|
await new Promise((ok, no) => server.once('error', no).listen(port, bind, ok)).catch((e) => {
|
|
1998
2096
|
throw fail(e.code === 'EADDRINUSE' ? `port ${port} is in use; pass --port` : e.code === 'EADDRNOTAVAIL' ? `${bind} is not an address of this machine; pass another --bind` : e.message, 'SERVE_FAILED', 'Run `sign-off-axi --help` for the flags, then start it again');
|
|
1999
2097
|
});
|
|
@@ -2048,6 +2146,9 @@ async function exportBoard(args) {
|
|
|
2048
2146
|
const parsed = parse('export', args, { out: { type: 'string' }, 'evidence-root': { type: 'string' } });
|
|
2049
2147
|
ready(parsed.file, parsed.on, parsed.values['evidence-root']);
|
|
2050
2148
|
[file, evidenceRoot] = [parsed.file, parsed.values['evidence-root'] ? resolve(parsed.values['evidence-root']) : ''];
|
|
2149
|
+
config = join(dirname(parsed.named), 'config.yaml');
|
|
2150
|
+
guardReady = warmGuard();
|
|
2151
|
+
await guardReady;
|
|
2051
2152
|
const { file: _, messages: _messages, annotations: _annotations, ...frozen } = state(), files = {}, q = encodeURIComponent;
|
|
2052
2153
|
let missing = 0;
|
|
2053
2154
|
// The same URLs the page asks the server for, read through the same hash and path checks.
|
|
@@ -2094,19 +2195,92 @@ const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
|
2094
2195
|
/** A card whose picture is a colour-bar test card or a placeholder is not shown: a card posted before the guard can hold one. Read by the
|
|
2095
2196
|
* stored hash, so each picture is measured once. */
|
|
2096
2197
|
const unfitMedia = new Map();
|
|
2097
|
-
const fakeEvidence = (d) => d.type === 'evidence' && (d.media ?? []).some((m) =>
|
|
2098
|
-
|
|
2099
|
-
|
|
2198
|
+
const fakeEvidence = (d) => d.type === 'evidence' && (d.media ?? []).some((m) => isStill(m.path) && unfitMedia.get(m.sha256) === true);
|
|
2199
|
+
/** Every still on the board, once, so a request never walks the media: the board's files are hash-checked and immutable. */
|
|
2200
|
+
const stillJobs = () => {
|
|
2201
|
+
const jobs = [], seen = new Set();
|
|
2202
|
+
for (const d of loadDecisions(file)) {
|
|
2203
|
+
if (d.type !== 'evidence')
|
|
2204
|
+
continue;
|
|
2205
|
+
for (const m of d.media ?? []) {
|
|
2206
|
+
if (!isStill(m.path) || seen.has(m.sha256) || unfitMedia.has(m.sha256))
|
|
2207
|
+
continue;
|
|
2208
|
+
seen.add(m.sha256);
|
|
2209
|
+
try {
|
|
2210
|
+
jobs.push({ path: under(dirname(file), m.path), sha256: m.sha256 });
|
|
2211
|
+
}
|
|
2212
|
+
catch { /* an absent file has no faults */ }
|
|
2213
|
+
}
|
|
2214
|
+
}
|
|
2215
|
+
return jobs;
|
|
2216
|
+
};
|
|
2217
|
+
/** Measures the board's stills in parallel and off the request path. A fresh board is therefore ready before it is asked for cards: the
|
|
2218
|
+
* page and media answer at once, and the guard's pass never blocks them. One follow-up pass takes whatever a lost worker never reported. */
|
|
2219
|
+
let guardReady = Promise.resolve();
|
|
2220
|
+
/** A board read waits this long for the warm pass, so a still that is slow or stuck never holds the cards. */
|
|
2221
|
+
const GUARD_WAIT = 500;
|
|
2222
|
+
/** A worker still measuring after this is stuck in a convert that outlived its own timeout: it is stopped and its stills stay unmeasured. */
|
|
2223
|
+
const WORKER_WAIT = 35_000;
|
|
2224
|
+
const warmGuard = () => new Promise((done) => {
|
|
2225
|
+
const pass = (retry) => {
|
|
2226
|
+
let jobs;
|
|
2100
2227
|
try {
|
|
2101
|
-
|
|
2228
|
+
jobs = stillJobs();
|
|
2102
2229
|
}
|
|
2103
2230
|
catch {
|
|
2104
|
-
return
|
|
2231
|
+
return done();
|
|
2232
|
+
}
|
|
2233
|
+
if (!jobs.length)
|
|
2234
|
+
return done();
|
|
2235
|
+
const workers = Math.max(1, Math.min(8, availableParallelism(), jobs.length));
|
|
2236
|
+
let left = workers;
|
|
2237
|
+
const ended = () => {
|
|
2238
|
+
if (--left)
|
|
2239
|
+
return;
|
|
2240
|
+
if (retry)
|
|
2241
|
+
pass(false);
|
|
2242
|
+
else
|
|
2243
|
+
done();
|
|
2244
|
+
};
|
|
2245
|
+
for (let i = 0; i < workers; i++) {
|
|
2246
|
+
let worker;
|
|
2247
|
+
try {
|
|
2248
|
+
worker = new Worker(new URL('./stills-worker.js', import.meta.url), { workerData: jobs.filter((_, j) => j % workers === i) });
|
|
2249
|
+
}
|
|
2250
|
+
catch {
|
|
2251
|
+
ended();
|
|
2252
|
+
continue;
|
|
2253
|
+
}
|
|
2254
|
+
let stop;
|
|
2255
|
+
const arm = () => {
|
|
2256
|
+
clearTimeout(stop);
|
|
2257
|
+
stop = setTimeout(() => { void worker.terminate(); }, WORKER_WAIT);
|
|
2258
|
+
stop.unref();
|
|
2259
|
+
};
|
|
2260
|
+
arm();
|
|
2261
|
+
worker.on('message', ([sha, unfit]) => { unfitMedia.set(sha, unfit); arm(); });
|
|
2262
|
+
worker.once('error', () => clearTimeout(stop));
|
|
2263
|
+
worker.once('exit', () => { clearTimeout(stop); ended(); });
|
|
2105
2264
|
}
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
return unfitMedia.get(m.sha256);
|
|
2265
|
+
};
|
|
2266
|
+
pass(true);
|
|
2109
2267
|
});
|
|
2268
|
+
/** How much agents may do without asking, from the leash: line of config.yaml: ask (wait on every ask), matters (take the default at the
|
|
2269
|
+
* silent-by time, the default) or act (take the recommended pick now and report it). With the name: line it is the page's only setting. */
|
|
2270
|
+
const LEASHES = ['ask', 'matters', 'act'];
|
|
2271
|
+
const configLine = (text, key) => new RegExp(`^[ \\t]*${key}[ \\t]*:([^#\\n]*)`, 'm').exec(text)?.[1].trim() || undefined;
|
|
2272
|
+
/** The user's name and leash; none for a board with no config.yaml beside it. */
|
|
2273
|
+
function profile() {
|
|
2274
|
+
let text;
|
|
2275
|
+
try {
|
|
2276
|
+
text = readFileSync(config, 'utf8');
|
|
2277
|
+
}
|
|
2278
|
+
catch {
|
|
2279
|
+
return {};
|
|
2280
|
+
}
|
|
2281
|
+
const leash = configLine(text, 'leash') ?? '';
|
|
2282
|
+
return { name: configLine(text, 'name')?.slice(0, 40), leash: LEASHES.includes(leash) ? leash : 'matters' };
|
|
2283
|
+
}
|
|
2110
2284
|
const state = () => {
|
|
2111
2285
|
reap();
|
|
2112
2286
|
// The page marks sent comments in annotations, so answers and rounds go without theirs: a read-only board or an export carries none.
|
|
@@ -2121,6 +2295,7 @@ const state = () => {
|
|
|
2121
2295
|
...(d.mocks && { mocks: d.mocks.map((m) => ({ ...m, hash: mockHash(m.path) })) }) })),
|
|
2122
2296
|
readonly: readOnly(),
|
|
2123
2297
|
answers_only: answersOnly(),
|
|
2298
|
+
...profile(),
|
|
2124
2299
|
answers,
|
|
2125
2300
|
seen: Object.values(answers).filter(taken).map((a) => a.key),
|
|
2126
2301
|
rounds,
|
|
@@ -2292,9 +2467,12 @@ async function route(req) {
|
|
|
2292
2467
|
if (req.method === 'GET' && req.url === '/api/board')
|
|
2293
2468
|
return [200, { file, pid: process.pid }];
|
|
2294
2469
|
if (req.method === 'GET' && req.url === '/api/decisions') {
|
|
2470
|
+
let timer;
|
|
2471
|
+
await Promise.race([guardReady, new Promise((ok) => { timer = setTimeout(ok, GUARD_WAIT); })]);
|
|
2472
|
+
clearTimeout(timer);
|
|
2295
2473
|
return [200, state()];
|
|
2296
2474
|
}
|
|
2297
|
-
if (req.method !== 'POST' || !['/api/answers', '/api/rounds', '/api/messages', '/api/annotations', '/api/dismiss', '/api/signin'].includes(req.url ?? ''))
|
|
2475
|
+
if (req.method !== 'POST' || !['/api/answers', '/api/rounds', '/api/messages', '/api/annotations', '/api/dismiss', '/api/signin', '/api/leash'].includes(req.url ?? ''))
|
|
2298
2476
|
return [404, { error: 'Not found' }];
|
|
2299
2477
|
if (readOnly())
|
|
2300
2478
|
return [403, { error: 'This board is read-only' }];
|
|
@@ -2362,6 +2540,21 @@ async function route(req) {
|
|
|
2362
2540
|
return board(file).find((x) => x.key === key)?.answer ? [200, { state: state() }] : [502, { error: e.message, state: state() }];
|
|
2363
2541
|
}
|
|
2364
2542
|
}
|
|
2543
|
+
if (req.url === '/api/leash') {
|
|
2544
|
+
const { leash } = JSON.parse(body) ?? {};
|
|
2545
|
+
if (!LEASHES.includes(leash))
|
|
2546
|
+
return [400, { error: `Send a leash of ${LEASHES.join(', ')}` }];
|
|
2547
|
+
let text;
|
|
2548
|
+
try {
|
|
2549
|
+
text = readFileSync(config, 'utf8');
|
|
2550
|
+
}
|
|
2551
|
+
catch {
|
|
2552
|
+
return [409, { error: 'This board has no config.yaml to keep the leash in; run sign-off-axi init' }];
|
|
2553
|
+
}
|
|
2554
|
+
const line = /^[ \t]*leash[ \t]*:.*$/m;
|
|
2555
|
+
writeFileSync(config, line.test(text) ? text.replace(line, `leash: ${leash}`) : `${text.replace(/\n?$/, '\n')}leash: ${leash}\n`);
|
|
2556
|
+
return [200, state()];
|
|
2557
|
+
}
|
|
2365
2558
|
if (req.url === '/api/dismiss') {
|
|
2366
2559
|
const { keys, dismissed } = JSON.parse(body) ?? {};
|
|
2367
2560
|
if (!Array.isArray(keys) || !keys.length || keys.length > 200 || typeof dismissed !== 'boolean')
|
|
@@ -2369,8 +2562,6 @@ async function route(req) {
|
|
|
2369
2562
|
const have = new Map(board(file).map((x) => [x.key, x]));
|
|
2370
2563
|
if (!keys.every((k) => isKey(k) && have.has(k)))
|
|
2371
2564
|
return [404, { error: 'This item is no longer in the file' }];
|
|
2372
|
-
if (dismissed && keys.some((k) => have.get(k)?.type === 'plan' && !have.get(k)?.answer))
|
|
2373
|
-
return [400, { error: 'A plan waits until it is sent back, so it cannot be dismissed' }];
|
|
2374
2565
|
appendDismissed(file, [...new Set(keys)], dismissed);
|
|
2375
2566
|
return [200, state()];
|
|
2376
2567
|
}
|
|
@@ -2597,7 +2788,7 @@ await runAxiCli({
|
|
|
2597
2788
|
topLevelHelp: TOP_HELP,
|
|
2598
2789
|
getCommandHelp: (cmd) => HELP[cmd],
|
|
2599
2790
|
home: axi('list', list),
|
|
2600
|
-
commands: { list: axi('list', list), raise: axi('raise', (args) => post(args, 'raise')), post: axi('post', post), answers: axi('answers', answers), wait: axi('wait', wait), poll: axi('poll', poll), open: axi('open', open), status: axi('status', status), stop: axi('stop', stop), serve: axi('serve', serve), export: axi('export', exportBoard), init: axi('init', init), report: axi('report', report), signin: axi('signin', signin), doctor: axi('doctor', doctor), validate, help: ([name, ...rest]) => {
|
|
2791
|
+
commands: { list: axi('list', list), raise: axi('raise', (args) => post(args, 'raise')), post: axi('post', post), answers: axi('answers', answers), wait: axi('wait', wait), poll: axi('poll', poll), open: axi('open', open), status: axi('status', status), stop: axi('stop', stop), serve: axi('serve', serve), export: axi('export', exportBoard), init: axi('init', init), report: axi('report', report), signin: axi('signin', signin), doctor: axi('doctor', doctor), notify: axi('notify', notify), validate, help: ([name, ...rest]) => {
|
|
2601
2792
|
if (rest.length || (name && !HELP[name]))
|
|
2602
2793
|
throw fail(`unknown command or argument: ${[name, ...rest].join(' ')}`, 'VALIDATION_ERROR', 'Run `sign-off-axi --help` to see the commands');
|
|
2603
2794
|
return name ? HELP[name] : TOP_HELP;
|