@atalk/sdk 0.1.0-alpha.1 → 0.1.0-alpha.11

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.
@@ -0,0 +1,1093 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { open, stat } from "node:fs/promises";
3
+ import { basename, extname, resolve } from "node:path";
4
+ import { attachmentPartDescriptors, createChunkedAttachmentDescriptor, decryptMandateTerms, decryptWorkroomPayload, encryptAttachment, encryptAttachmentChunk, encryptWorkroomPayload, evaluateMandateUse, hashCanonical, mandateCommitmentMatchesEncryptedTerms, mandateCommitmentMatchesTerms, splitEncryptedAttachment, signWorkroomReceipt, resolveWorkroomRouting, validateWorkroomContentRouting, verifyMandate, verifyMandateCommitment, verifyMandateRevocation, verifyWorkroomApprovalDecision, workroomApprovalRequestPayloadSchema, workroomContentPayloadSchema, workroomDescriptorSchema, } from "@atalk/protocol";
5
+ const MAX_PROCESSED_WORKROOM_EVENTS = 10_000;
6
+ /** E2EE task client. The relay sees signed ciphertext and routing projections only. */
7
+ export class WorkroomClient {
8
+ dependencies;
9
+ executionTail = Promise.resolve();
10
+ constructor(dependencies) {
11
+ this.dependencies = dependencies;
12
+ }
13
+ async list(cursor, limit = 50) {
14
+ const query = new URLSearchParams({ limit: String(Math.max(1, Math.min(limit, 200))) });
15
+ if (cursor)
16
+ query.set("cursor", cursor);
17
+ const page = await this.retry(() => this.dependencies.request(`/v1/workrooms?${query}`), 3);
18
+ return { ...page, workrooms: page.workrooms.map((detail) => this.openDetail(detail)) };
19
+ }
20
+ async get(workroomId, afterSequence = 0, eventLimit = 100) {
21
+ const query = new URLSearchParams({
22
+ afterSequence: String(Math.max(0, afterSequence)),
23
+ eventLimit: String(Math.max(1, Math.min(eventLimit, 200))),
24
+ });
25
+ const detail = await this.retry(() => this.dependencies.request(`/v1/workrooms/${encodeURIComponent(workroomId)}?${query}`), 3);
26
+ return this.openDetail(detail);
27
+ }
28
+ /**
29
+ * Polls from the last durably acknowledged sequence and invokes the agent
30
+ * handler only for events explicitly directed to this identity. Undirected
31
+ * events are still authenticated and consumed so shared Task conversation
32
+ * traffic cannot accidentally start a model turn. A handler failure does not
33
+ * advance the cursor, so the same directed event is retried after restart.
34
+ */
35
+ async poll(workroomId, handler, options = {}) {
36
+ let cursor = this.dependencies.runtimeState().workroomCursors?.[workroomId] ?? 0;
37
+ const limit = Math.max(1, Math.min(options.limit ?? 100, 500));
38
+ do {
39
+ throwIfAborted(options.signal);
40
+ const page = await this.readEventPage(workroomId, cursor, limit, options.maxAttempts ?? 3, options.signal);
41
+ for (const decrypted of page.events) {
42
+ throwIfAborted(options.signal);
43
+ const record = decrypted;
44
+ const alreadyHandled = this.dependencies.runtimeState().processedWorkroomEvents?.[record.event.eventId];
45
+ if (!alreadyHandled && decrypted.directedToMe)
46
+ await handler(autonomousEventView(decrypted));
47
+ cursor = record.sequence;
48
+ await this.dependencies.mutateRuntimeState((state) => {
49
+ state.workroomCursors ??= {};
50
+ state.processedWorkroomEvents ??= {};
51
+ state.workroomCursors[workroomId] = cursor;
52
+ state.processedWorkroomEvents[record.event.eventId] = true;
53
+ const ids = Object.keys(state.processedWorkroomEvents);
54
+ for (let index = 0; index < ids.length - MAX_PROCESSED_WORKROOM_EVENTS; index += 1) {
55
+ delete state.processedWorkroomEvents[ids[index]];
56
+ }
57
+ });
58
+ }
59
+ if (page.nextAfterSequence === null || page.events.length === 0)
60
+ return cursor;
61
+ } while (true);
62
+ }
63
+ /**
64
+ * Reads every authenticated event without advancing the autonomous handler
65
+ * cursor. This is the explicit operator/audit surface; never use it as a
66
+ * model-trigger loop because it includes traffic addressed to other agents.
67
+ */
68
+ async readAuditEvents(workroomId, afterSequence = 0, limit = 100, signal) {
69
+ return this.readEventPage(workroomId, afterSequence, limit, 3, signal);
70
+ }
71
+ /** Long-polls using the durable cursor until aborted. */
72
+ async watch(workroomId, handler, options = {}) {
73
+ while (!options.signal?.aborted) {
74
+ await this.poll(workroomId, handler, options);
75
+ await abortableDelay(Math.max(250, options.intervalMs ?? 2_000), options.signal);
76
+ }
77
+ }
78
+ async publish(workroomId, threadId, candidate, options = {}) {
79
+ const prepared = prepareContentAndProjection(workroomContentPayloadSchema.parse(candidate), options.projection);
80
+ const content = prepared.content;
81
+ if ((content.kind === "message" || content.kind === "activity") && content.threadId !== threadId) {
82
+ throw new Error("WORKROOM_THREAD_MISMATCH");
83
+ }
84
+ const credentials = this.dependencies.credentials();
85
+ const detail = await this.get(workroomId, 0, 1);
86
+ const activePeers = exactActiveMemberPeers(detail);
87
+ validateWorkroomContentRouting(content, activePeers.map(routingPeer), credentials.peer.id);
88
+ const recipients = activePeers.map(({ id, encryptionPublicKey }) => ({ peerId: id, encryptionPublicKey }));
89
+ if (!recipients.some(({ peerId }) => peerId === credentials.peer.id))
90
+ throw new Error("WORKROOM_MEMBERSHIP_REQUIRED");
91
+ if (detail.membership.role === "observer")
92
+ throw new Error("WORKROOM_READ_ONLY");
93
+ const now = new Date().toISOString();
94
+ const eventId = options.eventId ?? randomUUID();
95
+ const envelope = encryptWorkroomPayload({
96
+ envelopeId: eventId,
97
+ workroomId,
98
+ senderPeerId: credentials.peer.id,
99
+ keyEpoch: detail.workroom.currentKeyEpoch,
100
+ payload: content,
101
+ senderSigningSecretKey: credentials.keys.signingSecretKey,
102
+ senderEncryptionSecretKey: credentials.keys.encryptionSecretKey,
103
+ recipients,
104
+ createdAt: now,
105
+ });
106
+ const event = {
107
+ eventId,
108
+ workroomId,
109
+ threadId,
110
+ actorPeerId: credentials.peer.id,
111
+ kind: content.kind,
112
+ envelope,
113
+ idempotencyKey: options.idempotencyKey ?? `event-${eventId}`,
114
+ createdAt: now,
115
+ };
116
+ const projection = prepared.projection;
117
+ const body = { event, ...(projection ? { projection } : {}) };
118
+ const result = await this.retry(() => this.dependencies.request(`/v1/workrooms/${encodeURIComponent(workroomId)}/events`, {
119
+ method: "POST",
120
+ body: JSON.stringify(body),
121
+ }), options.maxAttempts ?? 3);
122
+ return result.record;
123
+ }
124
+ message(workroomId, threadId, body, mentions = []) {
125
+ return this.publish(workroomId, threadId, { version: 1, kind: "message", threadId, body, mentions });
126
+ }
127
+ activity(workroomId, threadId, activityType, summary, mentions = []) {
128
+ return this.publish(workroomId, threadId, {
129
+ version: 1,
130
+ kind: "activity",
131
+ threadId,
132
+ activityType,
133
+ summary,
134
+ mentions,
135
+ sourceEventIds: [],
136
+ attributes: {},
137
+ });
138
+ }
139
+ plan(workroomId, threadId, payload, options) {
140
+ return this.publish(workroomId, threadId, { version: 1, kind: "plan", ...payload }, options);
141
+ }
142
+ artifactVersion(workroomId, threadId, payload, options) {
143
+ return this.publish(workroomId, threadId, { version: 1, kind: "artifact_version", ...payload }, options);
144
+ }
145
+ deliverable(workroomId, threadId, payload, options) {
146
+ return this.publish(workroomId, threadId, { version: 1, kind: "deliverable", ...payload }, options);
147
+ }
148
+ /**
149
+ * Preferred publication boundary for agent runtimes. It derives the same
150
+ * action names as the app, checks the current signed mandate twice, and
151
+ * records durable counters plus a signed receipt after publication.
152
+ */
153
+ async publishMandated(input) {
154
+ const content = workroomContentPayloadSchema.parse(input.payload);
155
+ if (content.kind === "cost")
156
+ throw new Error("WORKROOM_COST_MUST_BE_DERIVED_FROM_AN_EXECUTED_ACTION");
157
+ if (content.kind === "approval_request") {
158
+ throw new Error("WORKROOM_APPROVAL_REQUESTS_ARE_CREATED_BY_THE_MANDATE_GUARD");
159
+ }
160
+ const detail = await this.get(input.workroomId, 0, 1);
161
+ const participantPeerIds = activePeerIds(detail);
162
+ const action = defaultWorkroomAction(content.kind);
163
+ const volumeDelta = payloadVolume(content);
164
+ const dataAccesses = input.dataAccesses ?? (content.kind === "artifact_version"
165
+ ? [{
166
+ resource: "workroom.attachments",
167
+ permission: "write",
168
+ recipientPeerIds: participantPeerIds,
169
+ classification: "workroom",
170
+ }]
171
+ : []);
172
+ return this.executeMandatedAction({
173
+ ...input,
174
+ action,
175
+ participantPeerIds: input.participantPeerIds ?? participantPeerIds,
176
+ dataAccesses,
177
+ volumeDelta: input.volumeDelta ?? volumeDelta,
178
+ summary: input.summary?.trim() || publicationSummary(content),
179
+ effect: input.effect?.trim() || publicationEffect(content),
180
+ }, async () => ({
181
+ value: await this.publish(input.workroomId, input.threadId, content, {
182
+ ...input.publish,
183
+ eventId: input.publish?.eventId ?? deterministicUuid(`${input.operationId}:event`),
184
+ idempotencyKey: input.publish?.idempotencyKey ?? `operation-${input.operationId}`,
185
+ }),
186
+ }));
187
+ }
188
+ /** Permission-aware local decryption for files supplied to an external agent. */
189
+ async downloadAttachmentToMandated(input) {
190
+ const detail = await this.get(input.workroomId, 0, 1);
191
+ const participantPeerIds = activePeerIds(detail);
192
+ return this.executeMandatedAction({
193
+ ...input,
194
+ action: "file.read",
195
+ participantPeerIds: input.participantPeerIds ?? participantPeerIds,
196
+ dataAccesses: input.dataAccesses ?? [{
197
+ resource: "workroom.attachments",
198
+ permission: "read",
199
+ recipientPeerIds: [],
200
+ classification: "workroom",
201
+ }],
202
+ volumeDelta: input.volumeDelta ?? {
203
+ messages: 0, files: 1, totalBytes: input.descriptor.size, actions: 1, custom: {},
204
+ },
205
+ summary: input.summary?.trim() || `Read ${input.descriptor.name}`,
206
+ effect: input.effect?.trim() || "Decrypt one Task file inside the configured agent runtime",
207
+ }, async () => ({
208
+ value: await this.downloadAttachmentTo(input.descriptor, input.path, input.transfer),
209
+ }));
210
+ }
211
+ /** Guard, upload and publish one encrypted Task file as a single runtime operation. */
212
+ async submitFileMandated(input) {
213
+ const path = resolve(input.path);
214
+ const metadata = await stat(path);
215
+ if (!metadata.isFile())
216
+ throw new Error("ATTACHMENT_NOT_A_FILE");
217
+ const detail = await this.get(input.workroomId, 0, 1);
218
+ const participantPeerIds = activePeerIds(detail);
219
+ const name = input.name?.trim() || basename(path);
220
+ return this.executeMandatedAction({
221
+ ...input,
222
+ action: "file.create",
223
+ participantPeerIds: input.participantPeerIds ?? participantPeerIds,
224
+ dataAccesses: input.dataAccesses ?? [{
225
+ resource: "workroom.attachments",
226
+ permission: "write",
227
+ recipientPeerIds: participantPeerIds,
228
+ classification: "workroom",
229
+ }],
230
+ volumeDelta: { messages: 0, files: 1, totalBytes: metadata.size, actions: 1, custom: {} },
231
+ summary: input.summary?.trim() || `Add file: ${input.title?.trim() || name}`,
232
+ effect: input.effect?.trim() || "Encrypt and share one file with every current Task participant",
233
+ }, async () => {
234
+ const descriptor = await this.uploadAttachmentFile({
235
+ workroomId: input.workroomId,
236
+ path,
237
+ name,
238
+ ...(input.mimeType ? { mimeType: input.mimeType } : {}),
239
+ ...(input.transfer ? { transfer: input.transfer } : {}),
240
+ });
241
+ try {
242
+ const artifactId = input.artifactId ?? deterministicUuid(`${input.operationId}:artifact`);
243
+ const artifactVersion = input.artifactVersion ?? 1;
244
+ const artifactVersionId = deterministicUuid(`${input.operationId}:artifact-version`);
245
+ const record = await this.artifactVersion(input.workroomId, input.threadId, {
246
+ artifactId,
247
+ artifactVersion,
248
+ artifactVersionId,
249
+ artifactType: input.artifactType ?? "file",
250
+ title: input.title?.trim() || name,
251
+ ...(input.description?.trim() ? { description: input.description.trim() } : {}),
252
+ mediaType: descriptor.mimeType,
253
+ fileName: descriptor.name,
254
+ attachmentIds: [descriptor.id],
255
+ attachments: [descriptor],
256
+ contentHash: await hashFile(path),
257
+ mentions: input.mentions ?? [],
258
+ }, {
259
+ eventId: deterministicUuid(`${input.operationId}:artifact-event`),
260
+ idempotencyKey: `artifact-${input.operationId}`,
261
+ });
262
+ return { value: { descriptor, artifactId, artifactVersion, artifactVersionId, record } };
263
+ }
264
+ catch (error) {
265
+ await Promise.allSettled(attachmentPartDescriptors(descriptor).map(({ id }) => this.dependencies.deletePart(id)));
266
+ throw error;
267
+ }
268
+ });
269
+ }
270
+ async uploadAttachment(input) {
271
+ const encrypted = splitEncryptedAttachment(encryptAttachment({
272
+ id: randomUUID(),
273
+ bytes: input.data,
274
+ name: input.name,
275
+ mimeType: input.mimeType ?? "application/octet-stream",
276
+ }), randomUUID);
277
+ try {
278
+ for (const part of encrypted.parts) {
279
+ await this.dependencies.uploadPart({ workroomId: input.workroomId }, part.id, part.ciphertext);
280
+ }
281
+ return encrypted.descriptor;
282
+ }
283
+ catch (error) {
284
+ await Promise.allSettled(encrypted.parts.map(({ id }) => this.dependencies.deletePart(id)));
285
+ throw error;
286
+ }
287
+ }
288
+ async uploadAttachmentFile(input) {
289
+ const path = resolve(input.path);
290
+ const metadata = await stat(path);
291
+ if (!metadata.isFile())
292
+ throw new Error("ATTACHMENT_NOT_A_FILE");
293
+ const descriptor = createChunkedAttachmentDescriptor({
294
+ id: randomUUID(),
295
+ size: metadata.size,
296
+ name: input.name?.trim() || basename(path),
297
+ mimeType: input.mimeType?.trim() || mimeTypeFromPath(path),
298
+ nextId: randomUUID,
299
+ });
300
+ if (descriptor.version !== 2)
301
+ throw new Error("ATTACHMENT_VERSION_UNSUPPORTED");
302
+ const file = await open(path, "r");
303
+ let transferred = 0;
304
+ try {
305
+ for (let index = 0; index < descriptor.chunks.length; index += 1) {
306
+ throwIfAborted(input.transfer?.signal);
307
+ const part = descriptor.chunks[index];
308
+ const plaintext = new Uint8Array(part.plaintextSize);
309
+ const { bytesRead } = await file.read(plaintext, 0, plaintext.byteLength, transferred);
310
+ if (bytesRead !== plaintext.byteLength)
311
+ throw new Error("ATTACHMENT_SIZE_MISMATCH");
312
+ await this.dependencies.uploadPart({ workroomId: input.workroomId }, part.id, encryptAttachmentChunk(plaintext, descriptor, index), input.transfer);
313
+ transferred += bytesRead;
314
+ input.transfer?.onProgress?.({
315
+ phase: "UPLOADING",
316
+ bytesTransferred: transferred,
317
+ totalBytes: descriptor.size,
318
+ partIndex: index + 1,
319
+ partCount: descriptor.chunks.length,
320
+ });
321
+ }
322
+ return descriptor;
323
+ }
324
+ catch (error) {
325
+ await Promise.allSettled(attachmentPartDescriptors(descriptor).map(({ id }) => this.dependencies.deletePart(id)));
326
+ throw error;
327
+ }
328
+ finally {
329
+ await file.close();
330
+ }
331
+ }
332
+ downloadAttachment(descriptor) {
333
+ return this.dependencies.downloadAttachment(descriptor);
334
+ }
335
+ downloadAttachmentTo(descriptor, path, transfer) {
336
+ return this.dependencies.downloadAttachmentTo(descriptor, path, transfer);
337
+ }
338
+ async guardMandateUse(input, createApproval = true) {
339
+ const credentials = this.dependencies.credentials();
340
+ const detail = await this.get(input.workroomId, 0, 1);
341
+ const workroomEnded = workroomStopReason(detail.workroom);
342
+ if (workroomEnded) {
343
+ return { status: "denied", decision: { status: "denied", code: "MANDATE_ENDED", detail: workroomEnded } };
344
+ }
345
+ const view = detail.latestMandates
346
+ .filter(({ mandate }) => mandate.actorPeerId === credentials.peer.id && (!input.mandateId || mandate.mandateId === input.mandateId))
347
+ .sort((left, right) => right.mandate.revision - left.mandate.revision)[0];
348
+ if (!view)
349
+ return { status: "denied", decision: { status: "denied", code: "MANDATE_MISMATCH" } };
350
+ const signedMandate = this.openMandate(view, detail);
351
+ if (view.revocation) {
352
+ verifyRevocation(view.revocation, detail);
353
+ return { status: "denied", decision: { status: "denied", code: "MANDATE_ENDED", detail: "revoked" } };
354
+ }
355
+ const verifiedApprovals = await this.verifiedApprovals(detail, signedMandate, input);
356
+ const request = mandateUseRequest(this.withDurableUsage(input, signedMandate), signedMandate, credentials.peer.id, verifiedApprovals);
357
+ const decision = evaluateMandateUse(signedMandate.mandate, request);
358
+ if (decision.status === "denied")
359
+ return { status: "denied", decision };
360
+ if (decision.status === "permitted")
361
+ return { status: "permitted", mandate: signedMandate };
362
+ const requestIds = createApproval
363
+ ? await this.ensureApprovalRequests(detail, signedMandate, input, decision)
364
+ : [];
365
+ return { status: "requires_approval", decision, requestIds };
366
+ }
367
+ /**
368
+ * Revalidates immediately before the callback. Callbacks must use operationId
369
+ * as their external idempotency key so a crash cannot duplicate side effects.
370
+ */
371
+ async executeMandatedAction(input, effect) {
372
+ return this.serializedExecution(async () => {
373
+ const initial = await this.guardMandateUse(input, true);
374
+ if (initial.status !== "permitted")
375
+ return initial;
376
+ const immediatelyBeforeEffect = await this.guardMandateUse(input, false);
377
+ if (immediatelyBeforeEffect.status !== "permitted")
378
+ return immediatelyBeforeEffect;
379
+ const effectResult = await effect({ operationId: input.operationId, mandate: immediatelyBeforeEffect.mandate });
380
+ await this.recordDurableUsage(input, immediatelyBeforeEffect.mandate);
381
+ for (let index = 0; index < (effectResult.costs?.length ?? 0); index += 1) {
382
+ const cost = effectResult.costs[index];
383
+ await this.publish(input.workroomId, input.threadId, cost, {
384
+ eventId: deterministicUuid(`${input.operationId}:cost:${index}`),
385
+ idempotencyKey: `cost-${input.operationId}-${index}`,
386
+ });
387
+ }
388
+ const receipt = await this.appendExecutionReceipt(input, effectResult);
389
+ return { status: "executed", value: effectResult.value, receipt };
390
+ });
391
+ }
392
+ openDetail(detail) {
393
+ const credentials = this.dependencies.credentials();
394
+ const envelope = detail.workroom.descriptorEnvelope;
395
+ if (detail.workroom.descriptorHash !== hashCanonical(envelope))
396
+ throw new Error("WORKROOM_DESCRIPTOR_HASH_MISMATCH");
397
+ const sender = requiredPeer(detail, envelope.senderPeerId);
398
+ const descriptor = workroomDescriptorSchema.parse(decryptWorkroomPayload({
399
+ envelope,
400
+ recipientPeerId: credentials.peer.id,
401
+ recipientEncryptionSecretKey: credentials.keys.encryptionSecretKey,
402
+ senderEncryptionPublicKey: sender.encryptionPublicKey,
403
+ senderSigningPublicKey: sender.signingPublicKey,
404
+ }));
405
+ return { ...detail, descriptor };
406
+ }
407
+ async decryptEvent(record, detail) {
408
+ const credentials = this.dependencies.credentials();
409
+ const actor = detail.members.find(({ membership }) => membership.peerId === record.event.actorPeerId)?.peer
410
+ ?? await this.dependencies.request(`/v1/peers/${record.event.actorPeerId}/keys`);
411
+ if (!actor)
412
+ throw new Error("WORKROOM_EVENT_ACTOR_MISSING");
413
+ const content = workroomContentPayloadSchema.parse(decryptWorkroomPayload({
414
+ envelope: record.event.envelope,
415
+ recipientPeerId: credentials.peer.id,
416
+ recipientEncryptionSecretKey: credentials.keys.encryptionSecretKey,
417
+ senderEncryptionPublicKey: actor.encryptionPublicKey,
418
+ senderSigningPublicKey: actor.signingPublicKey,
419
+ }));
420
+ if (content.kind !== record.event.kind)
421
+ throw new Error("WORKROOM_EVENT_KIND_MISMATCH");
422
+ if ((content.kind === "message" || content.kind === "activity")
423
+ && content.threadId !== record.event.threadId) {
424
+ throw new Error("WORKROOM_THREAD_MISMATCH");
425
+ }
426
+ validateWorkroomContentRouting(content, exactActiveMemberPeers(detail).map(routingPeer), record.event.actorPeerId);
427
+ validateProjectionBinding(record.projection, content);
428
+ const routing = resolveWorkroomRouting(content, credentials.peer.id, record.event.actorPeerId);
429
+ return {
430
+ ...record,
431
+ actor,
432
+ content,
433
+ routing,
434
+ directedToMe: routing.directedToMe,
435
+ };
436
+ }
437
+ async readEventPage(workroomId, afterSequence, limit, maxAttempts, signal) {
438
+ const cursor = Number.isSafeInteger(afterSequence) && afterSequence >= 0 ? afterSequence : 0;
439
+ const pageSize = Math.max(1, Math.min(Number.isSafeInteger(limit) ? limit : 100, 500));
440
+ const query = new URLSearchParams({ afterSequence: String(cursor), limit: String(pageSize) });
441
+ const page = await this.retry(() => this.dependencies.request(`/v1/workrooms/${encodeURIComponent(workroomId)}/events?${query}`), maxAttempts, signal);
442
+ const detail = await this.get(workroomId, cursor, 1);
443
+ const events = [];
444
+ for (const record of page.events) {
445
+ throwIfAborted(signal);
446
+ events.push(await this.decryptEvent(record, detail));
447
+ }
448
+ return { events, nextAfterSequence: page.nextAfterSequence };
449
+ }
450
+ openMandate(view, detail) {
451
+ const credentials = this.dependencies.credentials();
452
+ const issuer = requiredPeer(detail, view.mandate.issuedByPeerId);
453
+ if (issuer.signingPublicKey !== view.mandate.issuerSigningPublicKey)
454
+ throw new Error("MANDATE_ISSUER_KEY_CHANGED");
455
+ if (!verifyMandateCommitment(view.mandate.signedCommitment, issuer.signingPublicKey)
456
+ || !mandateCommitmentMatchesEncryptedTerms(view.mandate.signedCommitment, view.mandate.encryptedTermsEnvelope)) {
457
+ throw new Error("INVALID_MANDATE_COMMITMENT");
458
+ }
459
+ const signed = decryptMandateTerms({
460
+ envelope: view.mandate.encryptedTermsEnvelope,
461
+ recipientPeerId: credentials.peer.id,
462
+ recipientEncryptionSecretKey: credentials.keys.encryptionSecretKey,
463
+ senderEncryptionPublicKey: issuer.encryptionPublicKey,
464
+ senderSigningPublicKey: issuer.signingPublicKey,
465
+ });
466
+ if (!verifyMandate(signed, issuer.signingPublicKey)
467
+ || !mandateCommitmentMatchesTerms(view.mandate.signedCommitment, signed)
468
+ || signed.mandate.workroomId !== detail.workroom.id) {
469
+ throw new Error("INVALID_MANDATE_TERMS");
470
+ }
471
+ return signed;
472
+ }
473
+ async verifiedApprovals(detail, signed, input) {
474
+ const credentials = this.dependencies.credentials();
475
+ const evidence = [];
476
+ for (const threshold of signed.mandate.approvalThresholds) {
477
+ const decisionIds = [];
478
+ const approverPeerIds = new Set();
479
+ for (const approval of detail.approvals) {
480
+ const expectedRequestId = approvalRequestId(input, signed, threshold.id);
481
+ if (approval.requestId !== expectedRequestId)
482
+ continue;
483
+ if (approval.status !== "approved" || approval.requestCiphertextHash !== approval.requestEnvelope.ciphertextHash)
484
+ continue;
485
+ if (approval.expiresAt && Date.now() >= Date.parse(approval.expiresAt))
486
+ continue;
487
+ const requester = requiredPeer(detail, approval.requestedByPeerId);
488
+ const payload = workroomApprovalRequestPayloadSchema.parse(decryptWorkroomPayload({
489
+ envelope: approval.requestEnvelope,
490
+ recipientPeerId: credentials.peer.id,
491
+ recipientEncryptionSecretKey: credentials.keys.encryptionSecretKey,
492
+ senderEncryptionPublicKey: requester.encryptionPublicKey,
493
+ senderSigningPublicKey: requester.signingPublicKey,
494
+ }));
495
+ if (payload.requestId !== approval.requestId
496
+ || payload.thresholdId !== threshold.id
497
+ || approval.requestEnvelope.envelopeId !== approval.sourceEventId
498
+ || approval.requestEnvelope.workroomId !== approval.workroomId
499
+ || approval.requestEnvelope.senderPeerId !== approval.requestedByPeerId
500
+ || approval.requestedByPeerId !== credentials.peer.id
501
+ || payload.action !== input.action || payload.mandateId !== signed.mandate.mandateId
502
+ || !approvalRequestPayloadMatchesInput(payload, input)
503
+ || payload.requiredApprovals !== threshold.requiredApprovals
504
+ || !sameSet(payload.requestedApproverPeerIds, threshold.approverPeerIds)
505
+ || !sameSet(approval.eligiblePeerIds, threshold.approverPeerIds)
506
+ || (payload.expiresAt ?? undefined) !== (approval.expiresAt ?? undefined))
507
+ continue;
508
+ for (const record of approval.decisions) {
509
+ const decision = record.signedDecision.decision;
510
+ const signer = detail.members.find(({ membership }) => membership.peerId === decision.decidedByPeerId)?.peer;
511
+ if (!signer || signer.signingPublicKey !== record.signingPublicKey
512
+ || !approval.eligiblePeerIds.includes(signer.id)
513
+ || decision.requestId !== approval.requestId
514
+ || decision.workroomId !== detail.workroom.id
515
+ || decision.requestCiphertextHash !== approval.requestCiphertextHash
516
+ || decision.decision !== "approve"
517
+ || (approval.expiresAt !== undefined && Date.parse(decision.decidedAt) >= Date.parse(approval.expiresAt))
518
+ || !verifyWorkroomApprovalDecision(record.signedDecision, signer.signingPublicKey))
519
+ continue;
520
+ decisionIds.push(decision.decisionId);
521
+ approverPeerIds.add(signer.id);
522
+ }
523
+ }
524
+ if (approverPeerIds.size >= threshold.requiredApprovals) {
525
+ evidence.push({ thresholdId: threshold.id, decisionIds, approverPeerIds: [...approverPeerIds] });
526
+ }
527
+ }
528
+ return evidence;
529
+ }
530
+ async ensureApprovalRequests(detail, signed, input, decision) {
531
+ const thresholds = decision.reason === "DELEGATION"
532
+ ? [{ id: "delegation", requiredApprovals: 1, approverPeerIds: [signed.mandate.principalPeerId] }]
533
+ : signed.mandate.approvalThresholds.filter(({ id }) => decision.thresholdIds.includes(id));
534
+ const requestIds = [];
535
+ if (!input.summary?.trim() || !input.effect?.trim()) {
536
+ throw new Error("APPROVAL_INFORMED_CONSENT_REQUIRED: summary and effect are required");
537
+ }
538
+ if (isPurchaseAction(input.action) && !input.financialImpact) {
539
+ throw new Error("APPROVAL_FINANCIAL_IMPACT_REQUIRED");
540
+ }
541
+ for (const threshold of thresholds) {
542
+ const requestId = approvalRequestId(input, signed, threshold.id);
543
+ requestIds.push(requestId);
544
+ if (detail.approvals.some((approval) => approval.requestId === requestId && approval.status === "pending"))
545
+ continue;
546
+ await this.publish(input.workroomId, input.threadId, {
547
+ version: 1,
548
+ kind: "approval_request",
549
+ requestId,
550
+ thresholdId: threshold.id,
551
+ action: input.action,
552
+ rationale: input.rationale?.trim() || `Approve ${input.action}`,
553
+ summary: input.summary.trim(),
554
+ ...(input.target ? { target: input.target } : {}),
555
+ effect: input.effect.trim(),
556
+ ...(input.financialImpact ? { financialImpact: input.financialImpact } : {}),
557
+ dataCategories: input.dataCategories ?? [],
558
+ mandateId: signed.mandate.mandateId,
559
+ relatedEventIds: [],
560
+ requestedApproverPeerIds: threshold.approverPeerIds,
561
+ requiredApprovals: threshold.requiredApprovals,
562
+ }, {
563
+ eventId: deterministicUuid(`${requestId}:event`),
564
+ idempotencyKey: `approval-${requestId}`,
565
+ projection: {
566
+ kind: "approval_request",
567
+ id: requestId,
568
+ requiredApprovals: threshold.requiredApprovals,
569
+ eligiblePeerIds: threshold.approverPeerIds,
570
+ },
571
+ });
572
+ }
573
+ return requestIds;
574
+ }
575
+ async appendExecutionReceipt(input, result) {
576
+ const credentials = this.dependencies.credentials();
577
+ for (let attempt = 0; attempt < 3; attempt += 1) {
578
+ const detail = await this.get(input.workroomId, 0, 1);
579
+ const receipt = signWorkroomReceipt({
580
+ version: 1,
581
+ receiptId: deterministicUuid(`${input.operationId}:receipt`),
582
+ workroomId: input.workroomId,
583
+ actorPeerId: credentials.peer.id,
584
+ signingPublicKey: credentials.peer.signingPublicKey,
585
+ event: result.costs?.length ? "cost_recorded" : "event_appended",
586
+ idempotencyKey: `effect-${input.operationId}`,
587
+ payloadHash: hashCanonical({ operationId: input.operationId, action: input.action, result: result.value, costs: result.costs ?? [] }),
588
+ previousReceiptHash: detail.latestReceiptHash,
589
+ outcome: "recorded",
590
+ occurredAt: new Date().toISOString(),
591
+ }, credentials.keys.signingSecretKey);
592
+ try {
593
+ await this.dependencies.request(`/v1/workrooms/${encodeURIComponent(input.workroomId)}/receipts`, {
594
+ method: "POST",
595
+ body: JSON.stringify({ signedReceipt: receipt }),
596
+ });
597
+ return receipt;
598
+ }
599
+ catch (error) {
600
+ if (attempt === 2)
601
+ throw error;
602
+ }
603
+ }
604
+ throw new Error("RECEIPT_WRITE_FAILED");
605
+ }
606
+ withDurableUsage(input, signed) {
607
+ const usage = this.dependencies.runtimeState().workroomMandateUsage?.[mandateUsageKey(signed)];
608
+ if (!usage)
609
+ return input;
610
+ const alreadyCompleted = Boolean(usage.completedOperations?.[input.operationId]);
611
+ const now = new Date();
612
+ const storedSpend = Object.fromEntries(signed.mandate.spendLimits.map((limit) => {
613
+ const key = `${limit.currency}:${limit.period}`;
614
+ const stored = usage.spend[key];
615
+ return [key, stored?.bucket === spendBucket(limit.period, now) ? stored.amountMinor : 0];
616
+ }));
617
+ return {
618
+ ...input,
619
+ volumeUsed: maximumVolume(input.volumeUsed, usage.volume),
620
+ spendUsedMinorByLimit: maximumCounters(input.spendUsedMinorByLimit, storedSpend),
621
+ ...(alreadyCompleted ? {
622
+ volumeDelta: emptyVolume(),
623
+ ...(input.spend ? { spend: { ...input.spend, amountMinor: 0 } } : {}),
624
+ } : {}),
625
+ };
626
+ }
627
+ async recordDurableUsage(input, signed) {
628
+ const now = new Date();
629
+ await this.dependencies.mutateRuntimeState((state) => {
630
+ state.workroomMandateUsage ??= {};
631
+ const key = mandateUsageKey(signed);
632
+ const usage = state.workroomMandateUsage[key] ?? emptyMandateUsage();
633
+ usage.completedOperations ??= {};
634
+ if (usage.completedOperations[input.operationId])
635
+ return;
636
+ usage.volume = addVolume(maximumVolume(input.volumeUsed, usage.volume), input.volumeDelta);
637
+ if (input.spend) {
638
+ for (const limit of signed.mandate.spendLimits.filter(({ currency }) => currency === input.spend.currency)) {
639
+ const counterKey = `${limit.currency}:${limit.period}`;
640
+ const bucket = spendBucket(limit.period, now);
641
+ const current = usage.spend[counterKey];
642
+ const supplied = input.spendUsedMinorByLimit?.[counterKey] ?? 0;
643
+ usage.spend[counterKey] = {
644
+ bucket,
645
+ amountMinor: safeAdd(Math.max(current?.bucket === bucket ? current.amountMinor : 0, supplied), input.spend.amountMinor),
646
+ };
647
+ }
648
+ }
649
+ usage.completedOperations[input.operationId] = now.toISOString();
650
+ const completedIds = Object.keys(usage.completedOperations);
651
+ for (let index = 0; index < completedIds.length - MAX_PROCESSED_WORKROOM_EVENTS; index += 1) {
652
+ delete usage.completedOperations[completedIds[index]];
653
+ }
654
+ state.workroomMandateUsage[key] = usage;
655
+ });
656
+ }
657
+ async serializedExecution(operation) {
658
+ const previous = this.executionTail;
659
+ let release;
660
+ this.executionTail = new Promise((resolveExecution) => { release = resolveExecution; });
661
+ await previous;
662
+ try {
663
+ return await operation();
664
+ }
665
+ finally {
666
+ release();
667
+ }
668
+ }
669
+ async retry(operation, maxAttempts, signal) {
670
+ const attempts = Math.max(1, Math.min(5, maxAttempts));
671
+ let lastError;
672
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
673
+ throwIfAborted(signal);
674
+ try {
675
+ return await operation();
676
+ }
677
+ catch (error) {
678
+ lastError = error;
679
+ if (attempt + 1 < attempts)
680
+ await abortableDelay(250 * (2 ** attempt), signal);
681
+ }
682
+ }
683
+ throw lastError;
684
+ }
685
+ }
686
+ function exactActiveMemberPeers(detail) {
687
+ const peers = detail.members.filter(({ membership }) => !membership.leftAt).map(({ membership, peer }) => {
688
+ if (!peer || peer.status !== "ACTIVE")
689
+ throw new Error("WORKROOM_MEMBER_KEY_MISSING");
690
+ if (peer.type === "ORGANIZATION"
691
+ || peer.id !== membership.peerId
692
+ || peer.type !== membership.peerType) {
693
+ throw new Error("WORKROOM_ROUTING_MEMBER_INVALID");
694
+ }
695
+ return peer;
696
+ });
697
+ if (new Set(peers.map(({ id }) => id)).size !== peers.length)
698
+ throw new Error("WORKROOM_MEMBER_DUPLICATE");
699
+ return peers.sort((left, right) => left.id.localeCompare(right.id));
700
+ }
701
+ function routingPeer(peer) {
702
+ if (peer.status !== "ACTIVE" || peer.type === "ORGANIZATION")
703
+ throw new Error("WORKROOM_MEMBER_TYPE_INVALID");
704
+ return {
705
+ id: peer.id,
706
+ handle: peer.handle,
707
+ type: peer.type,
708
+ status: "ACTIVE",
709
+ };
710
+ }
711
+ function exactActiveRecipients(detail) {
712
+ return exactActiveMemberPeers(detail)
713
+ .map(({ id, encryptionPublicKey }) => ({ peerId: id, encryptionPublicKey }));
714
+ }
715
+ function prepareContentAndProjection(candidate, supplied) {
716
+ if (candidate.kind === "message" || candidate.kind === "activity") {
717
+ if (supplied)
718
+ throw new Error("WORKROOM_PROJECTION_UNEXPECTED");
719
+ return { content: candidate };
720
+ }
721
+ if (supplied && supplied.kind !== candidate.kind)
722
+ throw new Error("PROJECTION_KIND_MISMATCH");
723
+ if (candidate.kind === "plan") {
724
+ const id = supplied?.kind === "plan" ? supplied.id : candidate.planId ?? randomUUID();
725
+ const content = { ...candidate, planId: id };
726
+ return { content, projection: { kind: "plan", id, version: candidate.planVersion } };
727
+ }
728
+ if (candidate.kind === "artifact_version") {
729
+ const id = supplied?.kind === "artifact_version" ? supplied.id : candidate.artifactVersionId ?? randomUUID();
730
+ const content = { ...candidate, artifactVersionId: id };
731
+ return { content, projection: {
732
+ kind: "artifact_version", id, artifactId: candidate.artifactId,
733
+ artifactVersion: candidate.artifactVersion, attachmentIds: candidate.attachmentIds,
734
+ } };
735
+ }
736
+ if (candidate.kind === "deliverable") {
737
+ const projection = supplied?.kind === "deliverable" ? supplied : undefined;
738
+ const id = projection?.id ?? candidate.deliverableId ?? randomUUID();
739
+ const artifactVersionId = projection?.artifactVersionId ?? candidate.artifactVersionId;
740
+ if (!artifactVersionId)
741
+ throw new Error("DELIVERABLE_ARTIFACT_VERSION_ID_REQUIRED");
742
+ return { content: { ...candidate, deliverableId: id, artifactVersionId }, projection: {
743
+ kind: "deliverable", id, artifactId: candidate.artifactId, artifactVersionId,
744
+ } };
745
+ }
746
+ if (candidate.kind === "cost") {
747
+ const id = supplied?.kind === "cost" ? supplied.id : candidate.costId ?? randomUUID();
748
+ return { content: { ...candidate, costId: id }, projection: { kind: "cost", id } };
749
+ }
750
+ const id = supplied?.kind === "approval_request" ? supplied.id : candidate.requestId ?? randomUUID();
751
+ return { content: { ...candidate, requestId: id }, projection: {
752
+ kind: "approval_request",
753
+ id,
754
+ requiredApprovals: candidate.requiredApprovals,
755
+ eligiblePeerIds: candidate.requestedApproverPeerIds,
756
+ ...(candidate.expiresAt ? { expiresAt: candidate.expiresAt } : {}),
757
+ } };
758
+ }
759
+ function validateProjectionBinding(projection, content) {
760
+ if (content.kind === "message" || content.kind === "activity") {
761
+ if (projection)
762
+ throw new Error("WORKROOM_PROJECTION_UNEXPECTED");
763
+ return;
764
+ }
765
+ if (!projection || projection.kind !== content.kind)
766
+ throw new Error("WORKROOM_PROJECTION_MISSING");
767
+ if (content.kind === "plan" && (projection.kind !== "plan"
768
+ || content.planId !== projection.id || content.planVersion !== projection.version)) {
769
+ throw new Error("WORKROOM_PROJECTION_MISMATCH");
770
+ }
771
+ if (content.kind === "artifact_version" && (projection.kind !== "artifact_version" || (content.artifactVersionId !== projection.id
772
+ || content.artifactId !== projection.artifactId
773
+ || content.artifactVersion !== projection.artifactVersion
774
+ || !sameOrdered(content.attachmentIds, projection.attachmentIds))))
775
+ throw new Error("WORKROOM_PROJECTION_MISMATCH");
776
+ if (content.kind === "deliverable" && (projection.kind !== "deliverable" || (content.deliverableId !== projection.id
777
+ || content.artifactId !== projection.artifactId
778
+ || content.artifactVersionId !== projection.artifactVersionId)))
779
+ throw new Error("WORKROOM_PROJECTION_MISMATCH");
780
+ if (content.kind === "cost" && (projection.kind !== "cost" || content.costId !== projection.id)) {
781
+ throw new Error("WORKROOM_PROJECTION_MISMATCH");
782
+ }
783
+ if (content.kind === "approval_request" && (projection.kind !== "approval_request" || (content.requestId !== projection.id
784
+ || content.requiredApprovals !== projection.requiredApprovals
785
+ || !sameSet(content.requestedApproverPeerIds, projection.eligiblePeerIds)
786
+ || (content.expiresAt ?? undefined) !== (projection.expiresAt ?? undefined))))
787
+ throw new Error("WORKROOM_PROJECTION_MISMATCH");
788
+ }
789
+ function mandateUseRequest(input, signed, actingPeerId, verifiedApprovals) {
790
+ return {
791
+ mandateId: signed.mandate.mandateId,
792
+ revision: signed.mandate.revision,
793
+ actingPeerId,
794
+ participantPeerIds: input.participantPeerIds ?? [],
795
+ action: input.action,
796
+ ...(input.tool ? { tool: input.tool } : {}),
797
+ dataAccesses: input.dataAccesses ?? [],
798
+ ...(input.spend ? { spend: input.spend } : {}),
799
+ spendUsedMinorByLimit: input.spendUsedMinorByLimit ?? {},
800
+ volumeUsed: input.volumeUsed ?? { messages: 0, files: 0, totalBytes: 0, actions: 0, custom: {} },
801
+ volumeDelta: input.volumeDelta ?? { messages: 0, files: 0, totalBytes: 0, actions: 1, custom: {} },
802
+ delegationDepth: input.delegationDepth ?? 0,
803
+ principalApprovedDelegation: input.principalApprovedDelegation ?? false,
804
+ verifiedApprovals,
805
+ metEndConditionIds: input.metEndConditionIds ?? [],
806
+ evaluatedAt: new Date().toISOString(),
807
+ };
808
+ }
809
+ function verifyRevocation(revocation, detail) {
810
+ const peer = requiredPeer(detail, revocation.signedRevocation.revocation.revokedByPeerId);
811
+ if (peer.signingPublicKey !== revocation.revokerSigningPublicKey
812
+ || !verifyMandateRevocation(revocation.signedRevocation, peer.signingPublicKey)) {
813
+ throw new Error("INVALID_MANDATE_REVOCATION");
814
+ }
815
+ }
816
+ function requiredPeer(detail, peerId) {
817
+ const peer = detail.members.find(({ membership }) => membership.peerId === peerId)?.peer;
818
+ if (!peer)
819
+ throw new Error("WORKROOM_MEMBER_KEY_MISSING");
820
+ return peer;
821
+ }
822
+ function sameSet(left, right) {
823
+ const leftSet = new Set(left);
824
+ const rightSet = new Set(right);
825
+ return leftSet.size === left.length
826
+ && rightSet.size === right.length
827
+ && leftSet.size === rightSet.size
828
+ && [...leftSet].every((value) => rightSet.has(value));
829
+ }
830
+ function sameOrdered(left, right) {
831
+ return left.length === right.length && left.every((value, index) => value === right[index]);
832
+ }
833
+ function isPurchaseAction(action) {
834
+ return /(?:^|[._:/-])(purchase|payment|pay|spend)(?:$|[._:/-])/iu.test(action);
835
+ }
836
+ /** @internal Approval identity is bound to the complete proposed operation, not only its action name. */
837
+ export function approvalRequestId(input, signed, thresholdId) {
838
+ return deterministicUuid(`approval:${hashCanonical(approvalOperation(input, signed, thresholdId))}`);
839
+ }
840
+ function approvalOperation(input, signed, thresholdId) {
841
+ const surface = approvalSurface(input);
842
+ return {
843
+ operationId: input.operationId,
844
+ mandateId: signed.mandate.mandateId,
845
+ revision: signed.mandate.revision,
846
+ thresholdId,
847
+ action: input.action,
848
+ ...surface,
849
+ participantPeerIds: [...(input.participantPeerIds ?? [])].sort((left, right) => left.localeCompare(right)),
850
+ tool: input.tool ?? null,
851
+ dataAccesses: input.dataAccesses ?? [],
852
+ spend: input.spend ?? null,
853
+ delegationDepth: input.delegationDepth ?? 0,
854
+ principalApprovedDelegation: input.principalApprovedDelegation ?? false,
855
+ };
856
+ }
857
+ function approvalSurface(input) {
858
+ return {
859
+ rationale: input.rationale?.trim() || `Approve ${input.action}`,
860
+ summary: input.summary?.trim() ?? "",
861
+ target: input.target ? {
862
+ type: input.target.type.trim(),
863
+ label: input.target.label.trim(),
864
+ ...(input.target.reference?.trim() ? { reference: input.target.reference.trim() } : {}),
865
+ } : null,
866
+ effect: input.effect?.trim() ?? "",
867
+ financialImpact: input.financialImpact ?? null,
868
+ dataCategories: (input.dataCategories ?? []).map((value) => value.trim()),
869
+ };
870
+ }
871
+ function approvalRequestPayloadMatchesInput(payload, input) {
872
+ const expected = approvalSurface(input);
873
+ return payload.rationale === expected.rationale
874
+ && payload.summary === expected.summary
875
+ && payload.effect === expected.effect
876
+ && hashCanonical(payload.target ?? null) === hashCanonical(expected.target)
877
+ && hashCanonical(payload.financialImpact ?? null) === hashCanonical(expected.financialImpact)
878
+ && sameOrdered(payload.dataCategories, expected.dataCategories)
879
+ && payload.relatedEventIds.length === 0;
880
+ }
881
+ /** Stable action identifiers emitted by the app's agent-permission editor. */
882
+ export function defaultWorkroomAction(kind) {
883
+ switch (kind) {
884
+ case "message":
885
+ case "activity":
886
+ return "message.send";
887
+ case "artifact_version":
888
+ return "file.create";
889
+ case "deliverable":
890
+ return "deliverable.submit";
891
+ case "plan":
892
+ return "plan.update";
893
+ case "cost":
894
+ throw new Error("WORKROOM_COST_MUST_BE_DERIVED_FROM_AN_EXECUTED_ACTION");
895
+ case "approval_request":
896
+ throw new Error("WORKROOM_APPROVAL_REQUESTS_ARE_CREATED_BY_THE_MANDATE_GUARD");
897
+ }
898
+ }
899
+ /** Returns why autonomous execution must stop, independent of encrypted mandate terms. */
900
+ export function workroomStopReason(workroom, now = Date.now()) {
901
+ if (workroom.status === "completed" || workroom.status === "cancelled" || workroom.status === "expired") {
902
+ return workroom.status;
903
+ }
904
+ if (workroom.deadline && now >= Date.parse(workroom.deadline))
905
+ return "deadline";
906
+ return undefined;
907
+ }
908
+ function activePeerIds(detail) {
909
+ return detail.members
910
+ .filter(({ membership }) => !membership.leftAt)
911
+ .map(({ membership }) => membership.peerId)
912
+ .sort((left, right) => left.localeCompare(right));
913
+ }
914
+ /** The automation surface cannot accidentally treat another peer's plan as its own work. */
915
+ function autonomousEventView(event) {
916
+ if (event.content.kind !== "plan")
917
+ return event;
918
+ return {
919
+ ...event,
920
+ content: { ...event.content, steps: event.routing.assignedSteps },
921
+ };
922
+ }
923
+ function payloadVolume(content) {
924
+ const encodedBytes = Buffer.byteLength(JSON.stringify(content), "utf8");
925
+ if (content.kind === "artifact_version") {
926
+ return {
927
+ messages: 0,
928
+ files: content.attachmentIds.length,
929
+ totalBytes: content.attachments?.reduce((total, descriptor) => total + descriptor.size, 0) ?? 0,
930
+ actions: 1,
931
+ custom: {},
932
+ };
933
+ }
934
+ return {
935
+ messages: content.kind === "message" || content.kind === "activity" ? 1 : 0,
936
+ files: 0,
937
+ totalBytes: encodedBytes,
938
+ actions: 1,
939
+ custom: {},
940
+ };
941
+ }
942
+ function publicationSummary(content) {
943
+ switch (content.kind) {
944
+ case "message": return "Send a message in this Task";
945
+ case "activity": return content.summary;
946
+ case "plan": return content.summary;
947
+ case "artifact_version": return `Add file: ${content.title}`;
948
+ case "deliverable": return "Submit a deliverable for review";
949
+ case "cost": return "Record Task usage";
950
+ case "approval_request": return content.summary ?? content.rationale;
951
+ }
952
+ }
953
+ function publicationEffect(content) {
954
+ switch (content.kind) {
955
+ case "message": return "Share the message with every current Task participant";
956
+ case "activity": return "Share this progress update with every current Task participant";
957
+ case "plan": return "Replace the visible Task plan with this signed version";
958
+ case "artifact_version": return "Share the encrypted file version with every current Task participant";
959
+ case "deliverable": return "Mark the selected artifact version as a deliverable awaiting review";
960
+ case "cost": return "Append this usage record to the Task";
961
+ case "approval_request": return content.effect ?? "Ask the selected people to approve this operation";
962
+ }
963
+ }
964
+ function mandateUsageKey(signed) {
965
+ return `${signed.mandate.mandateId}:${signed.mandate.revision}`;
966
+ }
967
+ function emptyVolume() {
968
+ return { messages: 0, files: 0, totalBytes: 0, actions: 0, custom: {} };
969
+ }
970
+ function emptyMandateUsage() {
971
+ return { volume: emptyVolume(), spend: {}, completedOperations: {} };
972
+ }
973
+ function normalizeVolume(value) {
974
+ return {
975
+ messages: value?.messages ?? 0,
976
+ files: value?.files ?? 0,
977
+ totalBytes: value?.totalBytes ?? 0,
978
+ actions: value?.actions ?? 0,
979
+ custom: value?.custom ?? {},
980
+ };
981
+ }
982
+ function maximumVolume(supplied, stored) {
983
+ const left = normalizeVolume(supplied);
984
+ const customKeys = new Set([...Object.keys(left.custom), ...Object.keys(stored.custom ?? {})]);
985
+ return {
986
+ messages: Math.max(left.messages, stored.messages ?? 0),
987
+ files: Math.max(left.files, stored.files ?? 0),
988
+ totalBytes: Math.max(left.totalBytes, stored.totalBytes ?? 0),
989
+ actions: Math.max(left.actions, stored.actions ?? 0),
990
+ custom: Object.fromEntries([...customKeys].map((key) => [
991
+ key,
992
+ Math.max(left.custom[key] ?? 0, stored.custom?.[key] ?? 0),
993
+ ])),
994
+ };
995
+ }
996
+ function addVolume(stored, delta) {
997
+ const left = normalizeVolume(stored);
998
+ const right = normalizeVolume(delta);
999
+ const customKeys = new Set([...Object.keys(left.custom), ...Object.keys(right.custom)]);
1000
+ return {
1001
+ messages: safeAdd(left.messages, right.messages),
1002
+ files: safeAdd(left.files, right.files),
1003
+ totalBytes: safeAdd(left.totalBytes, right.totalBytes),
1004
+ actions: safeAdd(left.actions, right.actions),
1005
+ custom: Object.fromEntries([...customKeys].map((key) => [
1006
+ key,
1007
+ safeAdd(left.custom[key] ?? 0, right.custom[key] ?? 0),
1008
+ ])),
1009
+ };
1010
+ }
1011
+ function maximumCounters(supplied, stored) {
1012
+ const result = { ...stored };
1013
+ for (const [key, value] of Object.entries(supplied ?? {}))
1014
+ result[key] = Math.max(result[key] ?? 0, value);
1015
+ return result;
1016
+ }
1017
+ function safeAdd(left, right) {
1018
+ const result = left + right;
1019
+ if (!Number.isSafeInteger(result) || result < 0)
1020
+ throw new Error("MANDATE_USAGE_OVERFLOW");
1021
+ return result;
1022
+ }
1023
+ function spendBucket(period, now) {
1024
+ const year = now.getUTCFullYear();
1025
+ const month = String(now.getUTCMonth() + 1).padStart(2, "0");
1026
+ const day = String(now.getUTCDate()).padStart(2, "0");
1027
+ if (period === "mandate")
1028
+ return "mandate";
1029
+ if (period === "month")
1030
+ return `${year}-${month}`;
1031
+ if (period === "day")
1032
+ return `${year}-${month}-${day}`;
1033
+ const monday = new Date(Date.UTC(year, now.getUTCMonth(), now.getUTCDate()));
1034
+ const weekday = monday.getUTCDay() || 7;
1035
+ monday.setUTCDate(monday.getUTCDate() - weekday + 1);
1036
+ return monday.toISOString().slice(0, 10);
1037
+ }
1038
+ function deterministicUuid(value) {
1039
+ const bytes = createHash("sha256").update(value).digest().subarray(0, 16);
1040
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
1041
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
1042
+ const hex = bytes.toString("hex");
1043
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
1044
+ }
1045
+ async function hashFile(path) {
1046
+ const hash = createHash("sha256");
1047
+ const file = await open(path, "r");
1048
+ const buffer = Buffer.allocUnsafe(1024 * 1024);
1049
+ try {
1050
+ let position = 0;
1051
+ while (true) {
1052
+ const { bytesRead } = await file.read(buffer, 0, buffer.byteLength, position);
1053
+ if (bytesRead === 0)
1054
+ break;
1055
+ hash.update(buffer.subarray(0, bytesRead));
1056
+ position += bytesRead;
1057
+ }
1058
+ }
1059
+ finally {
1060
+ await file.close();
1061
+ }
1062
+ return hash.digest("base64url");
1063
+ }
1064
+ function throwIfAborted(signal) {
1065
+ if (!signal?.aborted)
1066
+ return;
1067
+ const error = new Error("Workroom polling was cancelled");
1068
+ error.name = "AbortError";
1069
+ throw error;
1070
+ }
1071
+ async function abortableDelay(milliseconds, signal) {
1072
+ throwIfAborted(signal);
1073
+ await new Promise((resolveDelay, rejectDelay) => {
1074
+ const timer = setTimeout(resolveDelay, milliseconds);
1075
+ signal?.addEventListener("abort", () => {
1076
+ clearTimeout(timer);
1077
+ const error = new Error("Workroom polling was cancelled");
1078
+ error.name = "AbortError";
1079
+ rejectDelay(error);
1080
+ }, { once: true });
1081
+ });
1082
+ }
1083
+ function mimeTypeFromPath(path) {
1084
+ const types = {
1085
+ ".aac": "audio/aac", ".csv": "text/csv", ".gif": "image/gif", ".heic": "image/heic",
1086
+ ".jpeg": "image/jpeg", ".jpg": "image/jpeg", ".json": "application/json", ".m4a": "audio/mp4",
1087
+ ".mov": "video/quicktime", ".mp3": "audio/mpeg", ".mp4": "video/mp4", ".ogg": "audio/ogg",
1088
+ ".pdf": "application/pdf", ".png": "image/png", ".txt": "text/plain", ".wav": "audio/wav",
1089
+ ".webm": "video/webm", ".webp": "image/webp", ".zip": "application/zip",
1090
+ };
1091
+ return types[extname(path).toLowerCase()] ?? "application/octet-stream";
1092
+ }
1093
+ //# sourceMappingURL=workrooms.js.map