@poa-box/agent 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.agent.template +20 -0
- package/README.md +46 -0
- package/brain/Config/agent-config.json +14 -0
- package/brain/Config/brain-allowlist.json +20 -0
- package/brain/Identity/goals.template.md +23 -0
- package/brain/Identity/how-i-think.md +406 -0
- package/brain/Identity/who-i-am.template.md +34 -0
- package/brain/Knowledge/BOOTSTRAP.md +66 -0
- package/brain/Knowledge/audit-corpus-index.json +406 -0
- package/brain/Knowledge/discussions.json +245 -0
- package/brain/Knowledge/pop.brain.brainstorms.generated.md +48 -0
- package/brain/Knowledge/pop.brain.brainstorms.genesis.bin +0 -0
- package/brain/Knowledge/pop.brain.heuristics.snapshot.bin +0 -0
- package/brain/Knowledge/pop.brain.projects.generated.md +16 -0
- package/brain/Knowledge/pop.brain.projects.genesis.bin +0 -0
- package/brain/Knowledge/pop.brain.retros.generated.md +91 -0
- package/brain/Knowledge/pop.brain.retros.genesis.bin +0 -0
- package/brain/Knowledge/pop.brain.shared.generated.md +3811 -0
- package/brain/Knowledge/pop.brain.shared.genesis.bin +0 -0
- package/brain/Knowledge/projects.md +181 -0
- package/brain/Knowledge/risk-framework.md +90 -0
- package/brain/Knowledge/shared.md +416 -0
- package/brain/Knowledge/sprint-priorities.md +439 -0
- package/brain/Memory/.gitkeep +0 -0
- package/dist/commands/agent/daily-digest.d.ts +24 -0
- package/dist/commands/agent/daily-digest.js +336 -0
- package/dist/commands/agent/delegate.d.ts +12 -0
- package/dist/commands/agent/delegate.js +91 -0
- package/dist/commands/agent/deploy-to-org.d.ts +20 -0
- package/dist/commands/agent/deploy-to-org.js +154 -0
- package/dist/commands/agent/index.d.ts +2 -0
- package/dist/commands/agent/index.js +27 -0
- package/dist/commands/agent/init.d.ts +19 -0
- package/dist/commands/agent/init.js +303 -0
- package/dist/commands/agent/onboard.d.ts +22 -0
- package/dist/commands/agent/onboard.js +192 -0
- package/dist/commands/agent/paymaster-status.d.ts +14 -0
- package/dist/commands/agent/paymaster-status.js +130 -0
- package/dist/commands/agent/register.d.ts +21 -0
- package/dist/commands/agent/register.js +116 -0
- package/dist/commands/agent/setup-sponsorship.d.ts +22 -0
- package/dist/commands/agent/setup-sponsorship.js +154 -0
- package/dist/commands/agent/status.d.ts +12 -0
- package/dist/commands/agent/status.js +171 -0
- package/dist/commands/agent/triage.d.ts +12 -0
- package/dist/commands/agent/triage.js +503 -0
- package/dist/commands/brain/advance-stage.d.ts +42 -0
- package/dist/commands/brain/advance-stage.js +206 -0
- package/dist/commands/brain/allowlist.d.ts +30 -0
- package/dist/commands/brain/allowlist.js +274 -0
- package/dist/commands/brain/append-lesson.d.ts +55 -0
- package/dist/commands/brain/append-lesson.js +245 -0
- package/dist/commands/brain/brainstorm.d.ts +154 -0
- package/dist/commands/brain/brainstorm.js +573 -0
- package/dist/commands/brain/daemon.d.ts +31 -0
- package/dist/commands/brain/daemon.js +348 -0
- package/dist/commands/brain/doctor.d.ts +27 -0
- package/dist/commands/brain/doctor.js +497 -0
- package/dist/commands/brain/edit-lesson.d.ts +51 -0
- package/dist/commands/brain/edit-lesson.js +248 -0
- package/dist/commands/brain/import-snapshot.d.ts +68 -0
- package/dist/commands/brain/import-snapshot.js +177 -0
- package/dist/commands/brain/index.d.ts +2 -0
- package/dist/commands/brain/index.js +67 -0
- package/dist/commands/brain/list.d.ts +21 -0
- package/dist/commands/brain/list.js +83 -0
- package/dist/commands/brain/migrate-projects.d.ts +44 -0
- package/dist/commands/brain/migrate-projects.js +209 -0
- package/dist/commands/brain/migrate.d.ts +74 -0
- package/dist/commands/brain/migrate.js +306 -0
- package/dist/commands/brain/new-project.d.ts +53 -0
- package/dist/commands/brain/new-project.js +226 -0
- package/dist/commands/brain/read.d.ts +24 -0
- package/dist/commands/brain/read.js +81 -0
- package/dist/commands/brain/remove-lesson.d.ts +47 -0
- package/dist/commands/brain/remove-lesson.js +206 -0
- package/dist/commands/brain/remove-project.d.ts +36 -0
- package/dist/commands/brain/remove-project.js +177 -0
- package/dist/commands/brain/retro-file-tasks.d.ts +84 -0
- package/dist/commands/brain/retro-file-tasks.js +372 -0
- package/dist/commands/brain/retro-list.d.ts +28 -0
- package/dist/commands/brain/retro-list.js +125 -0
- package/dist/commands/brain/retro-mark-change.d.ts +58 -0
- package/dist/commands/brain/retro-mark-change.js +176 -0
- package/dist/commands/brain/retro-remove.d.ts +36 -0
- package/dist/commands/brain/retro-remove.js +142 -0
- package/dist/commands/brain/retro-respond.d.ts +56 -0
- package/dist/commands/brain/retro-respond.js +250 -0
- package/dist/commands/brain/retro-show.d.ts +23 -0
- package/dist/commands/brain/retro-show.js +100 -0
- package/dist/commands/brain/retro-start.d.ts +55 -0
- package/dist/commands/brain/retro-start.js +311 -0
- package/dist/commands/brain/search.d.ts +48 -0
- package/dist/commands/brain/search.js +190 -0
- package/dist/commands/brain/snapshot.d.ts +32 -0
- package/dist/commands/brain/snapshot.js +243 -0
- package/dist/commands/brain/status.d.ts +15 -0
- package/dist/commands/brain/status.js +166 -0
- package/dist/commands/brain/subscribe.d.ts +28 -0
- package/dist/commands/brain/subscribe.js +90 -0
- package/dist/commands/brain/tag.d.ts +46 -0
- package/dist/commands/brain/tag.js +192 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +22 -0
- package/dist/lib/brain-daemon.d.ts +126 -0
- package/dist/lib/brain-daemon.js +811 -0
- package/dist/lib/brain-membership.d.ts +58 -0
- package/dist/lib/brain-membership.js +115 -0
- package/dist/lib/brain-migrate-projects.d.ts +43 -0
- package/dist/lib/brain-migrate-projects.js +247 -0
- package/dist/lib/brain-migrate.d.ts +77 -0
- package/dist/lib/brain-migrate.js +328 -0
- package/dist/lib/brain-ops.d.ts +271 -0
- package/dist/lib/brain-ops.js +571 -0
- package/dist/lib/brain-paths.d.ts +15 -0
- package/dist/lib/brain-paths.js +33 -0
- package/dist/lib/brain-projections.d.ts +216 -0
- package/dist/lib/brain-projections.js +829 -0
- package/dist/lib/brain-schemas.d.ts +36 -0
- package/dist/lib/brain-schemas.js +316 -0
- package/dist/lib/brain-signing.d.ts +103 -0
- package/dist/lib/brain-signing.js +256 -0
- package/dist/lib/brain.d.ts +198 -0
- package/dist/lib/brain.js +1057 -0
- package/dist/pop-agent.d.ts +1 -0
- package/dist/pop-agent.js +18 -0
- package/docs/agent.md +126 -0
- package/docs/agents/brain-anti-entropy.md +127 -0
- package/docs/agents/brain-cross-device-onboarding.md +210 -0
- package/docs/agents/brain-cross-machine-smoke.md +241 -0
- package/docs/agents/brain-layer-setup.md +725 -0
- package/docs/agents/offboarding-protocol.md +188 -0
- package/docs/agents/onboarding-protocol.md +243 -0
- package/docs/agents/running-an-agent.md +200 -0
- package/docs/brain.md +560 -0
- package/package.json +61 -0
- package/scripts/apply.sh +140 -0
- package/scripts/onboard.sh +205 -0
- package/scripts/setup-agent.ts +272 -0
|
@@ -0,0 +1,811 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Brain daemon — persistent libp2p process that keeps gossipsub alive between
|
|
4
|
+
* agent CLI invocations and periodically re-broadcasts the local manifest's
|
|
5
|
+
* head CIDs so peers coming online can catch up.
|
|
6
|
+
*
|
|
7
|
+
* ## Why
|
|
8
|
+
*
|
|
9
|
+
* The brain layer's HB#322 dogfood finding: three consecutive vigil_01 brain
|
|
10
|
+
* writes were invisible to argus_prime because agent sessions run in separate
|
|
11
|
+
* 15-min cron slots and never overlap in wall-clock time. Gossipsub is
|
|
12
|
+
* broadcast-only (no store-and-forward), so announcements published while a
|
|
13
|
+
* peer is offline vanish permanently. Every agent ended up with a per-agent
|
|
14
|
+
* append-only journal, not a shared substrate.
|
|
15
|
+
*
|
|
16
|
+
* Hudson's HB#322 directive: fix it properly with a long-running daemon. No
|
|
17
|
+
* shortcuts. Model it on the go-ds-crdt reference architecture
|
|
18
|
+
* (github.com/ipfs/go-ds-crdt, examples/globaldb/globaldb.go).
|
|
19
|
+
*
|
|
20
|
+
* ## Design (mapped from go-ds-crdt)
|
|
21
|
+
*
|
|
22
|
+
* go-ds-crdt | This module
|
|
23
|
+
* -----------------------------|--------------------------------------
|
|
24
|
+
* Datastore (long-lived) | runDaemon() — single process per agent
|
|
25
|
+
* Broadcaster (PubSub) | existing publishBrainHead / subscribeBrainTopic
|
|
26
|
+
* DAGSyncer (DAGService) | existing Helia blockstore + Bitswap
|
|
27
|
+
* RebroadcastInterval = 1m | REBROADCAST_INTERVAL_MS = 60_000
|
|
28
|
+
* keepalive netTopic (20s) | KEEPALIVE_TOPIC + 20s interval
|
|
29
|
+
* seenHeads map | DEFERRED — v1 rebroadcasts unconditionally
|
|
30
|
+
* RepairInterval = 1h | DEFERRED — MVP envelope is snapshot-per-write
|
|
31
|
+
* | so there's no DAG to walk
|
|
32
|
+
* signal handling | SIGTERM / SIGINT / SIGHUP
|
|
33
|
+
* PutHook / DeleteHook | DEFERRED — v2 adds IPC routing so existing
|
|
34
|
+
* | commands become clients
|
|
35
|
+
*
|
|
36
|
+
* ## Process model
|
|
37
|
+
*
|
|
38
|
+
* pop brain daemon start parent spawns a detached child running
|
|
39
|
+
* `node dist/index.js brain daemon __run`;
|
|
40
|
+
* parent writes PID file and exits
|
|
41
|
+
* pop brain daemon __run the child entrypoint, calls runDaemon()
|
|
42
|
+
* pop brain daemon stop reads PID, sends SIGTERM, waits cleanup
|
|
43
|
+
* pop brain daemon status reads PID, checks liveness, opens Unix
|
|
44
|
+
* socket, sends {method: "status"}, prints
|
|
45
|
+
* pop brain daemon logs tails daemon.log
|
|
46
|
+
*
|
|
47
|
+
* ${POP_BRAIN_HOME}/daemon.pid parent-written PID file
|
|
48
|
+
* ${POP_BRAIN_HOME}/daemon.sock Unix socket for IPC (mode 0600)
|
|
49
|
+
* ${POP_BRAIN_HOME}/daemon.log append-only log
|
|
50
|
+
*
|
|
51
|
+
* ## IPC protocol
|
|
52
|
+
*
|
|
53
|
+
* Newline-delimited JSON over Unix socket. Each request is one line:
|
|
54
|
+
*
|
|
55
|
+
* {"id": "1", "method": "status"}
|
|
56
|
+
*
|
|
57
|
+
* Each response is one line:
|
|
58
|
+
*
|
|
59
|
+
* {"id": "1", "result": {...}}
|
|
60
|
+
* {"id": "1", "error": "..."}
|
|
61
|
+
*
|
|
62
|
+
* First-ship methods: status. Second-ship methods: appendLesson, readDoc,
|
|
63
|
+
* snapshot — those let existing CLI commands route through a running daemon
|
|
64
|
+
* instead of spinning up their own libp2p.
|
|
65
|
+
*/
|
|
66
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
67
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
68
|
+
};
|
|
69
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
70
|
+
exports.BrainIpcError = exports.CANONICAL_BRAIN_DOCS = exports.KEEPALIVE_TOPIC = exports.REDIAL_INTERVAL_MS = exports.KEEPALIVE_INTERVAL_MS = exports.REBROADCAST_GRACE_MS = exports.REBROADCAST_JITTER = exports.REBROADCAST_INTERVAL_MS = void 0;
|
|
71
|
+
exports.getDaemonPidPath = getDaemonPidPath;
|
|
72
|
+
exports.getDaemonSockPath = getDaemonSockPath;
|
|
73
|
+
exports.getDaemonLogPath = getDaemonLogPath;
|
|
74
|
+
exports.getRunningDaemonPid = getRunningDaemonPid;
|
|
75
|
+
exports.runDaemon = runDaemon;
|
|
76
|
+
exports.sendIpcRequest = sendIpcRequest;
|
|
77
|
+
const net_1 = __importDefault(require("net"));
|
|
78
|
+
const path_1 = require("path");
|
|
79
|
+
const fs_1 = require("fs");
|
|
80
|
+
const ethers_1 = require("ethers");
|
|
81
|
+
const brain_1 = require("./brain");
|
|
82
|
+
exports.REBROADCAST_INTERVAL_MS = 60_000;
|
|
83
|
+
// T1 (task #429): anti-entropy tuning knobs.
|
|
84
|
+
// Jitter randomizes each interval by ±(JITTER*100)% so a 3-agent fleet
|
|
85
|
+
// does not lockstep-rebroadcast. Grace suppresses re-publishing a head we
|
|
86
|
+
// just received from a peer — avoids amplification when all agents hold
|
|
87
|
+
// identical state.
|
|
88
|
+
exports.REBROADCAST_JITTER = 0.3;
|
|
89
|
+
exports.REBROADCAST_GRACE_MS = 5_000;
|
|
90
|
+
exports.KEEPALIVE_INTERVAL_MS = 20_000;
|
|
91
|
+
// HB#365: default peer redial interval. Daemon periodically checks each
|
|
92
|
+
// POP_BRAIN_PEERS entry and re-dials any that are not currently in the
|
|
93
|
+
// active connection set. Fixes the "peer drops after one side reboots"
|
|
94
|
+
// fragility: without redial, the cross-device setup becomes manual-restart
|
|
95
|
+
// after any transient disconnect. 30s is a conservative default that's
|
|
96
|
+
// short enough to recover from a reboot within one heartbeat cycle.
|
|
97
|
+
// Override with POP_BRAIN_REDIAL_INTERVAL_MS.
|
|
98
|
+
exports.REDIAL_INTERVAL_MS = 30_000;
|
|
99
|
+
exports.KEEPALIVE_TOPIC = 'pop/brain/net/v1';
|
|
100
|
+
/**
|
|
101
|
+
* Canonical brain docs every daemon subscribes to at startup regardless
|
|
102
|
+
* of local manifest state. A fresh brain home has an empty manifest, so
|
|
103
|
+
* without this list the daemon would not subscribe to any doc topics
|
|
104
|
+
* and could never receive remote head announcements for
|
|
105
|
+
* `pop.brain.shared` / `pop.brain.projects` until after its first
|
|
106
|
+
* local write.
|
|
107
|
+
*
|
|
108
|
+
* Adding a new canonical doc here makes every daemon pick it up on
|
|
109
|
+
* next restart. To experiment with a non-canonical doc, just perform a
|
|
110
|
+
* local write via `pop brain append-lesson --doc <id>` — the write
|
|
111
|
+
* path adds the doc to the manifest, and the next daemon loop iteration
|
|
112
|
+
* picks it up via listBrainDocs().
|
|
113
|
+
*/
|
|
114
|
+
exports.CANONICAL_BRAIN_DOCS = [
|
|
115
|
+
'pop.brain.shared',
|
|
116
|
+
'pop.brain.projects',
|
|
117
|
+
];
|
|
118
|
+
function getDaemonPidPath() {
|
|
119
|
+
return (0, path_1.join)((0, brain_1.getBrainHome)(), 'daemon.pid');
|
|
120
|
+
}
|
|
121
|
+
function getDaemonSockPath() {
|
|
122
|
+
return (0, path_1.join)((0, brain_1.getBrainHome)(), 'daemon.sock');
|
|
123
|
+
}
|
|
124
|
+
function getDaemonLogPath() {
|
|
125
|
+
return (0, path_1.join)((0, brain_1.getBrainHome)(), 'daemon.log');
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Check if a daemon appears to be running for this brain home.
|
|
129
|
+
* Returns the PID if alive, null otherwise. Cleans up stale PID files.
|
|
130
|
+
*/
|
|
131
|
+
function getRunningDaemonPid() {
|
|
132
|
+
const p = getDaemonPidPath();
|
|
133
|
+
if (!(0, fs_1.existsSync)(p))
|
|
134
|
+
return null;
|
|
135
|
+
let pid;
|
|
136
|
+
try {
|
|
137
|
+
pid = parseInt((0, fs_1.readFileSync)(p, 'utf8').trim(), 10);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
if (!Number.isFinite(pid) || pid <= 0)
|
|
143
|
+
return null;
|
|
144
|
+
try {
|
|
145
|
+
// Signal 0 probes for process existence without actually signaling.
|
|
146
|
+
process.kill(pid, 0);
|
|
147
|
+
return pid;
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
if (err.code === 'ESRCH') {
|
|
151
|
+
// Stale PID file — process is gone. Clean up.
|
|
152
|
+
try {
|
|
153
|
+
(0, fs_1.unlinkSync)(p);
|
|
154
|
+
}
|
|
155
|
+
catch { }
|
|
156
|
+
const sp = getDaemonSockPath();
|
|
157
|
+
if ((0, fs_1.existsSync)(sp)) {
|
|
158
|
+
try {
|
|
159
|
+
(0, fs_1.unlinkSync)(sp);
|
|
160
|
+
}
|
|
161
|
+
catch { }
|
|
162
|
+
}
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
// EPERM or other — process exists but we can't signal it.
|
|
166
|
+
return pid;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Run the daemon event loop. Blocks until a termination signal arrives.
|
|
171
|
+
* This is the __run entrypoint invoked by the detached child process.
|
|
172
|
+
*
|
|
173
|
+
* NEVER call this from the parent CLI path — it will never return.
|
|
174
|
+
*/
|
|
175
|
+
async function runDaemon() {
|
|
176
|
+
const home = (0, brain_1.getBrainHome)();
|
|
177
|
+
const pidPath = getDaemonPidPath();
|
|
178
|
+
const sockPath = getDaemonSockPath();
|
|
179
|
+
const logPath = getDaemonLogPath();
|
|
180
|
+
// Guard against double-start. The parent already checked via
|
|
181
|
+
// getRunningDaemonPid(), but this is a second line of defense for
|
|
182
|
+
// direct __run invocations.
|
|
183
|
+
const existingPid = getRunningDaemonPid();
|
|
184
|
+
if (existingPid !== null && existingPid !== process.pid) {
|
|
185
|
+
throw new Error(`Brain daemon already running with PID ${existingPid} for ${home}`);
|
|
186
|
+
}
|
|
187
|
+
// Author address is derived from POP_PRIVATE_KEY, same as applyBrainChange.
|
|
188
|
+
// Used only for the rebroadcast envelope — the head CID itself is the
|
|
189
|
+
// authenticated payload, so the announcement "author" is metadata.
|
|
190
|
+
let authorAddress;
|
|
191
|
+
try {
|
|
192
|
+
const key = process.env.POP_PRIVATE_KEY;
|
|
193
|
+
if (!key)
|
|
194
|
+
throw new Error('POP_PRIVATE_KEY not set');
|
|
195
|
+
authorAddress = new ethers_1.ethers.Wallet(key).address.toLowerCase();
|
|
196
|
+
}
|
|
197
|
+
catch (err) {
|
|
198
|
+
throw new Error(`Brain daemon cannot start: ${err.message}. Set POP_PRIVATE_KEY in your env.`);
|
|
199
|
+
}
|
|
200
|
+
const logStream = (0, fs_1.createWriteStream)(logPath, { flags: 'a' });
|
|
201
|
+
const log = (msg) => {
|
|
202
|
+
const line = `${new Date().toISOString()} [${process.pid}] ${msg}\n`;
|
|
203
|
+
logStream.write(line);
|
|
204
|
+
};
|
|
205
|
+
log(`daemon starting — home=${home} author=${authorAddress}`);
|
|
206
|
+
// HB#324: do NOT write the PID file yet. A fast-following CLI command
|
|
207
|
+
// that sees the PID file will try to open the IPC socket, which may
|
|
208
|
+
// not yet exist. The startup race has to be closed by writing the PID
|
|
209
|
+
// file AFTER the IPC server is listening. See the end of this function
|
|
210
|
+
// for the actual PID file write.
|
|
211
|
+
// Initialize libp2p once for the whole daemon lifetime. All CLI
|
|
212
|
+
// commands invoked while the daemon is up will (eventually) route
|
|
213
|
+
// through IPC instead of spinning up their own node.
|
|
214
|
+
const node = await (0, brain_1.initBrainNode)();
|
|
215
|
+
log(`libp2p up — peer=${node.libp2p.peerId.toString()}`);
|
|
216
|
+
const stats = {
|
|
217
|
+
startedAt: Date.now(),
|
|
218
|
+
rebroadcastCount: 0,
|
|
219
|
+
rebroadcastsSuppressedBySeen: 0,
|
|
220
|
+
keepaliveCount: 0,
|
|
221
|
+
lastRebroadcastAt: null,
|
|
222
|
+
lastKeepaliveAt: null,
|
|
223
|
+
incomingAnnouncements: 0,
|
|
224
|
+
incomingMerges: 0,
|
|
225
|
+
incomingRejects: 0,
|
|
226
|
+
};
|
|
227
|
+
// T1 (task #429): seen-heads tracking for anti-entropy suppression.
|
|
228
|
+
// When an announcement arrives from a peer, record (docId, cid, receivedAt).
|
|
229
|
+
// The rebroadcast loop checks this map before publishing: if a head was
|
|
230
|
+
// received from any peer less than GRACE_MS ago, suppress the rebroadcast —
|
|
231
|
+
// another agent already did the work, no need to amplify. Keyed by
|
|
232
|
+
// "docId|cid" for O(1) lookup.
|
|
233
|
+
const seenHeads = new Map();
|
|
234
|
+
const seenKey = (docId, cid) => `${docId}|${cid}`;
|
|
235
|
+
// Anti-entropy tuning — read from env, fall back to module defaults.
|
|
236
|
+
// Setting POP_BRAIN_REBROADCAST_INTERVAL_MS=0 disables the loop entirely
|
|
237
|
+
// (useful for unit tests that want deterministic write-path behavior).
|
|
238
|
+
const rebroadcastIntervalMs = (() => {
|
|
239
|
+
const raw = process.env.POP_BRAIN_REBROADCAST_INTERVAL_MS;
|
|
240
|
+
if (raw === undefined)
|
|
241
|
+
return exports.REBROADCAST_INTERVAL_MS;
|
|
242
|
+
const n = parseInt(raw, 10);
|
|
243
|
+
return Number.isFinite(n) && n >= 0 ? n : exports.REBROADCAST_INTERVAL_MS;
|
|
244
|
+
})();
|
|
245
|
+
const rebroadcastJitter = (() => {
|
|
246
|
+
const raw = process.env.POP_BRAIN_REBROADCAST_JITTER;
|
|
247
|
+
if (raw === undefined)
|
|
248
|
+
return exports.REBROADCAST_JITTER;
|
|
249
|
+
const n = parseFloat(raw);
|
|
250
|
+
return Number.isFinite(n) && n >= 0 && n < 1 ? n : exports.REBROADCAST_JITTER;
|
|
251
|
+
})();
|
|
252
|
+
const rebroadcastGraceMs = (() => {
|
|
253
|
+
const raw = process.env.POP_BRAIN_REBROADCAST_GRACE_MS;
|
|
254
|
+
if (raw === undefined)
|
|
255
|
+
return exports.REBROADCAST_GRACE_MS;
|
|
256
|
+
const n = parseInt(raw, 10);
|
|
257
|
+
return Number.isFinite(n) && n >= 0 ? n : exports.REBROADCAST_GRACE_MS;
|
|
258
|
+
})();
|
|
259
|
+
const nextInterval = () => {
|
|
260
|
+
if (rebroadcastJitter <= 0)
|
|
261
|
+
return rebroadcastIntervalMs;
|
|
262
|
+
const delta = rebroadcastIntervalMs * rebroadcastJitter;
|
|
263
|
+
return Math.max(0, Math.round(rebroadcastIntervalMs + (Math.random() * 2 - 1) * delta));
|
|
264
|
+
};
|
|
265
|
+
// --- Subscribe to the keepalive net topic ---
|
|
266
|
+
//
|
|
267
|
+
// The globaldb example publishes "hi!" on a separate netTopic every 20s
|
|
268
|
+
// so that the libp2p ConnManager tags participating peers with a "keep"
|
|
269
|
+
// priority and refuses to evict them. We port that verbatim.
|
|
270
|
+
const pubsub = node.libp2p.services.pubsub;
|
|
271
|
+
pubsub.subscribe(exports.KEEPALIVE_TOPIC);
|
|
272
|
+
log(`subscribed keepalive topic ${exports.KEEPALIVE_TOPIC}`);
|
|
273
|
+
// Tag peers who publish on the keepalive topic (a rough port of
|
|
274
|
+
// h.ConnManager().TagPeer(msg.ReceivedFrom, "keep", 100)). libp2p-js
|
|
275
|
+
// exposes this via node.libp2p.peerStore.merge(peerId, {tags: {...}}).
|
|
276
|
+
const keepaliveListener = async (evt) => {
|
|
277
|
+
const msg = evt.detail;
|
|
278
|
+
if (!msg || msg.topic !== exports.KEEPALIVE_TOPIC)
|
|
279
|
+
return;
|
|
280
|
+
const from = msg.from;
|
|
281
|
+
if (!from)
|
|
282
|
+
return;
|
|
283
|
+
try {
|
|
284
|
+
await node.libp2p.peerStore.merge(from, {
|
|
285
|
+
tags: { 'pop-brain-keep': { value: 50 } },
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
catch (err) {
|
|
289
|
+
// Non-fatal; peerStore.merge errors on some libp2p versions if the
|
|
290
|
+
// peer isn't in the store yet. Log and move on.
|
|
291
|
+
log(`keepalive tag err ${from}: ${err.message}`);
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
pubsub.addEventListener('message', keepaliveListener);
|
|
295
|
+
// --- Subscribe to every doc topic in the local manifest ---
|
|
296
|
+
//
|
|
297
|
+
// The daemon process keeps these subscriptions alive across CLI
|
|
298
|
+
// invocations. Incoming head-CID announcements fire fetchAndMergeRemoteHead
|
|
299
|
+
// immediately — no more "the CLI exited before the announcement arrived".
|
|
300
|
+
const subscribedDocs = new Set();
|
|
301
|
+
const unsubscribes = [];
|
|
302
|
+
async function subscribeDoc(docId) {
|
|
303
|
+
if (subscribedDocs.has(docId))
|
|
304
|
+
return;
|
|
305
|
+
subscribedDocs.add(docId);
|
|
306
|
+
const unsub = await (0, brain_1.subscribeBrainTopic)(docId, (ann, from) => {
|
|
307
|
+
stats.incomingAnnouncements += 1;
|
|
308
|
+
// T1: record the (docId, cid) we just heard so the rebroadcast loop
|
|
309
|
+
// can skip re-publishing it during the grace window.
|
|
310
|
+
seenHeads.set(seenKey(docId, ann.cid), Date.now());
|
|
311
|
+
log(`recv doc=${docId} cid=${ann.cid} from=${from} author=${ann.author}`);
|
|
312
|
+
// Fire-and-forget the block fetch + merge. Errors are logged.
|
|
313
|
+
(0, brain_1.fetchAndMergeRemoteHead)(ann.docId, ann.cid)
|
|
314
|
+
.then(result => {
|
|
315
|
+
// Task #373: only count actions where content actually landed.
|
|
316
|
+
// 'adopt' = fast-forward or first head, 'merge' = CRDT merge.
|
|
317
|
+
// 'skip' = already at head, 'reject' = auth/fetch/disjoint fail.
|
|
318
|
+
if (result.action === 'adopt' || result.action === 'merge') {
|
|
319
|
+
stats.incomingMerges += 1;
|
|
320
|
+
}
|
|
321
|
+
else if (result.action === 'reject') {
|
|
322
|
+
stats.incomingRejects += 1;
|
|
323
|
+
}
|
|
324
|
+
log(`merge doc=${docId} cid=${ann.cid} action=${result.action} reason=${result.reason}`);
|
|
325
|
+
})
|
|
326
|
+
.catch(err => {
|
|
327
|
+
log(`merge err doc=${docId} cid=${ann.cid}: ${err.message}`);
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
unsubscribes.push(unsub);
|
|
331
|
+
log(`subscribed doc ${docId}`);
|
|
332
|
+
}
|
|
333
|
+
// Bootstrap subscription: always subscribe to the canonical well-known
|
|
334
|
+
// doc topics AND every doc currently in the manifest. A fresh brain
|
|
335
|
+
// home will have an empty manifest but still needs to be listening
|
|
336
|
+
// for pop.brain.shared and pop.brain.projects announcements so it
|
|
337
|
+
// can catch up on first contact with a peer.
|
|
338
|
+
const docsToSubscribe = new Set([
|
|
339
|
+
...exports.CANONICAL_BRAIN_DOCS,
|
|
340
|
+
...(0, brain_1.listBrainDocs)().map(d => d.docId),
|
|
341
|
+
]);
|
|
342
|
+
for (const docId of docsToSubscribe) {
|
|
343
|
+
try {
|
|
344
|
+
await subscribeDoc(docId);
|
|
345
|
+
}
|
|
346
|
+
catch (err) {
|
|
347
|
+
log(`subscribe err ${docId}: ${err.message}`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
// --- Rebroadcast loop (T1, task #429) ---
|
|
351
|
+
//
|
|
352
|
+
// go-ds-crdt default: every 60s ±30% jitter, re-publish current heads so
|
|
353
|
+
// peers that came online after the last write can catch up. Suppresses
|
|
354
|
+
// re-publishing a head we received from a peer within the grace window —
|
|
355
|
+
// avoids amplification when fleet state is already converged.
|
|
356
|
+
//
|
|
357
|
+
// Disabled entirely if POP_BRAIN_REBROADCAST_INTERVAL_MS=0.
|
|
358
|
+
// Implemented as setTimeout-self-rescheduling instead of setInterval so
|
|
359
|
+
// each tick picks a fresh jittered delay.
|
|
360
|
+
let rebroadcastTimer = null;
|
|
361
|
+
async function rebroadcastTick() {
|
|
362
|
+
const docs = (0, brain_1.listBrainDocs)();
|
|
363
|
+
for (const { docId, headCid } of docs) {
|
|
364
|
+
// If the manifest picked up a new doc since startup, make sure we
|
|
365
|
+
// are also subscribed to its topic.
|
|
366
|
+
if (!subscribedDocs.has(docId)) {
|
|
367
|
+
try {
|
|
368
|
+
await subscribeDoc(docId);
|
|
369
|
+
}
|
|
370
|
+
catch { }
|
|
371
|
+
}
|
|
372
|
+
// Suppress rebroadcast of heads seen from peers within the grace
|
|
373
|
+
// window — another agent already published it; we'd just amplify.
|
|
374
|
+
const seenAt = seenHeads.get(seenKey(docId, headCid));
|
|
375
|
+
if (seenAt !== undefined && Date.now() - seenAt < rebroadcastGraceMs) {
|
|
376
|
+
stats.rebroadcastsSuppressedBySeen += 1;
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
try {
|
|
380
|
+
await (0, brain_1.publishBrainHead)(docId, headCid, authorAddress);
|
|
381
|
+
stats.rebroadcastCount += 1;
|
|
382
|
+
stats.lastRebroadcastAt = Date.now();
|
|
383
|
+
}
|
|
384
|
+
catch (err) {
|
|
385
|
+
log(`rebroadcast err doc=${docId}: ${err.message}`);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
// Prune seenHeads entries older than the grace window — bounded memory.
|
|
389
|
+
const cutoff = Date.now() - rebroadcastGraceMs;
|
|
390
|
+
for (const [key, ts] of seenHeads) {
|
|
391
|
+
if (ts < cutoff)
|
|
392
|
+
seenHeads.delete(key);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
function scheduleRebroadcast() {
|
|
396
|
+
if (rebroadcastIntervalMs === 0)
|
|
397
|
+
return;
|
|
398
|
+
rebroadcastTimer = setTimeout(async () => {
|
|
399
|
+
try {
|
|
400
|
+
await rebroadcastTick();
|
|
401
|
+
}
|
|
402
|
+
catch (err) {
|
|
403
|
+
log(`rebroadcast tick err: ${err.message}`);
|
|
404
|
+
}
|
|
405
|
+
scheduleRebroadcast();
|
|
406
|
+
}, nextInterval());
|
|
407
|
+
}
|
|
408
|
+
scheduleRebroadcast();
|
|
409
|
+
// --- Keepalive loop ---
|
|
410
|
+
//
|
|
411
|
+
// Publish "alive" to the net topic every 20s. This both tags peers and
|
|
412
|
+
// keeps pubsub mesh connections from going idle.
|
|
413
|
+
const keepaliveTimer = setInterval(async () => {
|
|
414
|
+
try {
|
|
415
|
+
await pubsub.publish(exports.KEEPALIVE_TOPIC, new TextEncoder().encode(`alive ${Date.now()}`));
|
|
416
|
+
stats.keepaliveCount += 1;
|
|
417
|
+
stats.lastKeepaliveAt = Date.now();
|
|
418
|
+
}
|
|
419
|
+
catch (err) {
|
|
420
|
+
log(`keepalive err: ${err.message}`);
|
|
421
|
+
}
|
|
422
|
+
}, exports.KEEPALIVE_INTERVAL_MS);
|
|
423
|
+
// --- IPC server ---
|
|
424
|
+
//
|
|
425
|
+
// Unix socket, newline-delimited JSON-RPC. First-ship methods: status.
|
|
426
|
+
// Second-ship will add appendLesson/readDoc/snapshot.
|
|
427
|
+
if ((0, fs_1.existsSync)(sockPath)) {
|
|
428
|
+
try {
|
|
429
|
+
(0, fs_1.unlinkSync)(sockPath);
|
|
430
|
+
}
|
|
431
|
+
catch { }
|
|
432
|
+
}
|
|
433
|
+
const server = net_1.default.createServer(socket => {
|
|
434
|
+
let buf = '';
|
|
435
|
+
socket.on('data', chunk => {
|
|
436
|
+
buf += chunk.toString('utf8');
|
|
437
|
+
let idx;
|
|
438
|
+
while ((idx = buf.indexOf('\n')) >= 0) {
|
|
439
|
+
const line = buf.slice(0, idx);
|
|
440
|
+
buf = buf.slice(idx + 1);
|
|
441
|
+
handleIpcLine(line, socket).catch(err => {
|
|
442
|
+
log(`ipc handler err: ${err.message}`);
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
socket.on('error', err => {
|
|
447
|
+
log(`ipc socket err: ${err.message}`);
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
async function handleIpcLine(line, socket) {
|
|
451
|
+
if (!line.trim())
|
|
452
|
+
return;
|
|
453
|
+
let req;
|
|
454
|
+
try {
|
|
455
|
+
req = JSON.parse(line);
|
|
456
|
+
}
|
|
457
|
+
catch (err) {
|
|
458
|
+
socket.write(JSON.stringify({ error: `bad json: ${err.message}` }) + '\n');
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
const id = req.id ?? null;
|
|
462
|
+
try {
|
|
463
|
+
const result = await dispatchIpc(req.method, req.params);
|
|
464
|
+
socket.write(JSON.stringify({ id, result }) + '\n');
|
|
465
|
+
}
|
|
466
|
+
catch (err) {
|
|
467
|
+
socket.write(JSON.stringify({ id, error: err.message }) + '\n');
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
async function dispatchIpc(method, _params) {
|
|
471
|
+
switch (method) {
|
|
472
|
+
case 'status': {
|
|
473
|
+
const topics = pubsub.getTopics?.() ?? [];
|
|
474
|
+
const peers = node.libp2p.getPeers().map((p) => p.toString());
|
|
475
|
+
const connections = node.libp2p.getConnections().length;
|
|
476
|
+
// Return the full /p2p/<peerId> multiaddrs so operators and test
|
|
477
|
+
// fixtures can dial this daemon from another process when automatic
|
|
478
|
+
// discovery (mDNS, bootstrap) isn't finding it.
|
|
479
|
+
const peerIdStr = node.libp2p.peerId.toString();
|
|
480
|
+
const listenAddrs = [];
|
|
481
|
+
try {
|
|
482
|
+
for (const ma of node.libp2p.getMultiaddrs()) {
|
|
483
|
+
const s = ma.toString();
|
|
484
|
+
listenAddrs.push(s.includes('/p2p/') ? s : `${s}/p2p/${peerIdStr}`);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
catch { }
|
|
488
|
+
return {
|
|
489
|
+
peerId: peerIdStr,
|
|
490
|
+
listenAddrs,
|
|
491
|
+
uptime: Math.floor((Date.now() - stats.startedAt) / 1000),
|
|
492
|
+
connections,
|
|
493
|
+
knownPeerCount: peers.length,
|
|
494
|
+
topics,
|
|
495
|
+
subscribedDocs: Array.from(subscribedDocs),
|
|
496
|
+
rebroadcastCount: stats.rebroadcastCount,
|
|
497
|
+
rebroadcastsSuppressedBySeen: stats.rebroadcastsSuppressedBySeen,
|
|
498
|
+
rebroadcastIntervalMs,
|
|
499
|
+
rebroadcastJitter,
|
|
500
|
+
rebroadcastGraceMs,
|
|
501
|
+
lastRebroadcastAt: stats.lastRebroadcastAt,
|
|
502
|
+
keepaliveCount: stats.keepaliveCount,
|
|
503
|
+
lastKeepaliveAt: stats.lastKeepaliveAt,
|
|
504
|
+
incomingAnnouncements: stats.incomingAnnouncements,
|
|
505
|
+
incomingMerges: stats.incomingMerges,
|
|
506
|
+
incomingRejects: stats.incomingRejects,
|
|
507
|
+
brainHome: home,
|
|
508
|
+
pidPath,
|
|
509
|
+
sockPath,
|
|
510
|
+
logPath,
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
case 'dial': {
|
|
514
|
+
// Operator/test escape hatch for bringing peers together when
|
|
515
|
+
// automatic discovery fails. Accepts a `/ip4/.../p2p/<peerId>`
|
|
516
|
+
// multiaddr and asks libp2p to open a connection.
|
|
517
|
+
//
|
|
518
|
+
// Expected params: {multiaddr: string}
|
|
519
|
+
// Returns: {dialed: string, peerId: string}
|
|
520
|
+
const addr = _params?.multiaddr;
|
|
521
|
+
if (!addr || typeof addr !== 'string') {
|
|
522
|
+
throw new Error('dial: multiaddr (string) is required');
|
|
523
|
+
}
|
|
524
|
+
// Lazy import so we don't pull multiaddr into the module namespace
|
|
525
|
+
// unless someone actually uses this path.
|
|
526
|
+
const esmImport = new Function('s', 'return import(s)');
|
|
527
|
+
const { multiaddr: makeMultiaddr } = await esmImport('@multiformats/multiaddr');
|
|
528
|
+
const ma = makeMultiaddr(addr);
|
|
529
|
+
await node.libp2p.dial(ma);
|
|
530
|
+
log(`dial via IPC: ${addr}`);
|
|
531
|
+
return { dialed: addr, peerId: node.libp2p.peerId.toString() };
|
|
532
|
+
}
|
|
533
|
+
case 'ping': {
|
|
534
|
+
return { pong: true, ts: Date.now() };
|
|
535
|
+
}
|
|
536
|
+
case 'applyOp': {
|
|
537
|
+
// HB#324 ship-2: unified write dispatch. The CLI serialized a
|
|
538
|
+
// BrainOp into _params.op; we run it through the same dispatchOp
|
|
539
|
+
// function the CLI would use if no daemon were running. This
|
|
540
|
+
// keeps the local and routed code paths byte-identical — only
|
|
541
|
+
// the transport differs.
|
|
542
|
+
//
|
|
543
|
+
// Lazy import to avoid a module-level cycle: brain-ops imports
|
|
544
|
+
// from brain-daemon (getRunningDaemonPid, sendIpcRequest), and
|
|
545
|
+
// brain-daemon needs to import dispatchOp from brain-ops here.
|
|
546
|
+
// Defer the require until the first applyOp lands.
|
|
547
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
548
|
+
const { dispatchOp } = require('./brain-ops');
|
|
549
|
+
const result = await dispatchOp(_params?.op);
|
|
550
|
+
// If the op touched a doc we weren't subscribed to, add it to
|
|
551
|
+
// our live subscription set so incoming announcements for that
|
|
552
|
+
// doc get handled going forward.
|
|
553
|
+
const docId = _params?.op?.docId;
|
|
554
|
+
if (docId && !subscribedDocs.has(docId)) {
|
|
555
|
+
try {
|
|
556
|
+
await subscribeDoc(docId);
|
|
557
|
+
}
|
|
558
|
+
catch { }
|
|
559
|
+
}
|
|
560
|
+
log(`applyOp doc=${docId} type=${_params?.op?.type} head=${result.headCid} ` +
|
|
561
|
+
`author=${result.envelopeAuthor}`);
|
|
562
|
+
// dispatchOp returns routedViaDaemon=false because it ran in the
|
|
563
|
+
// local (daemon) process. The CLI's routedDispatch will override
|
|
564
|
+
// this flag to true because it was sent via IPC.
|
|
565
|
+
return {
|
|
566
|
+
headCid: result.headCid,
|
|
567
|
+
envelopeAuthor: result.envelopeAuthor,
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
default:
|
|
571
|
+
throw new Error(`Unknown IPC method: ${method}`);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
await new Promise((resolve, reject) => {
|
|
575
|
+
server.listen(sockPath, () => resolve());
|
|
576
|
+
server.on('error', reject);
|
|
577
|
+
});
|
|
578
|
+
try {
|
|
579
|
+
(0, fs_1.chmodSync)(sockPath, 0o600);
|
|
580
|
+
}
|
|
581
|
+
catch { }
|
|
582
|
+
log(`IPC listening on ${sockPath}`);
|
|
583
|
+
// HB#324: NOW write the PID file, after the IPC socket is listening.
|
|
584
|
+
// A CLI command that sees the PID file will immediately try to IPC
|
|
585
|
+
// and that connection must succeed on the first try. Writing the PID
|
|
586
|
+
// before the server was listening was a startup race bug in ship-1.
|
|
587
|
+
(0, fs_1.writeFileSync)(pidPath, String(process.pid), { mode: 0o600 });
|
|
588
|
+
log(`PID file written — daemon is now discoverable`);
|
|
589
|
+
// HB#333 (task #349): auto-dial peers listed in POP_BRAIN_PEERS.
|
|
590
|
+
// Format: comma-separated /ip4/.../p2p/<peerId> multiaddrs.
|
|
591
|
+
// Example (3-agent local setup):
|
|
592
|
+
// POP_BRAIN_PEERS=/ip4/127.0.0.1/tcp/50126/p2p/12D3...,/ip4/127.0.0.1/tcp/50134/p2p/12D3...
|
|
593
|
+
//
|
|
594
|
+
// HB#365 (task tbd): periodic redial on disconnect.
|
|
595
|
+
// Before this, POP_BRAIN_PEERS was fire-once at startup — any
|
|
596
|
+
// disconnect (peer reboot, network blip, macOS sleep) left the daemon
|
|
597
|
+
// stuck at connections=0 until manual restart. Now a periodic timer
|
|
598
|
+
// re-evaluates the list every REDIAL_INTERVAL_MS and dials any peer
|
|
599
|
+
// that is not currently in the active connection set. Override the
|
|
600
|
+
// interval via POP_BRAIN_REDIAL_INTERVAL_MS.
|
|
601
|
+
//
|
|
602
|
+
// Semantics:
|
|
603
|
+
// - Unset or empty POP_BRAIN_PEERS → no-op, no timer
|
|
604
|
+
// - Parse each entry, empty segments dropped silently
|
|
605
|
+
// - Initial dial at startup is still best-effort parallel
|
|
606
|
+
// - Every interval tick: for each configured peer, extract the peerId
|
|
607
|
+
// suffix, check libp2p.getPeers() membership. If not connected,
|
|
608
|
+
// dial. If connected, skip (no duplicate dials).
|
|
609
|
+
// - Individual failures logged but never block the timer loop
|
|
610
|
+
// - Timer is cleared in shutdown() alongside rebroadcast/keepalive
|
|
611
|
+
const peersEnv = process.env.POP_BRAIN_PEERS;
|
|
612
|
+
const parsedPeerAddrs = peersEnv && peersEnv.trim() !== ''
|
|
613
|
+
? peersEnv.split(',').map(s => s.trim()).filter(Boolean)
|
|
614
|
+
: [];
|
|
615
|
+
const esmImportPeers = new Function('s', 'return import(s)');
|
|
616
|
+
let makeMultiaddrLocal = null;
|
|
617
|
+
if (parsedPeerAddrs.length > 0) {
|
|
618
|
+
const mod = await esmImportPeers('@multiformats/multiaddr');
|
|
619
|
+
makeMultiaddrLocal = mod.multiaddr;
|
|
620
|
+
}
|
|
621
|
+
// Extract target peerId from /p2p/<id> suffix so we can test
|
|
622
|
+
// connection membership without re-parsing on every tick.
|
|
623
|
+
function peerIdOfMultiaddr(addr) {
|
|
624
|
+
const m = /\/p2p\/([^/]+)$/.exec(addr);
|
|
625
|
+
return m ? m[1] : null;
|
|
626
|
+
}
|
|
627
|
+
async function dialIfDisconnected(addr, reason) {
|
|
628
|
+
try {
|
|
629
|
+
const targetPeerId = peerIdOfMultiaddr(addr);
|
|
630
|
+
if (targetPeerId) {
|
|
631
|
+
const connected = node.libp2p.getPeers().some((p) => p.toString() === targetPeerId);
|
|
632
|
+
if (connected) {
|
|
633
|
+
// Already connected — no-op. Quiet in redial loop to avoid log spam.
|
|
634
|
+
if (reason === 'startup')
|
|
635
|
+
log(`auto-dial skip (already connected): ${addr}`);
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
const ma = makeMultiaddrLocal(addr);
|
|
640
|
+
await node.libp2p.dial(ma);
|
|
641
|
+
log(`${reason === 'startup' ? 'auto-dial' : 'redial'} success: ${addr}`);
|
|
642
|
+
}
|
|
643
|
+
catch (err) {
|
|
644
|
+
log(`${reason === 'startup' ? 'auto-dial' : 'redial'} failed: ${addr} — ${err?.message ?? err}`);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
if (parsedPeerAddrs.length > 0) {
|
|
648
|
+
log(`auto-dial: POP_BRAIN_PEERS has ${parsedPeerAddrs.length} entry(ies)`);
|
|
649
|
+
// Fire all dials in parallel at startup — independent best-effort.
|
|
650
|
+
await Promise.all(parsedPeerAddrs.map(a => dialIfDisconnected(a, 'startup')));
|
|
651
|
+
}
|
|
652
|
+
// HB#365: periodic redial timer. Handles peer reboots, transient
|
|
653
|
+
// disconnects, and cross-device sessions where the remote side is
|
|
654
|
+
// occasionally offline. Only runs when POP_BRAIN_PEERS is set.
|
|
655
|
+
const redialInterval = (() => {
|
|
656
|
+
const raw = process.env.POP_BRAIN_REDIAL_INTERVAL_MS;
|
|
657
|
+
if (!raw)
|
|
658
|
+
return exports.REDIAL_INTERVAL_MS;
|
|
659
|
+
const n = Number(raw);
|
|
660
|
+
return Number.isFinite(n) && n >= 5_000 ? n : exports.REDIAL_INTERVAL_MS;
|
|
661
|
+
})();
|
|
662
|
+
const redialTimer = parsedPeerAddrs.length > 0
|
|
663
|
+
? setInterval(async () => {
|
|
664
|
+
for (const addr of parsedPeerAddrs) {
|
|
665
|
+
await dialIfDisconnected(addr, 'redial');
|
|
666
|
+
}
|
|
667
|
+
}, redialInterval)
|
|
668
|
+
: null;
|
|
669
|
+
// --- Graceful shutdown ---
|
|
670
|
+
let shuttingDown = false;
|
|
671
|
+
const shutdown = async (sig) => {
|
|
672
|
+
if (shuttingDown)
|
|
673
|
+
return;
|
|
674
|
+
shuttingDown = true;
|
|
675
|
+
log(`shutdown signal ${sig}`);
|
|
676
|
+
if (rebroadcastTimer !== null)
|
|
677
|
+
clearTimeout(rebroadcastTimer);
|
|
678
|
+
clearInterval(keepaliveTimer);
|
|
679
|
+
if (redialTimer)
|
|
680
|
+
clearInterval(redialTimer);
|
|
681
|
+
try {
|
|
682
|
+
pubsub.removeEventListener('message', keepaliveListener);
|
|
683
|
+
}
|
|
684
|
+
catch { }
|
|
685
|
+
for (const u of unsubscribes) {
|
|
686
|
+
try {
|
|
687
|
+
u();
|
|
688
|
+
}
|
|
689
|
+
catch { }
|
|
690
|
+
}
|
|
691
|
+
await new Promise(res => server.close(() => res()));
|
|
692
|
+
if ((0, fs_1.existsSync)(sockPath)) {
|
|
693
|
+
try {
|
|
694
|
+
(0, fs_1.unlinkSync)(sockPath);
|
|
695
|
+
}
|
|
696
|
+
catch { }
|
|
697
|
+
}
|
|
698
|
+
if ((0, fs_1.existsSync)(pidPath)) {
|
|
699
|
+
try {
|
|
700
|
+
(0, fs_1.unlinkSync)(pidPath);
|
|
701
|
+
}
|
|
702
|
+
catch { }
|
|
703
|
+
}
|
|
704
|
+
try {
|
|
705
|
+
await (0, brain_1.stopBrainNode)();
|
|
706
|
+
}
|
|
707
|
+
catch (err) {
|
|
708
|
+
log(`stop err: ${err.message}`);
|
|
709
|
+
}
|
|
710
|
+
log(`shutdown complete`);
|
|
711
|
+
logStream.end();
|
|
712
|
+
// Give the log stream a tick to flush, then exit.
|
|
713
|
+
setTimeout(() => process.exit(0), 50);
|
|
714
|
+
};
|
|
715
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
716
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
717
|
+
process.on('SIGHUP', () => shutdown('SIGHUP'));
|
|
718
|
+
process.on('uncaughtException', err => {
|
|
719
|
+
log(`uncaught: ${err.stack ?? err.message}`);
|
|
720
|
+
shutdown('uncaughtException');
|
|
721
|
+
});
|
|
722
|
+
log(`daemon ready — rebroadcast=${rebroadcastIntervalMs === 0 ? 'disabled' : `${rebroadcastIntervalMs}ms±${Math.round(rebroadcastJitter * 100)}% grace=${rebroadcastGraceMs}ms`} ` +
|
|
723
|
+
`keepalive=${exports.KEEPALIVE_INTERVAL_MS}ms ` +
|
|
724
|
+
(redialTimer ? `redial=${redialInterval}ms ` : '') +
|
|
725
|
+
`subscribed=${subscribedDocs.size} docs`);
|
|
726
|
+
// Park forever. The timers and the IPC server keep the event loop alive.
|
|
727
|
+
// Shutdown only happens via signal handler.
|
|
728
|
+
await new Promise(() => { });
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* Typed IPC error. Attaches a `.code` for the caller to branch on.
|
|
732
|
+
*
|
|
733
|
+
* phase = 'pre-connect' The connection was never established (socket
|
|
734
|
+
* missing, ECONNREFUSED). Safe to fall back to a
|
|
735
|
+
* local execution path — the write did not land
|
|
736
|
+
* in the daemon's process.
|
|
737
|
+
* phase = 'post-connect' The connection was established and the request
|
|
738
|
+
* was sent, but a response did not come back. The
|
|
739
|
+
* write may or may not have landed. NOT safe to
|
|
740
|
+
* fall back — see routedDispatch() in brain-ops.ts.
|
|
741
|
+
*/
|
|
742
|
+
class BrainIpcError extends Error {
|
|
743
|
+
code;
|
|
744
|
+
phase;
|
|
745
|
+
constructor(message, code, phase) {
|
|
746
|
+
super(message);
|
|
747
|
+
this.name = 'BrainIpcError';
|
|
748
|
+
this.code = code;
|
|
749
|
+
this.phase = phase;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
exports.BrainIpcError = BrainIpcError;
|
|
753
|
+
/**
|
|
754
|
+
* IPC client helper: send a request to the running daemon.
|
|
755
|
+
*
|
|
756
|
+
* Throws a BrainIpcError whose `.phase` indicates whether the failure is
|
|
757
|
+
* safe to recover from by falling back to a local code path. Pre-connect
|
|
758
|
+
* failures (ECONNREFUSED, ENOENT, daemon not running) are safe. Post-connect
|
|
759
|
+
* failures (timeout, ECONNRESET, EPIPE) leave the write in an unknown state.
|
|
760
|
+
*/
|
|
761
|
+
async function sendIpcRequest(method, params = {}, timeoutMs = 5000) {
|
|
762
|
+
const sockPath = getDaemonSockPath();
|
|
763
|
+
if (!(0, fs_1.existsSync)(sockPath)) {
|
|
764
|
+
throw new BrainIpcError(`No brain daemon socket at ${sockPath}. Run "pop brain daemon start".`, 'ENOENT', 'pre-connect');
|
|
765
|
+
}
|
|
766
|
+
return await new Promise((resolve, reject) => {
|
|
767
|
+
const socket = net_1.default.createConnection(sockPath);
|
|
768
|
+
let buf = '';
|
|
769
|
+
let connected = false;
|
|
770
|
+
const timer = setTimeout(() => {
|
|
771
|
+
socket.destroy();
|
|
772
|
+
reject(new BrainIpcError(`IPC timeout after ${timeoutMs}ms`, 'ETIMEDOUT', connected ? 'post-connect' : 'pre-connect'));
|
|
773
|
+
}, timeoutMs);
|
|
774
|
+
socket.on('connect', () => {
|
|
775
|
+
connected = true;
|
|
776
|
+
// Now that we have a live connection, write the request. Doing this
|
|
777
|
+
// in the connect handler (instead of immediately after createConnection)
|
|
778
|
+
// closes a subtle phase-classification race on fast local sockets.
|
|
779
|
+
socket.write(JSON.stringify({ id: Date.now().toString(), method, params }) + '\n');
|
|
780
|
+
});
|
|
781
|
+
socket.on('data', chunk => {
|
|
782
|
+
buf += chunk.toString('utf8');
|
|
783
|
+
const nl = buf.indexOf('\n');
|
|
784
|
+
if (nl >= 0) {
|
|
785
|
+
clearTimeout(timer);
|
|
786
|
+
const line = buf.slice(0, nl);
|
|
787
|
+
try {
|
|
788
|
+
const res = JSON.parse(line);
|
|
789
|
+
socket.end();
|
|
790
|
+
if (res.error) {
|
|
791
|
+
// Response-level error (daemon rejected the request). This is
|
|
792
|
+
// post-connect; the write did not land.
|
|
793
|
+
reject(new BrainIpcError(res.error, 'EHANDLER', 'post-connect'));
|
|
794
|
+
}
|
|
795
|
+
else {
|
|
796
|
+
resolve(res.result);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
catch (err) {
|
|
800
|
+
reject(new BrainIpcError(`bad ipc response: ${err.message}`, 'EPROTO', 'post-connect'));
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
});
|
|
804
|
+
socket.on('error', err => {
|
|
805
|
+
clearTimeout(timer);
|
|
806
|
+
const code = err.code ?? 'EIPC';
|
|
807
|
+
const phase = connected ? 'post-connect' : 'pre-connect';
|
|
808
|
+
reject(new BrainIpcError(err.message, code, phase));
|
|
809
|
+
});
|
|
810
|
+
});
|
|
811
|
+
}
|