@twinsearth/w2m-dsh-plugin 1.0.10 → 1.0.12
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 +115 -0
- package/package.json +5 -3
- package/plugin.json +1 -1
- package/src/plugin/fleet-merge.mjs +270 -0
- package/src/plugin/tools.mjs +118 -4
- package/src/relay/state.mjs +14 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,121 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project are documented here.
|
|
4
4
|
|
|
5
|
+
## [1.0.12] — 2026-10-17
|
|
6
|
+
|
|
7
|
+
**The five fleet-memory actions are verified end to end, against a real relay, a real profile and a real
|
|
8
|
+
`fleet-memory.md` -- and the first attempt found a real defect.**
|
|
9
|
+
|
|
10
|
+
### What was unverified until now
|
|
11
|
+
|
|
12
|
+
`publish`, `pull`, `latest`, `diff` and `merge` had each been shipped marked **UNVERIFIED**. The merge *rule* was
|
|
13
|
+
proven exhaustively as a pure function, and the relay *routes* were proven against a real relay -- **but nothing had
|
|
14
|
+
ever executed the tool surface itself.** A caller with no execution is indistinguishable from a working one, which is
|
|
15
|
+
the same gap this project keeps restating in a different shape.
|
|
16
|
+
|
|
17
|
+
### The defect the end-to-end test found on its first run
|
|
18
|
+
|
|
19
|
+
`latest` reported `published_by: null` for a publication whose author the relay knew perfectly well.
|
|
20
|
+
|
|
21
|
+
`RabbitState.putFleetBlob` accepted a `publishedBy` argument that **nothing ever supplied**: the pointer store took
|
|
22
|
+
it, defaulted it to `null`, and the field looked implemented. Fixed by filling it from **the authenticated device's
|
|
23
|
+
`machine_id`, never from the request body** -- for the same reason the P2P announce route reads `machine_id` off the
|
|
24
|
+
token: a caller that could name itself in the body could attribute a publication to a machine that never made it.
|
|
25
|
+
|
|
26
|
+
**No unit test could have caught this**, because the unit under test was the pointer store, and it was behaving
|
|
27
|
+
exactly as written. The bug lived in the seam between two correct components, which is what an end-to-end test is for.
|
|
28
|
+
|
|
29
|
+
### What the test exercises, in the order a machine would
|
|
30
|
+
|
|
31
|
+
A real relay on an ephemeral port and a real profile, then:
|
|
32
|
+
|
|
33
|
+
1. **`publish`** — returns a 64-hex hash and the byte count.
|
|
34
|
+
2. **`latest`** — discovers that hash **without being told it**, and reports the publishing machine.
|
|
35
|
+
3. **`pull latest`** — fetches the bytes, re-hashes them, and they round-trip **exactly**.
|
|
36
|
+
4. **`diff`** — on an unchanged document: `same: true`, no difference, **and the local file untouched**.
|
|
37
|
+
5. the fleet's document is replaced out of band through the relay's own route;
|
|
38
|
+
6. **`diff`** — now reports the divergence, and the local file is **still untouched**;
|
|
39
|
+
7. **`merge`** — applies the declared rule (neither side declares a repair, so the **default layer** applies and the
|
|
40
|
+
newer publication wins), records the decision with a reason, carries the winning value into the merged text, and
|
|
41
|
+
**writes nothing to the local document.**
|
|
42
|
+
|
|
43
|
+
**`wrote_anything: false` is asserted three times**, because "these actions never edit the file they read" is the
|
|
44
|
+
property that makes it safe to let a network round trip participate at all.
|
|
45
|
+
|
|
46
|
+
### Verified
|
|
47
|
+
|
|
48
|
+
- the end-to-end suite: 1 test, passing, covering all five actions;
|
|
49
|
+
- `test/fleet-merge.test.mjs`: 13 tests over the rule itself;
|
|
50
|
+
- full suite: **2374 tests in 91 files, 2360 pass, 0 fail, 14 skip**;
|
|
51
|
+
- pre-flight **GREEN, 15 of 15 checks**.
|
|
52
|
+
|
|
53
|
+
### NOT verified, even now
|
|
54
|
+
|
|
55
|
+
**Concurrency.** Everything here is one machine at a time. Two machines publishing and merging simultaneously is
|
|
56
|
+
untested, and the pointer has no compare-and-swap, so the last writer to the pointer wins -- which is **the same rule
|
|
57
|
+
as the default layer**, but that is a coincidence rather than a guarantee. Recorded so the next person does not read
|
|
58
|
+
"end to end" as "under contention".
|
|
59
|
+
|
|
60
|
+
## [1.0.11] — 2026-10-17
|
|
61
|
+
|
|
62
|
+
**`w2m_memory {"action":"merge"}`: merge this machine's collective memory with the fleet's current blob, under the
|
|
63
|
+
operator's conflict rule, and still write nothing.**
|
|
64
|
+
|
|
65
|
+
### The rule, which is the operator's decision
|
|
66
|
+
|
|
67
|
+
> **默认最新的赢;如果是修复默认流程,最全 & 验证最全面的有最后修改权。**
|
|
68
|
+
>
|
|
69
|
+
> Default: the newest wins. But if it is a repair of the default flow, the most complete and most thoroughly verified
|
|
70
|
+
> one has final edit authority.
|
|
71
|
+
|
|
72
|
+
Two layers, and the second is the one that is easy to lose: **a declared repair outranks recency**, so a repaired
|
|
73
|
+
fact is not overwritten by a later-written older understanding. Between two repairs, the better-supported one wins.
|
|
74
|
+
|
|
75
|
+
### What is the operator's, and what is my operationalisation -- stated so it can be challenged
|
|
76
|
+
|
|
77
|
+
**Theirs:** the two layers above. **Mine, and it is in the source:**
|
|
78
|
+
|
|
79
|
+
* **The entry syntax.** There is no way to read "is this a repair of the default flow" out of prose; any attempt to
|
|
80
|
+
guess would overwrite correct versions with wrong ones. So a keyed entry declares it:
|
|
81
|
+
`- 〔id:<stable-id> fix evidence=<n> verified=<n>〕<the fact>`.
|
|
82
|
+
Entries without an `id` are prose: they are unioned and never adjudicated, because without an id there is no way to
|
|
83
|
+
tell "same fact, two versions" from "two different facts".
|
|
84
|
+
* **`completeness = evidence + 2 × verified`. The weight 2 is a judgement, not the operator's words.** One reproduced
|
|
85
|
+
fact outranks three unreproduced ones. It only applies when **both** sides declare `fix`.
|
|
86
|
+
|
|
87
|
+
**Every decision is recorded** as a comment in the merged text (`<!-- <id> -> local (repair): ... -->`), because a
|
|
88
|
+
merge that cannot explain its choices produces a document nobody can audit -- and the first sign of a wrong rule
|
|
89
|
+
would be a fact nobody can find.
|
|
90
|
+
|
|
91
|
+
### What it refuses
|
|
92
|
+
|
|
93
|
+
| Case | Behaviour |
|
|
94
|
+
|---|---|
|
|
95
|
+
| the same id twice **inside one document** | **refused**, not resolved: a broken document is not a conflict |
|
|
96
|
+
| a blob that fails its own hash | `W2M_BAD_BLOB`, and **nothing is merged** -- comparing unverified content would launder it |
|
|
97
|
+
| a pointer naming an evicted blob | reported as that, with the remedy (publish again) |
|
|
98
|
+
| no fleet document at all | reported as a state: publishing comes first |
|
|
99
|
+
|
|
100
|
+
### And it still writes nothing
|
|
101
|
+
|
|
102
|
+
The merged text comes back in the result; **the local file is not modified.** Half of a merge's input arrived over the
|
|
103
|
+
network, and letting one round trip rewrite the only local copy would hand a remote party a write into local memory.
|
|
104
|
+
|
|
105
|
+
### Verified
|
|
106
|
+
|
|
107
|
+
**13 tests in `test/fleet-merge.test.mjs`** exercise the rule as a rule, with no profile and no relay: the default
|
|
108
|
+
layer picks the newer publication; **a declared repair beats a NEWER non-repair** (the operator's override -- if this
|
|
109
|
+
test flips, the rule has silently become "newest wins"); two repairs are separated by completeness, then by
|
|
110
|
+
verification count, then by recency; a genuine tie resolves deterministically **and is recorded as a tie**; a
|
|
111
|
+
duplicated id is refused; prose unions; inputs are not mutated; an identical id produces no decision.
|
|
112
|
+
|
|
113
|
+
Full suite: see the pre-flight line. Pre-flight **GREEN, 15 of 15 checks**.
|
|
114
|
+
|
|
115
|
+
### NOT verified
|
|
116
|
+
|
|
117
|
+
The `merge` action **end to end** -- the same gap as `publish`/`pull`/`latest`/`diff`: the rule is proven, the
|
|
118
|
+
plumbing around it is not, because that needs a profile with a `fleet-memory.md` and a live relay.
|
|
119
|
+
|
|
5
120
|
## [1.0.10] — 2026-10-17
|
|
6
121
|
|
|
7
122
|
**`w2m_memory {"action":"diff"}`: compare this machine's collective memory against the fleet's current blob,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/w2m-dsh-plugin",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.12",
|
|
4
4
|
"description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 11 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -117,13 +117,13 @@
|
|
|
117
117
|
}
|
|
118
118
|
},
|
|
119
119
|
"scripts": {
|
|
120
|
-
"test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.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/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/ilink-shape.test.mjs test/chatops-fleet-join.test.mjs",
|
|
120
|
+
"test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/fleet-merge.test.mjs test/fleet-memory-e2e.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.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/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/ilink-shape.test.mjs test/chatops-fleet-join.test.mjs",
|
|
121
121
|
"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",
|
|
122
122
|
"test:relay": "node --test --test-force-exit test/relay.test.mjs",
|
|
123
123
|
"test:agent": "node --test --test-force-exit test/agent.test.mjs test/memory.test.mjs",
|
|
124
124
|
"test:tools": "node --test --test-force-exit test/tools.test.mjs",
|
|
125
125
|
"test:crossnetwork": "node --test --test-force-exit test/crossnetwork.test.mjs",
|
|
126
|
-
"test:all": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.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/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/ilink-shape.test.mjs test/chatops-fleet-join.test.mjs",
|
|
126
|
+
"test:all": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/fleet-merge.test.mjs test/fleet-memory-e2e.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.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/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/ilink-shape.test.mjs test/chatops-fleet-join.test.mjs",
|
|
127
127
|
"pack": "node scripts/pack.mjs",
|
|
128
128
|
"verify:join": "node scripts/verify-join.mjs",
|
|
129
129
|
"lint": "eslint .",
|
|
@@ -137,6 +137,8 @@
|
|
|
137
137
|
"test:blocks": "node --test --test-force-exit test/blocks.test.mjs",
|
|
138
138
|
"test:approval-index": "node --test --test-force-exit test/approval-index.test.mjs",
|
|
139
139
|
"test:fleet-sync": "node --test --test-force-exit test/fleet-sync.test.mjs",
|
|
140
|
+
"test:fleet-merge": "node --test --test-force-exit test/fleet-merge.test.mjs",
|
|
141
|
+
"test:fleet-memory-e2e": "node --test --test-force-exit test/fleet-memory-e2e.test.mjs",
|
|
140
142
|
"test:health": "node --test --test-force-exit test/health.test.mjs",
|
|
141
143
|
"test:storage": "node --test --test-force-exit test/storage.test.mjs",
|
|
142
144
|
"test:exit-channel": "node --test --test-force-exit test/exit-channel.test.mjs",
|
package/plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
3
|
"name": "twinsearth-w2m-dsh",
|
|
4
|
-
"version": "1.0.
|
|
4
|
+
"version": "1.0.12",
|
|
5
5
|
"description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 11 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"keywords": [
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fleet memory merge — the deterministic core of #2b.
|
|
3
|
+
*
|
|
4
|
+
* ## The rule, which is the operator's and not mine
|
|
5
|
+
*
|
|
6
|
+
* > **默认最新的赢;如果是修复默认流程,最全 & 验证最全面的有最后修改权。**
|
|
7
|
+
* > (Default: the newest wins. But if it is a repair of the default flow, the most complete and most thoroughly
|
|
8
|
+
* > verified one has final edit authority.)
|
|
9
|
+
*
|
|
10
|
+
* ## What is the operator's decision, and what is my operationalisation
|
|
11
|
+
*
|
|
12
|
+
* **The operator's:** the two layers. Newest wins by default; a declared repair of the default flow outranks
|
|
13
|
+
* recency. A repaired fact must not be overwritten by a later-written older understanding.
|
|
14
|
+
*
|
|
15
|
+
* **Mine, and it must be reviewable:**
|
|
16
|
+
*
|
|
17
|
+
* * the entry syntax (§3 of FLEET-SYNC-2B-MERGE-SPEC.md) — there is no way to read "is this a repair" out of
|
|
18
|
+
* prose, and guessing would overwrite correct versions with wrong ones;
|
|
19
|
+
* * `completeness = evidence + 2 * verified`. Verified evidence is a stronger signal than more unverified evidence
|
|
20
|
+
* — one reproduced fact beats three unreproduced ones. **The weight 2 is a judgement, not the operator's words**,
|
|
21
|
+
* and it only applies when both sides declare `fix`.
|
|
22
|
+
*
|
|
23
|
+
* ## Why nothing here is silent
|
|
24
|
+
*
|
|
25
|
+
* Every adjudicated id produces a decision record, and the decision records are appended to the output as comments.
|
|
26
|
+
* **A merge that cannot explain its own choices produces a document nobody can audit**, and the first sign of a
|
|
27
|
+
* wrong rule would be a fact nobody can find.
|
|
28
|
+
*
|
|
29
|
+
* @module fleet-merge
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** The bracket pair that marks an entry: U+3014 / U+3015. Chosen because they never occur in the prose. */
|
|
33
|
+
const OPEN = '〔';
|
|
34
|
+
const CLOSE = '〕';
|
|
35
|
+
/** `- 〔...〕content` -- built from the constants so a change to the pair cannot desynchronise the parser. */
|
|
36
|
+
const ENTRY_RE = new RegExp('^-\\s+' + OPEN + '([^' + CLOSE + ']*)' + CLOSE + '(.*)$');
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Parse one markdown line as a keyed entry.
|
|
40
|
+
*
|
|
41
|
+
* Only `- ` list items that begin with a `〔...〕` metadata bracket are entries. **Everything else is unkeyed prose**,
|
|
42
|
+
* and unkeyed lines never participate in adjudication: without an id there is no way to tell "these two lines are
|
|
43
|
+
* about the same thing" from "these two lines are different facts", and guessing produces conflicts that are not
|
|
44
|
+
* conflicts.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} line
|
|
47
|
+
* @returns {{id:string, fix:boolean, evidence:number, verified:number, content:string, raw:string}|null}
|
|
48
|
+
*/
|
|
49
|
+
export function parseEntry(line) {
|
|
50
|
+
if (typeof line !== 'string') return null;
|
|
51
|
+
const match = ENTRY_RE.exec(line);
|
|
52
|
+
if (match === null) return null;
|
|
53
|
+
const meta = match[1];
|
|
54
|
+
const content = match[2];
|
|
55
|
+
|
|
56
|
+
let id = null;
|
|
57
|
+
let fix = false;
|
|
58
|
+
let evidence = 0;
|
|
59
|
+
let verified = 0;
|
|
60
|
+
for (const token of meta.trim().split(/\s+/)) {
|
|
61
|
+
if (token.startsWith('id:')) {
|
|
62
|
+
id = token.slice(3).trim();
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (token === 'fix') { fix = true; continue; }
|
|
66
|
+
const eq = token.indexOf('=');
|
|
67
|
+
if (eq > 0) {
|
|
68
|
+
const key = token.slice(0, eq);
|
|
69
|
+
const value = Number(token.slice(eq + 1));
|
|
70
|
+
if (!Number.isInteger(value) || value < 0) continue;
|
|
71
|
+
if (key === 'evidence') evidence = value;
|
|
72
|
+
else if (key === 'verified') verified = value;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (id === null || id === '') return null;
|
|
76
|
+
return { id, fix, evidence, verified, content: content.trim(), raw: line };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* How much a declared repair has to show, for the "most complete and most thoroughly verified" half of the rule.
|
|
81
|
+
*
|
|
82
|
+
* @param {{evidence:number, verified:number}} entry
|
|
83
|
+
*/
|
|
84
|
+
export function completeness(entry) {
|
|
85
|
+
return entry.evidence + 2 * entry.verified;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Split a document into unkeyed prose and keyed entries.
|
|
90
|
+
*
|
|
91
|
+
* A duplicate id **within one document** is recorded rather than merged, because two entries claiming the same id in
|
|
92
|
+
* one document is a broken document, not a conflict to resolve.
|
|
93
|
+
*
|
|
94
|
+
* @param {string} text
|
|
95
|
+
* @returns {{prose:string[], entries:Map<string, object>, duplicates:string[]}}
|
|
96
|
+
*/
|
|
97
|
+
export function parseDocument(text) {
|
|
98
|
+
const prose = [];
|
|
99
|
+
const entries = new Map();
|
|
100
|
+
const duplicates = [];
|
|
101
|
+
for (const line of String(text ?? '').split('\n')) {
|
|
102
|
+
const entry = parseEntry(line);
|
|
103
|
+
if (entry === null) {
|
|
104
|
+
if (line.trim() !== '') prose.push(line);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (entries.has(entry.id)) {
|
|
108
|
+
if (!duplicates.includes(entry.id)) duplicates.push(entry.id);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
entries.set(entry.id, entry);
|
|
112
|
+
}
|
|
113
|
+
return { prose, entries, duplicates };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Decide one id, given the same entry from two documents.
|
|
118
|
+
*
|
|
119
|
+
* The order is fixed so the result never depends on iteration order:
|
|
120
|
+
*
|
|
121
|
+
* 1. only one side declares `fix` -> that side
|
|
122
|
+
* 2. both declare `fix` -> higher `completeness`
|
|
123
|
+
* 3. equal `completeness` -> higher `verified`
|
|
124
|
+
* 4. still equal -> newer (higher `addedAtMs`)
|
|
125
|
+
* 5. still equal -> lower hash, for determinism only, and the tie is recorded
|
|
126
|
+
*
|
|
127
|
+
* @returns {{winner:'local'|'fleet', layer:'repair'|'default'|'tie', reason:string}}
|
|
128
|
+
*/
|
|
129
|
+
export function decide(local, fleet, context) {
|
|
130
|
+
const { localAddedAtMs = 0, fleetAddedAtMs = 0, localHash = '', fleetHash = '' } = context;
|
|
131
|
+
|
|
132
|
+
if (local.fix !== fleet.fix) {
|
|
133
|
+
const winner = local.fix ? 'local' : 'fleet';
|
|
134
|
+
return {
|
|
135
|
+
winner,
|
|
136
|
+
layer: 'repair',
|
|
137
|
+
reason: `${winner} declares "fix" of the default flow, which outranks recency (the other side does not)`,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (local.fix && fleet.fix) {
|
|
142
|
+
const localScore = completeness(local);
|
|
143
|
+
const fleetScore = completeness(fleet);
|
|
144
|
+
if (localScore !== fleetScore) {
|
|
145
|
+
return {
|
|
146
|
+
winner: localScore > fleetScore ? 'local' : 'fleet',
|
|
147
|
+
layer: 'repair',
|
|
148
|
+
reason: `both declare "fix"; completeness ${localScore} vs ${fleetScore} (evidence + 2 x verified)`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
if (local.verified !== fleet.verified) {
|
|
152
|
+
return {
|
|
153
|
+
winner: local.verified > fleet.verified ? 'local' : 'fleet',
|
|
154
|
+
layer: 'repair',
|
|
155
|
+
reason: `both declare "fix" with equal completeness; verified ${local.verified} vs ${fleet.verified}`,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
// fall through to recency
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (localAddedAtMs !== fleetAddedAtMs) {
|
|
162
|
+
return {
|
|
163
|
+
winner: localAddedAtMs > fleetAddedAtMs ? 'local' : 'fleet',
|
|
164
|
+
layer: 'default',
|
|
165
|
+
reason: `default layer: the newer publication wins (${Math.max(localAddedAtMs, fleetAddedAtMs)} > ${Math.min(localAddedAtMs, fleetAddedAtMs)})`,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const winner = localHash < fleetHash ? 'local' : 'fleet';
|
|
170
|
+
return {
|
|
171
|
+
winner,
|
|
172
|
+
layer: 'tie',
|
|
173
|
+
reason: 'identical timestamps and scores; resolved by hash order for determinism only -- this is a genuine tie',
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Merge two documents under the rule above.
|
|
179
|
+
*
|
|
180
|
+
* **Never throws for a resolvable merge, never resolves something it cannot justify, and never invents content.**
|
|
181
|
+
*
|
|
182
|
+
* @param {{localText:string, localAddedAtMs?:number, localHash?:string,
|
|
183
|
+
* fleetText:string, fleetAddedAtMs?:number, fleetHash?:string}} input
|
|
184
|
+
* @returns {{text:string, decisions:{id:string,winner:string,layer:string,reason:string}[],
|
|
185
|
+
* unresolved:{id:string,reason:string}[], refused:{id:string,reason:string}[],
|
|
186
|
+
* added:string[], removed:string[], unchanged:number, wroteAnything:false}}
|
|
187
|
+
*/
|
|
188
|
+
export function mergeMemory(input) {
|
|
189
|
+
const {
|
|
190
|
+
localText, localAddedAtMs = 0, localHash = '',
|
|
191
|
+
fleetText, fleetAddedAtMs = 0, fleetHash = '',
|
|
192
|
+
} = input;
|
|
193
|
+
|
|
194
|
+
const local = parseDocument(localText);
|
|
195
|
+
const fleet = parseDocument(fleetText);
|
|
196
|
+
const context = { localAddedAtMs, fleetAddedAtMs, localHash, fleetHash };
|
|
197
|
+
|
|
198
|
+
const decisions = [];
|
|
199
|
+
const unresolved = [];
|
|
200
|
+
const refused = [];
|
|
201
|
+
|
|
202
|
+
/** ids that are broken (duplicated) in either document -- refused, never merged. */
|
|
203
|
+
const broken = new Set([...local.duplicates, ...fleet.duplicates]);
|
|
204
|
+
for (const id of broken) {
|
|
205
|
+
refused.push({ id, reason: 'this id appears more than once in one document; a broken document is not a conflict' });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const allIds = [...new Set([...local.entries.keys(), ...fleet.entries.keys()])];
|
|
209
|
+
const chosen = new Map();
|
|
210
|
+
let unchanged = 0;
|
|
211
|
+
|
|
212
|
+
for (const id of allIds) {
|
|
213
|
+
if (broken.has(id)) continue;
|
|
214
|
+
const l = local.entries.get(id);
|
|
215
|
+
const f = fleet.entries.get(id);
|
|
216
|
+
|
|
217
|
+
if (l !== undefined && f === undefined) { chosen.set(id, { entry: l, side: 'local' }); continue; }
|
|
218
|
+
if (l === undefined && f !== undefined) { chosen.set(id, { entry: f, side: 'fleet' }); continue; }
|
|
219
|
+
|
|
220
|
+
// Same id on both sides. Identical text is not a conflict.
|
|
221
|
+
if (l.raw === f.raw || l.content === f.content) { chosen.set(id, { entry: l, side: 'both' }); unchanged += 1; continue; }
|
|
222
|
+
|
|
223
|
+
const verdict = decide(l, f, context);
|
|
224
|
+
chosen.set(id, { entry: verdict.winner === 'local' ? l : f, side: verdict.winner });
|
|
225
|
+
decisions.push({ id, winner: verdict.winner, layer: verdict.layer, reason: verdict.reason });
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Prose is a union; it cannot conflict because it has no identity to conflict on.
|
|
229
|
+
const localProse = new Set(local.prose);
|
|
230
|
+
const proseOut = [...local.prose];
|
|
231
|
+
for (const line of fleet.prose) if (!localProse.has(line)) proseOut.push(line);
|
|
232
|
+
|
|
233
|
+
// Entries: local document order first (so the machine's own structure survives), then fleet-only ids in fleet order.
|
|
234
|
+
const entryLines = [];
|
|
235
|
+
for (const line of String(localText ?? '').split('\n')) {
|
|
236
|
+
const parsed = parseEntry(line);
|
|
237
|
+
if (parsed === null || !chosen.has(parsed.id)) continue;
|
|
238
|
+
const pick = chosen.get(parsed.id);
|
|
239
|
+
if (!entryLines.some((e) => e.id === parsed.id)) entryLines.push({ id: parsed.id, raw: pick.entry.raw });
|
|
240
|
+
}
|
|
241
|
+
for (const line of String(fleetText ?? '').split('\n')) {
|
|
242
|
+
const parsed = parseEntry(line);
|
|
243
|
+
if (parsed === null || !chosen.has(parsed.id)) continue;
|
|
244
|
+
if (!entryLines.some((e) => e.id === parsed.id)) entryLines.push({ id: parsed.id, raw: chosen.get(parsed.id).entry.raw });
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const audit = [
|
|
248
|
+
'<!-- fleet-merge: decisions are recorded so a wrong rule is visible after the fact -->',
|
|
249
|
+
...decisions.map((d) => `<!-- ${d.id} -> ${d.winner} (${d.layer}): ${d.reason} -->`),
|
|
250
|
+
...unresolved.map((u) => `<!-- ${u.id} UNRESOLVED: ${u.reason} -->`),
|
|
251
|
+
...refused.map((r) => `<!-- ${r.id} REFUSED: ${r.reason} -->`),
|
|
252
|
+
];
|
|
253
|
+
|
|
254
|
+
const text = [
|
|
255
|
+
...proseOut,
|
|
256
|
+
...entryLines.map((e) => e.raw),
|
|
257
|
+
...audit,
|
|
258
|
+
].join('\n');
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
text,
|
|
262
|
+
decisions,
|
|
263
|
+
unresolved,
|
|
264
|
+
refused,
|
|
265
|
+
added: [...fleet.entries.keys()].filter((id) => !local.entries.has(id) && !broken.has(id)),
|
|
266
|
+
removed: [...local.entries.keys()].filter((id) => !fleet.entries.has(id) && !broken.has(id)),
|
|
267
|
+
unchanged,
|
|
268
|
+
wroteAnything: false,
|
|
269
|
+
};
|
|
270
|
+
}
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -104,6 +104,9 @@ import { appendMemory, readMemorySync } from '../agent/memory.mjs';
|
|
|
104
104
|
// plugin asserting a claim on its own behalf, which is the sender's job.
|
|
105
105
|
import { acceptBlock, verifyBlock } from '../agent/blocks.mjs';
|
|
106
106
|
import { acceptWithAuthority } from '../agent/field-authority.mjs';
|
|
107
|
+
// v1.0.11: the merge rule (#2b) lives in its own module so the operator's two layers can be tested as a rule,
|
|
108
|
+
// exhaustively, without a profile or a relay.
|
|
109
|
+
import { mergeMemory } from './fleet-merge.mjs';
|
|
107
110
|
|
|
108
111
|
// The base-address rules live in one place (§2), shared with the agent side. Importing the - rather
|
|
109
112
|
// than re-deriving them her - is what stops the plugin and the agent from disagreeing about what
|
|
@@ -208,7 +211,7 @@ const MAX_STAGES = 16;
|
|
|
208
211
|
* `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
|
|
209
212
|
* overrides it at runtime for tests and packagers.
|
|
210
213
|
*/
|
|
211
|
-
const PLUGIN_VERSION = '1.0.
|
|
214
|
+
const PLUGIN_VERSION = '1.0.12';
|
|
212
215
|
|
|
213
216
|
/** How long any single HTTP call may take before it is aborted, in milliseconds. */
|
|
214
217
|
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|
|
@@ -5141,14 +5144,15 @@ export async function apply(ctx, config = {}) {
|
|
|
5141
5144
|
*/
|
|
5142
5145
|
action: {
|
|
5143
5146
|
type: 'string',
|
|
5144
|
-
enum: ['read', 'publish', 'pull', 'latest', 'diff'],
|
|
5147
|
+
enum: ['read', 'publish', 'pull', 'latest', 'diff', 'merge'],
|
|
5145
5148
|
description:
|
|
5146
5149
|
'read (default) = answer from memory. publish = upload the curated collective memory '
|
|
5147
5150
|
+ '(`fleet-memory.md`) to the relay as a content-addressed blob and return its SHA-256. latest = ask the '
|
|
5148
5151
|
+ 'relay which blob this fleet published last. pull = fetch one blob by SHA-256 -- or by "latest" -- and '
|
|
5149
5152
|
+ 'return its text, re-hashing it. diff = compare the local document against the fleet current blob and '
|
|
5150
|
-
+ 'report what differs
|
|
5151
|
-
+ '
|
|
5153
|
+
+ 'report what differs. merge = apply the declared conflict rule (newest wins by default; a declared '
|
|
5154
|
+
+ 'repair of the default flow outranks recency) and return the merged text with every decision recorded. '
|
|
5155
|
+
+ '**No action modifies the local file.**',
|
|
5152
5156
|
default: 'read',
|
|
5153
5157
|
},
|
|
5154
5158
|
hash: {
|
|
@@ -5609,6 +5613,116 @@ export async function apply(ctx, config = {}) {
|
|
|
5609
5613
|
});
|
|
5610
5614
|
}
|
|
5611
5615
|
|
|
5616
|
+
/**
|
|
5617
|
+
* v1.0.11 (#2b): merge this machine's collective memory with the fleet's current blob, **under the operator's
|
|
5618
|
+
* rule**, and still write nothing.
|
|
5619
|
+
*
|
|
5620
|
+
* ## The rule (the operator's, quoted)
|
|
5621
|
+
*
|
|
5622
|
+
* > 默认最新的赢;如果是修复默认流程,最全 & 验证最全面的有最后修改权。
|
|
5623
|
+
*
|
|
5624
|
+
* The two layers are implemented in `src/plugin/fleet-merge.mjs`: newest publication wins by default, and a
|
|
5625
|
+
* declared repair of the default flow outranks recency. **The operationalisation is that module's; this branch
|
|
5626
|
+
* only supplies the inputs.**
|
|
5627
|
+
*
|
|
5628
|
+
* ## Why the result is returned rather than written
|
|
5629
|
+
*
|
|
5630
|
+
* The local file is this machine's only copy, and half of a merge's input arrived over the network. **Letting
|
|
5631
|
+
* one round trip rewrite it would hand a remote party a write into local memory.** So the merged text comes
|
|
5632
|
+
* back in the result, decisions and all, and the caller decides.
|
|
5633
|
+
*
|
|
5634
|
+
* ## Recency inputs, named
|
|
5635
|
+
*
|
|
5636
|
+
* `localAddedAtMs` is the file's mtime; `fleetAddedAtMs` is the relay's `published_at` for the pointer's blob.
|
|
5637
|
+
* Both are compared as milliseconds. **Neither is a claim about content** -- which is exactly why they are only
|
|
5638
|
+
* the tie-break of last resort under the default layer.
|
|
5639
|
+
*/
|
|
5640
|
+
if (typeof args?.action === 'string' && args.action === 'merge') {
|
|
5641
|
+
const file = collectivePath();
|
|
5642
|
+
if (file === null) {
|
|
5643
|
+
throw new W2MError('W2M_CONFIG', 'W2M_CONFIG: there is no profile to merge collective memory from', {
|
|
5644
|
+
hint: 'the collective layer lives beside the profile that mounted this plugin',
|
|
5645
|
+
});
|
|
5646
|
+
}
|
|
5647
|
+
let localBytes;
|
|
5648
|
+
let localStat;
|
|
5649
|
+
try {
|
|
5650
|
+
localBytes = await fs.readFile(file);
|
|
5651
|
+
localStat = await fs.stat(file);
|
|
5652
|
+
} catch {
|
|
5653
|
+
throw new W2MError('W2M_NOT_FOUND', `W2M_NOT_FOUND: no collective memory to merge at ${file}`, {
|
|
5654
|
+
hint: 'merge needs a local document; it compares against the fleet copy, it does not create one',
|
|
5655
|
+
});
|
|
5656
|
+
}
|
|
5657
|
+
const localHash = createHash('sha256').update(localBytes).digest('hex');
|
|
5658
|
+
const base = requireRabbit(cfg);
|
|
5659
|
+
const token = await resolveToken(cfg);
|
|
5660
|
+
|
|
5661
|
+
const pointer = await request({ rabbitUrl: base, token, pathname: '/v1/fleet/blobs/latest', signal: exec?.signal });
|
|
5662
|
+
if (pointer.status === 404) {
|
|
5663
|
+
return JSON.stringify({
|
|
5664
|
+
ok: true, merged: false, wrote_anything: false,
|
|
5665
|
+
local: { hash: localHash, bytes: localBytes.length, file },
|
|
5666
|
+
reason: 'this fleet has published no blob yet, so there is nothing to merge with. Publishing comes first.',
|
|
5667
|
+
});
|
|
5668
|
+
}
|
|
5669
|
+
if (pointer.status !== 200) {
|
|
5670
|
+
throw new W2MError('W2M_RABBIT_REFUSED', `W2M_RABBIT_REFUSED: GET /v1/fleet/blobs/latest answered HTTP ${pointer.status}`, {
|
|
5671
|
+
http_status: pointer.status, detail: pointer.error ?? null,
|
|
5672
|
+
});
|
|
5673
|
+
}
|
|
5674
|
+
const fleetHash = String(pointer.json?.hash ?? '');
|
|
5675
|
+
const fleetRes = await request({
|
|
5676
|
+
rabbitUrl: base, token, pathname: `/v1/fleet/blobs/${fleetHash}`,
|
|
5677
|
+
accept: 'application/octet-stream', signal: exec?.signal,
|
|
5678
|
+
});
|
|
5679
|
+
if (fleetRes.status !== 200) {
|
|
5680
|
+
throw new W2MError('W2M_NOT_FOUND', `W2M_NOT_FOUND: the fleet pointer names ${fleetHash}, which answered HTTP ${fleetRes.status}`, {
|
|
5681
|
+
hint: 'the pointer is a hint (see #3); oldest-first eviction can remove the blob it names',
|
|
5682
|
+
prescribed: 'publish from a machine in this fleet to establish a current document again',
|
|
5683
|
+
});
|
|
5684
|
+
}
|
|
5685
|
+
const fleetText = fleetRes.text ?? '';
|
|
5686
|
+
const fleetActual = createHash('sha256').update(fleetText, 'utf8').digest('hex');
|
|
5687
|
+
if (fleetActual !== fleetHash) {
|
|
5688
|
+
throw new W2MError('W2M_BAD_BLOB', `W2M_BAD_BLOB: the fleet blob ${fleetHash} hashes to ${fleetActual} instead`, {
|
|
5689
|
+
hint: 'refusing to merge unverified content', expected: fleetHash, actual: fleetActual,
|
|
5690
|
+
});
|
|
5691
|
+
}
|
|
5692
|
+
|
|
5693
|
+
const fleetPublishedAt = Date.parse(String(pointer.json?.published_at ?? ''));
|
|
5694
|
+
const outcome = mergeMemory({
|
|
5695
|
+
localText: localBytes.toString('utf8'),
|
|
5696
|
+
localAddedAtMs: Number.isFinite(localStat.mtimeMs) ? localStat.mtimeMs : 0,
|
|
5697
|
+
localHash,
|
|
5698
|
+
fleetText,
|
|
5699
|
+
fleetAddedAtMs: Number.isFinite(fleetPublishedAt) ? fleetPublishedAt : 0,
|
|
5700
|
+
fleetHash,
|
|
5701
|
+
});
|
|
5702
|
+
|
|
5703
|
+
return JSON.stringify({
|
|
5704
|
+
ok: true,
|
|
5705
|
+
merged: true,
|
|
5706
|
+
wrote_anything: false,
|
|
5707
|
+
rule: 'default: newest publication wins; a declared repair of the default flow outranks recency, and between '
|
|
5708
|
+
+ 'two repairs the one with the higher (evidence + 2 x verified) wins',
|
|
5709
|
+
local: { hash: localHash, bytes: localBytes.length, file, added_at_ms: Math.round(localStat.mtimeMs) },
|
|
5710
|
+
fleet: {
|
|
5711
|
+
hash: fleetHash, bytes: Buffer.byteLength(fleetText, 'utf8'),
|
|
5712
|
+
published_at: pointer.json?.published_at ?? null, published_by: pointer.json?.published_by ?? null,
|
|
5713
|
+
},
|
|
5714
|
+
decisions: outcome.decisions,
|
|
5715
|
+
unresolved: outcome.unresolved,
|
|
5716
|
+
refused: outcome.refused,
|
|
5717
|
+
unchanged: outcome.unchanged,
|
|
5718
|
+
added: outcome.added,
|
|
5719
|
+
removed: outcome.removed,
|
|
5720
|
+
merged_text: outcome.text,
|
|
5721
|
+
next: 'this result is text only. Writing it back is the caller\'s decision -- a merge does not edit the '
|
|
5722
|
+
+ 'document it read.',
|
|
5723
|
+
});
|
|
5724
|
+
}
|
|
5725
|
+
|
|
5612
5726
|
const limitRaw = Number(args?.limit);
|
|
5613
5727
|
const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(Math.floor(limitRaw), 500) : 20;
|
|
5614
5728
|
|
package/src/relay/state.mjs
CHANGED
|
@@ -560,7 +560,20 @@ export class RabbitState {
|
|
|
560
560
|
}
|
|
561
561
|
const scope = this.scopeBlobFleet(device);
|
|
562
562
|
if (scope.ok !== true) return { ok: false, reason: scope.reason, detail: scope.text };
|
|
563
|
-
|
|
563
|
+
/**
|
|
564
|
+
* v1.0.12: `publishedBy` comes from **the authenticated device, never from the request body.**
|
|
565
|
+
*
|
|
566
|
+
* The relay knows which device is writing; the request does not get a say, for the same reason `machine_id` is
|
|
567
|
+
* read off the token in the P2P announce route -- a caller that could name itself in the body could attribute a
|
|
568
|
+
* publication to a machine that never made it.
|
|
569
|
+
*
|
|
570
|
+
* An end-to-end test caught this field being `null`: the pointer store accepted a `publishedBy` argument that
|
|
571
|
+
* nothing supplied, so `latest` reported `published_by: null` for a publication whose author was right there.
|
|
572
|
+
*/
|
|
573
|
+
return this._persist.putBlob(bytes, {
|
|
574
|
+
fleetId: scope.fleetId,
|
|
575
|
+
publishedBy: typeof device?.machine_id === 'string' ? device.machine_id : null,
|
|
576
|
+
});
|
|
564
577
|
}
|
|
565
578
|
|
|
566
579
|
/**
|