reploid 0.0.0-stage → 0.1.1
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/apps/poolday/config/chat-files.json +27 -0
- package/apps/poolday/config/partition-generation.js +21 -0
- package/apps/poolday/config/partition-policy.json +74 -0
- package/apps/poolday/config/partition-releases.json +3 -0
- package/apps/poolday/config/work-profile.json +122 -0
- package/apps/poolday/host/chat-view.js +47 -0
- package/apps/poolday/host/earning/client.js +199 -0
- package/apps/poolday/host/earning/contribution.js +168 -0
- package/apps/poolday/host/partition-release.js +33 -0
- package/apps/poolday/host/work-partition-artifact-source.js +37 -0
- package/apps/poolday/host/work-partition-capsule.js +39 -0
- package/apps/poolday/host/work-partition-plan.js +167 -0
- package/apps/poolday/host/work-partition-tokenizer-guard.js +21 -0
- package/apps/poolday/infrastructure/model-storage-lock.js +22 -0
- package/apps/poolday/infrastructure/pack-release-storage.js +52 -0
- package/apps/poolday/pool/pack-operation.js +161 -0
- package/apps/poolday/pool/pack-release-policy.js +44 -0
- package/apps/poolday/pool/peer-pack-data-channel.js +217 -0
- package/apps/poolday/providers/work-adapter.js +67 -0
- package/apps/poolday/providers/work-device.js +33 -0
- package/apps/poolday/providers/work-generation-completion.js +55 -0
- package/apps/poolday/providers/work-provider.js +209 -0
- package/apps/poolday/providers/work-resident-provider.js +113 -0
- package/deploy/artifacts/doppler-gpu-0.7.1.tgz +0 -0
- package/docs/fleet-service.md +214 -0
- package/docs/native-participant.md +542 -0
- package/native-delivery.json +1174 -0
- package/package.json +29 -4
- package/packages/reploid/src/artifacts/binary.js +25 -0
- package/packages/reploid/src/artifacts/canonical-json.js +23 -0
- package/packages/reploid/src/artifacts/custody/exchange.js +402 -0
- package/packages/reploid/src/artifacts/custody/piece-acquisition.js +70 -0
- package/packages/reploid/src/artifacts/custody/runtime.js +404 -0
- package/packages/reploid/src/artifacts/custody/supply-budget.js +19 -0
- package/packages/reploid/src/artifacts/custody-certificate.js +123 -0
- package/packages/reploid/src/artifacts/delivery-attestation.js +64 -0
- package/packages/reploid/src/artifacts/generation-terminal.js +36 -0
- package/packages/reploid/src/artifacts/identity.js +91 -0
- package/packages/reploid/src/artifacts/participant-proofs.js +159 -0
- package/packages/reploid/src/artifacts/receipt.js +131 -0
- package/packages/reploid/src/artifacts/signatures.js +94 -0
- package/packages/reploid/src/artifacts/whole-model-receipt.js +42 -0
- package/packages/reploid/src/cancellation.js +44 -0
- package/packages/reploid/src/config/defaults.json +110 -0
- package/packages/reploid/src/config/index.js +209 -0
- package/packages/reploid/src/config/request-overrides.json +9 -0
- package/packages/reploid/src/config/transport-defaults.json +7 -0
- package/packages/reploid/src/hashing.js +30 -0
- package/packages/reploid/src/measurement/capacity-utilization.js +321 -0
- package/packages/reploid/src/measurement/index.js +5 -0
- package/packages/reploid/src/measurement/readiness-records.js +310 -0
- package/packages/reploid/src/mesh/computer-identity.js +50 -0
- package/packages/reploid/src/mesh/computer-identity.policy.json +8 -0
- package/packages/reploid/src/mesh/contribution.js +141 -0
- package/packages/reploid/src/mesh/generation-recovery.js +58 -0
- package/packages/reploid/src/mesh/gpu-signature-shader.js +27 -0
- package/packages/reploid/src/mesh/gpu-signature.js +136 -0
- package/packages/reploid/src/mesh/gpu-signature.policy.json +11 -0
- package/packages/reploid/src/mesh/index.js +66 -0
- package/packages/reploid/src/mesh/partitions/automatic-ordered-partitions.js +540 -0
- package/packages/reploid/src/mesh/partitions/automatic-partitions.js +298 -0
- package/packages/reploid/src/mesh/partitions/partition-attempt-evidence.js +282 -0
- package/packages/reploid/src/mesh/partitions/partition-attempt-queue-policy.json +6 -0
- package/packages/reploid/src/mesh/partitions/partition-attempt-queue.js +112 -0
- package/packages/reploid/src/mesh/partitions/partition-canonical-contract.js +167 -0
- package/packages/reploid/src/mesh/partitions/partition-canonical-execution.js +168 -0
- package/packages/reploid/src/mesh/partitions/partition-chat-canonical.js +49 -0
- package/packages/reploid/src/mesh/partitions/partition-chat.js +125 -0
- package/packages/reploid/src/mesh/partitions/partition-contract.js +192 -0
- package/packages/reploid/src/mesh/partitions/partition-device-admission.js +96 -0
- package/packages/reploid/src/mesh/partitions/partition-discovery.js +96 -0
- package/packages/reploid/src/mesh/partitions/partition-entry-publication.js +183 -0
- package/packages/reploid/src/mesh/partitions/partition-entry.js +434 -0
- package/packages/reploid/src/mesh/partitions/partition-generation-request.js +33 -0
- package/packages/reploid/src/mesh/partitions/partition-grants.js +277 -0
- package/packages/reploid/src/mesh/partitions/partition-link.js +103 -0
- package/packages/reploid/src/mesh/partitions/partition-network.js +109 -0
- package/packages/reploid/src/mesh/partitions/partition-ordered-chat.js +290 -0
- package/packages/reploid/src/mesh/partitions/partition-ordered-peer.js +415 -0
- package/packages/reploid/src/mesh/partitions/partition-ordered-publication.js +100 -0
- package/packages/reploid/src/mesh/partitions/partition-peer.js +314 -0
- package/packages/reploid/src/mesh/partitions/partition-placement.js +143 -0
- package/packages/reploid/src/mesh/partitions/partition-preparation-progress.js +25 -0
- package/packages/reploid/src/mesh/partitions/partition-receiver-policy.json +7 -0
- package/packages/reploid/src/mesh/partitions/partition-reservations.js +75 -0
- package/packages/reploid/src/mesh/partitions/partition-route-envelope-policy.json +16 -0
- package/packages/reploid/src/mesh/partitions/partition-route-envelope.js +74 -0
- package/packages/reploid/src/mesh/partitions/partition-route-execution.js +409 -0
- package/packages/reploid/src/mesh/partitions/partition-route-network.js +179 -0
- package/packages/reploid/src/mesh/partitions/partition-route-ranking.js +268 -0
- package/packages/reploid/src/mesh/partitions/partition-runner.js +319 -0
- package/packages/reploid/src/mesh/partitions/partition-stage-assignment.js +109 -0
- package/packages/reploid/src/mesh/partitions/partition-stage-commit.js +103 -0
- package/packages/reploid/src/mesh/partitions/partition-step-receiver.js +291 -0
- package/packages/reploid/src/mesh/partitions/partition-summary.js +122 -0
- package/packages/reploid/src/mesh/partitions/preparation-owner.js +40 -0
- package/packages/reploid/src/mesh/partitions/resident-error-codes.json +10 -0
- package/packages/reploid/src/mesh/partitions/resident-partition.js +333 -0
- package/packages/reploid/src/mesh/partitions/resident-route.js +46 -0
- package/packages/reploid/src/mesh/partitions/signed-resident-factory.js +59 -0
- package/packages/reploid/src/mesh/peer-generation.js +896 -0
- package/packages/reploid/src/mesh/peer-identity.js +87 -0
- package/packages/reploid/src/mesh/placement-beliefs.js +107 -0
- package/packages/reploid/src/mesh/preparation-progress.js +46 -0
- package/packages/reploid/src/mesh/provider-hardware.js +11 -0
- package/packages/reploid/src/mesh/remote-generation-requests.js +53 -0
- package/packages/reploid/src/mesh/swarm-coordination.js +140 -0
- package/packages/reploid/src/mesh/web-access-network.js +112 -0
- package/packages/reploid/src/mesh/web-access-policy.json +17 -0
- package/packages/reploid/src/retrieval/citations.js +156 -0
- package/packages/reploid/src/retrieval/claim-support.js +113 -0
- package/packages/reploid/src/retrieval/costs.js +101 -0
- package/packages/reploid/src/retrieval/fixed-provider.js +34 -0
- package/packages/reploid/src/retrieval/index.js +20 -0
- package/packages/reploid/src/retrieval/lexical-support.js +27 -0
- package/packages/reploid/src/retrieval/model-reviewer.js +67 -0
- package/packages/reploid/src/retrieval/page-fetch.json +10 -0
- package/packages/reploid/src/retrieval/page-text.js +182 -0
- package/packages/reploid/src/retrieval/passage.js +37 -0
- package/packages/reploid/src/retrieval/policy.json +14 -0
- package/packages/reploid/src/retrieval/retrieval.js +23 -0
- package/packages/reploid/src/retrieval/review-queue.js +76 -0
- package/packages/reploid/src/retrieval/sources.js +263 -0
- package/packages/reploid/src/retrieval/support-review.js +155 -0
- package/packages/reploid/src/retrieval/text.js +21 -0
- package/packages/reploid/src/rules/contribution.rules.json +8 -0
- package/packages/reploid/src/rules/routing.rules.json +24 -0
- package/packages/reploid/src/transport/assignment.js +571 -0
- package/packages/reploid/src/transport/bounded-channel-writer.js +46 -0
- package/packages/reploid/src/transport/index.js +11 -0
- package/packages/reploid/src/transport/introduction-admission.js +24 -0
- package/packages/reploid/src/transport/introduction-bootstrap.js +112 -0
- package/packages/reploid/src/transport/introduction-policy.json +12 -0
- package/packages/reploid/src/transport/introduction-records.js +90 -0
- package/packages/reploid/src/transport/introductions.js +209 -0
- package/packages/reploid/src/transport/partition-data-channel.js +421 -0
- package/packages/reploid/src/transport/partition-errors.json +16 -0
- package/packages/reploid/src/transport/retry-policy.js +36 -0
- package/packages/reploid/src/transport/room.js +502 -0
- package/packages/reploid/src/transport/sdp-fingerprint.js +5 -0
- package/packages/reploid/src/transport/signaling.js +571 -0
- package/packages/reploid/src/transport/swarm.js +1247 -0
- package/packages/reploid/src/transport/tool-offer-channel.js +137 -0
- package/server/browser-roots.js +42 -0
- package/server/browser-web-worker.js +94 -0
- package/server/chat-completions.js +45 -0
- package/server/fleet/browser-bridge.js +145 -0
- package/server/fleet/contract.js +42 -0
- package/server/fleet/credit-evidence.js +103 -0
- package/server/fleet/credit-recovery.js +175 -0
- package/server/fleet/credits.js +178 -0
- package/server/fleet/execution-admission.js +32 -0
- package/server/fleet/native-host.js +817 -0
- package/server/fleet/placement.js +132 -0
- package/server/fleet/requester.js +346 -0
- package/server/fleet/service.js +311 -0
- package/server/fleet/terminal-policy.js +83 -0
- package/server/fleet/text-api.js +94 -0
- package/server/hashing.js +35 -0
- package/server/http-request-owner.js +49 -0
- package/server/messages.js +57 -0
- package/server/native-canonical-epochs.js +90 -0
- package/server/native-change-log.js +40 -0
- package/server/native-earning.js +203 -0
- package/server/native-generation-journal.js +46 -0
- package/server/native-model-config.js +57 -0
- package/server/native-model-files.js +382 -0
- package/server/native-model-origin.js +54 -0
- package/server/native-model-origins.json +12 -0
- package/server/native-node.js +85 -0
- package/server/native-owner/controls.js +162 -0
- package/server/native-owner/monitor.js +251 -0
- package/server/native-owner/policy.js +273 -0
- package/server/native-owner/rate.js +92 -0
- package/server/native-participant.js +866 -0
- package/server/native-readiness-log.js +30 -0
- package/server/native-release-storage.js +64 -0
- package/server/native-shader-cache.js +369 -0
- package/server/native-supply-budget.js +66 -0
- package/server/native-whole-earning.js +67 -0
- package/server/native-whole-model-fill.js +182 -0
- package/server/native-whole-model.js +130 -0
- package/server/public-web-worker.js +66 -0
- package/server/responses.js +92 -0
- package/server/search/errors.js +47 -0
- package/server/search/page-fetch.js +185 -0
- package/server/text-api.js +83 -0
- package/server/text-recovery.js +45 -0
- package/server/text-request-options.js +23 -0
- package/server/text-requester.js +231 -0
- package/server/token-auth.js +9 -0
- package/server/whole-model-requester.js +99 -0
- package/shared/config/chat-models.json +4685 -0
- package/shared/config/doppler-execution-contracts.js +32 -0
- package/shared/config/doppler-execution-contracts.json +43 -0
- package/shared/config/doppler-generation-contract.js +223 -0
- package/shared/config/doppler-local-models.js +79 -0
- package/shared/config/doppler-package.json +7 -0
- package/shared/config/model-manifests/a0dc69e917133a82780823d17fc771c0f6b882d7c86c571deeaef45be2ff108b.json +6434 -0
- package/shared/config/model-manifests/e985e3cdad60d0cb57d84b436161e66ab5b3f6589a168de6f00b4610e5f1fb59.json +16534 -0
- package/shared/config/model-manifests/fa6eb7bbc3ba63571f8cede780b877d9272bde948697af93dd3d33837cb46137.json +7175 -0
- package/shared/config/model-pieces/qwen-0-8b-pieces.json +1506 -0
- package/shared/config/model-pieces/qwen-2b-pieces.json +4800 -0
- package/shared/config/model-pieces/qwen35b-q4_0-pieces.json +28880 -0
- package/shared/config/reploid-library.json +49 -0
- package/shared/config/swarm-bootstrap.json +21 -0
- package/shared/infrastructure/doppler-runtime-service.js +261 -0
- package/shared/pool/executable-pack.js +87 -0
- package/shared/pool/pack-operation-adapters.js +124 -0
- package/shared/pool/pack-operation-policy.js +75 -0
- package/shared/pool/pool-config.json +2280 -0
- package/shared/vendor/reploid/adapters/doppler-session.js +372 -0
- package/shared/vendor/reploid/artifacts/binary.js +25 -0
- package/shared/vendor/reploid/artifacts/canonical-json.js +23 -0
- package/shared/vendor/reploid/artifacts/delivery-attestation.js +64 -0
- package/shared/vendor/reploid/artifacts/identity.js +91 -0
- package/shared/vendor/reploid/artifacts/manifest-identity.js +17 -0
- package/shared/vendor/reploid/artifacts/participant-proofs.js +159 -0
- package/shared/vendor/reploid/artifacts/signatures.js +94 -0
- package/shared/vendor/reploid/config/defaults.json +110 -0
- package/shared/vendor/reploid/config/index.js +209 -0
- package/shared/vendor/reploid/config/request-overrides.json +9 -0
- package/shared/vendor/reploid/hashing.js +30 -0
- package/shared/vendor/reploid/mesh/generation-context-cache.js +97 -0
- package/shared/vendor/reploid/mesh/partitions/partition-attempt-evidence.js +282 -0
- package/shared/vendor/reploid/mesh/partitions/partition-contract.js +192 -0
- package/shared/vendor/reploid/mesh/partitions/partition-receiver-policy.json +7 -0
- package/shared/vendor/reploid/mesh/partitions/partition-route-ranking.js +268 -0
- package/shared/vendor/reploid/mesh/preparation-progress.js +46 -0
- package/shared/vendor/reploid/mesh/provider-hardware.js +11 -0
- package/shared/vendor/reploid/rules/routing.rules.json +24 -0
- package/shared/vendor/reploid/transport/partition-errors.json +16 -0
- package/README.md +0 -3
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/** Fresh proof of a signing identity bound to the current WebRTC certificates. */
|
|
2
|
+
import { createPeerIdFromPublicJwk, importSigningKey, importVerificationKey,
|
|
3
|
+
getIdentitySignAlgorithm, encodeBytes, fromBase64Url, toBase64Url } from '../artifacts/identity.js';
|
|
4
|
+
import { createRemoteGenerationRequests } from './remote-generation-requests.js';
|
|
5
|
+
import policy from './computer-identity.policy.json' with { type: 'json' };
|
|
6
|
+
|
|
7
|
+
const challengeType = 'reploid:identity-challenge', proofType = 'reploid:identity-proof';
|
|
8
|
+
const sameBinding = (a, b) => !!a && !!b && a.local === b.local && a.remote === b.remote;
|
|
9
|
+
const signedBytes = (challenge, participantId) => encodeBytes(JSON.stringify([
|
|
10
|
+
'reploid.mesh-recipient/v1', challenge.roomId, challenge.nonce,
|
|
11
|
+
challenge.requester, challenge.provider, challenge.local, challenge.remote, participantId
|
|
12
|
+
]));
|
|
13
|
+
|
|
14
|
+
export function createMeshPeerIdentity({ transport, getIdentity, roomId, timeoutMs, maxPending, onVerified = () => {} }) {
|
|
15
|
+
let closed = false;
|
|
16
|
+
const signing = new Set();
|
|
17
|
+
const verified = new Map();
|
|
18
|
+
const requests = createRemoteGenerationRequests({ timeoutMs, maxPending, sendCancel() {} });
|
|
19
|
+
// Label discovery must never consume a foreground generation/recovery proof slot.
|
|
20
|
+
const observations = createRemoteGenerationRequests({ timeoutMs, maxPending: policy.maxBackgroundProofs, sendCancel() {} });
|
|
21
|
+
const binding = peerId => transport.getPeerBinding?.(peerId) || null;
|
|
22
|
+
const attempt = peerId => transport.getPeerStates?.().find(peer => peer.id === peerId)?.attemptId ?? null;
|
|
23
|
+
const getVerified = peerId => {
|
|
24
|
+
const record = verified.get(peerId);
|
|
25
|
+
return !closed && record && sameBinding(record.channel, binding(peerId)) && record.attemptId === attempt(peerId)
|
|
26
|
+
? record.participantId : null;
|
|
27
|
+
};
|
|
28
|
+
const answer = async (peerId, input) => {
|
|
29
|
+
if (closed || signing.size >= maxPending + policy.maxBackgroundProofs) return;
|
|
30
|
+
const channel = binding(peerId), challenge = structuredClone(input);
|
|
31
|
+
if (!channel || !challenge || challenge.roomId !== roomId || challenge.requester !== peerId
|
|
32
|
+
|| challenge.provider !== transport._getPeerId() || typeof challenge.nonce !== 'string'
|
|
33
|
+
|| !/^[a-f0-9-]{36}$/.test(challenge.nonce)
|
|
34
|
+
|| challenge.local !== channel.remote || challenge.remote !== channel.local) return;
|
|
35
|
+
// Discovery and generation can verify the same connection concurrently.
|
|
36
|
+
// Deduplicate the challenge, not the peer; each fresh nonce needs its proof.
|
|
37
|
+
const requestKey = JSON.stringify([peerId, challenge.nonce]);
|
|
38
|
+
if (signing.has(requestKey)) return;
|
|
39
|
+
signing.add(requestKey);
|
|
40
|
+
try {
|
|
41
|
+
const identity = getIdentity(), key = await importSigningKey(identity);
|
|
42
|
+
const signature = await crypto.subtle.sign(getIdentitySignAlgorithm(identity), key, signedBytes(challenge, identity.peerId));
|
|
43
|
+
if (!closed && sameBinding(channel, binding(peerId))) transport.sendToPeer(peerId, proofType, {
|
|
44
|
+
nonce: challenge.nonce, participantId: identity.peerId, publicJwk: identity.publicJwk,
|
|
45
|
+
signature: toBase64Url(signature)
|
|
46
|
+
});
|
|
47
|
+
} finally { signing.delete(requestKey); }
|
|
48
|
+
};
|
|
49
|
+
const accept = async (peerId, input) => {
|
|
50
|
+
const owner = requests.get(input?.nonce, peerId) ? requests : observations;
|
|
51
|
+
const pending = owner.get(input?.nonce, peerId);
|
|
52
|
+
if (!pending || pending.processing) return;
|
|
53
|
+
pending.processing = true;
|
|
54
|
+
try {
|
|
55
|
+
const proof = structuredClone(input);
|
|
56
|
+
if (await createPeerIdFromPublicJwk(proof.publicJwk) !== proof.participantId) throw new Error('Recipient signing identity mismatch');
|
|
57
|
+
const key = await importVerificationKey(proof.publicJwk);
|
|
58
|
+
if (!await crypto.subtle.verify(getIdentitySignAlgorithm(proof.publicJwk), key,
|
|
59
|
+
fromBase64Url(proof.signature), signedBytes(pending.challenge, proof.participantId))) throw new Error('Invalid recipient identity proof');
|
|
60
|
+
if (closed || owner.get(proof.nonce, peerId) !== pending) return;
|
|
61
|
+
if (!sameBinding(pending.channel, binding(peerId))) throw new Error('Recipient connection changed during verification');
|
|
62
|
+
if (pending.attemptId !== attempt(peerId)) throw new Error('Recipient connection attempt changed during verification');
|
|
63
|
+
verified.set(peerId, { participantId: proof.participantId, channel: pending.channel, attemptId: pending.attemptId });
|
|
64
|
+
owner.settle(pending, { response: proof.participantId });
|
|
65
|
+
try { onVerified(peerId, proof.participantId); } catch { /* Observers cannot invalidate a proof. */ }
|
|
66
|
+
} catch (error) { owner.settle(pending, { error }); }
|
|
67
|
+
};
|
|
68
|
+
transport.onMessage(challengeType, (peerId, payload) => { answer(peerId, payload).catch(() => {}); });
|
|
69
|
+
transport.onMessage(proofType, (peerId, payload) => { accept(peerId, payload).catch(() => {}); });
|
|
70
|
+
const verify = async (owner, peerId, signal) => {
|
|
71
|
+
const channel = binding(peerId);
|
|
72
|
+
if (!channel) return null;
|
|
73
|
+
const challenge = { roomId, nonce: crypto.randomUUID(), requester: transport._getPeerId(), provider: peerId, ...channel };
|
|
74
|
+
const attemptId = attempt(peerId);
|
|
75
|
+
const participantId = await owner.start({ requestId: challenge.nonce, providerPeerId: peerId, signal, channel, challenge, attemptId },
|
|
76
|
+
() => transport.sendToPeer(peerId, challengeType, challenge));
|
|
77
|
+
if (attemptId && sameBinding(channel, binding(peerId))) transport.observePeerAuthentication?.(peerId, attemptId);
|
|
78
|
+
return participantId;
|
|
79
|
+
};
|
|
80
|
+
return Object.freeze({
|
|
81
|
+
getVerified,
|
|
82
|
+
verify: (peerId, signal) => verify(requests, peerId, signal),
|
|
83
|
+
observe: (peerId, signal) => verify(observations, peerId, signal),
|
|
84
|
+
retirePeer(peerId) { verified.delete(peerId); requests.retirePeer(peerId); observations.retirePeer(peerId); },
|
|
85
|
+
close() { closed = true; verified.clear(); requests.close(); observations.close(); }
|
|
86
|
+
});
|
|
87
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { snapshotJson, freezeJson } from '../config/index.js';
|
|
2
|
+
|
|
3
|
+
const fail = message => { throw new TypeError(`Placement beliefs: ${message}`); };
|
|
4
|
+
const fields = (value, names) => {
|
|
5
|
+
if (!value || Array.isArray(value) || typeof value !== 'object'
|
|
6
|
+
|| Object.keys(value).some(key => !names.includes(key))
|
|
7
|
+
|| names.some(key => !Object.hasOwn(value, key))) fail(`expected fields ${names.join(', ')}`);
|
|
8
|
+
};
|
|
9
|
+
const id = value => {
|
|
10
|
+
if (typeof value !== 'string' || !/^[a-zA-Z0-9._:-]{1,160}$/.test(value)) fail('bounded identifier required');
|
|
11
|
+
};
|
|
12
|
+
const bounded = (value, minimum, maximum) => {
|
|
13
|
+
if (!Number.isFinite(value) || value < minimum || value > maximum) fail('finite bounded number required');
|
|
14
|
+
};
|
|
15
|
+
const lexical = (a, b) => a < b ? -1 : a > b ? 1 : 0;
|
|
16
|
+
|
|
17
|
+
/** A categorical likelihood with Dirichlet priors. Outcome costs are host-declared
|
|
18
|
+
* estimates, not measured hardware facts. The host owns observation admission.
|
|
19
|
+
* @param {import('./placement-beliefs.js').PlacementBeliefPolicy} input
|
|
20
|
+
*/
|
|
21
|
+
export function resolvePlacementBeliefPolicy(input) {
|
|
22
|
+
const policy = snapshotJson(input);
|
|
23
|
+
fields(policy, ['schema', 'id', 'cohortId', 'maxObservations', 'maxAgeMs', 'completionValue',
|
|
24
|
+
'timeCostPerMs', 'costUnit', 'outcomes']);
|
|
25
|
+
if (policy.schema !== 'reploid.placement-belief-policy/v1') fail('unsupported policy');
|
|
26
|
+
id(policy.id); id(policy.cohortId); id(policy.costUnit);
|
|
27
|
+
bounded(policy.maxObservations, 1, 4096); bounded(policy.maxAgeMs, 1, Number.MAX_SAFE_INTEGER);
|
|
28
|
+
if (!Number.isSafeInteger(policy.maxObservations) || !Number.isSafeInteger(policy.maxAgeMs)) fail('integer bounds required');
|
|
29
|
+
bounded(policy.completionValue, 0, 1e9); bounded(policy.timeCostPerMs, 0, 1e6);
|
|
30
|
+
if (!Array.isArray(policy.outcomes) || policy.outcomes.length < 2 || policy.outcomes.length > 64
|
|
31
|
+
|| new Set(policy.outcomes.map(row => row.id)).size !== policy.outcomes.length) fail('bounded distinct outcomes required');
|
|
32
|
+
for (const outcome of policy.outcomes) {
|
|
33
|
+
fields(outcome, ['id', 'prior', 'completed', 'latencyMs', 'totalCost']);
|
|
34
|
+
id(outcome.id); bounded(outcome.prior, 1e-6, 1e6);
|
|
35
|
+
if (typeof outcome.completed !== 'boolean') fail('explicit completion outcome required');
|
|
36
|
+
bounded(outcome.latencyMs, 0, 1e9); bounded(outcome.totalCost, 0, 1e9);
|
|
37
|
+
}
|
|
38
|
+
return freezeJson(policy);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Pure, replayable projection. No clocks, storage, CI, network or authority grants.
|
|
42
|
+
* Cancellation is censored (null outcome), never invented as a failed completion.
|
|
43
|
+
* @param {import('./placement-beliefs.js').PlacementBeliefInput} input
|
|
44
|
+
* @returns {import('./placement-beliefs.js').PlacementBeliefProjection}
|
|
45
|
+
*/
|
|
46
|
+
export function projectPlacementBeliefs(input) {
|
|
47
|
+
const { observations, candidates, now } = snapshotJson(input);
|
|
48
|
+
const policy = resolvePlacementBeliefPolicy(input.policy);
|
|
49
|
+
if (!Number.isSafeInteger(now) || now < 0) fail('explicit time required');
|
|
50
|
+
if (!Array.isArray(observations) || observations.length > policy.maxObservations) fail('bounded observations required');
|
|
51
|
+
if (!Array.isArray(candidates) || candidates.length > 4096
|
|
52
|
+
|| new Set(candidates.map(row => row.providerId)).size !== candidates.length) fail('bounded unique candidates required');
|
|
53
|
+
for (const candidate of candidates) {
|
|
54
|
+
fields(candidate, ['providerId', 'contextId']); id(candidate.providerId); id(candidate.contextId);
|
|
55
|
+
}
|
|
56
|
+
const outcomes = new Map(policy.outcomes.map((row, i) => [row.id, i]));
|
|
57
|
+
const evidence = new Map(), dependencies = new Map(), ignored = [];
|
|
58
|
+
// Sort first so repeated delivery and caller iteration order cannot choose evidence.
|
|
59
|
+
const sorted = [...observations].sort((a, b) => lexical(a.evidenceId, b.evidenceId));
|
|
60
|
+
for (const row of sorted) {
|
|
61
|
+
fields(row, ['evidenceId', 'dependencyId', 'providerId', 'contextId', 'observedAt', 'outcomeId']);
|
|
62
|
+
for (const key of ['evidenceId', 'dependencyId', 'providerId', 'contextId']) id(row[key]);
|
|
63
|
+
if (!Number.isSafeInteger(row.observedAt) || row.observedAt < 0 || row.observedAt > now) fail('invalid observation time');
|
|
64
|
+
if (row.outcomeId !== null && !outcomes.has(row.outcomeId)) fail('unknown outcome');
|
|
65
|
+
const prior = evidence.get(row.evidenceId);
|
|
66
|
+
if (prior) {
|
|
67
|
+
if (['dependencyId', 'providerId', 'contextId', 'observedAt', 'outcomeId'].some(key => prior[key] !== row[key])) fail('conflicting evidence identity');
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
evidence.set(row.evidenceId, row);
|
|
71
|
+
const related = dependencies.get(row.dependencyId);
|
|
72
|
+
if (related && ['providerId', 'contextId', 'outcomeId', 'observedAt'].some(key => related[key] !== row[key])) {
|
|
73
|
+
fail('dependent observations require a joint model; conflicting reports cannot be independent trials');
|
|
74
|
+
}
|
|
75
|
+
if (related) { ignored.push({ evidenceId: row.evidenceId, reason: 'shared-evidence' }); continue; }
|
|
76
|
+
dependencies.set(row.dependencyId, row);
|
|
77
|
+
}
|
|
78
|
+
const counts = new Map(candidates.map(row => [row.providerId, policy.outcomes.map(() => 0)]));
|
|
79
|
+
const used = new Map(candidates.map(row => [row.providerId, []]));
|
|
80
|
+
for (const row of dependencies.values()) {
|
|
81
|
+
const candidate = candidates.find(item => item.providerId === row.providerId);
|
|
82
|
+
const reason = now - row.observedAt > policy.maxAgeMs ? 'stale'
|
|
83
|
+
: !candidate || candidate.contextId !== row.contextId ? 'different-context'
|
|
84
|
+
: row.outcomeId === null ? 'censored' : null;
|
|
85
|
+
if (reason) { ignored.push({ evidenceId: row.evidenceId, reason }); continue; }
|
|
86
|
+
counts.get(row.providerId)[outcomes.get(row.outcomeId)] += 1;
|
|
87
|
+
used.get(row.providerId).push(row.evidenceId);
|
|
88
|
+
}
|
|
89
|
+
const rows = [...candidates].sort((a, b) => lexical(a.providerId, b.providerId)).map(candidate => {
|
|
90
|
+
const alpha = policy.outcomes.map((row, i) => row.prior + counts.get(candidate.providerId)[i]);
|
|
91
|
+
const concentration = alpha.reduce((sum, value) => sum + value, 0);
|
|
92
|
+
const probability = alpha.map(value => value / concentration);
|
|
93
|
+
const mean = values => values.reduce((sum, value, i) => sum + value * probability[i], 0);
|
|
94
|
+
const utility = policy.outcomes.map(row => (row.completed ? policy.completionValue : 0)
|
|
95
|
+
- row.totalCost - policy.timeCostPerMs * row.latencyMs);
|
|
96
|
+
const expectedUtility = mean(utility);
|
|
97
|
+
return { ...candidate, evidenceIds: used.get(candidate.providerId),
|
|
98
|
+
posterior: policy.outcomes.map((row, i) => ({ outcomeId: row.id, alpha: alpha[i], probability: probability[i],
|
|
99
|
+
probabilityVariance: probability[i] * (1 - probability[i]) / (concentration + 1) })),
|
|
100
|
+
completionProbability: mean(policy.outcomes.map(row => Number(row.completed))),
|
|
101
|
+
expectedLatencyMs: mean(policy.outcomes.map(row => row.latencyMs)),
|
|
102
|
+
expectedTotalCost: mean(policy.outcomes.map(row => row.totalCost)), expectedUtility,
|
|
103
|
+
utilityVariance: mean(utility.map(value => (value - expectedUtility) ** 2)) / (concentration + 1) };
|
|
104
|
+
});
|
|
105
|
+
return freezeJson({ schema: 'reploid.placement-beliefs/v1', policy, selectedAt: now, candidates: rows,
|
|
106
|
+
ignored: ignored.sort((a, b) => lexical(a.evidenceId, b.evidenceId)) });
|
|
107
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** Bounded public loading telemetry. Never publish file paths, prompts or raw errors. */
|
|
2
|
+
export function publicPreparationProgress(progress) {
|
|
3
|
+
if (!progress || typeof progress !== 'object') return null;
|
|
4
|
+
let kind = progress.kind;
|
|
5
|
+
if (!['downloading', 'receiving', 'cached', 'pipelines', 'loading'].includes(kind)) {
|
|
6
|
+
if (progress.stage === 'acquiring') kind = /^Downloading/.test(progress.message) ? 'downloading'
|
|
7
|
+
: /^Receiving/.test(progress.message) ? 'receiving' : /^Reusing/.test(progress.message) ? 'cached' : 'loading';
|
|
8
|
+
else kind = progress.phase === 'pipelines' || progress.stage === 'preparing' && Number.isSafeInteger(progress.prepared)
|
|
9
|
+
? 'pipelines' : 'loading';
|
|
10
|
+
}
|
|
11
|
+
const result = { kind };
|
|
12
|
+
if (typeof progress.workId === 'string' && progress.workId.length <= 96) result.workId = progress.workId;
|
|
13
|
+
if (Number.isSafeInteger(progress.sequence) && progress.sequence >= 0) result.sequence = progress.sequence;
|
|
14
|
+
if (['preparing', 'prepared', 'cancelling', 'cancelled', 'timed-out', 'failed'].includes(progress.status)) result.status = progress.status;
|
|
15
|
+
if (typeof progress.outstanding === 'boolean') result.outstanding = progress.outstanding;
|
|
16
|
+
if (Number.isSafeInteger(progress.receivedBytes) && Number.isSafeInteger(progress.totalBytes)
|
|
17
|
+
&& progress.totalBytes > 0 && progress.receivedBytes >= 0 && progress.receivedBytes <= progress.totalBytes) {
|
|
18
|
+
result.receivedBytes = progress.receivedBytes; result.totalBytes = progress.totalBytes;
|
|
19
|
+
}
|
|
20
|
+
if (kind === 'pipelines' && Number.isSafeInteger(progress.prepared) && Number.isSafeInteger(progress.total)
|
|
21
|
+
&& progress.total > 0 && progress.prepared >= 0 && progress.prepared <= progress.total) {
|
|
22
|
+
result.prepared = progress.prepared; result.total = progress.total;
|
|
23
|
+
}
|
|
24
|
+
return result;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
/** Observation-only high-water counters per preparation and file/pipeline work item.
|
|
29
|
+
* Paths stay local; opaque work IDs distinguish a new file from regressing progress.
|
|
30
|
+
*/
|
|
31
|
+
export function createPreparationProgressObserver() {
|
|
32
|
+
const operation = crypto.randomUUID(), work = new Map();
|
|
33
|
+
let sequence = 0;
|
|
34
|
+
return value => {
|
|
35
|
+
const step = publicPreparationProgress(value);
|
|
36
|
+
if (!step) return value;
|
|
37
|
+
const key = JSON.stringify([step.kind, value.path || '', step.totalBytes ?? step.total ?? null]);
|
|
38
|
+
const previous = work.get(key);
|
|
39
|
+
const next = { ...value, ...step, workId: previous?.workId || `${operation}:${work.size}`, sequence: ++sequence };
|
|
40
|
+
for (const field of ['receivedBytes', 'prepared']) if (previous?.[field] !== undefined) {
|
|
41
|
+
next[field] = Math.max(previous[field], next[field] ?? 0);
|
|
42
|
+
}
|
|
43
|
+
work.set(key, next);
|
|
44
|
+
return next;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Bounded, self-reported GPU metadata. Never an identity or an admission input. */
|
|
2
|
+
export function publicProviderHardware(value) {
|
|
3
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
4
|
+
const result = {};
|
|
5
|
+
for (const key of ['vendor', 'architecture', 'device', 'description']) {
|
|
6
|
+
if (typeof value[key] !== 'string') continue;
|
|
7
|
+
const text = value[key].replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, 160);
|
|
8
|
+
if (text) result[key] = text;
|
|
9
|
+
}
|
|
10
|
+
return Object.keys(result).length ? result : null;
|
|
11
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** Single terminal settlement for bounded, cancellable remote generation requests. */
|
|
2
|
+
export function createRemoteGenerationRequests({ timeoutMs, maxPending, sendCancel }) {
|
|
3
|
+
const requests = new Map();
|
|
4
|
+
let closed = false;
|
|
5
|
+
const settle = (pending, outcome = {}) => {
|
|
6
|
+
if (!pending || requests.get(pending.requestId) !== pending) return false;
|
|
7
|
+
requests.delete(pending.requestId);
|
|
8
|
+
clearTimeout(pending.timer);
|
|
9
|
+
pending.signal?.removeEventListener('abort', pending.abort);
|
|
10
|
+
if (outcome.cancel) {
|
|
11
|
+
// Transport teardown must not prevent local settlement.
|
|
12
|
+
try { sendCancel(pending.providerPeerId, pending.requestId); } catch { /* Original failure remains authoritative. */ }
|
|
13
|
+
}
|
|
14
|
+
if (Object.hasOwn(outcome, 'error')) pending.reject(outcome.error ?? new Error('Remote request cancelled'));
|
|
15
|
+
else pending.resolve(outcome.response);
|
|
16
|
+
return true;
|
|
17
|
+
};
|
|
18
|
+
const start = (metadata, send) => {
|
|
19
|
+
if (closed) return Promise.reject(new Error('Mesh is closed'));
|
|
20
|
+
if (requests.size >= maxPending) return Promise.reject(new Error('Pending remote job limit exceeded'));
|
|
21
|
+
if (requests.has(metadata.requestId)) return Promise.reject(new Error('Duplicate remote request identity'));
|
|
22
|
+
metadata.signal?.throwIfAborted();
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
const pending = { ...metadata, resolve, reject, processing: false };
|
|
25
|
+
pending.abort = () => settle(pending, { error: pending.signal.reason, cancel: true });
|
|
26
|
+
pending.timer = setTimeout(() => settle(pending, {
|
|
27
|
+
error: new Error('Timed out waiting for remote host slot response'), cancel: true
|
|
28
|
+
}), timeoutMs);
|
|
29
|
+
requests.set(pending.requestId, pending);
|
|
30
|
+
pending.signal?.addEventListener('abort', pending.abort, { once: true });
|
|
31
|
+
try {
|
|
32
|
+
if (!send()) settle(pending, { error: new Error('Failed to send swarm generation request') });
|
|
33
|
+
} catch (error) { settle(pending, { error, cancel: true }); }
|
|
34
|
+
});
|
|
35
|
+
};
|
|
36
|
+
return Object.freeze({
|
|
37
|
+
start, settle,
|
|
38
|
+
get size() { return requests.size; },
|
|
39
|
+
get(requestId, peerId) {
|
|
40
|
+
const pending = requests.get(String(requestId || ''));
|
|
41
|
+
return pending?.providerPeerId === peerId ? pending : null;
|
|
42
|
+
},
|
|
43
|
+
retirePeer(peerId) {
|
|
44
|
+
for (const pending of requests.values()) if (pending.providerPeerId === peerId) {
|
|
45
|
+
settle(pending, { error: new Error('Execution peer disconnected; retry requires a new attempt') });
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
close() {
|
|
49
|
+
closed = true;
|
|
50
|
+
for (const pending of requests.values()) settle(pending, { error: new Error('Mesh closed'), cancel: true });
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Swarm roles, task envelopes, and transport-agnostic coordination.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { DEFAULT_REWARD_POLICY, rankProviderPeers } from './contribution.js';
|
|
6
|
+
|
|
7
|
+
export const SWARM_ROLES = Object.freeze({
|
|
8
|
+
SOLO: 'solo',
|
|
9
|
+
PROVIDER: 'provider',
|
|
10
|
+
CONSUMER: 'consumer',
|
|
11
|
+
DEAD: 'dead'
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export function deriveSwarmRole(options = {}) {
|
|
15
|
+
const hasInference = !!options.hasInference;
|
|
16
|
+
const swarmEnabled = !!options.swarmEnabled;
|
|
17
|
+
|
|
18
|
+
if (hasInference && !swarmEnabled) return SWARM_ROLES.SOLO;
|
|
19
|
+
if (hasInference && swarmEnabled) return SWARM_ROLES.PROVIDER;
|
|
20
|
+
if (!hasInference && swarmEnabled) return SWARM_ROLES.CONSUMER;
|
|
21
|
+
return SWARM_ROLES.DEAD;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function createPeerAdvertisement(options = {}) {
|
|
25
|
+
const peerId = String(options.peerId || 'unknown').trim() || 'unknown';
|
|
26
|
+
const capabilities = Array.from(new Set(Array.isArray(options.capabilities) ? options.capabilities.filter(Boolean) : []));
|
|
27
|
+
const swarmEnabled = !!options.swarmEnabled;
|
|
28
|
+
const hasInference = !!options.hasInference;
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
kind: 'peer_advertisement',
|
|
32
|
+
peerId,
|
|
33
|
+
role: deriveSwarmRole({ hasInference, swarmEnabled }),
|
|
34
|
+
swarmEnabled,
|
|
35
|
+
hasInference,
|
|
36
|
+
capabilities,
|
|
37
|
+
contribution: options.contribution || null,
|
|
38
|
+
updatedAt: Number(options.updatedAt || Date.now())
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function createGenerationRequest(options = {}) {
|
|
43
|
+
const task = String(options.task || '').trim();
|
|
44
|
+
if (!task) {
|
|
45
|
+
throw new Error('Missing task');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
kind: 'generation_request',
|
|
50
|
+
requestId: String(options.requestId || `req_${Date.now()}`),
|
|
51
|
+
consumer: String(options.consumer || '').trim() || 'unknown',
|
|
52
|
+
provider: options.provider ? String(options.provider) : null,
|
|
53
|
+
model: options.model ? String(options.model) : null,
|
|
54
|
+
task,
|
|
55
|
+
context: options.context || null,
|
|
56
|
+
maxInputTokens: Math.max(0, Number(options.maxInputTokens || 0)),
|
|
57
|
+
maxOutputTokens: Math.max(0, Number(options.maxOutputTokens || 0)),
|
|
58
|
+
createdAt: Number(options.createdAt || Date.now())
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function createGenerationResult(options = {}) {
|
|
63
|
+
return {
|
|
64
|
+
kind: 'generation_result',
|
|
65
|
+
requestId: String(options.requestId || ''),
|
|
66
|
+
provider: String(options.provider || '').trim() || 'unknown',
|
|
67
|
+
content: String(options.content || ''),
|
|
68
|
+
usage: options.usage || null,
|
|
69
|
+
error: options.error ? String(options.error) : null,
|
|
70
|
+
completedAt: Number(options.completedAt || Date.now())
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function buildSwarmState(options = {}) {
|
|
75
|
+
const peers = Array.isArray(options.peers) ? options.peers : [];
|
|
76
|
+
const role = deriveSwarmRole({
|
|
77
|
+
hasInference: !!options.hasInference,
|
|
78
|
+
swarmEnabled: !!options.swarmEnabled
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
enabled: !!options.swarmEnabled,
|
|
83
|
+
role,
|
|
84
|
+
peerCount: peers.length,
|
|
85
|
+
providerCount: peers.filter((peer) => peer?.role === SWARM_ROLES.PROVIDER).length,
|
|
86
|
+
consumerCount: peers.filter((peer) => peer?.role === SWARM_ROLES.CONSUMER).length,
|
|
87
|
+
soloCount: peers.filter((peer) => peer?.role === SWARM_ROLES.SOLO).length
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function chooseProviderPeer(peers = [], options = {}) {
|
|
92
|
+
const ranked = rankProviderPeers(peers, {
|
|
93
|
+
now: options.now || Date.now(),
|
|
94
|
+
policy: options.policy || DEFAULT_REWARD_POLICY
|
|
95
|
+
});
|
|
96
|
+
return ranked[0] || null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function createSwarmController(options = {}) {
|
|
100
|
+
const peerMap = new Map();
|
|
101
|
+
const policy = options.policy || DEFAULT_REWARD_POLICY;
|
|
102
|
+
|
|
103
|
+
const listPeers = () => Array.from(peerMap.values());
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
upsertPeer(peer) {
|
|
107
|
+
if (!peer?.peerId) return null;
|
|
108
|
+
const next = createPeerAdvertisement(peer);
|
|
109
|
+
peerMap.set(next.peerId, next);
|
|
110
|
+
return next;
|
|
111
|
+
},
|
|
112
|
+
removePeer(peerId) {
|
|
113
|
+
return peerMap.delete(String(peerId || ''));
|
|
114
|
+
},
|
|
115
|
+
listPeers,
|
|
116
|
+
chooseProvider(now = Date.now()) {
|
|
117
|
+
return chooseProviderPeer(listPeers(), { now, policy });
|
|
118
|
+
},
|
|
119
|
+
getState(input = {}) {
|
|
120
|
+
return buildSwarmState({
|
|
121
|
+
...input,
|
|
122
|
+
peers: listPeers()
|
|
123
|
+
});
|
|
124
|
+
},
|
|
125
|
+
createAdvertisement: createPeerAdvertisement,
|
|
126
|
+
createRequest: createGenerationRequest,
|
|
127
|
+
createResult: createGenerationResult
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export default {
|
|
132
|
+
SWARM_ROLES,
|
|
133
|
+
buildSwarmState,
|
|
134
|
+
chooseProviderPeer,
|
|
135
|
+
createGenerationRequest,
|
|
136
|
+
createGenerationResult,
|
|
137
|
+
createPeerAdvertisement,
|
|
138
|
+
createSwarmController,
|
|
139
|
+
deriveSwarmRole
|
|
140
|
+
};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/** Bounded search/extraction jobs on the existing authenticated peer connection.
|
|
2
|
+
* Hosts provide public-web execution and opt-in. This channel never reads browser tabs,
|
|
3
|
+
* requests model execution, changes disclosure recipients, or closes its borrowed transport. */
|
|
4
|
+
import policy from './web-access-policy.json' with { type: 'json' };
|
|
5
|
+
import { createRemoteGenerationRequests } from './remote-generation-requests.js';
|
|
6
|
+
|
|
7
|
+
const OFFER = 'reploid:web-offer', REQUEST = 'reploid:web-request';
|
|
8
|
+
const RESPONSE = 'reploid:web-response', CANCEL = 'reploid:web-cancel';
|
|
9
|
+
const validId = value => typeof value === 'string' && value.length > 0 && value.length <= 256;
|
|
10
|
+
const size = value => new TextEncoder().encode(JSON.stringify(value)).length;
|
|
11
|
+
const fail = message => { throw new Error(message); };
|
|
12
|
+
|
|
13
|
+
export function createWebAccessNetwork({ transport, verifyPeer, worker = null, enabled = () => false, onChange = () => {} }) {
|
|
14
|
+
const offers = new Map(), checking = new Map(), epochs = new Map(), inbound = new Map(), seen = new Map();
|
|
15
|
+
let closed = false;
|
|
16
|
+
const send = (peer, type, payload) => !closed && size(payload) <= policy.maxMessageBytes && transport.sendToPeer(peer, type, payload);
|
|
17
|
+
const pending = createRemoteGenerationRequests({ timeoutMs: policy.timeoutMs, maxPending: policy.maxPending,
|
|
18
|
+
sendCancel: (peer, requestId) => send(peer, CANCEL, { requestId }) });
|
|
19
|
+
const peers = () => new Set(transport.getConnectedPeers().map(peer => peer.id));
|
|
20
|
+
const available = () => !closed && worker && enabled() === true;
|
|
21
|
+
const describe = () => available() && (worker.isAvailable?.() ?? true) ? { service: worker.service, version: worker.version } : null;
|
|
22
|
+
const publish = peer => send(peer, OFFER, describe());
|
|
23
|
+
const notify = () => onChange();
|
|
24
|
+
const retire = peer => {
|
|
25
|
+
if (offers.delete(peer)) notify();
|
|
26
|
+
pending.retirePeer(peer);
|
|
27
|
+
for (const job of inbound.values()) if (job.peer === peer) job.controller.abort(new Error('Search requester disconnected'));
|
|
28
|
+
};
|
|
29
|
+
const tick = () => {
|
|
30
|
+
if (closed) return;
|
|
31
|
+
const connected = peers();
|
|
32
|
+
for (const peer of epochs.keys()) if (!connected.has(peer)) { epochs.delete(peer); checking.get(peer)?.abort(); }
|
|
33
|
+
for (const peer of offers.keys()) if (!connected.has(peer)) retire(peer);
|
|
34
|
+
for (const job of inbound.values()) if (!connected.has(job.peer) || !available()) job.controller.abort(new Error('Web contribution stopped'));
|
|
35
|
+
for (const peer of connected) publish(peer);
|
|
36
|
+
const cutoff = Date.now() - policy.resultTtlMs;
|
|
37
|
+
for (const [key, at] of seen) if (at < cutoff) seen.delete(key);
|
|
38
|
+
};
|
|
39
|
+
transport.onMessage(OFFER, (peer, offer) => {
|
|
40
|
+
if (closed || !peers().has(peer)) return;
|
|
41
|
+
if (!offer) { checking.get(peer)?.abort(); epochs.delete(peer); retire(peer); return; }
|
|
42
|
+
if (!Object.hasOwn(policy.services, offer.service) || offer.version !== policy.services[offer.service] || checking.has(peer) || checking.size >= policy.maxPeers) return;
|
|
43
|
+
const controller = new AbortController(), timer = setTimeout(() => controller.abort(), policy.timeoutMs);
|
|
44
|
+
checking.set(peer, controller); epochs.set(peer, controller);
|
|
45
|
+
Promise.resolve().then(() => verifyPeer(peer, controller.signal)).then(participantId => {
|
|
46
|
+
if (closed || epochs.get(peer) !== controller || controller.signal.aborted || !peers().has(peer) || !validId(participantId)) return;
|
|
47
|
+
const prior = offers.get(peer);
|
|
48
|
+
if (prior?.participantId === participantId && prior.service === offer.service && prior.version === offer.version) return;
|
|
49
|
+
if (offers.size >= policy.maxPeers) return;
|
|
50
|
+
offers.set(peer, { peerId: peer, participantId, service: offer.service, version: offer.version }); notify();
|
|
51
|
+
}).catch(() => {}).finally(() => { clearTimeout(timer); checking.delete(peer); });
|
|
52
|
+
});
|
|
53
|
+
transport.onMessage(RESPONSE, (peer, payload) => {
|
|
54
|
+
if (closed || !payload || size(payload) > policy.maxMessageBytes) return;
|
|
55
|
+
const job = pending.get(payload.requestId, peer);
|
|
56
|
+
if (!job) return;
|
|
57
|
+
if (payload.error) pending.settle(job, { error: new Error(String(payload.error).slice(0, 256)) });
|
|
58
|
+
else pending.settle(job, { response: payload.result });
|
|
59
|
+
});
|
|
60
|
+
transport.onMessage(CANCEL, (peer, payload) => {
|
|
61
|
+
const job = inbound.get(`${peer}/${payload?.requestId}`);
|
|
62
|
+
job?.controller.abort(new Error('Web request cancelled'));
|
|
63
|
+
});
|
|
64
|
+
transport.onMessage(REQUEST, (peer, input) => {
|
|
65
|
+
if (closed || !input || !validId(input.requestId) || size(input) > policy.maxMessageBytes) return;
|
|
66
|
+
const key = `${peer}/${input.requestId}`;
|
|
67
|
+
const reject = message => send(peer, RESPONSE, { requestId: input.requestId, error: message });
|
|
68
|
+
if (!available()) return reject('Web contribution is off');
|
|
69
|
+
if (input.operation === 'search' && worker.isAvailable?.() === false) return reject('Contributor browser is unavailable');
|
|
70
|
+
if (input.service !== worker.service || input.version !== worker.version) return reject('Search service changed');
|
|
71
|
+
if (!['search', 'extract'].includes(input.operation) || !input.args || typeof input.args !== 'object') return reject('Invalid web request');
|
|
72
|
+
if (inbound.has(key) || seen.has(key)) return reject('Duplicate web request');
|
|
73
|
+
if (inbound.size >= policy.maxInbound || seen.size >= policy.maxSeen) return reject('Web contributor is busy');
|
|
74
|
+
const controller = new AbortController(), timer = setTimeout(() => controller.abort(new Error('Web request timed out')), policy.timeoutMs);
|
|
75
|
+
const job = { peer, controller, promise: null }; inbound.set(key, job); seen.set(key, Date.now());
|
|
76
|
+
job.promise = Promise.resolve().then(async () => {
|
|
77
|
+
const requester = await verifyPeer(peer, controller.signal);
|
|
78
|
+
controller.signal.throwIfAborted();
|
|
79
|
+
if (!validId(requester) || !available() || !peers().has(peer)) fail('Web request is not authorized');
|
|
80
|
+
const result = await worker[input.operation](input.args, { signal: controller.signal, requester });
|
|
81
|
+
controller.signal.throwIfAborted();
|
|
82
|
+
if (!available()) fail('Web contribution stopped');
|
|
83
|
+
if (size({ requestId: input.requestId, result }) > policy.maxMessageBytes) fail('Web result exceeds the transfer limit');
|
|
84
|
+
send(peer, RESPONSE, { requestId: input.requestId, result });
|
|
85
|
+
}).catch(() => reject(controller.signal.aborted ? 'Web request cancelled or timed out' : 'Web request failed'))
|
|
86
|
+
.finally(() => { clearTimeout(timer); inbound.delete(key); });
|
|
87
|
+
});
|
|
88
|
+
const timer = setInterval(tick, policy.pollMs); timer.unref?.(); tick();
|
|
89
|
+
return Object.freeze({
|
|
90
|
+
getOffers: () => [...offers.values()].map(offer => ({ ...offer })),
|
|
91
|
+
refresh: tick,
|
|
92
|
+
async request(offer, operation, args, signal) {
|
|
93
|
+
offer = structuredClone(offer); args = structuredClone(args);
|
|
94
|
+
signal?.throwIfAborted();
|
|
95
|
+
const current = offers.get(offer?.peerId);
|
|
96
|
+
if (closed || !current || current.participantId !== offer.participantId || current.service !== offer.service || current.version !== offer.version || !peers().has(offer.peerId)) fail('Search contributor changed; allow a new search');
|
|
97
|
+
const identity = await verifyPeer(offer.peerId, signal);
|
|
98
|
+
signal?.throwIfAborted();
|
|
99
|
+
if (identity !== offer.participantId || offers.get(offer.peerId) !== current) fail('Search contributor changed; allow a new search');
|
|
100
|
+
const requestId = crypto.randomUUID();
|
|
101
|
+
return pending.start({ requestId, providerPeerId: offer.peerId, signal }, () => send(offer.peerId, REQUEST,
|
|
102
|
+
{ requestId, service: current.service, version: current.version, operation, args }));
|
|
103
|
+
},
|
|
104
|
+
async close() {
|
|
105
|
+
for (const peer of peers()) send(peer, OFFER, null);
|
|
106
|
+
for (const controller of checking.values()) controller.abort();
|
|
107
|
+
pending.close(); closed = true; clearInterval(timer); offers.clear(); notify();
|
|
108
|
+
for (const job of inbound.values()) job.controller.abort(new Error('Web contributor closed'));
|
|
109
|
+
await Promise.allSettled([...inbound.values()].map(job => job.promise));
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"pollMs": 5000,
|
|
3
|
+
"timeoutMs": 15000,
|
|
4
|
+
"maxPending": 8,
|
|
5
|
+
"maxInbound": 8,
|
|
6
|
+
"maxPeers": 64,
|
|
7
|
+
"maxMessageBytes": 48000,
|
|
8
|
+
"maxQueryCharacters": 2000,
|
|
9
|
+
"maxResults": 5,
|
|
10
|
+
"maxSourceCharacters": 6000,
|
|
11
|
+
"maxSeen": 512,
|
|
12
|
+
"resultTtlMs": 60000,
|
|
13
|
+
"services": {
|
|
14
|
+
"google-browser": "1",
|
|
15
|
+
"bing": "1"
|
|
16
|
+
}
|
|
17
|
+
}
|