@cello-protocol/daemon 0.0.131 → 0.0.133
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/dist/content-park.d.ts.map +1 -1
- package/dist/content-park.js +29 -11
- package/dist/content-park.js.map +1 -1
- package/dist/daemon.d.ts +11 -0
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +398 -18
- package/dist/daemon.js.map +1 -1
- package/dist/document-ack-inbound.d.ts +57 -0
- package/dist/document-ack-inbound.d.ts.map +1 -0
- package/dist/document-ack-inbound.js +174 -0
- package/dist/document-ack-inbound.js.map +1 -0
- package/dist/document-control-notifier.d.ts +61 -0
- package/dist/document-control-notifier.d.ts.map +1 -0
- package/dist/document-control-notifier.js +70 -0
- package/dist/document-control-notifier.js.map +1 -0
- package/dist/document-delivery-transport.d.ts +94 -0
- package/dist/document-delivery-transport.d.ts.map +1 -0
- package/dist/document-delivery-transport.js +179 -0
- package/dist/document-delivery-transport.js.map +1 -0
- package/dist/document-delivery.d.ts +181 -0
- package/dist/document-delivery.d.ts.map +1 -0
- package/dist/document-delivery.js +289 -0
- package/dist/document-delivery.js.map +1 -0
- package/dist/document-frame-router.d.ts +210 -0
- package/dist/document-frame-router.d.ts.map +1 -0
- package/dist/document-frame-router.js +396 -0
- package/dist/document-frame-router.js.map +1 -0
- package/dist/document-handlers.d.ts +47 -0
- package/dist/document-handlers.d.ts.map +1 -0
- package/dist/document-handlers.js +657 -0
- package/dist/document-handlers.js.map +1 -0
- package/dist/document-handshake.d.ts +156 -0
- package/dist/document-handshake.d.ts.map +1 -0
- package/dist/document-handshake.js +398 -0
- package/dist/document-handshake.js.map +1 -0
- package/dist/document-inbound.d.ts +91 -0
- package/dist/document-inbound.d.ts.map +1 -0
- package/dist/document-inbound.js +290 -0
- package/dist/document-inbound.js.map +1 -0
- package/dist/document-layer.d.ts +137 -0
- package/dist/document-layer.d.ts.map +1 -0
- package/dist/document-layer.js +255 -0
- package/dist/document-layer.js.map +1 -0
- package/dist/document-lifecycle.d.ts +125 -0
- package/dist/document-lifecycle.d.ts.map +1 -0
- package/dist/document-lifecycle.js +433 -0
- package/dist/document-lifecycle.js.map +1 -0
- package/dist/document-live-docs.d.ts +58 -0
- package/dist/document-live-docs.d.ts.map +1 -0
- package/dist/document-live-docs.js +126 -0
- package/dist/document-live-docs.js.map +1 -0
- package/dist/document-notify.d.ts +173 -0
- package/dist/document-notify.d.ts.map +1 -0
- package/dist/document-notify.js +438 -0
- package/dist/document-notify.js.map +1 -0
- package/dist/document-publish.d.ts +67 -0
- package/dist/document-publish.d.ts.map +1 -0
- package/dist/document-publish.js +149 -0
- package/dist/document-publish.js.map +1 -0
- package/dist/document-reachability.d.ts +42 -0
- package/dist/document-reachability.d.ts.map +1 -0
- package/dist/document-reachability.js +80 -0
- package/dist/document-reachability.js.map +1 -0
- package/dist/document-rejection.d.ts +240 -0
- package/dist/document-rejection.d.ts.map +1 -0
- package/dist/document-rejection.js +407 -0
- package/dist/document-rejection.js.map +1 -0
- package/dist/document-store.d.ts +154 -8
- package/dist/document-store.d.ts.map +1 -1
- package/dist/document-store.js +462 -4
- package/dist/document-store.js.map +1 -1
- package/dist/document-write-path.d.ts.map +1 -1
- package/dist/document-write-path.js +10 -43
- package/dist/document-write-path.js.map +1 -1
- package/dist/inbound-sessions.d.ts +25 -0
- package/dist/inbound-sessions.d.ts.map +1 -1
- package/dist/inbound-sessions.js +60 -0
- package/dist/inbound-sessions.js.map +1 -1
- package/dist/initiate-session-handler.d.ts +24 -1
- package/dist/initiate-session-handler.d.ts.map +1 -1
- package/dist/initiate-session-handler.js +35 -9
- package/dist/initiate-session-handler.js.map +1 -1
- package/dist/ipc-server.d.ts +11 -1
- package/dist/ipc-server.d.ts.map +1 -1
- package/dist/ipc-server.js +7 -1
- package/dist/ipc-server.js.map +1 -1
- package/dist/line-lcs.d.ts +51 -0
- package/dist/line-lcs.d.ts.map +1 -0
- package/dist/line-lcs.js +71 -0
- package/dist/line-lcs.js.map +1 -0
- package/dist/notification-handlers.d.ts +2 -1
- package/dist/notification-handlers.d.ts.map +1 -1
- package/dist/notification-handlers.js +11 -2
- package/dist/notification-handlers.js.map +1 -1
- package/dist/outbound-sessions.d.ts +2 -0
- package/dist/outbound-sessions.d.ts.map +1 -1
- package/dist/outbound-sessions.js +23 -2
- package/dist/outbound-sessions.js.map +1 -1
- package/dist/session-content-handlers.d.ts.map +1 -1
- package/dist/session-content-handlers.js +3 -2
- package/dist/session-content-handlers.js.map +1 -1
- package/dist/session-node-manager.d.ts +15 -0
- package/dist/session-node-manager.d.ts.map +1 -1
- package/dist/session-node-manager.js +188 -9
- package/dist/session-node-manager.js.map +1 -1
- package/dist/vocabulary.d.ts.map +1 -1
- package/dist/vocabulary.js +16 -0
- package/dist/vocabulary.js.map +1 -1
- package/dist/wire-content-hash.d.ts +27 -0
- package/dist/wire-content-hash.d.ts.map +1 -0
- package/dist/wire-content-hash.js +37 -0
- package/dist/wire-content-hash.js.map +1 -0
- package/package.json +5 -5
package/dist/daemon.js
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
* f. Exit 0
|
|
27
27
|
*/
|
|
28
28
|
import { mkdir } from "node:fs/promises";
|
|
29
|
-
import { randomUUID
|
|
29
|
+
import { randomUUID } from "node:crypto";
|
|
30
30
|
import { dirname, join } from "node:path";
|
|
31
31
|
import { loadAgents } from "./agent-loader.js";
|
|
32
32
|
import { acquireLock, removeLockIfOwned } from "./lock-file.js";
|
|
@@ -65,6 +65,14 @@ import { registerContactHandlers } from "./contact-handlers.js";
|
|
|
65
65
|
import { createSealCoordinator } from "./seal-coordinator.js";
|
|
66
66
|
import { createTelegramDoorbell } from "./telegram-doorbell.js";
|
|
67
67
|
import { registerSessionContentHandlers } from "./session-content-handlers.js";
|
|
68
|
+
import { createDocumentLayer, agentPublicKeyFromId } from "./document-layer.js";
|
|
69
|
+
import { registerDocumentHandlers } from "./document-handlers.js";
|
|
70
|
+
import { createDocumentControlNotifier } from "./document-control-notifier.js";
|
|
71
|
+
import { wireContentHash } from "./wire-content-hash.js";
|
|
72
|
+
import { DocumentPublish } from "./document-publish.js";
|
|
73
|
+
import { createDocumentDeliveryTransport } from "./document-delivery-transport.js";
|
|
74
|
+
import { DocumentDelivery } from "./document-delivery.js";
|
|
75
|
+
import { encodeDocumentUpdateEnvelope, DOCUMENT_UPDATE_ENCODING_V1 } from "@cello-protocol/protocol-types";
|
|
68
76
|
import { createSealFlows } from "./seal-flows.js";
|
|
69
77
|
import { registerCloseSessionHandler } from "./close-session-handler.js";
|
|
70
78
|
import { createInboundSessions } from "./inbound-sessions.js";
|
|
@@ -910,7 +918,7 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
910
918
|
// is anchored on (a counterparty daemon's end-anchored detector must see this close).
|
|
911
919
|
const rejectText = "This inbox only accepts one message per visit. Closing. [[WRAP]]";
|
|
912
920
|
const rejectBytes = new TextEncoder().encode(rejectText);
|
|
913
|
-
const rejectHash =
|
|
921
|
+
const rejectHash = wireContentHash(rejectBytes);
|
|
914
922
|
// Best-effort: a send failure still triggers the seal — we are closing regardless.
|
|
915
923
|
const sendResult = await sessionNodeManager.sendContent(agentName, sessionId, rejectBytes, new Uint8Array(rejectHash), randomUUID());
|
|
916
924
|
// M12-P13: commit the leaf when the rejection went out OR when it is durably queued —
|
|
@@ -1078,7 +1086,7 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
1078
1086
|
const contentBytes = awayVerdict.disposition === "redact" && awayVerdict.content !== undefined
|
|
1079
1087
|
? new Uint8Array(awayVerdict.content)
|
|
1080
1088
|
: draftBytes;
|
|
1081
|
-
const contentHash =
|
|
1089
|
+
const contentHash = wireContentHash(contentBytes);
|
|
1082
1090
|
const sendResult = await sessionNodeManager.sendContent(agentName, sessionId, contentBytes, new Uint8Array(contentHash), randomUUID());
|
|
1083
1091
|
if (!sendResult.ok && !sendResult.durable) {
|
|
1084
1092
|
// Reviewer MEDIUM fix: a transient failure must NOT permanently silence the rest of this
|
|
@@ -1204,7 +1212,7 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
1204
1212
|
new LocalAutoNatStub();
|
|
1205
1213
|
// The OUTBOUND session path (outbound-sessions.ts): discovery, the session request, and cross-node
|
|
1206
1214
|
// setup via a transient VISITING connection to the counterparty's home node.
|
|
1207
|
-
const { openVisitingConnection, crossNodeBrokerBySession, resolvedSessionNegotiator } = createOutboundSessions({
|
|
1215
|
+
const { openVisitingConnection, crossNodeBrokerBySession, resolvedSessionNegotiator, runDiscoveryLookup } = createOutboundSessions({
|
|
1208
1216
|
logger,
|
|
1209
1217
|
sessionNodeManager,
|
|
1210
1218
|
getKeyProvider: (agentName) => keyProviders.get(agentName),
|
|
@@ -1545,11 +1553,13 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
1545
1553
|
// down below the module. Construction is synchronous and its deps are all ready — the handlers
|
|
1546
1554
|
// it registers are fine this early because the handler map already exists.
|
|
1547
1555
|
// Seam 2: inbound session establishment — the counterparty side (inbound-sessions.ts).
|
|
1548
|
-
const { registerHandlers: registerInboundSessionHandlers, wirePerAgentSessionInbound, handleTrustSignalPickup, enqueueInboundSession, reapExpiredInboundSessions, inboundSessionQueues, inboundSessionWaiters, expiredSessionRequests, offeredMonikers, offerKey, } = createInboundSessions({
|
|
1556
|
+
const { registerHandlers: registerInboundSessionHandlers, wirePerAgentSessionInbound, handleTrustSignalPickup, enqueueInboundSession, reapExpiredInboundSessions, inboundSessionQueues, inboundSessionWaiters, expiredSessionRequests, refusedSessionRequests, offeredMonikers, offerKey, } = createInboundSessions({
|
|
1549
1557
|
logger,
|
|
1550
1558
|
sessionNodeManager,
|
|
1551
1559
|
agents,
|
|
1552
1560
|
isExplicitlyOffline: (agentName) => explicitlyOfflineAgents.has(agentName),
|
|
1561
|
+
// M12-P18: lets the responder send a refusal reason back to a TRUSTED sender.
|
|
1562
|
+
sendOver,
|
|
1553
1563
|
getConnState: (connectionId) => perConnectionState.get(connectionId),
|
|
1554
1564
|
resolveCurrentAgent,
|
|
1555
1565
|
NO_CURRENT_AGENT_RESPONSE,
|
|
@@ -2723,7 +2733,7 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
2723
2733
|
}
|
|
2724
2734
|
// cello_initiate_session (initiate-session-handler.ts). The relay witness is BEST-EFFORT: a
|
|
2725
2735
|
// session with no relay still runs on the direct content path. Degraded, never blocked.
|
|
2726
|
-
registerInitiateSessionHandler({
|
|
2736
|
+
const { openSessionFor } = registerInitiateSessionHandler({
|
|
2727
2737
|
handlers,
|
|
2728
2738
|
logger,
|
|
2729
2739
|
sessionNodeManager,
|
|
@@ -3012,6 +3022,7 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
3012
3022
|
reapExpiredInboundSessions,
|
|
3013
3023
|
inboundSessionQueues,
|
|
3014
3024
|
expiredSessionRequests,
|
|
3025
|
+
refusedSessionRequests,
|
|
3015
3026
|
});
|
|
3016
3027
|
// The address book — contacts, tiers, monikers, settings, the telegram token. Ten handlers, now
|
|
3017
3028
|
// in contact-handlers.ts. Extracting them showed the address book closes over almost nothing: it
|
|
@@ -3050,17 +3061,32 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
3050
3061
|
//
|
|
3051
3062
|
// Wrapping the handler map is the ONE choke point that has both the response and the connection's
|
|
3052
3063
|
// clientType. Doing it per-handler would mean 60+ call sites, and the 61st would forget.
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
+
//
|
|
3065
|
+
// RESOLVED AT DISPATCH, not copied at construction. This was a `for…of` that snapshotted
|
|
3066
|
+
// `handlers` into a second map, which made the ORDER of registration load-bearing in a file
|
|
3067
|
+
// 3,500 lines long: anything registered after this line was written into a map nothing
|
|
3068
|
+
// dispatched from. The document surface landed 245 lines below it and every `cello_doc_*` verb
|
|
3069
|
+
// answered `method_not_found` — whose guidance blames version skew between the shim and the
|
|
3070
|
+
// daemon, so an operator would re-pin, reinstall and restart, and find both sides matching.
|
|
3071
|
+
//
|
|
3072
|
+
// A comment saying "register above this line" would have been one more rule to remember. Late
|
|
3073
|
+
// binding makes the ordering unrepresentable instead: the map below reads `handlers` when a
|
|
3074
|
+
// request arrives, so a handler registered at any point before the first request is dispatchable.
|
|
3075
|
+
const renderedHandlers = {
|
|
3076
|
+
get(method) {
|
|
3077
|
+
const handler = handlers.get(method);
|
|
3078
|
+
if (!handler)
|
|
3079
|
+
return undefined;
|
|
3080
|
+
return async (params, connectionId) => {
|
|
3081
|
+
const result = await handler(params, connectionId);
|
|
3082
|
+
// Default to "cli": a connection that never sent ipc.connect has no recorded surface, and
|
|
3083
|
+
// the CLI verb is the safe answer — it is at least a real command an operator can run,
|
|
3084
|
+
// whereas an MCP tool name is useless in a terminal.
|
|
3085
|
+
const surface = perConnectionState.get(connectionId)?.clientType === "mcp" ? "mcp" : "cli";
|
|
3086
|
+
return renderForSurface(result, surface);
|
|
3087
|
+
};
|
|
3088
|
+
},
|
|
3089
|
+
};
|
|
3064
3090
|
// Create and start IPC server
|
|
3065
3091
|
const ipcServer = createIpcServer({ socketPath, maxConnections, logger }, renderedHandlers);
|
|
3066
3092
|
try {
|
|
@@ -3096,6 +3122,337 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
3096
3122
|
// M8C-TGDOOR-1: message-waiting — coalesced (ring-once-until-read) inside sendTelegramDoorbell.
|
|
3097
3123
|
void sendTelegramDoorbell(agentName, sessionId, "message_waiting", "New message waiting");
|
|
3098
3124
|
});
|
|
3125
|
+
// M14 / DOD-DOC-INBOUND-2: the document layer, wired to the session content path.
|
|
3126
|
+
//
|
|
3127
|
+
// Built as ONE thing (see `document-layer.ts`): every half-wiring is a distinct silent failure —
|
|
3128
|
+
// an inbound path with no ack producer leaves the peer retrying until their document stalls, a
|
|
3129
|
+
// delivery worker with no inbound counterpart publishes envelopes nobody can answer.
|
|
3130
|
+
//
|
|
3131
|
+
// It shares the daemon's SQLCipher handle rather than opening its own. One encrypted database,
|
|
3132
|
+
// one key, one file to back up — and a second opener is a second thing that has to agree about
|
|
3133
|
+
// the key, which is how a store ends up plaintext.
|
|
3134
|
+
// THE owner key. One function, used by the inbound router and the delivery sweep alike, because
|
|
3135
|
+
// the two halves scoping differently is a silent-empty-query bug rather than a visible one.
|
|
3136
|
+
//
|
|
3137
|
+
// Reads `loadedAgents`, which is the live registry — `cello_create_agent` pushes into it at
|
|
3138
|
+
// runtime — rather than the boot-time snapshot, so an agent registered after startup resolves
|
|
3139
|
+
// without a restart. Lower-cased because the store compares owner keys as strings and a peer's
|
|
3140
|
+
// id arrives off the wire in whatever case they sent.
|
|
3141
|
+
const documentOwnerKeyFor = (agentName) => loadedAgents.find((a) => a.name === agentName)?.pubkey?.toLowerCase() ?? null;
|
|
3142
|
+
const documentLayer = createDocumentLayer({
|
|
3143
|
+
db: sessionNodeManager.getDb(),
|
|
3144
|
+
logger,
|
|
3145
|
+
// M14-D5: a remote agent's id IS its K_local pubkey hex, so this needs no lookup — and a lookup
|
|
3146
|
+
// on the critical path of every signature check is precisely what it must not have.
|
|
3147
|
+
publicKeyFor: agentPublicKeyFromId,
|
|
3148
|
+
ownerKeyFor: documentOwnerKeyFor,
|
|
3149
|
+
// Documents materialize as files under the operator's CELLO_DIR, alongside the database that
|
|
3150
|
+
// is their source of truth. Not the current working directory: a daemon serves many agents and
|
|
3151
|
+
// outlives any shell, so a relative root would scatter one operator's documents across
|
|
3152
|
+
// wherever they happened to launch it from.
|
|
3153
|
+
workspaceRoot: join(config.celloDir, "documents"),
|
|
3154
|
+
// The ack's road to the peer — the same open-or-reuse-then-seal path every other document frame
|
|
3155
|
+
// takes. Resolved from the owner KEY back to the agent name, because the transport is per agent.
|
|
3156
|
+
sendFrame: async (ownerAgentId, peerAgentId, bytes) => {
|
|
3157
|
+
const agentName = loadedAgents.find((a) => a.pubkey?.toLowerCase() === ownerAgentId)?.name;
|
|
3158
|
+
if (!agentName)
|
|
3159
|
+
return { ok: false, reason: "document_ack_no_agent" };
|
|
3160
|
+
const sent = await documentTransportFor(agentName).sendBytes({
|
|
3161
|
+
peerAgentId,
|
|
3162
|
+
documentId: "ack",
|
|
3163
|
+
bytes,
|
|
3164
|
+
correlationId: randomUUID(),
|
|
3165
|
+
});
|
|
3166
|
+
return sent.ok ? { ok: true } : { ok: false, reason: sent.reason };
|
|
3167
|
+
},
|
|
3168
|
+
// ONE implementation, shared with the two-party test. It was a closure here, and that is exactly
|
|
3169
|
+
// how the surface tests passed while the feature did nothing: the test wired this seam to
|
|
3170
|
+
// `async () => ({ ok: true })`, which reported success, sent nothing, and agreed with whatever
|
|
3171
|
+
// the near side did.
|
|
3172
|
+
notifyPeer: createDocumentControlNotifier({
|
|
3173
|
+
get store() {
|
|
3174
|
+
// Lazily, because `documentLayer` is what is being constructed. The notifier is only ever
|
|
3175
|
+
// called from lifecycle, long after construction returns.
|
|
3176
|
+
return documentLayer.store;
|
|
3177
|
+
},
|
|
3178
|
+
owners: () => loadedAgents
|
|
3179
|
+
.filter((a) => a.pubkey)
|
|
3180
|
+
.map((a) => ({ agentName: a.name, ownerAgentId: a.pubkey.toLowerCase() })),
|
|
3181
|
+
sign: async (agentName, tbs) => {
|
|
3182
|
+
const provider = keyProviders.get(agentName);
|
|
3183
|
+
if (!provider)
|
|
3184
|
+
throw new Error(`document_control_unsigned: no key provider for ${agentName}`);
|
|
3185
|
+
return provider.sign(tbs);
|
|
3186
|
+
},
|
|
3187
|
+
send: (agentName, input) => documentTransportFor(agentName).sendBytes(input),
|
|
3188
|
+
now: () => Date.now(),
|
|
3189
|
+
}),
|
|
3190
|
+
rollback: () => ({
|
|
3191
|
+
// Likewise: withdraw REFUSES rather than claiming a rollback that did not happen. Reporting
|
|
3192
|
+
// success having reverted nothing tells an operator their update was retracted while their
|
|
3193
|
+
// file still contains it.
|
|
3194
|
+
ok: false,
|
|
3195
|
+
reason: "document_rollback_not_wired",
|
|
3196
|
+
}),
|
|
3197
|
+
sign: async (ownerAgentId, tbs) => {
|
|
3198
|
+
// Signs as the OWNING agent, over the rejection's canonical preimage (DOD-DOC-REJECT-2).
|
|
3199
|
+
//
|
|
3200
|
+
// `ownerAgentId` here is the OWNER KEY — pubkey hex — because that is what the layer is
|
|
3201
|
+
// scoped by, and `keyProviders` is keyed by agent NAME. This did `keyProviders.get(ownerAgentId)`
|
|
3202
|
+
// and the comment above it asserted the two were the same thing. They are not: the lookup
|
|
3203
|
+
// missed on every call, so every auto-rejection threw `document_rejection_unsigned` and NO
|
|
3204
|
+
// rejection was ever signed or sent. A peer whose update we refused was never told why —
|
|
3205
|
+
// their sender retried into a gate that would refuse it every time, until the document
|
|
3206
|
+
// stalled for a reason neither operator could see.
|
|
3207
|
+
//
|
|
3208
|
+
// Invisible to every test: the unit and e2e fixtures alike wire `sign: (_o, tbs) => keys.sign(tbs)`,
|
|
3209
|
+
// discarding the agent argument, so nothing could disagree about which key was resolved.
|
|
3210
|
+
const agentName = loadedAgents.find((a) => a.pubkey?.toLowerCase() === ownerAgentId)?.name;
|
|
3211
|
+
const provider = agentName ? keyProviders.get(agentName) : undefined;
|
|
3212
|
+
if (!provider) {
|
|
3213
|
+
// Still throws rather than substituting another agent's key — an earlier version took no
|
|
3214
|
+
// agent at all and reached for whichever provider was first in the map, which is fabricated
|
|
3215
|
+
// crypto wearing a real signature.
|
|
3216
|
+
throw new Error(`document_rejection_unsigned: no key provider for owner ${ownerAgentId.slice(0, 16)}…, ` +
|
|
3217
|
+
`so its rejection cannot be signed — refusing rather than writing an unsigned leaf ` +
|
|
3218
|
+
`into an append-only log`);
|
|
3219
|
+
}
|
|
3220
|
+
return provider.sign(tbs);
|
|
3221
|
+
},
|
|
3222
|
+
});
|
|
3223
|
+
sessionNodeManager.setOnDocumentFrame(documentLayer.onDocumentFrame);
|
|
3224
|
+
// M14 / DOD-DOC-DELIVERY-2 — the outbound half, wired only now that INBOUND-2 is. The ordering
|
|
3225
|
+
// constraint on the DoD line is real and this is where it is honoured: a delivery worker with no
|
|
3226
|
+
// inbound counterpart publishes envelopes nobody can answer, so every document would stall at the
|
|
3227
|
+
// unacked ceiling.
|
|
3228
|
+
//
|
|
3229
|
+
// PER AGENT, because every capability below is: the signaling stream is authenticated as one
|
|
3230
|
+
// agent, sessions belong to one agent, and `openSessionFor` signs as one agent. A single worker
|
|
3231
|
+
// with an agent-shaped hole in it is how a delivery ends up sent under the wrong identity.
|
|
3232
|
+
//
|
|
3233
|
+
// CACHED per agent. A worker rebuilt every tick has its own re-entry guard (`#inFlight`)
|
|
3234
|
+
// constructed away — permanently null, and the comment claiming it protected anything was wrong.
|
|
3235
|
+
// The sweep's serial loop happens to cover it today; a cached worker means it is covered because
|
|
3236
|
+
// the guard exists, not by accident of loop shape.
|
|
3237
|
+
const documentDeliveryWorkers = new Map();
|
|
3238
|
+
// Cached separately from the worker because the OPERATOR SURFACE needs it too: a proposal is not
|
|
3239
|
+
// in the envelope log — there is no document yet — so it has no delivery record to schedule, and
|
|
3240
|
+
// it still has to travel the same open-or-reuse-then-seal path an update does.
|
|
3241
|
+
const documentTransports = new Map();
|
|
3242
|
+
const documentTransportFor = (agentName) => {
|
|
3243
|
+
const cached = documentTransports.get(agentName);
|
|
3244
|
+
if (cached)
|
|
3245
|
+
return cached;
|
|
3246
|
+
const transport = createDocumentDeliveryTransport({
|
|
3247
|
+
agentName,
|
|
3248
|
+
logger,
|
|
3249
|
+
// The SAME 3-state discovery answer cello_initiate_session uses, from the same closure —
|
|
3250
|
+
// online / offline / unknown_agent, kept distinct from "the lookup itself failed". A second
|
|
3251
|
+
// implementation of that distinction drifts until one of them reports a directory outage as
|
|
3252
|
+
// the peer being offline.
|
|
3253
|
+
lookupPeer: async (peerAgentId, correlationId) => {
|
|
3254
|
+
const entry = perAgentSignaling.get(agentName);
|
|
3255
|
+
if (!entry) {
|
|
3256
|
+
// Our OWN stream is not up. A transport fault, never the peer being away — the
|
|
3257
|
+
// reachability mapper turns this into a throw, which the worker logs as lookup_failed.
|
|
3258
|
+
return { kind: "send_failed", reason: "signaling_unavailable" };
|
|
3259
|
+
}
|
|
3260
|
+
return runDiscoveryLookup(entry.signaling, peerAgentId, 10_000, correlationId);
|
|
3261
|
+
},
|
|
3262
|
+
// Most recent LAST — §16.4 reuses the most recent active session, and getSessionsForAgent
|
|
3263
|
+
// returns newest first.
|
|
3264
|
+
activeSessionsWith: (agent, peerAgentId) => sessionNodeManager
|
|
3265
|
+
.getSessionsForAgent(agent)
|
|
3266
|
+
.filter((row) => row.status === "active" && row.counterparty_pubkey === peerAgentId)
|
|
3267
|
+
.map((row) => row.session_id)
|
|
3268
|
+
.reverse(),
|
|
3269
|
+
openSession: async (agent, peerAgentId, correlationId) => {
|
|
3270
|
+
// The same path `cello_initiate_session` takes, now callable without an IPC connection.
|
|
3271
|
+
const res = (await openSessionFor(agent, { targetPubkey: peerAgentId }));
|
|
3272
|
+
if (res.ok !== true || typeof res.sessionId !== "string") {
|
|
3273
|
+
return { ok: false, reason: res.reason ?? "session_open_failed", guidance: res.guidance };
|
|
3274
|
+
}
|
|
3275
|
+
logger.info("document.delivery.session_opened", { agent, peerAgentId, sessionId: res.sessionId, correlationId });
|
|
3276
|
+
return { ok: true, sessionId: res.sessionId };
|
|
3277
|
+
},
|
|
3278
|
+
sealSession: async (agent, sessionId, correlationId) => {
|
|
3279
|
+
// §16.4: the autonomous session still carries the seal — the ceremony goes to zero, the
|
|
3280
|
+
// seal does not. Only ever called for a session this worker OPENED; one it reused belongs
|
|
3281
|
+
// to whoever started that conversation.
|
|
3282
|
+
const close = handlers.get("cello_close_session");
|
|
3283
|
+
if (!close) {
|
|
3284
|
+
// Reported, not swallowed. The outcome of a missing handler is a live session node the
|
|
3285
|
+
// operator never started, with no sealed record — the exact thing the seal exists to
|
|
3286
|
+
// prevent, and previously indistinguishable from a clean seal.
|
|
3287
|
+
logger.error("document.delivery.seal_failed", { agent, sessionId, reason: "close_handler_missing", correlationId });
|
|
3288
|
+
return;
|
|
3289
|
+
}
|
|
3290
|
+
const sealed = (await close({ session_id: sessionId, agent }, `doc-delivery-${correlationId}`));
|
|
3291
|
+
if (sealed?.ok !== true) {
|
|
3292
|
+
// `cello_close_session` has distinct failure codes — session_already_sealed,
|
|
3293
|
+
// seal_interrupted_*, signaling_reconnecting — and every one of them landed nowhere.
|
|
3294
|
+
logger.warn("document.delivery.seal_failed", { agent, sessionId, reason: sealed?.reason ?? "unknown", correlationId });
|
|
3295
|
+
}
|
|
3296
|
+
},
|
|
3297
|
+
sendContent: (agent, sessionId, content, contentHash, correlationId) => sessionNodeManager.sendContent(agent, sessionId, content, contentHash, correlationId),
|
|
3298
|
+
// The `0x04` doc leaf for a frame WE sent — the same step `cello_send` takes after its own
|
|
3299
|
+
// successful send. See the comment at the call site for why this is delivery-critical and
|
|
3300
|
+
// not audit bookkeeping.
|
|
3301
|
+
appendLeaf: (agent, sessionId, contentHash, correlationId) => {
|
|
3302
|
+
sessionNodeManager.appendSessionLeaf(agent, sessionId, "doc", Buffer.from(contentHash).toString("hex"), correlationId);
|
|
3303
|
+
},
|
|
3304
|
+
encodeEnvelope: (envelope) => {
|
|
3305
|
+
const bytes = encodeDocumentUpdateEnvelope({
|
|
3306
|
+
type: "document_update",
|
|
3307
|
+
document_id: envelope.documentId,
|
|
3308
|
+
epoch_id: envelope.epochId,
|
|
3309
|
+
doc_prev_hash: envelope.docPrevHash,
|
|
3310
|
+
sender_agent_id: envelope.senderAgentId,
|
|
3311
|
+
sender_client_id: envelope.senderClientId ?? 0,
|
|
3312
|
+
update_encoding: DOCUMENT_UPDATE_ENCODING_V1,
|
|
3313
|
+
state_vector: envelope.stateVector,
|
|
3314
|
+
update: envelope.payload ?? new Uint8Array(0),
|
|
3315
|
+
signature: envelope.signature,
|
|
3316
|
+
});
|
|
3317
|
+
// Same defect as `sendBytes` had, on the UPDATE path: the receiver recomputes with the
|
|
3318
|
+
// `0x00` domain prefix, so a bare hash is discarded at the authenticity check.
|
|
3319
|
+
return { bytes, hash: wireContentHash(bytes) };
|
|
3320
|
+
},
|
|
3321
|
+
});
|
|
3322
|
+
documentTransports.set(agentName, transport);
|
|
3323
|
+
return transport;
|
|
3324
|
+
};
|
|
3325
|
+
const documentDeliveryFor = (agentName) => {
|
|
3326
|
+
const existing = documentDeliveryWorkers.get(agentName);
|
|
3327
|
+
if (existing)
|
|
3328
|
+
return existing;
|
|
3329
|
+
const worker = new DocumentDelivery(documentLayer.store, documentTransportFor(agentName), logger);
|
|
3330
|
+
documentDeliveryWorkers.set(agentName, worker);
|
|
3331
|
+
return worker;
|
|
3332
|
+
};
|
|
3333
|
+
// M14 / DOD-DOC-TOOLS-1 — the OPERATOR SURFACE. Registered last of the document wiring, because
|
|
3334
|
+
// it is the only part that can create a document, and everything it creates has to have somewhere
|
|
3335
|
+
// to go: without the inbound path a peer's answer is unroutable, and without the delivery worker a
|
|
3336
|
+
// published update never leaves.
|
|
3337
|
+
const documentPublish = new DocumentPublish({
|
|
3338
|
+
store: documentLayer.store,
|
|
3339
|
+
engine: documentLayer.engine,
|
|
3340
|
+
logger,
|
|
3341
|
+
sign: async (ownerAgentId, tbs) => {
|
|
3342
|
+
// `ownerAgentId` is the owner KEY here, and keyProviders is keyed by NAME — so it is resolved
|
|
3343
|
+
// back through the same map the owner key came from rather than guessed. A miss throws: an
|
|
3344
|
+
// unsigned envelope in an append-only log is worse than a failed publish.
|
|
3345
|
+
const agentName = loadedAgents.find((a) => a.pubkey?.toLowerCase() === ownerAgentId)?.name;
|
|
3346
|
+
const provider = agentName ? keyProviders.get(agentName) : undefined;
|
|
3347
|
+
if (!provider) {
|
|
3348
|
+
throw new Error(`document_publish_unsigned: no key provider for owner ${ownerAgentId.slice(0, 16)}…, ` +
|
|
3349
|
+
`so this update cannot be signed`);
|
|
3350
|
+
}
|
|
3351
|
+
return provider.sign(tbs);
|
|
3352
|
+
},
|
|
3353
|
+
// M14-D5: our wire sender id IS the owner key. Kept as its own callback because they are
|
|
3354
|
+
// different facts that happen to coincide — see DocumentStore.pendingDeliveries.
|
|
3355
|
+
senderIdFor: (ownerAgentId) => ownerAgentId,
|
|
3356
|
+
canPublish: (ownerAgentId, documentId) => documentLayer.lifecycle.canPublish(ownerAgentId, documentId),
|
|
3357
|
+
});
|
|
3358
|
+
registerDocumentHandlers({
|
|
3359
|
+
handlers,
|
|
3360
|
+
logger,
|
|
3361
|
+
layer: documentLayer,
|
|
3362
|
+
publish: documentPublish,
|
|
3363
|
+
transportFor: documentTransportFor,
|
|
3364
|
+
resolveAgent: (connectionId, explicit) => resolveCurrentAgent(perConnectionState.get(connectionId), explicit),
|
|
3365
|
+
ownerKeyFor: documentOwnerKeyFor,
|
|
3366
|
+
sign: async (agentName, tbs) => {
|
|
3367
|
+
const provider = keyProviders.get(agentName);
|
|
3368
|
+
if (!provider)
|
|
3369
|
+
throw new Error(`document_proposal_unsigned: no key provider for ${agentName}`);
|
|
3370
|
+
return provider.sign(tbs);
|
|
3371
|
+
},
|
|
3372
|
+
now: () => Date.now(),
|
|
3373
|
+
});
|
|
3374
|
+
/**
|
|
3375
|
+
* The delivery tick.
|
|
3376
|
+
*
|
|
3377
|
+
* Interval-driven rather than event-driven because the event we are actually waiting for — the
|
|
3378
|
+
* peer coming back online — is not one this daemon observes. There is no presence subscription
|
|
3379
|
+
* (parked, M14-P4), so the honest substitute is to ask periodically and let the per-envelope
|
|
3380
|
+
* backoff keep the cost down: a peer that has been away an hour is checked at the cap, not every
|
|
3381
|
+
* tick.
|
|
3382
|
+
*
|
|
3383
|
+
* Slow on purpose. Publish is fire-and-forget and nothing waits on this; a document that syncs a
|
|
3384
|
+
* minute later is working correctly, while a tight loop over every attended agent is a cost paid
|
|
3385
|
+
* forever for a case that is rare.
|
|
3386
|
+
*/
|
|
3387
|
+
//
|
|
3388
|
+
// Overridable for tests, the way `CELLO_SEAL_BILATERAL_TIMEOUT_MS` already is. The live enforcers
|
|
3389
|
+
// spend their wall clock waiting for this tick — two of them is four minutes — and a test that
|
|
3390
|
+
// must sit out a production-paced timer either runs slowly or is written with a window so tight
|
|
3391
|
+
// that adding a second test to the file makes the first one fail. Both happened.
|
|
3392
|
+
//
|
|
3393
|
+
// Floored, so a misread env cannot turn the sweep into a busy loop against the directory.
|
|
3394
|
+
const tickOverride = Number(process.env["CELLO_DOCUMENT_DELIVERY_TICK_MS"]);
|
|
3395
|
+
const DOCUMENT_DELIVERY_TICK_MS = Number.isFinite(tickOverride) && tickOverride >= 250 ? tickOverride : 60_000;
|
|
3396
|
+
let documentDeliveryRunning = false;
|
|
3397
|
+
let documentDeliveryStopping = false;
|
|
3398
|
+
let documentDeliveryInFlight = null;
|
|
3399
|
+
const documentDeliveryTimer = setInterval(() => {
|
|
3400
|
+
// NO OVERLAP. A tick that dials a slow peer can outlast the interval, and a second pass would
|
|
3401
|
+
// re-send envelopes already in flight — the worker's own re-entry guard covers one agent, this
|
|
3402
|
+
// covers the sweep across all of them.
|
|
3403
|
+
if (documentDeliveryRunning)
|
|
3404
|
+
return;
|
|
3405
|
+
documentDeliveryRunning = true;
|
|
3406
|
+
documentDeliveryInFlight = (async () => {
|
|
3407
|
+
try {
|
|
3408
|
+
let agentsSwept = 0;
|
|
3409
|
+
let attempted = 0;
|
|
3410
|
+
let delivered = 0;
|
|
3411
|
+
let failed = 0;
|
|
3412
|
+
for (const agentName of perAgentSignaling.keys()) {
|
|
3413
|
+
// M2: a tick already running when the daemon stops must not keep dialling into a
|
|
3414
|
+
// half-torn-down transport. `clearInterval` stops the NEXT tick, not this one.
|
|
3415
|
+
if (documentDeliveryStopping)
|
|
3416
|
+
break;
|
|
3417
|
+
const ownerAgentId = documentOwnerKeyFor(agentName);
|
|
3418
|
+
// M14-D5: the owner key is our own pubkey hex. It is what BOTH halves scope by — the
|
|
3419
|
+
// inbound router resolves the same function — and it is what the wire sender id is.
|
|
3420
|
+
// Scoping the two halves differently returns nothing pending, reports nothing attempted,
|
|
3421
|
+
// and leaves a fully synced document invisible with no error on any path.
|
|
3422
|
+
if (ownerAgentId === null)
|
|
3423
|
+
continue;
|
|
3424
|
+
agentsSwept++;
|
|
3425
|
+
const peerFor = (documentId) => documentLayer.store.getDocument(ownerAgentId, documentId)?.peerAgentId ?? null;
|
|
3426
|
+
const result = await documentDeliveryFor(agentName).tick(ownerAgentId, peerFor, Date.now(), {
|
|
3427
|
+
senderAgentId: ownerAgentId,
|
|
3428
|
+
});
|
|
3429
|
+
attempted += result.attempted;
|
|
3430
|
+
delivered += result.delivered;
|
|
3431
|
+
failed += result.failed;
|
|
3432
|
+
}
|
|
3433
|
+
// H3: a sweep that delivers nothing was byte-for-byte identical to a healthy idle one, which
|
|
3434
|
+
// is what made every scoping bug above invisible. One line per tick, always.
|
|
3435
|
+
logger.debug("document.delivery.sweep", { agents: agentsSwept, attempted, delivered, failed });
|
|
3436
|
+
if (agentsSwept === 0 && documentLayer.store.anyDocumentExists()) {
|
|
3437
|
+
logger.warn("document.delivery.sweep_empty", {
|
|
3438
|
+
reason: "documents exist but no agent was swept — nothing will ever be delivered",
|
|
3439
|
+
});
|
|
3440
|
+
}
|
|
3441
|
+
}
|
|
3442
|
+
catch (err) {
|
|
3443
|
+
// Contained: one agent's delivery failure must not stop the sweep, and an unhandled
|
|
3444
|
+
// rejection out of a timer takes the daemon down.
|
|
3445
|
+
logger.warn("document.delivery.tick.failed", { reason: extractErrorMessage(err) });
|
|
3446
|
+
}
|
|
3447
|
+
finally {
|
|
3448
|
+
documentDeliveryRunning = false;
|
|
3449
|
+
documentDeliveryInFlight = null;
|
|
3450
|
+
}
|
|
3451
|
+
})();
|
|
3452
|
+
void documentDeliveryInFlight;
|
|
3453
|
+
}, DOCUMENT_DELIVERY_TICK_MS);
|
|
3454
|
+
// Never hold the process open on account of document delivery.
|
|
3455
|
+
documentDeliveryTimer.unref?.();
|
|
3099
3456
|
// MCP-001: Clean up per-connection state when a connection disconnects
|
|
3100
3457
|
// MCP-002: Also unregister from notification dispatcher
|
|
3101
3458
|
ipcServer.onDisconnect((connectionId) => {
|
|
@@ -3150,6 +3507,14 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
3150
3507
|
// lifecycle is the daemon's, not any agent connection's — so it polls even with zero agents.
|
|
3151
3508
|
// Graceful shutdown
|
|
3152
3509
|
async function stop(reason) {
|
|
3510
|
+
// M14: stop the document delivery sweep first — it opens sessions, and a tick landing during
|
|
3511
|
+
// teardown would dial into a daemon that is going away.
|
|
3512
|
+
clearInterval(documentDeliveryTimer);
|
|
3513
|
+
// ...and wait for the tick already running. `clearInterval` stops the next one, not this one,
|
|
3514
|
+
// which may be mid-dial into a transport that is going away.
|
|
3515
|
+
documentDeliveryStopping = true;
|
|
3516
|
+
if (documentDeliveryInFlight)
|
|
3517
|
+
await documentDeliveryInFlight;
|
|
3153
3518
|
// M8C-TGDOOR-1: stop the single long-lived getUpdates poller (no-op if never started) — bump
|
|
3154
3519
|
// the generation so the running loop's while-condition fails on its next check.
|
|
3155
3520
|
stopTelegramPoller(); // M8C-TGDOOR-1: invalidate the poll loop; it exits on its next generation check
|
|
@@ -3230,6 +3595,21 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
3230
3595
|
// M8C-TGDOOR-1: cold-capable — start the poller if a token was already configured from a
|
|
3231
3596
|
// prior run, without waiting for any agent to come online or any client to attach.
|
|
3232
3597
|
startTelegramPollerIfConfigured();
|
|
3233
|
-
|
|
3598
|
+
/**
|
|
3599
|
+
* The live handler map.
|
|
3600
|
+
*
|
|
3601
|
+
* Exposed so the LATE-BINDING property can be asserted rather than assumed: dispatch resolves
|
|
3602
|
+
* from this map when a request arrives, and the only way to prove that is to register something
|
|
3603
|
+
* after `start()` has resolved and call it. The property is not decorative — a snapshot copy here
|
|
3604
|
+
* once made every `cello_doc_*` verb unreachable while the whole suite stayed green.
|
|
3605
|
+
*
|
|
3606
|
+
* Sits alongside `getSessionNodeManager` and `getTypeRegistry`, which are exposed for the same
|
|
3607
|
+
* reason. Production code has no business mutating it after boot.
|
|
3608
|
+
*/
|
|
3609
|
+
const getHandlers = () => handlers;
|
|
3610
|
+
return {
|
|
3611
|
+
stop, getStatus, getSessionNodeManager, getTransportSelector, getAutoNatService, getTypeRegistry,
|
|
3612
|
+
getHandlers,
|
|
3613
|
+
};
|
|
3234
3614
|
}
|
|
3235
3615
|
//# sourceMappingURL=daemon.js.map
|