@twinsearth/w2m-dsh-plugin 0.10.5 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +114 -0
- package/deploy/logrotate/README.md +102 -0
- package/deploy/logrotate/w2m.logrotate +86 -0
- package/package.json +8 -4
- package/src/agent/council.mjs +347 -0
- package/src/agent/log-bounds.mjs +330 -0
- package/src/plugin/tools.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,120 @@ All notable changes to this project are documented here. The format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
|
|
5
5
|
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.12.0] — 2026-10-15
|
|
8
|
+
|
|
9
|
+
**Bounding unbounded writers, and making the layering enforceable instead of merely described.**
|
|
10
|
+
|
|
11
|
+
### The log bounding, from three recorded incidents
|
|
12
|
+
|
|
13
|
+
| When | What | Measurement |
|
|
14
|
+
|---|---|---|
|
|
15
|
+
| 2026-10-14 | Linux relay host filled to 100% | one uncapped writer at **14.7 GB/day** |
|
|
16
|
+
| 2026-10-11 → 10-12 | MacMini `relay-test` and node logs | grew **40×** in a day |
|
|
17
|
+
| recurring | build output in project copies and `/tmp` | accumulates without bound |
|
|
18
|
+
|
|
19
|
+
Recovery on the 14th was `truncate`, **not** `rm`, because rsyslog still held the descriptor open — removing
|
|
20
|
+
the file frees nothing while appearing to succeed. That single fact shapes the module: `methodFor` returns
|
|
21
|
+
`truncate` for anything a process writes and `remove` for build output, and the two are never conflated.
|
|
22
|
+
|
|
23
|
+
The 40× case is why there are **two independent findings** rather than one size threshold. `absolute` means
|
|
24
|
+
this file is already too big; `rate` means it grew this much between two samples, so it can be acted on
|
|
25
|
+
*before* it becomes the first kind. The rate is computed from the caller's elapsed time and **not** from
|
|
26
|
+
`mtime`, because `mtime` is rewritten by a rotation, a copy or a `touch` — a rate read from it would be a rate
|
|
27
|
+
of the last touch rather than of the writing.
|
|
28
|
+
|
|
29
|
+
### What is refused, and why the refusal carries its reason
|
|
30
|
+
|
|
31
|
+
`memory.jsonl` is the only durable copy of what a command printed, since the relay keeps `stdout_sha256` and
|
|
32
|
+
never the text. `device.key` is the identity peers know a machine by, and losing it makes every peer holding
|
|
33
|
+
the old one refuse that machine with no visible cause. Protection is by **resolved path prefix and by name**,
|
|
34
|
+
not by name alone, because a name-based check is defeated by putting the same file in another directory. A
|
|
35
|
+
protected file that *was* found is listed with its reason rather than dropped: a report that omits the
|
|
36
|
+
database is indistinguishable from one where the database was not there.
|
|
37
|
+
|
|
38
|
+
`node_modules`, `dist`, `target` and `build` are deliberately **not** protected — being rebuildable is exactly
|
|
39
|
+
what makes them legitimate targets, and `classFor` marks them `rebuildable` so a caller prefers them over
|
|
40
|
+
anything unique.
|
|
41
|
+
|
|
42
|
+
### The layering, as data rather than as a paragraph
|
|
43
|
+
|
|
44
|
+
The goal's wording is "分模块&分层级&分权限". A prose layering is true on the day it is written and quietly
|
|
45
|
+
false afterwards, because nothing fails when an import is added. So `scripts/layer-model.mjs` holds the model
|
|
46
|
+
as **data**, `scripts/layer-check.mjs` derives the graph from the real tree, and `test/layer-model.test.mjs`
|
|
47
|
+
fails when they disagree.
|
|
48
|
+
|
|
49
|
+
It is an **allow-list**, not a denylist. A denylist lets a new edge through and reports nothing; an allow-list
|
|
50
|
+
refuses it and forces the question. The measured shape it now describes is a chain with one shared leaf:
|
|
51
|
+
`plugin → agent(12), util(1), relay(1)`, `relay → chatops(4)`, and `agent`/`relay → shared(1 each)`.
|
|
52
|
+
|
|
53
|
+
Three defects found while building it, all by running rather than reading:
|
|
54
|
+
|
|
55
|
+
* **`src/signing.mjs` was treated as a layer.** It is a file sitting directly in `src/`, so its basename
|
|
56
|
+
became a layer name and two genuine edges came back as "unexplained" — and worse, any future root-level
|
|
57
|
+
file would have silently passed as a layer nobody declared. It now folds into a declared `shared` layer.
|
|
58
|
+
* **`buildGraph(srcDir)` ignored its own argument.** `layerOf` and `insideSrc` hard-coded the real `src`, so
|
|
59
|
+
every synthetic-tree test returned zero edges. Two tests caught it; without them the real-tree test would
|
|
60
|
+
have passed for the wrong reason.
|
|
61
|
+
* **`/x/dist` was classified `report_only`.** The match was on `/dist/` and `\dist\`, which a directory path
|
|
62
|
+
with no trailing separator never matches — so the single most obvious cleanup target of all was not a
|
|
63
|
+
target. Matching is now on whole path segments.
|
|
64
|
+
|
|
65
|
+
A fourth finding is about the tests rather than the code: two fixtures used `mkdtempSync(tmpdir())`, so the
|
|
66
|
+
OS temp path put a `tmp` segment in every path and made files legitimately removable. **The code was right
|
|
67
|
+
both times and the fixture was wrong**, which is worth recording because the reflex is to change the code.
|
|
68
|
+
|
|
69
|
+
### The configuration is offered; the module is what runs
|
|
70
|
+
|
|
71
|
+
`deploy/logrotate/w2m.logrotate` is a host-level file that needs root to install, so this repository can
|
|
72
|
+
*offer* it and cannot *enforce* it — saying otherwise is the kind of claim the fleet rules exist to prevent.
|
|
73
|
+
What runs without root, from a task on any machine, is `src/agent/log-bounds.mjs`. `copytruncate` and
|
|
74
|
+
`maxsize` are both set in the configuration: without the first, rotation renames the file while the daemon
|
|
75
|
+
keeps writing to the old descriptor and the disk stays full; without the second, a file growing 40× a day
|
|
76
|
+
reaches its cap long before a daily timer looks.
|
|
77
|
+
|
|
78
|
+
**28 tests for log bounds and 22 for the layer model, 0 failures.**
|
|
79
|
+
|
|
80
|
+
## [0.11.0] — 2026-10-15
|
|
81
|
+
|
|
82
|
+
**Committee governance (roadmap v4.10.0): one decision engine, ordered phases, weighted votes, retained dissent.**
|
|
83
|
+
|
|
84
|
+
### Built now, wired to nothing — and that is the roadmap's own instruction
|
|
85
|
+
|
|
86
|
+
ROADMAP §3 says a governance layer above a fleet *that has no disagreements yet* is indistinguishable from
|
|
87
|
+
a delay, and that the council earns its place when two fleets want the same machine. So `council.mjs` ships
|
|
88
|
+
as the decision engine, tested in isolation, with no caller. Building it now is justified because its
|
|
89
|
+
semantics are testable; deploying it now would not be.
|
|
90
|
+
|
|
91
|
+
### The three falsifying tests the roadmap names, as the suite's actual cases
|
|
92
|
+
|
|
93
|
+
1. **A phase cannot be skipped.** `advance()` takes no argument, so there is no way to name a destination;
|
|
94
|
+
`goTo`/`setPhase`/`jumpTo` do not exist and a test asserts their absence. A vote before the vote phase
|
|
95
|
+
refuses with `COUNCIL_UNCLEARED_TO_VOTE`, and the message says why: *a vote on nothing*.
|
|
96
|
+
2. **A vote's weights are reproducible from stored inputs.** `weightOf()` is a pure function of two stored
|
|
97
|
+
integers, so `recount()` reproduces an outcome on any machine. The falsification is in the suite: a
|
|
98
|
+
tampered weight is **detected** rather than reproduced, and the tally is asserted order-independent.
|
|
99
|
+
3. **A decision is retrievable *with* its dissent.** Dissent is part of the `Decision` value, not a log
|
|
100
|
+
line, and `unanimous` is an explicit flag because an empty dissent list alone cannot distinguish
|
|
101
|
+
unanimous from unrecorded. A test seals a decision and checks that dropping a ballot changes the seal.
|
|
102
|
+
|
|
103
|
+
### The design choice worth naming
|
|
104
|
+
|
|
105
|
+
`weight = authority × (1 + reputation/100)`, so reputation is a bounded multiplier in `[1, 2)` and **can
|
|
106
|
+
never overturn authority**: authority 49 at perfect reputation (98) still loses to a bare authority 99. A
|
|
107
|
+
governance layer that let reputation outvote the authority model would be a second, undeclared authority
|
|
108
|
+
model, and v4.7.0 already owns that question. A tie is a **refusal**, not a coin toss, because a split
|
|
109
|
+
council is exactly the case a person must see — and the refusal carries the tally that shows the split.
|
|
110
|
+
|
|
111
|
+
### Two design bugs the suite caught, both the same mistake
|
|
112
|
+
|
|
113
|
+
`record()` sets the seal, and `adjourn` comes **after** `record`. Deriving "sealed" from `decision !== null`
|
|
114
|
+
made `record()` block `advance()`, so the final phase was unreachable; and then having `advance()` consult
|
|
115
|
+
the seal at all was self-contradictory for the same reason. The fix is the distinction the module now states
|
|
116
|
+
explicitly: **the seal protects the decision, not the phase machine**. Conflating "decided" with "sealed"
|
|
117
|
+
produced a state nobody could reach — twice.
|
|
118
|
+
|
|
119
|
+
**28 tests, 0 failures.**
|
|
120
|
+
|
|
7
121
|
## [0.10.5] — 2026-10-15
|
|
8
122
|
|
|
9
123
|
**FLEET-RULES.md enters the tree, with this host's reachability measured and written down.**
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# Bounding the log writers on a fleet machine
|
|
2
|
+
|
|
3
|
+
Two incidents put this file here, and both are recorded in the CHANGELOG with their measurements:
|
|
4
|
+
|
|
5
|
+
* **2026-10-14, the Linux relay host** — root filesystem reached 100% from a single uncapped writer at
|
|
6
|
+
**14.7 GB/day**. Recovery was `truncate`, **not** `rm`, because rsyslog still held the descriptor open.
|
|
7
|
+
* **2026-10-11 → 10-12, MacMini** — `relay-test` and node logs grew **40×** in a day.
|
|
8
|
+
|
|
9
|
+
## What this directory is, and what it is not
|
|
10
|
+
|
|
11
|
+
`w2m.logrotate` is a **host-level** configuration. It has to be installed on the machine, by someone with
|
|
12
|
+
root, and it is therefore something this repository can *offer* and cannot *enforce*. Saying otherwise would
|
|
13
|
+
be the kind of claim the fleet rules exist to prevent.
|
|
14
|
+
|
|
15
|
+
What the repository **can** enforce is `src/agent/log-bounds.mjs`: it finds what is growing, decides the
|
|
16
|
+
method (`truncate` for anything a process writes, `remove` for build output), and refuses anything
|
|
17
|
+
irreplaceable. That runs from a task on any machine, needs no root, and reports through an exit code.
|
|
18
|
+
|
|
19
|
+
**Use both.** The configuration is the durable fix; the module is what tells you it is needed and proves it
|
|
20
|
+
was applied.
|
|
21
|
+
|
|
22
|
+
## Install the configuration
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
# Debian/Ubuntu, as root
|
|
26
|
+
install -m 0644 deploy/logrotate/w2m.logrotate /etc/logrotate.d/w2m
|
|
27
|
+
|
|
28
|
+
# Check what it would do, without doing it
|
|
29
|
+
logrotate --debug /etc/logrotate.d/w2m
|
|
30
|
+
|
|
31
|
+
# Force one pass now
|
|
32
|
+
logrotate --force /etc/logrotate.d/w2m
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
On a systemd host the timer is usually already enabled:
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
systemctl status logrotate.timer
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## The two directives that matter, and why
|
|
42
|
+
|
|
43
|
+
**`copytruncate` is not optional here.** Without it, rotation renames the file and the daemon keeps writing
|
|
44
|
+
to the old descriptor: the new file stays empty and **the disk stays full**. That is the 2026-10-14 failure
|
|
45
|
+
in a different costume, and it is why `src/agent/log-bounds.mjs` returns `truncate` rather than `remove` for
|
|
46
|
+
anything a process is writing. The cost of `copytruncate` is a brief window where writes to the file can be
|
|
47
|
+
lost, which is acceptable for logs and would not be for a ledger.
|
|
48
|
+
|
|
49
|
+
**`maxsize` is set as well as the schedule.** A file growing 40× in a day reaches any sane cap long before a
|
|
50
|
+
daily timer looks at it. `daily` plus `maxsize 256M` means "at least once a day, and immediately if it gets
|
|
51
|
+
big", which is the behaviour both incidents needed.
|
|
52
|
+
|
|
53
|
+
## If the paths are not these paths
|
|
54
|
+
|
|
55
|
+
The configuration names the directories this fleet actually uses. Confirm them on the machine before
|
|
56
|
+
installing, rather than trusting this list:
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
# The largest files under the directories that matter, largest first
|
|
60
|
+
du -ah /var/log /var/lib/w2m /tmp 2>/dev/null | sort -rh | head -30
|
|
61
|
+
|
|
62
|
+
# What is growing right now, over 60 seconds
|
|
63
|
+
node -e '
|
|
64
|
+
const { snapshot, growth } = await import("./src/agent/log-bounds.mjs");
|
|
65
|
+
const before = snapshot("/var/log");
|
|
66
|
+
await new Promise((r) => setTimeout(r, 60000));
|
|
67
|
+
const after = snapshot("/var/log");
|
|
68
|
+
for (const f of growth(before, after, 60000).findings) {
|
|
69
|
+
console.log((f.bytes_per_day / 1024 ** 3).toFixed(2) + " GB/day " + f.path);
|
|
70
|
+
}
|
|
71
|
+
'
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Build artefacts: `cargo clean` and its equivalents
|
|
75
|
+
|
|
76
|
+
The third part of the same problem is that build output accumulates in project copies and in `/tmp`. The
|
|
77
|
+
general rule the module applies is **rebuildable things are preferred cleanup targets over anything unique**
|
|
78
|
+
(`classFor` returns `rebuildable` for `node_modules`, `dist`, `target` and `build`).
|
|
79
|
+
|
|
80
|
+
```sh
|
|
81
|
+
cargo clean # a Rust target/ directory is routinely tens of GB
|
|
82
|
+
rm -rf node_modules dist .next # rebuildable from the lockfile and the source
|
|
83
|
+
git -C <repo> clean -xdf # removes ignored files; check `git clean -xdn` first
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`git clean -xdf` is the one to be careful with: it removes *everything* untracked and ignored, which includes
|
|
87
|
+
any local notes that were never committed. Run `git clean -xdn` first and read the list.
|
|
88
|
+
|
|
89
|
+
## What must never be cleaned, whatever the pressure
|
|
90
|
+
|
|
91
|
+
`src/agent/log-bounds.mjs` refuses these by resolved path and by name, and the refusal carries its reason:
|
|
92
|
+
|
|
93
|
+
| Path | Why |
|
|
94
|
+
|---|---|
|
|
95
|
+
| `memory.jsonl` | The **only** durable copy of what a command printed — the relay keeps `stdout_sha256` and never the text. |
|
|
96
|
+
| `device.json`, `device.key` | The identity peers know this machine by. Losing the key makes every peer holding the old one refuse this machine, with no visible cause. |
|
|
97
|
+
| `fleet-memory.md` | Curated by hand; not reproducible from anything. |
|
|
98
|
+
| `.git/` | The history, which is the only record of what was verified. |
|
|
99
|
+
| `src/`, `lib/`, `bin/`, `deploy/`, `profiles/` | The tree and the data directory. |
|
|
100
|
+
|
|
101
|
+
A directory that fills up is a reason to clean the *rebuildable* things first. It is never a reason to delete
|
|
102
|
+
the database.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# Log rotation for the w2m fleet.
|
|
2
|
+
#
|
|
3
|
+
# Install: install -m 0644 deploy/logrotate/w2m.logrotate /etc/logrotate.d/w2m
|
|
4
|
+
# Test: logrotate --debug /etc/logrotate.d/w2m
|
|
5
|
+
#
|
|
6
|
+
# Two incidents put this file here, and both are in the CHANGELOG with their measurements:
|
|
7
|
+
# * 2026-10-14 the relay host's root filesystem reached 100% at 14.7 GB/day from one uncapped writer.
|
|
8
|
+
# Recovery was `truncate`, not `rm`, because rsyslog still held the descriptor open.
|
|
9
|
+
# * 2026-10-11 MacMini's relay-test and node logs grew 40x in a single day.
|
|
10
|
+
#
|
|
11
|
+
# Two directives carry the weight, and neither is a default:
|
|
12
|
+
#
|
|
13
|
+
# copytruncate Without it, rotation renames the file while the daemon keeps writing to the old
|
|
14
|
+
# descriptor: the new file stays empty and THE DISK STAYS FULL. That is the first
|
|
15
|
+
# incident in a different costume. The cost is a brief window where writes to the file
|
|
16
|
+
# can be lost, which is acceptable for logs and would not be for a ledger.
|
|
17
|
+
#
|
|
18
|
+
# maxsize Set as well as the schedule, because a file growing 40x a day reaches any sane cap long
|
|
19
|
+
# before a daily timer looks at it. `daily` + `maxsize 256M` means "at least once a day,
|
|
20
|
+
# and immediately if it gets big", which is what both incidents needed.
|
|
21
|
+
|
|
22
|
+
# The w2m daemons. `w2m-rabbit` owns the relay; `w2m-localside` and `w2m-stun` write far less but share
|
|
23
|
+
# the directory, and rotating a file that is not there is why `missingok` is set.
|
|
24
|
+
/var/log/w2m/*.log /var/log/w2m/*.out /var/log/w2m/*.err {
|
|
25
|
+
daily
|
|
26
|
+
maxsize 256M
|
|
27
|
+
rotate 7
|
|
28
|
+
compress
|
|
29
|
+
delaycompress
|
|
30
|
+
missingok
|
|
31
|
+
notifempty
|
|
32
|
+
copytruncate
|
|
33
|
+
create 0640 root adm
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
# The relay's own test output. This is the path named in the 2026-10-11 incident: it is written by a test
|
|
37
|
+
# harness rather than a service, so nothing signals it and `copytruncate` is the only safe method.
|
|
38
|
+
/var/log/w2m/relay-test* {
|
|
39
|
+
daily
|
|
40
|
+
maxsize 128M
|
|
41
|
+
rotate 3
|
|
42
|
+
compress
|
|
43
|
+
delaycompress
|
|
44
|
+
missingok
|
|
45
|
+
notifempty
|
|
46
|
+
copytruncate
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
# Anything a node process leaves behind under the state directory. Bounded tightly, because a crash loop
|
|
50
|
+
# writes these faster than anything else on the machine.
|
|
51
|
+
/var/lib/w2m/*.log /var/lib/w2m/logs/*.log {
|
|
52
|
+
daily
|
|
53
|
+
maxsize 128M
|
|
54
|
+
rotate 5
|
|
55
|
+
compress
|
|
56
|
+
delaycompress
|
|
57
|
+
missingok
|
|
58
|
+
notifempty
|
|
59
|
+
copytruncate
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# Deploy-time and test-time logs that accumulate in the checkout. These are not written by a long-lived
|
|
63
|
+
# process, so `nocopytruncate` would also work -- `copytruncate` is kept for uniformity, since a file that
|
|
64
|
+
# nobody expects to be open is exactly the file that will be.
|
|
65
|
+
/home/*/w2m-dev/*.log /home/*/w2m-dev/logs/*.log /Users/*/w2m-dev/*.log /Users/*/w2m-dev/logs/*.log {
|
|
66
|
+
daily
|
|
67
|
+
maxsize 128M
|
|
68
|
+
rotate 3
|
|
69
|
+
compress
|
|
70
|
+
delaycompress
|
|
71
|
+
missingok
|
|
72
|
+
notifempty
|
|
73
|
+
copytruncate
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
# A backstop for /tmp. This is deliberately last and deliberately blunt: /tmp on a machine that runs tests
|
|
77
|
+
# accumulates build output, and the alternative is the filesystem filling while every specific rule above
|
|
78
|
+
# reports nothing to do. `maxage` is the part that helps; the size cap is a secondary guard.
|
|
79
|
+
/tmp/w2m* /tmp/relay-test* {
|
|
80
|
+
daily
|
|
81
|
+
maxsize 512M
|
|
82
|
+
rotate 1
|
|
83
|
+
missingok
|
|
84
|
+
notifempty
|
|
85
|
+
copytruncate
|
|
86
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/w2m-dsh-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "DSH: Windows2MacOS — drive the same project on online Windows and macOS machines from one DeepSeek account, across networks and regions. P2P hole punching is the default path (relay + shared server as rendezvous, ledger and fallback), with a bundled STUN responder, Tailscale/public-VPS/tunnel shapes, Rabbit relay + per-machine Localside + eight DSH tools (replicate / split), nine aggregate verdicts and signed requests.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -113,13 +113,13 @@
|
|
|
113
113
|
}
|
|
114
114
|
},
|
|
115
115
|
"scripts": {
|
|
116
|
-
"test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/chatops-fleet-join.test.mjs",
|
|
116
|
+
"test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/log-bounds.test.mjs test/layer-model.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/chatops-fleet-join.test.mjs",
|
|
117
117
|
"test:e2e": "node --test --test-force-exit test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/p2p-agent.test.mjs",
|
|
118
118
|
"test:relay": "node --test --test-force-exit test/relay.test.mjs",
|
|
119
119
|
"test:agent": "node --test --test-force-exit test/agent.test.mjs test/memory.test.mjs",
|
|
120
120
|
"test:tools": "node --test --test-force-exit test/tools.test.mjs",
|
|
121
121
|
"test:crossnetwork": "node --test --test-force-exit test/crossnetwork.test.mjs",
|
|
122
|
-
"test:all": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-agent.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/p2p-crypto.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/chatops-fleet-join.test.mjs",
|
|
122
|
+
"test:all": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/log-bounds.test.mjs test/layer-model.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-agent.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/p2p-crypto.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/chatops-fleet-join.test.mjs",
|
|
123
123
|
"pack": "node scripts/pack.mjs",
|
|
124
124
|
"verify:join": "node scripts/verify-join.mjs",
|
|
125
125
|
"lint": "eslint .",
|
|
@@ -135,7 +135,11 @@
|
|
|
135
135
|
"test:fleet-sync": "node --test --test-force-exit test/fleet-sync.test.mjs",
|
|
136
136
|
"test:health": "node --test --test-force-exit test/health.test.mjs",
|
|
137
137
|
"test:storage": "node --test --test-force-exit test/storage.test.mjs",
|
|
138
|
-
"test:exit-channel": "node --test --test-force-exit test/exit-channel.test.mjs"
|
|
138
|
+
"test:exit-channel": "node --test --test-force-exit test/exit-channel.test.mjs",
|
|
139
|
+
"test:council": "node --test --test-force-exit test/council.test.mjs",
|
|
140
|
+
"test:layers": "node --test --test-force-exit test/layer-model.test.mjs",
|
|
141
|
+
"test:log-bounds": "node --test --test-force-exit test/log-bounds.test.mjs",
|
|
142
|
+
"layer-check": "node scripts/layer-check.mjs"
|
|
139
143
|
},
|
|
140
144
|
"keywords": [
|
|
141
145
|
"dsh-bundle",
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The council: one decision engine with ordered phases, weighted votes, and retained dissent.
|
|
3
|
+
*
|
|
4
|
+
* ## What this is for, and what it refuses to be
|
|
5
|
+
*
|
|
6
|
+
* The source design describes a six-role council and the ROADMAP's own §3 is explicit that a governance
|
|
7
|
+
* layer above a fleet **that has no disagreements yet** is indistinguishable from a delay. So this module is
|
|
8
|
+
* not wired into anything: it is the decision engine itself, built and tested, waiting for the measurement
|
|
9
|
+
* that earns it a caller. Building it now is justified because its *semantics* are testable in isolation;
|
|
10
|
+
* deploying it now would not be, and is not done.
|
|
11
|
+
*
|
|
12
|
+
* ## The three properties the ROADMAP names, and how each is enforced
|
|
13
|
+
*
|
|
14
|
+
* 1. **A phase cannot be skipped.** Phases are an ordered list and `advance()` moves exactly one step, to
|
|
15
|
+
* the declared next phase or nowhere. There is no `goTo(phase)` and no way to open a vote before the
|
|
16
|
+
* deliberation phase has closed, because the vote reads the arguments it claims to have considered.
|
|
17
|
+
* 2. **A vote's weights are reproducible from stored inputs.** `weightOf()` is a pure function of an
|
|
18
|
+
* authority level and a reputation, both of which are stored on the ballot. Nothing about a weight comes
|
|
19
|
+
* from the wall clock, the environment, or the order votes arrived in — so `recount()` on a stored
|
|
20
|
+
* decision reproduces the original outcome, and a discrepancy is detectable rather than arguable.
|
|
21
|
+
* 3. **A decision with recorded dissent is retrievable *with* the dissent.** Dissent is not a log line; it
|
|
22
|
+
* is part of the `Decision` value. A reader who asks for a decision gets the losing arguments, because
|
|
23
|
+
* "what did the people who disagreed say" is the question that makes a decision auditable later.
|
|
24
|
+
*
|
|
25
|
+
* ## Why dissent is retained even when it changes nothing
|
|
26
|
+
*
|
|
27
|
+
* A council that records only outcomes cannot answer "was this contested?" after the fact, and the answer
|
|
28
|
+
* determines whether a decision should be revisited or trusted. Losing arguments are the *reason* the
|
|
29
|
+
* majority is a majority, so discarding them discards the only evidence that the question was hard.
|
|
30
|
+
*
|
|
31
|
+
* ## Design choices that are deliberately narrow
|
|
32
|
+
*
|
|
33
|
+
* * **Reputation is an input, not an inference.** This module never computes a reputation from past
|
|
34
|
+
* behaviour, because a reputation derived here would be a second, hidden authority model competing with
|
|
35
|
+
* v4.7.0's. A caller supplies it; the ballot records it; the recount reproduces it.
|
|
36
|
+
* * **A tie is a refusal, not a coin toss.** Returning *some* outcome for a tie would hide that the council
|
|
37
|
+
* is split, and a split council is precisely the case a human must see.
|
|
38
|
+
*/
|
|
39
|
+
import { createHash } from 'node:crypto';
|
|
40
|
+
|
|
41
|
+
/** The seven phases, in order. Index is the position; there is no other ordering. */
|
|
42
|
+
export const PHASES = Object.freeze([
|
|
43
|
+
'convene', // the question is stated and the council is formed
|
|
44
|
+
'frame', // the question is bounded: what is in scope, what is not
|
|
45
|
+
'deliberate', // arguments are recorded, for and against
|
|
46
|
+
'vote', // weighted votes are cast
|
|
47
|
+
'decide', // the outcome is fixed and dissent is attached
|
|
48
|
+
'record', // the decision is sealed with a hash over its inputs
|
|
49
|
+
'adjourn', // the council closes; nothing further may be added
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
/** The six roles of the AURA council. A role is a hat, not a machine: one machine may hold several. */
|
|
53
|
+
export const ROLES = Object.freeze(['strategist', 'analyst', 'critic', 'auditor', 'executor', 'operator']);
|
|
54
|
+
|
|
55
|
+
/** Refusal codes, so a caller branches on a value rather than on prose. */
|
|
56
|
+
export const COUNCIL_REFUSAL = Object.freeze({
|
|
57
|
+
UNKNOWN_PHASE: 'COUNCIL_UNKNOWN_PHASE',
|
|
58
|
+
WRONG_PHASE: 'COUNCIL_WRONG_PHASE',
|
|
59
|
+
ALREADY_SEALED: 'COUNCIL_ALREADY_SEALED',
|
|
60
|
+
UNKNOWN_ROLE: 'COUNCIL_UNKNOWN_ROLE',
|
|
61
|
+
UNCLEARED_TO_VOTE: 'COUNCIL_UNCLEARED_TO_VOTE',
|
|
62
|
+
ALREADY_VOTED: 'COUNCIL_ALREADY_VOTED',
|
|
63
|
+
BAD_WEIGHT_INPUT: 'COUNCIL_BAD_WEIGHT_INPUT',
|
|
64
|
+
TIED: 'COUNCIL_TIED',
|
|
65
|
+
NOT_FOUND: 'COUNCIL_NOT_FOUND',
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
export class CouncilRefusal extends Error {
|
|
69
|
+
constructor(code, detail, extra = {}) {
|
|
70
|
+
super(`${code}: ${detail}`);
|
|
71
|
+
this.name = 'CouncilRefusal';
|
|
72
|
+
this.code = code;
|
|
73
|
+
this.detail = detail;
|
|
74
|
+
Object.assign(this, extra);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Authority bands, taken from v4.7.0: 0-49 ordinary, 50-69 captain, 70-89 org admin, 90+ system admin. */
|
|
79
|
+
export const AUTHORITY_MAX = 100;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The weight of one participant, as a pure function of the two numbers on its ballot.
|
|
83
|
+
*
|
|
84
|
+
* **This is the whole reproducibility property.** Every input is a stored integer, so the same ballot
|
|
85
|
+
* recounts to the same weight on any machine and at any time. The formula is deliberately simple and
|
|
86
|
+
* deliberately *not* clever:
|
|
87
|
+
*
|
|
88
|
+
* weight = authority × (1 + reputation/100)
|
|
89
|
+
*
|
|
90
|
+
* Authority is the primary term because v4.7.0 already defines what it means and this module has no standing
|
|
91
|
+
* to reinterpret it. Reputation is a bounded multiplier in `[1, 2)`, so reputation can promote a voice but
|
|
92
|
+
* **can never outvote authority**: a participant at authority 40 with a perfect reputation weighs 80, which
|
|
93
|
+
* is still less than a bare authority of 90. That ordering is the point — a governance layer that let
|
|
94
|
+
* reputation overturn the authority model would be a second, undeclared authority model.
|
|
95
|
+
*
|
|
96
|
+
* @param {{authority: number, reputation: number}} ballot
|
|
97
|
+
* @returns {number} the weight, exact and reproducible
|
|
98
|
+
*/
|
|
99
|
+
export function weightOf({ authority, reputation } = {}) {
|
|
100
|
+
if (!Number.isInteger(authority) || authority < 0 || authority > AUTHORITY_MAX) {
|
|
101
|
+
throw new CouncilRefusal(COUNCIL_REFUSAL.BAD_WEIGHT_INPUT, `authority must be an integer in 0..${AUTHORITY_MAX}, got ${JSON.stringify(authority)}`);
|
|
102
|
+
}
|
|
103
|
+
if (!Number.isFinite(reputation) || reputation < 0 || reputation > 100) {
|
|
104
|
+
throw new CouncilRefusal(COUNCIL_REFUSAL.BAD_WEIGHT_INPUT, `reputation must be a number in 0..100, got ${JSON.stringify(reputation)}`);
|
|
105
|
+
}
|
|
106
|
+
// Rounded to 6 places so a floating-point tail cannot make two equal ballots differ in a recount.
|
|
107
|
+
return Math.round(authority * (1 + reputation / 100) * 1e6) / 1e6;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* A stable hash over everything a decision depended on.
|
|
112
|
+
*
|
|
113
|
+
* Exists so "the weights are reproducible" is checkable rather than assertable: recompute the seal from the
|
|
114
|
+
* stored inputs and compare. The fields are sorted and JSON-encoded with no spacing, so the seal does not
|
|
115
|
+
* depend on property order.
|
|
116
|
+
*/
|
|
117
|
+
export function sealOf(decision) {
|
|
118
|
+
const inputs = {
|
|
119
|
+
question: decision.question,
|
|
120
|
+
phase_reached: decision.phase_reached,
|
|
121
|
+
outcomes: (decision.ballots ?? []).map((b) => ({
|
|
122
|
+
participant: b.participant, role: b.role, authority: b.authority,
|
|
123
|
+
reputation: b.reputation, choice: b.choice, weight: b.weight, reason: b.reason,
|
|
124
|
+
})),
|
|
125
|
+
arguments: (decision.arguments ?? []).map((a) => ({ participant: a.participant, stance: a.stance, text: a.text })),
|
|
126
|
+
};
|
|
127
|
+
return createHash('sha256').update(JSON.stringify(inputs)).digest('hex');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Open a council.
|
|
132
|
+
*
|
|
133
|
+
* @param {object} options
|
|
134
|
+
* @param {string} options.question what is being decided
|
|
135
|
+
* @param {string[]} [options.participants] who is in the room, before roles are assigned
|
|
136
|
+
* @param {() => number} [options.now] injected clock
|
|
137
|
+
*/
|
|
138
|
+
export function convene({ question, participants = [], now = () => Date.now() } = {}) {
|
|
139
|
+
if (typeof question !== 'string' || question.trim() === '') throw new TypeError('convene: question is required');
|
|
140
|
+
|
|
141
|
+
/** @type {Array<{participant: string, role: string|null}>} */
|
|
142
|
+
const members = participants.map((p) => ({ participant: p, role: null }));
|
|
143
|
+
let phaseIndex = 0;
|
|
144
|
+
const arguments_ = [];
|
|
145
|
+
const ballots = [];
|
|
146
|
+
let decision = null;
|
|
147
|
+
/**
|
|
148
|
+
* Whether the decision has been *sealed*, which is not the same as whether it exists.
|
|
149
|
+
*
|
|
150
|
+
* The first version derived this from `decision !== null`, and that made `record()` block `advance()` —
|
|
151
|
+
* so the council could never reach `adjourn`, which comes after `record`. The tests caught it in two
|
|
152
|
+
* places. The distinction is worth keeping explicit: a decided-but-unsealed outcome is still open to being
|
|
153
|
+
* recorded, and only a sealed one is closed to every change.
|
|
154
|
+
*/
|
|
155
|
+
let isSealed = false;
|
|
156
|
+
const openedAt = new Date(now()).toISOString();
|
|
157
|
+
|
|
158
|
+
const currentPhase = () => PHASES[phaseIndex];
|
|
159
|
+
const refuseIfSealed = () => {
|
|
160
|
+
if (isSealed) throw new CouncilRefusal(COUNCIL_REFUSAL.ALREADY_SEALED, 'the council has adjourned; a sealed decision is not modified');
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
/** Assign a role. Roles are additive, so one participant may hold several -- a fact worth allowing. */
|
|
164
|
+
function assign(participant, role) {
|
|
165
|
+
refuseIfSealed();
|
|
166
|
+
if (!ROLES.includes(role)) throw new CouncilRefusal(COUNCIL_REFUSAL.UNKNOWN_ROLE, `${JSON.stringify(role)} is not one of ${ROLES.join(', ')}`);
|
|
167
|
+
if (phaseIndex > 0) throw new CouncilRefusal(COUNCIL_REFUSAL.WRONG_PHASE, `roles are assigned during "convene"; the council is in "${currentPhase()}"`);
|
|
168
|
+
const existing = members.find((m) => m.participant === participant);
|
|
169
|
+
if (existing) existing.role = existing.role === null ? role : `${existing.role},${role}`;
|
|
170
|
+
else members.push({ participant, role });
|
|
171
|
+
return { participant, role };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Move exactly one phase forward.
|
|
176
|
+
*
|
|
177
|
+
* There is no way to move two phases or to name a destination. A phase cannot be skipped because the only
|
|
178
|
+
* transition available is to the declared successor -- and that is the ROADMAP's first falsifying test,
|
|
179
|
+
* enforced structurally rather than by a check a caller could bypass.
|
|
180
|
+
*
|
|
181
|
+
* **`advance()` deliberately does not consult the seal.** The seal protects the *decision* from being
|
|
182
|
+
* changed; it is not a lock on the phase machine. Tying the two together is self-contradictory, because
|
|
183
|
+
* `record` seals and `adjourn` comes after it -- so a seal that blocked advancement would make the final
|
|
184
|
+
* phase unreachable. Two tests caught that, which is the second time in this module that conflating
|
|
185
|
+
* "decided" with "sealed" produced a state nobody could reach.
|
|
186
|
+
*/
|
|
187
|
+
function advance() {
|
|
188
|
+
if (phaseIndex >= PHASES.length - 1) return { phase: currentPhase(), moved: false, detail: 'already in the final phase' };
|
|
189
|
+
phaseIndex += 1;
|
|
190
|
+
return { phase: currentPhase(), moved: true };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Record an argument. Only during `deliberate`, because arguments exist to be considered by the vote. */
|
|
194
|
+
function argue({ participant, stance, text }) {
|
|
195
|
+
refuseIfSealed();
|
|
196
|
+
if (currentPhase() !== 'deliberate') {
|
|
197
|
+
throw new CouncilRefusal(COUNCIL_REFUSAL.WRONG_PHASE, `arguments are recorded during "deliberate"; the council is in "${currentPhase()}"`);
|
|
198
|
+
}
|
|
199
|
+
if (stance !== 'for' && stance !== 'against') {
|
|
200
|
+
throw new CouncilRefusal(COUNCIL_REFUSAL.WRONG_PHASE, `stance must be "for" or "against", got ${JSON.stringify(stance)}`);
|
|
201
|
+
}
|
|
202
|
+
const entry = { participant, stance, text: String(text ?? '') };
|
|
203
|
+
arguments_.push(entry);
|
|
204
|
+
return entry;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Cast a weighted vote. Only during `vote`, and only after the deliberation phase has closed. */
|
|
208
|
+
function vote({ participant, role = null, authority, reputation, choice, reason = null }) {
|
|
209
|
+
refuseIfSealed();
|
|
210
|
+
if (currentPhase() !== 'vote') {
|
|
211
|
+
throw new CouncilRefusal(
|
|
212
|
+
COUNCIL_REFUSAL.UNCLEARED_TO_VOTE,
|
|
213
|
+
`votes are cast during "vote"; the council is in "${currentPhase()}". A vote before deliberation would be a vote on nothing.`,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
if (!Number.isInteger(authority)) throw new CouncilRefusal(COUNCIL_REFUSAL.BAD_WEIGHT_INPUT, 'authority must be an integer');
|
|
217
|
+
const weight = weightOf({ authority, reputation });
|
|
218
|
+
if (ballots.some((b) => b.participant === participant)) {
|
|
219
|
+
throw new CouncilRefusal(COUNCIL_REFUSAL.ALREADY_VOTED, `${participant} has already voted; a second ballot would let one participant vote twice`);
|
|
220
|
+
}
|
|
221
|
+
const ballot = { participant, role, authority, reputation, choice, weight, reason, cast_at: new Date(now()).toISOString() };
|
|
222
|
+
ballots.push(ballot);
|
|
223
|
+
return ballot;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Fix the outcome and attach the dissent.
|
|
228
|
+
*
|
|
229
|
+
* A tie is refused rather than broken. Returning *some* outcome for a tie would hide that the council is
|
|
230
|
+
* split, and a split council is exactly the case a person must see.
|
|
231
|
+
*/
|
|
232
|
+
function decide() {
|
|
233
|
+
refuseIfSealed();
|
|
234
|
+
if (currentPhase() !== 'decide') {
|
|
235
|
+
throw new CouncilRefusal(COUNCIL_REFUSAL.WRONG_PHASE, `the outcome is fixed during "decide"; the council is in "${currentPhase()}"`);
|
|
236
|
+
}
|
|
237
|
+
const tally = {};
|
|
238
|
+
for (const b of ballots) tally[b.choice] = Math.round(((tally[b.choice] ?? 0) + b.weight) * 1e6) / 1e6;
|
|
239
|
+
const ranked = Object.entries(tally).sort((a, b) => b[1] - a[1]);
|
|
240
|
+
if (ranked.length === 0) throw new CouncilRefusal(COUNCIL_REFUSAL.TIED, 'no votes were cast, so there is no outcome to fix');
|
|
241
|
+
if (ranked.length > 1 && ranked[0][1] === ranked[1][1]) {
|
|
242
|
+
throw new CouncilRefusal(COUNCIL_REFUSAL.TIED, `a tie between ${ranked[0][0]} and ${ranked[1][0]} at weight ${ranked[0][1]}; a split council needs a person`, { tally });
|
|
243
|
+
}
|
|
244
|
+
const [outcome, weight] = ranked[0];
|
|
245
|
+
const dissent = ballots
|
|
246
|
+
.filter((b) => b.choice !== outcome)
|
|
247
|
+
.map((b) => ({
|
|
248
|
+
participant: b.participant,
|
|
249
|
+
role: b.role,
|
|
250
|
+
choice: b.choice,
|
|
251
|
+
weight: b.weight,
|
|
252
|
+
// The arguing text is carried alongside the ballot where the participant supplied one, because a
|
|
253
|
+
// dissent without its reason is a number rather than a position.
|
|
254
|
+
text: arguments_.filter((a) => a.participant === b.participant).map((a) => a.text).join(' ').trim() || null,
|
|
255
|
+
}));
|
|
256
|
+
decision = {
|
|
257
|
+
question,
|
|
258
|
+
opened_at: openedAt,
|
|
259
|
+
decided_at: new Date(now()).toISOString(),
|
|
260
|
+
phase_reached: 'decide',
|
|
261
|
+
outcome,
|
|
262
|
+
weight,
|
|
263
|
+
tally,
|
|
264
|
+
ballots: [...ballots],
|
|
265
|
+
arguments: [...arguments_],
|
|
266
|
+
dissent,
|
|
267
|
+
unanimous: dissent.length === 0,
|
|
268
|
+
};
|
|
269
|
+
return decision;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Seal the decision. After this the council refuses every mutation. */
|
|
273
|
+
function record() {
|
|
274
|
+
refuseIfSealed();
|
|
275
|
+
if (currentPhase() !== 'record') {
|
|
276
|
+
throw new CouncilRefusal(COUNCIL_REFUSAL.WRONG_PHASE, `the decision is sealed during "record"; the council is in "${currentPhase()}"`);
|
|
277
|
+
}
|
|
278
|
+
if (decision === null) {
|
|
279
|
+
throw new CouncilRefusal(COUNCIL_REFUSAL.WRONG_PHASE, 'there is no decision to seal; decide() must run before record()');
|
|
280
|
+
}
|
|
281
|
+
decision.phase_reached = 'record';
|
|
282
|
+
decision.seal = sealOf(decision);
|
|
283
|
+
decision.sealed_at = new Date(now()).toISOString();
|
|
284
|
+
isSealed = true;
|
|
285
|
+
return decision;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
get phase() { return currentPhase(); },
|
|
290
|
+
get phases() { return [...PHASES]; },
|
|
291
|
+
get members() { return members.map((m) => ({ ...m })); },
|
|
292
|
+
get votes() { return ballots.map((b) => ({ ...b })); },
|
|
293
|
+
get arguments() { return arguments_.map((a) => ({ ...a })); },
|
|
294
|
+
get decision() { return decision; },
|
|
295
|
+
assign,
|
|
296
|
+
argue,
|
|
297
|
+
vote,
|
|
298
|
+
decide,
|
|
299
|
+
record,
|
|
300
|
+
advance,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Recompute a stored decision's outcome from its stored inputs.
|
|
306
|
+
*
|
|
307
|
+
* This is what makes "the weights are reproducible" checkable instead of merely claimed: hand it a decision
|
|
308
|
+
* that was recorded earlier -- possibly on another machine -- and it either reproduces the outcome or says
|
|
309
|
+
* by how much it disagrees. It trusts nothing but the ballot fields.
|
|
310
|
+
*
|
|
311
|
+
* @param {object} decision
|
|
312
|
+
* @returns {{reproduced: boolean, outcome: string|null, weight: number|null, seal_matches: boolean, detail: string}}
|
|
313
|
+
*/
|
|
314
|
+
export function recount(decision) {
|
|
315
|
+
if (decision === null || typeof decision !== 'object') {
|
|
316
|
+
return { reproduced: false, outcome: null, weight: null, seal_matches: false, detail: 'no decision supplied' };
|
|
317
|
+
}
|
|
318
|
+
const tally = {};
|
|
319
|
+
for (const b of decision.ballots ?? []) {
|
|
320
|
+
let weight;
|
|
321
|
+
try {
|
|
322
|
+
weight = weightOf({ authority: b.authority, reputation: b.reputation });
|
|
323
|
+
} catch (e) {
|
|
324
|
+
return { reproduced: false, outcome: null, weight: null, seal_matches: false, detail: `ballot for ${b.participant} has inputs a weight cannot be computed from: ${e.message}` };
|
|
325
|
+
}
|
|
326
|
+
if (weight !== b.weight) {
|
|
327
|
+
return {
|
|
328
|
+
reproduced: false, outcome: null, weight: null, seal_matches: false,
|
|
329
|
+
detail: `ballot for ${b.participant} records weight ${b.weight} but its stored inputs give ${weight}`,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
tally[b.choice] = Math.round(((tally[b.choice] ?? 0) + weight) * 1e6) / 1e6;
|
|
333
|
+
}
|
|
334
|
+
const ranked = Object.entries(tally).sort((a, b) => b[1] - a[1]);
|
|
335
|
+
if (ranked.length === 0) return { reproduced: false, outcome: null, weight: null, seal_matches: false, detail: 'no ballots to recount' };
|
|
336
|
+
const sealMatches = decision.seal === undefined ? false : sealOf(decision) === decision.seal;
|
|
337
|
+
const same = ranked[0][0] === decision.outcome && ranked[0][1] === decision.weight;
|
|
338
|
+
return {
|
|
339
|
+
reproduced: same,
|
|
340
|
+
outcome: ranked[0][0],
|
|
341
|
+
weight: ranked[0][1],
|
|
342
|
+
seal_matches: sealMatches,
|
|
343
|
+
detail: same
|
|
344
|
+
? `outcome ${ranked[0][0]} at weight ${ranked[0][1]} reproduced from ${(decision.ballots ?? []).length} stored ballot(s)`
|
|
345
|
+
: `stored outcome was ${decision.outcome} at ${decision.weight}; the stored inputs give ${ranked[0][0]} at ${ranked[0][1]}`,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounding unbounded writers: find what is growing, classify what may be removed, and never touch what cannot.
|
|
3
|
+
*
|
|
4
|
+
* ## The incidents this comes from, because the thresholds are not guesses
|
|
5
|
+
*
|
|
6
|
+
* 1. **2026-10-14, the Linux relay host.** Root filesystem reached 100% because a process wrote 14.7 GB/day
|
|
7
|
+
* with no cap. The recovery was `truncate`, **not** `rm` — rsyslog still held the file descriptor open, so
|
|
8
|
+
* removing the file freed nothing while appearing to succeed. That single fact is why this module
|
|
9
|
+
* distinguishes "remove" from "truncate" instead of offering one delete operation.
|
|
10
|
+
* 2. **2026-10-11 → 10-12, MacMini.** `relay-test` and node logs grew by a factor of 40. A factor is the
|
|
11
|
+
* important part: the harm is not a file being large, it is a file being large *and still accelerating*,
|
|
12
|
+
* and a size threshold alone cannot see the difference.
|
|
13
|
+
*
|
|
14
|
+
* So there are two independent findings, and they need different responses:
|
|
15
|
+
*
|
|
16
|
+
* * `absolute` — this file is already too big. Act now.
|
|
17
|
+
* * `rate` — this file grew this much between two samples. Act *before* it becomes the first kind.
|
|
18
|
+
*
|
|
19
|
+
* ## Why this module proposes rather than performs
|
|
20
|
+
*
|
|
21
|
+
* The same reason `src/agent/health.mjs` does. A component that decides a file is disposable *and* deletes it
|
|
22
|
+
* is one bug away from deleting `memory.jsonl`, which is the only durable copy of what a command printed —
|
|
23
|
+
* the relay keeps hashes and never the text. Every removal here is returned as a **proposal with a method**
|
|
24
|
+
* (`truncate` or `remove`) and a reason, and the one function that does act refuses anything not marked
|
|
25
|
+
* removable.
|
|
26
|
+
*
|
|
27
|
+
* ## What "removable" means here
|
|
28
|
+
*
|
|
29
|
+
* Only files that are **this machine's own disposable output**: logs, test artefacts, build output, temp
|
|
30
|
+
* files. Never a repository, never a data directory, never a credential, never something a running process
|
|
31
|
+
* is writing. The protected list is checked by resolved path prefix, so a temp directory under a protected
|
|
32
|
+
* root is still protected.
|
|
33
|
+
*/
|
|
34
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
35
|
+
import { resolve } from 'node:path';
|
|
36
|
+
|
|
37
|
+
/** Bytes in a gigabyte, because every threshold below is about disk. */
|
|
38
|
+
const GB = 1024 ** 3;
|
|
39
|
+
|
|
40
|
+
/** A single file this large is worth acting on by itself. */
|
|
41
|
+
export const FILE_WARN_BYTES = 256 * 1024 * 1024;
|
|
42
|
+
|
|
43
|
+
/** A single file this large is the incident, not a warning about it. */
|
|
44
|
+
export const FILE_CRITICAL_BYTES = 1 * GB;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Growth per day that counts as unbounded.
|
|
48
|
+
*
|
|
49
|
+
* 1 GB/day is a tenth of the measured 14.7 GB/day that filled the relay host, so it is deliberately an early
|
|
50
|
+
* warning rather than a late one: at this rate a machine has roughly ten days, which is enough time to act
|
|
51
|
+
* without being urgent about it.
|
|
52
|
+
*/
|
|
53
|
+
export const RATE_WARN_BYTES_PER_DAY = 1 * GB;
|
|
54
|
+
|
|
55
|
+
/** A whole directory this large is reported even when no single file in it is remarkable. */
|
|
56
|
+
export const DIR_WARN_BYTES = 2 * GB;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Paths whose contents may never be proposed for removal.
|
|
60
|
+
*
|
|
61
|
+
* This is a **prefix list checked against resolved paths**, not a set of names, because a name-based check is
|
|
62
|
+
* defeated by putting the same file in another directory. The entries are the things whose loss is not
|
|
63
|
+
* recoverable from anywhere else in this system:
|
|
64
|
+
*
|
|
65
|
+
* * `.git` — the history. Removing it removes the only record of what was verified.
|
|
66
|
+
* * `memory.jsonl` lives under a profile directory; those directories are protected wholesale.
|
|
67
|
+
* * `node_modules` and `dist` are **not** here on purpose: they are rebuildable, which is exactly what
|
|
68
|
+
* makes them legitimate cleanup targets. They are separately flagged as `rebuildable` rather than
|
|
69
|
+
* `disposable`, so a caller can prefer them over anything unique.
|
|
70
|
+
*/
|
|
71
|
+
export const PROTECTED_PREFIXES = Object.freeze([
|
|
72
|
+
'.git',
|
|
73
|
+
'profiles',
|
|
74
|
+
'lib',
|
|
75
|
+
'src',
|
|
76
|
+
'bin',
|
|
77
|
+
'deploy',
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
/** Names that are never a cleanup target wherever they appear. */
|
|
81
|
+
export const PROTECTED_NAMES = Object.freeze([
|
|
82
|
+
'memory.jsonl',
|
|
83
|
+
'memory.jsonl.1',
|
|
84
|
+
'device.json',
|
|
85
|
+
'device.key',
|
|
86
|
+
'fleet-memory.md',
|
|
87
|
+
'ledger.jsonl',
|
|
88
|
+
'manifest.json',
|
|
89
|
+
'.env',
|
|
90
|
+
'id_rsa',
|
|
91
|
+
'id_ed25519',
|
|
92
|
+
]);
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Whether a path is protected.
|
|
96
|
+
*
|
|
97
|
+
* @param {string} pathname
|
|
98
|
+
* @param {string} root the directory the path is interpreted against
|
|
99
|
+
* @returns {{protected: boolean, reason: string|null}}
|
|
100
|
+
*/
|
|
101
|
+
export function isProtected(pathname, root) {
|
|
102
|
+
const base = String(pathname).split(/[/\\]/).pop() ?? '';
|
|
103
|
+
if (PROTECTED_NAMES.includes(base)) return { protected: true, reason: `"${base}" is irreplaceable and is never a cleanup target` };
|
|
104
|
+
const rel = resolve(root, pathname).slice(resolve(root).length).replace(/^[/\\]+/, '');
|
|
105
|
+
const first = rel.split(/[/\\]/)[0];
|
|
106
|
+
if (PROTECTED_PREFIXES.includes(first)) return { protected: true, reason: `"${first}/" is part of the tree or the data directory` };
|
|
107
|
+
return { protected: false, reason: null };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* How a file should be dealt with, which is not always "delete it".
|
|
112
|
+
*
|
|
113
|
+
* `truncate` exists because of the 2026-10-14 recovery: removing a file a daemon still holds open frees
|
|
114
|
+
* nothing, and the operation appears to succeed. For any log a process writes, truncation is the correct
|
|
115
|
+
* method and removal is the wrong one.
|
|
116
|
+
*/
|
|
117
|
+
export const METHOD = Object.freeze({
|
|
118
|
+
TRUNCATE: 'truncate',
|
|
119
|
+
REMOVE: 'remove',
|
|
120
|
+
ROTATE: 'rotate',
|
|
121
|
+
REPORT_ONLY: 'report_only',
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
/** Normalise a path for matching: one separator, no trailing separator. */
|
|
125
|
+
const normalise = (pathname) => String(pathname).replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
|
|
126
|
+
|
|
127
|
+
/** True when `segment` appears as a whole path segment, so `/x/dist` and `/x/dist/a` both match `dist`. */
|
|
128
|
+
function hasSegment(pathname, segment) {
|
|
129
|
+
return normalise(pathname).split('/').includes(segment);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Which method suits a path.
|
|
134
|
+
*
|
|
135
|
+
* A `*.log` (or `relay-test`, or a path under a `log` directory) is being written by something, so it is
|
|
136
|
+
* truncated or rotated. A build artefact is not being written, so it is removed outright.
|
|
137
|
+
*
|
|
138
|
+
* Matching is on **whole path segments** rather than on substrings. The first version looked for `/dist/` and
|
|
139
|
+
* `\dist\`, which failed for the directory itself — `/x/dist` has no trailing separator — so the single most
|
|
140
|
+
* obvious cleanup target of all was classified `report_only`. Two tests caught it, one of them written for a
|
|
141
|
+
* different purpose.
|
|
142
|
+
*/
|
|
143
|
+
export function methodFor(pathname) {
|
|
144
|
+
const p = normalise(pathname);
|
|
145
|
+
if (p.endsWith('.log') || p.endsWith('.err') || p.includes('relay-test') || hasSegment(p, 'log') || hasSegment(p, 'logs')) {
|
|
146
|
+
return METHOD.TRUNCATE;
|
|
147
|
+
}
|
|
148
|
+
if (hasSegment(p, 'node_modules') || hasSegment(p, 'dist') || hasSegment(p, 'target') || hasSegment(p, 'build')) {
|
|
149
|
+
return METHOD.REMOVE;
|
|
150
|
+
}
|
|
151
|
+
if (p.endsWith('.tmp') || hasSegment(p, 'tmp') || hasSegment(p, 'temp')) return METHOD.REMOVE;
|
|
152
|
+
return METHOD.REPORT_ONLY;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Classify a cleanup target so a caller can prefer rebuildable things over anything unique. */
|
|
156
|
+
export function classFor(pathname) {
|
|
157
|
+
const p = normalise(pathname);
|
|
158
|
+
if (hasSegment(p, 'node_modules') || hasSegment(p, 'dist') || hasSegment(p, 'target') || hasSegment(p, 'build')) return 'rebuildable';
|
|
159
|
+
if (p.endsWith('.log') || hasSegment(p, 'log') || hasSegment(p, 'logs') || p.includes('relay-test') || p.endsWith('.tmp')) return 'disposable';
|
|
160
|
+
return 'unknown';
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Sample the size of every file under a path, recursively.
|
|
165
|
+
*
|
|
166
|
+
* Bounded by `maxFiles` on purpose. The first version of the health check walked a user's home directory and
|
|
167
|
+
* made a health check that **timed out** — a monitoring tool too expensive to monitor. The same limit applies
|
|
168
|
+
* here for the same reason, and `truncated: true` says so rather than reporting a partial scan as complete.
|
|
169
|
+
*
|
|
170
|
+
* @returns {Map<string, number>} absolute path -> bytes
|
|
171
|
+
*/
|
|
172
|
+
export function snapshot(root, { maxFiles = 20000 } = {}) {
|
|
173
|
+
const sizes = new Map();
|
|
174
|
+
if (!existsSync(root)) return sizes;
|
|
175
|
+
const stack = [resolve(root)];
|
|
176
|
+
let seen = 0;
|
|
177
|
+
while (stack.length > 0 && seen < maxFiles) {
|
|
178
|
+
const dir = stack.pop();
|
|
179
|
+
let entries;
|
|
180
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { continue; }
|
|
181
|
+
for (const entry of entries) {
|
|
182
|
+
if (seen >= maxFiles) break;
|
|
183
|
+
const full = resolve(dir, entry.name);
|
|
184
|
+
if (entry.isDirectory()) stack.push(full);
|
|
185
|
+
else if (entry.isFile()) {
|
|
186
|
+
seen += 1;
|
|
187
|
+
try { sizes.set(full, statSync(full).size); } catch { sizes.set(full, -1); }
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return sizes;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Compare two snapshots and report what grew.
|
|
196
|
+
*
|
|
197
|
+
* `elapsedMs` comes from the caller as the difference between the two sample times, not from file
|
|
198
|
+
* modification times: `mtime` is rewritten by a rotation, a copy, or a `touch`, and a rate computed from it
|
|
199
|
+
* would be a rate of the last *touch* rather than of the writing.
|
|
200
|
+
*
|
|
201
|
+
* @param {Map<string, number>} before
|
|
202
|
+
* @param {Map<string, number>} after
|
|
203
|
+
* @param {number} elapsedMs
|
|
204
|
+
*/
|
|
205
|
+
export function growth(before, after, elapsedMs, { warnBytesPerDay = RATE_WARN_BYTES_PER_DAY } = {}) {
|
|
206
|
+
if (!Number.isFinite(elapsedMs) || elapsedMs <= 0) return { findings: [], elapsed_ms: elapsedMs, unreadable: true };
|
|
207
|
+
const perDay = (bytes) => Math.round((bytes / elapsedMs) * 86_400_000);
|
|
208
|
+
const findings = [];
|
|
209
|
+
for (const [path, size] of after) {
|
|
210
|
+
const previous = before.get(path) ?? 0;
|
|
211
|
+
const delta = size - previous;
|
|
212
|
+
if (delta <= 0) continue;
|
|
213
|
+
const rate = perDay(delta);
|
|
214
|
+
if (rate < warnBytesPerDay) continue;
|
|
215
|
+
findings.push({
|
|
216
|
+
path,
|
|
217
|
+
grew_bytes: delta,
|
|
218
|
+
from_bytes: previous,
|
|
219
|
+
to_bytes: size,
|
|
220
|
+
bytes_per_day: rate,
|
|
221
|
+
// A file that did not exist before is a new writer, which is a different diagnosis from one that was
|
|
222
|
+
// always there and has started accelerating.
|
|
223
|
+
new_file: previous === 0,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
findings.sort((a, b) => b.bytes_per_day - a.bytes_per_day);
|
|
227
|
+
return { findings, elapsed_ms: elapsedMs, unreadable: false };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Turn findings and sizes into proposals.
|
|
232
|
+
*
|
|
233
|
+
* Returns `{ proposals, protected, notes }`. A protected path that was found is listed under `protected`
|
|
234
|
+
* **with its reason**, rather than dropped: a report that silently omits the database is one a reader cannot
|
|
235
|
+
* tell from a report where the database was not found.
|
|
236
|
+
*/
|
|
237
|
+
export function plan({ root, sizes = new Map(), grown = [], fileWarn = FILE_WARN_BYTES, fileCritical = FILE_CRITICAL_BYTES } = {}) {
|
|
238
|
+
const proposals = [];
|
|
239
|
+
const protected_ = [];
|
|
240
|
+
|
|
241
|
+
const consider = (pathname, reason, severity, bytes) => {
|
|
242
|
+
const guard = isProtected(pathname, root ?? process.cwd());
|
|
243
|
+
if (guard.protected) {
|
|
244
|
+
protected_.push({ path: pathname, bytes, reason: guard.reason, triggered_by: reason });
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
proposals.push({
|
|
248
|
+
path: pathname,
|
|
249
|
+
bytes,
|
|
250
|
+
severity,
|
|
251
|
+
triggered_by: reason,
|
|
252
|
+
method: methodFor(pathname),
|
|
253
|
+
class: classFor(pathname),
|
|
254
|
+
});
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
for (const [path, size] of sizes) {
|
|
258
|
+
if (size < 0) continue;
|
|
259
|
+
if (size >= fileCritical) consider(path, 'absolute', 'critical', size);
|
|
260
|
+
else if (size >= fileWarn) consider(path, 'absolute', 'warn', size);
|
|
261
|
+
}
|
|
262
|
+
for (const g of grown) {
|
|
263
|
+
consider(g.path, 'rate', g.bytes_per_day >= 10 * GB ? 'critical' : 'warn', g.to_bytes);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Critical first, then by size: a caller working through the list top-down should reach the incident
|
|
267
|
+
// before the tidying.
|
|
268
|
+
proposals.sort((a, b) => (a.severity === b.severity ? b.bytes - a.bytes : a.severity === 'critical' ? -1 : 1));
|
|
269
|
+
return { proposals, protected: protected_, notes: [] };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* A `logrotate` configuration for the files that need one.
|
|
274
|
+
*
|
|
275
|
+
* `copytruncate` is the important directive and it is not a default: without it, rotation renames the file
|
|
276
|
+
* and the daemon keeps writing to the old descriptor, so the new file stays empty **and the disk stays
|
|
277
|
+
* full** — the exact failure of 2026-10-14 in a different costume. `copytruncate` copies then truncates in
|
|
278
|
+
* place, which works for a process nobody is going to signal.
|
|
279
|
+
*
|
|
280
|
+
* `maxsize` is set as well as `daily`, because a file that grows 40x in a day reaches its limit long before
|
|
281
|
+
* the daily job looks at it.
|
|
282
|
+
*/
|
|
283
|
+
export function logrotateConfig(paths, options = {}) {
|
|
284
|
+
const maxsize = options.maxsize ?? '256M';
|
|
285
|
+
const rotate = options.rotate ?? 5;
|
|
286
|
+
const frequency = options.frequency ?? 'daily';
|
|
287
|
+
const header = [
|
|
288
|
+
'# Generated by scripts/log-bounds.mjs --bounds.',
|
|
289
|
+
'# copytruncate, not create: a daemon holding the old descriptor keeps the disk full otherwise.',
|
|
290
|
+
'# maxsize as well as the schedule: a file growing 40x a day hits its cap before the timer notices.',
|
|
291
|
+
'',
|
|
292
|
+
].join('\n');
|
|
293
|
+
const body = paths.map((p) => [
|
|
294
|
+
`${p} {`,
|
|
295
|
+
` ${frequency}`,
|
|
296
|
+
` maxsize ${maxsize}`,
|
|
297
|
+
' rotate ' + rotate,
|
|
298
|
+
' compress',
|
|
299
|
+
' delaycompress',
|
|
300
|
+
' missingok',
|
|
301
|
+
' notifempty',
|
|
302
|
+
' copytruncate',
|
|
303
|
+
'}',
|
|
304
|
+
'',
|
|
305
|
+
].join('\n')).join('\n');
|
|
306
|
+
return header + body;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Delete or truncate a single path, refusing anything protected.
|
|
311
|
+
*
|
|
312
|
+
* The only acting function here, and it re-checks the guard rather than trusting the caller's plan: a plan is
|
|
313
|
+
* data that may have been filtered, sorted, serialised or hand-edited between being made and being applied.
|
|
314
|
+
*
|
|
315
|
+
* @returns {{ok: boolean, method: string, detail: string}}
|
|
316
|
+
*/
|
|
317
|
+
export function apply(pathname, root, { truncate } = {}) {
|
|
318
|
+
const guard = isProtected(pathname, root);
|
|
319
|
+
if (guard.protected) return { ok: false, method: METHOD.REPORT_ONLY, detail: `refused: ${guard.reason}` };
|
|
320
|
+
const method = methodFor(pathname);
|
|
321
|
+
if (method === METHOD.REPORT_ONLY) {
|
|
322
|
+
return { ok: false, method, detail: 'not a log, a build artefact or a temp file; reported rather than acted on' };
|
|
323
|
+
}
|
|
324
|
+
if (typeof truncate !== 'function') return { ok: false, method, detail: 'no truncate function supplied; refusing to act without one' };
|
|
325
|
+
try {
|
|
326
|
+
return { ok: true, method, detail: truncate(pathname, method) };
|
|
327
|
+
} catch (e) {
|
|
328
|
+
return { ok: false, method, detail: `failed: ${e.message}` };
|
|
329
|
+
}
|
|
330
|
+
}
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -185,7 +185,7 @@ const MAX_STAGES = 16;
|
|
|
185
185
|
* `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
|
|
186
186
|
* overrides it at runtime for tests and packagers.
|
|
187
187
|
*/
|
|
188
|
-
const PLUGIN_VERSION = '0.
|
|
188
|
+
const PLUGIN_VERSION = '0.12.0';
|
|
189
189
|
|
|
190
190
|
/** How long any single HTTP call may take before it is aborted, in milliseconds. */
|
|
191
191
|
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|