@cello-protocol/daemon 0.0.132 → 0.0.134
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/bin/cello-daemon.js +25 -0
- package/dist/bin/cello-daemon.js.map +1 -1
- 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 +455 -17
- 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.map +1 -1
- package/dist/inbound-sessions.js +4 -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.map +1 -1
- package/dist/notification-handlers.js +31 -14
- 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 +11 -1
- 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 +26 -4
- package/dist/session-node-manager.d.ts.map +1 -1
- package/dist/session-node-manager.js +201 -15
- package/dist/session-node-manager.js.map +1 -1
- package/dist/session-read-handlers.js +1 -1
- package/dist/session-read-handlers.js.map +1 -1
- package/dist/types.d.ts +40 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.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
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DOD-DOC-TOOLS-1 — the operator's surface onto documents.
|
|
3
|
+
*
|
|
4
|
+
* Until this module existed, nothing in production called `createDocument`. Every unit below it was
|
|
5
|
+
* built and tested, and none of it was reachable: no operator could propose a document, so no
|
|
6
|
+
* document existed, so the delivery sweep was a no-op by construction and the inbound path never
|
|
7
|
+
* had anything addressed to it. The layer was complete and unreachable, which reads exactly like a
|
|
8
|
+
* layer that works.
|
|
9
|
+
*
|
|
10
|
+
* ── WHY PROPOSE AND ACCEPT ARE SEPARATE VERBS ─────────────────────────────────────────────────
|
|
11
|
+
*
|
|
12
|
+
* A document is a standing agreement to apply a counterparty's signed operations to local state.
|
|
13
|
+
* That is a larger grant than receiving a message, and §16.3 puts a human consent decision in front
|
|
14
|
+
* of it. Accepting is the moment the operator agrees; everything after is the CRDT converging
|
|
15
|
+
* without asking again. So the proposal is recorded, listed, and answered once — never inferred
|
|
16
|
+
* from the first update arriving.
|
|
17
|
+
*
|
|
18
|
+
* ── WRITE IS FULL-CONTENT, NOT A PATCH ────────────────────────────────────────────────────────
|
|
19
|
+
*
|
|
20
|
+
* `cello_doc_write` takes the document's whole new text and the engine diffs it against the live
|
|
21
|
+
* doc. An agent that emits a patch has to be right about offsets in a document its peer is
|
|
22
|
+
* concurrently editing, and a wrong offset in a CRDT is not a rejected patch — it is a permanent,
|
|
23
|
+
* silent corruption that both sides converge on. Full content moves that problem to a diff run
|
|
24
|
+
* against the state the operator actually saw.
|
|
25
|
+
*/
|
|
26
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
27
|
+
import * as Y from "yjs";
|
|
28
|
+
import { encodeDocumentProposal, buildDocumentProposalTbs, documentIdFromProposal, seamViolation, ASSURANCE_TIER_V1, TOPOLOGY_V1, DOCUMENT_FEATURE_VERSION, encodeDocumentProposalAck, buildDocumentProposalAckTbs, DOCUMENT_PROPOSAL_ACK_VERSION, MAX_PROPOSAL_REFUSAL_REASON_LENGTH, } from "@cello-protocol/protocol-types";
|
|
29
|
+
import { lineHunks } from "./document-write-path.js";
|
|
30
|
+
/** Document types the notification/diff path understands. Anything else is stored, not diffed. */
|
|
31
|
+
const DEFAULT_DOCUMENT_TYPE = "markdown";
|
|
32
|
+
export function registerDocumentHandlers(deps) {
|
|
33
|
+
const { handlers, logger, layer, publish } = deps;
|
|
34
|
+
/**
|
|
35
|
+
* Resolve the agent AND its owner key together, because a handler that has one without the other
|
|
36
|
+
* is a handler that will pick the wrong scope. Returns a refusal the handler returns verbatim.
|
|
37
|
+
*/
|
|
38
|
+
function resolve(params, connectionId) {
|
|
39
|
+
const explicit = typeof params?.agent === "string" ? params.agent : undefined;
|
|
40
|
+
const agentName = deps.resolveAgent(connectionId, explicit);
|
|
41
|
+
if (agentName === null) {
|
|
42
|
+
return {
|
|
43
|
+
ok: false,
|
|
44
|
+
reason: "no_current_agent",
|
|
45
|
+
guidance: "Call cello_use_agent first, or pass 'agent' to say which agent this is for.",
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const ownerAgentId = deps.ownerKeyFor(agentName);
|
|
49
|
+
if (ownerAgentId === null) {
|
|
50
|
+
return {
|
|
51
|
+
ok: false,
|
|
52
|
+
reason: "agent_identity_unavailable",
|
|
53
|
+
guidance: `Agent '${agentName}' has no signing identity loaded, so it cannot hold documents.`,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
return { agentName, ownerAgentId };
|
|
57
|
+
}
|
|
58
|
+
const isRefusal = (r) => r.ok === false;
|
|
59
|
+
/**
|
|
60
|
+
* Write the document out as a file, or return null when no workspace is configured.
|
|
61
|
+
*
|
|
62
|
+
* Failures are LOGGED AND SWALLOWED, deliberately and only here: the file is a projection of the
|
|
63
|
+
* document, not the document. A disk problem must not fail a proposal or a consent decision that
|
|
64
|
+
* is otherwise complete and already recorded — the operator would be left with a peer who thinks
|
|
65
|
+
* they agreed and a local state that says they did not.
|
|
66
|
+
*/
|
|
67
|
+
async function materialize(ownerAgentId, documentId, documentType) {
|
|
68
|
+
if (!layer.writePath)
|
|
69
|
+
return null;
|
|
70
|
+
try {
|
|
71
|
+
return await layer.writePath.materialize(ownerAgentId, documentId, documentType, layer.live.get(ownerAgentId, documentId));
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
logger.warn("document.file.materialize_failed", {
|
|
75
|
+
documentId,
|
|
76
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
77
|
+
});
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// ─── propose ──────────────────────────────────────────────────────────────────────────────
|
|
82
|
+
handlers.set("cello_doc_propose", async (params, connectionId) => {
|
|
83
|
+
const who = resolve(params, connectionId);
|
|
84
|
+
if (isRefusal(who))
|
|
85
|
+
return who;
|
|
86
|
+
// RETRY PATH. A proposal whose send failed left a real local document and an unreachable peer;
|
|
87
|
+
// proposing again would mint a fresh nonce, hence a fresh document_id, hence a SECOND document —
|
|
88
|
+
// leaving the first an orphan the operator cannot explain or clear. Given the id, the stored
|
|
89
|
+
// envelope is re-sent unchanged, so the peer sees the offer that was always meant for them.
|
|
90
|
+
const retryId = typeof params?.document_id === "string" ? params.document_id : "";
|
|
91
|
+
if (retryId.length > 0) {
|
|
92
|
+
const stored = layer.handshake.get(who.ownerAgentId, retryId);
|
|
93
|
+
if (!stored || stored.proposerAgentId !== who.ownerAgentId) {
|
|
94
|
+
return {
|
|
95
|
+
ok: false,
|
|
96
|
+
reason: "document_proposal_not_ours",
|
|
97
|
+
guidance: `No proposal ${retryId.slice(0, 16)}… authored by this agent. Omit 'document_id' to ` +
|
|
98
|
+
`make a new proposal, or see cello_doc_list.`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const resendId = randomUUID();
|
|
102
|
+
const resent = await deps.transportFor(who.agentName).sendBytes({
|
|
103
|
+
peerAgentId: stored.envelope.peer_agent_id,
|
|
104
|
+
documentId: retryId,
|
|
105
|
+
bytes: encodeDocumentProposal(stored.envelope),
|
|
106
|
+
correlationId: resendId,
|
|
107
|
+
});
|
|
108
|
+
logger.info("document.proposal.resent", { documentId: retryId, sent: resent.ok, correlationId: resendId });
|
|
109
|
+
if (!resent.ok) {
|
|
110
|
+
return { ok: false, reason: resent.reason, guidance: resent.detail ?? "The peer is still unreachable." };
|
|
111
|
+
}
|
|
112
|
+
return { ok: true, documentId: retryId, proposalSent: true, peerAgentId: stored.envelope.peer_agent_id };
|
|
113
|
+
}
|
|
114
|
+
const peerAgentId = typeof params?.peer_pubkey === "string" ? params.peer_pubkey.toLowerCase() : "";
|
|
115
|
+
if (!/^[0-9a-f]{64}$/.test(peerAgentId)) {
|
|
116
|
+
return {
|
|
117
|
+
ok: false,
|
|
118
|
+
reason: "invalid_peer_pubkey",
|
|
119
|
+
guidance: "cello_doc_propose requires 'peer_pubkey' — the counterparty's 32-byte hex K_local " +
|
|
120
|
+
"public key, which is also their agent id. See cello_contacts.",
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
if (peerAgentId === who.ownerAgentId) {
|
|
124
|
+
// A document with yourself converges trivially and has no counterparty to consent, but every
|
|
125
|
+
// downstream unit would treat it as a real peer — including the delivery worker, which would
|
|
126
|
+
// dial the daemon it is running in.
|
|
127
|
+
return {
|
|
128
|
+
ok: false,
|
|
129
|
+
reason: "document_peer_is_self",
|
|
130
|
+
guidance: "A document needs a counterparty. 'peer_pubkey' is this agent's own key.",
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
const documentType = typeof params?.document_type === "string" ? params.document_type : DEFAULT_DOCUMENT_TYPE;
|
|
134
|
+
const startingText = typeof params?.starting_content === "string" ? params.starting_content : "";
|
|
135
|
+
// THE STARTING CONTENT IS A YJS UPDATE, not a string, and that is the whole reason it is on the
|
|
136
|
+
// proposal at all (§16.3 step 1). Both sides apply THESE BYTES, so epoch zero is byte-identical
|
|
137
|
+
// on both. Each side building its own doc from the same template string produces two documents
|
|
138
|
+
// that look the same and never converge — different client ids, different item ids, and every
|
|
139
|
+
// subsequent edit interleaving against a history the other has not got.
|
|
140
|
+
let startingContent = null;
|
|
141
|
+
if (startingText.length > 0) {
|
|
142
|
+
const seed = new Y.Doc();
|
|
143
|
+
// PINNED. A random client id would put the proposer's identity in the shared epoch-zero
|
|
144
|
+
// state, so the same proposal sent twice would produce different bytes and a different
|
|
145
|
+
// document_id — and the id is meant to be a function of what was proposed.
|
|
146
|
+
seed.clientID = 1;
|
|
147
|
+
seed.getText("content").insert(0, startingText);
|
|
148
|
+
startingContent = Y.encodeStateAsUpdate(seed);
|
|
149
|
+
}
|
|
150
|
+
const properties = {
|
|
151
|
+
assurance_tier: ASSURANCE_TIER_V1,
|
|
152
|
+
schema_enforcement: false,
|
|
153
|
+
topology: TOPOLOGY_V1,
|
|
154
|
+
append_only: params?.append_only === true,
|
|
155
|
+
};
|
|
156
|
+
const violation = seamViolation(properties);
|
|
157
|
+
if (violation) {
|
|
158
|
+
// Refused HERE rather than sent and refused by the peer: proposing something this build knows
|
|
159
|
+
// its counterparty must reject wastes a round trip and leaves a refused row on both sides.
|
|
160
|
+
return { ok: false, reason: "document_seam_violation", guidance: violation };
|
|
161
|
+
}
|
|
162
|
+
const envelope = {
|
|
163
|
+
type: "document_proposal",
|
|
164
|
+
feature_version: DOCUMENT_FEATURE_VERSION,
|
|
165
|
+
proposer_agent_id: who.ownerAgentId,
|
|
166
|
+
peer_agent_id: peerAgentId,
|
|
167
|
+
document_type: documentType,
|
|
168
|
+
properties,
|
|
169
|
+
starting_content: startingContent,
|
|
170
|
+
// Distinguishes two otherwise identical proposals. Without it, proposing the same document to
|
|
171
|
+
// the same peer twice collides on document_id and the second silently does nothing.
|
|
172
|
+
nonce: new Uint8Array(randomBytes(16)),
|
|
173
|
+
proposed_at_ms: deps.now(),
|
|
174
|
+
signature: new Uint8Array(0),
|
|
175
|
+
};
|
|
176
|
+
envelope.signature = await deps.sign(who.agentName, buildDocumentProposalTbs(envelope));
|
|
177
|
+
const documentId = documentIdFromProposal(envelope);
|
|
178
|
+
// LOCAL FIRST, then send. The reverse order loses the document if the process dies between the
|
|
179
|
+
// two, and the peer would then hold a document whose proposer has no record of proposing it —
|
|
180
|
+
// every update they send refused as `document_unknown`, with nothing on this side to explain it.
|
|
181
|
+
layer.store.createDocument({
|
|
182
|
+
documentId,
|
|
183
|
+
ownerAgentId: who.ownerAgentId,
|
|
184
|
+
peerAgentId,
|
|
185
|
+
documentType,
|
|
186
|
+
properties,
|
|
187
|
+
status: "active",
|
|
188
|
+
createdAtMs: envelope.proposed_at_ms,
|
|
189
|
+
});
|
|
190
|
+
// The envelope itself, so a failed send is recoverable rather than a dead end. See
|
|
191
|
+
// `DocumentHandshake.recordOutgoing`.
|
|
192
|
+
layer.handshake.recordOutgoing(who.ownerAgentId, envelope, envelope.proposed_at_ms);
|
|
193
|
+
if (startingContent) {
|
|
194
|
+
// Applied to OUR live doc from the same bytes the peer will apply, for the same reason they
|
|
195
|
+
// are on the wire at all.
|
|
196
|
+
Y.applyUpdate(layer.live.get(who.ownerAgentId, documentId), startingContent);
|
|
197
|
+
}
|
|
198
|
+
// THE FILE EXISTS FROM THE START. Materializing lazily would mean the first `cello_doc_publish`
|
|
199
|
+
// has no recorded projection to diff against and refuses — correct, and a bad first experience
|
|
200
|
+
// for something the operator never had to ask for.
|
|
201
|
+
const proposeFilePath = await materialize(who.ownerAgentId, documentId, documentType);
|
|
202
|
+
const correlationId = randomUUID();
|
|
203
|
+
const sent = await deps.transportFor(who.agentName).sendBytes({
|
|
204
|
+
peerAgentId,
|
|
205
|
+
documentId,
|
|
206
|
+
bytes: encodeDocumentProposal(envelope),
|
|
207
|
+
correlationId,
|
|
208
|
+
});
|
|
209
|
+
logger.info("document.proposed", { documentId, peerAgentId, sent: sent.ok, correlationId });
|
|
210
|
+
if (!sent.ok) {
|
|
211
|
+
// The document EXISTS and the proposal did not arrive. Both facts are reported, because
|
|
212
|
+
// reporting only the failure would hide a real local row and reporting only success would
|
|
213
|
+
// have the operator wait for a consent decision the peer was never asked to make.
|
|
214
|
+
return {
|
|
215
|
+
ok: true,
|
|
216
|
+
documentId,
|
|
217
|
+
proposalSent: false,
|
|
218
|
+
reason: sent.reason,
|
|
219
|
+
guidance: `The document was created locally but the proposal did not reach the peer (${sent.reason}). ` +
|
|
220
|
+
`Once they are online, run cello_doc_propose again with document_id='${documentId}' to ` +
|
|
221
|
+
`re-send this same offer — do not propose a new one, that would make a second document.`,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
return { ok: true, documentId, proposalSent: true, peerAgentId, filePath: proposeFilePath };
|
|
225
|
+
});
|
|
226
|
+
/**
|
|
227
|
+
* Tell the proposer what was decided.
|
|
228
|
+
*
|
|
229
|
+
* BEST-EFFORT, and the caller says so rather than failing the decision. Consent is local and
|
|
230
|
+
* final the moment the operator makes it — refusing to accept a document because the counterparty
|
|
231
|
+
* is momentarily unreachable would hand any network blip a veto over the operator's own choice.
|
|
232
|
+
* What an unsent ack costs is that the proposer keeps waiting, which the surface reports as
|
|
233
|
+
* unanswered rather than as a refusal.
|
|
234
|
+
*/
|
|
235
|
+
async function tellProposer(who, documentId, proposerAgentId, accepted, reason) {
|
|
236
|
+
const ack = {
|
|
237
|
+
type: "document_proposal_ack",
|
|
238
|
+
ack_version: DOCUMENT_PROPOSAL_ACK_VERSION,
|
|
239
|
+
document_id: documentId,
|
|
240
|
+
acker_agent_id: who.ownerAgentId,
|
|
241
|
+
accepted,
|
|
242
|
+
...(accepted ? {} : { refusal_reason: (reason ?? "declined").slice(0, MAX_PROPOSAL_REFUSAL_REASON_LENGTH) }),
|
|
243
|
+
decided_at_ms: deps.now(),
|
|
244
|
+
signature: new Uint8Array(0),
|
|
245
|
+
};
|
|
246
|
+
ack.signature = await deps.sign(who.agentName, buildDocumentProposalAckTbs(ack));
|
|
247
|
+
const sent = await deps.transportFor(who.agentName).sendBytes({
|
|
248
|
+
peerAgentId: proposerAgentId,
|
|
249
|
+
documentId,
|
|
250
|
+
bytes: encodeDocumentProposalAck(ack),
|
|
251
|
+
correlationId: randomUUID(),
|
|
252
|
+
});
|
|
253
|
+
if (!sent.ok) {
|
|
254
|
+
logger.warn("document.proposal.ack_unsent", { documentId, accepted, reason: sent.reason });
|
|
255
|
+
}
|
|
256
|
+
return sent.ok;
|
|
257
|
+
}
|
|
258
|
+
// ─── inbox / accept / refuse ──────────────────────────────────────────────────────────────
|
|
259
|
+
handlers.set("cello_doc_inbox", async (params, connectionId) => {
|
|
260
|
+
const who = resolve(params, connectionId);
|
|
261
|
+
if (isRefusal(who))
|
|
262
|
+
return who;
|
|
263
|
+
const pending = layer.handshake.pending(who.ownerAgentId);
|
|
264
|
+
return {
|
|
265
|
+
ok: true,
|
|
266
|
+
proposals: pending.map((p) => ({
|
|
267
|
+
documentId: p.documentId,
|
|
268
|
+
proposerAgentId: p.proposerAgentId,
|
|
269
|
+
documentType: p.envelope.document_type,
|
|
270
|
+
appendOnly: p.envelope.properties.append_only,
|
|
271
|
+
hasStartingContent: p.envelope.starting_content !== null,
|
|
272
|
+
proposedAtMs: p.envelope.proposed_at_ms,
|
|
273
|
+
})),
|
|
274
|
+
};
|
|
275
|
+
});
|
|
276
|
+
handlers.set("cello_doc_accept", async (params, connectionId) => {
|
|
277
|
+
const who = resolve(params, connectionId);
|
|
278
|
+
if (isRefusal(who))
|
|
279
|
+
return who;
|
|
280
|
+
const documentId = typeof params?.document_id === "string" ? params.document_id : "";
|
|
281
|
+
if (documentId.length === 0) {
|
|
282
|
+
return { ok: false, reason: "invalid_document_id", guidance: "Pass 'document_id' from cello_doc_inbox." };
|
|
283
|
+
}
|
|
284
|
+
const outcome = layer.handshake.accept(who.ownerAgentId, documentId, deps.now());
|
|
285
|
+
if (!outcome.ok)
|
|
286
|
+
return { ok: false, reason: outcome.reason, guidance: outcome.detail };
|
|
287
|
+
// THE CONSENT AND THE DOCUMENT ARE ONE ACT. `accept` moves the proposal row; without this the
|
|
288
|
+
// operator has agreed to a document that does not exist, and the peer's first update is refused
|
|
289
|
+
// as `document_unknown` — a refusal that names a real condition and explains nothing.
|
|
290
|
+
layer.store.createDocument({
|
|
291
|
+
documentId,
|
|
292
|
+
ownerAgentId: who.ownerAgentId,
|
|
293
|
+
peerAgentId: outcome.envelope.proposer_agent_id,
|
|
294
|
+
documentType: outcome.envelope.document_type,
|
|
295
|
+
properties: outcome.envelope.properties,
|
|
296
|
+
status: "active",
|
|
297
|
+
createdAtMs: deps.now(),
|
|
298
|
+
});
|
|
299
|
+
if (outcome.envelope.starting_content) {
|
|
300
|
+
Y.applyUpdate(layer.live.get(who.ownerAgentId, documentId), outcome.envelope.starting_content);
|
|
301
|
+
}
|
|
302
|
+
const acceptFilePath = await materialize(who.ownerAgentId, documentId, outcome.envelope.document_type);
|
|
303
|
+
logger.info("document.accepted", { documentId, proposerAgentId: outcome.envelope.proposer_agent_id });
|
|
304
|
+
const told = await tellProposer(who, documentId, outcome.envelope.proposer_agent_id, true, undefined);
|
|
305
|
+
return {
|
|
306
|
+
ok: true,
|
|
307
|
+
documentId,
|
|
308
|
+
peerAgentId: outcome.envelope.proposer_agent_id,
|
|
309
|
+
proposerNotified: told,
|
|
310
|
+
filePath: acceptFilePath,
|
|
311
|
+
};
|
|
312
|
+
});
|
|
313
|
+
handlers.set("cello_doc_refuse", async (params, connectionId) => {
|
|
314
|
+
const who = resolve(params, connectionId);
|
|
315
|
+
if (isRefusal(who))
|
|
316
|
+
return who;
|
|
317
|
+
const documentId = typeof params?.document_id === "string" ? params.document_id : "";
|
|
318
|
+
const reason = typeof params?.reason === "string" && params.reason.length > 0 ? params.reason : "declined_by_operator";
|
|
319
|
+
if (documentId.length === 0) {
|
|
320
|
+
return { ok: false, reason: "invalid_document_id", guidance: "Pass 'document_id' from cello_doc_inbox." };
|
|
321
|
+
}
|
|
322
|
+
const proposal = layer.handshake.get(who.ownerAgentId, documentId);
|
|
323
|
+
const outcome = layer.handshake.refuse(who.ownerAgentId, documentId, reason, deps.now());
|
|
324
|
+
if (!outcome.ok)
|
|
325
|
+
return { ok: false, reason: outcome.reason, guidance: outcome.detail };
|
|
326
|
+
// The REASON travels. A refusal the proposer cannot see the reason for leaves them unable to
|
|
327
|
+
// propose anything better, which is what makes people abandon a protocol rather than adjust.
|
|
328
|
+
const told = proposal
|
|
329
|
+
? await tellProposer(who, documentId, proposal.proposerAgentId, false, reason)
|
|
330
|
+
: false;
|
|
331
|
+
return { ok: true, documentId, proposerNotified: told };
|
|
332
|
+
});
|
|
333
|
+
// ─── list / read / write ──────────────────────────────────────────────────────────────────
|
|
334
|
+
handlers.set("cello_doc_list", async (params, connectionId) => {
|
|
335
|
+
const who = resolve(params, connectionId);
|
|
336
|
+
if (isRefusal(who))
|
|
337
|
+
return who;
|
|
338
|
+
return {
|
|
339
|
+
ok: true,
|
|
340
|
+
documents: layer.lifecycle.list(who.ownerAgentId, deps.now()).map((d) => {
|
|
341
|
+
// WHOSE OFFER WAS IT, and has the other side actually shown up?
|
|
342
|
+
//
|
|
343
|
+
// Without these three fields, `cello_doc_list` renders identically for a document the peer
|
|
344
|
+
// refused, one whose offer never reached them, and one being actively co-edited — the only
|
|
345
|
+
// moving part is `pendingUnsent`, which also moves for a peer who is merely offline. An
|
|
346
|
+
// operator cannot tell "they said no" from "they are asleep", and those want opposite
|
|
347
|
+
// actions.
|
|
348
|
+
const proposal = layer.handshake.get(who.ownerAgentId, d.documentId);
|
|
349
|
+
const peerAnswer = layer.handshake.peerAnswer(who.ownerAgentId, d.documentId);
|
|
350
|
+
return {
|
|
351
|
+
...d,
|
|
352
|
+
proposedByUs: proposal?.proposerAgentId === who.ownerAgentId,
|
|
353
|
+
// THE PEER'S OWN SIGNED ANSWER — true accepted, false refused, null not yet heard. This
|
|
354
|
+
// replaced an inference ("they have published into it") that could not tell refused from
|
|
355
|
+
// unreceived from accepted-but-untouched, two of which want the operator to act.
|
|
356
|
+
//
|
|
357
|
+
// The REASON comes with it. It was stored and read by nothing, which defeats why it is
|
|
358
|
+
// mandatory on the wire: a refusal whose reason the proposer cannot see leaves them
|
|
359
|
+
// unable to propose anything better.
|
|
360
|
+
peerAccepted: peerAnswer.accepted,
|
|
361
|
+
peerRefusalReason: peerAnswer.reason,
|
|
362
|
+
// Kept alongside it, because they answer different questions: whether they agreed, and
|
|
363
|
+
// whether anything has actually come back. A document accepted an hour ago with nothing
|
|
364
|
+
// in it is a fine state; it is just not the same state.
|
|
365
|
+
peerHasPublished: layer.store.knownEnvelopeHashesBySender(who.ownerAgentId, d.documentId, d.peerAgentId).size > 0,
|
|
366
|
+
consentState: proposal?.consentState ?? null,
|
|
367
|
+
};
|
|
368
|
+
}),
|
|
369
|
+
};
|
|
370
|
+
});
|
|
371
|
+
handlers.set("cello_doc_read", async (params, connectionId) => {
|
|
372
|
+
const who = resolve(params, connectionId);
|
|
373
|
+
if (isRefusal(who))
|
|
374
|
+
return who;
|
|
375
|
+
const documentId = typeof params?.document_id === "string" ? params.document_id : "";
|
|
376
|
+
if (documentId.length === 0) {
|
|
377
|
+
return { ok: false, reason: "invalid_document_id", guidance: "Pass 'document_id' from cello_doc_list." };
|
|
378
|
+
}
|
|
379
|
+
const document = layer.store.getDocument(who.ownerAgentId, documentId);
|
|
380
|
+
if (!document) {
|
|
381
|
+
return {
|
|
382
|
+
ok: false,
|
|
383
|
+
reason: "document_unknown",
|
|
384
|
+
guidance: `No document ${documentId.slice(0, 16)}… for this agent. See cello_doc_list.`,
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
// THROWS rather than returning empty when the log cannot be rebuilt — see LiveDocuments.get. An
|
|
388
|
+
// empty document handed to an agent here would be written back over the peer's real content.
|
|
389
|
+
const doc = layer.live.get(who.ownerAgentId, documentId);
|
|
390
|
+
const content = doc.getText("content").toString();
|
|
391
|
+
// THE READ IS THE BOOKMARK. `cello_doc_diff` answers "what changed since I looked", and looking
|
|
392
|
+
// is this call. Marking on an arriving update instead would erase the very change the diff
|
|
393
|
+
// exists to show, silently, at the moment it arrived.
|
|
394
|
+
layer.notifications.markRead(who.ownerAgentId, documentId, content, deps.now());
|
|
395
|
+
// And the unread notice is cleared, because it has now been read. Leaving it would keep an
|
|
396
|
+
// inbox entry for something the agent is holding in its hands.
|
|
397
|
+
layer.notifications.clear(who.ownerAgentId, documentId);
|
|
398
|
+
return {
|
|
399
|
+
ok: true,
|
|
400
|
+
documentId,
|
|
401
|
+
documentType: document.documentType,
|
|
402
|
+
peerAgentId: document.peerAgentId,
|
|
403
|
+
status: document.status,
|
|
404
|
+
content,
|
|
405
|
+
};
|
|
406
|
+
});
|
|
407
|
+
handlers.set("cello_doc_diff", async (params, connectionId) => {
|
|
408
|
+
const who = resolve(params, connectionId);
|
|
409
|
+
if (isRefusal(who))
|
|
410
|
+
return who;
|
|
411
|
+
const documentId = typeof params?.document_id === "string" ? params.document_id : "";
|
|
412
|
+
if (documentId.length === 0) {
|
|
413
|
+
return { ok: false, reason: "invalid_document_id", guidance: "Pass 'document_id' from cello_doc_list." };
|
|
414
|
+
}
|
|
415
|
+
const document = layer.store.getDocument(who.ownerAgentId, documentId);
|
|
416
|
+
if (!document) {
|
|
417
|
+
return {
|
|
418
|
+
ok: false,
|
|
419
|
+
reason: "document_unknown",
|
|
420
|
+
guidance: `No document ${documentId.slice(0, 16)}… for this agent. See cello_doc_list.`,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
const after = layer.live.get(who.ownerAgentId, documentId).getText("content").toString();
|
|
424
|
+
const before = layer.notifications.lastSeen(who.ownerAgentId, documentId);
|
|
425
|
+
if (before === null) {
|
|
426
|
+
// NEVER READ is not "nothing changed", and it is not an empty before either. Diffing against
|
|
427
|
+
// "" would render a first look at a long document as an enormous change the agent then treats
|
|
428
|
+
// as "what just arrived" — and act on. Said plainly instead.
|
|
429
|
+
return {
|
|
430
|
+
ok: false,
|
|
431
|
+
reason: "document_never_read",
|
|
432
|
+
guidance: `You have not read ${documentId.slice(0, 16)}… yet, so there is nothing to compare against. ` +
|
|
433
|
+
`Call cello_doc_read first; the diff answers "what changed since I looked".`,
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
const rendered = layer.notifications.diff(document.documentType, before, after, documentId);
|
|
437
|
+
// The STATS come from the same pair of texts, so an agent branching on `overlap` is branching on
|
|
438
|
+
// the same comparison it is being shown.
|
|
439
|
+
// OUR OWN edited lines, so `overlap` is a computed answer rather than the reassuring null three
|
|
440
|
+
// instruction sheets were telling agents to trust. Null here still means "not computed" — we
|
|
441
|
+
// have not written since the read — and `diffStats` keeps that distinct from "no conflict",
|
|
442
|
+
// which is the whole reason its parameter is required.
|
|
443
|
+
const myEdits = layer.notifications.myEditedLines(who.ownerAgentId, documentId);
|
|
444
|
+
const stats = layer.notifications.diffStats(documentId, before, after, myEdits, document.documentType);
|
|
445
|
+
if (!rendered.ok) {
|
|
446
|
+
// The stats still stand — they are structural and type-independent — so a document type this
|
|
447
|
+
// build cannot render is not a document an agent has to read blind.
|
|
448
|
+
return { ok: true, documentId, unchanged: before === after, diff: null, reason: rendered.reason, stats };
|
|
449
|
+
}
|
|
450
|
+
return {
|
|
451
|
+
ok: true,
|
|
452
|
+
documentId,
|
|
453
|
+
unchanged: before === after,
|
|
454
|
+
diff: rendered.diff,
|
|
455
|
+
...(rendered.fallback !== undefined ? { fallback: rendered.fallback } : {}),
|
|
456
|
+
stats,
|
|
457
|
+
};
|
|
458
|
+
});
|
|
459
|
+
handlers.set("cello_doc_write", async (params, connectionId) => {
|
|
460
|
+
const who = resolve(params, connectionId);
|
|
461
|
+
if (isRefusal(who))
|
|
462
|
+
return who;
|
|
463
|
+
const documentId = typeof params?.document_id === "string" ? params.document_id : "";
|
|
464
|
+
if (documentId.length === 0) {
|
|
465
|
+
return { ok: false, reason: "invalid_document_id", guidance: "Pass 'document_id' from cello_doc_list." };
|
|
466
|
+
}
|
|
467
|
+
if (typeof params?.content !== "string") {
|
|
468
|
+
return {
|
|
469
|
+
ok: false,
|
|
470
|
+
reason: "invalid_content",
|
|
471
|
+
guidance: "cello_doc_write takes 'content' — the document's COMPLETE new text, not a patch. The " +
|
|
472
|
+
"daemon diffs it against the current state, so offsets cannot go stale under a " +
|
|
473
|
+
"concurrent edit by the peer.",
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
const content = params.content;
|
|
477
|
+
const document = layer.store.getDocument(who.ownerAgentId, documentId);
|
|
478
|
+
if (!document) {
|
|
479
|
+
return {
|
|
480
|
+
ok: false,
|
|
481
|
+
reason: "document_unknown",
|
|
482
|
+
guidance: `No document ${documentId.slice(0, 16)}… for this agent. See cello_doc_list.`,
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
const doc = layer.live.get(who.ownerAgentId, documentId);
|
|
486
|
+
const text = doc.getText("content");
|
|
487
|
+
const before = text.toString();
|
|
488
|
+
if (before === content) {
|
|
489
|
+
return { ok: true, documentId, changed: false, published: false };
|
|
490
|
+
}
|
|
491
|
+
// LINE HUNKS, never a whole-text replace — and this is not a preference, it is measured.
|
|
492
|
+
//
|
|
493
|
+
// `delete(0, len); insert(0, content)` can only delete the items THIS side has seen. A peer's
|
|
494
|
+
// concurrently-inserted items survive the delete and are spliced into the new text as orphan
|
|
495
|
+
// fragments. Against yjs at this version:
|
|
496
|
+
//
|
|
497
|
+
// both sides full-replace "original" with "AAA" / "BBB" → "AAABBB" on BOTH sides
|
|
498
|
+
// "Hello world", peer inserts " dear", we replace w/ "Goodbye" → " dearGoodbye"
|
|
499
|
+
//
|
|
500
|
+
// The first is the ORDINARY case for an API whose contract is "send back the complete text",
|
|
501
|
+
// and it converges two whole documents concatenated — signed and published by both parties. It
|
|
502
|
+
// is exactly the "a wrong offset in a CRDT is a permanent corruption both sides converge on"
|
|
503
|
+
// outcome the full-content contract was chosen to avoid; the mechanism moved and the failure
|
|
504
|
+
// did not.
|
|
505
|
+
//
|
|
506
|
+
// `lineHunks` touches only the lines that actually changed, so untouched regions keep their
|
|
507
|
+
// items and a peer's concurrent edit to them merges. Same function `DocumentWritePath.#foldText`
|
|
508
|
+
// uses, whose header records the same hazard for the file path — one folding rule, not two.
|
|
509
|
+
const hunks = lineHunks(before, content);
|
|
510
|
+
// ONE TRANSACTION, so the whole edit is a single Yjs update rather than several — a peer
|
|
511
|
+
// applying them separately would pass through states no operator ever wrote.
|
|
512
|
+
doc.transact(() => {
|
|
513
|
+
// Back to front, so earlier offsets stay valid as later ones are rewritten.
|
|
514
|
+
for (const hunk of [...hunks].reverse()) {
|
|
515
|
+
if (hunk.to > hunk.from)
|
|
516
|
+
text.delete(hunk.from, hunk.to - hunk.from);
|
|
517
|
+
if (hunk.insert.length > 0)
|
|
518
|
+
text.insert(hunk.from, hunk.insert);
|
|
519
|
+
}
|
|
520
|
+
});
|
|
521
|
+
const result = await publish.publish(who.ownerAgentId, documentId, doc, deps.now());
|
|
522
|
+
if (!result.ok) {
|
|
523
|
+
// The EDIT IS APPLIED locally and is not published. Reported as such: an operator told the
|
|
524
|
+
// write failed would write it again, and the second write would be a no-op diff against the
|
|
525
|
+
// text it already applied — the change silently never leaving.
|
|
526
|
+
return {
|
|
527
|
+
ok: true,
|
|
528
|
+
documentId,
|
|
529
|
+
changed: true,
|
|
530
|
+
published: false,
|
|
531
|
+
reason: result.reason,
|
|
532
|
+
guidance: result.detail,
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
// WHAT WE WROTE, against the current read mark. Without it `cello_doc_diff` shows our own edits
|
|
536
|
+
// back to us as "what changed since I looked" — which the tool description frames as the
|
|
537
|
+
// COUNTERPARTY's contribution — and `overlap` has nothing to separate mine from theirs.
|
|
538
|
+
layer.notifications.markWritten(who.ownerAgentId, documentId, content);
|
|
539
|
+
// Delivery is the worker's, not this call's — publish is fire-and-forget by design (§16.4), and
|
|
540
|
+
// a write that blocked on an offline peer would make editing a shared document depend on the
|
|
541
|
+
// other party being awake.
|
|
542
|
+
return { ok: true, documentId, changed: true, published: true, envelopeHash: result.envelopeHash };
|
|
543
|
+
});
|
|
544
|
+
// ─── close / kill ─────────────────────────────────────────────────────────────────────────
|
|
545
|
+
handlers.set("cello_doc_close", async (params, connectionId) => {
|
|
546
|
+
const who = resolve(params, connectionId);
|
|
547
|
+
if (isRefusal(who))
|
|
548
|
+
return who;
|
|
549
|
+
const documentId = typeof params?.document_id === "string" ? params.document_id : "";
|
|
550
|
+
if (documentId.length === 0) {
|
|
551
|
+
return { ok: false, reason: "invalid_document_id", guidance: "Pass 'document_id' from cello_doc_list." };
|
|
552
|
+
}
|
|
553
|
+
const outcome = await layer.lifecycle.close(who.ownerAgentId, documentId, deps.now());
|
|
554
|
+
if (!outcome.ok)
|
|
555
|
+
return { ok: false, reason: outcome.reason, guidance: outcome.detail };
|
|
556
|
+
const status = layer.store.getDocument(who.ownerAgentId, documentId)?.status ?? "unknown";
|
|
557
|
+
// BILATERAL. The document settles when both sides have said it, so "closed" is not this call's
|
|
558
|
+
// answer to give — reporting it would tell an operator the collaboration is over while the peer
|
|
559
|
+
// is still editing.
|
|
560
|
+
//
|
|
561
|
+
// `peerNotified` is reported for the same reason `kill` reports it, and its absence here was an
|
|
562
|
+
// asymmetry with teeth: `status: "active"` after a close is INDISTINGUISHABLE from "sent fine,
|
|
563
|
+
// they have not answered yet". Control frames are fire-once — unlike update envelopes they are
|
|
564
|
+
// not in the log and the delivery sweep never retries them — so a close sent while the peer was
|
|
565
|
+
// offline means the document can never settle, forever, with nothing on either screen.
|
|
566
|
+
return {
|
|
567
|
+
ok: true,
|
|
568
|
+
documentId,
|
|
569
|
+
status,
|
|
570
|
+
peerNotified: outcome.peerNotified,
|
|
571
|
+
...(outcome.peerNotified
|
|
572
|
+
? {}
|
|
573
|
+
: {
|
|
574
|
+
guidance: `Your close was recorded but did not reach the peer, so the document cannot settle ` +
|
|
575
|
+
`until they hear it. A close is not retried — run cello_doc_close again when they ` +
|
|
576
|
+
`are back, or cello_doc_kill if you need it over now.`,
|
|
577
|
+
}),
|
|
578
|
+
};
|
|
579
|
+
});
|
|
580
|
+
handlers.set("cello_doc_kill", async (params, connectionId) => {
|
|
581
|
+
const who = resolve(params, connectionId);
|
|
582
|
+
if (isRefusal(who))
|
|
583
|
+
return who;
|
|
584
|
+
const documentId = typeof params?.document_id === "string" ? params.document_id : "";
|
|
585
|
+
if (documentId.length === 0) {
|
|
586
|
+
return { ok: false, reason: "invalid_document_id", guidance: "Pass 'document_id' from cello_doc_list." };
|
|
587
|
+
}
|
|
588
|
+
const outcome = await layer.lifecycle.kill(who.ownerAgentId, documentId, deps.now());
|
|
589
|
+
if (!outcome.ok)
|
|
590
|
+
return { ok: false, reason: outcome.reason, guidance: outcome.detail };
|
|
591
|
+
// `peerNotified` is REPORTED, never hidden behind ok. A kill is deliberately independent of the
|
|
592
|
+
// peer being reachable — a decision to stop that depends on the other party being online is not
|
|
593
|
+
// a decision to stop — but an operator who believes the peer was told, when they were not, will
|
|
594
|
+
// not understand why updates keep arriving.
|
|
595
|
+
return { ok: true, documentId, peerNotified: outcome.peerNotified, note: outcome.note };
|
|
596
|
+
});
|
|
597
|
+
handlers.set("cello_doc_publish", async (params, connectionId) => {
|
|
598
|
+
const who = resolve(params, connectionId);
|
|
599
|
+
if (isRefusal(who))
|
|
600
|
+
return who;
|
|
601
|
+
const documentId = typeof params?.document_id === "string" ? params.document_id : "";
|
|
602
|
+
if (documentId.length === 0) {
|
|
603
|
+
return { ok: false, reason: "invalid_document_id", guidance: "Pass 'document_id' from cello_doc_list." };
|
|
604
|
+
}
|
|
605
|
+
if (!layer.writePath) {
|
|
606
|
+
return {
|
|
607
|
+
ok: false,
|
|
608
|
+
reason: "document_files_unavailable",
|
|
609
|
+
guidance: "This daemon has no document workspace configured, so there is no file to publish from.",
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
const document = layer.store.getDocument(who.ownerAgentId, documentId);
|
|
613
|
+
if (!document) {
|
|
614
|
+
return {
|
|
615
|
+
ok: false,
|
|
616
|
+
reason: "document_unknown",
|
|
617
|
+
guidance: `No document ${documentId.slice(0, 16)}… for this agent. See cello_doc_list.`,
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
const doc = layer.live.get(who.ownerAgentId, documentId);
|
|
621
|
+
let update;
|
|
622
|
+
try {
|
|
623
|
+
// Diffs the FILE against the last recorded projection and folds the difference in as local
|
|
624
|
+
// operations. Refuses loudly on a stale baseline rather than diffing against something the
|
|
625
|
+
// document has moved past — which would read a peer's admitted content as a deliberate
|
|
626
|
+
// deletion and publish it as one.
|
|
627
|
+
update = await layer.writePath.publish(who.ownerAgentId, documentId, document.documentType, doc);
|
|
628
|
+
}
|
|
629
|
+
catch (err) {
|
|
630
|
+
const reason = err instanceof Error && "reason" in err ? String(err.reason) : "document_file_error";
|
|
631
|
+
return {
|
|
632
|
+
ok: false,
|
|
633
|
+
reason,
|
|
634
|
+
guidance: err instanceof Error ? err.message : String(err),
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
if (update === null) {
|
|
638
|
+
// A publish is an INTENT. Nothing changed on disk, so there is nothing to say, and saying it
|
|
639
|
+
// anyway costs a leaf and a round trip.
|
|
640
|
+
return { ok: true, documentId, changed: false, published: false };
|
|
641
|
+
}
|
|
642
|
+
const result = await publish.publish(who.ownerAgentId, documentId, doc, deps.now());
|
|
643
|
+
if (!result.ok) {
|
|
644
|
+
// The file's edits are already FOLDED INTO the document — same shape as cello_doc_write's
|
|
645
|
+
// applied-but-unpublished case, and reported the same way, because an operator told this
|
|
646
|
+
// failed would edit again and the second publish would diff against a projection that already
|
|
647
|
+
// contains their change.
|
|
648
|
+
return { ok: true, documentId, changed: true, published: false, reason: result.reason, guidance: result.detail };
|
|
649
|
+
}
|
|
650
|
+
layer.notifications.markWritten(who.ownerAgentId, documentId, doc.getText("content").toString());
|
|
651
|
+
return { ok: true, documentId, changed: true, published: true, envelopeHash: result.envelopeHash };
|
|
652
|
+
});
|
|
653
|
+
logger.debug("document.handlers.registered", {
|
|
654
|
+
verbs: ["propose", "inbox", "accept", "refuse", "list", "read", "diff", "write", "publish", "close", "kill"],
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
//# sourceMappingURL=document-handlers.js.map
|