Add features across agents, collab, trust, indexes, and hyperbee.
Manual pass: task cancelTask, workflow topologicalOrder/fail/reset, collab kick/replay, trust trustedPeers and multisig TTL sweep, graph shortestPath, inverted topTerms, dedup filterNew, bee notifyBatch/seekVersion — all with tests. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -15,8 +15,8 @@ Autonomous agent primitives: persistent causal memory, DAG task orchestration, a
|
|||||||
| Module | Protocol | Summary |
|
| Module | Protocol | Summary |
|
||||||
|--------|----------|---------|
|
|--------|----------|---------|
|
||||||
| [hyper-p2p-agent-memory](./hyper-p2p-agent-memory/) | `hyper-p2p-agent-memory/v1` | Episodic/semantic memory, tags, causal links, Hyperbee |
|
| [hyper-p2p-agent-memory](./hyper-p2p-agent-memory/) | `hyper-p2p-agent-memory/v1` | Episodic/semantic memory, tags, causal links, Hyperbee |
|
||||||
| [hyper-p2p-task-orchestrator](./hyper-p2p-task-orchestrator/) | `hyper-p2p-task-orchestrator/v1` | Signed tasks, `taskCounts()`, vector-clock lifecycle |
|
| [hyper-p2p-task-orchestrator](./hyper-p2p-task-orchestrator/) | `hyper-p2p-task-orchestrator/v1` | `cancelTask`, `listTaskIds`, signed DAG tasks |
|
||||||
| [hyper-p2p-workflow-graph](./hyper-p2p-workflow-graph/) | `workflow-graph/v1` | DAG nodes/edges, `readyNodes()`, cycle detection |
|
| [hyper-p2p-workflow-graph](./hyper-p2p-workflow-graph/) | `workflow-graph/v1` | `topologicalOrder`, `fail`, `reset`, cycle-safe DAG |
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,13 @@ DAG task orchestration with signed tasks, vector-clock lifecycle, optional Hyper
|
|||||||
|
|
||||||
### `registerHandler(taskType, fn)` / `getTask(taskId)` / `getResult(taskId)`
|
### `registerHandler(taskType, fn)` / `getTask(taskId)` / `getResult(taskId)`
|
||||||
|
|
||||||
### `taskCounts() → { pending, running, completed, failed, total }`
|
### `cancelTask(taskId, reason?) → task`
|
||||||
|
|
||||||
|
Marks non-terminal tasks as failed with `cancelReason`.
|
||||||
|
|
||||||
|
### `listTaskIds() → string[]`
|
||||||
|
|
||||||
|
### `taskCounts() → { pending, assigned, completed, failed }`
|
||||||
|
|
||||||
### `getStats() → { ...metrics, ...taskCounts(), protocol }`
|
### `getStats() → { ...metrics, ...taskCounts(), protocol }`
|
||||||
|
|
||||||
|
|||||||
@@ -342,6 +342,27 @@ class HyperP2PTaskOrchestrator extends EventEmitter {
|
|||||||
return this.tasks.get(taskId) || null
|
return this.tasks.get(taskId) || null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async cancelTask (taskId, reason = '') {
|
||||||
|
await this.ready()
|
||||||
|
const task = this.tasks.get(taskId)
|
||||||
|
if (!task) throw new Error('Task not found')
|
||||||
|
if (task.status === 'completed' || task.status === 'failed') {
|
||||||
|
throw new Error('Task already terminal')
|
||||||
|
}
|
||||||
|
task.status = 'failed'
|
||||||
|
task.cancelledAt = Date.now()
|
||||||
|
task.cancelReason = reason
|
||||||
|
this.metrics.tasksFailed++
|
||||||
|
await this._persist()
|
||||||
|
this.emit('task:cancelled', task)
|
||||||
|
this._gossipTask(task)
|
||||||
|
return task
|
||||||
|
}
|
||||||
|
|
||||||
|
listTaskIds () {
|
||||||
|
return [...this.tasks.keys()]
|
||||||
|
}
|
||||||
|
|
||||||
async queryTasks (filter = {}) {
|
async queryTasks (filter = {}) {
|
||||||
await this.ready()
|
await this.ready()
|
||||||
let results = Array.from(this.tasks.values())
|
let results = Array.from(this.tasks.values())
|
||||||
|
|||||||
@@ -146,3 +146,15 @@ test('hyper-p2p-task-orchestrator: validation rejects invalid input', async (t)
|
|||||||
}
|
}
|
||||||
await m.close()
|
await m.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('cancelTask and listTaskIds', async (t) => {
|
||||||
|
const keyPair = require('hypercore-crypto').keyPair()
|
||||||
|
const orch = new HyperP2PTaskOrchestrator({ keyPair, storageDir: '/tmp/task-orch-cancel-' + Date.now() })
|
||||||
|
await orch.ready()
|
||||||
|
const id = await orch.submitTask({ type: 'x', payload: {} })
|
||||||
|
t.ok(orch.listTaskIds().includes(id))
|
||||||
|
await orch.cancelTask(id, 'user abort')
|
||||||
|
const task = await orch.getTask(id)
|
||||||
|
t.is(task.status, 'failed')
|
||||||
|
await orch.close()
|
||||||
|
})
|
||||||
|
|||||||
@@ -95,12 +95,66 @@ class HyperP2PWorkflowGraph extends EventEmitter {
|
|||||||
return [...this._nodes.values()].filter((n) => n.state === 'pending').length
|
return [...this._nodes.values()].filter((n) => n.state === 'pending').length
|
||||||
}
|
}
|
||||||
|
|
||||||
|
failedCount () {
|
||||||
|
return [...this._nodes.values()].filter((n) => n.state === 'failed').length
|
||||||
|
}
|
||||||
|
|
||||||
isComplete () {
|
isComplete () {
|
||||||
return this._nodes.size > 0 && this.pendingCount() === 0
|
return this._nodes.size > 0 && this.pendingCount() === 0 && this.failedCount() === 0
|
||||||
}
|
}
|
||||||
|
|
||||||
listNodes () { return [...this._nodes.values()] }
|
listNodes () { return [...this._nodes.values()] }
|
||||||
|
|
||||||
|
topologicalOrder () {
|
||||||
|
const inDeg = new Map()
|
||||||
|
for (const id of this._nodes.keys()) inDeg.set(id, 0)
|
||||||
|
for (const e of this._edges) {
|
||||||
|
inDeg.set(e.to, (inDeg.get(e.to) || 0) + 1)
|
||||||
|
}
|
||||||
|
const q = []
|
||||||
|
for (const [id, d] of inDeg) if (d === 0) q.push(id)
|
||||||
|
const order = []
|
||||||
|
const adj = new Map()
|
||||||
|
for (const e of this._edges) {
|
||||||
|
if (!adj.has(e.from)) adj.set(e.from, [])
|
||||||
|
adj.get(e.from).push(e.to)
|
||||||
|
}
|
||||||
|
while (q.length) {
|
||||||
|
const id = q.shift()
|
||||||
|
order.push(id)
|
||||||
|
for (const next of adj.get(id) || []) {
|
||||||
|
inDeg.set(next, inDeg.get(next) - 1)
|
||||||
|
if (inDeg.get(next) === 0) q.push(next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (order.length !== this._nodes.size) {
|
||||||
|
throw new Error('workflow graph has cycle')
|
||||||
|
}
|
||||||
|
return order
|
||||||
|
}
|
||||||
|
|
||||||
|
fail (id, reason = '') {
|
||||||
|
const node = this._nodes.get(id)
|
||||||
|
if (!node) return false
|
||||||
|
node.state = 'failed'
|
||||||
|
node.failedAt = Date.now()
|
||||||
|
node.failReason = reason
|
||||||
|
if (this._peerMsgs) gossipSend(this, { type: 'fail', id, reason })
|
||||||
|
this.emit('fail', node)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
reset (id) {
|
||||||
|
const node = this._nodes.get(id)
|
||||||
|
if (!node) return false
|
||||||
|
node.state = 'pending'
|
||||||
|
delete node.completedAt
|
||||||
|
delete node.failedAt
|
||||||
|
if (this._peerMsgs) gossipSend(this, { type: 'reset', id })
|
||||||
|
this.emit('reset', node)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
toJSON () {
|
toJSON () {
|
||||||
return { nodes: [...this._nodes.values()], edges: [...this._edges] }
|
return { nodes: [...this._nodes.values()], edges: [...this._edges] }
|
||||||
}
|
}
|
||||||
@@ -135,6 +189,8 @@ class HyperP2PWorkflowGraph extends EventEmitter {
|
|||||||
if (data && data.type === 'node' && data.node) this.merge({ nodes: [data.node], edges: [] })
|
if (data && data.type === 'node' && data.node) this.merge({ nodes: [data.node], edges: [] })
|
||||||
else if (data && data.type === 'edge' && data.edge) this.merge({ nodes: [], edges: [data.edge] })
|
else if (data && data.type === 'edge' && data.edge) this.merge({ nodes: [], edges: [data.edge] })
|
||||||
else if (data && data.type === 'complete' && data.id) this.complete(data.id)
|
else if (data && data.type === 'complete' && data.id) this.complete(data.id)
|
||||||
|
else if (data && data.type === 'fail' && data.id) this.fail(data.id, data.reason)
|
||||||
|
else if (data && data.type === 'reset' && data.id) this.reset(data.id)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return this
|
return this
|
||||||
@@ -147,6 +203,7 @@ class HyperP2PWorkflowGraph extends EventEmitter {
|
|||||||
nodes: this._nodes.size,
|
nodes: this._nodes.size,
|
||||||
edges: this._edges.length,
|
edges: this._edges.length,
|
||||||
pending: this.pendingCount(),
|
pending: this.pendingCount(),
|
||||||
|
failed: this.failedCount(),
|
||||||
protocol: PROTOCOL
|
protocol: PROTOCOL
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,19 @@ test('workflow-graph: DAG ready nodes', async (t) => {
|
|||||||
await w.close()
|
await w.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('workflow-graph: topologicalOrder fail reset', async (t) => {
|
||||||
|
const w = new HyperP2PWorkflowGraph()
|
||||||
|
w.addNode('a')
|
||||||
|
w.addNode('b')
|
||||||
|
w.addEdge('a', 'b')
|
||||||
|
t.alike(w.topologicalOrder(), ['a', 'b'])
|
||||||
|
w.fail('a', 'err')
|
||||||
|
t.is(w.failedCount(), 1)
|
||||||
|
t.ok(w.reset('a'))
|
||||||
|
t.is(w.pendingCount(), 2)
|
||||||
|
await w.close()
|
||||||
|
})
|
||||||
|
|
||||||
test('workflow-graph: cycle rejected', async (t) => {
|
test('workflow-graph: cycle rejected', async (t) => {
|
||||||
const w = new HyperP2PWorkflowGraph()
|
const w = new HyperP2PWorkflowGraph()
|
||||||
w.addNode('x')
|
w.addNode('x')
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ Real-time collab primitives: rooms, cursors, line locks, and whiteboard ops over
|
|||||||
|
|
||||||
| Module | Protocol | Highlights |
|
| Module | Protocol | Highlights |
|
||||||
|--------|----------|------------|
|
|--------|----------|------------|
|
||||||
| [hyper-p2p-collab-room](./hyper-p2p-collab-room/) | `collab-room/v1` | `listRooms()`, `getMembers`, broadcast |
|
| [hyper-p2p-collab-room](./hyper-p2p-collab-room/) | `collab-room/v1` | `kickMember`, `isMember`, `memberCount` |
|
||||||
|
| [hyper-p2p-whiteboard-op](./hyper-p2p-whiteboard-op/) | `whiteboard-op/v1` | `replay(fromSeq)`, `lastSeq` |
|
||||||
| [hyper-p2p-cursor-presence](./hyper-p2p-cursor-presence/) | `cursor-presence/v1` | Per-doc cursor map |
|
| [hyper-p2p-cursor-presence](./hyper-p2p-cursor-presence/) | `cursor-presence/v1` | Per-doc cursor map |
|
||||||
| [hyper-p2p-document-line-lock](./hyper-p2p-document-line-lock/) | `document-line-lock/v1` | Line-level locks |
|
| [hyper-p2p-document-line-lock](./hyper-p2p-document-line-lock/) | `document-line-lock/v1` | Line-level locks |
|
||||||
| [hyper-p2p-whiteboard-op](./hyper-p2p-whiteboard-op/) | `whiteboard-op/v1` | Op log + replay |
|
| [hyper-p2p-whiteboard-op](./hyper-p2p-whiteboard-op/) | `whiteboard-op/v1` | Op log + replay |
|
||||||
|
|||||||
@@ -93,6 +93,31 @@ class HyperP2PCollabRoom extends EventEmitter {
|
|||||||
return room.events.slice(-limit)
|
return room.events.slice(-limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
memberCount (roomId) {
|
||||||
|
const room = this._rooms.get(roomId)
|
||||||
|
return room ? room.members.size : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
isMember (roomId, peer = null) {
|
||||||
|
const room = this._rooms.get(roomId)
|
||||||
|
if (!room) return false
|
||||||
|
return room.members.has(peer || this.peerHex)
|
||||||
|
}
|
||||||
|
|
||||||
|
kickMember (roomId, peer) {
|
||||||
|
assertNonEmpty(roomId, 'roomId')
|
||||||
|
assertNonEmpty(peer, 'peer')
|
||||||
|
const room = this._rooms.get(roomId)
|
||||||
|
if (!room || !room.members.has(peer)) return false
|
||||||
|
room.members.delete(peer)
|
||||||
|
if (this._peerMsgs) {
|
||||||
|
gossipSend(this, { type: 'kick', roomId, peer })
|
||||||
|
this._stats.gossipOut++
|
||||||
|
}
|
||||||
|
this.emit('kick', { roomId, peer })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
_onGossip (data) {
|
_onGossip (data) {
|
||||||
if (!data || !data.type) return
|
if (!data || !data.type) return
|
||||||
this._stats.gossipIn++
|
this._stats.gossipIn++
|
||||||
@@ -107,6 +132,10 @@ class HyperP2PCollabRoom extends EventEmitter {
|
|||||||
room.members.delete(data.peer)
|
room.members.delete(data.peer)
|
||||||
this.emit('peer-left', { roomId: data.roomId, peer: data.peer })
|
this.emit('peer-left', { roomId: data.roomId, peer: data.peer })
|
||||||
}
|
}
|
||||||
|
if (data.type === 'kick' && data.peer) {
|
||||||
|
room.members.delete(data.peer)
|
||||||
|
this.emit('peer-kicked', { roomId: data.roomId, peer: data.peer })
|
||||||
|
}
|
||||||
if (data.type === 'broadcast' && data.event) {
|
if (data.type === 'broadcast' && data.event) {
|
||||||
room.events.push(data.event)
|
room.events.push(data.event)
|
||||||
this.emit('broadcast', { roomId: data.roomId, event: data.event, remote: true })
|
this.emit('broadcast', { roomId: data.roomId, event: data.event, remote: true })
|
||||||
|
|||||||
@@ -47,3 +47,15 @@ test('getStats', async (t) => {
|
|||||||
t.ok(room.getStats().protocol)
|
t.ok(room.getStats().protocol)
|
||||||
await room.close()
|
await room.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('kickMember and isMember', async (t) => {
|
||||||
|
const room = new HyperP2PCollabRoom()
|
||||||
|
room.createRoom('k')
|
||||||
|
room.join('k')
|
||||||
|
room._onGossip({ type: 'join', roomId: 'k', member: { peer: 'bad', meta: {} } })
|
||||||
|
t.is(room.memberCount('k'), 2)
|
||||||
|
t.ok(room.kickMember('k', 'bad'))
|
||||||
|
t.is(room.memberCount('k'), 1)
|
||||||
|
t.ok(room.isMember('k'))
|
||||||
|
await room.close()
|
||||||
|
})
|
||||||
|
|||||||
@@ -42,6 +42,17 @@ class HyperP2PWhiteboardOp extends EventEmitter {
|
|||||||
return this._log.filter((e) => e.roomId === roomId).slice(-limit)
|
return this._log.filter((e) => e.roomId === roomId).slice(-limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
replay (roomId, fromSeq = 0) {
|
||||||
|
assertNonEmpty(roomId, 'roomId')
|
||||||
|
const seq = Number(fromSeq) | 0
|
||||||
|
return this._log.filter((e) => e.roomId === roomId && e.seq >= seq)
|
||||||
|
}
|
||||||
|
|
||||||
|
lastSeq (roomId) {
|
||||||
|
const entries = this.history(roomId, 1)
|
||||||
|
return entries.length ? entries[0].seq : -1
|
||||||
|
}
|
||||||
|
|
||||||
mergeRemote (entry) {
|
mergeRemote (entry) {
|
||||||
if (!entry || !entry.roomId) return false
|
if (!entry || !entry.roomId) return false
|
||||||
this._stats.gossipIn++
|
this._stats.gossipIn++
|
||||||
|
|||||||
@@ -37,3 +37,14 @@ test('getStats', async (t) => {
|
|||||||
t.ok(wb.getStats().protocol)
|
t.ok(wb.getStats().protocol)
|
||||||
await wb.close()
|
await wb.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('replay from seq', async (t) => {
|
||||||
|
const wb = new HyperP2PWhiteboardOp()
|
||||||
|
wb.apply('r', { n: 0 })
|
||||||
|
wb.apply('r', { n: 1 })
|
||||||
|
const replay = wb.replay('r', 1)
|
||||||
|
t.is(replay.length, 1)
|
||||||
|
t.is(replay[0].op.n, 1)
|
||||||
|
t.is(wb.lastSeq('r'), 1)
|
||||||
|
await wb.close()
|
||||||
|
})
|
||||||
|
|||||||
@@ -103,6 +103,27 @@ class HyperP2PGraphIndex extends EventEmitter {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
shortestPath (from, to, maxHops = 32) {
|
||||||
|
assertNonEmpty(from, 'from')
|
||||||
|
assertNonEmpty(to, 'to')
|
||||||
|
if (from === to) return [from]
|
||||||
|
const queue = [[from]]
|
||||||
|
const visited = new Set([from])
|
||||||
|
while (queue.length) {
|
||||||
|
const path = queue.shift()
|
||||||
|
if (path.length > maxHops) break
|
||||||
|
const node = path[path.length - 1]
|
||||||
|
for (const nbr of this.neighbors(node)) {
|
||||||
|
if (visited.has(nbr)) continue
|
||||||
|
const next = [...path, nbr]
|
||||||
|
if (nbr === to) return next
|
||||||
|
visited.add(nbr)
|
||||||
|
queue.push(next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
getStats () {
|
getStats () {
|
||||||
return {
|
return {
|
||||||
...this._stats,
|
...this._stats,
|
||||||
|
|||||||
@@ -47,3 +47,12 @@ test('removeEdge outDegree edgeCount', async (t) => {
|
|||||||
t.is(m.outDegree('a'), 1)
|
t.is(m.outDegree('a'), 1)
|
||||||
await m.close()
|
await m.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('shortestPath', async (t) => {
|
||||||
|
const m = new HyperP2PGraphIndex()
|
||||||
|
m.addEdge('a', 'b')
|
||||||
|
m.addEdge('b', 'c')
|
||||||
|
t.alike(m.shortestPath('a', 'c'), ['a', 'b', 'c'])
|
||||||
|
t.is(m.shortestPath('a', 'z'), null)
|
||||||
|
await m.close()
|
||||||
|
})
|
||||||
|
|||||||
@@ -104,6 +104,13 @@ class HyperP2PInvertedIndex extends EventEmitter {
|
|||||||
return this._docs.size
|
return this._docs.size
|
||||||
}
|
}
|
||||||
|
|
||||||
|
topTerms (limit = 10) {
|
||||||
|
const ranked = [...this._terms.entries()]
|
||||||
|
.map(([term, set]) => ({ term, docs: set.size }))
|
||||||
|
.sort((a, b) => b.docs - a.docs)
|
||||||
|
return ranked.slice(0, Math.max(0, limit | 0))
|
||||||
|
}
|
||||||
|
|
||||||
_gossip (data) {
|
_gossip (data) {
|
||||||
if (!this._peerMsgs) return
|
if (!this._peerMsgs) return
|
||||||
gossipSend(this, data)
|
gossipSend(this, data)
|
||||||
|
|||||||
@@ -48,3 +48,13 @@ test('searchAll getDocTerms', async (t) => {
|
|||||||
t.alike(m.getDocTerms('d1'), ['cat', 'dog'])
|
t.alike(m.getDocTerms('d1'), ['cat', 'dog'])
|
||||||
await m.close()
|
await m.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('topTerms', async (t) => {
|
||||||
|
const m = new HyperP2PInvertedIndex()
|
||||||
|
m.index('a', ['x'])
|
||||||
|
m.index('b', ['x', 'y'])
|
||||||
|
m.index('c', ['x'])
|
||||||
|
t.is(m.topTerms(1)[0].term, 'x')
|
||||||
|
t.is(m.topTerms(1)[0].docs, 3)
|
||||||
|
await m.close()
|
||||||
|
})
|
||||||
|
|||||||
@@ -69,6 +69,22 @@ class HyperP2PDedupFilter extends EventEmitter {
|
|||||||
this.emit('compact', { size: this._seen.size })
|
this.emit('compact', { size: this._seen.size })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
filterNew (ids) {
|
||||||
|
if (!Array.isArray(ids)) throw new Error('ids must be an array')
|
||||||
|
const out = []
|
||||||
|
for (const id of ids) {
|
||||||
|
if (!this.seen(id)) {
|
||||||
|
this.add(id)
|
||||||
|
out.push(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
listRecent (limit = 100) {
|
||||||
|
const arr = Array.from(this._seen)
|
||||||
|
return arr.slice(-Math.max(0, limit | 0))
|
||||||
|
}
|
||||||
|
|
||||||
getStats () {
|
getStats () {
|
||||||
return { ...this._stats, seen: this._seen.size, maxIds: this.maxIds, protocol: PROTOCOL }
|
return { ...this._stats, seen: this._seen.size, maxIds: this.maxIds, protocol: PROTOCOL }
|
||||||
|
|||||||
@@ -16,6 +16,15 @@ test('dedup-filter: compact', async (t) => {
|
|||||||
t.ok(f._seen.size <= 10)
|
t.ok(f._seen.size <= 10)
|
||||||
await f.close()
|
await f.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('filterNew and listRecent', async (t) => {
|
||||||
|
const f = new HyperP2PDedupFilter()
|
||||||
|
f.add('old')
|
||||||
|
const fresh = f.filterNew(['old', 'new1', 'new2'])
|
||||||
|
t.alike(fresh, ['new1', 'new2'])
|
||||||
|
t.ok(f.listRecent(10).includes('new2'))
|
||||||
|
await f.close()
|
||||||
|
})
|
||||||
test('hyper-p2p-dedup-filter: close without leak', async (t) => {
|
test('hyper-p2p-dedup-filter: close without leak', async (t) => {
|
||||||
const m = new HyperP2PDedupFilter()
|
const m = new HyperP2PDedupFilter()
|
||||||
await m.close()
|
await m.close()
|
||||||
|
|||||||
@@ -81,6 +81,15 @@ class HyperP2PBeeDiffFollow extends EventEmitter {
|
|||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
|
seekVersion (version) {
|
||||||
|
const v = Number(version)
|
||||||
|
if (!Number.isFinite(v) || v < 0) throw new Error('version must be non-negative')
|
||||||
|
this._cursor = v
|
||||||
|
this._buffer = this._buffer.filter((e) => e.version > v)
|
||||||
|
this.emit('seek', { cursor: this._cursor })
|
||||||
|
return this._cursor
|
||||||
|
}
|
||||||
|
|
||||||
getStats () {
|
getStats () {
|
||||||
return {
|
return {
|
||||||
...this._stats,
|
...this._stats,
|
||||||
|
|||||||
@@ -38,3 +38,13 @@ test('getStats', async (t) => {
|
|||||||
t.is(m.getStats().protocol, 'bee-diff-follow/v1')
|
t.is(m.getStats().protocol, 'bee-diff-follow/v1')
|
||||||
await m.close()
|
await m.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('seekVersion', async (t) => {
|
||||||
|
const m = new HyperP2PBeeDiffFollow()
|
||||||
|
m.recordDiff({ version: 1, key: 'a' })
|
||||||
|
m.recordDiff({ version: 5, key: 'b' })
|
||||||
|
m.seekVersion(3)
|
||||||
|
t.is(m.diffCursor(), 3)
|
||||||
|
t.is(m.pullDiff(10).length, 1)
|
||||||
|
await m.close()
|
||||||
|
})
|
||||||
|
|||||||
@@ -50,6 +50,15 @@ class HyperP2PBeeRangeWatch extends EventEmitter {
|
|||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
|
notifyBatch (entries) {
|
||||||
|
if (!Array.isArray(entries)) throw new Error('entries must be an array')
|
||||||
|
let total = 0
|
||||||
|
for (const e of entries) {
|
||||||
|
if (e && e.key != null) total += this.emitChange(e.key, e.value, e.op || 'put')
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
unwatch (id) {
|
unwatch (id) {
|
||||||
const ok = this._watches.delete(id)
|
const ok = this._watches.delete(id)
|
||||||
if (ok) this._stats.unwatch++
|
if (ok) this._stats.unwatch++
|
||||||
|
|||||||
@@ -46,3 +46,16 @@ test('watch notify aliases listWatches', async (t) => {
|
|||||||
t.is(m.listWatches().length, 1)
|
t.is(m.listWatches().length, 1)
|
||||||
await m.close()
|
await m.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('notifyBatch', async (t) => {
|
||||||
|
const m = new HyperP2PBeeRangeWatch()
|
||||||
|
let hit = 0
|
||||||
|
m.watchRange('a', 'z', () => { hit++ })
|
||||||
|
const n = m.notifyBatch([
|
||||||
|
{ key: 'b', value: 1 },
|
||||||
|
{ key: 'c', value: 2 }
|
||||||
|
])
|
||||||
|
t.is(n, 2)
|
||||||
|
t.is(hit, 2)
|
||||||
|
await m.close()
|
||||||
|
})
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class HyperP2PMultisigThreshold extends EventEmitter {
|
|||||||
this._peerMsgs = null
|
this._peerMsgs = null
|
||||||
}
|
}
|
||||||
|
|
||||||
createProposal (id, signers, threshold) {
|
createProposal (id, signers, threshold, opts = {}) {
|
||||||
assertNonEmpty(id, 'id')
|
assertNonEmpty(id, 'id')
|
||||||
if (!Array.isArray(signers) || signers.length === 0) {
|
if (!Array.isArray(signers) || signers.length === 0) {
|
||||||
throw new Error('signers must be a non-empty array')
|
throw new Error('signers must be a non-empty array')
|
||||||
@@ -24,12 +24,14 @@ class HyperP2PMultisigThreshold extends EventEmitter {
|
|||||||
if (threshold < 1 || threshold > signers.length) {
|
if (threshold < 1 || threshold > signers.length) {
|
||||||
throw new Error('threshold must be between 1 and signers.length')
|
throw new Error('threshold must be between 1 and signers.length')
|
||||||
}
|
}
|
||||||
|
const expiresAt = opts.expiresAt ?? (opts.ttlMs ? Date.now() + opts.ttlMs : null)
|
||||||
const proposal = {
|
const proposal = {
|
||||||
id,
|
id,
|
||||||
signers: [...signers],
|
signers: [...signers],
|
||||||
threshold,
|
threshold,
|
||||||
signatures: new Set(),
|
signatures: new Set(),
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
|
expiresAt,
|
||||||
approved: false
|
approved: false
|
||||||
}
|
}
|
||||||
this._proposals.set(id, proposal)
|
this._proposals.set(id, proposal)
|
||||||
@@ -66,9 +68,47 @@ class HyperP2PMultisigThreshold extends EventEmitter {
|
|||||||
isApproved (id) {
|
isApproved (id) {
|
||||||
assertNonEmpty(id, 'id')
|
assertNonEmpty(id, 'id')
|
||||||
const p = this._proposals.get(id)
|
const p = this._proposals.get(id)
|
||||||
|
if (p && p.expiresAt && Date.now() > p.expiresAt) return false
|
||||||
return !!(p && p.approved)
|
return !!(p && p.approved)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
signatureCount (id) {
|
||||||
|
const p = this._proposals.get(id)
|
||||||
|
return p ? p.signatures.size : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
listProposals () {
|
||||||
|
return [...this._proposals.values()].map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
signers: p.signers,
|
||||||
|
threshold: p.threshold,
|
||||||
|
signatures: p.signatures.size,
|
||||||
|
approved: p.approved,
|
||||||
|
expiresAt: p.expiresAt,
|
||||||
|
createdAt: p.createdAt
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
expireProposal (id) {
|
||||||
|
assertNonEmpty(id, 'id')
|
||||||
|
const ok = this._proposals.delete(id)
|
||||||
|
if (ok) this.emit('expired', { id })
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
sweepExpired () {
|
||||||
|
const now = Date.now()
|
||||||
|
let n = 0
|
||||||
|
for (const [id, p] of this._proposals) {
|
||||||
|
if (p.expiresAt && now > p.expiresAt && !p.approved) {
|
||||||
|
this._proposals.delete(id)
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (n) this.emit('sweep', { removed: n })
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
_gossip (data) {
|
_gossip (data) {
|
||||||
if (!this._peerMsgs) return
|
if (!this._peerMsgs) return
|
||||||
gossipSend(this, data)
|
gossipSend(this, data)
|
||||||
|
|||||||
@@ -36,3 +36,12 @@ test('getStats', async (t) => {
|
|||||||
t.is(m.getStats().created, 1)
|
t.is(m.getStats().created, 1)
|
||||||
await m.close()
|
await m.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('ttl sweep and listProposals', async (t) => {
|
||||||
|
const m = new HyperP2PMultisigThreshold()
|
||||||
|
m.createProposal('exp', ['a', 'b'], 2, { expiresAt: Date.now() - 1 })
|
||||||
|
t.is(m.listProposals().length, 1)
|
||||||
|
t.is(m.sweepExpired(), 1)
|
||||||
|
t.is(m.listProposals().length, 0)
|
||||||
|
await m.close()
|
||||||
|
})
|
||||||
|
|||||||
@@ -102,6 +102,39 @@ class HyperP2PTrustGraph extends EventEmitter {
|
|||||||
return nodes.size
|
return nodes.size
|
||||||
}
|
}
|
||||||
|
|
||||||
|
listNeighbors (from) {
|
||||||
|
const f = typeof from === 'string' ? from : b4a.toString(from, 'hex')
|
||||||
|
const m = this._edges.get(f)
|
||||||
|
return m ? [...m.keys()] : []
|
||||||
|
}
|
||||||
|
|
||||||
|
removeEdge (from, to) {
|
||||||
|
const f = typeof from === 'string' ? from : b4a.toString(from, 'hex')
|
||||||
|
const t = typeof to === 'string' ? to : b4a.toString(to, 'hex')
|
||||||
|
const m = this._edges.get(f)
|
||||||
|
if (!m || !m.has(t)) return false
|
||||||
|
m.delete(t)
|
||||||
|
if (!m.size) this._edges.delete(f)
|
||||||
|
this.emit('edge-removed', { from: f, to: t })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
trustedPeers (from, minScore = 0.5) {
|
||||||
|
const f = typeof from === 'string' ? from : b4a.toString(from, 'hex')
|
||||||
|
const candidates = new Set()
|
||||||
|
for (const edge of this.edges()) {
|
||||||
|
candidates.add(edge.from)
|
||||||
|
candidates.add(edge.to)
|
||||||
|
}
|
||||||
|
const out = []
|
||||||
|
for (const peer of candidates) {
|
||||||
|
if (peer === f) continue
|
||||||
|
const score = this.trustScore(f, peer)
|
||||||
|
if (score >= minScore) out.push({ peer, score })
|
||||||
|
}
|
||||||
|
return out.sort((a, b) => b.score - a.score)
|
||||||
|
}
|
||||||
|
|
||||||
toJSON () {
|
toJSON () {
|
||||||
return { edges: this.edges(), decay: this.decay }
|
return { edges: this.edges(), decay: this.decay }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,18 @@ test('trust-graph: edge and score', async (t) => {
|
|||||||
await g.close()
|
await g.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('trust-graph: neighbors and trustedPeers', async (t) => {
|
||||||
|
const g = new HyperP2PTrustGraph()
|
||||||
|
g.addEdge('a', 'b', 0.9)
|
||||||
|
t.ok(g.listNeighbors('a').includes('b'))
|
||||||
|
t.ok(g.removeEdge('a', 'b'))
|
||||||
|
t.is(g.listNeighbors('a').length, 0)
|
||||||
|
g.addEdge('a', 'b', 1)
|
||||||
|
const trusted = g.trustedPeers('a', 0.5)
|
||||||
|
t.ok(trusted.length >= 1)
|
||||||
|
await g.close()
|
||||||
|
})
|
||||||
|
|
||||||
test('trust-graph: merge', async (t) => {
|
test('trust-graph: merge', async (t) => {
|
||||||
const a = new HyperP2PTrustGraph()
|
const a = new HyperP2PTrustGraph()
|
||||||
const b = new HyperP2PTrustGraph()
|
const b = new HyperP2PTrustGraph()
|
||||||
|
|||||||
Reference in New Issue
Block a user