sign-off-axi 0.5.7 → 0.5.9
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 +2 -0
- package/dist/cli.js +136 -38
- package/dist/evidence-text.js +16 -6
- package/dist/images.js +274 -0
- package/dist/store.js +33 -1
- package/package.json +2 -2
- package/skills/sign-off-axi/SKILL.md +1 -0
- package/web/index.html +810 -448
package/README.md
CHANGED
|
@@ -87,6 +87,7 @@ One line on what the agent runs per kind; the full reference is [docs/question-k
|
|
|
87
87
|
|---|---|---|
|
|
88
88
|
| Yes or no, pick one | `raise --ask-ref <captain-message-id-or-hold-id> --title ... --recommend ...` | buttons, the recommended one first |
|
|
89
89
|
| Design pick (mocks A/B/C) | `post --type design --mock a=... --mock b=... --link a=<url>` | mocks side by side, an Open live link under each option that has a `--link`, a note on any part |
|
|
90
|
+
| Design review (screens of a product) | `post --kind design --from design.json` | nothing in Decide: it waits under **Designs** for Main to review; once Main passes every screen, the lead posts one card per product that opens them all, phone and desktop, and Main's `promote <key>` puts that card in Decide |
|
|
90
91
|
| Proof update | `post --type evidence --media shot.png` | the capture, its result, Pass / Fail / Needs work |
|
|
91
92
|
| Proof with a file to install | `post --type evidence --media shot.png --attach app.apk` | the capture, and the file with its size and a Download button (any type, up to 1 GB, `SIGN_OFF_ATTACH_MAX_MB` to change) |
|
|
92
93
|
| Explainer video | `post --type explainer --media clip.mp4 --chapter 0:00=Start` | a player with chapter buttons, then Yes or No |
|
|
@@ -118,6 +119,7 @@ sign-off stands alone: the CLI and the skill need no other tool and no superviso
|
|
|
118
119
|
- [Question kinds](docs/question-kinds.md): the reference for each kind.
|
|
119
120
|
- [Contract](docs/contract.md): the message rules agents post by.
|
|
120
121
|
- [File formats](docs/file-formats.md): board and message files.
|
|
122
|
+
- [Vision](VISION.md): who sign-off is for and where it is going.
|
|
121
123
|
|
|
122
124
|
## Development
|
|
123
125
|
|
package/dist/cli.js
CHANGED
|
@@ -7,15 +7,17 @@ import { accessSync, chmodSync, closeSync, constants, copyFileSync, createReadSt
|
|
|
7
7
|
import { lookup } from 'node:dns/promises';
|
|
8
8
|
import { availableParallelism, homedir, tmpdir, userInfo } from 'node:os';
|
|
9
9
|
import { Worker } from 'node:worker_threads';
|
|
10
|
+
import { brotliCompressSync, constants as zlib, gzipSync } from 'node:zlib';
|
|
10
11
|
import { pipeline } from 'node:stream';
|
|
11
12
|
import { basename, delimiter, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
12
13
|
import { parseArgs } from 'node:util';
|
|
13
14
|
import { encode } from '@toon-format/toon';
|
|
14
15
|
import { acknowledgeOwner, checkOwner, deliveries, dispatchOwners, registerOwner } from './owner.js';
|
|
15
16
|
import { AxiError, runAxiCli } from 'axi-sdk-js';
|
|
16
|
-
import { ATTACH_DEFAULT_MB, ATTACH_MAX, ATTACH_NAME, CHAPTERS_MAX, SIGNED_IN, CLEARED, InputError, MEDIA_MAX, NOTE_MAX, REPLY, answersPath, appendAnnotation, appendPartComment, annotationsPath, markCommentsRead, readPartComments, replyComment, asks, appendAnswer, appendDismissed, appendSendBack, appendDesignAnswer, appendMessage, appendRound, awaitsVerdict, lapsed, askEngagement, checkEngagement, markEngagementSeen, appendRead, appendOpened, readOpened, answerDeeper, askDeeper, deeperPath, guidePart, markDeeperRead, readDeeper, readReads, seenAt, isKey, loadDecisions, markDelivered, markSeen, messagesPath, readAnnotations, readAnswers, reading, readDismissed, readItems, sameDecision, readMessages, readRounds, roundsPath, seenBy, seenPath, sentIds, unreadable as reasonOf, writeItems } from './store.js';
|
|
17
|
+
import { ATTACH_DEFAULT_MB, ATTACH_MAX, ATTACH_NAME, CHAPTERS_MAX, SIGNED_IN, CLEARED, InputError, MEDIA_MAX, NOTE_MAX, REPLY, answersPath, appendAnnotation, appendPartComment, annotationsPath, markCommentsRead, readPartComments, replyComment, asks, appendAnswer, appendDismissed, appendPromoted, appendSendBack, appendDesignAnswer, appendMessage, appendRound, awaitsVerdict, lapsed, askEngagement, checkEngagement, markEngagementSeen, appendRead, appendOpened, readOpened, answerDeeper, askDeeper, deeperPath, guidePart, markDeeperRead, readDeeper, readReads, seenAt, isKey, loadDecisions, markDelivered, markSeen, messagesPath, readAnnotations, readAnswers, reading, readDismissed, readItems, readPromoted, sameDecision, readMessages, readRounds, roundsPath, seenBy, seenPath, sentIds, unreadable as reasonOf, writeItems } from './store.js';
|
|
17
18
|
import { ContractError, KINDS, MEDIA_BYTES, REPORT_INIT, checkMessage, diagramPages, diagramViews, guided, mediaFiles, parseMessage, reportPage } from './contract.js';
|
|
18
19
|
import { faults, isStill } from './evidence-text.js';
|
|
20
|
+
import { copiesOf } from './images.js';
|
|
19
21
|
import { deliverVault, errors, readVaultExpiry, sweepVault, takeVaultExpiries, VaultError } from './vault.js';
|
|
20
22
|
const FORMAT = 'https://github.com/umeranjum17/sign-off/blob/main/docs/file-formats.md';
|
|
21
23
|
const CONTRACT = 'https://github.com/umeranjum17/sign-off/blob/main/docs/contract.md';
|
|
@@ -30,8 +32,8 @@ const AGENTS = {
|
|
|
30
32
|
'gemini-cli': { bin: 'gemini', dir: '.agents/skills', home: join(homedir(), '.gemini', 'skills') },
|
|
31
33
|
};
|
|
32
34
|
const TOP_HELP = `usage: sign-off-axi [command] [board.json] [flags]
|
|
33
|
-
commands[
|
|
34
|
-
(none)=list, raise, post, answers, wait, poll, deeper, owner, open, status, stop, serve, export, init, report, signin, dismiss, restore, refile, doctor, notify, validate, help
|
|
35
|
+
commands[24]:
|
|
36
|
+
(none)=list, raise, post, answers, wait, poll, deeper, owner, open, status, stop, serve, export, init, report, signin, dismiss, restore, promote, refile, doctor, notify, validate, help
|
|
35
37
|
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
|
|
36
38
|
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
|
|
37
39
|
examples:
|
|
@@ -225,7 +227,7 @@ An empty items list removes that filer's items.
|
|
|
225
227
|
--type diagram and --type interactive are a decision shown with a picture: --view names one board-relative file,
|
|
226
228
|
an image (.svg, .png, .jpg, .jpeg, .webp, 5 MB or less) shown at card width for a diagram, or one self-contained .html page for interactive.
|
|
227
229
|
The page runs in a sandboxed frame: no fetch, no remote files, no access to the dashboard or its tab. Answers work as for a decision.
|
|
228
|
-
--type evidence posts the next numbered update (key update-<number>): 1 to ${MEDIA_MAX} screenshots or recordings (.png, .jpg, .jpeg, .webp, .mp4, .webm, 50 MB each), copied into evidence/<number>/ next to the board, with a short note and an optional commit or PR link. A still that is not a picture of the product is refused: mostly text (a log, receipt or summary), a colour-bar test card or a placeholder card. Post a real capture of the running product; a colour-bar or placeholder card already posted is not shown on the board.
|
|
230
|
+
--type evidence posts the next numbered update (key update-<number>): 1 to ${MEDIA_MAX} screenshots or recordings (.png, .jpg, .jpeg, .webp, .mp4, .webm, 50 MB each), copied into evidence/<number>/ next to the board (each still also gets a lighter copy and a thumbnail there, which the board shows; the original stays and opens full size), with a short note and an optional commit or PR link. A still that is not a picture of the product is refused: mostly text (a log, receipt or summary), a colour-bar test card or a placeholder card. Post a real capture of the running product; a colour-bar or placeholder card already posted is not shown on the board.
|
|
229
231
|
--attach <file> (up to ${ATTACH_MAX}, any file type, such as an Android .apk) adds a file the user downloads from the card, over the board link, on a phone or tablet. It is copied (never read into memory) into evidence/<number>/ and streamed on download. A file over ${ATTACH_DEFAULT_MB} MB is refused; raise the limit with SIGN_OFF_ATTACH_MAX_MB=<megabytes>. An update may have only --attach files and no --media.
|
|
230
232
|
The user gives a verdict, pass, fail or needs-work, with an optional note; read it with answers or wait --key update-<number>.
|
|
231
233
|
--type explainer is a decision shown with a narrated video you made: one --media .mp4 or .webm (50 MB or less), copied into evidence/<number>/ like evidence
|
|
@@ -254,6 +256,7 @@ examples:
|
|
|
254
256
|
sign-off-axi post --kind plan --from plan.json --filer agent-41
|
|
255
257
|
sign-off-axi post --kind design --from design.json --filer agent-41
|
|
256
258
|
A design message lists project journeys, per-screen image or HTML view variants, and a card preview; post checks every asset stays under the board folder.
|
|
259
|
+
A design shows under Designs for Main to review, never in the user's Decide or any count of asks, until Main runs promote (see promote --help).
|
|
257
260
|
sign-off-axi post .sign-off/decisions.json --type interactive --title "Does the sign-in flow feel right?" --view mocks/sign-in/demo.html --option yes=Yes --option no="Needs work" --recommend yes
|
|
258
261
|
sign-off-axi post --type evidence --title "Sign-in screen on a phone" --about "The new screen loads in 1 second." --media phone.png --media flow.mp4 --link https://github.com/acme/app/pull/12
|
|
259
262
|
sign-off-axi post --type evidence --title "Debug build for the phone" --about "The app installs and signs in." --media phone.png --attach app-debug.apk
|
|
@@ -286,6 +289,15 @@ examples:
|
|
|
286
289
|
`,
|
|
287
290
|
restore: `usage: sign-off-axi restore <key> [board.json]
|
|
288
291
|
Brings back a card that dismiss hid. See \`sign-off-axi dismiss --help\`.
|
|
292
|
+
`,
|
|
293
|
+
promote: `usage: sign-off-axi promote <key> [board.json] [--undo]
|
|
294
|
+
For Main only, never a lead. A design review a lead posts (post --kind design) shows under Designs, where Main reviews it; it never waits in Decide or counts as the user's ask.
|
|
295
|
+
Once Main has passed every screen, the lead posts one design per product that opens all its screens, phone and desktop, and Main promotes that one card:
|
|
296
|
+
it then waits in Decide, counts there and notifies like any ask until the user answers it. --undo puts it back under Designs only.
|
|
297
|
+
It writes one line to <board>.promoted.jsonl beside the board; no post, feed or board request writes that file.
|
|
298
|
+
examples:
|
|
299
|
+
sign-off-axi promote fleet-dashboard-prototype
|
|
300
|
+
sign-off-axi promote fleet-dashboard-prototype --undo
|
|
289
301
|
`,
|
|
290
302
|
report: `usage: sign-off-axi report init <folder> [--plan] [--page]
|
|
291
303
|
Writes the guided template to fill: <folder>/report.json, or <folder>/plan.json with --plan. A file already there is kept.
|
|
@@ -572,6 +584,8 @@ const waitFor = (on, filer, key) => (filer ? `Run \`sign-off-axi wait${on} --fil
|
|
|
572
584
|
: `Run \`sign-off-axi wait${on}${key ? ` --key ${key}` : ''}\` to block until the user answers`);
|
|
573
585
|
/** Still waiting on the user: not answered, not dismissed, not an expired sign-in, and something to answer (an item with no options and no reply is only shown). */
|
|
574
586
|
const waits = (d) => asks(d) && !lapsed(d) && !d.dismissed && awaitsVerdict(d.type, d.answer);
|
|
587
|
+
/** Waiting on the user, as Decide counts it: a design review waits on Main instead until Main promotes it (web/index.html area() says the same); its poster's wait still waits for the verdict. */
|
|
588
|
+
const asksUser = (d) => waits(d) && (d.kind !== 'design' || !!d.promoted);
|
|
575
589
|
/** The items that archive after a day: all of them, except on a board another program feeds, which owns what it shows. */
|
|
576
590
|
const archivable = (file, decisions) => ((x) => (x.readonly === true || x.answers_only === true ? [] : decisions))(readItems(file).extra);
|
|
577
591
|
/** The board's decisions with their latest answer; a missing board file is an empty board.
|
|
@@ -584,7 +598,8 @@ function board(file, say = warn) {
|
|
|
584
598
|
for (const key of Object.keys(answers))
|
|
585
599
|
if (!decisions.some((d) => d.key === key))
|
|
586
600
|
say(`left out the answer for ${key}: that question is no longer in ${basename(file)}`);
|
|
587
|
-
|
|
601
|
+
const promoted = new Set(readPromoted(file, say));
|
|
602
|
+
return decisions.map((d) => ({ ...d, answer: Object.hasOwn(answers, d.key) ? answers[d.key] : undefined, dismissed: hidden.has(d.key), ...(promoted.has(d.key) && { promoted: true }) }));
|
|
588
603
|
}
|
|
589
604
|
function ready(file, on, root) {
|
|
590
605
|
if (!existsSync(file))
|
|
@@ -604,7 +619,7 @@ function ready(file, on, root) {
|
|
|
604
619
|
}
|
|
605
620
|
function list(args) {
|
|
606
621
|
const { file, values, on } = parse('list', args, TIERS);
|
|
607
|
-
const rows = board(file), waiting = rows.filter(
|
|
622
|
+
const rows = board(file), waiting = rows.filter(asksUser), answered = rows.filter((d) => d.answer && !awaitsVerdict(d.type, d.answer) && !d.dismissed).length;
|
|
608
623
|
if (values.json)
|
|
609
624
|
return JSON.stringify({ board: file, decisions: rows.map(({ dismissed, ...d }) => ({ ...d, ...(d.type === 'signin' && { link: undefined }), ...(dismissed && { dismissed }) })) });
|
|
610
625
|
const help = [
|
|
@@ -614,7 +629,7 @@ function list(args) {
|
|
|
614
629
|
];
|
|
615
630
|
const where = tilde(file) + (existsSync(file) ? '' : ' (no file yet; raise creates it)');
|
|
616
631
|
if (values.full) {
|
|
617
|
-
return toon({ board: where, decisions: rows.map((d) => ({ key: d.key, status: awaitsVerdict(d.type, d.answer) ? (d.dismissed ? 'dismissed' : d.type === 'evidence' ? 'shown' : 'waiting') : d.answer.answer, title: d.title, about: d.about ?? '', detail: d.detail ?? '',
|
|
632
|
+
return toon({ board: where, decisions: rows.map((d) => ({ key: d.key, status: awaitsVerdict(d.type, d.answer) ? (d.dismissed ? 'dismissed' : d.type === 'evidence' ? 'shown' : d.kind === 'design' && !d.promoted ? 'in review' : 'waiting') : d.answer.answer, title: d.title, about: d.about ?? '', detail: d.detail ?? '',
|
|
618
633
|
recommend: d.recommend_value ?? '', default_if_silent: d.default_if_silent ?? '', close: d.close })) }, help);
|
|
619
634
|
}
|
|
620
635
|
return toon({
|
|
@@ -1200,6 +1215,24 @@ function dismiss(restoring) {
|
|
|
1200
1215
|
});
|
|
1201
1216
|
};
|
|
1202
1217
|
}
|
|
1218
|
+
/** promote <key> [board.json] [--undo]: Main puts a design review it passed in front of the user, in Decide; --undo takes it back to Designs. */
|
|
1219
|
+
function promote(args) {
|
|
1220
|
+
const at = args.findIndex((a) => !a.startsWith('-') && !existsSync(a) && !a.endsWith('.json')), key = args[at];
|
|
1221
|
+
if (at < 0 || !isKey(key))
|
|
1222
|
+
throw fail('promote needs a design card key', 'VALIDATION_ERROR', 'sign-off-axi promote <key> [board.json]');
|
|
1223
|
+
const { file, named, values, on } = parse('promote', args.filter((_, i) => i !== at), { undo: { type: 'boolean' } });
|
|
1224
|
+
return locked(`${file}.lock`, () => {
|
|
1225
|
+
const d = board(file).find((x) => x.key === key);
|
|
1226
|
+
if (!d)
|
|
1227
|
+
throw fail(`no card with key ${key} on ${tilde(file)}`, 'NOT_FOUND', `Run \`sign-off-axi list${on} --full\` to see the keys`);
|
|
1228
|
+
if (d.kind !== 'design')
|
|
1229
|
+
throw fail(`${key} is not a design review`, 'VALIDATION_ERROR', 'Only a design posted with post --kind design is promoted; every other ask already waits in Decide');
|
|
1230
|
+
const before = waitingKeys(file);
|
|
1231
|
+
appendPromoted(file, key, !values.undo);
|
|
1232
|
+
notifyEntered(file, named, before);
|
|
1233
|
+
return toon({ board: tilde(file), [values.undo ? 'unpromoted' : 'promoted']: [key] }, [values.undo ? `It shows under Designs only. Run \`sign-off-axi promote${on} ${key}\` to put it in Decide again` : `It waits in Decide. Run \`sign-off-axi promote${on} ${key} --undo\` to take it back`]);
|
|
1234
|
+
});
|
|
1235
|
+
}
|
|
1203
1236
|
/** refile <key> [board.json] --media <file>...: an old proof posted as a Pass/Fail ask becomes an evidence update (title and note kept), and the ask is dismissed. */
|
|
1204
1237
|
async function refile(args) {
|
|
1205
1238
|
const at = args.findIndex((a) => !a.startsWith('-') && !existsSync(a) && !a.endsWith('.json')), key = args[at];
|
|
@@ -1417,11 +1450,23 @@ function postMedia(file, named, on, values, type, filer, message) {
|
|
|
1417
1450
|
}
|
|
1418
1451
|
const key = own || `update-${number}`, dir = join(store, String(number));
|
|
1419
1452
|
let media, attachments = [];
|
|
1453
|
+
const full = [];
|
|
1420
1454
|
try {
|
|
1421
1455
|
media = sources.map((src, i) => {
|
|
1422
1456
|
const bytes = readFileSync(src), path = `evidence/${number}/${i + 1}${extname(src).toLowerCase()}`;
|
|
1423
1457
|
writeFileSync(join(dir, `${i + 1}${extname(src).toLowerCase()}`), bytes, { mode: 0o600 });
|
|
1424
|
-
|
|
1458
|
+
// The original stays as it was posted; the board shows a lighter copy of a still where one weighs less.
|
|
1459
|
+
const made = isStill(src) ? copiesOf(bytes, extname(src)) : {}, refs = {};
|
|
1460
|
+
for (const size of ['light', 'thumb']) {
|
|
1461
|
+
const c = made[size];
|
|
1462
|
+
if (!c)
|
|
1463
|
+
continue;
|
|
1464
|
+
writeFileSync(join(dir, `${i + 1}-${size}${c.ext}`), c.bytes, { mode: 0o600 });
|
|
1465
|
+
refs[size] = { path: `evidence/${number}/${i + 1}-${size}${c.ext}`, sha256: sha256(c.bytes), bytes: c.bytes.length, width: c.width, height: c.height };
|
|
1466
|
+
}
|
|
1467
|
+
if (made.reason)
|
|
1468
|
+
full.push(`Capture ${i + 1} shows at full size: ${made.reason}`);
|
|
1469
|
+
return { path, sha256: sha256(bytes), bytes: bytes.length, ...refs };
|
|
1425
1470
|
});
|
|
1426
1471
|
// An attachment is copied by the kernel and hashed in chunks: the file is never held in memory.
|
|
1427
1472
|
attachments = attached.map(({ src, ext, name }, i) => {
|
|
@@ -1459,7 +1504,7 @@ function postMedia(file, named, on, values, type, filer, message) {
|
|
|
1459
1504
|
notifyEntered(file, named, before);
|
|
1460
1505
|
if (explainer)
|
|
1461
1506
|
return toon({ board: tilde(file), raised: [{ key, status: 'raised', title }] }, [`Run \`sign-off-axi open${on}\` so the user can watch and answer`, waitFor(on, filer, key)]);
|
|
1462
|
-
return toon({ board: tilde(file), posted: [{ key, number, media: media.length, ...(attachments.length && { attached: attachments.length }), title, ...(message && { status: 'posted' }) }] });
|
|
1507
|
+
return toon({ board: tilde(file), posted: [{ key, number, media: media.length, ...(attachments.length && { attached: attachments.length }), title, ...(message && { status: 'posted' }) }] }, full);
|
|
1463
1508
|
});
|
|
1464
1509
|
}
|
|
1465
1510
|
/** post --type report: the flags as one report message, posted as post --kind report posts it. A report with no page is refused before anything is read. */
|
|
@@ -1957,7 +2002,7 @@ const skillInstalled = (dir, agent) => [join(dir, AGENTS[agent].dir), AGENTS[age
|
|
|
1957
2002
|
.some((skills) => existsSync(join(skills, 'sign-off-axi', 'SKILL.md')));
|
|
1958
2003
|
const SKILL_ADD = ['-y', 'skills', 'add', 'umeranjum17/sign-off', '--skill', 'sign-off-axi', '-y', '-a'];
|
|
1959
2004
|
const skillAdd = (agents) => `npx ${[...SKILL_ADD, ...agents].join(' ')}`;
|
|
1960
|
-
const GITIGNORE = 'mocks/\nevidence/\n*.answers.jsonl\n*.rounds.jsonl\n*.messages.jsonl\n*.annotations.jsonl\n*.seen.jsonl\n*.dismissed.jsonl\n*.reads.jsonl\n*.deeper.jsonl\n*.owners.json\n*.delivery.jsonl\n*.board.pid\n*.board.log\nnotify.log\n*.token\n*.export.html\n';
|
|
2005
|
+
const GITIGNORE = 'mocks/\nevidence/\n*.answers.jsonl\n*.rounds.jsonl\n*.messages.jsonl\n*.annotations.jsonl\n*.seen.jsonl\n*.dismissed.jsonl\n*.promoted.jsonl\n*.reads.jsonl\n*.deeper.jsonl\n*.owners.json\n*.delivery.jsonl\n*.board.pid\n*.board.log\nnotify.log\n*.token\n*.export.html\n';
|
|
1961
2006
|
/** The board, port, bind and hosts in <so>/config.yaml (the `key: value` lines init writes). A bad line falls back on its own default
|
|
1962
2007
|
* (decisions.json, 4410) and `problems` says what is wrong; a file that cannot be read throws. */
|
|
1963
2008
|
function readSetup(so) {
|
|
@@ -2041,7 +2086,7 @@ const tokenMissing = (n) => n.muxr
|
|
|
2041
2086
|
: n.token_env && !process.env[n.token_env] ? `${n.token_env} is not set` : undefined;
|
|
2042
2087
|
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')})();`;
|
|
2043
2088
|
/** Cards worth a notice: those waiting on the user, and a new evidence update (shown, never waiting). */
|
|
2044
|
-
const notifies = (d) =>
|
|
2089
|
+
const notifies = (d) => asksUser(d) || (d.type === 'evidence' && !d.dismissed);
|
|
2045
2090
|
/** The cards that would notify before a write: one that notifies after the write but not before it is new to the user. */
|
|
2046
2091
|
function waitingKeys(file) {
|
|
2047
2092
|
try {
|
|
@@ -2502,7 +2547,7 @@ async function exportBoard(args) {
|
|
|
2502
2547
|
let missing = 0;
|
|
2503
2548
|
// The same URLs the page asks the server for, read through the same hash and path checks.
|
|
2504
2549
|
const inline = async (url, query, document = false) => {
|
|
2505
|
-
const [code, body] = await route({ method: 'GET', url }).catch(() => [404, null]);
|
|
2550
|
+
const [code, body] = await route({ method: 'GET', url, headers: {} }).catch(() => [404, null]);
|
|
2506
2551
|
if (code !== 200) {
|
|
2507
2552
|
missing++;
|
|
2508
2553
|
return;
|
|
@@ -2524,8 +2569,9 @@ async function exportBoard(args) {
|
|
|
2524
2569
|
await inline(at(d.key, '', d.view), undefined, true);
|
|
2525
2570
|
if (d.preview)
|
|
2526
2571
|
await inline(at(d.key, '', d.preview));
|
|
2527
|
-
for (const i of (d.media ?? []).
|
|
2528
|
-
|
|
2572
|
+
for (const [i, m] of (d.media ?? []).entries())
|
|
2573
|
+
for (const v of ['', ...['light', 'thumb'].filter((v) => m[v])])
|
|
2574
|
+
await inline(`/api/media?key=${q(d.key)}&i=${i}${v && `&v=${v}`}`);
|
|
2529
2575
|
for (const m of d.evidenceCard?.media ?? [])
|
|
2530
2576
|
await inline(m.url);
|
|
2531
2577
|
}
|
|
@@ -2539,6 +2585,26 @@ async function exportBoard(args) {
|
|
|
2539
2585
|
return toon({ board: tilde(parsed.file), exported: tilde(out), items: frozen.decisions.length, inlined: Object.keys(files).length, ...(missing && { missing }) }, [`Open ${tilde(out)} in a browser: one read-only file, no server needed`]);
|
|
2540
2586
|
}
|
|
2541
2587
|
const page = readFileSync(new URL('../web/index.html', import.meta.url));
|
|
2588
|
+
/** Text a browser fetches is sent compressed when it takes that: the page and the board's state are mostly text and shrink 4-8x, which on a
|
|
2589
|
+
* phone's network is most of the wait. Brotli at a fast level for each reply; the page itself is squeezed once, on its first request. */
|
|
2590
|
+
const COMPRESSIBLE = /^(text\/|application\/(json|javascript|manifest\+json)|image\/svg)/;
|
|
2591
|
+
const squeeze = (req, bytes) => {
|
|
2592
|
+
const takes = String(req.headers['accept-encoding'] ?? '');
|
|
2593
|
+
if (bytes.length < 1024)
|
|
2594
|
+
return { bytes };
|
|
2595
|
+
if (/\bbr\b/.test(takes))
|
|
2596
|
+
return { bytes: brotliCompressSync(bytes, { params: { [zlib.BROTLI_PARAM_QUALITY]: 5, [zlib.BROTLI_PARAM_SIZE_HINT]: bytes.length } }), encoding: 'br' };
|
|
2597
|
+
if (/\bgzip\b/.test(takes))
|
|
2598
|
+
return { bytes: gzipSync(bytes, { level: 6 }), encoding: 'gzip' };
|
|
2599
|
+
return { bytes };
|
|
2600
|
+
};
|
|
2601
|
+
const pageTag = `"${createHash('sha256').update(page).digest('hex').slice(0, 32)}"`;
|
|
2602
|
+
// Level 9 takes about 50 ms and level 11 over half a second for 7% less, so the page is squeezed at 9.
|
|
2603
|
+
const squeezed = {};
|
|
2604
|
+
const pageAs = (encoding) => (squeezed[encoding] ??= encoding === 'br'
|
|
2605
|
+
? brotliCompressSync(page, { params: { [zlib.BROTLI_PARAM_QUALITY]: 9, [zlib.BROTLI_PARAM_SIZE_HINT]: page.length } }) : gzipSync(page, { level: 9 }));
|
|
2606
|
+
/** The browser keeps a reply and asks again with its tag, so an unchanged page, state or file costs a 304 and no body. */
|
|
2607
|
+
const unchanged = (req, tag) => req.headers['if-none-match']?.split(/\s*,\s*/).includes(tag) ?? false;
|
|
2542
2608
|
/** The board's design tokens, favicon, home-screen icon and install manifest, served beside the page; see web/tokens.css and web/icon.svg. */
|
|
2543
2609
|
const ICONS = Object.fromEntries([['tokens.css', 'text/css; charset=utf-8'], ['icon.svg', 'image/svg+xml'], ['apple-touch-icon.png', 'image/png'], ['icon-192.png', 'image/png'], ['icon-512.png', 'image/png'], ['icon-maskable-512.png', 'image/png'], ['manifest.webmanifest', 'application/manifest+json']]
|
|
2544
2610
|
.map(([name, type]) => [`/${name}`, { type, bytes: readFileSync(new URL(`../web/${name}`, import.meta.url)) }]));
|
|
@@ -2641,15 +2707,17 @@ const state = () => {
|
|
|
2641
2707
|
// The page marks sent comments in annotations, so answers and rounds go without theirs: a read-only board or an export carries none.
|
|
2642
2708
|
const answers = Object.fromEntries(Object.entries(readAnswers(file, warn)).map(([k, { comments: _, ...a }]) => [k, a])), taken = seenBy(file);
|
|
2643
2709
|
const rounds = readRounds(file, warn).map(({ comments: _, ...r }) => r);
|
|
2710
|
+
// The board file is read once for the whole state: it can hold thousands of items.
|
|
2711
|
+
const { extra } = readItems(file), fed = extra.readonly === true || extra.answers_only === true, items = loadDecisions(file);
|
|
2644
2712
|
return {
|
|
2645
2713
|
file,
|
|
2646
2714
|
mockBase: `/mock/${loopback(bind) ? 'local' : mockPass(tokenOf(file))}/`,
|
|
2647
2715
|
// Who filed an item stays in the file: the page only learns that its filer took the answer.
|
|
2648
2716
|
// A sign-in's start command stays on this host too: the page only learns that a tap makes a new link, and how the last run went.
|
|
2649
|
-
decisions:
|
|
2717
|
+
decisions: items.filter((d) => !fakeEvidence(d)).map(({ filer: _, start, ...d }) => ({ ...d, ...(d.type === 'design' && answers[d.key]?.answer === 'approve' && { status: 'approved' }), ...(start && { start: true, run: signinState(d.key) }), evidenceCard: d.evidence ? evidenceCard(d.evidence) : null,
|
|
2650
2718
|
...(d.mocks && { mocks: d.mocks.map((m) => ({ ...m, hash: mockHash(m.path) })) }) })),
|
|
2651
|
-
readonly:
|
|
2652
|
-
answers_only:
|
|
2719
|
+
readonly: extra.readonly === true,
|
|
2720
|
+
answers_only: extra.answers_only === true,
|
|
2653
2721
|
...profile(),
|
|
2654
2722
|
answers,
|
|
2655
2723
|
seen: Object.values(answers).filter(taken).map((a) => a.key),
|
|
@@ -2660,19 +2728,28 @@ const state = () => {
|
|
|
2660
2728
|
delivery: deliveries(file),
|
|
2661
2729
|
rounds,
|
|
2662
2730
|
messages: readMessages(file, warn),
|
|
2663
|
-
annotations:
|
|
2664
|
-
comments:
|
|
2665
|
-
dismissed: readDismissed(file, answers, rounds, warn,
|
|
2731
|
+
annotations: fed ? [] : ((sent) => readAnnotations(file, warn).map((a) => (sent.has(a.id) ? { ...a, sent: true } : a)))(sentIds(file)),
|
|
2732
|
+
comments: fed ? [] : readPartComments(file),
|
|
2733
|
+
dismissed: readDismissed(file, answers, rounds, warn, fed ? [] : items),
|
|
2734
|
+
promoted: readPromoted(file, warn),
|
|
2666
2735
|
};
|
|
2667
2736
|
};
|
|
2668
2737
|
/** A mock file's sha256 now, or null when it is gone: a comment shows only on the exact file it was made on.
|
|
2669
|
-
*
|
|
2670
|
-
const
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2738
|
+
* Kept per file while its size, change time and inode stay the same, so a state read does not rehash every mock. */
|
|
2739
|
+
const mockHashes = new Map();
|
|
2740
|
+
const mockHash = (path) => {
|
|
2741
|
+
try {
|
|
2742
|
+
const actual = under(dirname(file), mockFile(path)), st = statSync(actual), at = `${st.ino}:${st.size}:${st.mtimeMs}:${st.ctimeMs}`, kept = mockHashes.get(actual);
|
|
2743
|
+
if (kept?.at === at)
|
|
2744
|
+
return kept.hash;
|
|
2745
|
+
const hash = sha256(readFileSync(actual));
|
|
2746
|
+
mockHashes.set(actual, { at, hash });
|
|
2747
|
+
return hash;
|
|
2748
|
+
}
|
|
2749
|
+
catch {
|
|
2750
|
+
return null;
|
|
2751
|
+
}
|
|
2752
|
+
};
|
|
2676
2753
|
/** The comments that go with a card's next answer or round: not sent yet, and made on the mock file the card shows now. */
|
|
2677
2754
|
const unsent = (d) => {
|
|
2678
2755
|
const sent = sentIds(file), now = new Map((d.mocks ?? []).map((m) => [m.value, mockHash(m.path)]));
|
|
@@ -2830,18 +2907,22 @@ async function route(req) {
|
|
|
2830
2907
|
}
|
|
2831
2908
|
if (req.method === 'GET' && req.url?.startsWith('/api/media?')) {
|
|
2832
2909
|
const q = new URL(req.url, 'http://localhost').searchParams;
|
|
2833
|
-
|
|
2834
|
-
const
|
|
2910
|
+
// v=light or v=thumb asks for the copy post made of a still; with no v, the file as it was posted.
|
|
2911
|
+
const v = q.get('v'), posted = loadDecisions(file).find((d) => d.key === q.get('key'))?.media?.[Number(q.get('i') || NaN)];
|
|
2912
|
+
const m = v === null ? posted : v === 'light' || v === 'thumb' ? posted?.[v] : undefined, type = m && mediaTypes[extname(m.path).toLowerCase()];
|
|
2835
2913
|
if (!m || !type)
|
|
2836
2914
|
return [404, { error: 'Evidence is unavailable' }];
|
|
2837
2915
|
try {
|
|
2838
|
-
const actual = under(dirname(file), m.path);
|
|
2916
|
+
const actual = under(dirname(file), m.path), forever = q.get('h') === m.sha256.slice(0, 16);
|
|
2839
2917
|
if (statSync(actual).size !== m.bytes)
|
|
2840
2918
|
return [404, { error: 'Evidence file changed since it was posted' }];
|
|
2919
|
+
// The browser already holds this exact capture: no read and no hash.
|
|
2920
|
+
if (unchanged(req, `"${m.sha256.slice(0, 32)}"`))
|
|
2921
|
+
return [200, { media: Buffer.alloc(0), type, tag: m.sha256.slice(0, 32), forever }];
|
|
2841
2922
|
const bytes = readFileSync(actual);
|
|
2842
2923
|
if (sha256(bytes) !== m.sha256)
|
|
2843
2924
|
return [404, { error: 'Evidence file changed since it was posted' }];
|
|
2844
|
-
return [200, { media: bytes, type }];
|
|
2925
|
+
return [200, { media: bytes, type, tag: m.sha256.slice(0, 32), forever }];
|
|
2845
2926
|
}
|
|
2846
2927
|
catch {
|
|
2847
2928
|
return [404, { error: 'Evidence is unavailable' }];
|
|
@@ -3200,7 +3281,11 @@ const server = createServer(async (req, res) => {
|
|
|
3200
3281
|
if (req.method === 'GET' && req.url === '/favicon.ico')
|
|
3201
3282
|
return res.writeHead(204).end();
|
|
3202
3283
|
if (req.method === 'GET' && req.url === '/') {
|
|
3203
|
-
|
|
3284
|
+
const headers = { 'content-type': 'text/html; charset=utf-8', 'content-security-policy': "default-src 'self' 'unsafe-inline'", etag: pageTag, 'cache-control': 'no-cache', vary: 'accept-encoding' };
|
|
3285
|
+
if (unchanged(req, pageTag))
|
|
3286
|
+
return res.writeHead(304, headers).end();
|
|
3287
|
+
const takes = String(req.headers['accept-encoding'] ?? ''), encoding = /\bbr\b/.test(takes) ? 'br' : /\bgzip\b/.test(takes) ? 'gzip' : undefined, bytes = encoding ? pageAs(encoding) : page;
|
|
3288
|
+
return res.writeHead(200, { ...headers, ...(encoding && { 'content-encoding': encoding }), 'content-length': bytes.length }).end(bytes);
|
|
3204
3289
|
}
|
|
3205
3290
|
let status, body;
|
|
3206
3291
|
try {
|
|
@@ -3223,19 +3308,32 @@ const server = createServer(async (req, res) => {
|
|
|
3223
3308
|
return bytes ? pipeline(createReadStream(path, { start, end }), res, () => { }) : res.end();
|
|
3224
3309
|
}
|
|
3225
3310
|
if (body && typeof body === 'object' && 'media' in body) {
|
|
3226
|
-
const { media: bytes, type, point, page
|
|
3227
|
-
const
|
|
3311
|
+
const { media: bytes, type, point, page, tag: stored, forever } = body;
|
|
3312
|
+
const media = point ? Buffer.concat([bytes, pointer, ...(page ? [page] : [])]) : bytes, tag = `"${stored ?? sha256(media).slice(0, 32)}"`;
|
|
3313
|
+
// A capture named by its hash never changes, so the browser keeps it; any other file is asked again with its tag.
|
|
3314
|
+
const kept = { etag: tag, 'cache-control': forever ? 'private, max-age=31536000, immutable' : 'private, no-cache' };
|
|
3315
|
+
if (status === 200 && unchanged(req, tag))
|
|
3316
|
+
return res.writeHead(304, kept).end();
|
|
3317
|
+
const headers = { 'content-type': type, 'x-content-type-options': 'nosniff', 'content-security-policy': FRAME_CSP, 'accept-ranges': 'bytes', 'content-length': media.length, ...(status === 200 && kept) };
|
|
3228
3318
|
// One byte range, so phone browsers can play and seek a recording.
|
|
3229
3319
|
const range = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range ?? '');
|
|
3230
|
-
if (!range || (!range[1] && !range[2]))
|
|
3231
|
-
|
|
3320
|
+
if (!range || (!range[1] && !range[2])) {
|
|
3321
|
+
const sent = COMPRESSIBLE.test(type) ? squeeze(req, media) : { bytes: media };
|
|
3322
|
+
return res.writeHead(status, { ...headers, 'content-length': sent.bytes.length, ...(sent.encoding && { 'content-encoding': sent.encoding, vary: 'accept-encoding' }) }).end(sent.bytes);
|
|
3323
|
+
}
|
|
3232
3324
|
const start = range[1] ? Number(range[1]) : Math.max(0, media.length - Number(range[2]));
|
|
3233
3325
|
const end = range[1] && range[2] ? Math.min(Number(range[2]), media.length - 1) : media.length - 1;
|
|
3234
3326
|
if (start > end)
|
|
3235
3327
|
return res.writeHead(416, { 'content-range': `bytes */${media.length}` }).end();
|
|
3236
3328
|
return res.writeHead(206, { ...headers, 'content-length': end - start + 1, 'content-range': `bytes ${start}-${end}/${media.length}` }).end(media.subarray(start, end + 1));
|
|
3237
3329
|
}
|
|
3238
|
-
|
|
3330
|
+
const json = Buffer.from(JSON.stringify(body));
|
|
3331
|
+
// The page reads the state again every 20 seconds: unchanged, that read is a 304 with no body.
|
|
3332
|
+
const tag = req.method === 'GET' && status === 200 ? `"${sha256(json).slice(0, 32)}"` : undefined;
|
|
3333
|
+
if (tag && unchanged(req, tag))
|
|
3334
|
+
return res.writeHead(304, { etag: tag, 'cache-control': 'no-cache' }).end();
|
|
3335
|
+
const sent = squeeze(req, json);
|
|
3336
|
+
res.writeHead(status, { 'content-type': 'application/json', 'content-length': sent.bytes.length, ...(sent.encoding && { 'content-encoding': sent.encoding }), vary: 'accept-encoding', ...(tag && { etag: tag, 'cache-control': 'no-cache' }) }).end(sent.bytes);
|
|
3239
3337
|
});
|
|
3240
3338
|
const argv = process.argv.slice(2);
|
|
3241
3339
|
// Flags with no command belong to the default list view (sign-off-axi --json).
|
|
@@ -3248,7 +3346,7 @@ await runAxiCli({
|
|
|
3248
3346
|
topLevelHelp: TOP_HELP,
|
|
3249
3347
|
getCommandHelp: (cmd) => HELP[cmd],
|
|
3250
3348
|
home: axi('list', list),
|
|
3251
|
-
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), owner: axi('owner', owner), deeper: axi('deeper', deeper), signin: axi('signin', signin), dismiss: axi('dismiss', dismiss(false)), restore: axi('restore', dismiss(true)), refile: axi('refile', refile), doctor: axi('doctor', doctor), notify: axi('notify', notify), validate, help: ([name, ...rest]) => {
|
|
3349
|
+
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), owner: axi('owner', owner), deeper: axi('deeper', deeper), signin: axi('signin', signin), dismiss: axi('dismiss', dismiss(false)), restore: axi('restore', dismiss(true)), promote: axi('promote', promote), refile: axi('refile', refile), doctor: axi('doctor', doctor), notify: axi('notify', notify), validate, help: ([name, ...rest]) => {
|
|
3252
3350
|
if (rest.length || (name && !HELP[name]))
|
|
3253
3351
|
throw fail(`unknown command or argument: ${[name, ...rest].join(' ')}`, 'VALIDATION_ERROR', 'Run `sign-off-axi --help` to see the commands');
|
|
3254
3352
|
return name ? HELP[name] : TOP_HELP;
|
package/dist/evidence-text.js
CHANGED
|
@@ -157,9 +157,11 @@ function convert(file) {
|
|
|
157
157
|
});
|
|
158
158
|
}
|
|
159
159
|
/** Packed 8-bit RGB of a PNG, every colour type and bit depth, interlaced or not. undefined for a PNG it does not read. */
|
|
160
|
-
|
|
160
|
+
const pngRgb = (buf) => pngPixels(buf, 3)?.data;
|
|
161
|
+
/** A PNG's pixels as packed 8-bit RGB (3 channels) or RGBA (4, with its alpha channel or palette transparency), and its size. */
|
|
162
|
+
export function pngPixels(buf, out) {
|
|
161
163
|
try {
|
|
162
|
-
let width = 0, height = 0, type = 0, depth = 0, interlace = 0, palette;
|
|
164
|
+
let width = 0, height = 0, type = 0, depth = 0, interlace = 0, palette, trns;
|
|
163
165
|
const idat = [];
|
|
164
166
|
for (let at = 8; at + 8 <= buf.length;) {
|
|
165
167
|
const len = buf.readUInt32BE(at), name = buf.toString('latin1', at + 4, at + 8), data = buf.subarray(at + 8, at + 8 + len);
|
|
@@ -167,6 +169,8 @@ function pngRgb(buf) {
|
|
|
167
169
|
[width, height, depth, type, interlace] = [data.readUInt32BE(0), data.readUInt32BE(4), data[8], data[9], data[12]];
|
|
168
170
|
else if (name === 'PLTE')
|
|
169
171
|
palette = data;
|
|
172
|
+
else if (name === 'tRNS')
|
|
173
|
+
trns = data;
|
|
170
174
|
else if (name === 'IDAT')
|
|
171
175
|
idat.push(data);
|
|
172
176
|
else if (name === 'IEND')
|
|
@@ -177,7 +181,7 @@ function pngRgb(buf) {
|
|
|
177
181
|
if (!channels || ![1, 2, 4, 8, 16].includes(depth) || (type === 3 && (!palette || depth > 8)))
|
|
178
182
|
return undefined;
|
|
179
183
|
const bits = channels * depth, bpp = Math.max(1, bits >> 3), raw = inflateSync(Buffer.concat(idat));
|
|
180
|
-
const rgb = Buffer.alloc(width * height *
|
|
184
|
+
const rgb = Buffer.alloc(width * height * out, 255);
|
|
181
185
|
const to8 = (v) => (depth < 8 ? Math.round((v * 255) / ((1 << depth) - 1)) : v);
|
|
182
186
|
let at = 0;
|
|
183
187
|
for (const [x0, y0, dx, dy] of interlace ? ADAM7 : WHOLE) {
|
|
@@ -215,25 +219,31 @@ function pngRgb(buf) {
|
|
|
215
219
|
};
|
|
216
220
|
for (let y = 0; y < ph; y++) {
|
|
217
221
|
for (let x = 0; x < pw; x++) {
|
|
218
|
-
const n = x * channels, o = ((y0 + y * dy) * width + x0 + x * dx) *
|
|
222
|
+
const n = x * channels, o = ((y0 + y * dy) * width + x0 + x * dx) * out;
|
|
219
223
|
if (type === 3) {
|
|
220
|
-
const
|
|
224
|
+
const v = sample(y, n), k = v * 3;
|
|
221
225
|
rgb[o] = palette[k] ?? 0;
|
|
222
226
|
rgb[o + 1] = palette[k + 1] ?? 0;
|
|
223
227
|
rgb[o + 2] = palette[k + 2] ?? 0;
|
|
228
|
+
if (out === 4)
|
|
229
|
+
rgb[o + 3] = trns?.[v] ?? 255;
|
|
224
230
|
}
|
|
225
231
|
else if (channels <= 2) {
|
|
226
232
|
rgb[o] = rgb[o + 1] = rgb[o + 2] = to8(sample(y, n));
|
|
233
|
+
if (out === 4 && channels === 2)
|
|
234
|
+
rgb[o + 3] = to8(sample(y, n + 1));
|
|
227
235
|
}
|
|
228
236
|
else {
|
|
229
237
|
rgb[o] = to8(sample(y, n));
|
|
230
238
|
rgb[o + 1] = to8(sample(y, n + 1));
|
|
231
239
|
rgb[o + 2] = to8(sample(y, n + 2));
|
|
240
|
+
if (out === 4 && channels === 4)
|
|
241
|
+
rgb[o + 3] = to8(sample(y, n + 3));
|
|
232
242
|
}
|
|
233
243
|
}
|
|
234
244
|
}
|
|
235
245
|
}
|
|
236
|
-
return rgb;
|
|
246
|
+
return { width, height, data: rgb };
|
|
237
247
|
}
|
|
238
248
|
catch {
|
|
239
249
|
return undefined;
|