reploid 0.0.0-stage → 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/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 +534 -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 +83 -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,298 @@
|
|
|
1
|
+
import { partitionAdapterIdentities, samePartitionAdapters, selectPartitionContribution } from './partition-contract.js';
|
|
2
|
+
import { publicPreparationProgress, createPreparationProgressObserver } from '../preparation-progress.js';
|
|
3
|
+
import { generationRequest, requireGenerationCapability } from './partition-generation-request.js';
|
|
4
|
+
import { createAutomaticOrderedPartitions } from './automatic-ordered-partitions.js';
|
|
5
|
+
import { createPartitionDiscovery } from './partition-discovery.js';
|
|
6
|
+
import { selectPartitionPlacement, selectPartitionExecution } from './partition-placement.js';
|
|
7
|
+
import { createPartitionPeer } from './partition-peer.js';
|
|
8
|
+
import { createPartitionEntry } from './partition-entry.js';
|
|
9
|
+
import { createPartitionChat } from './partition-chat.js';
|
|
10
|
+
import { createPartitionGrantAuthority } from './partition-grants.js';
|
|
11
|
+
import { canonicalPartitionJson, assertPartition as assert } from './partition-contract.js';
|
|
12
|
+
|
|
13
|
+
/** Automatic placement over authenticated discovery. Only the supplied host
|
|
14
|
+
* loader may open Doppler, and only after local contribution approval. */
|
|
15
|
+
export function createAutomaticPartitions({ identity, meshId, models, policy, peers, createNetwork,
|
|
16
|
+
loadProgram, canonical = null, ordered = null, onChange = () => {} }) {
|
|
17
|
+
if (ordered !== null) {
|
|
18
|
+
assert(canonical === null, 'Ordered automatic participation is distinct from canonical publication');
|
|
19
|
+
return createAutomaticOrderedPartitions({ identity, meshId, models, policy, peers, createNetwork, loadProgram, ordered, onChange });
|
|
20
|
+
}
|
|
21
|
+
const config = structuredClone(policy), catalog = structuredClone(models);
|
|
22
|
+
for (const model of catalog) assert(model.partitionContributionIndexes === undefined
|
|
23
|
+
|| Array.isArray(model.partitionContributionIndexes) && model.partitionContributionIndexes.length > 0
|
|
24
|
+
&& model.partitionContributionIndexes.every(index => index === 0 || index === 1), 'Invalid eligible partition indexes');
|
|
25
|
+
assert(identity?.peerId && typeof peers === 'function' && typeof createNetwork === 'function'
|
|
26
|
+
&& typeof loadProgram === 'function' && Number.isSafeInteger(config.pollMs) && config.pollMs > 0,
|
|
27
|
+
'Automatic partitions require identity, discovery, loader and explicit policy');
|
|
28
|
+
if (canonical !== null) {
|
|
29
|
+
assert(canonical && /^[a-f0-9]{32}$/.test(canonical.coordinatorEpoch)
|
|
30
|
+
&& /^[a-f0-9]{32}$/.test(canonical.participantEpoch)
|
|
31
|
+
&& ['metadataDigest', 'canonicalGenerationIdentity', 'validateResidentAttemptIdentity', 'tokenDigest']
|
|
32
|
+
.every(name => typeof canonical.runtime?.[name] === 'function'), 'Explicit canonical runtime and epochs required');
|
|
33
|
+
canonical = { runtime: canonical.runtime, coordinatorEpoch: canonical.coordinatorEpoch, participantEpoch: canonical.participantEpoch };
|
|
34
|
+
}
|
|
35
|
+
const listeners = new Set(), admissions = new Map();
|
|
36
|
+
const lifetime = new AbortController();
|
|
37
|
+
let offer = null, program = null, execution = null, chat = null, executionPeer = null, preparing = null;
|
|
38
|
+
let contributionController = null;
|
|
39
|
+
let phase = 'idle', error = null, progress = null, placement = null, closed = false, ticking = null;
|
|
40
|
+
let placementDecision = null, closing = null, stopping = null;
|
|
41
|
+
const unsettledOwners = new Set(), settledCloseOwners = new Set();
|
|
42
|
+
const authority = createPartitionGrantAuthority({ identity, meshId,
|
|
43
|
+
maxGrants: config.limits.maxAttempts, maxTtlMs: config.grantMs, maxClockSkewMs: config.grantClockSkewMs, canonicalRuntime: canonical?.runtime });
|
|
44
|
+
const getModels = () => discovery.getModels().filter(model => !!model.canonical === !!canonical
|
|
45
|
+
&& model.partition?.schema !== 'reploid.partition-selection/v2');
|
|
46
|
+
const getState = () => ({ phase, error, progress: structuredClone(progress), offering: !!offer, modelId: offer?.id || null,
|
|
47
|
+
placement: structuredClone(placement), placementDecision: structuredClone(placementDecision),
|
|
48
|
+
capabilities: discovery.getSnapshot(), descriptor: program?.resident.getState().descriptor || null,
|
|
49
|
+
acquisition: program?.getReceipt?.() || null, models: getModels(),
|
|
50
|
+
reservations: program?.resident.getState().reservations || null, attempts: chat?.getState().attempts || [],
|
|
51
|
+
unsettledOwners: unsettledOwners.size });
|
|
52
|
+
const notify = () => { onChange(getState()); for (const listener of listeners) listener(getModels()); };
|
|
53
|
+
const service = {
|
|
54
|
+
getModels: () => chat?.getModels() || [],
|
|
55
|
+
async refresh(options) { await tick(); return chat?.refresh(options); },
|
|
56
|
+
generate: (request, controls) => {
|
|
57
|
+
assert(chat && offer && !closed, 'Partition input owner unavailable');
|
|
58
|
+
return chat.generate(request, controls);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
const entries = createNetwork({ label: 'reploid-partitions-input',
|
|
62
|
+
maxPeers: config.maxPeers, timeoutMs: config.connectTimeoutMs,
|
|
63
|
+
createEndpoint: ({ channel, remoteParticipantId }) => createPartitionEntry({ channel,
|
|
64
|
+
localParticipantId: identity.peerId, remoteParticipantId, service, canonical: !!canonical,
|
|
65
|
+
limits: config.inputChannel, inputLimits: config.inputLimits,
|
|
66
|
+
async authorize(input, { action }) {
|
|
67
|
+
if (input.requesterId === identity.peerId) {
|
|
68
|
+
const admitted = admissions.get(input.attemptId);
|
|
69
|
+
return !!admitted && canonicalPartitionJson(admitted) === canonicalPartitionJson(input);
|
|
70
|
+
}
|
|
71
|
+
return !!offer && program?.resident.index === 0 && !!chat
|
|
72
|
+
&& input.requesterId === remoteParticipantId && input.participantA === identity.peerId
|
|
73
|
+
&& input.participantB === placement?.[1] && input.modelIdentity === offer.identity
|
|
74
|
+
&& input.planId === program.planId;
|
|
75
|
+
}
|
|
76
|
+
}) });
|
|
77
|
+
const discovery = createPartitionDiscovery({ identity, catalog, policy: config, peers, createNetwork,
|
|
78
|
+
onChange: notify,
|
|
79
|
+
describe: () => ({ participantId: identity.peerId, offer: offer && { id: offer.id, identity: offer.identity, adapters: offer.adapters || [] },
|
|
80
|
+
eligibleIndexes: offer?.partitionContributionIndexes ?? [0, 1],
|
|
81
|
+
phase, preparation: phase === 'loading' ? publicPreparationProgress(progress) : null, placement, index: program?.resident.index ?? placement?.indexOf(identity.peerId) ?? null,
|
|
82
|
+
planId: program?.planId ?? null, models: chat?.getModels() || [],
|
|
83
|
+
// Discovery advertises capacity only; attempt identities remain with their owners.
|
|
84
|
+
capacity: program ? (({ active, availableSlots, closed }) => ({ active, availableSlots, closed }))(
|
|
85
|
+
program.resident.getState().reservations) : null }) });
|
|
86
|
+
async function retire() {
|
|
87
|
+
for (const owner of [chat, execution, program?.resident]) if (owner) unsettledOwners.add(owner);
|
|
88
|
+
const owners = [...unsettledOwners];
|
|
89
|
+
chat = null; executionPeer = null; execution = null; program = null; placement = null;
|
|
90
|
+
const results = await Promise.allSettled(owners.map(owner => Promise.resolve().then(() => owner?.close())));
|
|
91
|
+
results.forEach((result, index) => { if (result.status === 'fulfilled') unsettledOwners.delete(owners[index]); });
|
|
92
|
+
const failures = results.filter(result => result.status === 'rejected').map(result => result.reason);
|
|
93
|
+
if (failures.length) {
|
|
94
|
+
phase = 'failed';
|
|
95
|
+
throw new AggregateError(failures, 'Partition contribution settlement failed');
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
async function reconcile() {
|
|
99
|
+
if (!offer || preparing || stopping || closed || phase === 'failed' || unsettledOwners.size) return;
|
|
100
|
+
placementDecision = selectPartitionPlacement({ participantId: identity.peerId, offer,
|
|
101
|
+
localIndex: program?.resident.index ?? null, previous: placement, capabilities: discovery.getSnapshot() });
|
|
102
|
+
// Discovery expires independently of the execution channel. Existing signed
|
|
103
|
+
// attempts own this route until settlement; a missing advertisement or a new
|
|
104
|
+
// replica must not cancel them or change their authorized recipients.
|
|
105
|
+
const reserved = program?.resident.getState().reservations.active > 0;
|
|
106
|
+
const activeInput = chat && (chat.getState().active > 0 || reserved)
|
|
107
|
+
&& !executionPeer.getState().receipt.closed;
|
|
108
|
+
// Output stages have no chat runner. Their channel/receiver owns settlement;
|
|
109
|
+
// discovery cannot retire their shared resident while any state is charged.
|
|
110
|
+
if (activeInput || program?.resident.index === 1 && reserved) {
|
|
111
|
+
placementDecision = { ...placementDecision, participants: [...placement], reason: 'retain-active-execution' };
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const selected = placementDecision.participants;
|
|
115
|
+
if (selected.length !== 2) {
|
|
116
|
+
await chat?.close(); chat = null; executionPeer = null;
|
|
117
|
+
phase = 'waiting'; progress = { message: 'Waiting for another contributor' }; notify(); return;
|
|
118
|
+
}
|
|
119
|
+
if (placement && canonicalPartitionJson(placement) !== canonicalPartitionJson(selected)) {
|
|
120
|
+
await chat?.close(); chat = null; executionPeer = null;
|
|
121
|
+
if (program && program.resident.index !== selected.indexOf(identity.peerId)) await retire();
|
|
122
|
+
}
|
|
123
|
+
placement = selected;
|
|
124
|
+
if (!program) {
|
|
125
|
+
placement = selected; phase = 'loading'; error = null; notify();
|
|
126
|
+
const selectedOffer = offer, observeProgress = createPreparationProgressObserver();
|
|
127
|
+
let observingPreparation = true;
|
|
128
|
+
const preparationOwners = new Set();
|
|
129
|
+
preparing = Promise.resolve().then(() => loadProgram(selectedOffer, selected.indexOf(identity.peerId), {
|
|
130
|
+
signal: AbortSignal.any([lifetime.signal, contributionController.signal]), participantId: identity.peerId,
|
|
131
|
+
...(canonical ? { canonical: { identity, participantEpoch: canonical.participantEpoch } } : {}),
|
|
132
|
+
registerCleanup(owner) {
|
|
133
|
+
assert(typeof owner?.close === 'function', 'Preparation cleanup owner must expose close');
|
|
134
|
+
preparationOwners.add(owner);
|
|
135
|
+
// The host releases this registration only after successful cleanup.
|
|
136
|
+
// A failed opening is not itself evidence of failed resource disposal.
|
|
137
|
+
return () => preparationOwners.delete(owner);
|
|
138
|
+
},
|
|
139
|
+
onProgress: value => { if (observingPreparation) { error = null; progress = observeProgress(value); notify(); } }
|
|
140
|
+
}));
|
|
141
|
+
try {
|
|
142
|
+
program = await preparing;
|
|
143
|
+
// A returned resident now owns successful preparation. Pending loading
|
|
144
|
+
// remains stoppable; only rejected cleanup becomes unsettled ownership.
|
|
145
|
+
// Stop/close own retirement after this tick settles. A late loader may
|
|
146
|
+
// not retire a newer contribution or race another cleanup caller.
|
|
147
|
+
if (closed || offer !== selectedOffer) return;
|
|
148
|
+
execution = createNetwork({ label: 'reploid-partitions', maxPeers: config.maxPeers,
|
|
149
|
+
timeoutMs: config.connectTimeoutMs,
|
|
150
|
+
createEndpoint: ({ channel, remoteParticipantId }) => createPartitionPeer({ channel,
|
|
151
|
+
localParticipantId: identity.peerId, remoteParticipantId, runtime: program.runtime,
|
|
152
|
+
modelIdentity: program.model.identity, plan: program.plan, planId: program.planId, authority,
|
|
153
|
+
canonical: !!canonical, contributor: program.resident.index === 1 ? program.resident : null, limits: config.executionChannel,
|
|
154
|
+
receiverLimits: config.receiver, descriptorTtlMs: config.inputLimits.descriptorTtlMs }) });
|
|
155
|
+
phase = 'ready'; progress = null; notify();
|
|
156
|
+
} catch (cause) {
|
|
157
|
+
for (const owner of preparationOwners) unsettledOwners.add(owner);
|
|
158
|
+
phase = 'failed'; error = cause.message; notify(); throw cause;
|
|
159
|
+
}
|
|
160
|
+
finally { observingPreparation = false; preparing = null; }
|
|
161
|
+
}
|
|
162
|
+
if (program && phase === 'waiting') { phase = 'ready'; progress = null; notify(); }
|
|
163
|
+
// A restarted contributor may return between discovery polls. Its identity
|
|
164
|
+
// and placement stay the same, but the old execution channel is retired.
|
|
165
|
+
if (chat && executionPeer.getState().receipt.closed) {
|
|
166
|
+
await chat.close(); chat = null; executionPeer = null;
|
|
167
|
+
}
|
|
168
|
+
if (program?.resident.index === 0 && !chat) {
|
|
169
|
+
const next = discovery.getSnapshot().find(peer => peer.available && peer.participantId === placement[1]
|
|
170
|
+
&& peer.description?.phase === 'ready' && peer.description.index === 1
|
|
171
|
+
&& peer.description.planId === program.planId);
|
|
172
|
+
if (!next) return;
|
|
173
|
+
const remote = await execution.connect(next.transportId);
|
|
174
|
+
executionPeer = remote;
|
|
175
|
+
chat = createPartitionChat({ runtime: program.runtime, local: program.resident, remote,
|
|
176
|
+
authority, model: program.model, plan: program.plan, planId: program.planId,
|
|
177
|
+
limits: config.limits, grantTtlMs: config.grantMs,
|
|
178
|
+
...(canonical ? { canonical: { coordinatorEpoch: canonical.coordinatorEpoch } } : {}),
|
|
179
|
+
authorizeRequester: async () => !!offer && !closed });
|
|
180
|
+
await chat.refresh({ signal: lifetime.signal }); notify();
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
async function tick() {
|
|
184
|
+
if (closed) return;
|
|
185
|
+
if (ticking) return ticking;
|
|
186
|
+
ticking = (async () => {
|
|
187
|
+
await discovery.refresh();
|
|
188
|
+
notify(); await reconcile();
|
|
189
|
+
})();
|
|
190
|
+
try { await ticking; } catch (cause) { if (!closed) { error = cause.message; notify(); } }
|
|
191
|
+
finally { ticking = null; }
|
|
192
|
+
}
|
|
193
|
+
async function refreshEntry(entry, transportId, model, signal) {
|
|
194
|
+
let current;
|
|
195
|
+
try { current = await entry.refresh({ signal }); }
|
|
196
|
+
catch (cause) { if (!signal?.aborted) discovery.invalidateModel(transportId, model); throw cause; }
|
|
197
|
+
if (!current.ready || !current.descriptor.models.some(candidate => candidate.id === model.id
|
|
198
|
+
&& candidate.identity === model.identity && samePartitionAdapters(candidate, model) && candidate.availability === 'ready')) {
|
|
199
|
+
discovery.invalidateModel(transportId, model);
|
|
200
|
+
}
|
|
201
|
+
return current;
|
|
202
|
+
}
|
|
203
|
+
const timer = setInterval(tick, config.pollMs);
|
|
204
|
+
return Object.freeze({ getState, getModels,
|
|
205
|
+
subscribe(listener) { listeners.add(listener); listener(getModels()); return () => listeners.delete(listener); },
|
|
206
|
+
async contribute(modelId, approved, adapters) {
|
|
207
|
+
assert(approved === true && !closed, 'Approve partition computation before contributing');
|
|
208
|
+
assert(!stopping && !unsettledOwners.size, 'Previous contribution must settle before replacement');
|
|
209
|
+
assert(!offer, 'Stop existing partition contribution first');
|
|
210
|
+
const selected = catalog.find(model => model.id === modelId);
|
|
211
|
+
assert(selected, 'No pinned partition model for this contribution');
|
|
212
|
+
offer = selectPartitionContribution(selected, adapters);
|
|
213
|
+
contributionController = new AbortController();
|
|
214
|
+
phase = 'waiting'; error = null; progress = { message: 'Waiting for another contributor' }; notify(); await tick();
|
|
215
|
+
},
|
|
216
|
+
stop() {
|
|
217
|
+
if (stopping) return stopping;
|
|
218
|
+
const pendingTick = ticking;
|
|
219
|
+
const operation = Promise.resolve().then(async () => {
|
|
220
|
+
await pendingTick?.catch(() => {});
|
|
221
|
+
try { await retire(); phase = 'idle'; error = null; progress = null; }
|
|
222
|
+
catch (cause) { phase = 'failed'; error = cause.message; throw cause; }
|
|
223
|
+
finally { notify(); }
|
|
224
|
+
}).finally(() => { if (stopping === operation) stopping = null; });
|
|
225
|
+
stopping = operation; offer = null; phase = 'stopping';
|
|
226
|
+
contributionController?.abort(new Error('Partition contribution stopped')); notify();
|
|
227
|
+
return operation;
|
|
228
|
+
},
|
|
229
|
+
async generate(request, controls) {
|
|
230
|
+
request = structuredClone(request);
|
|
231
|
+
const requested = generationRequest(request.generationRequest);
|
|
232
|
+
assert(!requested || requested.maxOutputTokens <= config.limits.maxTokens, 'Caller token limit exceeds route budget');
|
|
233
|
+
assert(!!controls.publication === !!canonical && (!canonical || typeof controls.onPublished === 'function'
|
|
234
|
+
&& /^[a-f0-9]{32}$/.test(controls.publisherEpoch)), 'Canonical requester requires durable publication and explicit mesh capability');
|
|
235
|
+
const decision = selectPartitionExecution({ model: request.model, capabilities: discovery.getSnapshot() });
|
|
236
|
+
assert(decision.transportId, 'No prepared cooperative partition path is available');
|
|
237
|
+
const entry = await entries.connect(decision.transportId);
|
|
238
|
+
const refreshed = await refreshEntry(entry, decision.transportId, request.model, controls.signal);
|
|
239
|
+
const model = refreshed.descriptor.models.find(model => model.id === request.model.id
|
|
240
|
+
&& model.identity === request.model.identity && samePartitionAdapters(model, request.model) && model.availability === 'ready');
|
|
241
|
+
assert(refreshed.ready && model, 'No prepared cooperative partition path is available');
|
|
242
|
+
assert(!!model.canonical === !!canonical, 'Prepared route has a different publication capability');
|
|
243
|
+
requireGenerationCapability(model, requested, !!canonical);
|
|
244
|
+
const messages = structuredClone(request.messages);
|
|
245
|
+
let expiresAt = Date.now() + config.grantMs;
|
|
246
|
+
const approved = await controls.requestApproval({ id: crypto.randomUUID(), threadId: request.threadId,
|
|
247
|
+
attemptId: request.attemptId, operation: 'generate-partition',
|
|
248
|
+
peerId: model.partition.participantA, recipientIdentity: model.partition.participantA,
|
|
249
|
+
...model.partition, modelId: model.id, modelIdentity: model.identity,
|
|
250
|
+
disclosure: 'partition-activations-and-tokens', adapterIdentities: partitionAdapterIdentities(model), expiresAt, executionTimeoutMs: config.grantMs,
|
|
251
|
+
input: { messages: structuredClone(messages), ...(requested ? {generationRequest: requested} : {}), recipients: [model.partition.participantA, model.partition.participantB] } });
|
|
252
|
+
assert(approved === true, 'Partition input disclosure declined'); controls.signal.throwIfAborted();
|
|
253
|
+
expiresAt = Math.min(controls.authorizationExpiresAt?.() ?? expiresAt, Date.now() + config.grantMs);
|
|
254
|
+
assert(Date.now() < expiresAt, 'Partition input disclosure expired');
|
|
255
|
+
// Human review can outlive an availability descriptor. Refresh liveness,
|
|
256
|
+
// but never transfer reviewed input to a newly selected recipient or plan.
|
|
257
|
+
const current = await refreshEntry(entry, decision.transportId, model, controls.signal);
|
|
258
|
+
const same = current.descriptor.models.find(candidate => candidate.id === model.id
|
|
259
|
+
&& candidate.identity === model.identity && samePartitionAdapters(candidate, model) && candidate.availability === 'ready'
|
|
260
|
+
&& canonicalPartitionJson(candidate.partition) === canonicalPartitionJson(model.partition)
|
|
261
|
+
&& canonicalPartitionJson(candidate.canonical ?? null) === canonicalPartitionJson(model.canonical ?? null));
|
|
262
|
+
assert(current.ready && same, 'Approved partition placement changed; review a new attempt');
|
|
263
|
+
assert(Date.now() < expiresAt, 'Partition input disclosure expired'); controls.signal.throwIfAborted();
|
|
264
|
+
const input = { schema: canonical ? 'reploid.partition-input/v2' : 'reploid.partition-input/v1', operation: 'generate', meshId,
|
|
265
|
+
requesterId: identity.peerId, ...model.partition, modelId: model.id, modelIdentity: model.identity,
|
|
266
|
+
threadId: request.threadId, attemptId: request.attemptId, placementGeneration: 0,
|
|
267
|
+
messages, ...(requested ? {generationRequest: requested} : {}), ...(canonical ? { canonical: { schema: 'reploid.partition-publication-context/v1',
|
|
268
|
+
...structuredClone(model.canonical), publisherEpoch: controls.publisherEpoch, deadlineAt: expiresAt } } : {}) };
|
|
269
|
+
admissions.set(request.attemptId, input);
|
|
270
|
+
let sequence = 0;
|
|
271
|
+
controls.onState({ threadId: request.threadId, attemptId: request.attemptId, status: 'executing',
|
|
272
|
+
execution: { placement: 'two-device-layer-partition', ...model.partition } });
|
|
273
|
+
try {
|
|
274
|
+
const result = await entry.generate(input, { signal: controls.signal,
|
|
275
|
+
onUsage: usage => controls.onUsage?.({threadId: request.threadId, attemptId: request.attemptId, ...usage}),
|
|
276
|
+
...(canonical ? { publication: controls.publication, onPublished: controls.onPublished } : {}),
|
|
277
|
+
onDelta: text => controls.onDelta({ threadId: request.threadId, attemptId: request.attemptId, sequence: sequence++, text }) });
|
|
278
|
+
return { ...result, threadId: request.threadId, attemptId: request.attemptId,
|
|
279
|
+
modelId: model.id, modelIdentity: model.identity, adapterIdentities: partitionAdapterIdentities(model),
|
|
280
|
+
execution: { ...result.execution, selection: decision } };
|
|
281
|
+
} finally { admissions.delete(request.attemptId); }
|
|
282
|
+
},
|
|
283
|
+
close() {
|
|
284
|
+
if (closing) return closing;
|
|
285
|
+
closed = true; clearInterval(timer); lifetime.abort(new Error('Partition mesh closed'));
|
|
286
|
+
closing = (async () => {
|
|
287
|
+
const results = await Promise.allSettled([...[entries, discovery].map(owner => Promise.resolve().then(async () => {
|
|
288
|
+
if (!settledCloseOwners.has(owner)) { await owner.close(); settledCloseOwners.add(owner); }
|
|
289
|
+
})), ticking, stopping]);
|
|
290
|
+
results.push(...await Promise.allSettled([retire()]));
|
|
291
|
+
authority.close(); listeners.clear(); admissions.clear();
|
|
292
|
+
const failures = results.filter(result => result.status === 'rejected').map(result => result.reason);
|
|
293
|
+
if (failures.length) throw new AggregateError(failures, 'Partition mesh settlement failed');
|
|
294
|
+
})().catch(error => { closing = null; throw error; });
|
|
295
|
+
return closing;
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signed execution evidence for one charged ordered-route attempt.
|
|
3
|
+
*
|
|
4
|
+
* The requester binds the attempt to its reservation with a signed
|
|
5
|
+
* acknowledgment of zero outputs (the charge binding) before dispatch. Every
|
|
6
|
+
* stage that admits the attempt signs cumulative stage receipts with its
|
|
7
|
+
* persistent participant identity: reservation and route binding, steps
|
|
8
|
+
* executed, tokens processed, timing, and its closing status. The final stage
|
|
9
|
+
* also commits to its outputs as a hash chain over every step's delta. The
|
|
10
|
+
* requester acknowledges each output it received by signing the cumulative
|
|
11
|
+
* chain; it acknowledges only a receipt whose chain matches what it received.
|
|
12
|
+
* Under the receiver policy's bound K, no stage computes step s unless it holds
|
|
13
|
+
* the requester's verified acknowledgment of at least s - K outputs, so a
|
|
14
|
+
* requester that stops acknowledging leaves at most K + 1 outputs of work
|
|
15
|
+
* unacknowledged, whatever the coordinator does.
|
|
16
|
+
* Signatures establish who asserted what; they do not prove honest or correct
|
|
17
|
+
* computation, and verification against registered devices is the service's.
|
|
18
|
+
* WebCrypto only, so browser and native hosts sign with the code the service
|
|
19
|
+
* verifies with.
|
|
20
|
+
*/
|
|
21
|
+
import { canonicalPartitionJson, canonicalRouteAttemptBinding } from './partition-contract.js';
|
|
22
|
+
import { sha256Hex as digestText } from '../../artifacts/signatures.js';
|
|
23
|
+
import { normalizeParticipantPublicKey, participantIdFor, signAsParticipant, verifyParticipantSignature } from '../../artifacts/participant-proofs.js';
|
|
24
|
+
import receiverPolicy from './partition-receiver-policy.json' with { type: 'json' };
|
|
25
|
+
|
|
26
|
+
export const STAGE_RECEIPT_SCHEMA = 'reploid.stage-receipt/v1';
|
|
27
|
+
export const OUTPUT_ACKNOWLEDGMENT_SCHEMA = 'reploid.output-acknowledgment/v1';
|
|
28
|
+
export const OUTPUT_CHAIN_SCHEMA = 'reploid.output-chain/v1';
|
|
29
|
+
export const ATTEMPT_EVIDENCE_SCHEMA = 'reploid.attempt-evidence/v1';
|
|
30
|
+
export const STAGE_RECEIPT_STATUSES = Object.freeze(['executing', 'settled', 'failed']);
|
|
31
|
+
export const ACKNOWLEDGMENT_OVERDUE = 'ACKNOWLEDGMENT_OVERDUE';
|
|
32
|
+
|
|
33
|
+
/** K: the most outputs a charged attempt may run ahead of the requester's verified acknowledgments. */
|
|
34
|
+
export const MAX_UNACKNOWLEDGED_OUTPUTS = receiverPolicy.maxUnacknowledgedOutputs;
|
|
35
|
+
if (!Number.isSafeInteger(MAX_UNACKNOWLEDGED_OUTPUTS) || MAX_UNACKNOWLEDGED_OUTPUTS < 0) {
|
|
36
|
+
throw new Error('partition-receiver-policy.json must declare maxUnacknowledgedOutputs as a non-negative integer');
|
|
37
|
+
}
|
|
38
|
+
/** A step refused or abandoned because the requester's acknowledgments fell more than K outputs behind. */
|
|
39
|
+
export const acknowledgmentOverdue = (message = 'The requester\'s output acknowledgments are overdue') => Object.assign(new Error(message), { code: ACKNOWLEDGMENT_OVERDUE });
|
|
40
|
+
|
|
41
|
+
const RECEIPT_KEYS = Object.freeze(['schema', 'reservationId', 'attemptId', 'routeHash', 'partitionIndex', 'participantId',
|
|
42
|
+
'sequence', 'status', 'steps', 'tokens', 'timing', 'output', 'signedAt']);
|
|
43
|
+
const ACKNOWLEDGMENT_KEYS = Object.freeze(['schema', 'reservationId', 'attemptId', 'routeHash', 'requesterId', 'count', 'chain', 'signedAt']);
|
|
44
|
+
const TIMING_KEYS = Object.freeze(['reservedAt', 'firstStepAt', 'lastStepAt', 'closedAt']);
|
|
45
|
+
const OUTPUT_KEYS = Object.freeze(['count', 'chain', 'done', 'stopReason']);
|
|
46
|
+
const BUNDLE_KEYS = Object.freeze(['schema', 'reservationId', 'attemptId', 'identity', 'receipts', 'acknowledged']);
|
|
47
|
+
const DIGEST = /^sha256:[a-f0-9]{64}$/;
|
|
48
|
+
|
|
49
|
+
const assert = (ok, message) => { if (!ok) throw new Error(message); };
|
|
50
|
+
const exact = (value, keys) => Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
|
51
|
+
&& Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key));
|
|
52
|
+
const text = (value, max) => typeof value === 'string' && value.length > 0 && value.length <= max;
|
|
53
|
+
const count = value => Number.isSafeInteger(value) && value >= 0;
|
|
54
|
+
const isoOrNull = value => value === null || typeof value === 'string' && value.length <= 40 && Number.isFinite(Date.parse(value));
|
|
55
|
+
const iso = ms => new Date(ms).toISOString();
|
|
56
|
+
const sameJson = (a, b) => canonicalPartitionJson(a) === canonicalPartitionJson(b);
|
|
57
|
+
|
|
58
|
+
/** The ledger's route hash: sha256 over the canonical route attempt binding. */
|
|
59
|
+
export const attemptRouteHash = identity => digestText(canonicalPartitionJson(canonicalRouteAttemptBinding(identity)));
|
|
60
|
+
/** Chain value before any output, bound to the reservation and route. */
|
|
61
|
+
export const outputChainGenesis = ({ reservationId, routeHash }) => digestText(canonicalPartitionJson({ schema: OUTPUT_CHAIN_SCHEMA, reservationId, routeHash }));
|
|
62
|
+
/** One step's output appended to the chain; `delta` is that step's text, empty when it published none. */
|
|
63
|
+
export const extendOutputChain = (previous, { step, delta }) => digestText(canonicalPartitionJson({ previous, step, delta }));
|
|
64
|
+
|
|
65
|
+
/** Signing port over a host's persistent participant identity and its clock; the host keeps the identity. */
|
|
66
|
+
export function createAttemptEvidenceSigner({ identity, now = Date.now }) {
|
|
67
|
+
const publicJwk = normalizeParticipantPublicKey(identity?.publicJwk);
|
|
68
|
+
if (!publicJwk || !identity.privateJwk || typeof identity.peerId !== 'string') throw new TypeError('A persistent participant signing identity is required');
|
|
69
|
+
const bundle = structuredClone(identity);
|
|
70
|
+
let checked = null;
|
|
71
|
+
if (typeof now !== 'function') throw new TypeError('The signing host clock must be a function');
|
|
72
|
+
return Object.freeze({
|
|
73
|
+
participantId: bundle.peerId,
|
|
74
|
+
now,
|
|
75
|
+
async sign(domain, body) {
|
|
76
|
+
checked ??= participantIdFor(publicJwk).then(id => assert(id === bundle.peerId, 'Signing identity does not derive its participant id'));
|
|
77
|
+
await checked;
|
|
78
|
+
return { ...structuredClone(body), publicJwk: structuredClone(publicJwk), signature: await signAsParticipant(bundle, domain, body) };
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function receiptProblem(body) {
|
|
84
|
+
if (!exact(body, RECEIPT_KEYS) || body.schema !== STAGE_RECEIPT_SCHEMA || !text(body.reservationId, 160) || !text(body.attemptId, 256)
|
|
85
|
+
|| !DIGEST.test(body.routeHash) || !count(body.partitionIndex) || !text(body.participantId, 256)
|
|
86
|
+
|| !Number.isSafeInteger(body.sequence) || body.sequence < 1 || !STAGE_RECEIPT_STATUSES.includes(body.status) || !count(body.steps)
|
|
87
|
+
|| !exact(body.tokens, ['prompt', 'processed']) || !(body.tokens.prompt === null || count(body.tokens.prompt)) || !count(body.tokens.processed)
|
|
88
|
+
|| !exact(body.timing, TIMING_KEYS) || !TIMING_KEYS.every(key => isoOrNull(body.timing[key]))
|
|
89
|
+
|| !isoOrNull(body.signedAt) || body.signedAt === null) return 'receipt_malformed';
|
|
90
|
+
if (body.output !== null && !(exact(body.output, OUTPUT_KEYS) && count(body.output.count) && body.output.count === body.steps
|
|
91
|
+
&& DIGEST.test(body.output.chain) && typeof body.output.done === 'boolean'
|
|
92
|
+
&& (body.output.stopReason === null || text(body.output.stopReason, 64)))) return 'receipt_malformed';
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function acknowledgmentProblem(body) {
|
|
97
|
+
return exact(body, ACKNOWLEDGMENT_KEYS) && body.schema === OUTPUT_ACKNOWLEDGMENT_SCHEMA && text(body.reservationId, 160)
|
|
98
|
+
&& text(body.attemptId, 256) && DIGEST.test(body.routeHash) && text(body.requesterId, 256) && count(body.count)
|
|
99
|
+
&& DIGEST.test(body.chain) && isoOrNull(body.signedAt) && body.signedAt !== null ? null : 'acknowledgment_malformed';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function verifySigned(record, { domain, problemOf, signerOf, publicJwk }) {
|
|
103
|
+
if (!record || typeof record !== 'object' || Array.isArray(record)) return { ok: false, problem: problemOf(null) };
|
|
104
|
+
const { publicJwk: carried, signature, ...body } = record;
|
|
105
|
+
const problem = problemOf(body);
|
|
106
|
+
if (problem) return { ok: false, problem };
|
|
107
|
+
const key = normalizeParticipantPublicKey(carried);
|
|
108
|
+
if (!key || !exact(carried, Object.keys(key)) || typeof signature !== 'string') return { ok: false, problem: problemOf(null) };
|
|
109
|
+
if (publicJwk && !sameJson(normalizeParticipantPublicKey(publicJwk), key)) return { ok: false, problem: 'signer_key_mismatch' };
|
|
110
|
+
if (await participantIdFor(key) !== signerOf(body)) return { ok: false, problem: 'signer_key_mismatch' };
|
|
111
|
+
if (!await verifyParticipantSignature(key, domain, body, signature)) return { ok: false, problem: 'signature_invalid' };
|
|
112
|
+
return { ok: true, body };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Form, key-to-participant binding and signature of a stage receipt; `publicJwk`, when given, must be the signing key. */
|
|
116
|
+
export const verifyStageReceipt = (receipt, { publicJwk = null } = {}) => verifySigned(receipt, {
|
|
117
|
+
domain: STAGE_RECEIPT_SCHEMA, problemOf: body => (body ? receiptProblem(body) : 'receipt_malformed'), signerOf: body => body.participantId, publicJwk });
|
|
118
|
+
|
|
119
|
+
/** Form, key-to-requester binding and signature of an output acknowledgment. */
|
|
120
|
+
export const verifyOutputAcknowledgment = (acknowledgment, { publicJwk = null } = {}) => verifySigned(acknowledgment, {
|
|
121
|
+
domain: OUTPUT_ACKNOWLEDGMENT_SCHEMA, problemOf: body => (body ? acknowledgmentProblem(body) : 'acknowledgment_malformed'),
|
|
122
|
+
signerOf: body => body.requesterId, publicJwk });
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Form of a charge binding carried with an ordered input: the requester's
|
|
126
|
+
* acknowledgment of zero outputs of this attempt. Stages verify the signature.
|
|
127
|
+
*/
|
|
128
|
+
export function isChargeBindingShape(charge, { attemptId, requesterId }) {
|
|
129
|
+
if (!exact(charge, [...ACKNOWLEDGMENT_KEYS, 'publicJwk', 'signature']) || typeof charge.signature !== 'string' || charge.signature.length > 256) return false;
|
|
130
|
+
const { publicJwk, signature: _signature, ...body } = charge;
|
|
131
|
+
return acknowledgmentProblem(body) === null && body.count === 0 && body.attemptId === attemptId && body.requesterId === requesterId
|
|
132
|
+
&& normalizeParticipantPublicKey(publicJwk) !== null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Check the bundle's form; signatures and registrations are the verifier's. */
|
|
136
|
+
export function checkAttemptEvidenceBundle(bundle) {
|
|
137
|
+
if (!exact(bundle, BUNDLE_KEYS) || bundle.schema !== ATTEMPT_EVIDENCE_SCHEMA || !text(bundle.reservationId, 160)
|
|
138
|
+
|| !text(bundle.attemptId, 256) || !Array.isArray(bundle.receipts)
|
|
139
|
+
|| !(bundle.acknowledged === null || exact(bundle.acknowledged, ['acknowledgment', 'receipt']))) return 'evidence_malformed';
|
|
140
|
+
try { if (canonicalRouteAttemptBinding(bundle.identity).attemptId !== bundle.attemptId) return 'evidence_malformed'; }
|
|
141
|
+
catch { return 'evidence_malformed'; }
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The charge binding a requester signs after binding its attempt and before any input leaves the device. */
|
|
146
|
+
async function checkChargeBinding(charge, binding, routeHash) {
|
|
147
|
+
const checked = await verifyOutputAcknowledgment(charge);
|
|
148
|
+
const body = checked.body;
|
|
149
|
+
assert(checked.ok && body.count === 0 && body.attemptId === binding.attemptId && body.routeHash === routeHash
|
|
150
|
+
&& body.requesterId === binding.requesterId && body.chain === await outputChainGenesis({ reservationId: body.reservationId, routeHash }),
|
|
151
|
+
'Attempt charge binding is not the requester\'s signed binding of this route');
|
|
152
|
+
return { reservationId: body.reservationId, requesterKey: normalizeParticipantPublicKey(charge.publicJwk) };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Stage side: cumulative signed receipts of one charged attempt at one stage.
|
|
157
|
+
* A repeated step never advances the tally twice; a closed tally signs nothing new.
|
|
158
|
+
*/
|
|
159
|
+
export async function openStageReceipts({ signer, identity, partitionIndex, charge, now = signer?.now ?? Date.now }) {
|
|
160
|
+
const binding = canonicalRouteAttemptBinding(identity);
|
|
161
|
+
const stage = binding.route.stages[partitionIndex];
|
|
162
|
+
assert(stage && typeof signer?.sign === 'function' && signer.participantId === stage.participantId, 'Stage receipts need this stage\'s own signing identity');
|
|
163
|
+
const routeHash = await attemptRouteHash(binding);
|
|
164
|
+
const { reservationId, requesterKey } = await checkChargeBinding(charge, binding, routeHash);
|
|
165
|
+
const finalStage = binding.route.stages.at(-1);
|
|
166
|
+
const final = partitionIndex === finalStage.index;
|
|
167
|
+
const state = { sequence: 0, steps: 0, prompt: null, processed: 0, timing: { reservedAt: iso(now()), firstStepAt: null, lastStepAt: null, closedAt: null },
|
|
168
|
+
output: final ? { count: 0, chain: await outputChainGenesis({ reservationId, routeHash }), done: false, stopReason: null } : null,
|
|
169
|
+
receipt: null, acknowledged: null, closed: false };
|
|
170
|
+
let queue = Promise.resolve();
|
|
171
|
+
const serial = task => { const run = queue.then(task); queue = run.catch(() => {}); return run; };
|
|
172
|
+
const sign = async status => {
|
|
173
|
+
state.sequence += 1;
|
|
174
|
+
state.receipt = await signer.sign(STAGE_RECEIPT_SCHEMA, { schema: STAGE_RECEIPT_SCHEMA, reservationId, attemptId: binding.attemptId, routeHash,
|
|
175
|
+
partitionIndex, participantId: stage.participantId, sequence: state.sequence, status, steps: state.steps,
|
|
176
|
+
tokens: { prompt: state.prompt, processed: state.processed }, timing: structuredClone(state.timing),
|
|
177
|
+
output: state.output && structuredClone(state.output), signedAt: iso(now()) });
|
|
178
|
+
return structuredClone(state.receipt);
|
|
179
|
+
};
|
|
180
|
+
return Object.freeze({
|
|
181
|
+
reservationId, routeHash,
|
|
182
|
+
/** Record one executed step and sign the cumulative receipt. */
|
|
183
|
+
step: ({ step, inputTokenCount, delta = '', done = false, stopReason = null }) => serial(async () => {
|
|
184
|
+
if (state.closed || step !== state.steps || !count(inputTokenCount)) return state.receipt && structuredClone(state.receipt);
|
|
185
|
+
const at = iso(now());
|
|
186
|
+
if (step === 0) { state.prompt = inputTokenCount; state.timing.firstStepAt = at; }
|
|
187
|
+
state.steps += 1; state.processed += inputTokenCount; state.timing.lastStepAt = at;
|
|
188
|
+
if (state.output) {
|
|
189
|
+
assert(typeof delta === 'string', 'Final stage output delta required');
|
|
190
|
+
state.output = { count: state.steps, chain: await extendOutputChain(state.output.chain, { step, delta }), done: done === true,
|
|
191
|
+
stopReason: done === true && typeof stopReason === 'string' ? stopReason.slice(0, 64) : null };
|
|
192
|
+
}
|
|
193
|
+
return sign('executing');
|
|
194
|
+
}),
|
|
195
|
+
/** Keep the latest requester acknowledgment and the final-stage receipt it acknowledged, as forwarded by the coordinator. */
|
|
196
|
+
/**
|
|
197
|
+
* Keep a newer acknowledgment only when the requester's key from the charge
|
|
198
|
+
* binding signed it and the final stage signed the receipt it pairs with.
|
|
199
|
+
*/
|
|
200
|
+
async acknowledge(pair) {
|
|
201
|
+
const ack = pair?.acknowledgment, receipt = pair?.receipt;
|
|
202
|
+
if (!ack || ack.count <= (state.acknowledged?.acknowledgment.count ?? 0)) return false;
|
|
203
|
+
const [signed, committed] = await Promise.all([verifyOutputAcknowledgment(ack, { publicJwk: requesterKey }), verifyStageReceipt(receipt)]);
|
|
204
|
+
if (!signed.ok || !committed.ok || ack.reservationId !== reservationId || ack.attemptId !== binding.attemptId || ack.routeHash !== routeHash
|
|
205
|
+
|| ack.requesterId !== binding.requesterId || ack.count < 1 || committed.body.reservationId !== reservationId
|
|
206
|
+
|| committed.body.attemptId !== binding.attemptId || committed.body.routeHash !== routeHash
|
|
207
|
+
|| committed.body.partitionIndex !== finalStage.index || committed.body.participantId !== finalStage.participantId
|
|
208
|
+
|| committed.body.output?.count !== ack.count || committed.body.output.chain !== ack.chain) return false;
|
|
209
|
+
if (ack.count > (state.acknowledged?.acknowledgment.count ?? 0)) state.acknowledged = structuredClone({ acknowledgment: ack, receipt });
|
|
210
|
+
return true;
|
|
211
|
+
},
|
|
212
|
+
/** Refuse step `step` unless the verified acknowledgments cover at least step - K outputs. */
|
|
213
|
+
async authorizeStep({ step, acknowledged = null }) {
|
|
214
|
+
if (acknowledged) await this.acknowledge(acknowledged);
|
|
215
|
+
if ((state.acknowledged?.acknowledgment.count ?? 0) < step - MAX_UNACKNOWLEDGED_OUTPUTS) {
|
|
216
|
+
throw acknowledgmentOverdue(`Step ${step} needs the requester's acknowledgment of ${step - MAX_UNACKNOWLEDGED_OUTPUTS} outputs`);
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
/** Sign the closing receipt once: settled, or failed when the stage recorded a failure. */
|
|
220
|
+
close: ({ failed = false } = {}) => serial(async () => {
|
|
221
|
+
if (state.closed) return state.receipt && structuredClone(state.receipt);
|
|
222
|
+
state.closed = true; state.timing.closedAt = iso(now());
|
|
223
|
+
return sign(failed ? 'failed' : 'settled');
|
|
224
|
+
}),
|
|
225
|
+
/** This stage's evidence for its own submission: its latest receipt and the latest acknowledgment it holds. */
|
|
226
|
+
evidence: () => ({ schema: ATTEMPT_EVIDENCE_SCHEMA, reservationId, attemptId: binding.attemptId, identity: structuredClone(binding),
|
|
227
|
+
receipts: state.receipt ? [structuredClone(state.receipt)] : [], acknowledged: state.acknowledged && structuredClone(state.acknowledged) })
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Requester side: the charge binding, then one signed acknowledgment per
|
|
233
|
+
* received output whose final-stage receipt commits to exactly the chain of
|
|
234
|
+
* deltas this device received. A receipt that does not match is not
|
|
235
|
+
* acknowledged, and nothing after it can be.
|
|
236
|
+
*/
|
|
237
|
+
export async function openOutputAcknowledgments({ signer, identity, reservationId, now = signer?.now ?? Date.now, restore = null }) {
|
|
238
|
+
const binding = canonicalRouteAttemptBinding(identity);
|
|
239
|
+
assert(typeof signer?.sign === 'function' && signer.participantId === binding.requesterId && text(reservationId, 160),
|
|
240
|
+
'Acknowledgments need the requester\'s own signing identity and reservation');
|
|
241
|
+
const routeHash = await attemptRouteHash(binding);
|
|
242
|
+
const finalStage = binding.route.stages.at(-1);
|
|
243
|
+
const sign = (outputs, chain) => signer.sign(OUTPUT_ACKNOWLEDGMENT_SCHEMA, { schema: OUTPUT_ACKNOWLEDGMENT_SCHEMA, reservationId,
|
|
244
|
+
attemptId: binding.attemptId, routeHash, requesterId: binding.requesterId, count: outputs, chain, signedAt: iso(now()) });
|
|
245
|
+
const state = restore && restore.reservationId === reservationId && restore.attemptId === binding.attemptId ? structuredClone(restore)
|
|
246
|
+
: { reservationId, attemptId: binding.attemptId, count: 0, chain: await outputChainGenesis({ reservationId, routeHash }),
|
|
247
|
+
charge: null, acknowledged: null, latestReceipt: null, broken: false };
|
|
248
|
+
state.charge ??= await sign(0, state.chain);
|
|
249
|
+
let queue = Promise.resolve();
|
|
250
|
+
const serial = task => { const run = queue.then(task); queue = run.catch(() => {}); return run; };
|
|
251
|
+
return Object.freeze({
|
|
252
|
+
routeHash,
|
|
253
|
+
charge: structuredClone(state.charge),
|
|
254
|
+
/** Acknowledge one step's output: `{ step, delta, receipt }` from the route. Returns the pair, or null when not acknowledged. */
|
|
255
|
+
observe: ({ step, delta, receipt }) => serial(async () => {
|
|
256
|
+
if (!state.broken && step === state.count - 1 && state.lastObserved
|
|
257
|
+
&& sameJson(state.lastObserved, { step, delta, receipt })) return structuredClone(state.acknowledged);
|
|
258
|
+
if (state.broken || step !== state.count || typeof delta !== 'string') { state.broken = true; return null; }
|
|
259
|
+
const checked = await verifyStageReceipt(receipt);
|
|
260
|
+
const body = checked.body;
|
|
261
|
+
const chain = await extendOutputChain(state.chain, { step, delta });
|
|
262
|
+
if (!checked.ok || body.reservationId !== reservationId || body.attemptId !== binding.attemptId || body.routeHash !== routeHash
|
|
263
|
+
|| body.partitionIndex !== finalStage.index || body.participantId !== finalStage.participantId
|
|
264
|
+
|| body.output?.count !== step + 1 || body.output.chain !== chain) { state.broken = true; return null; }
|
|
265
|
+
const acknowledgment = await sign(step + 1, chain);
|
|
266
|
+
Object.assign(state, { count: step + 1, chain, acknowledged: { acknowledgment, receipt: structuredClone(receipt) }, latestReceipt: structuredClone(receipt),
|
|
267
|
+
lastObserved: structuredClone({ step, delta, receipt }) });
|
|
268
|
+
return structuredClone(state.acknowledged);
|
|
269
|
+
}),
|
|
270
|
+
/** Durable state for a restarted host. */
|
|
271
|
+
state: () => structuredClone(state),
|
|
272
|
+
/** The requester's evidence for its report: the latest final-stage receipt and its acknowledgment. */
|
|
273
|
+
evidence: () => ({ schema: ATTEMPT_EVIDENCE_SCHEMA, reservationId, attemptId: binding.attemptId, identity: structuredClone(binding),
|
|
274
|
+
receipts: state.latestReceipt ? [structuredClone(state.latestReceipt)] : [], acknowledged: state.acknowledged && structuredClone(state.acknowledged) })
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export default {
|
|
279
|
+
STAGE_RECEIPT_SCHEMA, OUTPUT_ACKNOWLEDGMENT_SCHEMA, OUTPUT_CHAIN_SCHEMA, ATTEMPT_EVIDENCE_SCHEMA,
|
|
280
|
+
attemptRouteHash, outputChainGenesis, extendOutputChain, createAttemptEvidenceSigner,
|
|
281
|
+
verifyStageReceipt, verifyOutputAcknowledgment, checkAttemptEvidenceBundle, openStageReceipts, openOutputAcknowledgments
|
|
282
|
+
};
|