agents-can-communicate 0.1.18 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +78 -70
- package/SECURITY.md +31 -0
- package/bin/acc-hook.mjs +94 -12
- package/bin/acc-mcp.mjs +6 -2
- package/bin/acc.mjs +6 -1
- package/docs/ADAPTER_AUTHORING.md +172 -0
- package/docs/ARCHITECTURE.md +131 -0
- package/docs/CAPABILITIES.md +102 -214
- package/docs/CLI.md +157 -0
- package/docs/CONCEPTS.md +134 -0
- package/docs/CONFIGURATION.md +143 -0
- package/docs/DESIGN_DECISIONS.md +89 -0
- package/docs/GETTING_STARTED.md +145 -0
- package/docs/GLOSSARY.md +26 -0
- package/docs/MCP.md +94 -0
- package/docs/PROTOCOL.md +200 -0
- package/docs/RELEASING.md +109 -0
- package/docs/SECURITY_MODEL.md +131 -0
- package/docs/TROUBLESHOOTING.md +102 -0
- package/docs/WHY_ACC.md +61 -0
- package/docs/index.md +42 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/certification.json +78 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/PreToolUse-Edit.json +19 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/PreToolUse.json +17 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/SessionEnd.json +8 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/SessionStart.json +7 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/UserPromptSubmit.json +9 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/certification-provenance.json +77 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.252.json +19 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +9 -1
- package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/skills/acc/SKILL.md +20 -22
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +12 -4
- package/node_modules/@agents-can-communicate/adapter-codex/certification.json +117 -0
- package/node_modules/@agents-can-communicate/adapter-codex/fixtures/PreToolUse.json +14 -0
- package/node_modules/@agents-can-communicate/adapter-codex/fixtures/SessionEnd.json +7 -0
- package/node_modules/@agents-can-communicate/adapter-codex/fixtures/SessionStart.json +9 -0
- package/node_modules/@agents-can-communicate/adapter-codex/fixtures/UserPromptSubmit.json +10 -0
- package/node_modules/@agents-can-communicate/adapter-codex/fixtures/certification-provenance.json +66 -0
- package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.0.json +19 -0
- package/node_modules/@agents-can-communicate/adapter-codex/package.json +8 -1
- package/node_modules/@agents-can-communicate/adapter-codex/plugin/.codex-plugin/plugin.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +20 -22
- package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +18 -11
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/certification.json +52 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md +20 -22
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/BeforeAgent.json +8 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/BeforeTool-shell.json +12 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/BeforeTool.json +12 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/SessionEnd.json +8 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/SessionStart.json +8 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/certification-provenance.json +66 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +8 -1
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/adapter.mjs +7 -3
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/install.mjs +4 -2
- package/node_modules/@agents-can-communicate/adapter-grok/certification.json +3 -0
- package/node_modules/@agents-can-communicate/adapter-grok/package.json +2 -1
- package/node_modules/@agents-can-communicate/adapter-grok/plugin/skills/acc/SKILL.md +20 -22
- package/node_modules/@agents-can-communicate/adapter-grok/src/adapter.mjs +9 -9
- package/node_modules/@agents-can-communicate/adapter-kimi/certification.json +52 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/PreToolUse-Bash.json +12 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/PreToolUse-Write.json +12 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/SessionHeartbeat.json +7 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/SessionStart.json +9 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/UserPromptSubmit.json +8 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/certification-provenance.json +66 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/package.json +8 -1
- package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +20 -22
- package/node_modules/@agents-can-communicate/adapter-kimi/src/adapter.mjs +7 -3
- package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +34 -18
- package/node_modules/@agents-can-communicate/adapter-sdk/src/certification.mjs +158 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/context-projector.mjs +36 -17
- package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +7 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +1 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +13 -4
- package/node_modules/@agents-can-communicate/cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/cli/src/args.mjs +11 -30
- package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +3 -0
- package/node_modules/@agents-can-communicate/cli/src/help.mjs +2 -5
- package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +9 -2
- package/node_modules/@agents-can-communicate/cli/src/main.mjs +100 -121
- package/node_modules/@agents-can-communicate/core/package.json +1 -1
- package/node_modules/@agents-can-communicate/core/src/attention.mjs +106 -0
- package/node_modules/@agents-can-communicate/core/src/conversations.mjs +276 -0
- package/node_modules/@agents-can-communicate/core/src/delivery-bindings.mjs +81 -0
- package/node_modules/@agents-can-communicate/core/src/finish-retries.mjs +97 -0
- package/node_modules/@agents-can-communicate/core/src/inbox.mjs +91 -107
- package/node_modules/@agents-can-communicate/core/src/index.mjs +3 -3
- package/node_modules/@agents-can-communicate/core/src/intents.mjs +0 -1
- package/node_modules/@agents-can-communicate/core/src/ports.mjs +2 -1
- package/node_modules/@agents-can-communicate/core/src/receipts.mjs +109 -0
- package/node_modules/@agents-can-communicate/core/src/service.mjs +11 -10
- package/node_modules/@agents-can-communicate/core/src/sessions.mjs +22 -20
- package/node_modules/@agents-can-communicate/core/src/status.mjs +11 -9
- package/node_modules/@agents-can-communicate/core/src/sync.mjs +3 -294
- package/node_modules/@agents-can-communicate/delivery-router/package.json +12 -0
- package/node_modules/@agents-can-communicate/delivery-router/src/index.mjs +1 -0
- package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +117 -0
- package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
- package/node_modules/@agents-can-communicate/hook-runner/src/client-version.mjs +20 -0
- package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +115 -63
- package/node_modules/@agents-can-communicate/installer/package.json +1 -1
- package/node_modules/@agents-can-communicate/installer/src/apply.mjs +23 -7
- package/node_modules/@agents-can-communicate/installer/src/detect.mjs +20 -5
- package/node_modules/@agents-can-communicate/installer/src/index.mjs +3 -2
- package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +108 -12
- package/node_modules/@agents-can-communicate/installer/src/plan.mjs +19 -2
- package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
- package/node_modules/@agents-can-communicate/mcp-server/src/input-validator.mjs +79 -0
- package/node_modules/@agents-can-communicate/mcp-server/src/resources.mjs +23 -28
- package/node_modules/@agents-can-communicate/mcp-server/src/server.mjs +102 -72
- package/node_modules/@agents-can-communicate/mcp-server/src/tools.mjs +54 -97
- package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
- package/node_modules/@agents-can-communicate/protocol/src/config.mjs +1 -1
- package/node_modules/@agents-can-communicate/protocol/src/conversations.mjs +64 -0
- package/node_modules/@agents-can-communicate/protocol/src/index.mjs +4 -1
- package/node_modules/@agents-can-communicate/protocol/src/schema.mjs +49 -90
- package/node_modules/@agents-can-communicate/protocol/src/states.mjs +13 -40
- package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
- package/node_modules/@agents-can-communicate/storage-filesystem/src/active-journal.mjs +230 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +77 -28
- package/node_modules/@agents-can-communicate/storage-filesystem/src/identity.mjs +1 -1
- package/node_modules/@agents-can-communicate/storage-filesystem/src/journal.mjs +83 -35
- package/node_modules/@agents-can-communicate/storage-filesystem/src/retention.mjs +112 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-file.mjs +18 -8
- package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +68 -26
- package/node_modules/@agents-can-communicate/storage-filesystem/src/writer-mutex.mjs +113 -35
- package/package.json +19 -1
- package/node_modules/@agents-can-communicate/core/src/communication.mjs +0 -334
- package/node_modules/@agents-can-communicate/core/src/message-signals.mjs +0 -41
- package/node_modules/@agents-can-communicate/core/src/notify.mjs +0 -95
- package/node_modules/@agents-can-communicate/core/src/tasks.mjs +0 -244
- package/node_modules/@agents-can-communicate/core/src/workstreams.mjs +0 -109
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
|
|
1
|
+
// Kept cohesive above 300 lines because this is the single CLI composition boundary: every
|
|
2
|
+
// handler shares one owner-resolution, result, and error contract with `main`. Splitting the
|
|
3
|
+
// dispatch table would either duplicate that contract or expose storage/runtime context solely
|
|
4
|
+
// to pass it across a module boundary, making command behavior harder to audit as one surface.
|
|
5
|
+
import { AccError, CONFIG_FILENAME, EXIT, GENERIC_MESSAGE_KINDS, VALID_OBLIGATIONS,
|
|
6
|
+
failure, ok }
|
|
2
7
|
from "@agents-can-communicate/protocol";
|
|
3
|
-
import { createCoordinationService
|
|
8
|
+
import { createCoordinationService } from "@agents-can-communicate/core";
|
|
4
9
|
import { openFilesystemStore } from "@agents-can-communicate/storage-filesystem";
|
|
5
10
|
|
|
6
11
|
import { parseArgs, positiveNumber } from "./args.mjs";
|
|
@@ -80,8 +85,11 @@ async function locateContext(options, runtime) {
|
|
|
80
85
|
async function withService({ descriptor, paths }, runtime) {
|
|
81
86
|
const store = await openFilesystemStore({ root: paths.root, clock: runtime.clock,
|
|
82
87
|
ids: runtime.ids, workspaceId: descriptor.id });
|
|
88
|
+
const service = createCoordinationService({ store, clock: runtime.clock, ids: runtime.ids });
|
|
83
89
|
return { descriptor, paths,
|
|
84
|
-
service
|
|
90
|
+
service,
|
|
91
|
+
deliveryRouter: typeof runtime.createDeliveryRouter === "function"
|
|
92
|
+
? runtime.createDeliveryRouter({ service, clock: runtime.clock }) : null };
|
|
85
93
|
}
|
|
86
94
|
|
|
87
95
|
async function openContext(options, runtime) {
|
|
@@ -107,6 +115,49 @@ async function openDiagnosticContext(options, runtime) {
|
|
|
107
115
|
|
|
108
116
|
const human = value => (typeof value === "string" ? value : JSON.stringify(value, null, 2));
|
|
109
117
|
|
|
118
|
+
const clientMessageId = (options, context) =>
|
|
119
|
+
options.clientMessageId ?? context.service.ids.next("client");
|
|
120
|
+
|
|
121
|
+
function obligationFor(kind, explicit, addressed) {
|
|
122
|
+
if (!GENERIC_MESSAGE_KINDS.includes(kind)) {
|
|
123
|
+
const command = kind === "answer" ? "reply" : kind === "handoff" ? "finish" : "message";
|
|
124
|
+
throw usage(kind === "answer" || kind === "handoff"
|
|
125
|
+
? `${kind} messages require acc ${command}` : `unknown message type: ${kind}`);
|
|
126
|
+
}
|
|
127
|
+
const obligation = explicit ?? VALID_OBLIGATIONS[kind][0];
|
|
128
|
+
if (!VALID_OBLIGATIONS[kind].includes(obligation)
|
|
129
|
+
|| (!addressed && obligation !== "none")) {
|
|
130
|
+
throw usage(`message obligation ${obligation} is invalid for ${kind}`);
|
|
131
|
+
}
|
|
132
|
+
return obligation;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function recordedText(message, delivery) {
|
|
136
|
+
const diagnostics = delivery.map(item => item.outcome === "offered"
|
|
137
|
+
? `live offered to ${item.recipientParticipantId} via ${item.transport}`
|
|
138
|
+
: item.outcome === "queued"
|
|
139
|
+
? `live offer unavailable (${item.errorCode ?? "durable_fallback"})`
|
|
140
|
+
: `delivery already ${item.outcome}`);
|
|
141
|
+
return [`recorded ${message.messageId}`, ...diagnostics].join("; ");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function recordAndOffer({ record, router, selectMessage = value => value }) {
|
|
145
|
+
const recorded = await record();
|
|
146
|
+
const message = selectMessage(recorded);
|
|
147
|
+
if (router === null || router === undefined
|
|
148
|
+
|| !Array.isArray(message?.toParticipantIds) || message.toParticipantIds.length === 0) {
|
|
149
|
+
return { recorded, delivery: [] };
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
return { recorded, delivery: await router.offer(message) };
|
|
153
|
+
} catch {
|
|
154
|
+
return { recorded, delivery: message.toParticipantIds.map(recipientParticipantId => ({
|
|
155
|
+
recipientParticipantId, outcome: "queued", transport: "durable",
|
|
156
|
+
errorCode: "transport_error",
|
|
157
|
+
})) };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
110
161
|
/** How many are here, and how many only look it. */
|
|
111
162
|
export function describePresence({ live = 0, stale = 0 } = {}) {
|
|
112
163
|
if (live === 0) return "0 live";
|
|
@@ -168,8 +219,7 @@ const HANDLERS = Object.freeze({
|
|
|
168
219
|
if (options.summary === undefined) throw usage("work requires --summary");
|
|
169
220
|
const intent = await context.service.setIntent({ sessionId: options.session,
|
|
170
221
|
generation: options.generation, summary: options.summary, mode: options.mode ?? "edit",
|
|
171
|
-
state: options.state,
|
|
172
|
-
resourceHints: options.hint ?? [] });
|
|
222
|
+
state: options.state, resourceHints: options.hint ?? [] });
|
|
173
223
|
return { data: intent, text: `intent: ${intent.summary}` };
|
|
174
224
|
},
|
|
175
225
|
|
|
@@ -194,18 +244,17 @@ const HANDLERS = Object.freeze({
|
|
|
194
244
|
},
|
|
195
245
|
|
|
196
246
|
message: async ({ options, context }) => {
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
text: advice ? `sent ${message.messageId}\n${advice}` : `sent ${message.messageId}` };
|
|
247
|
+
const kind = options.type ?? "note";
|
|
248
|
+
const toParticipantIds = options.to ?? [];
|
|
249
|
+
const routed = await recordAndOffer({ router: context.deliveryRouter, record: () =>
|
|
250
|
+
context.service.sendMessage({ sessionId: options.session,
|
|
251
|
+
generation: options.generation, clientMessageId: clientMessageId(options, context),
|
|
252
|
+
toParticipantIds, kind,
|
|
253
|
+
obligation: obligationFor(kind, options.obligation, toParticipantIds.length > 0),
|
|
254
|
+
subject: options.subject, body: options.body, descriptor: context.descriptor }) });
|
|
255
|
+
const message = routed.recorded;
|
|
256
|
+
return { data: { message, delivery: routed.delivery },
|
|
257
|
+
text: recordedText(message, routed.delivery) };
|
|
209
258
|
},
|
|
210
259
|
|
|
211
260
|
inbox: async ({ options, context }) => {
|
|
@@ -216,121 +265,51 @@ const HANDLERS = Object.freeze({
|
|
|
216
265
|
},
|
|
217
266
|
|
|
218
267
|
reply: async ({ options, context }) => {
|
|
219
|
-
const
|
|
268
|
+
const routed = await recordAndOffer({ router: context.deliveryRouter,
|
|
269
|
+
selectMessage: value => value.reply, record: () => context.service.replyToMessage({
|
|
270
|
+
sessionId: options.session,
|
|
220
271
|
generation: options.generation, messageId: options.message, body: options.body,
|
|
221
|
-
subject: options.subject,
|
|
222
|
-
|
|
223
|
-
|
|
272
|
+
subject: options.subject, clientMessageId: clientMessageId(options, context) }) });
|
|
273
|
+
const result = routed.recorded;
|
|
274
|
+
return { data: { message: result.reply, delivery: routed.delivery },
|
|
275
|
+
text: recordedText(result.reply, routed.delivery) };
|
|
224
276
|
},
|
|
225
277
|
|
|
226
|
-
/**
|
|
227
|
-
* Ask another agent to do something. One call, one write.
|
|
228
|
-
*
|
|
229
|
-
* The recipient hears about it twice by design: the task raises an attention
|
|
230
|
-
* item for the participant it names, and the message reaches their turn as
|
|
231
|
-
* quoted peer text explaining why.
|
|
232
|
-
*/
|
|
233
278
|
request: async ({ options, context }) => {
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
279
|
+
const routed = await recordAndOffer({ router: context.deliveryRouter, record: () =>
|
|
280
|
+
context.service.sendMessage({
|
|
281
|
+
sessionId: options.session,
|
|
282
|
+
generation: options.generation,
|
|
283
|
+
clientMessageId: clientMessageId(options, context),
|
|
284
|
+
toParticipantIds: [options.to],
|
|
285
|
+
kind: "request",
|
|
286
|
+
obligation: "reply",
|
|
287
|
+
subject: options.title,
|
|
288
|
+
body: options.detail ?? options.title,
|
|
289
|
+
descriptor: context.descriptor,
|
|
290
|
+
}) });
|
|
291
|
+
const message = routed.recorded;
|
|
292
|
+
return { data: { message, delivery: routed.delivery },
|
|
293
|
+
text: recordedText(message, routed.delivery) };
|
|
241
294
|
},
|
|
242
295
|
|
|
243
296
|
ack: async ({ options, context }) => {
|
|
244
|
-
const receipt = await context.service.
|
|
245
|
-
generation: options.generation, messageId: options.message
|
|
246
|
-
state: options.state ?? "acknowledged" });
|
|
297
|
+
const receipt = await context.service.acknowledgeMessage({ sessionId: options.session,
|
|
298
|
+
generation: options.generation, messageId: options.message });
|
|
247
299
|
return { data: receipt, text: `${receipt.messageId} ${receipt.state}` };
|
|
248
300
|
},
|
|
249
301
|
|
|
250
|
-
/**
|
|
251
|
-
* What was settled, and on whose authority.
|
|
252
|
-
*
|
|
253
|
-
* `--human` is the caller stating that a person actually decided this. The
|
|
254
|
-
* core refuses `--authority human` without it, because a peer proposal
|
|
255
|
-
* becoming a human decision on its own is the one way this record could
|
|
256
|
-
* launder an agent's opinion into a ruling.
|
|
257
|
-
*/
|
|
258
|
-
decide: async ({ options, context }) => {
|
|
259
|
-
const decision = await context.service.recordDecision({
|
|
260
|
-
sessionId: options.session, generation: options.generation,
|
|
261
|
-
title: options.title, outcome: options.outcome,
|
|
262
|
-
authority: options.authority ?? "workstream",
|
|
263
|
-
workstreamId: options.workstream ?? null,
|
|
264
|
-
decidedBy: options.decidedBy,
|
|
265
|
-
supersedes: options.supersedes ?? null,
|
|
266
|
-
humanConfirmed: options.human === true,
|
|
267
|
-
descriptor: context.descriptor });
|
|
268
|
-
return { data: decision,
|
|
269
|
-
text: `${decision.decisionId} ${decision.authority}: ${decision.title}` };
|
|
270
|
-
},
|
|
271
|
-
|
|
272
|
-
workstream: async ({ options, context }) => {
|
|
273
|
-
const owner = { sessionId: options.session, generation: options.generation };
|
|
274
|
-
// Taking the coordination of one and creating one are the same noun, so
|
|
275
|
-
// they stay one command rather than two the model has to choose between -
|
|
276
|
-
// the same shape `acc task` already has.
|
|
277
|
-
if (options.take === true || options.release === true) {
|
|
278
|
-
if (options.workstream === undefined) {
|
|
279
|
-
throw usage(`workstream --${options.take === true ? "take" : "release"} `
|
|
280
|
-
+ "requires --workstream");
|
|
281
|
-
}
|
|
282
|
-
const acted = options.take === true
|
|
283
|
-
? await context.service.acquireCoordinator({ ...owner,
|
|
284
|
-
workstreamId: options.workstream })
|
|
285
|
-
: await context.service.releaseCoordinator({ ...owner,
|
|
286
|
-
workstreamId: options.workstream });
|
|
287
|
-
return { data: acted,
|
|
288
|
-
text: `${acted.workstreamId} ${options.take === true ? "coordinated" : "released"}` };
|
|
289
|
-
}
|
|
290
|
-
if (options.title === undefined) throw usage("workstream requires --title");
|
|
291
|
-
if (options.objective === undefined) throw usage("workstream requires --objective");
|
|
292
|
-
const workstream = await context.service.createWorkstream({ ...owner,
|
|
293
|
-
title: options.title, objective: options.objective,
|
|
294
|
-
descriptor: context.descriptor });
|
|
295
|
-
return { data: workstream, text: `${workstream.workstreamId} ${workstream.state}` };
|
|
296
|
-
},
|
|
297
|
-
|
|
298
|
-
task: async ({ options, context }) => {
|
|
299
|
-
const owner = { sessionId: options.session, generation: options.generation };
|
|
300
|
-
// Taking work and moving it along are the same noun as creating it, so they
|
|
301
|
-
// stay one command rather than three the model has to choose between.
|
|
302
|
-
if (options.take === true) {
|
|
303
|
-
if (options.task === undefined) throw usage("task --take requires --task");
|
|
304
|
-
const taken = await context.service.claimTask({ ...owner, taskId: options.task,
|
|
305
|
-
force: options.force === true });
|
|
306
|
-
return { data: taken, text: `${taken.taskId} ${taken.state}` };
|
|
307
|
-
}
|
|
308
|
-
if (options.decline === true) {
|
|
309
|
-
if (options.task === undefined) throw usage("task --decline requires --task");
|
|
310
|
-
const refused = await context.service.declineTask({ ...owner,
|
|
311
|
-
taskId: options.task, reason: options.reason });
|
|
312
|
-
return { data: refused, text: `${refused.taskId} declined` };
|
|
313
|
-
}
|
|
314
|
-
if (options.state !== undefined) {
|
|
315
|
-
if (options.task === undefined) throw usage("task --state requires --task");
|
|
316
|
-
const moved = await context.service.transitionTask({ ...owner,
|
|
317
|
-
taskId: options.task, state: options.state });
|
|
318
|
-
return { data: moved, text: `${moved.taskId} ${moved.state}` };
|
|
319
|
-
}
|
|
320
|
-
if (options.title === undefined) throw usage("task requires --title");
|
|
321
|
-
const task = await context.service.createTask({ ...owner,
|
|
322
|
-
workstreamId: options.workstream, title: options.title, detail: options.detail,
|
|
323
|
-
assigneeParticipantId: options.assignee, taskId: options.task,
|
|
324
|
-
dependsOn: options.dependsOn ?? [], descriptor: context.descriptor });
|
|
325
|
-
return { data: task, text: `${task.taskId} ${task.state}` };
|
|
326
|
-
},
|
|
327
|
-
|
|
328
302
|
finish: async ({ options, context }) => {
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
303
|
+
const routed = await recordAndOffer({ router: context.deliveryRouter,
|
|
304
|
+
selectMessage: value => value.message, record: () => context.service.finishSession({
|
|
305
|
+
sessionId: options.session,
|
|
306
|
+
generation: options.generation, clientMessageId: clientMessageId(options, context),
|
|
307
|
+
goal: options.goal, status: options.status,
|
|
308
|
+
toParticipantId: options.to, completed: options.completed ?? [],
|
|
309
|
+
remaining: options.remaining ?? [], blockers: options.blocker ?? [] }) });
|
|
310
|
+
const handoff = routed.recorded;
|
|
311
|
+
return { data: { message: handoff.message, delivery: routed.delivery },
|
|
312
|
+
text: recordedText(handoff.message, routed.delivery) };
|
|
334
313
|
},
|
|
335
314
|
|
|
336
315
|
status: async ({ options, context }) => {
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { AccError, EXIT } from "@agents-can-communicate/protocol";
|
|
2
|
+
|
|
3
|
+
import { overlaps } from "./claims.mjs";
|
|
4
|
+
import { classifySessionPresence } from "./sessions.mjs";
|
|
5
|
+
|
|
6
|
+
export const ATTENTION_PRIORITY = Object.freeze({
|
|
7
|
+
reply_required: 1,
|
|
8
|
+
acknowledgement_required: 2,
|
|
9
|
+
recipient_unavailable: 3,
|
|
10
|
+
claim_conflict: 4,
|
|
11
|
+
claim_contended: 5,
|
|
12
|
+
claim_expired: 6,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
function obligationItems(snapshot, participantId) {
|
|
16
|
+
const messages = new Map((snapshot.messages ?? []).map(item => [item.messageId, item]));
|
|
17
|
+
return (snapshot.receipts ?? []).flatMap(receipt => {
|
|
18
|
+
if (receipt.recipientParticipantId !== participantId
|
|
19
|
+
|| receipt.state === "acknowledged") return [];
|
|
20
|
+
const message = messages.get(receipt.messageId);
|
|
21
|
+
const kind = message?.obligation === "reply" ? "reply_required"
|
|
22
|
+
: message?.obligation === "acknowledge" ? "acknowledgement_required" : null;
|
|
23
|
+
const action = kind === "reply_required" ? "a reply" : "acknowledgement";
|
|
24
|
+
return kind === null ? [] : [{ kind, priority: ATTENTION_PRIORITY[kind],
|
|
25
|
+
sourceId: message.messageId,
|
|
26
|
+
summary: `message ${message.messageId} from ${message.fromParticipantId} is a `
|
|
27
|
+
+ `${message.kind} requiring ${action}` }];
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function presenceByParticipant(snapshot, now, pidIsAlive) {
|
|
32
|
+
const online = new Set();
|
|
33
|
+
for (const session of snapshot.sessions ?? []) {
|
|
34
|
+
if (classifySessionPresence(session, now, pidIsAlive) === "online") {
|
|
35
|
+
online.add(session.participantId);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return online;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function unavailableRecipients(snapshot, participantId, now, pidIsAlive) {
|
|
42
|
+
const online = presenceByParticipant(snapshot, now, pidIsAlive);
|
|
43
|
+
const receipts = snapshot.receipts ?? [];
|
|
44
|
+
return (snapshot.messages ?? []).flatMap(message => {
|
|
45
|
+
if (message.fromParticipantId !== participantId || message.toParticipantIds?.length === 0
|
|
46
|
+
|| message.obligation === "none") return [];
|
|
47
|
+
return receipts.filter(receipt => receipt.messageId === message.messageId
|
|
48
|
+
&& receipt.state !== "acknowledged"
|
|
49
|
+
&& !online.has(receipt.recipientParticipantId))
|
|
50
|
+
.map(receipt => ({ kind: "recipient_unavailable",
|
|
51
|
+
priority: ATTENTION_PRIORITY.recipient_unavailable,
|
|
52
|
+
sourceId: message.messageId,
|
|
53
|
+
summary: `${message.subject} - ${receipt.recipientParticipantId} is unavailable` }));
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function expiredClaims(snapshot, session, now) {
|
|
58
|
+
if (session == null) return [];
|
|
59
|
+
return (snapshot.claims ?? []).filter(claim => claim.ownerSessionId === session.sessionId
|
|
60
|
+
&& Date.parse(claim.expiresAt) <= Date.parse(now))
|
|
61
|
+
.map(claim => ({ kind: "claim_expired", priority: ATTENTION_PRIORITY.claim_expired,
|
|
62
|
+
sourceId: claim.claimId,
|
|
63
|
+
summary: `${claim.resource} - your claim has run out, and peers can write to it` }));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function claimConflicts(snapshot, session, now) {
|
|
67
|
+
const mine = (snapshot.intents ?? []).find(intent => intent.sessionId === session?.sessionId);
|
|
68
|
+
if (mine === undefined) return [];
|
|
69
|
+
return (snapshot.claims ?? []).filter(claim => claim.ownerSessionId !== session.sessionId
|
|
70
|
+
&& Date.parse(claim.expiresAt) > Date.parse(now)
|
|
71
|
+
&& mine.resourceHints.some(hint => overlaps(hint, claim.resource)))
|
|
72
|
+
.map(claim => ({ kind: "claim_conflict", priority: ATTENTION_PRIORITY.claim_conflict,
|
|
73
|
+
sourceId: claim.claimId,
|
|
74
|
+
summary: `${claim.resource} is claimed by ${claim.ownerSessionId}` }));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function claimContended(snapshot, session, now) {
|
|
78
|
+
if (session == null) return [];
|
|
79
|
+
const peerIntents = (snapshot.intents ?? [])
|
|
80
|
+
.filter(intent => intent.sessionId !== session.sessionId);
|
|
81
|
+
return (snapshot.claims ?? []).filter(claim => claim.ownerSessionId === session.sessionId
|
|
82
|
+
&& Date.parse(claim.expiresAt) > Date.parse(now)).flatMap(claim => {
|
|
83
|
+
const peer = peerIntents.find(intent => intent.resourceHints
|
|
84
|
+
.some(hint => overlaps(hint, claim.resource)));
|
|
85
|
+
if (peer === undefined) return [];
|
|
86
|
+
const participant = (snapshot.sessions ?? [])
|
|
87
|
+
.find(item => item.sessionId === peer.sessionId)?.participantId ?? "a peer";
|
|
88
|
+
return [{ kind: "claim_contended", priority: ATTENTION_PRIORITY.claim_contended,
|
|
89
|
+
sourceId: claim.claimId,
|
|
90
|
+
summary: `${claim.resource} - ${participant} means to work on what you hold` }];
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function computeAttention(snapshot, { session, participantId, now, pidIsAlive }) {
|
|
95
|
+
if (typeof pidIsAlive !== "function") {
|
|
96
|
+
throw new AccError(EXIT.USAGE, "computeAttention requires a pidIsAlive probe", {});
|
|
97
|
+
}
|
|
98
|
+
return [
|
|
99
|
+
...obligationItems(snapshot, participantId),
|
|
100
|
+
...unavailableRecipients(snapshot, participantId, now, pidIsAlive),
|
|
101
|
+
...claimConflicts(snapshot, session, now),
|
|
102
|
+
...claimContended(snapshot, session, now),
|
|
103
|
+
...expiredClaims(snapshot, session, now),
|
|
104
|
+
].sort((left, right) => left.priority - right.priority
|
|
105
|
+
|| left.sourceId.localeCompare(right.sourceId));
|
|
106
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { isDeepStrictEqual } from "node:util";
|
|
3
|
+
|
|
4
|
+
import { AccError, EXIT, SCHEMA_VERSION, validateRecord }
|
|
5
|
+
from "@agents-can-communicate/protocol";
|
|
6
|
+
|
|
7
|
+
import { projectReleasedClaim, reconstructFinishRetry } from "./finish-retries.mjs";
|
|
8
|
+
import { ensureMaterialised } from "./materialisation.mjs";
|
|
9
|
+
|
|
10
|
+
export const receiptId = (messageId, participantId) => `receipt_${createHash("sha256")
|
|
11
|
+
.update(JSON.stringify([messageId, participantId])).digest("base64url")}`;
|
|
12
|
+
|
|
13
|
+
const logicalContent = message => ({
|
|
14
|
+
toParticipantIds: message.toParticipantIds,
|
|
15
|
+
kind: message.kind,
|
|
16
|
+
obligation: message.obligation,
|
|
17
|
+
subject: message.subject,
|
|
18
|
+
body: message.body,
|
|
19
|
+
inReplyTo: message.inReplyTo,
|
|
20
|
+
artifacts: message.artifacts,
|
|
21
|
+
handoff: message.handoff,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const normalizedContent = input => logicalContent({
|
|
25
|
+
toParticipantIds: input.toParticipantIds ?? [],
|
|
26
|
+
kind: input.kind,
|
|
27
|
+
obligation: input.obligation,
|
|
28
|
+
subject: input.subject,
|
|
29
|
+
body: input.body,
|
|
30
|
+
inReplyTo: input.inReplyTo ?? null,
|
|
31
|
+
artifacts: input.artifacts ?? [],
|
|
32
|
+
handoff: input.handoff ?? null,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
function existingMessage(tx, session, input) {
|
|
36
|
+
const existing = tx.list("message", message =>
|
|
37
|
+
message.workspaceId === session.workspaceId
|
|
38
|
+
&& message.fromParticipantId === session.participantId
|
|
39
|
+
&& message.clientMessageId === input.clientMessageId).at(0);
|
|
40
|
+
if (existing !== undefined
|
|
41
|
+
&& !isDeepStrictEqual(logicalContent(existing), normalizedContent(input))) {
|
|
42
|
+
throw new AccError(EXIT.CONFLICT,
|
|
43
|
+
"clientMessageId was already used with different message content",
|
|
44
|
+
{ clientMessageId: input.clientMessageId, messageId: existing.messageId });
|
|
45
|
+
}
|
|
46
|
+
return existing;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function assertCurrentSession(tx, session, action) {
|
|
50
|
+
const current = tx.get("session", session.sessionId);
|
|
51
|
+
if (current === null || current.state !== "open"
|
|
52
|
+
|| current.generation !== session.generation) {
|
|
53
|
+
throw new AccError(EXIT.CONFLICT, `cannot ${action} from this session generation`,
|
|
54
|
+
{ sessionId: session.sessionId });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function addressedRecipients(tx, message, session) {
|
|
59
|
+
if (message.toParticipantIds.length === 0) {
|
|
60
|
+
return [...new Set(tx.list("session", item => item.state === "open"
|
|
61
|
+
&& item.participantId !== session.participantId)
|
|
62
|
+
.map(item => item.participantId))].sort();
|
|
63
|
+
}
|
|
64
|
+
const unique = [...new Set(message.toParticipantIds)];
|
|
65
|
+
if (unique.length !== message.toParticipantIds.length) {
|
|
66
|
+
throw new AccError(EXIT.USAGE, "a participant may be addressed only once", {});
|
|
67
|
+
}
|
|
68
|
+
const known = new Set(tx.list("participant").map(item => item.participantId));
|
|
69
|
+
const strangers = unique.filter(participantId => !known.has(participantId));
|
|
70
|
+
if (strangers.length > 0) {
|
|
71
|
+
throw new AccError(EXIT.DATA,
|
|
72
|
+
`no participant here is called ${strangers.join(", ")}. `
|
|
73
|
+
+ `This workspace has: ${[...known].sort().join(", ") || "nobody else yet"}`,
|
|
74
|
+
{ strangers, known: [...known].sort() });
|
|
75
|
+
}
|
|
76
|
+
return unique;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function threadFor(tx, messageId, inReplyTo) {
|
|
80
|
+
if (inReplyTo === null) return messageId;
|
|
81
|
+
const parent = tx.get("message", inReplyTo);
|
|
82
|
+
if (parent === null) {
|
|
83
|
+
throw new AccError(EXIT.DATA, "the message being replied to does not exist",
|
|
84
|
+
{ inReplyTo });
|
|
85
|
+
}
|
|
86
|
+
return parent.threadId;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function recordMessageInTransaction({ tx, session, input, now, messageId, ids,
|
|
90
|
+
action = "send a message" }) {
|
|
91
|
+
assertCurrentSession(tx, session, action);
|
|
92
|
+
const existing = existingMessage(tx, session, input);
|
|
93
|
+
if (existing !== undefined) {
|
|
94
|
+
return { message: existing, recipientParticipantIds: [], created: false };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const inReplyTo = input.inReplyTo ?? null;
|
|
98
|
+
const message = validateRecord("message", {
|
|
99
|
+
schemaVersion: SCHEMA_VERSION,
|
|
100
|
+
messageId,
|
|
101
|
+
threadId: threadFor(tx, messageId, inReplyTo),
|
|
102
|
+
clientMessageId: input.clientMessageId,
|
|
103
|
+
workspaceId: session.workspaceId,
|
|
104
|
+
fromParticipantId: session.participantId,
|
|
105
|
+
fromSessionId: session.sessionId,
|
|
106
|
+
toParticipantIds: input.toParticipantIds ?? [],
|
|
107
|
+
kind: input.kind,
|
|
108
|
+
obligation: input.obligation,
|
|
109
|
+
subject: input.subject,
|
|
110
|
+
body: input.body,
|
|
111
|
+
inReplyTo,
|
|
112
|
+
artifacts: input.artifacts ?? [],
|
|
113
|
+
handoff: input.handoff ?? null,
|
|
114
|
+
sentAt: now,
|
|
115
|
+
});
|
|
116
|
+
const recipientParticipantIds = addressedRecipients(tx, message, session);
|
|
117
|
+
tx.put("message", messageId, message);
|
|
118
|
+
for (const participantId of recipientParticipantIds) {
|
|
119
|
+
tx.put("receipt", receiptId(messageId, participantId), validateRecord("receipt", {
|
|
120
|
+
schemaVersion: SCHEMA_VERSION,
|
|
121
|
+
messageId,
|
|
122
|
+
workspaceId: session.workspaceId,
|
|
123
|
+
recipientParticipantId: participantId,
|
|
124
|
+
state: "queued",
|
|
125
|
+
updatedAt: now,
|
|
126
|
+
}));
|
|
127
|
+
}
|
|
128
|
+
tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"),
|
|
129
|
+
workspaceId: session.workspaceId, actorSessionId: session.sessionId,
|
|
130
|
+
type: "message.recorded", occurredAt: now,
|
|
131
|
+
payload: { messageId, threadId: message.threadId, recipientParticipantIds } });
|
|
132
|
+
return { message, recipientParticipantIds, created: true };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function handoffBody(goal, handoff) {
|
|
136
|
+
const section = (label, items) => items.length === 0
|
|
137
|
+
? [] : [`${label}:`, ...items.map(item => `- ${item}`)];
|
|
138
|
+
return [goal, handoff.status,
|
|
139
|
+
...section("completed", handoff.completed),
|
|
140
|
+
...section("remaining", handoff.remaining),
|
|
141
|
+
...section("blockers", handoff.blockers)].join("\n");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function createConversationService(ports, sessions) {
|
|
145
|
+
const { store, clock, ids } = ports;
|
|
146
|
+
|
|
147
|
+
async function requireSession(input, action, { open = true } = {}) {
|
|
148
|
+
const located = await sessions.locateSession(input.sessionId, input.workspaceId);
|
|
149
|
+
if (located === null || located.record.generation !== input.generation
|
|
150
|
+
|| (open && located.record.state !== "open")) {
|
|
151
|
+
throw new AccError(EXIT.CONFLICT, `cannot ${action} from this session generation`,
|
|
152
|
+
{ sessionId: input.sessionId });
|
|
153
|
+
}
|
|
154
|
+
return located.record;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function sendMessage(input) {
|
|
158
|
+
const session = await requireSession(input, "send a message");
|
|
159
|
+
await ensureMaterialised(ports, { workspaceId: session.workspaceId,
|
|
160
|
+
descriptor: input.descriptor, reason: "durable_object" });
|
|
161
|
+
return store.transaction(tx => recordMessageInTransaction({ tx, session, input,
|
|
162
|
+
now: clock.now(), messageId: ids.next("message"), ids }).message,
|
|
163
|
+
{ kinds: ["participant", "session", "message", "receipt"] });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function finishSession(input) {
|
|
167
|
+
const located = await sessions.locateSession(input.sessionId, input.workspaceId);
|
|
168
|
+
if (located === null || located.record.generation !== input.generation) {
|
|
169
|
+
throw new AccError(EXIT.CONFLICT, "cannot finish from this session generation",
|
|
170
|
+
{ sessionId: input.sessionId });
|
|
171
|
+
}
|
|
172
|
+
const session = located.record;
|
|
173
|
+
await ensureMaterialised(ports, { workspaceId: session.workspaceId,
|
|
174
|
+
descriptor: input.descriptor, reason: "durable_object" });
|
|
175
|
+
const handoff = {
|
|
176
|
+
status: input.status ?? "partial",
|
|
177
|
+
completed: input.completed ?? [],
|
|
178
|
+
remaining: input.remaining ?? [],
|
|
179
|
+
blockers: input.blockers ?? [],
|
|
180
|
+
verification: input.verification ?? [],
|
|
181
|
+
};
|
|
182
|
+
const messageInput = {
|
|
183
|
+
clientMessageId: input.clientMessageId,
|
|
184
|
+
toParticipantIds: input.toParticipantId === undefined ? [] : [input.toParticipantId],
|
|
185
|
+
kind: "handoff",
|
|
186
|
+
obligation: input.toParticipantId === undefined ? "none" : "acknowledge",
|
|
187
|
+
subject: input.goal,
|
|
188
|
+
body: handoffBody(input.goal, handoff),
|
|
189
|
+
inReplyTo: null,
|
|
190
|
+
artifacts: input.artifacts ?? [],
|
|
191
|
+
handoff,
|
|
192
|
+
};
|
|
193
|
+
const now = clock.now();
|
|
194
|
+
const outcome = await store.transaction(tx => {
|
|
195
|
+
const current = tx.get("session", session.sessionId);
|
|
196
|
+
if (current === null || current.generation !== session.generation) {
|
|
197
|
+
throw new AccError(EXIT.CONFLICT, "cannot finish from this session generation",
|
|
198
|
+
{ sessionId: session.sessionId });
|
|
199
|
+
}
|
|
200
|
+
const existing = existingMessage(tx, session, messageInput);
|
|
201
|
+
if (existing !== undefined) {
|
|
202
|
+
if (existing.fromSessionId !== session.sessionId) {
|
|
203
|
+
throw new AccError(EXIT.CONFLICT,
|
|
204
|
+
"clientMessageId belongs to a different finish generation",
|
|
205
|
+
{ clientMessageId: input.clientMessageId, messageId: existing.messageId });
|
|
206
|
+
}
|
|
207
|
+
return { retry: true, message: existing, session: current };
|
|
208
|
+
}
|
|
209
|
+
if (current.state !== "open") {
|
|
210
|
+
throw new AccError(EXIT.CONFLICT, "cannot finish from this session generation",
|
|
211
|
+
{ sessionId: session.sessionId });
|
|
212
|
+
}
|
|
213
|
+
const releasedClaims = tx.list("claim",
|
|
214
|
+
claim => claim.ownerSessionId === session.sessionId);
|
|
215
|
+
const recorded = recordMessageInTransaction({ tx, session, now,
|
|
216
|
+
messageId: ids.next("message"), ids, action: "finish",
|
|
217
|
+
input: messageInput });
|
|
218
|
+
for (const claim of releasedClaims) {
|
|
219
|
+
tx.remove("claim", claim.claimId, tx.generationOf("claim", claim.claimId));
|
|
220
|
+
tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"),
|
|
221
|
+
workspaceId: session.workspaceId, actorSessionId: session.sessionId,
|
|
222
|
+
type: "claim.released", occurredAt: now,
|
|
223
|
+
payload: { claimId: claim.claimId, authority: null, reason: null,
|
|
224
|
+
replacedGeneration: claim.generation } });
|
|
225
|
+
}
|
|
226
|
+
const closed = { ...current, state: "closed", heartbeatAt: now };
|
|
227
|
+
tx.put("session", session.sessionId, closed,
|
|
228
|
+
tx.generationOf("session", session.sessionId));
|
|
229
|
+
tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"),
|
|
230
|
+
workspaceId: session.workspaceId, actorSessionId: session.sessionId,
|
|
231
|
+
type: "session.closed", occurredAt: now,
|
|
232
|
+
payload: { cause: "finish", messageId: recorded.message.messageId,
|
|
233
|
+
sessionGeneration: session.generation,
|
|
234
|
+
releasedClaimIds: releasedClaims.map(claim => claim.claimId) } });
|
|
235
|
+
return { retry: false, message: recorded.message,
|
|
236
|
+
releasedClaims: releasedClaims.map(projectReleasedClaim), session: closed };
|
|
237
|
+
}, { kinds: ["participant", "session", "claim", "message", "receipt"] });
|
|
238
|
+
if (!outcome.retry) {
|
|
239
|
+
return { message: outcome.message, releasedClaims: outcome.releasedClaims,
|
|
240
|
+
session: outcome.session };
|
|
241
|
+
}
|
|
242
|
+
const releasedClaims = await reconstructFinishRetry({ store,
|
|
243
|
+
workspaceId: session.workspaceId, session: outcome.session, message: outcome.message });
|
|
244
|
+
return { message: outcome.message, releasedClaims, session: outcome.session };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function pendingMessages(input = {}) {
|
|
248
|
+
return (await nextTurnDelivery(input)).queuedMessages;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function nextTurnDelivery(input = {}) {
|
|
252
|
+
if (typeof input.participantId !== "string" || input.participantId === "") {
|
|
253
|
+
return { queuedMessages: [], liveOfferedMessageIds: [], roomMessageIds: [] };
|
|
254
|
+
}
|
|
255
|
+
const snapshot = await store.snapshot(input.workspaceId ?? store.workspaceId,
|
|
256
|
+
{ kinds: ["message", "receipt"] });
|
|
257
|
+
const receiptByMessage = new Map(snapshot.receipts
|
|
258
|
+
.filter(item => item.recipientParticipantId === input.participantId)
|
|
259
|
+
.map(item => [item.messageId, item]));
|
|
260
|
+
const eligible = snapshot.messages.filter(message => receiptByMessage.has(message.messageId)
|
|
261
|
+
&& message.fromSessionId !== input.exceptSessionId)
|
|
262
|
+
.sort((left, right) => left.sentAt.localeCompare(right.sentAt)
|
|
263
|
+
|| left.messageId.localeCompare(right.messageId));
|
|
264
|
+
return {
|
|
265
|
+
queuedMessages: eligible.filter(message =>
|
|
266
|
+
receiptByMessage.get(message.messageId).state === "queued"),
|
|
267
|
+
liveOfferedMessageIds: eligible.filter(message => message.toParticipantIds.length > 0
|
|
268
|
+
&& receiptByMessage.get(message.messageId).state === "offered")
|
|
269
|
+
.map(message => message.messageId),
|
|
270
|
+
roomMessageIds: eligible.filter(message => message.toParticipantIds.length === 0)
|
|
271
|
+
.map(message => message.messageId),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return { sendMessage, finishSession, pendingMessages, nextTurnDelivery };
|
|
276
|
+
}
|