@twinsearth/w2m-dsh-plugin 0.9.7 → 0.10.2
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 +273 -0
- package/ROADMAP.md +135 -0
- package/package.json +11 -4
- package/src/agent/agent.mjs +38 -32
- package/src/agent/approval-index.mjs +300 -0
- package/src/agent/authority.mjs +305 -0
- package/src/agent/blocks.mjs +426 -0
- package/src/agent/health.mjs +330 -0
- package/src/agent/storage.mjs +380 -0
- package/src/plugin/tools.mjs +1 -1
package/src/agent/agent.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
2
|
* The Localside agent: everything between "Rabbit hands us an offer" and
|
|
3
3
|
* "Rabbit has our envelope".
|
|
4
4
|
*
|
|
@@ -48,7 +48,7 @@ import {
|
|
|
48
48
|
resolveProjectCwd,
|
|
49
49
|
} from './git.mjs';
|
|
50
50
|
import { createSpool } from './spool.mjs';
|
|
51
|
-
import { appendMemory, memoryRecord } from './memory.mjs';
|
|
51
|
+
import { MEMORY_FILE, appendMemory, memoryRecord } from './memory.mjs';
|
|
52
52
|
import { matchAllowedCommand, parseAllowedCommands } from './allowed-commands.mjs';
|
|
53
53
|
import { CRYPTO_REASON, DEVICE_KEY_FILE, DeviceKey, PeerKeyStore } from './crypto.mjs';
|
|
54
54
|
import { DEFAULT_P2P_MODE, P2P_DEFAULTS, P2PNode, normalizeP2PMode, normalizeP2PPort } from './p2p-node.mjs';
|
|
@@ -1094,7 +1094,7 @@ export function createAgent(options) {
|
|
|
1094
1094
|
// Loaded here, at construction, rather than lazily at `start()`: the key file is also what a
|
|
1095
1095
|
// *dispatcher* publishes in an offer, and "which key will this machine present" must not depend on
|
|
1096
1096
|
// whether a particular code path happened to have run yet. `loadOrCreate` is the documented rule
|
|
1097
|
-
// ("generated the first time a machine pairs"), and a failure is never fatal
|
|
1097
|
+
// ("generated the first time a machine pairs"), and a failure is never fatal - see `deviceKeyError`.
|
|
1098
1098
|
const deviceKeyOutcome = loadDeviceKey(options.deviceKey ?? null, stateDir, identity.machine_id, identityPath);
|
|
1099
1099
|
/** @type {object|null} This machine's static keypair, or null when there is none. */
|
|
1100
1100
|
const deviceKey = deviceKeyOutcome.key;
|
|
@@ -1103,7 +1103,7 @@ export function createAgent(options) {
|
|
|
1103
1103
|
/**
|
|
1104
1104
|
* A key adopted from the pre-v0.6.2 location (`--state/device.key`). Reported once at startup,
|
|
1105
1105
|
* because a machine whose key moved is a machine whose public key a peer may have learned under the
|
|
1106
|
-
* old file
|
|
1106
|
+
* old file - silence here would make "my peers stopped talking to me" have no visible cause.
|
|
1107
1107
|
*/
|
|
1108
1108
|
const deviceKeyMigratedFrom = deviceKeyOutcome.migratedFrom ?? null;
|
|
1109
1109
|
/**
|
|
@@ -1120,7 +1120,7 @@ export function createAgent(options) {
|
|
|
1120
1120
|
* What each task's direct channel actually was, for the result envelope.
|
|
1121
1121
|
*
|
|
1122
1122
|
* Keyed by `task_id`, alongside the channel map rather than on the channel itself, because the
|
|
1123
|
-
* envelope is built *after* `planDirectDelivery` may have released the channel
|
|
1123
|
+
* envelope is built *after* `planDirectDelivery` may have released the channel - and
|
|
1124
1124
|
* `p2p.encrypted` has to describe the channel that carried this attempt, not whichever channel
|
|
1125
1125
|
* happens to be live when the envelope is written.
|
|
1126
1126
|
*
|
|
@@ -1166,7 +1166,7 @@ export function createAgent(options) {
|
|
|
1166
1166
|
const p2pPeers = new Map();
|
|
1167
1167
|
|
|
1168
1168
|
/**
|
|
1169
|
-
* One reverse-dial attempt per `task_id`
|
|
1169
|
+
* One reverse-dial attempt per `task_id` - the executor's own half of a two-way punch.
|
|
1170
1170
|
*
|
|
1171
1171
|
* A record exists for every offer this agent takes on, including the ones it decides *not* to
|
|
1172
1172
|
* dial for, and that is deliberate: "we did not try, because a channel was already live" and "we
|
|
@@ -1444,8 +1444,8 @@ export function createAgent(options) {
|
|
|
1444
1444
|
* happened rather than what was possible. `reason` is the named code from that same channel, so
|
|
1445
1445
|
* `p2p.encrypted: false` always arrives with a *why*.
|
|
1446
1446
|
*
|
|
1447
|
-
* It is called from both places a channel becomes a task's channel
|
|
1448
|
-
* and the dial that opened one
|
|
1447
|
+
* It is called from both places a channel becomes a task's channel - the offer that arrived on one
|
|
1448
|
+
* and the dial that opened one - and the most recent call wins, which is right: a task's result
|
|
1449
1449
|
* travels over the channel that is live *now*.
|
|
1450
1450
|
*
|
|
1451
1451
|
* @param {string} taskId
|
|
@@ -1479,7 +1479,7 @@ export function createAgent(options) {
|
|
|
1479
1479
|
*
|
|
1480
1480
|
* Without this, a task executed entirely over the relay reports `encrypted: false` with no
|
|
1481
1481
|
* explanation, and a reader cannot tell "this machine has no crypto" from "no direct channel was the
|
|
1482
|
-
* answer for this task"
|
|
1482
|
+
* answer for this task" - entirely different operational facts. The reason is the *same* string
|
|
1483
1483
|
* `offerTransportOf` puts in `p2p.reason`, deliberately: one sentence, decided in one place.
|
|
1484
1484
|
*
|
|
1485
1485
|
* It never overwrites a channel's own record: once a channel carried something for this task, which
|
|
@@ -2118,7 +2118,7 @@ export function createAgent(options) {
|
|
|
2118
2118
|
* Signing headers for one request, or nothing at all.
|
|
2119
2119
|
*
|
|
2120
2120
|
* The signed `path` is the **API-relative** path plus its query
|
|
2121
|
-
* (`/v1/stream?machine_id
|
|
2121
|
+
* (`/v1/stream?machine_id= - seq=5`), never the URL as sent.
|
|
2122
2122
|
*
|
|
2123
2123
|
* That looks backwards -- surely the signature should cover the bytes on the
|
|
2124
2124
|
* wire? -- so here is the case that settles it. With `rabbitUrl =
|
|
@@ -2283,7 +2283,7 @@ export function createAgent(options) {
|
|
|
2283
2283
|
* * a **pairing code** is the operator saying *"let this machine in"*, and it is the only way to
|
|
2284
2284
|
* place a device in a fleet the device could not compute for itself;
|
|
2285
2285
|
* * a **fleet key** is the account saying *"this machine is mine"*, and the relay derives the
|
|
2286
|
-
* fleet from it
|
|
2286
|
+
* fleet from it - so it can only ever reach a fleet its holder is already entitled to.
|
|
2287
2287
|
*
|
|
2288
2288
|
* Passing neither remains allowed: the relay answers `PAIRING_INVALID` and the caller reports it,
|
|
2289
2289
|
* which is the pre-v0.8.1 behaviour for an unpaired machine.
|
|
@@ -2298,8 +2298,7 @@ export function createAgent(options) {
|
|
|
2298
2298
|
/**
|
|
2299
2299
|
* Send only the discriminator that was actually given.
|
|
2300
2300
|
*
|
|
2301
|
-
* `pairing_code: null` would be *present*, and a presence check reads a null as "provided"
|
|
2302
|
-
* the same trap that made `createILinkChannel` pass `{ fetch: undefined }` and throw on mount in
|
|
2301
|
+
* `pairing_code: null` would be *present*, and a presence check reads a null as "provided" - * the same trap that made `createILinkChannel` pass `{ fetch: undefined }` and throw on mount in
|
|
2303
2302
|
* v0.7.0. An absent code must be an absent key, not a null one.
|
|
2304
2303
|
*/
|
|
2305
2304
|
if (typeof pairingCode === 'string' && pairingCode !== '') body.pairing_code = pairingCode;
|
|
@@ -2419,7 +2418,7 @@ export function createAgent(options) {
|
|
|
2419
2418
|
* states, and a bound nobody measures is a claim: this is the number that says
|
|
2420
2419
|
* whether the result path really stayed inside it.
|
|
2421
2420
|
* * `result_error` -- why the direct copy of *this result* did not land, when one was attempted
|
|
2422
|
-
* and not acknowledged (`P2P_ACK_TIMEOUT:
|
|
2421
|
+
* and not acknowledged (`P2P_ACK_TIMEOUT: - ). Null otherwise; this is the
|
|
2423
2422
|
* `delivery.error` that `p2pDeliveryMark` has always carried and the envelope
|
|
2424
2423
|
* never reported.
|
|
2425
2424
|
*
|
|
@@ -2438,8 +2437,7 @@ export function createAgent(options) {
|
|
|
2438
2437
|
*
|
|
2439
2438
|
* Read from what the agent *recorded* when it held the channel, not from a live object: by the
|
|
2440
2439
|
* time the envelope is built the channel may have been released, and `p2p.encrypted` has to
|
|
2441
|
-
* describe the channel that carried this attempt. `null` means no channel was ever involved
|
|
2442
|
-
* a task executed entirely over the relay — which reports `encrypted: false` because that is the
|
|
2440
|
+
* describe the channel that carried this attempt. `null` means no channel was ever involved - * a task executed entirely over the relay - which reports `encrypted: false` because that is the
|
|
2443
2441
|
* truth about this task's data plane, whatever this machine's keypair could have done.
|
|
2444
2442
|
*/
|
|
2445
2443
|
const encryption = encryptionFor(offer?.task_id);
|
|
@@ -2653,17 +2651,25 @@ export function createAgent(options) {
|
|
|
2653
2651
|
* The write happens **after** the envelope is complete, so the record it stores is the same one the
|
|
2654
2652
|
* relay will judge -- not a second, independently assembled opinion about what happened.
|
|
2655
2653
|
*
|
|
2656
|
-
* `appendMemory` never throws and its result
|
|
2657
|
-
*
|
|
2658
|
-
*
|
|
2659
|
-
*
|
|
2654
|
+
* `appendMemory` never throws and its result never changes the verdict: the task is the work and
|
|
2655
|
+
* this is a note about it, so a full disk or an unwritable directory must not turn a successful
|
|
2656
|
+
* command into a failed task. A memory that can fail work is worse than no memory.
|
|
2657
|
+
*
|
|
2658
|
+
* v0.9.8: its *outcome* is nonetheless reported, as `memory` on the envelope. Leaving it entirely
|
|
2659
|
+
* silent was the right call for the control flow and the wrong one for the reader -- a machine whose
|
|
2660
|
+
* memory is not being written is indistinguishable from one that has nothing to remember, which is
|
|
2661
|
+
* exactly the class of invisible failure this layer exists to end. One optional field, no verdict
|
|
2662
|
+
* moved: it is not a comparable field, so two machines cannot disagree over it.
|
|
2660
2663
|
*/
|
|
2661
|
-
appendMemory(stateDir, memoryRecord({
|
|
2664
|
+
const memoryWrite = appendMemory(stateDir, memoryRecord({
|
|
2662
2665
|
envelope,
|
|
2663
2666
|
commandArgv,
|
|
2664
2667
|
cwdRel,
|
|
2665
2668
|
ms: (Date.parse(endedAt) - Date.parse(startedAt)) || 0,
|
|
2666
2669
|
}));
|
|
2670
|
+
envelope.memory = memoryWrite.ok === true
|
|
2671
|
+
? { written: true, file: MEMORY_FILE, rotated: memoryWrite.rotated === true }
|
|
2672
|
+
: { written: false, reason_code: memoryWrite.error };
|
|
2667
2673
|
|
|
2668
2674
|
return buildEnvelope(envelope);
|
|
2669
2675
|
}
|
|
@@ -3220,7 +3226,7 @@ export function createAgent(options) {
|
|
|
3220
3226
|
}
|
|
3221
3227
|
|
|
3222
3228
|
if (channelFor(offer?.task_id) === null) {
|
|
3223
|
-
// No channel at all, so nothing about this task's data plane was ever sealed
|
|
3229
|
+
// No channel at all, so nothing about this task's data plane was ever sealed - and the reason
|
|
3224
3230
|
// recorded here is the one `offerTransportOf` already decided for `p2p.reason`.
|
|
3225
3231
|
rememberNoChannel(offer?.task_id, offerTransportOf(offer, p2pStatusSnapshot()).reason);
|
|
3226
3232
|
return p2pDeliveryMark();
|
|
@@ -3282,7 +3288,7 @@ export function createAgent(options) {
|
|
|
3282
3288
|
|
|
3283
3289
|
const startedAt = process.hrtime.bigint();
|
|
3284
3290
|
// `machine_id` travels with the notice because the dispatcher has to be able to say *which*
|
|
3285
|
-
// machine finished without waiting for the relay's copy
|
|
3291
|
+
// machine finished without waiting for the relay's copy - the plugin names its direct-result
|
|
3286
3292
|
// inbox file after it. The frame still carries no envelope: `result_path` is decided by the
|
|
3287
3293
|
// acknowledgement this frame is waiting for, and `envelope_sha256` covers `result_path`, so
|
|
3288
3294
|
// sending the envelope first and stamping it afterwards would break the hash it is signed with.
|
|
@@ -3415,7 +3421,7 @@ export function createAgent(options) {
|
|
|
3415
3421
|
code = null;
|
|
3416
3422
|
}
|
|
3417
3423
|
const error = new Error(`stream HTTP ${response.status} ${body.slice(0, 200)}`.trim());
|
|
3418
|
-
// Preserve the relay's own code (SIGNATURE_REQUIRED, SIGNATURE_EXPIRED,
|
|
3424
|
+
// Preserve the relay's own code (SIGNATURE_REQUIRED, SIGNATURE_EXPIRED, -
|
|
3419
3425
|
// so a signed link can be diagnosed without guessing.
|
|
3420
3426
|
error.code = code ?? `HTTP_${response.status}`;
|
|
3421
3427
|
state.lastRelayError = {
|
|
@@ -3568,7 +3574,7 @@ export function createAgent(options) {
|
|
|
3568
3574
|
// peer tried to put on the wire is overwritten, so a relay frame can never claim the
|
|
3569
3575
|
// direct path -- the only thing that sets `'p2p'` here is the channel having delivered it.
|
|
3570
3576
|
const offer = { ...payload, p2p_transport: source.transport };
|
|
3571
|
-
// v0.6.0: the block's `static_keys` is the *entire* key-distribution mechanism
|
|
3577
|
+
// v0.6.0: the block's `static_keys` is the *entire* key-distribution mechanism - a machine
|
|
3572
3578
|
// learns the keys of exactly the peers it was asked to talk to, from the offer that asked it,
|
|
3573
3579
|
// and never from a config file. Learned here, before the offer is queued, because the reverse
|
|
3574
3580
|
// dial starts inside `handleOffer` and a key merged any later would miss the handshake this
|
|
@@ -4127,8 +4133,8 @@ export function createAgent(options) {
|
|
|
4127
4133
|
/**
|
|
4128
4134
|
* Load this machine's static keypair, or explain why there is none (v0.6.0).
|
|
4129
4135
|
*
|
|
4130
|
-
* Never throws. A machine whose key file is unreadable is still a working W2M machine
|
|
4131
|
-
* still travels over the relay
|
|
4136
|
+
* Never throws. A machine whose key file is unreadable is still a working W2M machine - every task
|
|
4137
|
+
* still travels over the relay - and `agent.state.p2p.encryption.error` names the file that could not
|
|
4132
4138
|
* be read so an operator can fix it. Refusing to start an agent because a *security upgrade* could not
|
|
4133
4139
|
* complete is exactly the fleet-losing failure `docs/P2P-CRYPTO.md` §4 forbids.
|
|
4134
4140
|
*
|
|
@@ -4138,15 +4144,15 @@ export function createAgent(options) {
|
|
|
4138
4144
|
* used to break it: it loaded from the `--state` directory, justified by a comment claiming that
|
|
4139
4145
|
* `identity.mjs` puts `device.json` there "when the agent is run with `--state`". That claim is false.
|
|
4140
4146
|
* `device.json` is fixed at `<DSH_HOME>/xclient/device.json` by the identity contract and `--state`
|
|
4141
|
-
* does not move it
|
|
4147
|
+
* does not move it - which is why an agent run as `--state ~/.dsh/xclient/localside-mac` had its
|
|
4142
4148
|
* identity one directory *above* its key.
|
|
4143
4149
|
*
|
|
4144
4150
|
* The measured consequence, on one machine running both halves of W2M: the plugin (which loads the key
|
|
4145
4151
|
* correctly, from `dirname(device.json)`) and the agent each generated a *different* pair, and both
|
|
4146
|
-
* announced to the one `/v1/peer/{machine_id}` slot
|
|
4152
|
+
* announced to the one `/v1/peer/{machine_id}` slot - the relay's record alternated between the two
|
|
4147
4153
|
* nodes' ports every few seconds, and the `static_key` a dispatcher read was one node's or the other's.
|
|
4148
4154
|
* A dispatcher that learns the wrong key cannot arm a handshake, so every direct channel came back
|
|
4149
|
-
* `P2P_CRYPTO_PLAINTEXT_PEER`
|
|
4155
|
+
* `P2P_CRYPTO_PLAINTEXT_PEER` - reported as "the peer is an older build" when both ends were the same
|
|
4150
4156
|
* build on the same host.
|
|
4151
4157
|
*
|
|
4152
4158
|
* MIGRATION. `DeviceKey.load` warns that "silently generating a replacement key would make every peer
|
|
@@ -4168,7 +4174,7 @@ function loadDeviceKey(injected, stateDir, machineId, deviceFile = undefined) {
|
|
|
4168
4174
|
return { key: injected, error: null, migratedFrom: null };
|
|
4169
4175
|
}
|
|
4170
4176
|
|
|
4171
|
-
// Beside `device.json`
|
|
4177
|
+
// Beside `device.json` - the documented rule. `identityPath` is the path the caller actually
|
|
4172
4178
|
// loaded, never a fresh global resolution: resolving `DSH_HOME` here would make a test (or any
|
|
4173
4179
|
// caller with an injected identity) read and *create* a key in the real user's home.
|
|
4174
4180
|
let canonicalDir = null;
|
|
@@ -4179,7 +4185,7 @@ function loadDeviceKey(injected, stateDir, machineId, deviceFile = undefined) {
|
|
|
4179
4185
|
/**
|
|
4180
4186
|
* Candidates in order. `mayCreate` is the whole subtlety: only a location that is *the* documented
|
|
4181
4187
|
* home may mint a key. A legacy directory is consulted to adopt a key that is already there, never
|
|
4182
|
-
* to create one
|
|
4188
|
+
* to create one - `DeviceKey.load` warns that generating a replacement "would make every peer that
|
|
4183
4189
|
* knows the old one refuse this machine", so an upgrade must not silently change this machine's
|
|
4184
4190
|
* public key.
|
|
4185
4191
|
*
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cross-session pending-approval index (roadmap v4.9.0).
|
|
3
|
+
*
|
|
4
|
+
* ## The problem
|
|
5
|
+
*
|
|
6
|
+
* `approval-broker.mjs` answers approvals **per session**: a request belongs to one IM window, and the
|
|
7
|
+
* operator answers it there. That is right for the person who is *in* that conversation and wrong for a
|
|
8
|
+
* supervisor, who by definition is not — the design's own words are that a supervisor reviews all agents'
|
|
9
|
+
* pending approvals *from one queue*. Without this file, a request raised in a session nobody is watching
|
|
10
|
+
* sits until its deadline delegates it, and the fact that it was waiting is recorded only inside the
|
|
11
|
+
* session that could not answer it.
|
|
12
|
+
*
|
|
13
|
+
* ## What the index does and, more importantly, does not do
|
|
14
|
+
*
|
|
15
|
+
* It **presents** pending work across sessions, filtered by what the viewer has the authority to act on,
|
|
16
|
+
* and it **verifies** authority at the moment of decision. It does not decide anything itself: `decide()`
|
|
17
|
+
* returns a verdict and the caller settles the broker. That split exists because the broker already owns
|
|
18
|
+
* settlement — the timer, the abort listener and the delegate all settle one record — and a second settler
|
|
19
|
+
* would be a second owner of the same promise.
|
|
20
|
+
*
|
|
21
|
+
* ## Why the filter is authority-based rather than a flag
|
|
22
|
+
*
|
|
23
|
+
* Every actor above the ordinary band can in principle approve *something*; what differs is what. So
|
|
24
|
+
* `listFor` answers "what may this actor act on" rather than "is this actor a supervisor", and the answer
|
|
25
|
+
* is per request. An index that showed everything to everyone above a threshold would make the threshold
|
|
26
|
+
* the whole policy, and the authority model would be decoration.
|
|
27
|
+
*
|
|
28
|
+
* ## The three refusals, each for a different reason
|
|
29
|
+
*
|
|
30
|
+
* * **Self-approval** is refused: an actor that can approve its own request has no supervisor.
|
|
31
|
+
* * **Peer approval** is refused: two captains approving each other is a ring, not a chain, and the
|
|
32
|
+
* chain's whole purpose is that a decision can be escalated past the level that wanted it.
|
|
33
|
+
* * **A vanished request** is refused rather than ignored, because "already answered" and "never
|
|
34
|
+
* existed" look identical from a stale queue row and only one means someone should fix their view.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { AUTHORITY_VERDICT, isAuthorityLevel, mayApprove, requiredApproverLevel } from './authority.mjs';
|
|
38
|
+
|
|
39
|
+
/** Why the index refused to let an actor act. Codes, so a caller can branch without matching prose. */
|
|
40
|
+
export const INDEX_REFUSAL = Object.freeze({
|
|
41
|
+
UNKNOWN_REQUEST: 'APPROVAL_UNKNOWN_REQUEST',
|
|
42
|
+
ALREADY_SETTLED: 'APPROVAL_ALREADY_SETTLED',
|
|
43
|
+
SELF_APPROVAL: 'APPROVAL_SELF_APPROVAL',
|
|
44
|
+
INSUFFICIENT_AUTHORITY: 'APPROVAL_INSUFFICIENT_AUTHORITY',
|
|
45
|
+
NO_SUBJECT_LEVEL: 'APPROVAL_NO_SUBJECT_LEVEL',
|
|
46
|
+
BAD_ACTOR: 'APPROVAL_BAD_ACTOR',
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* How long a settled request stays visible to a supervisor.
|
|
51
|
+
*
|
|
52
|
+
* A queue that empties the instant a decision is made cannot answer "did I already deal with that", which
|
|
53
|
+
* is the question a supervisor returning to a busy queue actually has. Kept short deliberately: this is a
|
|
54
|
+
* receipt, not a history, and the audit trail is the place for history.
|
|
55
|
+
*/
|
|
56
|
+
export const SETTLED_RETENTION_MS = 120_000;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Create an index over one or more sessions' pending approvals.
|
|
60
|
+
*
|
|
61
|
+
* @param {object} [options]
|
|
62
|
+
* @param {() => number} [options.now] Injected clock, so a test can advance time without waiting.
|
|
63
|
+
* @param {(event: string, detail?: object) => void} [options.audit] Called on every state change.
|
|
64
|
+
* @param {number} [options.settledRetentionMs]
|
|
65
|
+
*/
|
|
66
|
+
export function createApprovalIndex(options = {}) {
|
|
67
|
+
const now = options.now ?? (() => Date.now());
|
|
68
|
+
const audit = options.audit ?? (() => {});
|
|
69
|
+
const settledRetentionMs = options.settledRetentionMs ?? SETTLED_RETENTION_MS;
|
|
70
|
+
|
|
71
|
+
/** approvalId -> entry. The entry is the index's own record; the broker owns settlement. */
|
|
72
|
+
const entries = new Map();
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Register a pending approval.
|
|
76
|
+
*
|
|
77
|
+
* Called by whatever raised it — the bridge, in practice — so that the request becomes visible outside
|
|
78
|
+
* the session that owns it. The subject's authority level is recorded **here**, at raise time, because
|
|
79
|
+
* it is a fact about who asked and must not be re-derived later from a caller who might be the approver.
|
|
80
|
+
* An index that let the approver supply the subject's level would let the approver lower it.
|
|
81
|
+
*
|
|
82
|
+
* @param {{approvalId: string, sessionKey?: string|null, subject: {id?: string|null, level: number},
|
|
83
|
+
* action: string, toolName?: string|null, reason?: string|null, openedAt?: number,
|
|
84
|
+
* deadlineAt?: number|null}} entry
|
|
85
|
+
* @returns {{ok: boolean, reason_code?: string, detail?: string}}
|
|
86
|
+
*/
|
|
87
|
+
function register(entry) {
|
|
88
|
+
const approvalId = entry?.approvalId;
|
|
89
|
+
if (typeof approvalId !== 'string' || approvalId === '') {
|
|
90
|
+
return { ok: false, reason_code: INDEX_REFUSAL.UNKNOWN_REQUEST, detail: 'register needs an approvalId' };
|
|
91
|
+
}
|
|
92
|
+
if (!isAuthorityLevel(entry?.subject?.level)) {
|
|
93
|
+
// Refused rather than defaulted. A request whose subject has no level cannot be placed relative to
|
|
94
|
+
// any approver, and guessing a level here would be inventing the one fact the decision rests on.
|
|
95
|
+
return { ok: false, reason_code: INDEX_REFUSAL.NO_SUBJECT_LEVEL, detail: `subject level must be an integer 0..100, got ${JSON.stringify(entry?.subject?.level ?? null)}` };
|
|
96
|
+
}
|
|
97
|
+
if (typeof entry.action !== 'string' || entry.action.trim() === '') {
|
|
98
|
+
return { ok: false, reason_code: INDEX_REFUSAL.UNKNOWN_REQUEST, detail: 'register needs a non-empty action' };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const at = Number.isFinite(entry.openedAt) ? entry.openedAt : now();
|
|
102
|
+
const record = {
|
|
103
|
+
approvalId,
|
|
104
|
+
sessionKey: entry.sessionKey ?? null,
|
|
105
|
+
subjectId: entry.subject.id ?? null,
|
|
106
|
+
subjectLevel: entry.subject.level,
|
|
107
|
+
action: entry.action,
|
|
108
|
+
toolName: entry.toolName ?? null,
|
|
109
|
+
reason: entry.reason ?? null,
|
|
110
|
+
openedAt: at,
|
|
111
|
+
deadlineAt: Number.isFinite(entry.deadlineAt) ? entry.deadlineAt : null,
|
|
112
|
+
settled: null,
|
|
113
|
+
settledAt: null,
|
|
114
|
+
};
|
|
115
|
+
entries.set(approvalId, record);
|
|
116
|
+
audit('approval-index/registered', { approval_id: approvalId, session: record.sessionKey, subject_level: record.subjectLevel, action: record.action });
|
|
117
|
+
return { ok: true };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Retention is applied on read, so no timer is needed and a paused process cannot accumulate work. */
|
|
121
|
+
function reap() {
|
|
122
|
+
const cutoff = now() - settledRetentionMs;
|
|
123
|
+
for (const [id, record] of entries) {
|
|
124
|
+
if (record.settled !== null && record.settledAt !== null && record.settledAt < cutoff) entries.delete(id);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Can this actor act on this request? A pure question: it settles nothing.
|
|
130
|
+
*
|
|
131
|
+
* Split from `decide` so a caller can render a queue with reasons and only then act, without every
|
|
132
|
+
* render having a side effect.
|
|
133
|
+
*
|
|
134
|
+
* @param {object} record
|
|
135
|
+
* @param {{id?: string|null, level: number}} actor
|
|
136
|
+
* @returns {{allowed: boolean, reason_code: string|null, detail: string|null, required: number|null}}
|
|
137
|
+
*/
|
|
138
|
+
function mayAct(record, actor) {
|
|
139
|
+
if (!isAuthorityLevel(actor?.level)) {
|
|
140
|
+
return { allowed: false, reason_code: INDEX_REFUSAL.BAD_ACTOR, detail: `actor level must be an integer 0..100, got ${JSON.stringify(actor?.level ?? null)}`, required: null };
|
|
141
|
+
}
|
|
142
|
+
if (record.settled !== null) {
|
|
143
|
+
return { allowed: false, reason_code: INDEX_REFUSAL.ALREADY_SETTLED, detail: `already settled as "${record.settled}"`, required: null };
|
|
144
|
+
}
|
|
145
|
+
const peer = mayApprove(
|
|
146
|
+
{ id: actor.id ?? null, level: actor.level },
|
|
147
|
+
{ id: record.subjectId, level: record.subjectLevel },
|
|
148
|
+
);
|
|
149
|
+
if (peer.ok) return { allowed: true, reason_code: null, detail: null, required: peer.required };
|
|
150
|
+
// `mayApprove` already distinguishes self-approval from an insufficient level, and the distinction
|
|
151
|
+
// matters to a reader: one is outranking, the other is identity. Mapped rather than re-derived, so
|
|
152
|
+
// the two modules cannot drift into disagreeing about which happened.
|
|
153
|
+
const selfRefused = typeof peer.reason === 'string' && peer.reason.includes('may not approve its own');
|
|
154
|
+
return {
|
|
155
|
+
allowed: false,
|
|
156
|
+
reason_code: selfRefused ? INDEX_REFUSAL.SELF_APPROVAL : INDEX_REFUSAL.INSUFFICIENT_AUTHORITY,
|
|
157
|
+
detail: peer.reason,
|
|
158
|
+
required: peer.required,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Record that a request was settled, by whoever settled it.
|
|
164
|
+
*
|
|
165
|
+
* Separate from `decide` because not every settlement is a decision: the broker's timer delegates, the
|
|
166
|
+
* host cancels on abort, and a disposal settles everything. A queue that only learned about operator
|
|
167
|
+
* decisions would keep showing requests that are long gone.
|
|
168
|
+
*/
|
|
169
|
+
function settle(approvalId, outcome) {
|
|
170
|
+
const record = entries.get(approvalId);
|
|
171
|
+
if (record === undefined) return { ok: false, reason_code: INDEX_REFUSAL.UNKNOWN_REQUEST };
|
|
172
|
+
if (record.settled !== null) return { ok: false, reason_code: INDEX_REFUSAL.ALREADY_SETTLED, detail: `already settled as "${record.settled}"` };
|
|
173
|
+
record.settled = String(outcome);
|
|
174
|
+
record.settledAt = now();
|
|
175
|
+
audit('approval-index/settled', { approval_id: approvalId, outcome: record.settled });
|
|
176
|
+
return { ok: true };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Decide a request as this actor, if the actor may.
|
|
181
|
+
*
|
|
182
|
+
* Returns the verdict and marks the entry settled so the queue stops showing it. The caller is
|
|
183
|
+
* responsible for actually settling the broker's promise — this function deliberately cannot, because
|
|
184
|
+
* the broker owns the resolver and a second settler would race the timer.
|
|
185
|
+
*
|
|
186
|
+
* @param {string} approvalId
|
|
187
|
+
* @param {{id?: string|null, level: number}} actor
|
|
188
|
+
* @param {string} outcome
|
|
189
|
+
* @returns {{ok: boolean, reason_code: string|null, detail: string|null, approvalId: string}}
|
|
190
|
+
*/
|
|
191
|
+
function decide(approvalId, actor, outcome) {
|
|
192
|
+
const record = entries.get(approvalId);
|
|
193
|
+
if (record === undefined) {
|
|
194
|
+
return { ok: false, reason_code: INDEX_REFUSAL.UNKNOWN_REQUEST, detail: `no pending approval ${approvalId}`, approvalId };
|
|
195
|
+
}
|
|
196
|
+
const check = mayAct(record, actor);
|
|
197
|
+
if (!check.allowed) return { ok: false, reason_code: check.reason_code, detail: check.detail, approvalId };
|
|
198
|
+
|
|
199
|
+
record.settled = String(outcome);
|
|
200
|
+
record.settledAt = now();
|
|
201
|
+
record.decidedBy = actor.id ?? null;
|
|
202
|
+
audit('approval-index/decided', { approval_id: approvalId, by: actor.id ?? null, level: actor.level, outcome: record.settled });
|
|
203
|
+
return { ok: true, reason_code: null, detail: null, approvalId };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* What this actor may act on, oldest first.
|
|
208
|
+
*
|
|
209
|
+
* Oldest first because a queue's job is to surface what has been waiting longest — the request closest to
|
|
210
|
+
* its deadline is the one whose loss costs most, and a newest-first view would hide exactly that.
|
|
211
|
+
*
|
|
212
|
+
* Each row carries its own verdict rather than being filtered out, so a supervisor can see that work
|
|
213
|
+
* exists above their authority and is not theirs to answer. A filtered list would make the fleet look
|
|
214
|
+
* idle at the moment it is most blocked.
|
|
215
|
+
*/
|
|
216
|
+
function listFor(actor, { includeSettled = false, nowMs = null } = {}) {
|
|
217
|
+
reap();
|
|
218
|
+
const at = Number.isFinite(nowMs) ? nowMs : now();
|
|
219
|
+
const rows = [];
|
|
220
|
+
for (const record of entries.values()) {
|
|
221
|
+
if (!includeSettled && record.settled !== null) continue;
|
|
222
|
+
const check = record.settled !== null
|
|
223
|
+
? { allowed: false, reason_code: INDEX_REFUSAL.ALREADY_SETTLED, detail: `settled as "${record.settled}"`, required: null }
|
|
224
|
+
: mayAct(record, actor);
|
|
225
|
+
rows.push({
|
|
226
|
+
approval_id: record.approvalId,
|
|
227
|
+
session: record.sessionKey,
|
|
228
|
+
action: record.action,
|
|
229
|
+
tool: record.toolName,
|
|
230
|
+
reason: record.reason,
|
|
231
|
+
subject: { id: record.subjectId, level: record.subjectLevel },
|
|
232
|
+
opened_at: new Date(record.openedAt).toISOString(),
|
|
233
|
+
waiting_ms: Math.max(0, at - record.openedAt),
|
|
234
|
+
deadline_at: record.deadlineAt === null ? null : new Date(record.deadlineAt).toISOString(),
|
|
235
|
+
overdue: record.deadlineAt !== null && record.deadlineAt <= at && record.settled === null,
|
|
236
|
+
settled: record.settled,
|
|
237
|
+
may_act: check.allowed,
|
|
238
|
+
required_level: check.required,
|
|
239
|
+
why_not: check.allowed ? null : { reason_code: check.reason_code, detail: check.detail },
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
rows.sort((a, b) => Date.parse(a.opened_at) - Date.parse(b.opened_at));
|
|
243
|
+
return rows;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The shape of the queue, without the rows.
|
|
248
|
+
*
|
|
249
|
+
* The design asks for one queue across sessions; a supervisor's first question is usually "how much is
|
|
250
|
+
* waiting and where", and a count grouped by session answers it without reading every row.
|
|
251
|
+
*/
|
|
252
|
+
function summary(actor) {
|
|
253
|
+
const rows = listFor(actor);
|
|
254
|
+
const bySession = new Map();
|
|
255
|
+
let actionable = 0;
|
|
256
|
+
let overdue = 0;
|
|
257
|
+
for (const row of rows) {
|
|
258
|
+
const key = row.session ?? '(no session)';
|
|
259
|
+
bySession.set(key, (bySession.get(key) ?? 0) + 1);
|
|
260
|
+
if (row.may_act) actionable += 1;
|
|
261
|
+
if (row.overdue) overdue += 1;
|
|
262
|
+
}
|
|
263
|
+
return {
|
|
264
|
+
pending: rows.length,
|
|
265
|
+
actionable,
|
|
266
|
+
overdue,
|
|
267
|
+
by_session: Object.fromEntries([...bySession.entries()].sort(([a], [b]) => String(a).localeCompare(String(b)))),
|
|
268
|
+
viewer: { id: actor?.id ?? null, level: actor?.level ?? null },
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Every pending approval, unfiltered. For diagnostics and for a caller that has already authorised. */
|
|
273
|
+
function all({ includeSettled = false } = {}) {
|
|
274
|
+
reap();
|
|
275
|
+
return [...entries.values()].filter((r) => includeSettled || r.settled === null);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
register,
|
|
280
|
+
settle,
|
|
281
|
+
decide,
|
|
282
|
+
mayAct: (approvalId, actor) => {
|
|
283
|
+
const record = entries.get(approvalId);
|
|
284
|
+
if (record === undefined) return { allowed: false, reason_code: INDEX_REFUSAL.UNKNOWN_REQUEST, detail: `no pending approval ${approvalId}`, required: null };
|
|
285
|
+
return mayAct(record, actor);
|
|
286
|
+
},
|
|
287
|
+
listFor,
|
|
288
|
+
summary,
|
|
289
|
+
all,
|
|
290
|
+
get size() {
|
|
291
|
+
reap();
|
|
292
|
+
return [...entries.values()].filter((r) => r.settled === null).length;
|
|
293
|
+
},
|
|
294
|
+
/** Exposed for diagnostics only. */
|
|
295
|
+
_internals: { entries },
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Re-exported so a caller of this module does not have to reach into `authority.mjs` for one name. */
|
|
300
|
+
export { AUTHORITY_VERDICT, requiredApproverLevel };
|