@twinsearth/w2m-dsh-plugin 0.25.0 → 0.27.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 +93 -0
- package/README.md +7 -6
- package/package.json +1 -1
- package/src/plugin/tools.mjs +193 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,99 @@ 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.27.0] — 2026-10-15
|
|
8
|
+
|
|
9
|
+
**A blocked fleet reports itself. The approval count is in `w2m_status`, and it arrives as a note.**
|
|
10
|
+
|
|
11
|
+
A queue visible only to someone who remembers to look at it is a queue that stays full, and **a fleet blocked
|
|
12
|
+
on approval looks exactly like an idle fleet.** `w2m_status` now carries an `approvals` block and, when
|
|
13
|
+
anything is waiting, a note placed **first** — ordering being the only emphasis a list of notes has:
|
|
14
|
+
|
|
15
|
+
> *"3 dispatch(es) are WAITING FOR AN APPROVER and will not run until one answers; read them with
|
|
16
|
+
> `w2m_approvals` (1 past their deadline); the oldest has been waiting 240s"*
|
|
17
|
+
|
|
18
|
+
### `constructed: false` is not `pending: 0`
|
|
19
|
+
|
|
20
|
+
A machine that has never tripped the permission gate should not be described as having an empty queue **as
|
|
21
|
+
though that were a measurement.** The block says which it is, and the note is emitted only for real pending
|
|
22
|
+
work.
|
|
23
|
+
|
|
24
|
+
### The whole chain, across three tools, asserted end to end
|
|
25
|
+
|
|
26
|
+
An ordinary actor (49) attempts a write → refused with `W2M_PERMISSION_NEEDS_APPROVAL` **carrying the queued
|
|
27
|
+
id** → `w2m_status` reports `pending: 1` and the blocking note **without being asked about approvals** → a
|
|
28
|
+
captain settles it → the signal clears.
|
|
29
|
+
|
|
30
|
+
### The default approver is the requester, and the index says so
|
|
31
|
+
|
|
32
|
+
The test discovered this rather than assuming it: a request records its subject as the machine's name, and an
|
|
33
|
+
approver who **defaults to that same name is the subject** — so `may_act` is false with
|
|
34
|
+
`APPROVAL_SELF_APPROVAL`. That is correct, and it is a real consequence of the defaults rather than a bug. The
|
|
35
|
+
test now names a distinct approver and **asserts the refusal first**, so the self-approval rule is pinned
|
|
36
|
+
rather than sidestepped.
|
|
37
|
+
|
|
38
|
+
### A payload silently dropped, caught by a test that found nothing
|
|
39
|
+
|
|
40
|
+
`W2MError` accepts only `code`, `message`, `hint`, `detail`, `cause` and `reason_code`. The first version
|
|
41
|
+
passed `approval_id` and `request` as **top-level options**, so they were **discarded without complaint** and
|
|
42
|
+
the refusal told a caller to await approval **with no way to name what they were waiting for.** They now
|
|
43
|
+
travel in `detail`, and the constructor's accepted keys are named in a comment beside the call.
|
|
44
|
+
|
|
45
|
+
Two of my own errors in this round were the same shape as the ones before it: a block hoisted into the **wrong
|
|
46
|
+
scope** (module level instead of inside `execute`, which `node --check` accepted because the name existed
|
|
47
|
+
*somewhere*), and a syntactic fix that put a statement **inside an object literal**. Both were caught by
|
|
48
|
+
running the suite, not by reading.
|
|
49
|
+
|
|
50
|
+
**1 new cross-tool test, 0 failures.**
|
|
51
|
+
|
|
52
|
+
## [0.26.0] — 2026-10-15
|
|
53
|
+
|
|
54
|
+
**`w2m_approvals`: the approval chain is reachable by an operator, not only by the suite.**
|
|
55
|
+
|
|
56
|
+
The tenth tool. `action: "list"` reads the queue, `approve` and `reject` settle one request by `approval_id`.
|
|
57
|
+
Until now the chain from 0.25.0 was **closed in code and testable end to end while no operator could reach
|
|
58
|
+
it**; this is the tool that opens it.
|
|
59
|
+
|
|
60
|
+
### Every row says whether *you* may act, and why not when you may not
|
|
61
|
+
|
|
62
|
+
A list filtered to what the caller may answer would be shorter and would **make the fleet look idle at the
|
|
63
|
+
moment it is most blocked.** `listFor` already returns each row with `may_act` and `why_not`; the tool passes
|
|
64
|
+
them through instead of hiding work above the caller's authority.
|
|
65
|
+
|
|
66
|
+
### `actor_level` is a parameter, and that is deliberate
|
|
67
|
+
|
|
68
|
+
The machine's own `actorAuthorityLevel` says what it may **dispatch as**. An approver's level is a fact about
|
|
69
|
+
the person answering, and a decision made on the dispatcher's level would let a machine approve its own
|
|
70
|
+
escalations. Passing it explicitly is what keeps `APPROVAL_SELF_APPROVAL` meaningful rather than incidental.
|
|
71
|
+
|
|
72
|
+
**Settling without a level is refused** (`W2M_APPROVAL_LEVEL`) rather than defaulted, so a decision is never
|
|
73
|
+
attributed to a level nobody supplied. Listing **without** a level still works and says so: `level_known:
|
|
74
|
+
false`, with a note explaining that the queue is visible but whether any of it is answerable is not known —
|
|
75
|
+
**knowing what is waiting does not require the authority to answer it, and a queue only its supervisors can
|
|
76
|
+
see is a queue that stays full.**
|
|
77
|
+
|
|
78
|
+
### The note on every settle, because a reader will assume otherwise
|
|
79
|
+
|
|
80
|
+
> *"the request is settled; the dispatch did not run and must be attempted again"*
|
|
81
|
+
|
|
82
|
+
Approval means the next attempt is allowed, not that anything already happened. A tool that settled quietly
|
|
83
|
+
would leave its caller waiting for a result that is never coming.
|
|
84
|
+
|
|
85
|
+
### Three defects of mine, all caught by the suite or by checking
|
|
86
|
+
|
|
87
|
+
* I called a `json(...)` helper **that does not exist in this file** — every tool returns `JSON.stringify(...)`.
|
|
88
|
+
`node --check` passed, so only reading the surrounding code found it.
|
|
89
|
+
* I referenced `AUTHORITY_MAX` without importing it.
|
|
90
|
+
* I omitted `presentCall`, which `ALLOWED_DEF_KEYS` requires and which the registration-shape test pins. It now
|
|
91
|
+
labels a list as a `read` and a settle as a `write`, because **calling a queue inspection a write would
|
|
92
|
+
misrepresent the common case.**
|
|
93
|
+
|
|
94
|
+
Four tool-count assertions said "exactly nine" and named the nine; they now say ten and name the ten. The
|
|
95
|
+
comment beside one of them already recorded the lesson from a previous version — that a **count** was once the
|
|
96
|
+
wrong property and the **message** was the right one. This time the count *was* the property.
|
|
97
|
+
|
|
98
|
+
**7 new tests, 0 failures.**
|
|
99
|
+
|
|
7
100
|
## [0.25.0] — 2026-10-15
|
|
8
101
|
|
|
9
102
|
**The chain is closed: dispatch → gate → queue → decision. The plugin builds a queue, and an escalation is a real entry an approver can find.**
|
package/README.md
CHANGED
|
@@ -39,7 +39,7 @@ So the cross-machine capability has to be built. This is that build.
|
|
|
39
39
|
▼
|
|
40
40
|
┌──────────────────────────────────┐ ┌──────────────────────────┐
|
|
41
41
|
│ Windows · DSH session │ │ macOS · DSH session │
|
|
42
|
-
│ w2m plugin (
|
|
42
|
+
│ w2m plugin (10 tools) │ │ w2m plugin (10 tools) │
|
|
43
43
|
│ │ │ │ │ │
|
|
44
44
|
│ Localside agent │ │ Localside agent │
|
|
45
45
|
│ · three anchors │ │ · three anchors │
|
|
@@ -293,15 +293,16 @@ Then give it the relay URL in that profile's `cordis.patch.yml` — or leave
|
|
|
293
293
|
> got "@twinsearth/w2m-dsh-plugin/tools"), skipping
|
|
294
294
|
> ```
|
|
295
295
|
>
|
|
296
|
-
> That is a silent failure by construction — the install looks right,
|
|
296
|
+
> That is a silent failure by construction — the install looks right, ten tools appear, and
|
|
297
297
|
> `w2m_status` reports `paired: false` and a null `rabbitUrl` because none of your values were ever
|
|
298
298
|
> applied. It is not hypothetical: this row's expected name moved from the `/tools` subpath to the
|
|
299
299
|
> package root in v0.5.4 (see `cordis.patch.yml`'s header for why), and every override that still
|
|
300
300
|
> asserted the old subpath stopped being applied on upgrade. `id` already identifies the row, so the
|
|
301
301
|
> safest `name:` is no `name:` at all.
|
|
302
302
|
|
|
303
|
-
Restart DSH. You should see
|
|
304
|
-
`w2m_report`, `w2m_status`, `w2m_update`, `w2m_history`, `w2m_stats`, `w2m_memory
|
|
303
|
+
Restart DSH. You should see ten tools: `w2m_devices`, `w2m_run`, `w2m_wait`,
|
|
304
|
+
`w2m_report`, `w2m_status`, `w2m_update`, `w2m_history`, `w2m_stats`, `w2m_memory`,
|
|
305
|
+
`w2m_approvals`.
|
|
305
306
|
|
|
306
307
|
> `dsh` may not be on your `PATH`. On a packaged install it lives under
|
|
307
308
|
> `resources/runtime/cli/bin/`. If `dsh` is not found, call it by path.
|
|
@@ -584,7 +585,7 @@ a product.
|
|
|
584
585
|
|---|---|---|
|
|
585
586
|
| relay | 142 | pairing, auth, SSE with `ready`-first and `seq` replay, leases with heartbeat renewal and expiry, dedupe, all **nine** aggregation states plus `pending`/`refused`, report generation, operator token, rate limiting, persistence and restart recovery, TLS, and the lost-offer recovery path |
|
|
586
587
|
| agent | 129 | allow-list allow/deny, timeout, output truncation, exit codes, anchors on clean and dirty trees, four concurrent fingerprint computations, spool, a 40-case URL join matrix, cursor lifecycle across relay restarts, and the bundled-runtime resolver |
|
|
587
|
-
| tools | 89 |
|
|
588
|
+
| tools | 89 | ten tools registered, schemas, typed errors, polling, operator-token enforcement (no request is sent without it), sub-path endpoints, and the credential canaries |
|
|
588
589
|
| broadcast | 19 | exactly one executor, the rest `observing`; observers excluded from every aggregation step so a machine that did not run is never counted as agreeing |
|
|
589
590
|
| claims | 19 | the document claims themselves: the version, the bundle patch, the tool list, every documented tarball URL, and the CHANGELOG's shape |
|
|
590
591
|
| fleet-target | 37 | the composer's target selection, and the `executor_machine_id` narrowing that overrides it |
|
|
@@ -712,7 +713,7 @@ the plugin:
|
|
|
712
713
|
|
|
713
714
|
```bash
|
|
714
715
|
node scripts/check-entrypoints.mjs # imports lib/tools.js, the exact specifier cordis.patch.yml mounts
|
|
715
|
-
node scripts/verify-installed.mjs <path-to-installed-package> # asserts the installed copy registers
|
|
716
|
+
node scripts/verify-installed.mjs <path-to-installed-package> # asserts the installed copy registers 10 tools
|
|
716
717
|
```
|
|
717
718
|
|
|
718
719
|
**Not verified:**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/w2m-dsh-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.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",
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -129,7 +129,7 @@ import { gateDispatch, approvalRequestFor, raiseApproval } from '../agent/permis
|
|
|
129
129
|
// v0.24.0: the queue an escalation is raised into. Imported from `src/`, never from `scripts/` -- a
|
|
130
130
|
// `src/` module reaching above itself breaks the published package (see CHANGELOG 0.23.0).
|
|
131
131
|
import { createApprovalIndex } from '../agent/approval-index.mjs';
|
|
132
|
-
import { isAuthorityLevel } from '../agent/authority.mjs';
|
|
132
|
+
import { AUTHORITY_MAX, isAuthorityLevel } from '../agent/authority.mjs';
|
|
133
133
|
// v0.6.0: the device keypair and the offer's key distribution (`docs/P2P-CRYPTO.md` §4). Imported
|
|
134
134
|
// rather than reimplemented: the dispatcher and the executor have to derive the *same* keys, and two
|
|
135
135
|
// implementations of a handshake are two chances to disagree about it.
|
|
@@ -193,7 +193,7 @@ const MAX_STAGES = 16;
|
|
|
193
193
|
* `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
|
|
194
194
|
* overrides it at runtime for tests and packagers.
|
|
195
195
|
*/
|
|
196
|
-
const PLUGIN_VERSION = '0.
|
|
196
|
+
const PLUGIN_VERSION = '0.27.0';
|
|
197
197
|
|
|
198
198
|
/** How long any single HTTP call may take before it is aborted, in milliseconds. */
|
|
199
199
|
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|
|
@@ -1118,6 +1118,7 @@ function safeFilePart(value) {
|
|
|
1118
1118
|
*/
|
|
1119
1119
|
function canonicalJson(value) {
|
|
1120
1120
|
if (value === undefined) return 'null';
|
|
1121
|
+
|
|
1121
1122
|
if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null';
|
|
1122
1123
|
if (Array.isArray(value)) return `[${value.map((entry) => canonicalJson(entry)).join(',')}]`;
|
|
1123
1124
|
const keys = Object.keys(value).sort();
|
|
@@ -3087,9 +3088,26 @@ function readAggregate(body) {
|
|
|
3087
3088
|
* @param {object} p2p The block from {@link readP2PStatus}.
|
|
3088
3089
|
* @returns {string[]}
|
|
3089
3090
|
*/
|
|
3090
|
-
function statusNotes(relay, rtt, cfg, p2p) {
|
|
3091
|
+
function statusNotes(relay, rtt, cfg, p2p, approvalsNote = null) {
|
|
3091
3092
|
const notes = [];
|
|
3092
3093
|
|
|
3094
|
+
/**
|
|
3095
|
+
* v0.26.0: blocked work announces itself.
|
|
3096
|
+
*
|
|
3097
|
+
* The whole reason the count is in `w2m_status` at all. A note is what a caller reads without asking a
|
|
3098
|
+
* further question, and "three dispatches are waiting for an approver" is the single most actionable
|
|
3099
|
+
* sentence this tool can emit: the fleet is not idle, it is stopped, and it is stopped on a person.
|
|
3100
|
+
*
|
|
3101
|
+
* It is placed first because ordering is the only emphasis a list of notes has.
|
|
3102
|
+
*/
|
|
3103
|
+
if (approvalsNote !== null && Number.isFinite(approvalsNote.pending) && approvalsNote.pending > 0) {
|
|
3104
|
+
notes.push(
|
|
3105
|
+
`${approvalsNote.pending} dispatch(es) are WAITING FOR AN APPROVER and will not run until one answers; ` +
|
|
3106
|
+
`read them with \`w2m_approvals\`${approvalsNote.overdue > 0 ? ` (${approvalsNote.overdue} past their deadline)` : ''}` +
|
|
3107
|
+
`${Number.isFinite(approvalsNote.oldest_waiting_ms) ? `; the oldest has been waiting ${Math.round(approvalsNote.oldest_waiting_ms / 1000)}s` : ''}`,
|
|
3108
|
+
);
|
|
3109
|
+
}
|
|
3110
|
+
|
|
3093
3111
|
/**
|
|
3094
3112
|
* §7's one-line note, and the reason it exists at all: the shared server is a *default*, and a
|
|
3095
3113
|
* default that an operator cannot see is a machine quietly talking to somebody else's host. The
|
|
@@ -3777,8 +3795,12 @@ export async function apply(ctx, config = {}) {
|
|
|
3777
3795
|
'W2M_PERMISSION_NEEDS_APPROVAL: ' + gate.decision.detail,
|
|
3778
3796
|
{
|
|
3779
3797
|
hint: 'this dispatch is queued awaiting an approver; approve or reject it with w2m_approvals, then dispatch again',
|
|
3780
|
-
|
|
3781
|
-
|
|
3798
|
+
// Carried in `detail`, not as top-level options: `W2MError` accepts only code, message, hint,
|
|
3799
|
+
// detail, cause and reason_code, so anything else passed here is DROPPED SILENTLY. The first
|
|
3800
|
+
// version of this passed `approval_id` at the top level and the test caught it by finding
|
|
3801
|
+
// nothing -- which is exactly the kind of omission that would otherwise have reached a user as
|
|
3802
|
+
// "await approval" with no way to name what they are waiting for.
|
|
3803
|
+
detail: { approval_id: raised.approval_id, request },
|
|
3782
3804
|
},
|
|
3783
3805
|
);
|
|
3784
3806
|
}
|
|
@@ -4327,6 +4349,45 @@ export async function apply(ctx, config = {}) {
|
|
|
4327
4349
|
await p2p.ensureStarted();
|
|
4328
4350
|
const p2pStatus = await readP2PStatus(cfg, p2p, Number.isInteger(relay.peers_announced) ? relay.peers_announced : null);
|
|
4329
4351
|
|
|
4352
|
+
/**
|
|
4353
|
+
* v0.26.0: how much work is waiting for an approver.
|
|
4354
|
+
*
|
|
4355
|
+
* ## Why it belongs in `w2m_status` rather than only in `w2m_approvals`
|
|
4356
|
+
*
|
|
4357
|
+
* A queue that is only visible to someone who remembers to look at it is a queue that stays full,
|
|
4358
|
+
* and a fleet that is blocked on approval looks exactly like a fleet that is idle. This makes the
|
|
4359
|
+
* blocked state **report itself**, which is the difference between a tool an operator has and a
|
|
4360
|
+
* signal an operator receives.
|
|
4361
|
+
*
|
|
4362
|
+
* `constructed` distinguishes "nothing is waiting" from "nothing has ever asked". They are the same
|
|
4363
|
+
* number and very different facts, and a machine that never tripped the permission gate should not
|
|
4364
|
+
* be described as having an empty queue as though that were a measurement.
|
|
4365
|
+
*/
|
|
4366
|
+
const approvalsBlock = (() => {
|
|
4367
|
+
if (approvals === null) {
|
|
4368
|
+
return {
|
|
4369
|
+
constructed: false,
|
|
4370
|
+
pending: null,
|
|
4371
|
+
detail: 'no approval has ever been requested on this machine, so no queue exists yet; this is not the same as an empty queue',
|
|
4372
|
+
};
|
|
4373
|
+
}
|
|
4374
|
+
const pendingRows = approvals.all();
|
|
4375
|
+
const overdue = pendingRows.filter((r) => r.overdue === true).length;
|
|
4376
|
+
return {
|
|
4377
|
+
constructed: true,
|
|
4378
|
+
pending: pendingRows.length,
|
|
4379
|
+
overdue,
|
|
4380
|
+
// The oldest request is the one whose loss costs most, so it is reported rather than left for a
|
|
4381
|
+
// reader to sort out of the rows.
|
|
4382
|
+
oldest_waiting_ms: pendingRows.length === 0
|
|
4383
|
+
? null
|
|
4384
|
+
: Math.max(...pendingRows.map((r) => r.waiting_ms ?? 0)),
|
|
4385
|
+
detail: pendingRows.length === 0
|
|
4386
|
+
? 'the queue exists and nothing is waiting'
|
|
4387
|
+
: `${pendingRows.length} dispatch(es) waiting for an approver${overdue > 0 ? `, ${overdue} overdue` : ''}`,
|
|
4388
|
+
};
|
|
4389
|
+
})();
|
|
4390
|
+
|
|
4330
4391
|
return JSON.stringify(
|
|
4331
4392
|
{
|
|
4332
4393
|
ok: true,
|
|
@@ -4416,7 +4477,8 @@ export async function apply(ctx, config = {}) {
|
|
|
4416
4477
|
: targetRoutes.reason,
|
|
4417
4478
|
sessions_with_a_target: defaultSelectionStore.list().length,
|
|
4418
4479
|
},
|
|
4419
|
-
|
|
4480
|
+
approvals: approvalsBlock,
|
|
4481
|
+
notes: statusNotes(relay, rtt, cfg, p2pStatus, approvalsBlock),
|
|
4420
4482
|
},
|
|
4421
4483
|
null,
|
|
4422
4484
|
2,
|
|
@@ -5088,6 +5150,131 @@ export async function apply(ctx, config = {}) {
|
|
|
5088
5150
|
rawInput: {},
|
|
5089
5151
|
}),
|
|
5090
5152
|
}));
|
|
5153
|
+
|
|
5154
|
+
/**
|
|
5155
|
+
* v0.25.0: the tool that makes the approval chain reachable by an operator.
|
|
5156
|
+
*
|
|
5157
|
+
* ## Why it reports refusals instead of hiding them
|
|
5158
|
+
*
|
|
5159
|
+
* Every row carries `may_act` and, when false, `why_not`. A list filtered down to what the caller may
|
|
5160
|
+
* answer would be shorter and **would make the fleet look idle at the moment it is most blocked** — the
|
|
5161
|
+
* work above their authority is exactly the work they most need to know exists.
|
|
5162
|
+
*
|
|
5163
|
+
* ## Why `actor_level` is a parameter and not read from config
|
|
5164
|
+
*
|
|
5165
|
+
* This machine's own `actorAuthorityLevel` says what it may *dispatch as*. An approver's level is a fact
|
|
5166
|
+
* about the person answering, and a decision made on the dispatcher's level would let a machine approve its
|
|
5167
|
+
* own escalations — which the index already refuses by name (`APPROVAL_SELF_APPROVAL`) when the ids match.
|
|
5168
|
+
* Making it explicit keeps that refusal meaningful rather than incidental.
|
|
5169
|
+
*/
|
|
5170
|
+
ctx.tools.register(defineTool({
|
|
5171
|
+
name: 'w2m_approvals',
|
|
5172
|
+
description:
|
|
5173
|
+
'List and settle the dispatches that are waiting for an approver. A dispatch whose actor level is ' +
|
|
5174
|
+
'exactly one band below what it needs is QUEUED rather than refused, and this is where that queue is ' +
|
|
5175
|
+
'read and answered. Every row reports whether YOU may act on it and why not if you may not, so work ' +
|
|
5176
|
+
'above your authority is visible rather than filtered away. Settling a request does not run the ' +
|
|
5177
|
+
'dispatch: approval means the next attempt is allowed, not that anything already happened.',
|
|
5178
|
+
parameters: {
|
|
5179
|
+
action: {
|
|
5180
|
+
type: 'string',
|
|
5181
|
+
enum: ['list', 'approve', 'reject'],
|
|
5182
|
+
description: 'list (default) shows the queue; approve and reject settle one request by approval_id.',
|
|
5183
|
+
default: 'list',
|
|
5184
|
+
},
|
|
5185
|
+
approval_id: {
|
|
5186
|
+
type: 'string',
|
|
5187
|
+
description: 'Which request to settle. Required for approve and reject.',
|
|
5188
|
+
},
|
|
5189
|
+
actor_id: {
|
|
5190
|
+
type: 'string',
|
|
5191
|
+
description: 'Who is answering. Defaults to this machine\'s name; it is recorded on the decision, and it is what makes a self-approval refusable.',
|
|
5192
|
+
},
|
|
5193
|
+
actor_level: {
|
|
5194
|
+
type: 'number',
|
|
5195
|
+
description: 'The authority level of the person answering, 0..100. Required to approve or reject: an approver with no level cannot be placed relative to the request, and the index refuses rather than guessing one.',
|
|
5196
|
+
},
|
|
5197
|
+
include_settled: {
|
|
5198
|
+
type: 'boolean',
|
|
5199
|
+
description: 'Also show recently settled requests. They are kept briefly as receipts, so a supervisor returning to a busy queue can tell "already dealt with" from "never seen".',
|
|
5200
|
+
default: false,
|
|
5201
|
+
},
|
|
5202
|
+
},
|
|
5203
|
+
output: jsonOutput,
|
|
5204
|
+
presentCall: (args) => ({
|
|
5205
|
+
card: 'generic',
|
|
5206
|
+
title: `w2m approvals ${typeof args?.action === 'string' ? args.action : 'list'}`,
|
|
5207
|
+
// Only `approve` and `reject` change anything; a list is a read. Labelling the whole tool 'write' would
|
|
5208
|
+
// make the common case -- looking at the queue -- read as an action.
|
|
5209
|
+
kind: args?.action === 'approve' || args?.action === 'reject' ? 'write' : 'read',
|
|
5210
|
+
rawInput: {},
|
|
5211
|
+
}),
|
|
5212
|
+
async execute(args, exec) {
|
|
5213
|
+
throwIfAborted(exec?.signal);
|
|
5214
|
+
const action = typeof args?.action === 'string' ? args.action : 'list';
|
|
5215
|
+
const queue = approvalQueue();
|
|
5216
|
+
|
|
5217
|
+
if (action === 'approve' || action === 'reject') {
|
|
5218
|
+
const approvalId = typeof args?.approval_id === 'string' ? args.approval_id.trim() : '';
|
|
5219
|
+
if (approvalId === '') {
|
|
5220
|
+
throw new W2MError('W2M_APPROVAL_ID', 'W2M_APPROVAL_ID: settling a request needs `approval_id`', {
|
|
5221
|
+
hint: 'call w2m_approvals with action="list" to see the pending ids',
|
|
5222
|
+
});
|
|
5223
|
+
}
|
|
5224
|
+
if (!isAuthorityLevel(args?.actor_level)) {
|
|
5225
|
+
// Refused rather than defaulted, and refused HERE rather than left to the index, so the message can
|
|
5226
|
+
// say what to do about it. A decision attributed to a level nobody supplied is a decision nobody
|
|
5227
|
+
// can audit.
|
|
5228
|
+
throw new W2MError(
|
|
5229
|
+
'W2M_APPROVAL_LEVEL',
|
|
5230
|
+
'W2M_APPROVAL_LEVEL: `actor_level` must be an integer 0..' + AUTHORITY_MAX + ' to settle a request',
|
|
5231
|
+
{ hint: 'an approver with no level cannot be placed relative to the request it is answering' },
|
|
5232
|
+
);
|
|
5233
|
+
}
|
|
5234
|
+
const actor = { id: typeof args?.actor_id === 'string' && args.actor_id.trim() !== '' ? args.actor_id.trim() : cfg.machineName, level: args.actor_level };
|
|
5235
|
+
const outcome = action === 'approve' ? 'approved' : 'rejected';
|
|
5236
|
+
const result = queue.decide(approvalId, actor, outcome);
|
|
5237
|
+
return JSON.stringify({
|
|
5238
|
+
ok: result.ok === true,
|
|
5239
|
+
action,
|
|
5240
|
+
approval_id: approvalId,
|
|
5241
|
+
settled: result.ok === true ? outcome : null,
|
|
5242
|
+
reason_code: result.reason_code ?? null,
|
|
5243
|
+
detail: result.detail ?? null,
|
|
5244
|
+
acted_by: actor,
|
|
5245
|
+
// Said every time, because a reader who assumes approval ran the dispatch will wait for a result
|
|
5246
|
+
// that is never coming.
|
|
5247
|
+
note: result.ok === true
|
|
5248
|
+
? 'the request is settled; the dispatch did not run and must be attempted again'
|
|
5249
|
+
: 'the decision was refused; the request is unchanged and still waiting',
|
|
5250
|
+
});
|
|
5251
|
+
}
|
|
5252
|
+
|
|
5253
|
+
const actor = {
|
|
5254
|
+
id: typeof args?.actor_id === 'string' && args.actor_id.trim() !== '' ? args.actor_id.trim() : cfg.machineName,
|
|
5255
|
+
level: isAuthorityLevel(args?.actor_level) ? args.actor_level : null,
|
|
5256
|
+
};
|
|
5257
|
+
// An actor with no level still gets the list: knowing what is waiting does not require the authority to
|
|
5258
|
+
// answer it, and a queue only its supervisors can see is a queue that stays full.
|
|
5259
|
+
const rows = queue.listFor(
|
|
5260
|
+
actor.level === null ? { id: actor.id, level: 0 } : actor,
|
|
5261
|
+
{ includeSettled: args?.include_settled === true },
|
|
5262
|
+
);
|
|
5263
|
+
const summary = queue.summary(actor.level === null ? { id: actor.id, level: 0 } : actor);
|
|
5264
|
+
return JSON.stringify({
|
|
5265
|
+
ok: true,
|
|
5266
|
+
action: 'list',
|
|
5267
|
+
as: actor,
|
|
5268
|
+
level_known: actor.level !== null,
|
|
5269
|
+
summary,
|
|
5270
|
+
count: rows.length,
|
|
5271
|
+
rows,
|
|
5272
|
+
note: actor.level === null
|
|
5273
|
+
? 'no actor_level was supplied, so may_act is reported as if the level were 0: the queue is visible, but whether you may answer any of it is not known'
|
|
5274
|
+
: 'may_act reflects the actor_level supplied',
|
|
5275
|
+
});
|
|
5276
|
+
},
|
|
5277
|
+
}));
|
|
5091
5278
|
}
|
|
5092
5279
|
|
|
5093
5280
|
/**
|