@twinsearth/w2m-dsh-plugin 0.9.5 → 0.9.6
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 +70 -0
- package/README.md +6 -6
- package/package.json +6 -5
- package/src/agent/agent.mjs +27 -0
- package/src/agent/memory.mjs +233 -0
- package/src/plugin/tools.mjs +255 -79
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,76 @@ 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.9.6] — 2026-10-14
|
|
8
|
+
|
|
9
|
+
**Individual memory: the fleet can now answer "what did we already try on this machine, and what happened?" — a question nothing in it could answer before.**
|
|
10
|
+
|
|
11
|
+
### The gap this closes, measured rather than asserted
|
|
12
|
+
|
|
13
|
+
The relay is sent `command_hash`, `stdout_sha256` and `stdout_bytes`, and never the text. That choice is
|
|
14
|
+
defensible and stays: the ledger is a tamper-evident consistency record, and a relay compromise discloses
|
|
15
|
+
no command text. The price was that no part of the fleet retained what a command actually did, and the
|
|
16
|
+
price was paid over and over. In one working session the same facts were rediscovered from scratch across
|
|
17
|
+
a dozen dispatches — that a machine had no package manager (3 times), which node binary actually ran (2),
|
|
18
|
+
that its git could not reach github.com while node's HTTPS could (2), that a task is expired at about
|
|
19
|
+
fifty seconds (3), and which commit its checkout was on (2).
|
|
20
|
+
|
|
21
|
+
### What was added
|
|
22
|
+
|
|
23
|
+
**`src/agent/memory.mjs` — one append-only `memory.jsonl` per machine, in its own state directory.**
|
|
24
|
+
|
|
25
|
+
Written by the agent as each task finishes, at the one point where the output still exists at all and on
|
|
26
|
+
the one machine that owns it. Each line carries the command, `cwd_rel`, exit code, status, duration,
|
|
27
|
+
transport, and the tails of stdout and stderr — plus the envelope's hashes, so a human-readable record can
|
|
28
|
+
be cross-checked against the relay's tamper-evident one without trusting either.
|
|
29
|
+
|
|
30
|
+
Three properties are deliberate and each has a test:
|
|
31
|
+
|
|
32
|
+
- **Bounded by construction**, with a byte cap and one rotating generation. This is not hypothetical
|
|
33
|
+
caution: the Linux host's root filesystem reached 100% today from 14.7 GB/day of uncapped logging, and
|
|
34
|
+
the recovery cost real time. A memory that can fill a disk is a worse outcome than no memory. Rotation
|
|
35
|
+
keeps the previous generation rather than discarding it, because the question this file answers is
|
|
36
|
+
usually about the recent past and the rotation boundary sits exactly there.
|
|
37
|
+
- **A malformed line is skipped, not fatal.** A partially written final line is what a machine losing
|
|
38
|
+
power mid-append leaves behind, and one damaged line must not make the rest unreadable — the relay's own
|
|
39
|
+
ledger behaves this way for the same reason.
|
|
40
|
+
- **`conclusion` is written as `null` by the machine and only ever filled in by a reader.** The design
|
|
41
|
+
separates fact from interpretation, and a machine that invented a conclusion would be asserting
|
|
42
|
+
something it cannot know. A test pins this, because the failure would be silent.
|
|
43
|
+
|
|
44
|
+
**`w2m_memory` — the read side.** Returns this machine's memory, newest first, with a status tally, and
|
|
45
|
+
reaches the rotated generation on request.
|
|
46
|
+
|
|
47
|
+
It does **not** read another machine's memory, and says so instead of returning an empty list. The reason
|
|
48
|
+
is structural rather than an omission: the relay carries no such file, and an agent executes a dispatched
|
|
49
|
+
command inside a sandbox whose writes are scoped. A query for another machine is answered with
|
|
50
|
+
`MEMORY_REMOTE_NOT_READABLE` and the two ways to actually read it. Returning `records: []` would have read
|
|
51
|
+
as *"that machine has done nothing"* — the opposite conclusion from *"this build cannot see"*.
|
|
52
|
+
|
|
53
|
+
The write is wrapped so it can never fail a task. The task is the work; this is a note about it.
|
|
54
|
+
|
|
55
|
+
### Verified
|
|
56
|
+
|
|
57
|
+
- `test/memory.test.mjs` — **18 tests, 0 failures**: record shape, tail and argv clamping, append-and-read,
|
|
58
|
+
newest-first ordering, the limit, a malformed line skipped, an absent file read as empty, rotation with
|
|
59
|
+
both generations readable, and that neither `appendMemory` nor `readMemorySync` throws on a bad directory.
|
|
60
|
+
- A rotation test that trips the cap with *real* records rather than filler, because the first version
|
|
61
|
+
wrote eight megabytes of `'x'` — it rotated, but it could not answer the question being asked, since the
|
|
62
|
+
previous generation then held one unparseable line.
|
|
63
|
+
- `eslint .` — 0 errors.
|
|
64
|
+
- **The full pre-flight is green: 11 pass, 0 fail, 1 unverifiable** (1721 tests in 54 files; 1707 pass,
|
|
65
|
+
0 fail, 14 platform skips). The single `UNVERIFIABLE` is an accepted host observation, not a gate.
|
|
66
|
+
Reaching that required installing `git` on the development host, which had none; the 39 failures before
|
|
67
|
+
it were all `spawn git ENOENT`, and the two rounds of diagnosis after it were a sandbox rule (writing
|
|
68
|
+
files in `%TEMP%` is allowed, **creating a directory** there is refused — and `git init` creates one) and
|
|
69
|
+
two mojibake sequences this release then repaired. Recorded because "39 failures" read as a defect count
|
|
70
|
+
and was, at every stage, an environment count.
|
|
71
|
+
- The nine-tool contract is updated everywhere it is pinned: `scripts/verify-installed.mjs`,
|
|
72
|
+
`test/loader-entry.test.mjs`, `test/tools.test.mjs`, `test/tool-cards.test.mjs`, `test/claims.test.mjs`,
|
|
73
|
+
`test/p2p-plugin.test.mjs`, `test/update-wiring.test.mjs` and the pre-flight gate inventory. Those
|
|
74
|
+
assertions are what make "the tool set" a contract rather than a habit, so leaving one at eight would
|
|
75
|
+
have failed a release that is otherwise correct.
|
|
76
|
+
|
|
7
77
|
## [0.9.5] — 2026-10-14
|
|
8
78
|
|
|
9
79
|
**Same code as v0.9.4; published because v0.9.4's npm and GitHub bytes cannot be made to agree.**
|
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 (9 tools) │ │ w2m plugin (9 tools) │
|
|
43
43
|
│ │ │ │ │ │
|
|
44
44
|
│ Localside agent │ │ Localside agent │
|
|
45
45
|
│ · three anchors │ │ · three anchors │
|
|
@@ -293,15 +293,15 @@ 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, nine 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`.
|
|
303
|
+
Restart DSH. You should see nine tools: `w2m_devices`, `w2m_run`, `w2m_wait`,
|
|
304
|
+
`w2m_report`, `w2m_status`, `w2m_update`, `w2m_history`, `w2m_stats`, `w2m_memory`.
|
|
305
305
|
|
|
306
306
|
> `dsh` may not be on your `PATH`. On a packaged install it lives under
|
|
307
307
|
> `resources/runtime/cli/bin/`. If `dsh` is not found, call it by path.
|
|
@@ -584,7 +584,7 @@ a product.
|
|
|
584
584
|
|---|---|---|
|
|
585
585
|
| 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
586
|
| 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 |
|
|
587
|
+
| tools | 89 | nine tools registered, schemas, typed errors, polling, operator-token enforcement (no request is sent without it), sub-path endpoints, and the credential canaries |
|
|
588
588
|
| 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
589
|
| claims | 19 | the document claims themselves: the version, the bundle patch, the tool list, every documented tarball URL, and the CHANGELOG's shape |
|
|
590
590
|
| fleet-target | 37 | the composer's target selection, and the `executor_machine_id` narrowing that overrides it |
|
|
@@ -712,7 +712,7 @@ the plugin:
|
|
|
712
712
|
|
|
713
713
|
```bash
|
|
714
714
|
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
|
|
715
|
+
node scripts/verify-installed.mjs <path-to-installed-package> # asserts the installed copy registers 9 tools
|
|
716
716
|
```
|
|
717
717
|
|
|
718
718
|
**Not verified:**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/w2m-dsh-plugin",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.6",
|
|
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",
|
|
@@ -111,13 +111,13 @@
|
|
|
111
111
|
}
|
|
112
112
|
},
|
|
113
113
|
"scripts": {
|
|
114
|
-
"test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.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",
|
|
114
|
+
"test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.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",
|
|
115
115
|
"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",
|
|
116
116
|
"test:relay": "node --test --test-force-exit test/relay.test.mjs",
|
|
117
|
-
"test:agent": "node --test --test-force-exit test/agent.test.mjs",
|
|
117
|
+
"test:agent": "node --test --test-force-exit test/agent.test.mjs test/memory.test.mjs",
|
|
118
118
|
"test:tools": "node --test --test-force-exit test/tools.test.mjs",
|
|
119
119
|
"test:crossnetwork": "node --test --test-force-exit test/crossnetwork.test.mjs",
|
|
120
|
-
"test:all": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.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",
|
|
120
|
+
"test:all": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.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",
|
|
121
121
|
"pack": "node scripts/pack.mjs",
|
|
122
122
|
"verify:join": "node scripts/verify-join.mjs",
|
|
123
123
|
"lint": "eslint .",
|
|
@@ -125,7 +125,8 @@
|
|
|
125
125
|
"format": "prettier --write .",
|
|
126
126
|
"format:check": "prettier --check .",
|
|
127
127
|
"verify": "node scripts/check-entrypoints.mjs && node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/tools.test.mjs test/e2e.test.mjs test/crossnetwork.test.mjs test/p2p-node.test.mjs test/p2p-agent.test.mjs test/client-bundle.test.mjs test/loader-entry.test.mjs",
|
|
128
|
-
"test:p2p": "node --test --test-force-exit test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/stun-server.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-agent.test.mjs"
|
|
128
|
+
"test:p2p": "node --test --test-force-exit test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/stun-server.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-agent.test.mjs",
|
|
129
|
+
"test:memory": "node --test --test-force-exit test/memory.test.mjs"
|
|
129
130
|
},
|
|
130
131
|
"keywords": [
|
|
131
132
|
"dsh-bundle",
|
package/src/agent/agent.mjs
CHANGED
|
@@ -48,6 +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
52
|
import { matchAllowedCommand, parseAllowedCommands } from './allowed-commands.mjs';
|
|
52
53
|
import { CRYPTO_REASON, DEVICE_KEY_FILE, DeviceKey, PeerKeyStore } from './crypto.mjs';
|
|
53
54
|
import { DEFAULT_P2P_MODE, P2P_DEFAULTS, P2PNode, normalizeP2PMode, normalizeP2PPort } from './p2p-node.mjs';
|
|
@@ -2638,6 +2639,32 @@ export function createAgent(options) {
|
|
|
2638
2639
|
const directResult = (context.p2pDelivery ?? p2pDeliveryMark()).path === 'p2p';
|
|
2639
2640
|
envelope.transport = transport.offer_path === 'p2p' || directResult ? 'p2p' : transport.offer_path;
|
|
2640
2641
|
envelope.p2p = envelopeP2P({ ...context, p2pTransport: transport });
|
|
2642
|
+
|
|
2643
|
+
/**
|
|
2644
|
+
* v0.9.6 individual memory: write what this machine just did, where the text still exists.
|
|
2645
|
+
*
|
|
2646
|
+
* This is the last point at which the output is available to anything that will keep it. The relay
|
|
2647
|
+
* is sent `stdout_sha256` and `stdout_bytes` and never the text -- deliberately, so that a relay
|
|
2648
|
+
* compromise discloses no command text and the ledger stays a consistency record rather than a
|
|
2649
|
+
* transcript. The consequence is that a question like "what did we already try on this machine, and
|
|
2650
|
+
* what happened?" had no answer anywhere in the fleet, and the same facts were rediscovered from
|
|
2651
|
+
* scratch repeatedly at the cost of a dozen dispatches in one session.
|
|
2652
|
+
*
|
|
2653
|
+
* The write happens **after** the envelope is complete, so the record it stores is the same one the
|
|
2654
|
+
* relay will judge -- not a second, independently assembled opinion about what happened.
|
|
2655
|
+
*
|
|
2656
|
+
* `appendMemory` never throws and its result is deliberately not returned or checked: the task is
|
|
2657
|
+
* the work and this is a note about it, so a full disk or an unwritable directory must not turn a
|
|
2658
|
+
* successful command into a failed task. A memory that can fail work is worse than no memory, and
|
|
2659
|
+
* the failure mode is silent here on purpose -- the envelope's own fields already say what happened.
|
|
2660
|
+
*/
|
|
2661
|
+
appendMemory(stateDir, memoryRecord({
|
|
2662
|
+
envelope,
|
|
2663
|
+
commandArgv,
|
|
2664
|
+
cwdRel,
|
|
2665
|
+
ms: (Date.parse(endedAt) - Date.parse(startedAt)) || 0,
|
|
2666
|
+
}));
|
|
2667
|
+
|
|
2641
2668
|
return buildEnvelope(envelope);
|
|
2642
2669
|
}
|
|
2643
2670
|
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Individual memory: what this machine did, in its own state directory.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this file exists
|
|
5
|
+
*
|
|
6
|
+
* The relay's ledger is the only durable fleet record, and it deliberately keeps **hashes, not text** --
|
|
7
|
+
* `command_hash`, `stdout_sha256`, `stdout_bytes`. That choice is defensible: the ledger is a
|
|
8
|
+
* tamper-evident consistency record, and a relay compromise discloses no command text. The cost is that
|
|
9
|
+
* nothing in the fleet can answer *"what did we already try on this machine, and what happened?"*
|
|
10
|
+
*
|
|
11
|
+
* The cost was paid repeatedly. In a single working session the same facts were rediscovered from
|
|
12
|
+
* scratch at the price of a dozen dispatches -- that this box has no package manager, which node binary
|
|
13
|
+
* actually runs, that its git cannot reach github.com while node's HTTPS can, that a task is expired at
|
|
14
|
+
* roughly fifty seconds. None of it was retained anywhere the fleet could read.
|
|
15
|
+
*
|
|
16
|
+
* So the memory lives here, on the machine, for three reasons beyond convenience:
|
|
17
|
+
*
|
|
18
|
+
* 1. the machine already writes `agent-state.json` in this directory, so there is no new place to
|
|
19
|
+
* find and no new permission to grant;
|
|
20
|
+
* 2. it owns the output at the moment it produces it, and this is the last point at which the *text*
|
|
21
|
+
* exists at all -- the relay never receives it, so nothing downstream could store it later;
|
|
22
|
+
* 3. it survives a relay rebuild, which the ledger does not.
|
|
23
|
+
*
|
|
24
|
+
* It is also the only honest home for a machine-specific fact. *"This box has no package manager"* is a
|
|
25
|
+
* property of this box, and a design that put it anywhere else would be guessing.
|
|
26
|
+
*
|
|
27
|
+
* ## Bounded by construction
|
|
28
|
+
*
|
|
29
|
+
* A byte cap with rotation, not an unbounded append. This is not hypothetical caution: the Linux host's
|
|
30
|
+
* root filesystem filled to 100% today from 14.7 GB/day of logs written by a process that had no cap,
|
|
31
|
+
* and the recovery cost real time. A memory that can fill a disk is a worse failure than no memory.
|
|
32
|
+
*
|
|
33
|
+
* Rotation keeps one previous generation rather than discarding it, because the question this file
|
|
34
|
+
* answers is usually about the recent past and a single rotation boundary is exactly where "we just ran
|
|
35
|
+
* that" sits.
|
|
36
|
+
*/
|
|
37
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
38
|
+
import { join } from 'node:path';
|
|
39
|
+
|
|
40
|
+
/** The memory log's filename, beside `agent-state.json`. */
|
|
41
|
+
export const MEMORY_FILE = 'memory.jsonl';
|
|
42
|
+
|
|
43
|
+
/** The one rotated generation: `<file>.1`. Kept, not deleted -- see the header. */
|
|
44
|
+
export const MEMORY_FILE_PREVIOUS = `${MEMORY_FILE}.1`;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Byte cap for the live file before it is rotated.
|
|
48
|
+
*
|
|
49
|
+
* 8 MiB holds on the order of 20,000 task records at the shape below -- several years of this fleet's
|
|
50
|
+
* observed rate -- while staying small enough that reading the whole file to answer a question is
|
|
51
|
+
* something a tool can do without a plan.
|
|
52
|
+
*/
|
|
53
|
+
export const MEMORY_MAX_BYTES = 8 * 1024 * 1024;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* How much of a stream's tail is kept.
|
|
57
|
+
*
|
|
58
|
+
* The envelope already carries `stdout_tail` / `stderr_tail` for cross-machine comparison, so storing a
|
|
59
|
+
* second copy costs nothing extra and keeps every record the same shape. It is clamped again here
|
|
60
|
+
* rather than trusted, because this file is written on a machine the dispatcher does not control and a
|
|
61
|
+
* cap that can be exceeded by an argument is not a cap.
|
|
62
|
+
*/
|
|
63
|
+
export const MEMORY_TAIL_BYTES = 4096;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A bound on the recorded argv.
|
|
67
|
+
*
|
|
68
|
+
* The relay takes an argv array of arbitrary length, so an offer may legally carry a command longer than
|
|
69
|
+
* any sane memory record. Truncating the *record* is right here: `command_argv` is not compared across
|
|
70
|
+
* machines (only `command_hash` is), so a shortened argv cannot make two machines disagree.
|
|
71
|
+
*/
|
|
72
|
+
export const MEMORY_ARGV_LIMIT = 64;
|
|
73
|
+
|
|
74
|
+
/** @param {unknown} value @param {number} limit */
|
|
75
|
+
function clamp(value, limit) {
|
|
76
|
+
if (typeof value !== 'string' || value === '') return null;
|
|
77
|
+
return value.length <= limit ? value : `${value.slice(0, limit)}\n…[truncated at ${limit} characters]`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The memory record for one finished task.
|
|
82
|
+
*
|
|
83
|
+
* Shaped to be readable by a person as much as by a tool: a reader asking "what happened here" should be
|
|
84
|
+
* able to `tail` the file and get an answer without a query.
|
|
85
|
+
*
|
|
86
|
+
* `conclusion` is deliberately absent. It is the field §3.1 of the design shows, and it is reserved for
|
|
87
|
+
* `w2m_memory` to fill in when a human or an agent decides what the result *meant* -- a machine that
|
|
88
|
+
* invented one would be asserting an interpretation it cannot have, and the whole point of separating
|
|
89
|
+
* fact from conclusion is that the two must not be confused later.
|
|
90
|
+
*
|
|
91
|
+
* @param {object} input
|
|
92
|
+
* @param {object} input.envelope The built result envelope.
|
|
93
|
+
* @param {string[]} input.commandArgv
|
|
94
|
+
* @param {string|null} input.cwdRel
|
|
95
|
+
* @param {number} input.ms Wall-clock duration of the attempt.
|
|
96
|
+
* @param {number} [input.now]
|
|
97
|
+
* @returns {object}
|
|
98
|
+
*/
|
|
99
|
+
export function memoryRecord({
|
|
100
|
+
envelope,
|
|
101
|
+
commandArgv,
|
|
102
|
+
cwdRel,
|
|
103
|
+
ms,
|
|
104
|
+
now = Date.now(),
|
|
105
|
+
}) {
|
|
106
|
+
const argv = Array.isArray(commandArgv) ? commandArgv.map(String) : [];
|
|
107
|
+
const truncatedArgv = argv.length > MEMORY_ARGV_LIMIT;
|
|
108
|
+
|
|
109
|
+
const record = {
|
|
110
|
+
at: new Date(now).toISOString(),
|
|
111
|
+
task_id: envelope.task_id,
|
|
112
|
+
attempt: envelope.attempt,
|
|
113
|
+
machine_id: envelope.machine_id,
|
|
114
|
+
machine_name: envelope.machine_name ?? null,
|
|
115
|
+
mode: envelope.mode ?? null,
|
|
116
|
+
argv: truncatedArgv ? argv.slice(0, MEMORY_ARGV_LIMIT) : argv,
|
|
117
|
+
cwd_rel: cwdRel ?? null,
|
|
118
|
+
// The exit code and status are what a reader checks first, so they sit above the tails.
|
|
119
|
+
exit_code: envelope.exit_code ?? null,
|
|
120
|
+
status: envelope.status ?? null,
|
|
121
|
+
refusal_reason: envelope.refusal_reason ?? null,
|
|
122
|
+
ms: Number.isFinite(ms) ? ms : null,
|
|
123
|
+
// Which way the offer travelled. Useful on its own: "this machine has not been reachable directly
|
|
124
|
+
// since Tuesday" is a fact about the fleet that only these records would show.
|
|
125
|
+
transport: envelope.transport ?? null,
|
|
126
|
+
stdout_tail: clamp(envelope.stdout_tail, MEMORY_TAIL_BYTES),
|
|
127
|
+
stderr_tail: clamp(envelope.stderr_tail, MEMORY_TAIL_BYTES),
|
|
128
|
+
// Hashes are repeated from the envelope on purpose: they are what ties this human-readable record
|
|
129
|
+
// to the relay's tamper-evident one, so the two can be cross-checked without trusting either.
|
|
130
|
+
stdout_sha256: envelope.stdout_sha256 ?? null,
|
|
131
|
+
stdout_bytes: envelope.stdout_bytes ?? null,
|
|
132
|
+
// Filled in later by `w2m_memory`, never by the machine. See above.
|
|
133
|
+
conclusion: null,
|
|
134
|
+
tags: [],
|
|
135
|
+
};
|
|
136
|
+
if (truncatedArgv) record.argv_truncated_from = argv.length;
|
|
137
|
+
return record;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Append a record, rotating first when the file has reached its cap.
|
|
142
|
+
*
|
|
143
|
+
* Never throws. A memory that can fail a task is worse than a memory that misses one: the task is the
|
|
144
|
+
* work, and this is a note about it. The caller gets `false` and the reason is on the record instead.
|
|
145
|
+
*
|
|
146
|
+
* Writes are synchronous and `O_APPEND`, so two attempts finishing at once interleave as whole lines
|
|
147
|
+
* rather than as corrupt ones -- the failure mode that would make the whole file unreadable.
|
|
148
|
+
*
|
|
149
|
+
* `maxBytes` is a parameter rather than a closed-over constant so that rotation is testable without
|
|
150
|
+
* writing eight megabytes to prove it. A cap that can only be exercised at production size is a cap
|
|
151
|
+
* whose rotation path is only exercised in production, which is the wrong place to find out.
|
|
152
|
+
*
|
|
153
|
+
* @param {string} stateDir
|
|
154
|
+
* @param {object} record
|
|
155
|
+
* @param {{maxBytes?: number}} [options]
|
|
156
|
+
* @returns {{ok: true, path: string, bytes: number, rotated: boolean}|{ok: false, error: string}}
|
|
157
|
+
*/
|
|
158
|
+
export function appendMemory(stateDir, record, { maxBytes = MEMORY_MAX_BYTES } = {}) {
|
|
159
|
+
try {
|
|
160
|
+
if (typeof stateDir !== 'string' || stateDir === '') {
|
|
161
|
+
return { ok: false, error: 'MEMORY_NO_STATE_DIR' };
|
|
162
|
+
}
|
|
163
|
+
mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
164
|
+
const path = join(stateDir, MEMORY_FILE);
|
|
165
|
+
|
|
166
|
+
let rotated = false;
|
|
167
|
+
if (existsSync(path)) {
|
|
168
|
+
const size = statSync(path).size;
|
|
169
|
+
if (size >= maxBytes) {
|
|
170
|
+
// Replace the previous generation rather than skipping rotation: keeping more than one would
|
|
171
|
+
// defeat the cap, and the cap is the point.
|
|
172
|
+
renameSync(path, join(stateDir, MEMORY_FILE_PREVIOUS));
|
|
173
|
+
writeFileSync(path, '', { mode: 0o600 });
|
|
174
|
+
rotated = true;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const line = `${JSON.stringify(record)}\n`;
|
|
179
|
+
appendFileSync(path, line, { mode: 0o600 });
|
|
180
|
+
return { ok: true, path, bytes: Buffer.byteLength(line), rotated };
|
|
181
|
+
} catch (error) {
|
|
182
|
+
return { ok: false, error: `MEMORY_APPEND_FAILED: ${error instanceof Error ? error.message : String(error)}` };
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Read records back, newest first.
|
|
188
|
+
*
|
|
189
|
+
* Ordering is by file position reversed, not by parsing every `at`: the file is append-only, so position
|
|
190
|
+
* *is* time, and sorting would both cost more and mis-order two records written in the same millisecond.
|
|
191
|
+
*
|
|
192
|
+
* A malformed line is skipped rather than fatal. A partially written final line is the expected outcome
|
|
193
|
+
* of a machine losing power mid-append, and one damaged line must not make the rest unreadable -- which
|
|
194
|
+
* is exactly how the relay's own ledger behaves (`ledger_lines_skipped`), and for the same reason.
|
|
195
|
+
*
|
|
196
|
+
* Synchronous on purpose, and named for it: the reader is called from a tool that answers a question
|
|
197
|
+
* about a local file, and every other reader in this codebase (`w2m_status` on `agent-state.json`) is a
|
|
198
|
+
* synchronous read for the same reason -- a memory query that could interleave with an append would
|
|
199
|
+
* report a state the file was never in.
|
|
200
|
+
*
|
|
201
|
+
* @param {string} stateDir
|
|
202
|
+
* @param {{limit?: number, previous?: boolean}} [options]
|
|
203
|
+
* @returns {{records: object[], skipped: number, truncated: boolean, path: string|null, error?: string}}
|
|
204
|
+
*/
|
|
205
|
+
export function readMemorySync(stateDir, { limit = 50, previous = false } = {}) {
|
|
206
|
+
const empty = { records: [], skipped: 0, truncated: false, path: null };
|
|
207
|
+
if (typeof stateDir !== 'string' || stateDir === '') return { ...empty, error: 'MEMORY_NO_STATE_DIR' };
|
|
208
|
+
const path = join(stateDir, previous ? MEMORY_FILE_PREVIOUS : MEMORY_FILE);
|
|
209
|
+
if (!existsSync(path)) return { ...empty, path };
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
const text = statSync(path).size === 0 ? '' : readFileSync(path, 'utf8');
|
|
213
|
+
const lines = text.split('\n');
|
|
214
|
+
if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
|
|
215
|
+
|
|
216
|
+
const records = [];
|
|
217
|
+
let skipped = 0;
|
|
218
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
219
|
+
if (records.length >= limit) break;
|
|
220
|
+
try {
|
|
221
|
+
records.push(JSON.parse(lines[i]));
|
|
222
|
+
} catch {
|
|
223
|
+
skipped += 1;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return { records, skipped, truncated: records.length >= limit, path };
|
|
227
|
+
} catch (error) {
|
|
228
|
+
return { ...empty, path, error: `MEMORY_READ_FAILED: ${error instanceof Error ? error.message : String(error)}` };
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Retained under the shorter name: the reader has always been synchronous. */
|
|
233
|
+
export const readMemory = readMemorySync;
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
* Everything in this file is callable by a model, against a fleet whose machines run commands under
|
|
36
36
|
* their own allow-lists, and it holds an operator token that authorises dispatch fleet-wide. That
|
|
37
37
|
* makes the *tool surface* a boundary, and this section is the boundary written down as it actually
|
|
38
|
-
* is
|
|
38
|
+
* is - not as it would sound best. `test/tool-cards.test.mjs` asserts the parts of it that can be
|
|
39
39
|
* asserted mechanically, and a change that crosses it is meant to turn that suite red.
|
|
40
40
|
*
|
|
41
41
|
* **No tool parameter names a credential, an allow-list, a key path, or a state directory.** There is
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
* and is applied in one direction only: {@link checkAllowedCommand} returns a refusal or `null`, and
|
|
50
50
|
* there is no path in this file that adds an entry to the list. Each target machine's own agent
|
|
51
51
|
* re-decides the same gate against its own list, so a dispatcher that lost this check would still not
|
|
52
|
-
* widen anything
|
|
52
|
+
* widen anything - the direction the shared matcher exists to keep honest (`src/agent/allowed-commands.mjs`).
|
|
53
53
|
*
|
|
54
54
|
* **No tool can change a machine's `p2pMode`.** The mode is resolved once, in `readConfig` at
|
|
55
55
|
* `apply()` time, from the config object or `W2M_P2P_MODE`, and every dispatch publishes it as a fact
|
|
@@ -58,19 +58,19 @@
|
|
|
58
58
|
* weaker path, which is the one place a dispatcher could otherwise have quietly downgraded a peer.
|
|
59
59
|
*
|
|
60
60
|
* **No tool writes another machine's identity or keys.** `device.json` is read (`readDevice`) and
|
|
61
|
-
* `device.key` is loaded (`DeviceKey.load`)
|
|
61
|
+
* `device.key` is loaded (`DeviceKey.load`) - neither is ever written here. What a dispatch publishes
|
|
62
62
|
* is this machine's own **public** key inside `p2p.static_keys`, so the executor can authenticate a
|
|
63
63
|
* dial; the private half never leaves `src/agent/crypto.mjs`.
|
|
64
64
|
*
|
|
65
65
|
* **No tool result carries a token or a key.** `w2m_status` reports `paired`,
|
|
66
66
|
* `operatorToken_configured` and `pairingCode_configured` as booleans and never the values. The
|
|
67
|
-
* operator token travels only in the `Authorization` header of `POST /v1/task`
|
|
68
|
-
* body and never in an answer
|
|
67
|
+
* operator token travels only in the `Authorization` header of `POST /v1/task` - never in the request
|
|
68
|
+
* body and never in an answer - and `direct_pushes[].static_key` is the literal marker `'published'`,
|
|
69
69
|
* not the key it describes.
|
|
70
70
|
*
|
|
71
71
|
* What an agent-facing tool *may* do across machines is bounded and stated: ask the machines the relay
|
|
72
72
|
* leased a task to run one argv inside their own allow-lists, in their own copy of the project,
|
|
73
|
-
* read-only unless `write: true` names a `write_scope`, and report what they said
|
|
73
|
+
* read-only unless `write: true` names a `write_scope`, and report what they said - refusals
|
|
74
74
|
* included, as values.
|
|
75
75
|
*
|
|
76
76
|
* # `@deepseek-ai/dsh-tools` and the offline fallback
|
|
@@ -93,6 +93,11 @@ import { createHash } from 'node:crypto';
|
|
|
93
93
|
import { promises as fs } from 'node:fs';
|
|
94
94
|
import os from 'node:os';
|
|
95
95
|
import path from 'node:path';
|
|
96
|
+
// The individual memory layer (v0.9.6). Read through the agent's own module rather than re-parsing the
|
|
97
|
+
// file here: the reader carries the rules that matter -- newest-first by file position, a malformed line
|
|
98
|
+
// skipped rather than fatal, and the rotated generation reachable -- and a second implementation would
|
|
99
|
+
// drift from the writer's shape without anything failing.
|
|
100
|
+
import { readMemorySync } from '../agent/memory.mjs';
|
|
96
101
|
|
|
97
102
|
// The base-address rules live in one place (§2), shared with the agent side. Importing the - rather
|
|
98
103
|
// than re-deriving them her - is what stops the plugin and the agent from disagreeing about what
|
|
@@ -180,7 +185,7 @@ const MAX_STAGES = 16;
|
|
|
180
185
|
* `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
|
|
181
186
|
* overrides it at runtime for tests and packagers.
|
|
182
187
|
*/
|
|
183
|
-
const PLUGIN_VERSION = '0.9.
|
|
188
|
+
const PLUGIN_VERSION = '0.9.6';
|
|
184
189
|
|
|
185
190
|
/** How long any single HTTP call may take before it is aborted, in milliseconds. */
|
|
186
191
|
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|
|
@@ -227,8 +232,8 @@ const RETRYABLE_STATUS = new Set([429, 502, 503, 504]);
|
|
|
227
232
|
* `w2m_status` as message prefixes.
|
|
228
233
|
*
|
|
229
234
|
* A code is added only when it names a *distinct remedy*. `W2M_CONFIG` still covers a malformed
|
|
230
|
-
* setting at large; the credential gate below has its own code because its remedy
|
|
231
|
-
* `operatorToken`
|
|
235
|
+
* setting at large; the credential gate below has its own code because its remedy - set
|
|
236
|
+
* `operatorToken` - is unlike any other configuration fault's.
|
|
232
237
|
*/
|
|
233
238
|
export const REFUSAL_CODE = Object.freeze({
|
|
234
239
|
/** The dispatch pre-flight found the command outside this plugin's `allowedCommands`. */
|
|
@@ -267,7 +272,7 @@ export const REFUSAL_CODE = Object.freeze({
|
|
|
267
272
|
/** Storing a result frame failed, so the result exists only on the sender. */
|
|
268
273
|
P2P_INBOX_WRITE_FAILED: 'P2P_INBOX_WRITE_FAILED',
|
|
269
274
|
/**
|
|
270
|
-
* The composer's selected target routed this dispatch. Not a refusal
|
|
275
|
+
* The composer's selected target routed this dispatch. Not a refusal - a named *reason*, so the
|
|
271
276
|
* routing decision is greppable in the answer instead of being a bare `applied: true`.
|
|
272
277
|
*/
|
|
273
278
|
SELECTION_APPLIED: 'SELECTION_APPLIED',
|
|
@@ -577,7 +582,7 @@ function readConfig(config = {}) {
|
|
|
577
582
|
/**
|
|
578
583
|
* §7: where the relay address came from.
|
|
579
584
|
*
|
|
580
|
-
* The order is `rabbitUrl`
|
|
585
|
+
* The order is `rabbitUrl` 鈫?`W2M_RABBIT_URL` 鈫?the shared server, and the *source* is carried out
|
|
581
586
|
* of here rather than re-derived at report time, because "which of the three won" is exactly the
|
|
582
587
|
* question a cross-network deployment gets wrong: a machine that silently talked to the shared
|
|
583
588
|
* server instead of the operator's own relay looks identical to a working one until it dispatches
|
|
@@ -709,7 +714,7 @@ function readConfig(config = {}) {
|
|
|
709
714
|
}
|
|
710
715
|
|
|
711
716
|
/**
|
|
712
|
-
* Resolve `p2pMode` from the config object, then `W2M_P2P_MODE`, then the default
|
|
717
|
+
* Resolve `p2pMode` from the config object, then `W2M_P2P_MODE`, then the default - loudly.
|
|
713
718
|
*
|
|
714
719
|
* A value that is present and wrong is a `W2M_CONFIG` error naming the setting: falling back to
|
|
715
720
|
* `auto` would dispatch work over a path the operator explicitly asked not to use, and falling back
|
|
@@ -717,7 +722,7 @@ function readConfig(config = {}) {
|
|
|
717
722
|
* decisions the operator has to make.
|
|
718
723
|
*
|
|
719
724
|
* "Present" is decided by the *key*, not by truthiness. A profile patch that writes `p2pMode:` with
|
|
720
|
-
* nothing after it hands this function `null`, and that is a value somebody typed
|
|
725
|
+
* nothing after it hands this function `null`, and that is a value somebody typed - collapsing it
|
|
721
726
|
* into "the key is absent" is exactly the silent default this function exists to refuse. An unset key
|
|
722
727
|
* and a blank `W2M_P2P_MODE` are the only two shapes that mean "nobody chose", and only the second is
|
|
723
728
|
* treated as absent because an unset environment variable is indistinguishable from an empty one.
|
|
@@ -753,12 +758,12 @@ function resolveP2PMode(config) {
|
|
|
753
758
|
* Every entry is validated here, before the node is built, because the alternative is a tick several
|
|
754
759
|
* seconds later inside the node's announce loop: an unusable STUN entry is a configuration mistake,
|
|
755
760
|
* and this project reports those at the call site rather than as a degraded status field. The rule
|
|
756
|
-
* itself is `parseServer` from `src/agent/stun.mjs`
|
|
761
|
+
* itself is `parseServer` from `src/agent/stun.mjs` - the same function the query path will use, so
|
|
757
762
|
* "valid here" and "usable there" cannot drift.
|
|
758
763
|
*
|
|
759
764
|
* An **empty list is the default**, not an error. `stunServers: []` is what the bundled
|
|
760
765
|
* `cordis.patch.yml` carries (a key that documents itself and names no server), and a profile that
|
|
761
|
-
* starts from that file must not fail to load. `null` entries are dropped for the same reason
|
|
766
|
+
* starts from that file must not fail to load. `null` entries are dropped for the same reason - a
|
|
762
767
|
* YAML list with a blank item is the same statement as a blank item in a profile patch. A list whose
|
|
763
768
|
* entries are *wrong* is still refused: there is a difference between "nothing named" and "the wrong
|
|
764
769
|
* thing named", and only the second is a mistake.
|
|
@@ -985,7 +990,7 @@ function resolveProfileDir(config) {
|
|
|
985
990
|
/**
|
|
986
991
|
* Require the Rabbit URL, naming the setting when it is absent.
|
|
987
992
|
*
|
|
988
|
-
* With §7's shared-server default this can no longer be reached by "the operator set nothing"
|
|
993
|
+
* With §7's shared-server default this can no longer be reached by "the operator set nothing" - an
|
|
989
994
|
* unset `rabbitUrl` resolves to {@link SHARED_SERVER}. It stays as the last-resort guard so that a
|
|
990
995
|
* future edit which clears the field fails with a sentence naming it instead of an `Invalid URL`
|
|
991
996
|
* from somewhere deep in `fetch`.
|
|
@@ -1042,7 +1047,7 @@ function devicePathFor(cfg) {
|
|
|
1042
1047
|
*
|
|
1043
1048
|
* {@link readDevice} answers from a `stateDir` and is the tool-facing reader; this one adds the
|
|
1044
1049
|
* `DSH_HOME` fallback (an unset `stateDir` is the documented default layout, not an error) and turns
|
|
1045
|
-
* every failure into a `machine_id` of null plus an `error`
|
|
1050
|
+
* every failure into a `machine_id` of null plus an `error` - a machine whose identity cannot be read
|
|
1046
1051
|
* must still be able to dispatch, because the relay path does not need an identity at all.
|
|
1047
1052
|
*
|
|
1048
1053
|
* @param {ReturnType<typeof readConfig>} cfg
|
|
@@ -1082,8 +1087,8 @@ function safeFilePart(value) {
|
|
|
1082
1087
|
*
|
|
1083
1088
|
* Deliberately *not* the relay's own `jcs` (that would make this file depend on relay internals for
|
|
1084
1089
|
* a two-line need). The digest here is only an integrity check of what this process stored, so the
|
|
1085
|
-
* rule is local by definition; the value that has to match another process byte for byte
|
|
1086
|
-
* dedupe key
|
|
1090
|
+
* rule is local by definition; the value that has to match another process byte for byte - the
|
|
1091
|
+
* dedupe key - is the one imported from the relay instead.
|
|
1087
1092
|
*
|
|
1088
1093
|
* @param {unknown} value
|
|
1089
1094
|
* @returns {string}
|
|
@@ -1099,9 +1104,9 @@ function canonicalJson(value) {
|
|
|
1099
1104
|
/**
|
|
1100
1105
|
* Does `frame` name both halves of the inbox key?
|
|
1101
1106
|
*
|
|
1102
|
-
* A frame without a `task_id` or without a `machine_id` cannot be stored or acknowledged
|
|
1107
|
+
* A frame without a `task_id` or without a `machine_id` cannot be stored or acknowledged - the
|
|
1103
1108
|
* acknowledgement is keyed by `task_id`, and two machines answering the same task must not overwrite
|
|
1104
|
-
* each other
|
|
1109
|
+
* each other - so it is refused by name instead of being written somewhere surprising.
|
|
1105
1110
|
*
|
|
1106
1111
|
* Both halves of the refusal are returned: `reason` is the `CODE: sentence` string this has always
|
|
1107
1112
|
* produced (it is what `w2m_status` shows), `reason_code` is the same code as a field, so a reader
|
|
@@ -1159,7 +1164,7 @@ async function writeFileAtomic(dir, file, text) {
|
|
|
1159
1164
|
* Count the stored result frames.
|
|
1160
1165
|
*
|
|
1161
1166
|
* Depth is read from the directory rather than tracked in memory, so it answers the question that
|
|
1162
|
-
* actually matters
|
|
1167
|
+
* actually matters - "is there anything here I have not consumed" - across a plugin reload. A
|
|
1163
1168
|
* missing directory is depth 0, not an error: the inbox exists only once something has arrived.
|
|
1164
1169
|
*
|
|
1165
1170
|
* @param {string|null} dir
|
|
@@ -1233,7 +1238,7 @@ function createP2PTransport(cfg) {
|
|
|
1233
1238
|
*
|
|
1234
1239
|
* A dispatcher behind a symmetric NAT (CGNAT, in practice) can send all it likes and still be heard
|
|
1235
1240
|
* by nobody: its outbound HELLO opens its own mapping, but the peer's filter stays shut until
|
|
1236
|
-
* *something of the peer's* has gone the other way. So the executor has to dial first
|
|
1241
|
+
* *something of the peer's* has gone the other way. So the executor has to dial first - and to dial
|
|
1237
1242
|
* it needs two things the offer itself can carry: where the dispatcher says it is, and which punch
|
|
1238
1243
|
* to join. Without them the executor has only the relay lookup, which is a second opinion about the
|
|
1239
1244
|
* same machine that answers 404 for a dispatcher whose announcement has just expired.
|
|
@@ -1241,14 +1246,14 @@ function createP2PTransport(cfg) {
|
|
|
1241
1246
|
* WHY A PUNCH ID RATHER THAN A SESSION ID
|
|
1242
1247
|
*
|
|
1243
1248
|
* The session id cannot be known when this request is written: one dispatch leases one task to
|
|
1244
|
-
* several machines, and the relay decides which
|
|
1249
|
+
* several machines, and the relay decides which - in the same request that mints the task id. A
|
|
1245
1250
|
* single session shared by those machines would be a defect, not a simplification: on the
|
|
1246
1251
|
* dispatcher's own node the second machine's HELLO would look like a re-acknowledgement of the
|
|
1247
1252
|
* first machine's live channel, so one of them would have its result delivered to the other's
|
|
1248
1253
|
* address. The block therefore publishes a per-dispatch *punch id*, and both ends derive the same
|
|
1249
1254
|
* per-machine session from it (`punchSession` in `src/agent/p2p.mjs`): the dispatcher from the
|
|
1250
|
-
* lease, the executor from the offer plus its own machine id. The pushed frame
|
|
1251
|
-
* to one machine, so the value *is* known
|
|
1255
|
+
* lease, the executor from the offer plus its own machine id. The pushed frame - which is addressed
|
|
1256
|
+
* to one machine, so the value *is* known - carries the derived `session` as well.
|
|
1252
1257
|
*
|
|
1253
1258
|
* COMPATIBILITY
|
|
1254
1259
|
*
|
|
@@ -1264,11 +1269,11 @@ function createP2PTransport(cfg) {
|
|
|
1264
1269
|
* WHY A MAP RATHER THAN ONE KEY
|
|
1265
1270
|
*
|
|
1266
1271
|
* The executor needs the *dispatcher's* key before it can dial back, and the dispatcher needs the
|
|
1267
|
-
* *executor's* key before it can push
|
|
1272
|
+
* *executor's* key before it can push - and at dispatch time the relay has not yet said which
|
|
1268
1273
|
* machines the task went to (`leases` come back in the same response that creates it). So the block
|
|
1269
1274
|
* carries this machine's own key, which is known before the request is written, and
|
|
1270
1275
|
* {@link offerP2PFor} adds the target's once the lease names it. Reading the field is therefore
|
|
1271
|
-
* "look up the machine you are about to talk to", never "assume the only entry is the peer"
|
|
1276
|
+
* "look up the machine you are about to talk to", never "assume the only entry is the peer" - a
|
|
1272
1277
|
* machine that receives an offer for a fleet of three has three entries and uses one.
|
|
1273
1278
|
*
|
|
1274
1279
|
* What that buys, in the document's own terms: no `[Peer]` block for anyone to fill in. A machine
|
|
@@ -1295,7 +1300,7 @@ function buildP2POfferBlock(cfg, runtime) {
|
|
|
1295
1300
|
block.candidates = candidates.map((candidate) => ({ address: candidate.address, port: candidate.port }));
|
|
1296
1301
|
}
|
|
1297
1302
|
// This machine's own static public key. Absent (not null) when there is none, so the wire stays
|
|
1298
|
-
// byte-identical to v0.5.x for a machine that has no key
|
|
1303
|
+
// byte-identical to v0.5.x for a machine that has no key - an additive change has to be absent to
|
|
1299
1304
|
// be additive.
|
|
1300
1305
|
const own = runtime?.encryption?.public_key;
|
|
1301
1306
|
const machineId = runtime?.machineId;
|
|
@@ -1311,7 +1316,7 @@ function buildP2POfferBlock(cfg, runtime) {
|
|
|
1311
1316
|
* The added `session` is what makes the push and a reverse dial one punch rather than two: an
|
|
1312
1317
|
* executor that received the offer over a channel may still have to dial back (the dispatcher closes
|
|
1313
1318
|
* the channel it pushed over, immediately after the push), and a dial carrying a session the
|
|
1314
|
-
* dispatcher is not filtering for would be answered by its `accept()` at best
|
|
1319
|
+
* dispatcher is not filtering for would be answered by its `accept()` at best - a second session,
|
|
1315
1320
|
* a second channel, and two live sessions for one task where one would do.
|
|
1316
1321
|
*
|
|
1317
1322
|
* v0.6.0 adds the *target's* key to `static_keys` when the caller supplies one, which is the half of
|
|
@@ -1343,8 +1348,7 @@ function offerP2PFor(published, { taskId, machineId, peerKey = null }) {
|
|
|
1343
1348
|
*
|
|
1344
1349
|
* The executor decides whether an offer is a first delivery or a duplicate on `dedupe_key` +
|
|
1345
1350
|
* `attempt` (§6), and the relay emits its own SSE offer with those exact values. A push that
|
|
1346
|
-
* disagreed by one character would make the machine execute the task twice
|
|
1347
|
-
* so the dedupe key is computed with the relay's own {@link computeDedupeKey}/{@link computeCommandHash},
|
|
1351
|
+
* disagreed by one character would make the machine execute the task twice - once from each path - * so the dedupe key is computed with the relay's own {@link computeDedupeKey}/{@link computeCommandHash},
|
|
1348
1352
|
* from the same `task_id`, `index`, `command_hash` inputs and `base_tree` the task was created with,
|
|
1349
1353
|
* and `machine_id`/`index`/`attempt` come from the lease rather than from the caller's arguments.
|
|
1350
1354
|
*
|
|
@@ -1419,7 +1423,7 @@ function buildOfferFrame({ taskId, lease, payload, originMachineId, peerKey = nu
|
|
|
1419
1423
|
/**
|
|
1420
1424
|
* Push one offer over the direct path. Best effort by construction: nothing here throws.
|
|
1421
1425
|
*
|
|
1422
|
-
* The session comes from the frame's own `p2p.session`
|
|
1426
|
+
* The session comes from the frame's own `p2p.session` - minted per machine by
|
|
1423
1427
|
* {@link offerP2PFor} from the punch id the relay is publishing for this same task. Dialling with a
|
|
1424
1428
|
* session of the node's own invention would leave the two ends on two sessions, and an executor
|
|
1425
1429
|
* whose reverse dial happened to arrive first would open a *second* channel beside this one instead of
|
|
@@ -1449,7 +1453,7 @@ async function pushOffer(node, frame, crypto = {}) {
|
|
|
1449
1453
|
}
|
|
1450
1454
|
// The dial's own options, built here rather than inline because the *presence* of `crypto` is the
|
|
1451
1455
|
// decision: `P2PNode.dial` arms a handshake only when this dispatcher holds a key for that peer, and
|
|
1452
|
-
// a peer with no published key is dialled exactly as v0.5.x dialled it
|
|
1456
|
+
// a peer with no published key is dialled exactly as v0.5.x dialled it - plaintext, recorded, and
|
|
1453
1457
|
// never a failure. That is §4's fallback clause applied to the push: a fleet must never lose a
|
|
1454
1458
|
// machine to a security upgrade. It is not a silent downgrade, because the peer never offered a key
|
|
1455
1459
|
// to downgrade *from*; a peer whose key we hold and whose handshake fails produces `ok: false` and
|
|
@@ -1505,14 +1509,14 @@ async function pushOffer(node, frame, crypto = {}) {
|
|
|
1505
1509
|
*
|
|
1506
1510
|
* Called after the relay has created the task, so the relay's own SSE offer has already gone out:
|
|
1507
1511
|
* this is an *additional* delivery over the faster path, and the relay's copy is what makes a failed
|
|
1508
|
-
* push harmless. Only leases in a runnable state are pushed
|
|
1512
|
+
* push harmless. Only leases in a runnable state are pushed - `refused` and `observing` machines
|
|
1509
1513
|
* were never handed the command, and sending them an offer would be asking them to run work the
|
|
1510
1514
|
* relay decided they must not run.
|
|
1511
1515
|
*
|
|
1512
1516
|
* v0.6.0: each target's static key is fetched from the relay's peer record before the push, which is
|
|
1513
1517
|
* the one moment the dispatcher learns the key of a machine it did not already know. It is fetched
|
|
1514
1518
|
* **here** rather than sourced from the stored `p2p` block because the block is written *before* the
|
|
1515
|
-
* relay decides which machines the task goes to
|
|
1519
|
+
* relay decides which machines the task goes to - the offer that carries the key is the one addressed
|
|
1516
1520
|
* to the machine that key belongs to. A missing key is not an error: the push proceeds and the
|
|
1517
1521
|
* executor records `P2P_CRYPTO_NO_KEY` for its plaintext channel, which is the documented outcome for
|
|
1518
1522
|
* a peer that never announced.
|
|
@@ -1520,7 +1524,7 @@ async function pushOffer(node, frame, crypto = {}) {
|
|
|
1520
1524
|
* @param {P2PNode} node
|
|
1521
1525
|
* @param {object} options
|
|
1522
1526
|
* @returns {Promise<Array<object>>} One entry per lease considered, in lease order. A machine that was
|
|
1523
|
-
* not pushed to carries `ok: false` **and** a `reason_code` naming which gate refused it
|
|
1527
|
+
* not pushed to carries `ok: false` **and** a `reason_code` naming which gate refused it - this
|
|
1524
1528
|
* block is a tool result, so a bare boolean here would be exactly the reduction the verdicts refuse.
|
|
1525
1529
|
*/
|
|
1526
1530
|
async function pushOffers(node, { taskId, leases, payload, originMachineId, getJson = null }) {
|
|
@@ -1574,7 +1578,7 @@ async function pushOffers(node, { taskId, leases, payload, originMachineId, getJ
|
|
|
1574
1578
|
// logs side by side. `null` when there was nothing to punch over (a v0.4.x-shaped frame).
|
|
1575
1579
|
session: result.session,
|
|
1576
1580
|
// True when the executor's dial got there first and this push travelled over the channel that
|
|
1577
|
-
// dial opened
|
|
1581
|
+
// dial opened - a two-way punch that worked, reported as such rather than as a plain push.
|
|
1578
1582
|
reused: result.reused === true,
|
|
1579
1583
|
// v0.6.0: whether the offer actually travelled sealed, and why not when it did not. Reported
|
|
1580
1584
|
// per machine because it is a per-machine fact: one peer can be encrypted and the next one a
|
|
@@ -1618,19 +1622,19 @@ async function readPeerStaticKey(getJson, machineId) {
|
|
|
1618
1622
|
* WHEN THE NODE STARTS, AND WHY IT IS NOT `apply()`
|
|
1619
1623
|
*
|
|
1620
1624
|
* The v0.4.0 contract says the node is "started in `apply()`". It is not, and this is the one place
|
|
1621
|
-
* this file departs from the contract's letter rather than its intent
|
|
1625
|
+
* this file departs from the contract's letter rather than its intent - reported as a deviation, not
|
|
1622
1626
|
* settled quietly here. What *is* kept is everything the sentence is about: the direct path is owned
|
|
1623
1627
|
* for the plugin's whole lifetime, it is disposed by a `ctx.effect` disposer, and a failure to start
|
|
1624
1628
|
* is a field in `w2m_status` rather than an exception at load.
|
|
1625
1629
|
*
|
|
1626
1630
|
* The reason for the change is what "start" costs. Starting this node binds a UDP socket and kicks
|
|
1627
|
-
* off a STUN sweep against four servers
|
|
1631
|
+
* off a STUN sweep against four servers - network traffic with a multi-second tail - for a machine
|
|
1628
1632
|
* that may never dispatch a task in that session. `apply()` runs on every session in a host that
|
|
1629
1633
|
* mounts this plugin, so an unconditional start is a rendezvous per session, and it puts traffic
|
|
1630
1634
|
* into every test and every host whose runtime is deterministic about what it sends. `ensureStarted()`
|
|
1631
1635
|
* therefore does the work the first time a tool actually needs the direct path: a dispatch
|
|
1632
1636
|
* (`w2m_run`) or a question about it (`w2m_status`). A machine that never dispatches never punches,
|
|
1633
|
-
* which is also the more honest reading of §1
|
|
1637
|
+
* which is also the more honest reading of §1 - "never dials" is the property that matters, and this
|
|
1634
1638
|
* is closer to it than an announce nobody asked for.
|
|
1635
1639
|
*
|
|
1636
1640
|
* @param {object|null} ctx Cordis context; `ctx.effect` is used when present.
|
|
@@ -1674,7 +1678,7 @@ function createP2PRuntime(ctx, cfg, block = {}) {
|
|
|
1674
1678
|
*
|
|
1675
1679
|
* Assigned before the relay-mode early return so the tools can call it unconditionally: a caller
|
|
1676
1680
|
* asks "is the direct path ready" and gets an answer either way, instead of having to know which
|
|
1677
|
-
* mode it is in
|
|
1681
|
+
* mode it is in - the defect this shape fixes was a `relay`-mode dispatch calling a method that did
|
|
1678
1682
|
* not exist, which is a crash in the mode that is supposed to be the safe one.
|
|
1679
1683
|
*
|
|
1680
1684
|
* @type {() => Promise<object>}
|
|
@@ -1722,7 +1726,7 @@ function createP2PRuntime(ctx, cfg, block = {}) {
|
|
|
1722
1726
|
*
|
|
1723
1727
|
* Never throws. A caller cannot fix an unreadable key file from a tool call, and a dispatch that
|
|
1724
1728
|
* refused because a *security upgrade* could not complete would be exactly the fleet-losing failure
|
|
1725
|
-
* §4 forbids
|
|
1729
|
+
* §4 forbids - so the failure becomes a reason string, the node runs without a key, and every offer
|
|
1726
1730
|
* this machine publishes is plainly labelled as having none.
|
|
1727
1731
|
*
|
|
1728
1732
|
* @param {object|null} injected Test seam: a `DeviceKey`, or null to use the file.
|
|
@@ -1823,7 +1827,7 @@ async function initP2P(ctx, cfg, runtime, block) {
|
|
|
1823
1827
|
/**
|
|
1824
1828
|
* Ownership: one node, one effect, one disposer.
|
|
1825
1829
|
*
|
|
1826
|
-
* Registered *here*, after the node exists, rather than in `createP2PRuntime`
|
|
1830
|
+
* Registered *here*, after the node exists, rather than in `createP2PRuntime` - an effect that owns
|
|
1827
1831
|
* nothing is a disposer that releases nothing, and `relay` mode and a machine with no identity must
|
|
1828
1832
|
* leave the host's effect list exactly as they found it. A host that provides no `ctx.effect` (an
|
|
1829
1833
|
* older Cordis, or a bare test context) still gets a working node: the node itself is asked not to
|
|
@@ -1832,7 +1836,7 @@ async function initP2P(ctx, cfg, runtime, block) {
|
|
|
1832
1836
|
* default.
|
|
1833
1837
|
*
|
|
1834
1838
|
* v0.5.10: `node.unref()` is a real call, not a description. Until it was added this comment
|
|
1835
|
-
* claimed an `unref()` that existed nowhere in the plugin or the node
|
|
1839
|
+
* claimed an `unref()` that existed nowhere in the plugin or the node - the socket went on holding
|
|
1836
1840
|
* the event loop, so a host without `ctx.effect` that started a node never exited (found by an
|
|
1837
1841
|
* independent verifier against the `w2m_status` test's bare context).
|
|
1838
1842
|
*/
|
|
@@ -1863,13 +1867,13 @@ async function initP2P(ctx, cfg, runtime, block) {
|
|
|
1863
1867
|
* Store one JSON frame that arrived over a direct channel, and acknowledge it.
|
|
1864
1868
|
*
|
|
1865
1869
|
* The writer is deliberately strict. A frame that is not a JSON object, or that names no
|
|
1866
|
-
* `task_id`/`machine_id`, is refused and **nothing** is written
|
|
1870
|
+
* `task_id`/`machine_id`, is refused and **nothing** is written - an inbox file is the only durable
|
|
1867
1871
|
* trace of a peer's result, and a file whose name does not identify its contents is worse than no
|
|
1868
1872
|
* file, because the next reader cannot tell a partial result from a complete one.
|
|
1869
1873
|
*
|
|
1870
1874
|
* `task.result` is the frame this exists for; `result.ack` and anything else a peer might send are
|
|
1871
1875
|
* counted and ignored rather than treated as faults. The acknowledgement goes back on the same
|
|
1872
|
-
* channel the frame arrived on, and a failed acknowledgement is recorded
|
|
1876
|
+
* channel the frame arrived on, and a failed acknowledgement is recorded - the sender retries (the
|
|
1873
1877
|
* channel is reliable) and the file is already on disk, so a lost ack must not lose the result.
|
|
1874
1878
|
*
|
|
1875
1879
|
* @param {object} runtime
|
|
@@ -1930,7 +1934,7 @@ async function handleP2PMessage(runtime, event) {
|
|
|
1930
1934
|
task_id: frame.task_id,
|
|
1931
1935
|
frame,
|
|
1932
1936
|
// A digest of the stored copy, so a reader can tell "the file changed under me" from "the peer
|
|
1933
|
-
// sent something else". Not a signature
|
|
1937
|
+
// sent something else". Not a signature - the channel is not authenticated at this layer.
|
|
1934
1938
|
frame_sha256: createHash('sha256').update(canonicalJson(frame)).digest('hex'),
|
|
1935
1939
|
};
|
|
1936
1940
|
const file = path.join(
|
|
@@ -1970,7 +1974,7 @@ async function handleP2PMessage(runtime, event) {
|
|
|
1970
1974
|
*
|
|
1971
1975
|
* Every field is present on every call, including when the direct path is off: a reader must be able
|
|
1972
1976
|
* to tell "relay mode" from "p2p was requested and could not start" without comparing two calls.
|
|
1973
|
-
* `null` is used for "never measured", never 0
|
|
1977
|
+
* `null` is used for "never measured", never 0 - the same rule the rest of this file follows for RTT.
|
|
1974
1978
|
*
|
|
1975
1979
|
* @param {ReturnType<typeof readConfig>} cfg
|
|
1976
1980
|
* @param {object} runtime
|
|
@@ -2012,7 +2016,7 @@ async function readP2PStatus(cfg, runtime, peersAnnounced = null) {
|
|
|
2012
2016
|
// v0.6.0: the crypto facts, named one by one rather than mirrored wholesale. `enabled` is about
|
|
2013
2017
|
// *this* machine's key; `encrypted_channels` and `plaintext_channels` are about the channels it
|
|
2014
2018
|
// has actually made, and they can both be non-zero on a mixed fleet. `error` is the reason there
|
|
2015
|
-
// is no key when there is none
|
|
2019
|
+
// is no key when there is none - a reader has to be able to tell "this build has no crypto" from
|
|
2016
2020
|
// "the key file is unreadable" without reading a log.
|
|
2017
2021
|
encryption: {
|
|
2018
2022
|
enabled: runtime.encryption?.enabled === true,
|
|
@@ -2504,7 +2508,7 @@ async function request(options) {
|
|
|
2504
2508
|
}
|
|
2505
2509
|
|
|
2506
2510
|
// §2: base + path, joined by hand. `new URL(pathname, base)` would resolve against the origin
|
|
2507
|
-
// and drop a deployment sub-path (`https://h/w2m` + `/v1/task`
|
|
2511
|
+
// and drop a deployment sub-path (`https://h/w2m` + `/v1/task` —?`https://h/v1/task` —?404).
|
|
2508
2512
|
const target = toRequestUrl(joinUrl(rabbitUrl, pathname));
|
|
2509
2513
|
for (const [key, value] of Object.entries(query ?? {})) {
|
|
2510
2514
|
if (value !== undefined && value !== null) target.searchParams.set(key, String(value));
|
|
@@ -2584,8 +2588,8 @@ function endpointOf(target) {
|
|
|
2584
2588
|
* Two sources, and the distinction is the point. An explicit `stateDir` is the operator pointing at
|
|
2585
2589
|
* a pairing, so a missing or unreadable `device.json` there is **named as the fault** rather than
|
|
2586
2590
|
* being turned into a mystery `401`. With no `stateDir`, this machine's own identity lives where the
|
|
2587
|
-
* agent writes it by default
|
|
2588
|
-
* reads
|
|
2591
|
+
* agent writes it by default - `$DSH_HOME/xclient/device.json`, the same file {@link readLocalIdentity}
|
|
2592
|
+
* reads - and the calls go out with it. v0.4.0 made that fallback load-bearing rather than optional:
|
|
2589
2593
|
* every P2P announcement is authenticated as the announcing device, so a machine whose identity was
|
|
2590
2594
|
* only reachable through `DSH_HOME` would have had a permanently unauthenticated rendezvous and a
|
|
2591
2595
|
* direct path that never worked, while the relay path kept working and hid the reason.
|
|
@@ -2655,8 +2659,7 @@ async function readDevice(cfg) {
|
|
|
2655
2659
|
/**
|
|
2656
2660
|
* Require a `device_token`, naming where it was looked for when it is absent.
|
|
2657
2661
|
*
|
|
2658
|
-
* The `code` stays `W2M_NO_TOKEN` for every case
|
|
2659
|
-
* while `reason_code` names which of the three it is, because the three have different remedies: pair
|
|
2662
|
+
* The `code` stays `W2M_NO_TOKEN` for every case - one class, "this machine cannot authenticate" - * while `reason_code` names which of the three it is, because the three have different remedies: pair
|
|
2660
2663
|
* the agent, repair the file, or finish a pairing that never completed.
|
|
2661
2664
|
*
|
|
2662
2665
|
* @param {{machine_id: string|null, device_token: string|null, path: string|null, present: boolean, error: string|null}} device
|
|
@@ -2817,13 +2820,13 @@ async function resolveTreeFingerprint(projectDir, timeoutMs) {
|
|
|
2817
2820
|
* resolving a path, and a pre-flight that strips the path can only ever compare names, so it would
|
|
2818
2821
|
* wave `E:\Temp\evil\node.exe` through while the agent refused it. Passing the argv through
|
|
2819
2822
|
* unchanged is what keeps the two ends one rule. What the shared matcher then resolves, it resolves
|
|
2820
|
-
* against **this** process's environment
|
|
2823
|
+
* against **this** process's environment - the dispatcher's PATH - which is a real limit of a
|
|
2821
2824
|
* pre-flight (the executing machine's PATH is the one that will decide), and the reason the refusal
|
|
2822
2825
|
* that matters is the agent's own.
|
|
2823
2826
|
*
|
|
2824
2827
|
* A refusal names itself: `reason_code` is the matcher's own code (so the two ends cannot report one
|
|
2825
2828
|
* refusal under two spellings) and `detail` says what would change it. The two ways this control
|
|
2826
|
-
* refuses are kept apart
|
|
2829
|
+
* refuses are kept apart - a command outside the list and a list that cannot be parsed have
|
|
2827
2830
|
* different remedies, and sharing one code would send the reader to the wrong one.
|
|
2828
2831
|
*
|
|
2829
2832
|
* @param {string[]} allowedCommands Entries as configured, e.g. `['node --test']`.
|
|
@@ -2903,7 +2906,7 @@ function compactEnvelope(raw, comparable) {
|
|
|
2903
2906
|
function excerpt(value) {
|
|
2904
2907
|
const buffer = Buffer.from(value, 'utf8');
|
|
2905
2908
|
if (buffer.byteLength <= MAX_EXCERPT_BYTES) return value;
|
|
2906
|
-
return `${buffer.subarray(0, MAX_EXCERPT_BYTES).toString('utf8')}\n -
|
|
2909
|
+
return `${buffer.subarray(0, MAX_EXCERPT_BYTES).toString('utf8')}\n - …[truncated at ${MAX_EXCERPT_BYTES} bytes]`;
|
|
2907
2910
|
}
|
|
2908
2911
|
|
|
2909
2912
|
/**
|
|
@@ -2952,7 +2955,7 @@ const OPTIONAL_FIELDS = new Set([
|
|
|
2952
2955
|
'lockfiles', 'unpinned_deps', 'submodules', 'lfs', 'signal',
|
|
2953
2956
|
// v0.4.0 §5: how the envelope travelled, and the path facts that came with it. Optional because a
|
|
2954
2957
|
// v0.3.9 agent sends neither and must keep working unchanged; here rather than in
|
|
2955
|
-
// `COMPARABLE_FIELDS` because **a path is not a result**
|
|
2958
|
+
// `COMPARABLE_FIELDS` because **a path is not a result** - two byte-identical runs on two machines
|
|
2956
2959
|
// must stay `consistent` when one of them punched a hole and the other used the relay.
|
|
2957
2960
|
'transport', 'p2p',
|
|
2958
2961
|
]);
|
|
@@ -3164,7 +3167,7 @@ export async function apply(ctx, config = {}) {
|
|
|
3164
3167
|
//
|
|
3165
3168
|
// Composed here, next to the updater's own `ctx.effect`, and for the same reason: a socket and a
|
|
3166
3169
|
// refresh timer have to be released when the plugin is unloaded, and an effect disposer is the
|
|
3167
|
-
// only mechanism Cordis gives for that. Creating it never throws
|
|
3170
|
+
// only mechanism Cordis gives for that. Creating it never throws - a machine that cannot punch
|
|
3168
3171
|
// still has to dispatch, and `w2m_status` is where the failure is reported.
|
|
3169
3172
|
const p2p = createP2PRuntime(ctx, cfg, config.p2pBlock);
|
|
3170
3173
|
|
|
@@ -3180,14 +3183,13 @@ export async function apply(ctx, config = {}) {
|
|
|
3180
3183
|
});
|
|
3181
3184
|
|
|
3182
3185
|
// v0.6.1: the two browser routes behind the composer's target control. Additive and optional: a
|
|
3183
|
-
// host without `connection.fetch` still gets every tool, and `w2m_status` reports the absence
|
|
3184
|
-
// which is the honest place for it, since a host that has no Connection at all is simply not a web
|
|
3186
|
+
// host without `connection.fetch` still gets every tool, and `w2m_status` reports the absence - // which is the honest place for it, since a host that has no Connection at all is simply not a web
|
|
3185
3187
|
// composition and does not need a warning on every headless run.
|
|
3186
3188
|
//
|
|
3187
3189
|
// Nothing here reads the service: `registerTargetRoutes` declares the optional dependency with
|
|
3188
3190
|
// `ctx.inject` and attaches from that callback (the comment there records why a plain
|
|
3189
3191
|
// `ctx.connection` read fails the whole entry, and why the read cannot be synchronous anyway). The
|
|
3190
|
-
// one mismatch worth a log line
|
|
3192
|
+
// one mismatch worth a log line - a Connection that carries no fetch registry - is logged where it
|
|
3191
3193
|
// is observed, inside that callback, so this call returns a status object that stays live.
|
|
3192
3194
|
const targetRoutes = registerTargetRoutes(ctx, {
|
|
3193
3195
|
cfg: () => cfg,
|
|
@@ -3580,7 +3582,7 @@ export async function apply(ctx, config = {}) {
|
|
|
3580
3582
|
//
|
|
3581
3583
|
// The precedence rule is the whole contract, and it is one line: **the caller's own fields
|
|
3582
3584
|
// always win**. `executor_machine_id` is checked below, and `target_machines` is never a tool
|
|
3583
|
-
// argument
|
|
3585
|
+
// argument - so a caller that named a machine (which, for this tool, means
|
|
3584
3586
|
// `executor_machine_id` under broadcast) is untouched, and a caller that named none inherits
|
|
3585
3587
|
// the selection. Nothing already published changes meaning: the selection only fills fields
|
|
3586
3588
|
// that were absent, and the relay has accepted both of them since v0.3.3/v0.4.1.
|
|
@@ -3605,7 +3607,7 @@ export async function apply(ctx, config = {}) {
|
|
|
3605
3607
|
* `applied` alone conflates three different situations an operator has to tell apart: nothing
|
|
3606
3608
|
* was selected, the selection was overridden by the call's own `executor_machine_id`, or a
|
|
3607
3609
|
* selection exists that names no machine at all and therefore routed nothing. The last one is
|
|
3608
|
-
* the one that used to be invisible
|
|
3610
|
+
* the one that used to be invisible - a stored selection with no `machine_id` produces
|
|
3609
3611
|
* `applied: false` and nothing else, which looks exactly like "the whole fleet ran it".
|
|
3610
3612
|
*/
|
|
3611
3613
|
const selectionReason = selection === null
|
|
@@ -3692,14 +3694,14 @@ export async function apply(ctx, config = {}) {
|
|
|
3692
3694
|
|
|
3693
3695
|
// §7: the two routing facts the offer carries to the machine that has to run the command.
|
|
3694
3696
|
//
|
|
3695
|
-
// `origin_machine_id` is *this* machine's id from `device.json`
|
|
3696
|
-
// where it runs
|
|
3697
|
+
// `origin_machine_id` is *this* machine's id from `device.json` - where the work was typed, not
|
|
3698
|
+
// where it runs - and it is omitted entirely (not sent as null) when this machine has no
|
|
3697
3699
|
// identity, because the relay's own validation refuses an empty string and "unknown origin" is
|
|
3698
3700
|
// what a missing field already means on the wire.
|
|
3699
3701
|
//
|
|
3700
3702
|
// The identity comes from the runtime, which resolves it off the dispatch path; a dispatch that
|
|
3701
3703
|
// arrives before that one small read has finished waits for it rather than racing it. The wait
|
|
3702
|
-
// is bounded by the identity read itself and can never reject
|
|
3704
|
+
// is bounded by the identity read itself and can never reject - see `createP2PRuntime`. This is
|
|
3703
3705
|
// also the first use of the direct path, so it is where the node is started.
|
|
3704
3706
|
await p2p.ensureStarted();
|
|
3705
3707
|
const originMachineId =
|
|
@@ -3742,7 +3744,7 @@ export async function apply(ctx, config = {}) {
|
|
|
3742
3744
|
// above refuses the combination, so this can never silently mean nothing.
|
|
3743
3745
|
...(mode === 'pipeline' && stagesPayload !== null ? { stages: stagesPayload } : {}),
|
|
3744
3746
|
// v0.4.0: stated on every task, so `w2m_wait`'s `transport` and a machine's report can say
|
|
3745
|
-
// which path the dispatcher expected to use
|
|
3747
|
+
// which path the dispatcher expected to use - and so the executor in `direct` mode knows it
|
|
3746
3748
|
// must refuse an offer that did not arrive over the direct path.
|
|
3747
3749
|
...(originMachineId !== null ? { origin_machine_id: originMachineId } : {}),
|
|
3748
3750
|
p2p: p2pOffer,
|
|
@@ -3788,7 +3790,7 @@ export async function apply(ctx, config = {}) {
|
|
|
3788
3790
|
* so a push that fails, a peer that never announced and a socket that was never bound all leave
|
|
3789
3791
|
* exactly the same outcome: the relay's own SSE offer is the delivery, and the machine's
|
|
3790
3792
|
* `transport` says which path won. A failed push is therefore **not** an error and never fails
|
|
3791
|
-
* this tool
|
|
3793
|
+
* this tool - but it is not hidden either, because `direct_pushes` names every attempt and the
|
|
3792
3794
|
* reason it did not land.
|
|
3793
3795
|
*
|
|
3794
3796
|
* `relay` mode and a machine with no direct path both report `attempted: 0`, which is the
|
|
@@ -3823,7 +3825,7 @@ export async function apply(ctx, config = {}) {
|
|
|
3823
3825
|
failed: pushes.filter((entry) => !entry.ok).length,
|
|
3824
3826
|
pushes,
|
|
3825
3827
|
// How many of the machines that were reached were reached over a channel *this* machine
|
|
3826
|
-
// did not open
|
|
3828
|
+
// did not open - the executor dialled first, and the push landed on that path. Zero on a
|
|
3827
3829
|
// fleet where only the dispatcher initiates, which is what makes the two-way punch
|
|
3828
3830
|
// visible in one dispatch's own answer instead of only in the ledger.
|
|
3829
3831
|
reused_channels: pushes.filter((entry) => entry.reused === true).length,
|
|
@@ -4023,7 +4025,7 @@ export async function apply(ctx, config = {}) {
|
|
|
4023
4025
|
* v0.4.0 §5: which path this machine's result travelled, and the path facts that came
|
|
4024
4026
|
* with it. Read from the envelope because that is where the agent writes them, with the
|
|
4025
4027
|
* per-machine record as the fallback (`GET /v1/tasks/{id}` copies both onto the machine
|
|
4026
|
-
* entry as well). `null` means the machine did not say
|
|
4028
|
+
* entry as well). `null` means the machine did not say - which is exactly what a
|
|
4027
4029
|
* v0.3.9 agent sends, so a missing value must not be dressed up as `'relay'`.
|
|
4028
4030
|
*/
|
|
4029
4031
|
transport: envelope?.transport ?? machine?.transport ?? null,
|
|
@@ -4223,7 +4225,7 @@ export async function apply(ctx, config = {}) {
|
|
|
4223
4225
|
const rtt = await readRtt(cfg);
|
|
4224
4226
|
// §7: this tool is one of the two places the direct path is asked about, so it is one of the
|
|
4225
4227
|
// two places the node is started. Never throws, and reports `enabled: false`/`running: false`
|
|
4226
|
-
// rather than omitting itself
|
|
4228
|
+
// rather than omitting itself - a reader has to be able to tell "relay mode" from "p2p was
|
|
4227
4229
|
// requested and did not start".
|
|
4228
4230
|
await p2p.ensureStarted();
|
|
4229
4231
|
const p2pStatus = await readP2PStatus(cfg, p2p, Number.isInteger(relay.peers_announced) ? relay.peers_announced : null);
|
|
@@ -4303,8 +4305,8 @@ export async function apply(ctx, config = {}) {
|
|
|
4303
4305
|
/**
|
|
4304
4306
|
* v0.6.1: the composer's target control and the selections it holds.
|
|
4305
4307
|
*
|
|
4306
|
-
* Reported here because "the control is not in my composer" has exactly two causes
|
|
4307
|
-
* host has no fetch-route registry, or the browser half is not loaded
|
|
4308
|
+
* Reported here because "the control is not in my composer" has exactly two causes - the
|
|
4309
|
+
* host has no fetch-route registry, or the browser half is not loaded - and this is the
|
|
4308
4310
|
* one call that can tell them apart from the host side. The selections themselves are
|
|
4309
4311
|
* counts, not contents: which session picked which machine is the operator's business and
|
|
4310
4312
|
* nothing the model needs.
|
|
@@ -4336,8 +4338,7 @@ export async function apply(ctx, config = {}) {
|
|
|
4336
4338
|
* v0.8.5: **say it out loud.**
|
|
4337
4339
|
*
|
|
4338
4340
|
* This branch is the whole reason a machine can run v0.8.4 with the account-derived fleet key,
|
|
4339
|
-
* hold a valid signed-in account, reach the relay over HTTP 200, and still be in no fleet
|
|
4340
|
-
* with nothing anywhere reporting a problem. The eight tools register, the plugin looks
|
|
4341
|
+
* hold a valid signed-in account, reach the relay over HTTP 200, and still be in no fleet - * with nothing anywhere reporting a problem. The eight tools register, the plugin looks
|
|
4341
4342
|
* healthy, and the join sequence simply never runs.
|
|
4342
4343
|
*
|
|
4343
4344
|
* A silent no-op that looks like success is the worst failure mode this plugin has had. One
|
|
@@ -4661,6 +4662,181 @@ export async function apply(ctx, config = {}) {
|
|
|
4661
4662
|
},
|
|
4662
4663
|
presentCall: () => ({ card: 'generic', title: 'w2m stats', kind: 'read', rawInput: {} }),
|
|
4663
4664
|
}));
|
|
4665
|
+
|
|
4666
|
+
/**
|
|
4667
|
+
* v0.9.6 — the individual memory layer.
|
|
4668
|
+
*
|
|
4669
|
+
* The relay keeps `command_hash`, `stdout_sha256` and `stdout_bytes` and never the text, deliberately:
|
|
4670
|
+
* the ledger is a tamper-evident consistency record, and a relay compromise discloses no command text.
|
|
4671
|
+
* The price is that nothing in the fleet could answer *"what did we already try on this machine, and
|
|
4672
|
+
* what happened?"* -- and that price was paid repeatedly, with the same facts rediscovered from scratch
|
|
4673
|
+
* across a dozen dispatches in one session.
|
|
4674
|
+
*
|
|
4675
|
+
* This tool is the read side of the fix. The write side is `appendMemory`, called by the agent as each
|
|
4676
|
+
* task finishes, in that machine's own state directory.
|
|
4677
|
+
*
|
|
4678
|
+
* ## What this first version does and does not reach
|
|
4679
|
+
*
|
|
4680
|
+
* It reaches **this machine's** memory directly: the file is local, so the read is a file read. It does
|
|
4681
|
+
* **not** yet read another machine's memory, and the reason is structural rather than an omission --
|
|
4682
|
+
* the relay carries no such file, an agent executes a dispatched command inside a sandbox whose writes
|
|
4683
|
+
* are scoped, and pretending otherwise would mean shipping a tool that silently returns nothing for
|
|
4684
|
+
* every machine but one.
|
|
4685
|
+
*
|
|
4686
|
+
* So a query for another machine says exactly that, names where to look instead, and stays useful: the
|
|
4687
|
+
* fleet's roster still comes from the relay, so the answer distinguishes "no such machine" from "that
|
|
4688
|
+
* machine keeps its own memory and this build cannot fetch it".
|
|
4689
|
+
*/
|
|
4690
|
+
ctx.tools.register(defineTool({
|
|
4691
|
+
name: 'w2m_memory',
|
|
4692
|
+
description:
|
|
4693
|
+
'Ask what this machine has already done: its individual memory, one line per finished task, ' +
|
|
4694
|
+
'recording the command, exit code, duration, transport and the tail of stdout/stderr. Use it ' +
|
|
4695
|
+
'before re-running work to find out whether it has been tried and what happened. The relay keeps ' +
|
|
4696
|
+
'only hashes, so this local file is the only place a command\'s output survives. Reads only this ' +
|
|
4697
|
+
'machine\'s memory; for another machine it reports where that memory lives rather than guessing.',
|
|
4698
|
+
parameters: {
|
|
4699
|
+
machine: {
|
|
4700
|
+
type: 'string',
|
|
4701
|
+
description:
|
|
4702
|
+
'Which machine\'s memory to read. Defaults to this machine. Naming another machine is ' +
|
|
4703
|
+
'answered with where its memory is kept, because this build can only read the local file.',
|
|
4704
|
+
},
|
|
4705
|
+
limit: {
|
|
4706
|
+
type: 'number',
|
|
4707
|
+
description: 'How many records to return, newest first. Defaults to 20.',
|
|
4708
|
+
default: 20,
|
|
4709
|
+
},
|
|
4710
|
+
include_previous: {
|
|
4711
|
+
type: 'boolean',
|
|
4712
|
+
description:
|
|
4713
|
+
'Also read the rotated generation (`memory.jsonl.1`). The log is capped and rotates, so a ' +
|
|
4714
|
+
'memory question about anything older than the current file needs this.',
|
|
4715
|
+
default: false,
|
|
4716
|
+
},
|
|
4717
|
+
},
|
|
4718
|
+
output: jsonOutput,
|
|
4719
|
+
async execute(args, exec) {
|
|
4720
|
+
throwIfAborted(exec?.signal);
|
|
4721
|
+
const limitRaw = Number(args?.limit);
|
|
4722
|
+
const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(Math.floor(limitRaw), 500) : 20;
|
|
4723
|
+
|
|
4724
|
+
const requested = typeof args?.machine === 'string' && args.machine.trim() !== '' ? args.machine.trim() : null;
|
|
4725
|
+
const identity = await readLocalIdentity(cfg);
|
|
4726
|
+
const self = identity?.machine_name ?? null;
|
|
4727
|
+
const selfId = identity?.machine_id ?? null;
|
|
4728
|
+
|
|
4729
|
+
// A query about another machine is answered honestly rather than with an empty list, which would
|
|
4730
|
+
// read as "that machine has done nothing" -- the opposite conclusion from "this build cannot see".
|
|
4731
|
+
if (requested !== null && requested !== self && requested !== selfId) {
|
|
4732
|
+
return JSON.stringify(
|
|
4733
|
+
{
|
|
4734
|
+
ok: true,
|
|
4735
|
+
scope: 'remote',
|
|
4736
|
+
requested_machine: requested,
|
|
4737
|
+
self: { machine_id: selfId, machine_name: self },
|
|
4738
|
+
records: null,
|
|
4739
|
+
reason_code: 'MEMORY_REMOTE_NOT_READABLE',
|
|
4740
|
+
detail:
|
|
4741
|
+
`the individual memory of "${requested}" is kept on that machine, in its own state ` +
|
|
4742
|
+
'directory as memory.jsonl; this build reads only the local file, and the relay never ' +
|
|
4743
|
+
'receives it',
|
|
4744
|
+
how_to_read_it: [
|
|
4745
|
+
`run \`w2m_run\` on ${requested} with a command that prints the tail of its memory.jsonl`,
|
|
4746
|
+
'or read it directly over an administration channel to that machine',
|
|
4747
|
+
],
|
|
4748
|
+
found: false,
|
|
4749
|
+
},
|
|
4750
|
+
null,
|
|
4751
|
+
2,
|
|
4752
|
+
);
|
|
4753
|
+
}
|
|
4754
|
+
|
|
4755
|
+
const stateDir = cfg.stateDir ?? null;
|
|
4756
|
+
if (stateDir === null) {
|
|
4757
|
+
return JSON.stringify(
|
|
4758
|
+
{
|
|
4759
|
+
ok: true,
|
|
4760
|
+
scope: 'local',
|
|
4761
|
+
machine_id: selfId,
|
|
4762
|
+
machine_name: self,
|
|
4763
|
+
records: null,
|
|
4764
|
+
reason_code: 'MEMORY_NO_STATE_DIR',
|
|
4765
|
+
detail: 'this plugin has no stateDir configured, so there is nowhere the memory log would be',
|
|
4766
|
+
found: false,
|
|
4767
|
+
},
|
|
4768
|
+
null,
|
|
4769
|
+
2,
|
|
4770
|
+
);
|
|
4771
|
+
}
|
|
4772
|
+
|
|
4773
|
+
const current = readMemorySync(stateDir, { limit });
|
|
4774
|
+
const previous = args?.include_previous === true ? readMemorySync(stateDir, { limit, previous: true }) : null;
|
|
4775
|
+
|
|
4776
|
+
/**
|
|
4777
|
+
* A small summary before the records themselves.
|
|
4778
|
+
*
|
|
4779
|
+
* The question that sent someone here is usually "have we run this before, and did it work", and a
|
|
4780
|
+
* count answers it faster than reading twenty lines. The counts are of the records *returned*, not
|
|
4781
|
+
* of the whole file, and that is stated -- a summary that silently described a different set than
|
|
4782
|
+
* the one shown is how a reader learns to distrust the summary.
|
|
4783
|
+
*/
|
|
4784
|
+
const tally = (records) => {
|
|
4785
|
+
const by = {};
|
|
4786
|
+
for (const r of records ?? []) {
|
|
4787
|
+
const key = r?.status ?? 'unknown';
|
|
4788
|
+
by[key] = (by[key] ?? 0) + 1;
|
|
4789
|
+
}
|
|
4790
|
+
return by;
|
|
4791
|
+
};
|
|
4792
|
+
|
|
4793
|
+
return JSON.stringify(
|
|
4794
|
+
{
|
|
4795
|
+
ok: true,
|
|
4796
|
+
scope: 'local',
|
|
4797
|
+
machine_id: selfId,
|
|
4798
|
+
machine_name: self,
|
|
4799
|
+
state_dir: stateDir,
|
|
4800
|
+
memory_path: current.path,
|
|
4801
|
+
found: current.path !== null && (current.records.length > 0 || previous !== null),
|
|
4802
|
+
count: current.records.length,
|
|
4803
|
+
skipped_lines: current.skipped,
|
|
4804
|
+
truncated: current.truncated,
|
|
4805
|
+
status_tally: tally(current.records),
|
|
4806
|
+
records: current.records,
|
|
4807
|
+
...(previous === null
|
|
4808
|
+
? {}
|
|
4809
|
+
: {
|
|
4810
|
+
previous: {
|
|
4811
|
+
path: previous.path,
|
|
4812
|
+
count: previous.records.length,
|
|
4813
|
+
skipped_lines: previous.skipped,
|
|
4814
|
+
status_tally: tally(previous.records),
|
|
4815
|
+
records: previous.records,
|
|
4816
|
+
},
|
|
4817
|
+
}),
|
|
4818
|
+
notes: [
|
|
4819
|
+
current.path === null
|
|
4820
|
+
? 'no memory has been written here yet: the log is created when a task finishes'
|
|
4821
|
+
: null,
|
|
4822
|
+
current.skipped > 0
|
|
4823
|
+
? `${current.skipped} line(s) could not be parsed and were skipped; a partially written final line is the expected cause`
|
|
4824
|
+
: null,
|
|
4825
|
+
'conclusion is null until something records what a result meant; the machine only stores the fact',
|
|
4826
|
+
'the relay keeps hashes and never this text, so this file is the only durable copy of the output',
|
|
4827
|
+
].filter((n) => n !== null),
|
|
4828
|
+
},
|
|
4829
|
+
null,
|
|
4830
|
+
2,
|
|
4831
|
+
);
|
|
4832
|
+
},
|
|
4833
|
+
presentCall: (args) => ({
|
|
4834
|
+
card: 'generic',
|
|
4835
|
+
title: `w2m memory${typeof args?.machine === 'string' ? ` ${args.machine}` : ''}`,
|
|
4836
|
+
kind: 'read',
|
|
4837
|
+
rawInput: {},
|
|
4838
|
+
}),
|
|
4839
|
+
}));
|
|
4664
4840
|
}
|
|
4665
4841
|
|
|
4666
4842
|
/**
|
|
@@ -4735,14 +4911,14 @@ async function startLocalStack(ctx, cfg) {
|
|
|
4735
4911
|
*
|
|
4736
4912
|
* It called `createAgent` with `projectDir` and without `identity`, and passed a
|
|
4737
4913
|
* `pairingCode` that `createAgent` does not accept. `createAgent` requires `project` and an
|
|
4738
|
-
* `identity.machine_id`, so **`autoStartAgent: true` threw on its first call**
|
|
4914
|
+
* `identity.machine_id`, so **`autoStartAgent: true` threw on its first call** - the code path
|
|
4739
4915
|
* that was supposed to bring an agent up had never run. That is why the fleet-join wire appeared
|
|
4740
4916
|
* to be missing: the place it was meant to live could not be reached at all.
|
|
4741
4917
|
*
|
|
4742
4918
|
* Two more things were wrong with the shape:
|
|
4743
4919
|
*
|
|
4744
4920
|
* * it created a **local relay** unconditionally. For a machine joining a shared fleet that
|
|
4745
|
-
* is the opposite of what is wanted
|
|
4921
|
+
* is the opposite of what is wanted - the machine must connect to the relay that already
|
|
4746
4922
|
* exists, not mint another one, and with `rabbitUrl` pointing at a remote host it would
|
|
4747
4923
|
* have tried to bind a relay on that hostname;
|
|
4748
4924
|
* * nothing paired the agent, so even a started agent could fetch no offers.
|