sign-off-axi 0.3.2 → 0.3.3
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 +5 -5
- package/dist/cli.js +124 -25
- package/dist/contract.js +69 -27
- package/dist/store.js +4 -0
- package/dist/vault.js +254 -21
- package/fixtures/contract/README.md +1 -1
- package/fixtures/contract/finding.v1/invalid-cause-missing.json +1 -0
- package/fixtures/contract/finding.v1/invalid-change_kind-unknown.json +1 -0
- package/fixtures/contract/finding.v1/invalid-evidence_ids-repeated.json +1 -0
- package/fixtures/contract/finding.v1/invalid-options-label-changed.json +1 -0
- package/fixtures/contract/finding.v1/invalid-proposal-too-long.json +1 -0
- package/fixtures/contract/finding.v1/invalid-record_text-unknown.json +1 -0
- package/fixtures/contract/finding.v1/valid-apply.json +1 -0
- package/fixtures/contract/finding.v1/valid-skip-no-records.json +1 -0
- package/fixtures/contract/gate.v1/invalid-default_if_silent-unknown.json +1 -0
- package/fixtures/contract/gate.v1/invalid-options-label-changed.json +1 -0
- package/fixtures/contract/gate.v1/invalid-options-too-few.json +1 -0
- package/fixtures/contract/gate.v1/invalid-run-missing.json +1 -0
- package/fixtures/contract/gate.v1/invalid-run-outcome-unknown.json +1 -0
- package/fixtures/contract/gate.v1/valid-three-actions.json +1 -0
- package/fixtures/contract/gate.v1/valid-two-actions.json +1 -0
- package/package.json +1 -1
- package/web/index.html +42 -19
package/README.md
CHANGED
|
@@ -131,7 +131,7 @@ sign-off-axi post --type explainer --title "<question>" --media <video> [--about
|
|
|
131
131
|
sign-off-axi init [dir] [--agent auto|<name>] write the repo setup under <dir>/.sign-off/ (config, board, mocks/ and evidence/ folders)
|
|
132
132
|
and install the skill for each chosen coding agent (auto: each one on PATH)
|
|
133
133
|
sign-off-axi doctor [dir] check that setup and each agent's skill; every failure names its fix
|
|
134
|
-
sign-off-axi validate <message.json>... [--kind <kind>] [--expect-invalid] check ask, report and
|
|
134
|
+
sign-off-axi validate <message.json>... [--kind <kind>] [--expect-invalid] check ask, report, evidence, gate and finding messages against the contract (docs/contract.md) without posting
|
|
135
135
|
sign-off-axi open [--port 4410] [--bind <address>] [--evidence-root <dir>] start the board in the background; prints its URL and pid
|
|
136
136
|
sign-off-axi status is the board open started for this file running? its URL and pid
|
|
137
137
|
sign-off-axi stop stop that board
|
|
@@ -145,12 +145,12 @@ sign-off-axi export [--out <file.html>] write a frozen read-only copy o
|
|
|
145
145
|
|
|
146
146
|
- The board is the one you name right after the command (`sign-off-axi answers ./decisions.json`). With none, it is the nearest `.sign-off/` board that `init` set up, found by the walk described in [Test runs never touch your board](#test-runs-never-touch-your-board); otherwise the default board there (`~/.sign-off/decisions.json`, or under `SIGN_OFF_HOME`). Answers are saved next to it.
|
|
147
147
|
- Next-step commands in the output name paths under the working folder relative to it, and say `npx -y sign-off-axi` when sign-off-axi runs through `npx`.
|
|
148
|
-
- `post --type design` asks for a pick between up to 6 mock options, each with an optional `--mock <value>=<path>` file (`.html`, `.svg`, `.png`, `.jpg`, `.jpeg`, `.webp`) shown as a tall phone frame above its button (the recommended one first; on a phone the frames swipe sideways, the next one peeking in, and a tap opens one full size), plus a `--tradeoff` line and a `--recommend` badge. Mock paths are relative to the board file's folder and never escape it. One `.html` page can hold every variant: `--mock a=mocks/<key>/index.html?variant=a` loads the page with `?variant=a`. An `.html` mock or view loads the css, scripts, images and fonts beside it by relative name (`style.css`, `img/dot.png`), from its own folder only, never anything above it. The board adds a small pointing script to each `.html` mock: once the board turns it on for that frame, a tap on a part of the mock is kept from the mock and named back to the board as a path to that element, its tag and its words; it passes only text and gives the mock no reach into the board. With **Comment on the mocks** on, a tap opens a comment on that element: one per element, listed under the mocks with Edit and Delete, kept on the board and shown, until it is sent, only on the exact mock file it was made on. The comments go to the agent with the next answer on that card (a pick, Other answer or **Another round**, which may then have no note), each with its element, in `wait` and `answers` (`comments`); once sent they are listed as sent and can no longer change. An option with no mock, or whose file is gone, says "No mock for this option". `post --type decision` (the default) is exactly `raise`, and `raise` stays as its alias.
|
|
148
|
+
- `post --type design` asks for a pick between up to 6 mock options, each with an optional `--mock <value>=<path>` file (`.html`, `.svg`, `.png`, `.jpg`, `.jpeg`, `.webp`) shown as a tall phone frame above its button (the recommended one first; on a phone the frames swipe sideways, the next one peeking in, and a tap opens one full size), plus a `--tradeoff` line and a `--recommend` badge. Mock paths are relative to the board file's folder and never escape it. One `.html` page can hold every variant: `--mock a=mocks/<key>/index.html?variant=a` loads the page with `?variant=a`. An `.html` mock or view loads the css, scripts, images and fonts beside it by relative name (`style.css`, `img/dot.png`), from its own folder only, never anything above it. The board adds a small pointing script to each `.html` mock: once the board turns it on for that frame, a tap on a part of the mock is kept from the mock and named back to the board as a path to that element, its tag and its words; it passes only text and gives the mock no reach into the board. With **Comment on the mocks** on, a tap opens a comment on that element: one per element, listed under the mocks with Edit and Delete (words typed but not yet saved stay with their element while the page is open, so tapping another element and back keeps them; a reload drops them), kept on the board and shown, until it is sent, only on the exact mock file it was made on. The comments go to the agent with the next answer on that card (a pick, Other answer or **Another round**, which may then have no note), each with its element, in `wait` and `answers` (`comments`); once sent they are listed as sent and can no longer change. An option with no mock, or whose file is gone, says "No mock for this option". `post --type decision` (the default) is exactly `raise`, and `raise` stays as its alias.
|
|
149
149
|
- `post --type evidence` posts the next numbered update (`update-1`, `update-2`, ...) with 1 to 12 screenshots or recordings (`.png`, `.jpg`, `.jpeg`, `.webp`, `.mp4`, `.webm`, up to 50 MB each), a short note and an optional commit or PR link. The files are copied into `evidence/<number>/` next to the board, so the board is the store: no hosted service. The page shows the updates newest first; each file is served only while its size and SHA-256 still match what was posted, and recordings play inline on a phone. The person gives a verdict, **Pass**, **Fail** or **Needs work**, with an optional note, read back with `answers` or `wait --key update-<number>`.
|
|
150
150
|
- `post --type diagram` and `post --type interactive` show a question with a picture: `--view <path>` names one board-relative file, an image (`.svg`, `.png`, `.jpg`, `.jpeg`, `.webp`) drawn at card width for a diagram, or one self-contained `.html` page that runs in a sandboxed frame for interactive. Every mock and view is served under a Content-Security-Policy with no `fetch`, no remote files and no access to the dashboard or its tab. They answer like a decision.
|
|
151
151
|
- `post --type explainer` shows a question with a narrated video the agent made itself (stills plus speech from a free local engine such as piper or espeak-ng, joined with ffmpeg; the skill has the recipe). `--media` names one `.mp4` or `.webm`, copied into `evidence/<number>/` and served hash-checked like evidence media; it plays on the card with its own controls, at phone width too. `--about` is a short summary and each `--chapter m:ss=<title>` is a button that plays the video from there. sign-off itself never makes the video: it calls no speech engine, no ffmpeg and no network service.
|
|
152
152
|
- The person can also tap **Another round** inside **Other answer** (with the typed note), which wakes a waiting agent the same way an answer does; the agent reads it back in `answers --json` under `rounds`, with any comments on mock elements it carried.
|
|
153
|
-
- **Dismiss** (the × on an item in a list, **Dismiss** under Needs you first and beside **Skip for now** in Triage, or **Clear all done** on Answered) hides items from the board, Today included; **Undo** in the toast brings them back, and so does **Restore** under **Dismissed (N)** at the end of Needs you first and Triage.
|
|
153
|
+
- **Dismiss** (the × on an item in a list, **Dismiss** under Needs you first and beside **Skip for now** in Triage, or **Clear all done** on Answered) hides items from the board, Today included; **Undo** in the toast brings them back, and so does **Restore** under **Dismissed (N)** at the end of Needs you first and Triage. `list` counts a dismissed item as neither waiting nor answered, as the board does (`list --full` says `dismissed` for an unanswered one and still its answer for an answered one), and `answers` does not count it as waiting; the agent is not told, so an ask it waits on keeps waiting, and `answers` still returns a dismissed answer. The storage is in [docs/file-formats.md](docs/file-formats.md).
|
|
154
154
|
- The person can comment on any item (**Send as a comment**, also inside **Other answer**: each card has that one place to type) and queue a free-text prompt for the agent (**Ask the agent**). `poll` blocks until one arrives and prints every new one once (`--json` prints `{"messages":[{"id","kind","key"?,"text","at"}]}`); the page then shows it as read by the agent. Typed notes that are not sent yet stay in the browser across reloads; everything sent is saved next to the board and survives a restart.
|
|
155
155
|
- The board runs on your own machine only: no hosted service, no recurring cost. It listens on `127.0.0.1` unless `--bind <address>` (or `bind:` in the `config.yaml` that `init` writes) names one private address of this machine, such as its VPN address, so a phone or tablet on your own network can open it. A `hosts:` line in that config names private DNS names that lead to the bind address, such as its mesh VPN name (commas between them), so the board also opens by name; `doctor` checks each one resolves to the bind address. All interfaces and public addresses are refused. Beyond `127.0.0.1` the board makes an access token once (`decisions.token`, mode 600, next to the board), `open` prints it in the link, and every request without it is refused; the link sets a cookie, so the page and its media work after the first load. Delete the token file to shut old links out at once, then `stop` and `open` for a new one.
|
|
156
156
|
- `export` writes one self-contained HTML file (`decisions.export.html` by default): every item, answer, note and another-round request with its local mocks and media inlined; comments and queued prompts are left out. `--out` must end in `.html`, so a wrong path never overwrites the board. It opens from disk with no server, is read-only, and makes no network request. A mock page is inlined on its own, so the css and images beside it show only on the running board.
|
|
@@ -159,10 +159,10 @@ sign-off-axi export [--out <file.html>] write a frozen read-only copy o
|
|
|
159
159
|
- Raising a question that is already on the board is a no-op that reports its status, so an agent can re-run it safely. `--key` defaults to a slug of the title.
|
|
160
160
|
- Each raised or posted item records when it was filed (`filed`), so Triage shows `Waiting since 09:15` and the Waiting on you tile `Oldest 09:15`; `post --replace` keeps an item's first time.
|
|
161
161
|
- `open` is a no-op when the same board is already open on the same address, and fails with `CONFLICT` when it runs on another one. `--evidence-root` is the folder holding `.done-done/`, for decisions that link run evidence.
|
|
162
|
-
- `--filer <id>` on `post` and `raise` saves who filed the item (an agent session id or any 1-128 of `A-Z a-z 0-9 . _ -`; `SIGN_OFF_FILER` sets it for every command, and a post with neither saves `CLAUDE_CODE_SESSION_ID` when it is set). `wait --filer <id>` then returns only answers to that filer's items it has not taken yet, marks each taken (`decisions.seen.jsonl`) and the card shows **Seen by agent**; it fails with `NOT_FOUND` when none of that filer's items
|
|
162
|
+
- `--filer <id>` on `post` and `raise` saves who filed the item (an agent session id or any 1-128 of `A-Z a-z 0-9 . _ -`; `SIGN_OFF_FILER` sets it for every command, and a post with neither saves `CLAUDE_CODE_SESSION_ID` when it is set). `wait --filer <id>` then returns only answers to that filer's items it has not taken yet, marks each taken (`decisions.seen.jsonl`) and the card shows **Seen by agent**; it fails with `NOT_FOUND` when none of that filer's items still needs an answer. The page never shows the filer id.
|
|
163
163
|
- `on_answer: <command> [args]` in the `config.yaml` beside the board runs that command for every saved answer, read when the board starts. It runs with no shell, in the board's folder, and gets the answer as one JSON line on stdin (`{"key","answer","label","close","at","note"?,"title"?,"filer"?,"comments"?}`, where `comments`, present when the answer carried comments on mock elements, lists each as `id`, `mock`, `hash`, `ref`, `tag`, `words` and `text`); the answer is never put into its arguments, so a typed note cannot inject a command. A failure is logged and never blocks the answer. With no shell, `|`, `>` and `&&` are passed as plain arguments, so put a pipeline in a script and name the script; a relative path such as `./hook.sh` is found from the board's folder. `doctor` checks that the command can run.
|
|
164
164
|
- `post --from <file> --replace --filer <id>` lets several programs feed one board: it replaces only that filer's items (and items with no filer that the file names by key), keeps every other item, the board's other top-level fields and all answers, and an empty items list removes that filer's items.
|
|
165
|
-
- `wait` replaces a polling loop. With `--key` it returns at once if that decision is already answered, and waits for a key that is not raised yet; without it, it waits for the next answer to any decision still
|
|
165
|
+
- `wait` replaces a polling loop. With `--key` it returns at once if that decision is already answered, and waits for a key that is not raised yet; without it, it waits for the next answer to any decision that still needs an answer and fails with `NOT_FOUND` when nothing needs one. It wakes on each save, gives up after `--timeout` seconds (600, ten minutes, by default) with code `TIMEOUT` and exit 1, and takes `--full` and `--json` like `answers`. Ctrl-C stops it without changing anything.
|
|
166
166
|
- `open` records the board's pid, port and address next to the board file (`decisions.board.pid`). `status` and `stop` read it and only act on a pid that really serves that board, so no one has to guess a pid to kill. `open` on a board already served by `serve` records that server, so `stop` can close it too.
|
|
167
167
|
|
|
168
168
|
```
|
package/dist/cli.js
CHANGED
|
@@ -12,7 +12,7 @@ import { encode } from '@toon-format/toon';
|
|
|
12
12
|
import { AxiError, runAxiCli } from 'axi-sdk-js';
|
|
13
13
|
import { CHAPTERS_MAX, CLEARED, InputError, MEDIA_MAX, NOTE_MAX, REPLY, answersPath, appendAnnotation, appendAnswer, appendDismissed, appendMessage, appendRound, isKey, loadDecisions, markDelivered, markSeen, messagesPath, readAnnotations, readAnswers, readDismissed, readItems, readMessages, readRounds, roundsPath, seenBy, seenPath, sentIds, writeItems } from './store.js';
|
|
14
14
|
import { ContractError, KINDS, MEDIA_BYTES, checkMessage, mediaFiles, parseMessage } from './contract.js';
|
|
15
|
-
import { sweepVault } from './vault.js';
|
|
15
|
+
import { deliverVault, errors, readVaultExpiry, sweepVault, takeVaultExpiries, VaultError } from './vault.js';
|
|
16
16
|
const FORMAT = 'https://github.com/umeranjum17/sign-off/blob/main/docs/file-formats.md';
|
|
17
17
|
const CONTRACT = 'https://github.com/umeranjum17/sign-off/blob/main/docs/contract.md';
|
|
18
18
|
/** Coding agents init installs the skill for: the `npx skills add -a` name, the command that shows it is installed,
|
|
@@ -74,8 +74,10 @@ examples:
|
|
|
74
74
|
`,
|
|
75
75
|
validate: `usage: sign-off-axi validate <message.json>... [--kind <kind>] [--expect-invalid]
|
|
76
76
|
Checks message files against the communication contract without posting them: ${CONTRACT}
|
|
77
|
-
A message is one JSON object with "contract": 1, its "kind" and its "schema". This build checks kinds ask, report and
|
|
77
|
+
A message is one JSON object with "contract": 1, its "kind" and its "schema". This build checks kinds ask, report, evidence, gate and finding, schema 1 each.
|
|
78
78
|
An evidence message's media paths read from the message file's folder: each file must be there, inside it, and 50 MB or less.
|
|
79
|
+
A gate names its done-done run (run_id and commit) and offers 2 or 3 of approve (Approve), skip (Skip the judge) and fix (Fix first).
|
|
80
|
+
A finding gives a repeated cause, its counts, record ids (never record text) and a proposed change, and offers apply (Apply) and skip (Skip).
|
|
79
81
|
Post one with \`sign-off-axi post --kind <kind> --from <message.json>\`, or --from - to read stdin.
|
|
80
82
|
A board file given to raise --from or post --from may hold such messages as items: each is checked the same way before it is stored, and a report shows under Reports with no answer.
|
|
81
83
|
In such a file a new item of a contract kind (a decision, the default, an ask or a report) must carry contract, kind and schema; new evidence is posted with post --kind evidence, one at a time.
|
|
@@ -90,6 +92,8 @@ examples:
|
|
|
90
92
|
sign-off-axi validate --kind ask messages/*.json
|
|
91
93
|
sign-off-axi validate --kind report report.json
|
|
92
94
|
sign-off-axi validate --kind evidence proof/evidence.json
|
|
95
|
+
sign-off-axi validate --kind gate gate.json
|
|
96
|
+
sign-off-axi validate --kind finding finding.json
|
|
93
97
|
sign-off-axi validate --expect-invalid node_modules/sign-off-axi/fixtures/contract/*/invalid-*.json
|
|
94
98
|
`,
|
|
95
99
|
answers: `usage: sign-off-axi answers [board.json] [--key <key>] [--full] [--json]
|
|
@@ -106,10 +110,10 @@ examples:
|
|
|
106
110
|
Blocks until the user answers, then prints the answer the way answers does. Use it instead of a polling loop.
|
|
107
111
|
--filer only the items posted with this --filer (or SIGN_OFF_FILER): returns every answer to them this filer has not taken yet,
|
|
108
112
|
even one saved before wait started, and marks it taken, so the board shows "Seen by agent" on that card;
|
|
109
|
-
NOT_FOUND when none of them
|
|
113
|
+
NOT_FOUND when none of them still needs an answer. Without --filer or SIGN_OFF_FILER, wait takes no filer
|
|
110
114
|
--key wait for that decision; an answer saved before wait starts returns at once
|
|
111
115
|
and a key not raised yet is waited for too
|
|
112
|
-
without --key, wait for the next answer to any decision still
|
|
116
|
+
without --key, wait for the next answer to any decision that still needs an answer; NOT_FOUND when nothing needs one
|
|
113
117
|
--timeout seconds to wait, 600 (10 minutes) by default; then exits 1 with code TIMEOUT
|
|
114
118
|
--full adds the button label, close and title
|
|
115
119
|
--json {"<key>":{"answer","label","close","at","note"?}} for the new answers; note only when the user wrote one
|
|
@@ -160,6 +164,8 @@ Posts an item for the user. --type decision (the default) is a yes/no or pick-on
|
|
|
160
164
|
else it is refused with one fix line and nothing is written. A key already on the board takes only the same message again; anything else exits 2.
|
|
161
165
|
--kind evidence copies its media, read from the message file's folder (the current folder for stdin), into evidence/<number>/ like --type evidence;
|
|
162
166
|
a "run" is shown as the update's proof record. With no key it posts the next update-<number>.
|
|
167
|
+
--kind gate holds a done-done run for the user: it waits like an ask; run done-done respond --action <the picked value>.
|
|
168
|
+
--kind finding asks the user to apply or skip a retro's proposed change: it waits like an ask.
|
|
163
169
|
Listing --option needs --recommend <value>, on every type: the option you would pick, shown first and marked. --from --replace feeds are not held to it.
|
|
164
170
|
--filer names who posts (your agent session or any id of 1-128 of A-Z a-z 0-9 . _ -), saved on the item; then \`wait --filer <id>\` gets the answer.
|
|
165
171
|
SIGN_OFF_FILER sets it for every command; with neither, the Claude Code session id is saved when there is one.
|
|
@@ -185,6 +191,8 @@ examples:
|
|
|
185
191
|
sign-off-axi post --type decision --title "Keep the weekly digest?" --recommend yes
|
|
186
192
|
sign-off-axi post --kind ask --from ask.json --filer agent-41
|
|
187
193
|
sign-off-axi post --kind evidence --from proof/evidence.json --filer done-done
|
|
194
|
+
sign-off-axi post --kind gate --from gate.json --filer done-done
|
|
195
|
+
sign-off-axi post --kind finding --from finding.json --filer learn-from-mistakes
|
|
188
196
|
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
|
|
189
197
|
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
|
|
190
198
|
sign-off-axi post --type explainer --title "Is the new sign-in clear?" --about "The new sign-in has 2 steps." --media explainer.mp4 --chapter 0:00=Start --chapter 0:12="Step 2"
|
|
@@ -362,17 +370,19 @@ function filerOf(values, session = false) {
|
|
|
362
370
|
}
|
|
363
371
|
const waitFor = (on, filer, key) => (filer ? `Run \`sign-off-axi wait${on} --filer ${filer}\` to block until the user answers`
|
|
364
372
|
: `Run \`sign-off-axi wait${on}${key ? ` --key ${key}` : ''}\` to block until the user answers`);
|
|
365
|
-
/** Still waiting on the user: not answered, and something to answer (an item with no options and no reply is only shown). */
|
|
366
|
-
const waits = (d) => !d.answer && (d.options.length > 0 || !!d.reply);
|
|
367
|
-
/** The board's decisions with their latest answer; a missing board file is an empty board.
|
|
373
|
+
/** Still waiting on the user: not answered, not dismissed, and something to answer (an item with no options and no reply is only shown). */
|
|
374
|
+
const waits = (d) => !d.dismissed && !d.answer && (d.options.length > 0 || !!d.reply);
|
|
375
|
+
/** The board's decisions with their latest answer; a missing board file is an empty board.
|
|
376
|
+
* A dismissal hides an item from the board and is not waiting here either, checked against the same dismissed log the board reads. */
|
|
368
377
|
function board(file, say = warn) {
|
|
369
378
|
if (!existsSync(file))
|
|
370
379
|
return [];
|
|
371
380
|
const decisions = loadDecisions(file), answers = readAnswers(file, say);
|
|
381
|
+
const hidden = new Set(readDismissed(file, answers, readRounds(file), say));
|
|
372
382
|
for (const key of Object.keys(answers))
|
|
373
383
|
if (!decisions.some((d) => d.key === key))
|
|
374
384
|
say(`left out the answer for ${key}: that question is no longer in ${basename(file)}`);
|
|
375
|
-
return decisions.map((d) => ({ ...d, answer: Object.hasOwn(answers, d.key) ? answers[d.key] : undefined }));
|
|
385
|
+
return decisions.map((d) => ({ ...d, answer: Object.hasOwn(answers, d.key) ? answers[d.key] : undefined, dismissed: hidden.has(d.key) }));
|
|
376
386
|
}
|
|
377
387
|
function ready(file, on, root) {
|
|
378
388
|
if (!existsSync(file))
|
|
@@ -383,9 +393,9 @@ function ready(file, on, root) {
|
|
|
383
393
|
}
|
|
384
394
|
function list(args) {
|
|
385
395
|
const { file, values, on } = parse('list', args, TIERS);
|
|
386
|
-
const rows = board(file), waiting = rows.filter(waits), answered = rows.filter((d) => d.answer).length;
|
|
396
|
+
const rows = board(file), waiting = rows.filter(waits), answered = rows.filter((d) => d.answer && !d.dismissed).length;
|
|
387
397
|
if (values.json)
|
|
388
|
-
return JSON.stringify({ board: file, decisions: rows });
|
|
398
|
+
return JSON.stringify({ board: file, decisions: rows.map(({ dismissed, ...d }) => (dismissed ? { ...d, dismissed } : d)) });
|
|
389
399
|
const help = [
|
|
390
400
|
...(waiting.length ? [`Run \`sign-off-axi open${on}\` to show the board to the user`] : []),
|
|
391
401
|
...(answered ? [`Run \`sign-off-axi answers${on}\` to read the ${answered} saved answers`] : []),
|
|
@@ -393,7 +403,7 @@ function list(args) {
|
|
|
393
403
|
];
|
|
394
404
|
const where = tilde(file) + (existsSync(file) ? '' : ' (no file yet; raise creates it)');
|
|
395
405
|
if (values.full) {
|
|
396
|
-
return toon({ board: where, decisions: rows.map((d) => ({ key: d.key, status: d.answer?.answer ?? 'waiting', title: d.title, about: d.about ?? '', detail: d.detail ?? '',
|
|
406
|
+
return toon({ board: where, decisions: rows.map((d) => ({ key: d.key, status: d.answer?.answer ?? (d.dismissed ? 'dismissed' : 'waiting'), title: d.title, about: d.about ?? '', detail: d.detail ?? '',
|
|
397
407
|
recommend: d.recommend_value ?? '', default_if_silent: d.default_if_silent ?? '', close: d.close })) }, help);
|
|
398
408
|
}
|
|
399
409
|
return toon({
|
|
@@ -433,10 +443,13 @@ function answers(args) {
|
|
|
433
443
|
return printAnswers(file, rows, waiting, values, waiting ? [`Run \`sign-off-axi open${on}\` so the user can answer the ${waiting} waiting`, `Run \`sign-off-axi wait${on}${key}\` to block until they answer`] : []);
|
|
434
444
|
}
|
|
435
445
|
async function wait(args) {
|
|
436
|
-
const { file, values, on: onBoard } = parse('wait', args, { ...TIERS, filer: { type: 'string' }, key: { type: 'string' }, timeout: { type: 'string', default: '600' } });
|
|
446
|
+
const { file, values, on: onBoard } = parse('wait', args, { ...TIERS, filer: { type: 'string' }, key: { type: 'string' }, timeout: { type: 'string', default: '600' }, into: { type: 'string' } });
|
|
437
447
|
if (!/^\d{1,7}$/.test(values.timeout))
|
|
438
448
|
throw fail('--timeout must be whole seconds, 0 or more', 'VALIDATION_ERROR', `Run \`sign-off-axi wait --help\``);
|
|
439
449
|
const filer = filerOf(values), on = onBoard + (filer ? ` --filer ${filer}` : '');
|
|
450
|
+
// Vault Give delivery: its own loop, before any board read, so no ordinary wait changes. Undocumented while the vault is OFF.
|
|
451
|
+
if (values.into !== undefined)
|
|
452
|
+
return waitInto(file, values, onBoard);
|
|
440
453
|
if (values.key === undefined)
|
|
441
454
|
ready(file, onBoard);
|
|
442
455
|
const seconds = Number(values.timeout), deadline = Date.now() + seconds * 1000, key = values.key === undefined ? '' : ` --key ${values.key}`;
|
|
@@ -448,7 +461,7 @@ async function wait(args) {
|
|
|
448
461
|
for (const r of readRounds(file))
|
|
449
462
|
seenRounds.set(r.key, (seenRounds.get(r.key) ?? 0) + 1);
|
|
450
463
|
// A save wakes the loop at once; the 1 second timer is the fallback where file events are unavailable.
|
|
451
|
-
let wake = () => { }, watcher;
|
|
464
|
+
let wake = () => { }, watcher, vaultWarned = false;
|
|
452
465
|
const name = basename(answersPath(file));
|
|
453
466
|
try {
|
|
454
467
|
try {
|
|
@@ -477,7 +490,36 @@ async function wait(args) {
|
|
|
477
490
|
if (found.length) {
|
|
478
491
|
return printAnswers(file, found, waiting, values, [waiting ? `Run \`sign-off-axi wait${on}\` for the next of the ${waiting} waiting` : `Run \`sign-off-axi stop${onBoard}\` to close the board`]);
|
|
479
492
|
}
|
|
480
|
-
|
|
493
|
+
// Private vault expiries and refusals reach a filer wait only once its ordinary answers are out; each is taken once.
|
|
494
|
+
let live = 0, unknown = false;
|
|
495
|
+
if (filer !== undefined && values.key === undefined) {
|
|
496
|
+
const vault = await takeVaultExpiries(file, filer).catch(() => undefined);
|
|
497
|
+
unknown = !vault;
|
|
498
|
+
if ((!vault || !vault.clean) && !vaultWarned) {
|
|
499
|
+
vaultWarned = true;
|
|
500
|
+
warn('Vault expiry status unavailable; ordinary answers still arrive.');
|
|
501
|
+
}
|
|
502
|
+
if (vault?.events.length)
|
|
503
|
+
return vaultEvent(file, vault.events, values);
|
|
504
|
+
live = vault?.live ?? 0;
|
|
505
|
+
}
|
|
506
|
+
// A keyed filer wait reads its own item's expiry or refusal, unless one of its board rows already holds the key.
|
|
507
|
+
if (filer !== undefined && values.key !== undefined && isKey(values.key) && !rows.some((d) => d.key === values.key)) {
|
|
508
|
+
let event;
|
|
509
|
+
try {
|
|
510
|
+
event = await readVaultExpiry(file, values.key, filer, true);
|
|
511
|
+
}
|
|
512
|
+
catch (e) {
|
|
513
|
+
if (e?.code !== 'READER_MISMATCH' && !vaultWarned) {
|
|
514
|
+
vaultWarned = true;
|
|
515
|
+
warn('Vault expiry status unavailable; ordinary answers still arrive.');
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
if (event)
|
|
519
|
+
return vaultEvent(file, [{ key: values.key, at: event.at, status: event.status }], values);
|
|
520
|
+
}
|
|
521
|
+
// A dismissed ask is hidden from the board, not withdrawn: it is not counted as waiting, but the agent still waits on it.
|
|
522
|
+
if (values.key === undefined && !rows.some((d) => waits({ ...d, dismissed: false })) && !live && !unknown) {
|
|
481
523
|
throw fail(`nothing${filer ? ` posted by ${filer}` : ''} is waiting on ${tilde(file)}`, 'NOT_FOUND', `Run \`sign-off-axi answers${onBoard}\` to read the saved answers`, `Run \`sign-off-axi raise${on} --title "<question>"\` to ask a new one`);
|
|
482
524
|
}
|
|
483
525
|
const left = deadline - Date.now();
|
|
@@ -496,6 +538,55 @@ async function wait(args) {
|
|
|
496
538
|
watcher?.close();
|
|
497
539
|
}
|
|
498
540
|
}
|
|
541
|
+
/** Metadata only: a fixed sentence, never a value, URL, path or filer. Failures exit 1; a delivery exits 0 with a label.
|
|
542
|
+
* One poll takes one kind, so all events share a status. */
|
|
543
|
+
const VAULT_EVENTS = {
|
|
544
|
+
expired: { sentence: errors.EXPIRED, code: 'EXPIRED' },
|
|
545
|
+
refused: { sentence: errors.REFUSED, code: 'REFUSED' },
|
|
546
|
+
'delivery-uncertain': { sentence: errors.DELIVERY_UNCERTAIN, code: 'DELIVERY_UNCERTAIN' },
|
|
547
|
+
delivered: { sentence: 'Saved to the approved destination file.' },
|
|
548
|
+
};
|
|
549
|
+
function vaultEvent(file, events, values) {
|
|
550
|
+
const status = events[0].status, { sentence, code } = VAULT_EVENTS[status], at = (ms) => new Date(ms).toISOString().slice(0, 19) + 'Z';
|
|
551
|
+
if (code)
|
|
552
|
+
process.exitCode = 1;
|
|
553
|
+
if (values.json)
|
|
554
|
+
return JSON.stringify(Object.fromEntries(events.map((e) => [e.key, { answer: status, label: sentence, close: 'done', at: at(e.at) }])));
|
|
555
|
+
return toon({ board: tilde(file), [status]: events.map((e) => ({ key: e.key, at: at(e.at) })), ...(code ? { error: sentence, code } : { label: sentence }) }, code ? ['Ask for a fresh item under a new key'] : []);
|
|
556
|
+
}
|
|
557
|
+
// Only these leave the item deliverable later; every other vault code is final for this wait.
|
|
558
|
+
const INTO_RETRY = ['BUSY', 'NOT_FOUND', 'VAULT_KEY_UNAVAILABLE'];
|
|
559
|
+
const INTO_HELP = { DESTINATION_DENIED: 'Fix the destination folder locally, then run the same wait again', DESTINATION_EXISTS: 'Fix the destination folder locally, then run the same wait again',
|
|
560
|
+
READER_MISMATCH: 'Check --key, --filer and --into against the item', VALIDATION_ERROR: 'Check --key, --filer and --into against the item' };
|
|
561
|
+
/** `wait --into`: delivers one Give once into its bound file, polling while it is pending. The destination is never printed. */
|
|
562
|
+
async function waitInto(file, values, onBoard) {
|
|
563
|
+
const { key, filer, into } = values, seconds = Number(values.timeout), deadline = Date.now() + seconds * 1000;
|
|
564
|
+
// An explicit --filer: an inherited SIGN_OFF_FILER must not route a secret write.
|
|
565
|
+
if (key === undefined || filer === undefined)
|
|
566
|
+
throw fail('wait --into needs --key and --filer', 'VALIDATION_ERROR', 'Pass the Give item\'s --key and your own --filer');
|
|
567
|
+
let warned = false;
|
|
568
|
+
for (;;) {
|
|
569
|
+
let done;
|
|
570
|
+
try {
|
|
571
|
+
done = await deliverVault(file, key, filer, into);
|
|
572
|
+
}
|
|
573
|
+
catch (e) {
|
|
574
|
+
const code = e instanceof VaultError ? e.code : 'BUSY';
|
|
575
|
+
if (!INTO_RETRY.includes(code))
|
|
576
|
+
throw fail(errors[code], code, INTO_HELP[code] ?? 'Ask for a fresh item under a new key');
|
|
577
|
+
if (code !== 'NOT_FOUND' && !warned) {
|
|
578
|
+
warned = true;
|
|
579
|
+
warn('Vault delivery status unavailable; still waiting.');
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
if (done)
|
|
583
|
+
return vaultEvent(file, [done], values);
|
|
584
|
+
const left = deadline - Date.now();
|
|
585
|
+
if (left <= 0)
|
|
586
|
+
throw fail(`no vault item with key ${key} was ready to deliver within ${seconds}s`, 'TIMEOUT', `Run \`sign-off-axi wait${onBoard} --key ${key} --filer ${filer} --into <file> --timeout ${seconds}\` to keep waiting`);
|
|
587
|
+
await new Promise((ok) => setTimeout(ok, Math.min(1000, left)));
|
|
588
|
+
}
|
|
589
|
+
}
|
|
499
590
|
const slug = (title) => title.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 64).replace(/^-+|-+$/g, '') || 'decision';
|
|
500
591
|
/** Runs fn while holding an exclusive lock file that names its pid, so two agents raising at once never drop a decision,
|
|
501
592
|
* and a lock a crash left behind can be told apart from one in use. */
|
|
@@ -608,7 +699,13 @@ function replace(file, on, from, filer) {
|
|
|
608
699
|
fits(i?.title, i?.key);
|
|
609
700
|
const feed = fed.items.map((i) => ({ filed: first.get(i?.key)?.filed ?? now, ...i, ...(filer && { filer }) }));
|
|
610
701
|
// An answer belongs to its question: an answered key may not come back asking something else.
|
|
611
|
-
const question = (d) => JSON.stringify([d.title, [...d.options].sort((a, b) => a.value.localeCompare(b.value)), !!d.reply]);
|
|
702
|
+
const question = (d) => JSON.stringify([d.title, [...d.options].sort((a, b) => a.value.localeCompare(b.value)), !!d.reply, d.run, d.finding]);
|
|
703
|
+
// A gate's answer is the action for its run, and a finding's the verdict on its proposal, so a dropped answered one cannot come back.
|
|
704
|
+
const saved = readAnswers(file);
|
|
705
|
+
for (const i of fed.items)
|
|
706
|
+
if ((i?.kind === 'gate' || i?.kind === 'finding') && !first.has(i?.key) && Object.hasOwn(saved, i?.key)) {
|
|
707
|
+
throw fail(`key ${i.key} was answered on ${tilde(file)} and is no longer there`, 'CONFLICT', 'Post it under a new key');
|
|
708
|
+
}
|
|
612
709
|
for (const had of board(file)) {
|
|
613
710
|
const d = incoming.find((x) => x.key === had.key);
|
|
614
711
|
if (filer && d && had.filer && had.filer !== filer)
|
|
@@ -881,26 +978,28 @@ function storeIncoming(file, on, { incoming, raw }, filer, exact = false) {
|
|
|
881
978
|
const current = board(file), items = existsSync(file) ? readItems(file).items : [], saved = readAnswers(file), now = new Date().toISOString();
|
|
882
979
|
const raised = incoming.map((d, i) => {
|
|
883
980
|
const had = current.find((c) => c.key === d.key);
|
|
981
|
+
// A contract message refused for its key exits 2 like any other refusal (contract section 4); raise --title keeps exit 1.
|
|
982
|
+
const contract = exact || raw[i]?.contract !== undefined || raw[i]?.kind !== undefined;
|
|
983
|
+
const refuse = (why) => {
|
|
984
|
+
if (contract)
|
|
985
|
+
process.once('exit', () => { process.exitCode = 2; });
|
|
986
|
+
return fail(`key ${d.key} ${why}`, 'CONFLICT', contract ? 'Post it under a new key' : 'Raise it under a new --key');
|
|
987
|
+
};
|
|
884
988
|
// An answer outlives its item: a new question under that key would inherit it.
|
|
885
989
|
if (!had && Object.hasOwn(saved, d.key))
|
|
886
|
-
throw
|
|
990
|
+
throw refuse(`already has a saved answer on ${tilde(file)} from an item no longer there`);
|
|
887
991
|
// The same question from another filer, or at another time, is the same question: it keeps its first filer and time.
|
|
888
|
-
const same = (x) => JSON.stringify({ ...x, answer: undefined, filer: undefined, filed: undefined, options: [...x.options].sort((a, b) => a.value.localeCompare(b.value)) });
|
|
992
|
+
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)) });
|
|
889
993
|
// A message that names no filed time matches the one the board stamped.
|
|
890
994
|
const differs = () => exact ? canon({ ...items.find((x) => x?.key === d.key), ...(raw[i].filed === undefined && { filed: undefined }) }) !== canon(raw[i]) : same(had) !== same(d);
|
|
891
|
-
if (had && differs())
|
|
892
|
-
|
|
893
|
-
const contract = exact || raw[i]?.contract !== undefined || raw[i]?.kind !== undefined;
|
|
894
|
-
if (contract)
|
|
895
|
-
process.once('exit', () => { process.exitCode = 2; });
|
|
896
|
-
throw fail(`key ${d.key} already holds a different ${contract ? 'message' : 'question'} on ${tilde(file)}`, 'CONFLICT', contract ? 'Post it under a new key' : 'Raise it under a new --key');
|
|
897
|
-
}
|
|
995
|
+
if (had && differs())
|
|
996
|
+
throw refuse(`already holds a different ${contract ? 'message' : 'question'} on ${tilde(file)}`);
|
|
898
997
|
// Each new item records when it was raised, unless it names its own time.
|
|
899
998
|
if (!had) {
|
|
900
999
|
fits(d.title, d.key);
|
|
901
1000
|
items.push({ filed: now, ...raw[i] });
|
|
902
1001
|
}
|
|
903
|
-
return { key: d.key, status: !had ? 'raised' : had.answer ? `already answered ${had.answer.answer}` : waits(had) ? 'already waiting' : 'already posted', title: d.title };
|
|
1002
|
+
return { key: d.key, status: !had ? 'raised' : had.answer ? `already answered ${had.answer.answer}` : waits({ ...had, dismissed: false }) ? 'already waiting' : 'already posted', title: d.title };
|
|
904
1003
|
});
|
|
905
1004
|
if (raised.some((r) => r.status === 'raised'))
|
|
906
1005
|
writeItems(file, items);
|
package/dist/contract.js
CHANGED
|
@@ -10,17 +10,24 @@ export class ContractError extends Error {
|
|
|
10
10
|
this.help = help;
|
|
11
11
|
}
|
|
12
12
|
}
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
export const
|
|
13
|
+
/** The kinds, with the schema number of each. */
|
|
14
|
+
export const SCHEMAS = { ask: 1, report: 1, evidence: 1, gate: 1, finding: 1 };
|
|
15
|
+
export const KINDS = Object.keys(SCHEMAS);
|
|
16
16
|
const COMMON = ['contract', 'kind', 'schema', 'key', 'title', 'filer', 'link', 'about'];
|
|
17
17
|
const FIELDS = {
|
|
18
18
|
ask: [...COMMON, 'detail', 'options', 'recommend_value', 'default_if_silent', 'close'],
|
|
19
19
|
report: [...COMMON, 'filed', 'group', 'decided'],
|
|
20
20
|
evidence: [...COMMON, 'media', 'run'],
|
|
21
|
+
gate: [...COMMON, 'run', 'options', 'recommend_value', 'close'],
|
|
22
|
+
finding: [...COMMON, 'cause', 'incidents', 'days', 'evidence_ids', 'change_kind', 'proposal', 'options', 'recommend_value'],
|
|
21
23
|
};
|
|
22
24
|
/** A done-done run: its id, full commit and the four Checks lines. */
|
|
23
25
|
const RUN = ['run_id', 'commit', 'outcome', 'repro', 'proof', 'evidence'];
|
|
26
|
+
/** The fixed options of a kind, each with its one label: a gate's are the actions of done-done respond --action. */
|
|
27
|
+
const ACTIONS = {
|
|
28
|
+
gate: { approve: 'Approve', skip: 'Skip the judge', fix: 'Fix first' },
|
|
29
|
+
finding: { apply: 'Apply', skip: 'Skip' },
|
|
30
|
+
};
|
|
24
31
|
export const MEDIA_BYTES = 50_000_000;
|
|
25
32
|
/** Parses one message; a field written twice in any object refuses it, since plain JSON.parse keeps the last one quietly. */
|
|
26
33
|
export function parseMessage(bytes) {
|
|
@@ -92,6 +99,24 @@ export function checkMessage(m, want) {
|
|
|
92
99
|
if ([...v].length > max)
|
|
93
100
|
throw bad(field, `is ${[...v].length} characters, over ${max}`, `Shorten "${name}" to ${max} characters`);
|
|
94
101
|
};
|
|
102
|
+
/** A done-done run block with exactly these fields: an id, a full commit, and on evidence the four Checks lines. */
|
|
103
|
+
const checkRun = (fields) => {
|
|
104
|
+
const r = m.run;
|
|
105
|
+
if (!r || typeof r !== 'object' || Array.isArray(r))
|
|
106
|
+
throw bad('run', 'must be an object', `Set "run" to {${fields.map(show).join(', ')}}`);
|
|
107
|
+
const odd = Object.keys(r).find((f) => !fields.includes(f));
|
|
108
|
+
if (odd !== undefined)
|
|
109
|
+
throw bad(`run.${escaped(odd) || '""'}`, 'is not a field of run', `Remove ${show(odd)}; run has only ${fields.join(', ')}`);
|
|
110
|
+
const missing = fields.find((f) => !Object.hasOwn(r, f));
|
|
111
|
+
if (missing)
|
|
112
|
+
throw bad(`run.${missing}`, 'is required', `Add "${missing}" to "run"`);
|
|
113
|
+
if (!isKey(r.run_id))
|
|
114
|
+
throw bad('run.run_id', 'must be 1-128 of A-Z a-z 0-9 . _ -', 'Set "run_id" to the done-done run id');
|
|
115
|
+
if (!(typeof r.commit === 'string' && /^[a-f0-9]{40}$/.test(r.commit)))
|
|
116
|
+
throw bad('run.commit', 'must be a full 40-character lowercase commit', 'Set "commit" to the full hash, such as the output of git rev-parse HEAD');
|
|
117
|
+
for (const f of fields.slice(2))
|
|
118
|
+
text(`run.${f}`, r[f], 1000, `the run's ${f} Checks line, in 1000 characters or fewer`);
|
|
119
|
+
};
|
|
95
120
|
need('contract');
|
|
96
121
|
if (m.contract !== 1)
|
|
97
122
|
throw bad('contract', `must be 1, not ${show(m.contract)}`, whole(m.contract) ? 'Upgrade sign-off-axi: this one knows contract 1' : 'Set "contract": 1');
|
|
@@ -100,8 +125,6 @@ export function checkMessage(m, want) {
|
|
|
100
125
|
throw bad('kind', `must be one of ${KINDS.join(', ')}, not ${show(m.kind)}`, `Set "kind" to one of ${KINDS.join(', ')}`);
|
|
101
126
|
if (want !== undefined && m.kind !== want)
|
|
102
127
|
throw bad('kind', `is ${m.kind}, not --kind ${want}`, `Pass --kind ${m.kind}, or name the ${want} message file`);
|
|
103
|
-
if (!SCHEMAS[m.kind])
|
|
104
|
-
throw bad('kind', `${m.kind} is not checked by this sign-off-axi yet`, `Upgrade sign-off-axi to a version that checks ${m.kind}`);
|
|
105
128
|
need('schema');
|
|
106
129
|
if (!whole(m.schema))
|
|
107
130
|
throw bad('schema', `must be a whole number, not ${show(m.schema)}`, `Set "schema": ${SCHEMAS[m.kind]}`);
|
|
@@ -123,7 +146,7 @@ export function checkMessage(m, want) {
|
|
|
123
146
|
throw bad('link', 'must be an http(s) link with no spaces or control characters, or a 7-40 character commit hash', 'Set "link" to a URL such as "https://example.com/pr/4" or a commit hash, or leave it out');
|
|
124
147
|
}
|
|
125
148
|
if (m.kind !== 'ask') {
|
|
126
|
-
const what =
|
|
149
|
+
const what = { report: 'what happened', gate: 'why the run is held', finding: 'the counts, with no record text' }[m.kind] ?? 'what this shows';
|
|
127
150
|
if (!Object.hasOwn(m, 'about'))
|
|
128
151
|
throw bad('about', 'is required', `Add "about": ${what}, in 500 characters or fewer`);
|
|
129
152
|
text('about', m.about, 500, `${what}, in 500 characters or fewer`);
|
|
@@ -142,23 +165,8 @@ export function checkMessage(m, want) {
|
|
|
142
165
|
throw bad(`media[${n}]`, `${show(p)} must end in .png, .jpg, .jpeg, .webp, .mp4 or .webm`, 'Post a screenshot or recording in one of those formats');
|
|
143
166
|
});
|
|
144
167
|
}
|
|
145
|
-
if (Object.hasOwn(m, 'run'))
|
|
146
|
-
|
|
147
|
-
if (!r || typeof r !== 'object' || Array.isArray(r))
|
|
148
|
-
throw bad('run', 'must be an object', `Set "run" to {${RUN.map(show).join(', ')}}`);
|
|
149
|
-
const odd = Object.keys(r).find((f) => !RUN.includes(f));
|
|
150
|
-
if (odd !== undefined)
|
|
151
|
-
throw bad(`run.${escaped(odd) || '""'}`, 'is not a field of run', `Remove ${show(odd)}; run has only ${RUN.join(', ')}`);
|
|
152
|
-
const missing = RUN.find((f) => !Object.hasOwn(r, f));
|
|
153
|
-
if (missing)
|
|
154
|
-
throw bad(`run.${missing}`, 'is required', `Add "${missing}" to "run"`);
|
|
155
|
-
if (!isKey(r.run_id))
|
|
156
|
-
throw bad('run.run_id', 'must be 1-128 of A-Z a-z 0-9 . _ -', 'Set "run_id" to the done-done run id');
|
|
157
|
-
if (!(typeof r.commit === 'string' && /^[a-f0-9]{40}$/.test(r.commit)))
|
|
158
|
-
throw bad('run.commit', 'must be a full 40-character lowercase commit', 'Set "commit" to the full hash, such as the output of git rev-parse HEAD');
|
|
159
|
-
for (const f of RUN.slice(2))
|
|
160
|
-
text(`run.${f}`, r[f], 1000, `the run's ${f} Checks line, in 1000 characters or fewer`);
|
|
161
|
-
}
|
|
168
|
+
if (Object.hasOwn(m, 'run'))
|
|
169
|
+
checkRun(RUN);
|
|
162
170
|
return { kind: m.kind, schema: m.schema };
|
|
163
171
|
}
|
|
164
172
|
if (m.kind === 'report') {
|
|
@@ -177,10 +185,12 @@ export function checkMessage(m, want) {
|
|
|
177
185
|
text('about', m.about, 500, 'a short note, or leave it out');
|
|
178
186
|
if (Object.hasOwn(m, 'detail'))
|
|
179
187
|
text('detail', m.detail, 1000, 'the longer note, or leave it out');
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
}
|
|
188
|
+
// A gate offers 2 or 3 of done-done's actions and a finding both of apply and skip, in any order, each with its own label.
|
|
189
|
+
const options = m.options, actions = ACTIONS[m.kind], max = actions ? Object.keys(actions).length : 6;
|
|
190
|
+
const named = actions && Object.entries(actions).map(([value, label]) => show({ value, label })).join(', ');
|
|
191
|
+
const listed = !actions ? 'Set "options" to 2-6 objects such as {"value":"yes","label":"Yes"}' : max === 3 ? `Set "options" to 2 or 3 of ${named}` : `Set "options" to ${named}`;
|
|
192
|
+
if (!Array.isArray(options) || options.length < 2 || options.length > max)
|
|
193
|
+
throw bad('options', Object.hasOwn(m, 'options') ? `must list ${{ 2: '2', 3: '2 or 3' }[max] ?? '2 to 6'} options` : 'is required', listed);
|
|
184
194
|
options.forEach((o, n) => {
|
|
185
195
|
const field = `options[${n}]`;
|
|
186
196
|
if (!o || typeof o !== 'object' || Array.isArray(o))
|
|
@@ -194,6 +204,9 @@ export function checkMessage(m, want) {
|
|
|
194
204
|
throw bad(`${field}.value`, `repeats ${o.value}`, 'Give every option its own value');
|
|
195
205
|
text(`${field}.label`, o.label, 80, 'the button text, in 80 characters or fewer');
|
|
196
206
|
});
|
|
207
|
+
const odd = actions && options.find((o) => !Object.hasOwn(actions, o.value) || actions[o.value] !== o.label);
|
|
208
|
+
if (odd)
|
|
209
|
+
throw bad('options', `${show(odd)} is not a ${m.kind} action: ${Object.keys(actions).join(', ')}, each with its label`, listed);
|
|
197
210
|
const values = options.map((o) => o.value), pick = `the option you would pick: one of ${values.join(', ')}`;
|
|
198
211
|
if (!Object.hasOwn(m, 'recommend_value'))
|
|
199
212
|
throw bad('recommend_value', 'is required', `Add "recommend_value": ${show(values[0])}, ${pick}`);
|
|
@@ -204,6 +217,35 @@ export function checkMessage(m, want) {
|
|
|
204
217
|
}
|
|
205
218
|
if (Object.hasOwn(m, 'close') && m.close !== 'done' && m.close !== 'release')
|
|
206
219
|
throw bad('close', 'must be done or release', 'Set "close" to "done" or "release", or leave it out');
|
|
220
|
+
// A gate's run is its own rule (section 5), so it comes after the field rules of section 4.
|
|
221
|
+
if (m.kind === 'gate') {
|
|
222
|
+
if (!Object.hasOwn(m, 'run'))
|
|
223
|
+
throw bad('run', 'is required', 'Add "run": {"run_id": "<done-done run id>", "commit": "<full commit>"}, the run held for approval');
|
|
224
|
+
checkRun(RUN.slice(0, 2));
|
|
225
|
+
}
|
|
226
|
+
// A finding names its records by id only; the validator cannot see record text, so keeping it out is the producer's job.
|
|
227
|
+
if (m.kind === 'finding') {
|
|
228
|
+
const must = (f, ok, rule, help) => {
|
|
229
|
+
if (!Object.hasOwn(m, f))
|
|
230
|
+
throw bad(f, 'is required', help);
|
|
231
|
+
if (!ok(m[f]))
|
|
232
|
+
throw bad(f, rule, help);
|
|
233
|
+
};
|
|
234
|
+
must('cause', (v) => typeof v === 'string' && /^[a-z0-9-]{1,64}$/.test(v), 'must be 1-64 of a-z 0-9 -', 'Set "cause" to the repeated cause, such as "missing-env"');
|
|
235
|
+
for (const f of ['incidents', 'days'])
|
|
236
|
+
must(f, whole, 'must be a whole number, 0 or more', `Set "${f}" to a count, such as 4`);
|
|
237
|
+
must('evidence_ids', (v) => Array.isArray(v) && v.length <= 50, 'must list 0 to 50 record ids', 'Set "evidence_ids" to the ids of the records, such as ["rec-7f3a"], never their text');
|
|
238
|
+
m.evidence_ids.forEach((id, n) => {
|
|
239
|
+
if (!isKey(id))
|
|
240
|
+
throw bad(`evidence_ids[${n}]`, 'must be 1-128 of A-Z a-z 0-9 . _ -', 'Name each record by its id, never its text');
|
|
241
|
+
if (m.evidence_ids.indexOf(id) < n)
|
|
242
|
+
throw bad(`evidence_ids[${n}]`, `repeats ${id}`, 'List each record id once');
|
|
243
|
+
});
|
|
244
|
+
must('change_kind', (v) => ['doc', 'rule', 'code'].includes(v), 'must be doc, rule or code', 'Set "change_kind" to "doc", "rule" or "code"');
|
|
245
|
+
if (!Object.hasOwn(m, 'proposal'))
|
|
246
|
+
throw bad('proposal', 'is required', 'Add "proposal": the change, in 500 characters or fewer');
|
|
247
|
+
text('proposal', m.proposal, 500, 'the change, in 500 characters or fewer');
|
|
248
|
+
}
|
|
207
249
|
return { kind: m.kind, schema: m.schema };
|
|
208
250
|
}
|
|
209
251
|
/** An evidence message's media files, from `base`, the message file's folder: each a file of 50,000,000 bytes or fewer whose real path,
|
package/dist/store.js
CHANGED
|
@@ -165,6 +165,10 @@ export function loadDecisions(path, board) {
|
|
|
165
165
|
...(d.steps && { steps: d.steps.map(({ title, marks }) => ({ title, ...(marks?.length && { marks }) })) }),
|
|
166
166
|
...(stored && d.media && { media: d.media.map(({ path, sha256, bytes }) => ({ path, sha256, bytes })) }),
|
|
167
167
|
...(d.type === 'evidence' && { number: d.number }),
|
|
168
|
+
// A gate's run is part of its question: the same key with another run is a different question.
|
|
169
|
+
...(d.kind === 'gate' && d.run && { run: { run_id: d.run.run_id, commit: d.run.commit } }),
|
|
170
|
+
// So is a finding's proposal, with the counts and records behind it.
|
|
171
|
+
...(d.kind === 'finding' && { finding: { cause: d.cause, incidents: d.incidents, days: d.days, evidence_ids: d.evidence_ids, change_kind: d.change_kind, proposal: d.proposal } }),
|
|
168
172
|
...(d.type === 'explainer' && d.chapters && { chapters: d.chapters.map(({ at, title }) => ({ at, title })) }),
|
|
169
173
|
};
|
|
170
174
|
});
|
package/dist/vault.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
// Vault remains OFF. The server imports only expiry
|
|
2
|
-
import { closeSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
1
|
+
// Vault remains OFF. The server imports only expiry functions; `wait --into` reaches payload only through deliverVault.
|
|
2
|
+
import { closeSync, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { dirname, isAbsolute, join, parse, resolve } from 'node:path';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
|
-
import { createCipheriv, createHash, randomBytes } from 'node:crypto';
|
|
5
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
|
|
6
6
|
import { isKey } from './store.js';
|
|
7
|
-
const errors = {
|
|
7
|
+
export const errors = {
|
|
8
8
|
VALIDATION_ERROR: 'Vault input is invalid: check mode, expiry, provider and destination.',
|
|
9
9
|
EXPIRED: 'Vault item expired; request a fresh item.',
|
|
10
10
|
VAULT_KEY_UNAVAILABLE: 'Vault key is unavailable; encrypted payload cannot be read.',
|
|
@@ -13,6 +13,10 @@ const errors = {
|
|
|
13
13
|
TIMEOUT: 'Vault expiry wait timed out; retry status later.',
|
|
14
14
|
READER_MISMATCH: 'This item belongs to a different filer or destination.',
|
|
15
15
|
REFUSED: 'User refused this vault item; no value was delivered.',
|
|
16
|
+
DESTINATION_DENIED: 'Destination is outside an approved private config root or has unsafe permissions.',
|
|
17
|
+
DESTINATION_EXISTS: 'Destination exists; nothing was overwritten.',
|
|
18
|
+
ALREADY_READ: 'Vault item was already delivered; value is unavailable.',
|
|
19
|
+
DELIVERY_UNCERTAIN: 'Delivery interrupted; do not retry this item. Check destination locally, then request a fresh item if needed.',
|
|
16
20
|
};
|
|
17
21
|
export class VaultError extends Error {
|
|
18
22
|
code;
|
|
@@ -35,6 +39,17 @@ function boardFile(board) {
|
|
|
35
39
|
bad('NOT_FOUND');
|
|
36
40
|
}
|
|
37
41
|
}
|
|
42
|
+
// Never used: callers stay silent with no permission walk, lock or state creation.
|
|
43
|
+
const unused = (file) => {
|
|
44
|
+
try {
|
|
45
|
+
return !lstatSync(join(stateHome(), 'sign-off', 'vault'), { throwIfNoEntry: false }) || !lstatSync(join(dirname(file), '.sign-off-vault-id'), { throwIfNoEntry: false });
|
|
46
|
+
}
|
|
47
|
+
catch (e) {
|
|
48
|
+
if (e?.code === 'ENOENT' || e?.code === 'ENOTDIR')
|
|
49
|
+
return true;
|
|
50
|
+
throw e;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
38
53
|
function boardId(file, create) {
|
|
39
54
|
const side = join(dirname(file), '.sign-off-vault-id');
|
|
40
55
|
if (!lstatSync(side, { throwIfNoEntry: false })) {
|
|
@@ -48,10 +63,12 @@ function boardId(file, create) {
|
|
|
48
63
|
bad('VALIDATION_ERROR');
|
|
49
64
|
return id;
|
|
50
65
|
}
|
|
66
|
+
// Absolute and free of raw '.' or '..' segments, which resolve() would otherwise hide.
|
|
67
|
+
const plain = (p) => isAbsolute(p) && !p.split('/').some((s) => s === '.' || s === '..');
|
|
51
68
|
function metadata(m) {
|
|
52
69
|
if (!m || typeof m.board !== 'string' || !VAULT_ID.test(m.board) || !isKey(m.key) || !isKey(m.filer) || !['give', 'receive'].includes(m.mode)
|
|
53
70
|
|| !Number.isSafeInteger(m.expires) || m.expires <= 0
|
|
54
|
-
|| (m.mode === 'give' ? typeof m.destination !== 'string' || !
|
|
71
|
+
|| (m.mode === 'give' ? typeof m.destination !== 'string' || !plain(m.destination) : m.destination !== undefined))
|
|
55
72
|
bad('VALIDATION_ERROR');
|
|
56
73
|
return { board: m.board, key: m.key, mode: m.mode, filer: m.filer, expires: m.expires,
|
|
57
74
|
...(m.destination !== undefined && { destination: resolve(m.destination) }) };
|
|
@@ -61,8 +78,9 @@ function privateFile(path) {
|
|
|
61
78
|
if (!s.isFile() || s.nlink !== 1 || s.uid !== process.getuid?.() || (s.mode & 0o777) !== 0o600 || s.size > 32_768)
|
|
62
79
|
bad('VAULT_KEY_UNAVAILABLE');
|
|
63
80
|
}
|
|
81
|
+
const stateHome = () => process.env.XDG_STATE_HOME || join(homedir(), '.local', 'state');
|
|
64
82
|
function root(create = true) {
|
|
65
|
-
const state =
|
|
83
|
+
const state = stateHome();
|
|
66
84
|
if (!isAbsolute(state) || !process.getuid)
|
|
67
85
|
bad('VAULT_KEY_UNAVAILABLE');
|
|
68
86
|
const owner = join(state, 'sign-off'), vault = join(owner, 'vault');
|
|
@@ -87,6 +105,45 @@ function root(create = true) {
|
|
|
87
105
|
}
|
|
88
106
|
return vault;
|
|
89
107
|
}
|
|
108
|
+
const within = (child, parent) => child === parent || child.startsWith(parent.replace(/\/$/, '') + '/');
|
|
109
|
+
function gitFree(path) {
|
|
110
|
+
let p = parse(path).root;
|
|
111
|
+
for (const part of path.slice(p.length).split('/')) {
|
|
112
|
+
p = join(p, part);
|
|
113
|
+
if (existsSync(join(p, '.git')))
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
// Owner-only list of approved destination directories: one canonical absolute path per line, `#` comments. Any bad line denies all.
|
|
119
|
+
// ponytail: exact-directory approvals, so a destination must be a direct child; ancestors get only the .git walk.
|
|
120
|
+
function approvedDirs(file, dir) {
|
|
121
|
+
const list = join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'sign-off', 'vault-destinations');
|
|
122
|
+
if (!isAbsolute(list))
|
|
123
|
+
bad('DESTINATION_DENIED');
|
|
124
|
+
let text;
|
|
125
|
+
try {
|
|
126
|
+
privateFile(list);
|
|
127
|
+
text = readFileSync(list, 'utf8');
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
bad('DESTINATION_DENIED');
|
|
131
|
+
}
|
|
132
|
+
const vaultDir = realpathSync(dir), home = realpathSync(homedir()), boardDir = dirname(file);
|
|
133
|
+
return text.split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('#')).map((line) => {
|
|
134
|
+
const s = lstatSync(line, { throwIfNoEntry: false });
|
|
135
|
+
if (!plain(line) || !s?.isDirectory() || realpathSync(line) !== line || s.uid !== process.getuid?.() || (s.mode & 0o777) !== 0o700
|
|
136
|
+
|| !gitFree(line) || within(home, line) || [vaultDir, boardDir].some((o) => within(o, line) || within(line, o)))
|
|
137
|
+
bad('DESTINATION_DENIED');
|
|
138
|
+
return line;
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
function checkDestination(dest, file, dir) {
|
|
142
|
+
if (!approvedDirs(file, dir).includes(dirname(dest)))
|
|
143
|
+
bad('DESTINATION_DENIED');
|
|
144
|
+
if (lstatSync(dest, { throwIfNoEntry: false }))
|
|
145
|
+
bad('DESTINATION_EXISTS'); // any entry, including a dangling symlink
|
|
146
|
+
}
|
|
90
147
|
function syncDir(path) {
|
|
91
148
|
const fd = openSync(path, 'r');
|
|
92
149
|
try {
|
|
@@ -145,12 +202,32 @@ function seal(dir, meta, value) {
|
|
|
145
202
|
key.fill(0);
|
|
146
203
|
}
|
|
147
204
|
}
|
|
148
|
-
|
|
205
|
+
function unseal(key, meta, s) {
|
|
206
|
+
const d = createDecipheriv('aes-256-gcm', key, Buffer.from(s.nonce, 'base64'));
|
|
207
|
+
d.setAAD(aad(meta));
|
|
208
|
+
d.setAuthTag(Buffer.from(s.tag, 'base64'));
|
|
209
|
+
return Buffer.concat([d.update(Buffer.from(s.data, 'base64')), d.final()]);
|
|
210
|
+
}
|
|
211
|
+
// A stop signal with no listener ends the process at once, even inside the locked section, and the lock it leaves fails closed for good.
|
|
212
|
+
// So a listener takes each stop signal the process has none for: it runs only between turns of the event loop, after a section has
|
|
213
|
+
// removed its lock, and raises the signal again with no listener, so the process still ends by it. It stays on once added, since
|
|
214
|
+
// Node drops a signal that came just before its listener was removed.
|
|
215
|
+
const STOPS = ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
|
216
|
+
function raise(signal) {
|
|
217
|
+
for (const s of STOPS)
|
|
218
|
+
process.off(s, raise);
|
|
219
|
+
if (!process.listenerCount(signal))
|
|
220
|
+
process.kill(process.pid, signal);
|
|
221
|
+
}
|
|
222
|
+
async function lockedVault(dir, fn, attempts = 50) {
|
|
223
|
+
for (const s of STOPS)
|
|
224
|
+
if (!process.listenerCount(s))
|
|
225
|
+
process.on(s, raise);
|
|
149
226
|
let lock;
|
|
150
227
|
try {
|
|
151
228
|
const candidate = join(dir, 'lock');
|
|
152
229
|
// ponytail: one global lock; split by board only if custody throughput requires it. Stale locks fail closed.
|
|
153
|
-
for (let tries =
|
|
230
|
+
for (let tries = 1;; tries++) {
|
|
154
231
|
try {
|
|
155
232
|
const fd = openSync(candidate, 'wx', 0o600);
|
|
156
233
|
closeSync(fd);
|
|
@@ -158,7 +235,7 @@ async function lockedVault(dir, fn) {
|
|
|
158
235
|
break;
|
|
159
236
|
}
|
|
160
237
|
catch (e) {
|
|
161
|
-
if (e.code !== 'EEXIST' || tries ===
|
|
238
|
+
if (e.code !== 'EEXIST' || tries === attempts)
|
|
162
239
|
bad('BUSY');
|
|
163
240
|
}
|
|
164
241
|
await new Promise((ok) => setTimeout(ok, 20));
|
|
@@ -195,11 +272,18 @@ function readRecord(path) {
|
|
|
195
272
|
bad('VALIDATION_ERROR');
|
|
196
273
|
}
|
|
197
274
|
if (!record || record.schema !== 1 || !VAULT_ID.test(record.meta.board)
|
|
198
|
-
|| JSON.stringify(metadata(record.meta)) !== JSON.stringify(record.meta) || !['pending', 'ready', 'refused', 'expired'].includes(record.status)
|
|
275
|
+
|| JSON.stringify(metadata(record.meta)) !== JSON.stringify(record.meta) || !['pending', 'ready', 'refused', 'expired', 'claimed', 'delivered'].includes(record.status)
|
|
199
276
|
|| path !== join(dirname(path), `${digest(record.meta.board, record.meta.key)}.json`))
|
|
200
277
|
bad('VALIDATION_ERROR');
|
|
201
278
|
return record;
|
|
202
279
|
}
|
|
280
|
+
// A refusal is admitted only while the item is live (`operate` expires first), so only an expiry is bound to the deadline.
|
|
281
|
+
function expiryEvent(id, record) {
|
|
282
|
+
const status = (record.status === 'claimed' ? 'delivery-uncertain' : record.status);
|
|
283
|
+
if (!Number.isSafeInteger(record.at) || record.at <= 0 || (status === 'expired' && record.at < record.meta.expires) || record.at > 8.64e15 || Object.hasOwn(record, 'sealed'))
|
|
284
|
+
bad('VALIDATION_ERROR');
|
|
285
|
+
return { schema: 1, id: `${digest(id, record.meta.key)}:${status}`, status, at: record.at };
|
|
286
|
+
}
|
|
203
287
|
function expire(path, record) {
|
|
204
288
|
if (['pending', 'ready'].includes(record.status) && record.meta.expires <= Date.now()) {
|
|
205
289
|
record = { schema: 1, meta: record.meta, status: 'expired', at: Date.now() };
|
|
@@ -258,30 +342,112 @@ export async function sweepVault(board) {
|
|
|
258
342
|
return false;
|
|
259
343
|
}
|
|
260
344
|
}
|
|
261
|
-
/**
|
|
262
|
-
|
|
345
|
+
/** Replayable expiry observation; never a payload consumer. The public keyed wait (`publicWait`) also expires its own item on access,
|
|
346
|
+
* gets a refusal, delivery or uncertain delivery as an event and tries the lock once; the private observer reports only expiry. */
|
|
347
|
+
export async function readVaultExpiry(board, key, filer, publicWait = false) {
|
|
263
348
|
if (!isKey(key) || !isKey(filer))
|
|
264
349
|
bad('VALIDATION_ERROR');
|
|
265
350
|
try {
|
|
266
|
-
|
|
351
|
+
// Never used: silent before any custody or permission walk.
|
|
352
|
+
const file = boardFile(board);
|
|
353
|
+
if (unused(file))
|
|
354
|
+
return;
|
|
355
|
+
const { dir } = custody(board, false);
|
|
267
356
|
if (!dir)
|
|
268
357
|
return;
|
|
269
358
|
return await lockedVault(dir, () => {
|
|
270
359
|
if (!lstatSync(join(dirname(file), '.sign-off-vault-id'), { throwIfNoEntry: false }))
|
|
271
360
|
return;
|
|
272
|
-
const { id, record } = boundRecord(file, dir, key, false);
|
|
361
|
+
const { id, path, record } = boundRecord(file, dir, key, false);
|
|
273
362
|
if (!record)
|
|
274
363
|
return;
|
|
275
364
|
if (record.meta.filer !== filer)
|
|
276
365
|
bad('READER_MISMATCH');
|
|
277
|
-
if (record.status === 'refused')
|
|
366
|
+
if (record.status === 'refused' && !publicWait)
|
|
278
367
|
bad('REFUSED');
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
368
|
+
const current = publicWait ? expire(path, record) : record;
|
|
369
|
+
const terminal = publicWait ? ['expired', 'refused', 'claimed', 'delivered'] : ['expired', 'refused'];
|
|
370
|
+
return terminal.includes(current.status) ? expiryEvent(id, current) : undefined;
|
|
371
|
+
}, publicWait ? 1 : 50);
|
|
372
|
+
}
|
|
373
|
+
catch (e) {
|
|
374
|
+
throw e instanceof VaultError ? e : new VaultError('BUSY');
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
/** For an ordinary filer wait: expires this filer's elapsed items in the board's folder, then takes each untaken terminal event once.
|
|
378
|
+
* A private `.<kind>.taken` marker per event dedupes filer waits; markers are durable before any event is returned.
|
|
379
|
+
* One kind per poll (expired, refused, delivery-uncertain, delivered), so one output never mixes two codes and a failure is never
|
|
380
|
+
* hidden behind a delivery's exit 0. */
|
|
381
|
+
export async function takeVaultExpiries(board, filer) {
|
|
382
|
+
if (!isKey(filer))
|
|
383
|
+
bad('VALIDATION_ERROR');
|
|
384
|
+
const none = { events: [], live: 0, clean: true };
|
|
385
|
+
try {
|
|
386
|
+
// Never used: silent, with no permission walk, lock or state creation.
|
|
387
|
+
const file = boardFile(board);
|
|
388
|
+
if (unused(file))
|
|
389
|
+
return none;
|
|
390
|
+
const { dir } = custody(board, false);
|
|
391
|
+
if (!dir)
|
|
392
|
+
return none;
|
|
393
|
+
// One try: an ordinary wait never queues behind custody; BUSY means unknown this poll.
|
|
394
|
+
return await lockedVault(dir, () => {
|
|
395
|
+
const id = boardId(file, false), found = [], made = [];
|
|
396
|
+
let live = 0, clean = true;
|
|
397
|
+
for (const name of readdirSync(dir).filter((n) => n.endsWith('.json')).sort()) {
|
|
398
|
+
try {
|
|
399
|
+
const path = join(dir, name), record = readRecord(path);
|
|
400
|
+
if (record.meta.board !== id || record.meta.filer !== filer)
|
|
401
|
+
continue;
|
|
402
|
+
const r = expire(path, record);
|
|
403
|
+
if (r.status === 'pending' || r.status === 'ready')
|
|
404
|
+
live++;
|
|
405
|
+
else
|
|
406
|
+
found.push({ path, key: r.meta.key, event: expiryEvent(id, r) });
|
|
407
|
+
}
|
|
408
|
+
catch {
|
|
409
|
+
clean = false;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
try {
|
|
413
|
+
const take = (kind) => found.filter(({ path, event }) => {
|
|
414
|
+
if (event.status !== kind)
|
|
415
|
+
return false;
|
|
416
|
+
const marker = path.replace(/\.json$/, `.${kind}.taken`);
|
|
417
|
+
let fd;
|
|
418
|
+
try {
|
|
419
|
+
fd = openSync(marker, 'wx', 0o600);
|
|
420
|
+
}
|
|
421
|
+
catch (e) {
|
|
422
|
+
if (e.code !== 'EEXIST')
|
|
423
|
+
throw e;
|
|
424
|
+
privateFile(marker);
|
|
425
|
+
return false;
|
|
426
|
+
}
|
|
427
|
+
made.push(marker);
|
|
428
|
+
try {
|
|
429
|
+
writeFileSync(fd, event.id);
|
|
430
|
+
fsyncSync(fd);
|
|
431
|
+
}
|
|
432
|
+
finally {
|
|
433
|
+
closeSync(fd);
|
|
434
|
+
}
|
|
435
|
+
return true;
|
|
436
|
+
});
|
|
437
|
+
let fresh = [];
|
|
438
|
+
for (const kind of ['expired', 'refused', 'delivery-uncertain', 'delivered'])
|
|
439
|
+
if (!fresh.length)
|
|
440
|
+
fresh = take(kind);
|
|
441
|
+
if (made.length)
|
|
442
|
+
syncDir(dir);
|
|
443
|
+
return { events: fresh.map(({ key, event }) => ({ key, at: event.at, status: event.status })), live, clean };
|
|
444
|
+
}
|
|
445
|
+
catch (e) {
|
|
446
|
+
for (const m of made)
|
|
447
|
+
rmSync(m, { force: true });
|
|
448
|
+
throw e;
|
|
449
|
+
}
|
|
450
|
+
}, 1);
|
|
285
451
|
}
|
|
286
452
|
catch (e) {
|
|
287
453
|
throw e instanceof VaultError ? e : new VaultError('BUSY');
|
|
@@ -310,6 +476,10 @@ const active = (r) => {
|
|
|
310
476
|
bad('REFUSED');
|
|
311
477
|
if (r.status === 'expired')
|
|
312
478
|
bad('EXPIRED');
|
|
479
|
+
if (r.status === 'delivered')
|
|
480
|
+
bad('ALREADY_READ');
|
|
481
|
+
if (r.status === 'claimed')
|
|
482
|
+
bad('DELIVERY_UNCERTAIN');
|
|
313
483
|
return r;
|
|
314
484
|
};
|
|
315
485
|
export async function createVault(input, value) {
|
|
@@ -325,6 +495,8 @@ export async function createVault(input, value) {
|
|
|
325
495
|
bad('VALIDATION_ERROR'); // Terminal keys are tombstones, never reusable.
|
|
326
496
|
if (meta.expires <= Date.now())
|
|
327
497
|
bad('EXPIRED');
|
|
498
|
+
if (meta.destination !== undefined)
|
|
499
|
+
checkDestination(meta.destination, boardFile(input.board), dir);
|
|
328
500
|
if (!value)
|
|
329
501
|
master(dir);
|
|
330
502
|
const record = { schema: 1, meta, status: value ? 'ready' : 'pending', at: Date.now(), ...(value && { sealed: seal(dir, meta, value) }) };
|
|
@@ -352,3 +524,64 @@ export async function refuseVault(board, key) {
|
|
|
352
524
|
return summary(record);
|
|
353
525
|
});
|
|
354
526
|
}
|
|
527
|
+
/** Give delivery for the bound filer and destination. Pending, or a never-used vault (nothing is created), returns undefined so the
|
|
528
|
+
* caller keeps waiting. Any failure after the claim leaves the record `claimed`, which is terminal (DELIVERY_UNCERTAIN) and never
|
|
529
|
+
* decrypts again. */
|
|
530
|
+
export async function deliverVault(board, key, filer, into, afterClaim) {
|
|
531
|
+
if (!isKey(key) || !isKey(filer) || typeof into !== 'string' || !plain(into))
|
|
532
|
+
bad('VALIDATION_ERROR');
|
|
533
|
+
try {
|
|
534
|
+
if (unused(boardFile(board)))
|
|
535
|
+
return undefined;
|
|
536
|
+
}
|
|
537
|
+
catch (e) {
|
|
538
|
+
throw e instanceof VaultError ? e : new VaultError('BUSY');
|
|
539
|
+
}
|
|
540
|
+
return operate(board, key, false, (dir, path, old) => {
|
|
541
|
+
if (old && old.meta.filer !== filer)
|
|
542
|
+
bad('READER_MISMATCH');
|
|
543
|
+
const r = active(old);
|
|
544
|
+
if (r.meta.mode !== 'give' || resolve(into) !== r.meta.destination)
|
|
545
|
+
bad(r.meta.mode === 'give' ? 'READER_MISMATCH' : 'VALIDATION_ERROR');
|
|
546
|
+
if (r.status === 'pending')
|
|
547
|
+
return undefined;
|
|
548
|
+
const dest = r.meta.destination;
|
|
549
|
+
checkDestination(dest, boardFile(board), dir); // pre-claim: the payload stays ready on any denial
|
|
550
|
+
const secret = master(dir);
|
|
551
|
+
// Commit point: the ciphertext leaves disk here, so no later process can decrypt it.
|
|
552
|
+
publish(path, JSON.stringify({ schema: 1, meta: r.meta, status: 'claimed', at: Date.now() }));
|
|
553
|
+
let made;
|
|
554
|
+
try {
|
|
555
|
+
afterClaim?.();
|
|
556
|
+
const value = unseal(secret, r.meta, r.sealed);
|
|
557
|
+
const fd = openSync(dest, 'wx', 0o600);
|
|
558
|
+
try {
|
|
559
|
+
const st = fstatSync(fd);
|
|
560
|
+
made = { dev: st.dev, ino: st.ino };
|
|
561
|
+
writeFileSync(fd, value);
|
|
562
|
+
fsyncSync(fd);
|
|
563
|
+
}
|
|
564
|
+
finally {
|
|
565
|
+
closeSync(fd);
|
|
566
|
+
value.fill(0);
|
|
567
|
+
}
|
|
568
|
+
syncDir(dirname(dest));
|
|
569
|
+
const done = { schema: 1, meta: r.meta, status: 'delivered', at: Date.now() };
|
|
570
|
+
publish(path, JSON.stringify(done));
|
|
571
|
+
return { key: r.meta.key, at: done.at, status: 'delivered' };
|
|
572
|
+
}
|
|
573
|
+
catch {
|
|
574
|
+
// Remove only a file this call created; a lost wx race is someone else's file and stays.
|
|
575
|
+
try {
|
|
576
|
+
const s = lstatSync(dest);
|
|
577
|
+
if (made && s.dev === made.dev && s.ino === made.ino)
|
|
578
|
+
rmSync(dest);
|
|
579
|
+
}
|
|
580
|
+
catch { /* best effort */ }
|
|
581
|
+
bad('DELIVERY_UNCERTAIN');
|
|
582
|
+
}
|
|
583
|
+
finally {
|
|
584
|
+
secret.fill(0);
|
|
585
|
+
}
|
|
586
|
+
});
|
|
587
|
+
}
|
|
@@ -9,7 +9,7 @@ fixtures/contract/<kind>.v<schema>/valid-<name>.json
|
|
|
9
9
|
fixtures/contract/<kind>.v<schema>/invalid-<field>-<rule>.json
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
-
- `<kind>` is `ask`, `report`, `evidence`, `gate` or `finding`, and `<schema>` its schema number, such as `ask.v1/`. A version ships a folder for every kind it validates. This version ships `ask.v1/`, `report.v1/` and `
|
|
12
|
+
- `<kind>` is `ask`, `report`, `evidence`, `gate` or `finding`, and `<schema>` its schema number, such as `ask.v1/`. A version ships a folder for every kind it validates. This version ships `ask.v1/`, `report.v1/`, `evidence.v1/`, `gate.v1/` and `finding.v1/`. This is the one list of shipped folders; other docs point here.
|
|
13
13
|
- Each file holds one message. An evidence fixture that names `media` ships those files next to it.
|
|
14
14
|
- Each kind has at least 2 valid files and 3 invalid ones: one with a required field missing, one over a limit, and one with an unknown field.
|
|
15
15
|
- An invalid file breaks exactly one rule. Its name gives the field and the rule, such as `invalid-title-too-long.json`, and sign-off's own tests check that the refusal names that field.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"contract":1,"kind":"finding","schema":1,"key":"lfm-missing-env","title":"Repeated cause: missing env var in CI","filer":"learn-from-mistakes","about":"4 incidents over 3 days in 2 sources.","incidents":4,"days":3,"evidence_ids":["rec-7f3a","rec-91be"],"change_kind":"doc","proposal":"Add the env var to the CI setup page.","options":[{"value":"apply","label":"Apply"},{"value":"skip","label":"Skip"}],"recommend_value":"apply"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"contract":1,"kind":"finding","schema":1,"key":"lfm-missing-env","title":"Repeated cause: missing env var in CI","filer":"learn-from-mistakes","about":"4 incidents over 3 days in 2 sources.","cause":"missing-env","incidents":4,"days":3,"evidence_ids":["rec-7f3a","rec-91be"],"change_kind":"process","proposal":"Add the env var to the CI setup page.","options":[{"value":"apply","label":"Apply"},{"value":"skip","label":"Skip"}],"recommend_value":"apply"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"contract":1,"kind":"finding","schema":1,"key":"lfm-missing-env","title":"Repeated cause: missing env var in CI","filer":"learn-from-mistakes","about":"4 incidents over 3 days in 2 sources.","cause":"missing-env","incidents":4,"days":3,"evidence_ids":["rec-7f3a","rec-7f3a"],"change_kind":"doc","proposal":"Add the env var to the CI setup page.","options":[{"value":"apply","label":"Apply"},{"value":"skip","label":"Skip"}],"recommend_value":"apply"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"contract":1,"kind":"finding","schema":1,"key":"lfm-missing-env","title":"Repeated cause: missing env var in CI","filer":"learn-from-mistakes","about":"4 incidents over 3 days in 2 sources.","cause":"missing-env","incidents":4,"days":3,"evidence_ids":["rec-7f3a","rec-91be"],"change_kind":"doc","proposal":"Add the env var to the CI setup page.","options":[{"value":"apply","label":"Apply"},{"value":"skip","label":"Leave it"}],"recommend_value":"apply"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"contract":1,"kind":"finding","schema":1,"key":"lfm-missing-env","title":"Repeated cause: missing env var in CI","filer":"learn-from-mistakes","about":"4 incidents over 3 days in 2 sources.","cause":"missing-env","incidents":4,"days":3,"evidence_ids":["rec-7f3a","rec-91be"],"change_kind":"doc","proposal":"Add the env var to the CI setup page. Add the env var to the CI setup page. Add the env var to the CI setup page. Add the env var to the CI setup page. Add the env var to the CI setup page. Add the env var to the CI setup page. Add the env var to the CI setup page. Add the env var to the CI setup page. Add the env var to the CI setup page. Add the env var to the CI setup page. Add the env var to the CI setup page. Add the env var to the CI setup page. Add the env var to the CI setup page. Add the env var to the CI setup page. ","options":[{"value":"apply","label":"Apply"},{"value":"skip","label":"Skip"}],"recommend_value":"apply"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"contract":1,"kind":"finding","schema":1,"key":"lfm-missing-env","title":"Repeated cause: missing env var in CI","filer":"learn-from-mistakes","about":"4 incidents over 3 days in 2 sources.","cause":"missing-env","incidents":4,"days":3,"evidence_ids":["rec-7f3a","rec-91be"],"change_kind":"doc","proposal":"Add the env var to the CI setup page.","options":[{"value":"apply","label":"Apply"},{"value":"skip","label":"Skip"}],"recommend_value":"apply","record_text":"CI failed: API_TOKEN is not set"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"contract":1,"kind":"finding","schema":1,"key":"lfm-missing-env","title":"Repeated cause: missing env var in CI","filer":"learn-from-mistakes","about":"4 incidents over 3 days in 2 sources.","cause":"missing-env","incidents":4,"days":3,"evidence_ids":["rec-7f3a","rec-91be"],"change_kind":"doc","proposal":"Add the env var to the CI setup page.","options":[{"value":"apply","label":"Apply"},{"value":"skip","label":"Skip"}],"recommend_value":"apply"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"contract":1,"kind":"finding","schema":1,"key":"lfm-flaky-wait","title":"Repeated cause: a test waits on a fixed sleep","about":"2 incidents in 1 day in Umer's runs.","cause":"fixed-sleep","incidents":2,"days":1,"evidence_ids":[],"change_kind":"code","proposal":"Wait for the page to be ready instead of sleeping.","options":[{"value":"skip","label":"Skip"},{"value":"apply","label":"Apply"}],"recommend_value":"skip"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"contract":1,"kind":"gate","schema":1,"key":"dd-gate-c4","title":"Move the sign-in run on past its judge?","about":"The judge could not tell the screens apart.","run":{"run_id":"20261011T120000Z-a1b2c3d4","commit":"0123456789abcdef0123456789abcdef01234567"},"options":[{"value":"approve","label":"Approve"},{"value":"fix","label":"Fix first"}],"recommend_value":"fix","default_if_silent":"fix"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"contract":1,"kind":"gate","schema":1,"key":"dd-gate-c3","title":"Move the sign-in run on past its judge?","about":"The judge could not tell the screens apart.","run":{"run_id":"20261011T120000Z-a1b2c3d4","commit":"0123456789abcdef0123456789abcdef01234567"},"options":[{"value":"approve","label":"Ship it"},{"value":"fix","label":"Fix first"}],"recommend_value":"fix"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"contract":1,"kind":"gate","schema":1,"key":"dd-gate-c2","title":"Move the sign-in run on past its judge?","about":"The judge could not tell the screens apart.","run":{"run_id":"20261011T120000Z-a1b2c3d4","commit":"0123456789abcdef0123456789abcdef01234567"},"options":[{"value":"approve","label":"Approve"}],"recommend_value":"approve"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"contract":1,"kind":"gate","schema":1,"key":"dd-gate-c1","title":"Move the sign-in run on past its judge?","about":"The judge could not tell the screens apart.","options":[{"value":"approve","label":"Approve"},{"value":"fix","label":"Fix first"}],"recommend_value":"fix"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"contract":1,"kind":"gate","schema":1,"key":"dd-gate-c5","title":"Move the sign-in run on past its judge?","about":"The judge could not tell the screens apart.","run":{"run_id":"20261011T120000Z-a1b2c3d4","commit":"0123456789abcdef0123456789abcdef01234567","outcome":"Umer can see it."},"options":[{"value":"approve","label":"Approve"},{"value":"fix","label":"Fix first"}],"recommend_value":"fix"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"contract":1,"kind":"gate","schema":1,"key":"dd-gate-a1b2c3d4","title":"Move the sign-in run on past its judge?","filer":"done-done","about":"The judge could not tell the screens apart.","run":{"run_id":"20261011T120000Z-a1b2c3d4","commit":"0123456789abcdef0123456789abcdef01234567"},"options":[{"value":"approve","label":"Approve"},{"value":"skip","label":"Skip the judge"},{"value":"fix","label":"Fix first"}],"recommend_value":"fix","close":"done"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"contract":1,"kind":"gate","schema":1,"key":"dd-gate-b5c6d7e8","title":"Approve the checkout run for Umer?","about":"Umer's checkout run passed every step but the judge.","run":{"run_id":"20261011T120000Z-a1b2c3d4","commit":"0123456789abcdef0123456789abcdef01234567"},"options":[{"value":"fix","label":"Fix first"},{"value":"approve","label":"Approve"}],"recommend_value":"approve"}
|
package/package.json
CHANGED
package/web/index.html
CHANGED
|
@@ -38,13 +38,15 @@
|
|
|
38
38
|
.tiles { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
|
|
39
39
|
.card, .tile { background: var(--surface); border: 1px solid var(--line); border-radius: 1rem; box-shadow: var(--shadow); }
|
|
40
40
|
.card { padding: 1rem; }
|
|
41
|
-
.tile { position: relative; display: grid; grid-template-columns: auto minmax(0, 1fr)
|
|
41
|
+
.tile { position: relative; display: grid; grid-template-columns: auto auto minmax(0, 1fr); align-content: start; gap: 0.25rem 0.5rem; padding: 0.625rem 0.875rem; color: var(--text); text-decoration: none; }
|
|
42
42
|
.tile::after { content: "›" / ""; position: absolute; top: 0.5rem; right: 0.875rem; color: var(--muted); font-size: 1.5rem; line-height: 1.2; }
|
|
43
43
|
.tile .name { grid-column: 1 / -1; font-size: 0.875rem; font-weight: 600; color: var(--muted); line-height: 1.3; }
|
|
44
44
|
.tile .detail { grid-column: 1 / -1; }
|
|
45
|
-
.tile .n { grid-column: 1 / 3; font-size: 2.125rem; font-weight: 700; line-height: 1.15; font-variant-numeric: tabular-nums; }
|
|
45
|
+
.tile .n { grid-column: 1 / 3; font-size: 2.125rem; font-weight: 700; line-height: 1.15; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
46
46
|
.tile .detail { font-size: 0.8125rem; color: var(--muted); display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; }
|
|
47
|
-
|
|
47
|
+
/* The marks give way to the count: one line at the right, and a mark with no room left wraps out of sight. */
|
|
48
|
+
.vis { align-self: center; display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: center; gap: 0.25rem; height: 2.25rem; overflow: hidden; }
|
|
49
|
+
.vis::before { content: ""; height: 100%; }
|
|
48
50
|
.vis img { width: 1.5rem; height: 2.25rem; object-fit: cover; object-position: top; border: 1px solid var(--line); border-radius: 0.25rem; }
|
|
49
51
|
.mark { width: 0.875rem; height: 0.875rem; border-radius: 50%; border: 2px solid var(--area); }
|
|
50
52
|
/* Waiting shows one clock and Answered up to three checks, drawn in the area's colour. */
|
|
@@ -123,15 +125,16 @@
|
|
|
123
125
|
.foot .meta { margin: 0 auto 0 0; }
|
|
124
126
|
.foot a { color: var(--accent); font-weight: 600; text-decoration: none; }
|
|
125
127
|
.foot button { border-color: transparent; border-radius: 999px; background: var(--sunk); font-weight: 600; }
|
|
126
|
-
|
|
128
|
+
/* Tall enough for the tallest column, six dots under its count, so no count reaches the caption above. */
|
|
129
|
+
.day { position: relative; height: 6.5rem; border-bottom: 1px solid var(--border); }
|
|
127
130
|
.col { position: absolute; bottom: 0.25rem; display: flex; flex-direction: column-reverse; align-items: center; gap: 0.1875rem; transform: translateX(-50%);
|
|
128
|
-
font-size: 0.8125rem; font-weight: 700; line-height: 1.2; }
|
|
131
|
+
font-size: 0.8125rem; font-weight: 700; line-height: 1.2; overflow-wrap: normal; }
|
|
129
132
|
.dot { width: 0.625rem; height: 0.625rem; border-radius: 50%; background: var(--c-answered); }
|
|
130
133
|
.hours { display: flex; justify-content: space-between; color: var(--muted); font-size: 0.8125rem; font-variant-numeric: tabular-nums; }
|
|
131
134
|
.events { list-style: none; margin: 0.75rem 0 0; padding: 0; display: grid; gap: 0.25rem; font-size: 0.9375rem; }
|
|
132
135
|
.events time, .sec-head .meta { font-weight: 700; font-variant-numeric: tabular-nums; margin-right: 0.5rem; }
|
|
133
136
|
.sec-head { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; }
|
|
134
|
-
.sec-head h2 { margin: 0; } .sec-head:has(+ .in-evidence) { margin: 1rem 0 0.375rem; }
|
|
137
|
+
.sec-head h2 { margin: 0; overflow-wrap: normal; } /* A long count beside a section name wraps itself, never the name mid-word. */ .sec-head:has(+ .in-evidence) { margin: 1rem 0 0.375rem; }
|
|
135
138
|
.rows { list-style: none; margin: 0; padding: 0 1rem; }
|
|
136
139
|
.rows > li + li { border-top: 1px solid var(--line); }
|
|
137
140
|
.compact { display: flex; align-items: center; gap: 0.75rem; padding: 0.75rem 0; color: var(--text); text-decoration: none; }
|
|
@@ -394,7 +397,7 @@
|
|
|
394
397
|
const drop = clearIfSaved(d.key, drafts.get(d.key));
|
|
395
398
|
call('/api/answers', {
|
|
396
399
|
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key: d.key, answer: o.value, note }),
|
|
397
|
-
}, () => {
|
|
400
|
+
}, () => { forget(d.key); if (!typed) drop(); }).then(() => offerUndo(d, before)).then(() => typed && call('/api/messages', {
|
|
398
401
|
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ kind: 'comment', key: d.key, text: typed }),
|
|
399
402
|
}, drop)).catch((e) => {
|
|
400
403
|
shown(err).textContent = `Couldn't save: ${e.message}`;
|
|
@@ -566,7 +569,7 @@
|
|
|
566
569
|
const drop = clearIfSaved(d.key, drafts.get(d.key));
|
|
567
570
|
call('/api/rounds', {
|
|
568
571
|
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key: d.key, note }),
|
|
569
|
-
}, () => {
|
|
572
|
+
}, () => { forget(d.key); drop(); }).catch((e) => {
|
|
570
573
|
shown(err).textContent = `Couldn't ask: ${e.message}`;
|
|
571
574
|
roundBtn.disabled = false;
|
|
572
575
|
});
|
|
@@ -672,6 +675,10 @@
|
|
|
672
675
|
|
|
673
676
|
/** Comments on mock elements: the open editor per item, then the unsent list, then the sent ones. An unsent comment shows only on the mock file it was made on. */
|
|
674
677
|
const editing = new Map(), talks = () => !state.readonly && !state.answers_only && !FROZEN;
|
|
678
|
+
// Words typed for an element and not saved yet, kept in page memory (not across a reload) so tapping another element or another Edit loses nothing.
|
|
679
|
+
const unsaved = new Map(), spot = (key, a) => `${key}~${a.mock}~${a.ref}`;
|
|
680
|
+
// Sent with an answer or round: the card's editor closes and its unsaved words go.
|
|
681
|
+
const forget = (key) => { editing.delete(key); for (const k of unsaved.keys()) if (k.startsWith(`${key}~`)) unsaved.delete(k); };
|
|
675
682
|
const redraw = (d) => document.querySelectorAll('.ann').forEach((n) => n.dataset.ann === d.key && n.replaceWith(annotations(d)));
|
|
676
683
|
function annotations(d) {
|
|
677
684
|
const hash = new Map((d.mocks ?? []).map((m) => [m.value, m.hash])), open = editing.get(d.key);
|
|
@@ -704,10 +711,18 @@
|
|
|
704
711
|
let form = '';
|
|
705
712
|
if (open && pages.length) {
|
|
706
713
|
const box = el('textarea', { id: `ann-text-${d.key}`, rows: 2, maxlength: 500, dir: 'auto' }), save = el('button', {}, 'Save comment'), cancel = el('button', { class: 'quick' }, 'Cancel');
|
|
707
|
-
|
|
708
|
-
box.
|
|
709
|
-
|
|
710
|
-
|
|
714
|
+
const at = spot(d.key, open);
|
|
715
|
+
box.value = unsaved.get(at) ?? mine.find((a) => !a.sent && a.mock === open.mock && a.ref === open.ref)?.text ?? '';
|
|
716
|
+
box.oninput = () => unsaved.set(at, box.value);
|
|
717
|
+
// Saved, only this editor closes: one opened while the save was on its way stays open with its words.
|
|
718
|
+
save.onclick = () => {
|
|
719
|
+
const text = box.value;
|
|
720
|
+
post(save, { id: open.id, key: d.key, mock: open.mock, ref: open.ref, tag: open.tag, words: open.words, text, hash: open.hash }, () => {
|
|
721
|
+
if (unsaved.get(at) === text) unsaved.delete(at);
|
|
722
|
+
if (editing.get(d.key) === open) editing.delete(d.key);
|
|
723
|
+
});
|
|
724
|
+
};
|
|
725
|
+
cancel.onclick = () => { unsaved.delete(at); editing.delete(d.key); redraw(d); };
|
|
711
726
|
form = el('div', {}, el('label', { for: box.id }, `Comment on ${what(open)}`), box, el('div', { class: 'actions' }, save, cancel));
|
|
712
727
|
}
|
|
713
728
|
const row = (a, ...buttons) => el('li', {}, el('div', {}, el('p', { class: 'meta' }, what(a)), el('p', { class: 'saved-note', dir: 'auto' }, a.text)), ...buttons);
|
|
@@ -716,7 +731,7 @@
|
|
|
716
731
|
const rows = mine.filter((a) => !a.sent).map((a) => {
|
|
717
732
|
const edit = el('button', { class: 'quick', 'aria-label': `Edit comment, ${what(a)}` }, 'Edit'), del = el('button', { class: 'quick', 'aria-label': `Delete comment, ${what(a)}` }, 'Delete');
|
|
718
733
|
edit.onclick = () => { editing.set(d.key, { ...a }); redraw(d); };
|
|
719
|
-
del.onclick = () => post(del, { id: a.id, deleted: true }, () => { if (editing.get(d.key)?.mock === a.mock && editing.get(d.key)?.ref === a.ref) editing.delete(d.key); });
|
|
734
|
+
del.onclick = () => post(del, { id: a.id, deleted: true }, () => { unsaved.delete(spot(d.key, a)); if (editing.get(d.key)?.mock === a.mock && editing.get(d.key)?.ref === a.ref) editing.delete(d.key); });
|
|
720
735
|
return row(a, el('span', { class: 'actions' }, edit, del));
|
|
721
736
|
});
|
|
722
737
|
return el('div', { class: 'ann', 'data-ann': d.key }, pages.length ? el('div', { class: 'actions' }, toggle) : '',
|
|
@@ -759,21 +774,23 @@
|
|
|
759
774
|
el('span', { class: 'n' }, String(items.length)), el('span', { class: 'vis', 'aria-hidden': 'true' }, ...marks), el('span', { class: 'detail', dir: 'auto' }, detail));
|
|
760
775
|
}
|
|
761
776
|
|
|
762
|
-
/** The day so far: a mark per answer on a line of hours, and the list of them. Only answers have a place on it; an item's `filed` time is not shown here.
|
|
777
|
+
/** The day so far: a mark per answer on a line of hours, and the list of them. Only answers have a place on it; an item's `filed` time is not shown here.
|
|
778
|
+
* Each column carries its own count, so the columns add up to the day's total in the caption; columns sit at least a tenth of the line apart, so two counts never touch. */
|
|
763
779
|
function today(answered) {
|
|
764
|
-
|
|
765
|
-
|
|
780
|
+
// `answered` arrives newest first with ties in the Answered list's order, so the list keeps that order as given; the chart sorts a copy by time.
|
|
781
|
+
const day = new Date().toDateString(), fresh = answered.map((d) => ({ d, t: new Date(state.answers[d.key].at) }))
|
|
782
|
+
.filter((e) => e.t.toDateString() === day), done = [...fresh].sort((x, y) => x.t - y.t);
|
|
766
783
|
const head = (range) => el('div', { class: 'sec-head' }, el('h2', {}, 'Today'), range ? el('span', { class: 'meta' }, range) : '');
|
|
767
784
|
if (!done.length) return el('section', { class: 'card today' }, head(), el('p', { class: 'meta' }, 'Nothing answered today yet.'));
|
|
768
785
|
const h0 = done[0].t.getHours(), step = Math.ceil((done.at(-1).t.getHours() + 1 - h0) / 4), h1 = h0 + step * Math.ceil((done.at(-1).t.getHours() + 1 - h0) / step);
|
|
769
786
|
const x = (t) => ((t.getHours() + t.getMinutes() / 60 - h0) / (h1 - h0)) * 100, cols = new Map();
|
|
770
|
-
for (const e of done) { const k = Math.round(x(e.t) /
|
|
787
|
+
for (const e of done) { const k = Math.round(x(e.t) / 10) * 10; cols.set(k, [...(cols.get(k) ?? []), e]); }
|
|
771
788
|
const hours = Array.from({ length: (h1 - h0) / step + 1 }, (_, i) => `${(h0 + i * step) % 24}:00`), SHOWN = 8;
|
|
772
789
|
return el('section', { class: 'card today' }, head(`${done.length} answered, ${hours[0]} to ${hours.at(-1)}`),
|
|
773
790
|
el('div', { class: 'day', 'aria-hidden': 'true' }, ...[...cols].map(([k, es]) => el('span', { class: 'col', style: `left: ${k}%` },
|
|
774
|
-
...es.slice(0, 6).map(() => el('span', { class: 'dot' })),
|
|
791
|
+
...es.slice(0, 6).map(() => el('span', { class: 'dot' })), String(es.length)))),
|
|
775
792
|
el('div', { class: 'hours', 'aria-hidden': 'true' }, ...hours.map((h) => el('span', {}, h))),
|
|
776
|
-
el('ol', { class: 'events' }, ...
|
|
793
|
+
el('ol', { class: 'events' }, ...fresh.slice(0, SHOWN).map((e) => el('li', { dir: 'auto' }, el('time', {}, hhmm(e.t)), `${title(e.d)} · ${state.answers[e.d.key].label}`))),
|
|
777
794
|
done.length > SHOWN ? el('p', { class: 'meta' }, `and ${done.length - SHOWN} earlier`) : '');
|
|
778
795
|
}
|
|
779
796
|
|
|
@@ -967,6 +984,12 @@
|
|
|
967
984
|
pointing.set(frame.dataset.mock, on);
|
|
968
985
|
aim(frame);
|
|
969
986
|
}
|
|
987
|
+
// While pointing is on, a tap never opens the mock full size, even one that reaches the board instead of the frame:
|
|
988
|
+
// one on the frame's edge, one the browser sends there until it has drawn the frame taking taps, or one before a frame drawn anew is ready.
|
|
989
|
+
addEventListener('click', (e) => {
|
|
990
|
+
const frame = e.target.closest?.('.shot')?.querySelector('iframe[data-mock]');
|
|
991
|
+
if (frame && talks() && pointing.get(frame.dataset.mock) === true) e.preventDefault();
|
|
992
|
+
});
|
|
970
993
|
addEventListener('message', (e) => {
|
|
971
994
|
const frame = mockFrame(e.source), msg = e.data;
|
|
972
995
|
if (!frame || !msg) return;
|