synomem 0.9.6 → 0.10.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/CHANGELOG.md +9 -0
- package/README.md +5 -3
- package/dist/actor-directory.d.ts +34 -0
- package/dist/actor-directory.d.ts.map +1 -0
- package/dist/actor-directory.js +52 -0
- package/dist/actor-directory.js.map +1 -0
- package/dist/backend.d.ts +3 -0
- package/dist/backend.d.ts.map +1 -1
- package/dist/backend.js +26 -3
- package/dist/backend.js.map +1 -1
- package/dist/bookmarks.d.ts +30 -0
- package/dist/bookmarks.d.ts.map +1 -0
- package/dist/bookmarks.js +87 -0
- package/dist/bookmarks.js.map +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +291 -61
- package/dist/cli.js.map +1 -1
- package/dist/client.d.ts +115 -4
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +854 -138
- package/dist/client.js.map +1 -1
- package/dist/cursors.d.ts +30 -0
- package/dist/cursors.d.ts.map +1 -0
- package/dist/cursors.js +101 -0
- package/dist/cursors.js.map +1 -0
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +2 -0
- package/dist/errors.js.map +1 -1
- package/dist/import.d.ts +605 -39
- package/dist/import.d.ts.map +1 -1
- package/dist/index.d.ts +19 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp/index.d.ts +1 -1
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +329 -22
- package/dist/mcp/index.js.map +1 -1
- package/dist/mutation-receipts.d.ts +8 -0
- package/dist/mutation-receipts.d.ts.map +1 -0
- package/dist/mutation-receipts.js +2 -0
- package/dist/mutation-receipts.js.map +1 -0
- package/dist/notifications.d.ts +73 -0
- package/dist/notifications.d.ts.map +1 -0
- package/dist/notifications.js +309 -0
- package/dist/notifications.js.map +1 -0
- package/dist/participation-repository.d.ts +46 -0
- package/dist/participation-repository.d.ts.map +1 -0
- package/dist/participation-repository.js +296 -0
- package/dist/participation-repository.js.map +1 -0
- package/dist/participation.d.ts +97 -0
- package/dist/participation.d.ts.map +1 -0
- package/dist/participation.js +10 -0
- package/dist/participation.js.map +1 -0
- package/dist/policy.d.ts +31 -0
- package/dist/policy.d.ts.map +1 -0
- package/dist/policy.js +71 -0
- package/dist/policy.js.map +1 -0
- package/dist/ports/repository.d.ts +23 -0
- package/dist/ports/repository.d.ts.map +1 -1
- package/dist/profiles.d.ts +12 -0
- package/dist/profiles.d.ts.map +1 -1
- package/dist/profiles.js +2 -1
- package/dist/profiles.js.map +1 -1
- package/dist/projections.d.ts.map +1 -1
- package/dist/projections.js +29 -11
- package/dist/projections.js.map +1 -1
- package/dist/remote.d.ts +79 -0
- package/dist/remote.d.ts.map +1 -1
- package/dist/remote.js +65 -6
- package/dist/remote.js.map +1 -1
- package/dist/schemas.d.ts +746 -58
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +114 -16
- package/dist/schemas.js.map +1 -1
- package/dist/search.d.ts +25 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +2 -0
- package/dist/search.js.map +1 -0
- package/dist/service.d.ts +89 -0
- package/dist/service.d.ts.map +1 -1
- package/dist/storage.d.ts +38 -3
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +494 -365
- package/dist/storage.js.map +1 -1
- package/dist/types.d.ts +65 -34
- package/dist/types.d.ts.map +1 -1
- package/docs/cli.md +6 -0
- package/docs/human-participation.md +20 -0
- package/docs/mcp.md +2 -0
- package/docs/storage-format.md +4 -15
- package/openapi/synomem-v1.yaml +187 -10
- package/package.json +2 -1
- package/skills/synomem/SKILL.md +2 -0
- package/src/actor-directory.ts +87 -0
- package/src/backend.ts +29 -3
- package/src/bookmarks.ts +127 -0
- package/src/cli.ts +941 -546
- package/src/client.ts +1143 -279
- package/src/cursors.ts +132 -0
- package/src/errors.ts +2 -0
- package/src/index.ts +50 -0
- package/src/mcp/index.ts +412 -96
- package/src/mutation-receipts.ts +7 -0
- package/src/notifications.ts +483 -0
- package/src/participation-repository.ts +406 -0
- package/src/participation.ts +103 -0
- package/src/policy.ts +109 -0
- package/src/ports/repository.ts +33 -0
- package/src/profiles.ts +2 -1
- package/src/projections.ts +49 -48
- package/src/remote.ts +227 -37
- package/src/schemas.ts +116 -16
- package/src/search.ts +24 -0
- package/src/service.ts +122 -8
- package/src/storage.ts +1039 -697
- package/src/types.ts +67 -32
package/dist/client.js
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import { actorDirectoryInput } from './actor-directory.js';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { threadInputSchema, threadSubscriptionSchema, replyCreateSchema, replyDeleteSchema, reactionSetSchema, } from './schemas.js';
|
|
4
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
5
|
+
import { cursorFilter } from './cursors.js';
|
|
6
|
+
import { isRecordAdministrator, localOwnerAuthority, overseesAgent, resolveAuthority, sameActor, } from './policy.js';
|
|
1
7
|
import { accessSync, constants as fsConstants, existsSync, lstatSync } from 'node:fs';
|
|
2
8
|
import { join, relative, resolve } from 'node:path';
|
|
3
9
|
import { ulid } from 'ulid';
|
|
@@ -5,7 +11,7 @@ import { resolveHome } from './config.js';
|
|
|
5
11
|
import { asSynomemError, SynomemError } from './errors.js';
|
|
6
12
|
import { assertNoSymlinkEscape } from './fs-utils.js';
|
|
7
13
|
import { escapeMarkdown, memoRecordsFromEvents, noteRecordsFromEvents, postRecordsFromEvents, ProjectionManager, recordsFromEvents, taskRecordsFromEvents, todoRecordsFromEvents, } from './projections.js';
|
|
8
|
-
import { actorSchema, agentLookupSchema, bindRuntimeSchema, createAgentSchema, createPostSchema, updatePostSchema, createNoteSchema, createTaskSchema, createTodoSchema, changesInputSchema, giveKudosSchema, itemListInputSchema, listInputSchema, reviseNoteSchema, sendMemoSchema, updateTaskSchema, updateTodoSchema, updateAgentSchema, createTopicSchema, updateTopicSchema, topicListInputSchema, topicLookupSchema, } from './schemas.js';
|
|
14
|
+
import { actorSchema, eventSchema, overrideTaskDecisionSchema, agentLookupSchema, bindRuntimeSchema, createAgentSchema, createPostSchema, updatePostSchema, createNoteSchema, createTaskSchema, createTodoSchema, changesInputSchema, giveKudosSchema, itemListInputSchema, listInputSchema, reviseNoteSchema, sendMemoSchema, updateTaskSchema, updateTodoSchema, updateAgentSchema, createTopicSchema, updateTopicSchema, topicListInputSchema, topicLookupSchema, } from './schemas.js';
|
|
9
15
|
import { SynomemStorage } from './storage.js';
|
|
10
16
|
export class SynomemCore {
|
|
11
17
|
/**
|
|
@@ -19,7 +25,280 @@ export class SynomemCore {
|
|
|
19
25
|
idGenerator;
|
|
20
26
|
signal;
|
|
21
27
|
administrative;
|
|
28
|
+
writeAllowed;
|
|
29
|
+
mutationContext = new AsyncLocalStorage();
|
|
30
|
+
authority;
|
|
22
31
|
initialized = false;
|
|
32
|
+
bookmarks = {
|
|
33
|
+
list: (input = {}) => {
|
|
34
|
+
const parsed = this.validate(() => z
|
|
35
|
+
.object({
|
|
36
|
+
after: z.string().max(4096).optional(),
|
|
37
|
+
limit: z.number().int().min(1).max(100).optional(),
|
|
38
|
+
})
|
|
39
|
+
.strict()
|
|
40
|
+
.parse(input));
|
|
41
|
+
return this.repository.bookmarks.list(parsed, this.actor);
|
|
42
|
+
},
|
|
43
|
+
has: async (rootId) => {
|
|
44
|
+
await this.getItem(rootId);
|
|
45
|
+
return { saved: await this.repository.bookmarks.has(rootId, this.boundRef()) };
|
|
46
|
+
},
|
|
47
|
+
set: async (input) => {
|
|
48
|
+
const parsed = this.validate(() => z
|
|
49
|
+
.object({ rootId: z.string().length(26), present: z.boolean() })
|
|
50
|
+
.strict()
|
|
51
|
+
.parse(input));
|
|
52
|
+
if (!this.writeAllowed)
|
|
53
|
+
throw new SynomemError('READ_ONLY', 'This binding cannot change saved records.');
|
|
54
|
+
await this.repository.transaction(async () => {
|
|
55
|
+
await this.resolveActor(this.boundRef(), true);
|
|
56
|
+
await this.getItem(parsed.rootId);
|
|
57
|
+
await this.repository.bookmarks.set(parsed.rootId, this.boundRef(), parsed.present);
|
|
58
|
+
});
|
|
59
|
+
return { saved: parsed.present };
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
notifications = {
|
|
63
|
+
list: async (input = {}) => {
|
|
64
|
+
const parsed = this.validate(() => z
|
|
65
|
+
.object({
|
|
66
|
+
after: z.string().max(4096).optional(),
|
|
67
|
+
limit: z.number().int().min(1).max(100).optional(),
|
|
68
|
+
view: z.enum(['all', 'unread', 'action_required', 'default']).optional(),
|
|
69
|
+
})
|
|
70
|
+
.strict()
|
|
71
|
+
.parse(input));
|
|
72
|
+
if (this.writeAllowed)
|
|
73
|
+
await this.repository.transaction(() => this.repository.notifications.drain());
|
|
74
|
+
const page = await this.repository.notifications.list(parsed, this.actor);
|
|
75
|
+
return page;
|
|
76
|
+
},
|
|
77
|
+
read: (id) => this.personalWrite(() => this.repository.notifications.read(id, this.boundRef())),
|
|
78
|
+
dismiss: (id) => this.personalWrite(() => this.repository.notifications.dismiss(id, this.boundRef())),
|
|
79
|
+
readThrough: (through) => this.personalWrite(() => this.repository.notifications.readThrough(through, this.boundRef())),
|
|
80
|
+
};
|
|
81
|
+
replies = {
|
|
82
|
+
changes: async (input) => {
|
|
83
|
+
const parsed = this.validate(() => threadInputSchema.parse(input));
|
|
84
|
+
await this.getItem(parsed.rootId);
|
|
85
|
+
return this.repository.participation.changes(parsed, this.actor);
|
|
86
|
+
},
|
|
87
|
+
create: (input) => this.mutate('replies.create', input, () => this.createReply(input)),
|
|
88
|
+
get: (id) => this.getReply(id),
|
|
89
|
+
delete: (input) => this.mutate('replies.delete', input, () => this.deleteReply(input)),
|
|
90
|
+
};
|
|
91
|
+
threads = {
|
|
92
|
+
read: async (input) => {
|
|
93
|
+
const parsed = this.validate(() => z
|
|
94
|
+
.object({ rootId: z.string().length(26), through: z.string().max(4096) })
|
|
95
|
+
.strict()
|
|
96
|
+
.parse(input));
|
|
97
|
+
await this.personalWrite(async () => {
|
|
98
|
+
await this.getItem(parsed.rootId);
|
|
99
|
+
await this.repository.participation.read(parsed.rootId, parsed.through, this.boundRef());
|
|
100
|
+
});
|
|
101
|
+
},
|
|
102
|
+
get: async (input) => {
|
|
103
|
+
const parsed = this.validate(() => threadInputSchema.parse(input));
|
|
104
|
+
await this.getItem(parsed.rootId);
|
|
105
|
+
return this.repository.participation.thread(parsed, this.actor);
|
|
106
|
+
},
|
|
107
|
+
subscription: async (input) => {
|
|
108
|
+
const parsed = this.validate(() => threadSubscriptionSchema.parse(input));
|
|
109
|
+
await this.personalWrite(async () => {
|
|
110
|
+
await this.getItem(parsed.rootId);
|
|
111
|
+
await this.repository.participation.subscription(parsed.rootId, this.boundRef(), parsed.following, parsed.muted);
|
|
112
|
+
});
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
reactions = {
|
|
116
|
+
set: (input) => this.mutate('reactions.set', input, () => this.setReaction(input)),
|
|
117
|
+
get: async (targetId) => {
|
|
118
|
+
await this.reactionRoot(targetId);
|
|
119
|
+
return this.repository.participation.reactions(targetId, this.boundRef());
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
allowedActions(record) {
|
|
123
|
+
const event = record.event;
|
|
124
|
+
const active = record.status !== 'archived' &&
|
|
125
|
+
(!('revocationStatus' in record) || record.revocationStatus !== 'revoked');
|
|
126
|
+
const owner = 'owner' in event ? event.owner : event.actor;
|
|
127
|
+
const participant = 'assignee' in event &&
|
|
128
|
+
(this.canManage(event.assignee) || this.canManage(event.actor));
|
|
129
|
+
const managed = event.type === 'memo.sent'
|
|
130
|
+
? this.canManage(event.recipient)
|
|
131
|
+
: event.type === 'task.created'
|
|
132
|
+
? participant
|
|
133
|
+
: this.canManage(owner);
|
|
134
|
+
const flags = {
|
|
135
|
+
reply: active,
|
|
136
|
+
edit: active &&
|
|
137
|
+
managed &&
|
|
138
|
+
['post.created', 'note.created', 'task.created', 'todo.created'].includes(event.type),
|
|
139
|
+
archive: active && managed && !['task.created', 'kudos.given'].includes(event.type),
|
|
140
|
+
moderateReplies: this.canModerateRoot(record),
|
|
141
|
+
acknowledge: event.type === 'kudos.given'
|
|
142
|
+
? active && sameActor(this.actor, event.recipient) && record.status === 'unacknowledged'
|
|
143
|
+
: event.type === 'post.created'
|
|
144
|
+
? active &&
|
|
145
|
+
'acknowledgments' in record &&
|
|
146
|
+
!record.acknowledgments.some((entry) => sameActor(this.actor, entry.actor))
|
|
147
|
+
: false,
|
|
148
|
+
withdrawAcknowledgment: event.type === 'post.created' &&
|
|
149
|
+
active &&
|
|
150
|
+
'acknowledgments' in record &&
|
|
151
|
+
record.acknowledgments.some((entry) => sameActor(this.actor, entry.actor)),
|
|
152
|
+
readMemo: event.type === 'memo.sent' &&
|
|
153
|
+
active &&
|
|
154
|
+
sameActor(this.actor, event.recipient) &&
|
|
155
|
+
record.status === 'unread',
|
|
156
|
+
revoke: event.type === 'kudos.given' && active && this.canManage(event.actor),
|
|
157
|
+
};
|
|
158
|
+
if (event.type === 'task.created')
|
|
159
|
+
Object.assign(flags, {
|
|
160
|
+
accept: record.status === 'assigned' && this.canManage(event.assignee),
|
|
161
|
+
reject: record.status === 'assigned' && this.canManage(event.assignee),
|
|
162
|
+
complete: record.status === 'open' && participant,
|
|
163
|
+
reopen: ['completed', 'canceled'].includes(record.status) && participant,
|
|
164
|
+
cancel: ['assigned', 'open'].includes(record.status) && participant,
|
|
165
|
+
overrideDecision: ['open', 'rejected'].includes(record.status) &&
|
|
166
|
+
this.actor.kind === 'human' &&
|
|
167
|
+
(this.administrative ||
|
|
168
|
+
(event.assignee.kind === 'agent' &&
|
|
169
|
+
overseesAgent(this.actor, this.authority, event.assignee.id)) ||
|
|
170
|
+
(event.actor.kind === 'agent' &&
|
|
171
|
+
overseesAgent(this.actor, this.authority, event.actor.id))),
|
|
172
|
+
});
|
|
173
|
+
if (event.type === 'todo.created')
|
|
174
|
+
Object.assign(flags, {
|
|
175
|
+
complete: record.status === 'open' && managed,
|
|
176
|
+
reopen: ['completed', 'canceled'].includes(record.status) && managed,
|
|
177
|
+
cancel: record.status === 'open' && managed,
|
|
178
|
+
});
|
|
179
|
+
return Object.fromEntries(Object.entries(flags).map(([name, allowed]) => [name, this.writeAllowed && allowed]));
|
|
180
|
+
}
|
|
181
|
+
async decorateRecord(record) {
|
|
182
|
+
return {
|
|
183
|
+
...record,
|
|
184
|
+
lifecycleVersion: (await this.repository.nextAggregateVersion(record.event.id)) - 1,
|
|
185
|
+
allowedActions: this.allowedActions(record),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
async getReply(id) {
|
|
189
|
+
const reply = await this.repository.participation.getReply(id);
|
|
190
|
+
if (!reply)
|
|
191
|
+
throw new SynomemError('ITEM_NOT_FOUND', 'Unknown reply.');
|
|
192
|
+
await this.getItem(reply.rootId);
|
|
193
|
+
return reply;
|
|
194
|
+
}
|
|
195
|
+
async createReply(input) {
|
|
196
|
+
await this.repository.assertEventCompatibility();
|
|
197
|
+
const parsed = this.validate(() => replyCreateSchema.parse(input));
|
|
198
|
+
const root = await this.getItem(parsed.rootId);
|
|
199
|
+
if (root.status === 'archived' ||
|
|
200
|
+
('revocationStatus' in root && root.revocationStatus === 'revoked'))
|
|
201
|
+
throw new SynomemError('MUTATION_FORBIDDEN', 'This root no longer accepts replies.');
|
|
202
|
+
if (parsed.parentId) {
|
|
203
|
+
const parent = await this.getReply(parsed.parentId);
|
|
204
|
+
if (parent.rootId !== parsed.rootId)
|
|
205
|
+
throw new SynomemError('INVALID_INPUT', 'The parent must belong to this thread.');
|
|
206
|
+
}
|
|
207
|
+
const mentions = [];
|
|
208
|
+
for (const supplied of parsed.mentions ?? []) {
|
|
209
|
+
const target = await this.resolveActor(supplied, true);
|
|
210
|
+
const ref = { kind: target.kind, id: target.id };
|
|
211
|
+
if (!(await this.repository.canActorReadItem(parsed.rootId, ref)))
|
|
212
|
+
throw new SynomemError('INVALID_INPUT', 'Mention targets must already have access.');
|
|
213
|
+
if (!mentions.some((actor) => sameActor(actor, ref)))
|
|
214
|
+
mentions.push(ref);
|
|
215
|
+
}
|
|
216
|
+
await this.repository.participation.reserveBudget('reply', this.boundRef(), this.now());
|
|
217
|
+
const id = this.nextId();
|
|
218
|
+
await this.repository.insertEvent({
|
|
219
|
+
...this.eventBase(id, 1, id),
|
|
220
|
+
type: 'reply.created',
|
|
221
|
+
rootId: parsed.rootId,
|
|
222
|
+
parentId: parsed.parentId ?? null,
|
|
223
|
+
body: parsed.body,
|
|
224
|
+
mentions,
|
|
225
|
+
});
|
|
226
|
+
return this.getReply(id);
|
|
227
|
+
}
|
|
228
|
+
canModerateRoot(root) {
|
|
229
|
+
if (this.actor.kind !== 'human')
|
|
230
|
+
return false;
|
|
231
|
+
const event = root.event;
|
|
232
|
+
if (this.administrative || sameActor(event.actor, this.actor))
|
|
233
|
+
return true;
|
|
234
|
+
const targets = [
|
|
235
|
+
event.actor,
|
|
236
|
+
...('recipient' in event ? [event.recipient] : []),
|
|
237
|
+
...('owner' in event ? [event.owner] : []),
|
|
238
|
+
...('assignee' in event ? [event.assignee] : []),
|
|
239
|
+
];
|
|
240
|
+
return targets.some((ref) => ref.kind === 'agent' && overseesAgent(this.actor, this.authority, ref.id));
|
|
241
|
+
}
|
|
242
|
+
async deleteReply(input) {
|
|
243
|
+
await this.repository.assertEventCompatibility();
|
|
244
|
+
const parsed = this.validate(() => replyDeleteSchema.parse(input));
|
|
245
|
+
const reply = await this.getReply(parsed.replyId);
|
|
246
|
+
const root = await this.getItem(reply.rootId);
|
|
247
|
+
const own = sameActor(reply.author, this.actor);
|
|
248
|
+
if (!own && !this.canModerateRoot(root))
|
|
249
|
+
throw new SynomemError('MUTATION_FORBIDDEN', 'Only the reply author or an authorized human moderator may delete it.');
|
|
250
|
+
if (!own && !parsed.reason)
|
|
251
|
+
throw new SynomemError('INVALID_INPUT', 'Moderation requires a reason.');
|
|
252
|
+
if (reply.version !== parsed.expectedVersion)
|
|
253
|
+
throw new SynomemError('REVISION_CONFLICT', 'The reply changed. Refresh before deleting.');
|
|
254
|
+
if (reply.deleted)
|
|
255
|
+
return reply;
|
|
256
|
+
await this.repository.insertEvent({
|
|
257
|
+
...this.eventBase(reply.id, reply.version + 1),
|
|
258
|
+
...(!own ? this.intervention(reply.author, parsed.reason) : {}),
|
|
259
|
+
type: 'reply.deleted',
|
|
260
|
+
replyId: reply.id,
|
|
261
|
+
rootId: reply.rootId,
|
|
262
|
+
...(parsed.reason ? { reason: parsed.reason } : {}),
|
|
263
|
+
});
|
|
264
|
+
return this.getReply(reply.id);
|
|
265
|
+
}
|
|
266
|
+
async reactionRoot(targetId) {
|
|
267
|
+
const reply = await this.repository.participation.getReply(targetId);
|
|
268
|
+
if (reply) {
|
|
269
|
+
await this.getItem(reply.rootId);
|
|
270
|
+
if (reply.deleted)
|
|
271
|
+
throw new SynomemError('MUTATION_FORBIDDEN', 'Deleted replies cannot receive reactions.');
|
|
272
|
+
return reply.rootId;
|
|
273
|
+
}
|
|
274
|
+
await this.getItem(targetId);
|
|
275
|
+
return targetId;
|
|
276
|
+
}
|
|
277
|
+
async setReaction(input) {
|
|
278
|
+
await this.repository.assertEventCompatibility();
|
|
279
|
+
const parsed = this.validate(() => reactionSetSchema.parse(input));
|
|
280
|
+
const rootId = await this.reactionRoot(parsed.targetId);
|
|
281
|
+
const actor = this.boundRef();
|
|
282
|
+
const present = await this.repository.participation.hasReaction(parsed.targetId, actor, parsed.code);
|
|
283
|
+
if (present !== parsed.present) {
|
|
284
|
+
await this.repository.participation.reserveBudget('reaction', actor, this.now());
|
|
285
|
+
const id = this.nextId();
|
|
286
|
+
await this.repository.insertEvent({
|
|
287
|
+
...this.eventBase(id, 1, id),
|
|
288
|
+
type: parsed.present ? 'reaction.added' : 'reaction.removed',
|
|
289
|
+
rootId,
|
|
290
|
+
targetId: parsed.targetId,
|
|
291
|
+
code: parsed.code,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
return this.repository.participation.reactions(parsed.targetId, actor);
|
|
295
|
+
}
|
|
296
|
+
async search(input) {
|
|
297
|
+
this.checkAbort();
|
|
298
|
+
if (!this.repository.search)
|
|
299
|
+
throw new SynomemError('UNSUPPORTED_BACKEND', 'Search is available on hosted workspaces.');
|
|
300
|
+
return this.repository.search(input, this.actor);
|
|
301
|
+
}
|
|
23
302
|
agents = {
|
|
24
303
|
create: (input) => this.createAgent(input),
|
|
25
304
|
update: (id, changes) => this.updateAgent(id, changes),
|
|
@@ -45,65 +324,66 @@ export class SynomemCore {
|
|
|
45
324
|
restore: (idOrAlias) => this.setTopicStatus(idOrAlias, 'active'),
|
|
46
325
|
};
|
|
47
326
|
kudos = {
|
|
48
|
-
give: (input) => this.giveKudos(input),
|
|
327
|
+
give: (input) => this.mutate('kudos.give', input, () => this.giveKudos(input)),
|
|
49
328
|
list: (input = {}) => this.listKudos(input),
|
|
50
329
|
changes: (input = {}) => this.listKudosChanges(input),
|
|
51
330
|
get: (id) => this.getKudos(id),
|
|
52
|
-
acknowledge: (input) => this.acknowledgeKudos(input),
|
|
53
|
-
revoke: (input) => this.revokeKudos(input),
|
|
331
|
+
acknowledge: (input) => this.mutate('kudos.acknowledge', input, () => this.acknowledgeKudos(input)),
|
|
332
|
+
revoke: (input) => this.mutate('kudos.revoke', input, () => this.revokeKudos(input)),
|
|
54
333
|
};
|
|
55
334
|
memos = {
|
|
56
|
-
send: (input) => this.sendMemo(input),
|
|
335
|
+
send: (input) => this.mutate('memos.send', input, () => this.sendMemo(input)),
|
|
57
336
|
list: (input = {}) => this.listItems({ ...input, kinds: ['memo'] }),
|
|
58
337
|
get: (id) => this.getMemo(id),
|
|
59
|
-
read: (input) => this.readMemo(input),
|
|
60
|
-
archive: (input) => this.archiveMemo(input),
|
|
338
|
+
read: (input) => this.mutate('memos.read', input, () => this.readMemo(input)),
|
|
339
|
+
archive: (input) => this.mutate('memos.archive', input, () => this.archiveMemo(input)),
|
|
61
340
|
};
|
|
62
341
|
posts = {
|
|
63
|
-
create: (input) => this.createPost(input),
|
|
342
|
+
create: (input) => this.mutate('posts.create', input, () => this.createPost(input)),
|
|
64
343
|
list: (input = {}) => this.listItems({ ...input, kinds: ['post'] }),
|
|
65
344
|
get: (id) => this.getPost(id),
|
|
66
|
-
update: (input) => this.updatePost(input),
|
|
67
|
-
archive: (input) => this.archivePost(input),
|
|
345
|
+
update: (input) => this.mutate('posts.update', input, () => this.updatePost(input)),
|
|
346
|
+
archive: (input) => this.mutate('posts.archive', input, () => this.archivePost(input)),
|
|
68
347
|
/*
|
|
69
348
|
* Acknowledging is an explicit call and always speaks for the caller alone.
|
|
70
349
|
* There is no bulk form and no acknowledge-on-behalf-of: an acknowledgement
|
|
71
350
|
* is one actor saying "I have seen this", and reading a post must never
|
|
72
351
|
* append one, or the roster stops meaning anything.
|
|
73
352
|
*/
|
|
74
|
-
acknowledge: (input) => this.acknowledgePost(input),
|
|
75
|
-
withdrawAcknowledgment: (input) => this.withdrawPostAcknowledgment(input),
|
|
353
|
+
acknowledge: (input) => this.mutate('posts.acknowledge', input, () => this.acknowledgePost(input)),
|
|
354
|
+
withdrawAcknowledgment: (input) => this.mutate('posts.withdrawAcknowledgment', input, () => this.withdrawPostAcknowledgment(input)),
|
|
76
355
|
roster: (postId) => this.postRoster(postId),
|
|
77
356
|
};
|
|
78
357
|
notes = {
|
|
79
|
-
create: (input) => this.createNote(input),
|
|
358
|
+
create: (input) => this.mutate('notes.create', input, () => this.createNote(input)),
|
|
80
359
|
list: (input = {}) => this.listItems({ ...input, kinds: ['note'] }),
|
|
81
360
|
get: (id) => this.getNote(id),
|
|
82
|
-
revise: (input) => this.reviseNote(input),
|
|
83
|
-
archive: (input) => this.archiveNote(input),
|
|
361
|
+
revise: (input) => this.mutate('notes.revise', input, () => this.reviseNote(input)),
|
|
362
|
+
archive: (input) => this.mutate('notes.archive', input, () => this.archiveNote(input)),
|
|
84
363
|
};
|
|
85
364
|
tasks = {
|
|
86
|
-
|
|
365
|
+
overrideDecision: (input) => this.mutate('tasks.overrideDecision', input, () => this.overrideTaskDecision(input)),
|
|
366
|
+
create: (input) => this.mutate('tasks.create', input, () => this.createTask(input)),
|
|
87
367
|
list: (input = {}) => this.listItems({ ...input, kinds: ['task'] }),
|
|
88
368
|
get: (id) => this.getTask(id),
|
|
89
|
-
update: (input) => this.updateTask(input),
|
|
369
|
+
update: (input) => this.mutate('tasks.update', input, () => this.updateTask(input)),
|
|
90
370
|
// A response is optional when accepting and required when rejecting: a
|
|
91
371
|
// refusal without a reason leaves the assigner unable to act on it.
|
|
92
|
-
accept: (input) => this.acceptTask(input),
|
|
93
|
-
reject: (input) => this.rejectTask(input),
|
|
94
|
-
complete: (input) => this.completeTask(input),
|
|
95
|
-
reopen: (input) => this.reopenTask(input),
|
|
96
|
-
cancel: (input) => this.cancelTask(input),
|
|
372
|
+
accept: (input) => this.mutate('tasks.accept', input, () => this.acceptTask(input)),
|
|
373
|
+
reject: (input) => this.mutate('tasks.reject', input, () => this.rejectTask(input)),
|
|
374
|
+
complete: (input) => this.mutate('tasks.complete', input, () => this.completeTask(input)),
|
|
375
|
+
reopen: (input) => this.mutate('tasks.reopen', input, () => this.reopenTask(input)),
|
|
376
|
+
cancel: (input) => this.mutate('tasks.cancel', input, () => this.cancelTask(input)),
|
|
97
377
|
};
|
|
98
378
|
todos = {
|
|
99
|
-
create: (input) => this.createTodo(input),
|
|
379
|
+
create: (input) => this.mutate('todos.create', input, () => this.createTodo(input)),
|
|
100
380
|
list: (input = {}) => this.listItems({ ...input, kinds: ['todo'] }),
|
|
101
381
|
get: (id) => this.getTodo(id),
|
|
102
|
-
update: (input) => this.updateTodo(input),
|
|
103
|
-
complete: (input) => this.todoTransition(input, 'todo.completed'),
|
|
104
|
-
reopen: (input) => this.todoTransition(input, 'todo.reopened'),
|
|
105
|
-
cancel: (input) => this.todoTransition(input, 'todo.canceled'),
|
|
106
|
-
archive: (input) => this.todoTransition(input, 'todo.archived'),
|
|
382
|
+
update: (input) => this.mutate('todos.update', input, () => this.updateTodo(input)),
|
|
383
|
+
complete: (input) => this.mutate('todos.complete', input, () => this.todoTransition(input, 'todo.completed')),
|
|
384
|
+
reopen: (input) => this.mutate('todos.reopen', input, () => this.todoTransition(input, 'todo.reopened')),
|
|
385
|
+
cancel: (input) => this.mutate('todos.cancel', input, () => this.todoTransition(input, 'todo.canceled')),
|
|
386
|
+
archive: (input) => this.mutate('todos.archive', input, () => this.todoTransition(input, 'todo.archived')),
|
|
107
387
|
};
|
|
108
388
|
/**
|
|
109
389
|
* Unanswered and overdue discovery.
|
|
@@ -122,7 +402,7 @@ export class SynomemCore {
|
|
|
122
402
|
const { olderThanHours, awaitingSince, ...rest } = input;
|
|
123
403
|
const since = awaitingSince ??
|
|
124
404
|
(olderThanHours !== undefined
|
|
125
|
-
? new Date(Date.now() - olderThanHours *
|
|
405
|
+
? new Date(Date.now() - olderThanHours * 3600000).toISOString()
|
|
126
406
|
: undefined);
|
|
127
407
|
return this.listItems({
|
|
128
408
|
...rest,
|
|
@@ -151,16 +431,36 @@ export class SynomemCore {
|
|
|
151
431
|
this.clock = options.clock ?? (() => new Date());
|
|
152
432
|
this.idGenerator = options.idGenerator ?? (() => ulid(this.clock().getTime()));
|
|
153
433
|
this.signal = options.signal;
|
|
154
|
-
this.
|
|
434
|
+
this.authority = resolveAuthority(options.authority);
|
|
435
|
+
this.writeAllowed = options.canWrite ?? true;
|
|
436
|
+
this.administrative = isRecordAdministrator(this.actor, this.authority);
|
|
155
437
|
this.repository = options.repository;
|
|
156
438
|
this.projectionWriter = options.projectionWriter;
|
|
157
439
|
}
|
|
440
|
+
/** Trusted adapters refresh live authority after acquiring the workspace write boundary. Never exposed as a tool/input. */
|
|
441
|
+
setAuthority(authority, canWrite = this.writeAllowed) {
|
|
442
|
+
this.writeAllowed = canWrite;
|
|
443
|
+
this.authority = resolveAuthority(authority);
|
|
444
|
+
this.administrative = isRecordAdministrator(this.actor, this.authority);
|
|
445
|
+
this.repository.setAuthority(this.authority);
|
|
446
|
+
}
|
|
158
447
|
async init() {
|
|
159
448
|
this.checkAbort();
|
|
160
449
|
if (this.initialized)
|
|
161
450
|
return;
|
|
162
451
|
await this.repository.init();
|
|
163
452
|
this.initialized = true;
|
|
453
|
+
if (this.actor.kind === 'human' &&
|
|
454
|
+
this.authority.mode === 'local-owner' &&
|
|
455
|
+
!(await this.repository.getActor({ kind: 'human', id: this.actor.id }))) {
|
|
456
|
+
await this.repository.transaction(() => this.repository.registerHuman({
|
|
457
|
+
kind: 'human',
|
|
458
|
+
id: this.actor.id,
|
|
459
|
+
handle: this.actor.id,
|
|
460
|
+
displayName: this.actor.displayName ?? this.actor.id,
|
|
461
|
+
status: 'active',
|
|
462
|
+
}));
|
|
463
|
+
}
|
|
164
464
|
/*
|
|
165
465
|
* An agent actor is resolved to its canonical identity here.
|
|
166
466
|
*
|
|
@@ -189,6 +489,321 @@ export class SynomemCore {
|
|
|
189
489
|
await this.repository.close();
|
|
190
490
|
this.initialized = false;
|
|
191
491
|
}
|
|
492
|
+
actors = {
|
|
493
|
+
profile: async (input) => {
|
|
494
|
+
const actor = await this.resolveActor(input.target);
|
|
495
|
+
return {
|
|
496
|
+
actor,
|
|
497
|
+
counts: await this.repository.actorCounts(actor, this.actor),
|
|
498
|
+
authored: await this.listItems({
|
|
499
|
+
actorKind: actor.kind,
|
|
500
|
+
actorId: actor.id,
|
|
501
|
+
cursor: input.after,
|
|
502
|
+
limit: input.limit,
|
|
503
|
+
}),
|
|
504
|
+
};
|
|
505
|
+
},
|
|
506
|
+
list: async (input = {}) => {
|
|
507
|
+
this.checkAbort();
|
|
508
|
+
return await this.repository.listActors(actorDirectoryInput(input), this.actor);
|
|
509
|
+
},
|
|
510
|
+
get: (ref) => this.resolveActor(ref),
|
|
511
|
+
registerHuman: async (input) => {
|
|
512
|
+
if (this.actor.kind !== 'human' || this.authority.mode !== 'local-owner')
|
|
513
|
+
throw new SynomemError('MUTATION_FORBIDDEN', 'Only the local owner may register a human.');
|
|
514
|
+
const ref = actorSchema.parse({
|
|
515
|
+
kind: 'human',
|
|
516
|
+
id: input.id,
|
|
517
|
+
displayName: input.displayName,
|
|
518
|
+
});
|
|
519
|
+
const handle = agentLookupSchema.parse(input.handle);
|
|
520
|
+
await this.repository.registerHuman({
|
|
521
|
+
kind: 'human',
|
|
522
|
+
id: ref.id,
|
|
523
|
+
handle,
|
|
524
|
+
displayName: input.displayName,
|
|
525
|
+
status: 'active',
|
|
526
|
+
});
|
|
527
|
+
return this.resolveActor({ kind: 'human', id: ref.id });
|
|
528
|
+
},
|
|
529
|
+
};
|
|
530
|
+
async resolveActor(ref, requireActive = false) {
|
|
531
|
+
if (!ref || (ref.kind !== 'human' && ref.kind !== 'agent'))
|
|
532
|
+
throw new SynomemError('INVALID_INPUT', 'An actor reference is required.');
|
|
533
|
+
const actor = await this.repository.getActor(ref);
|
|
534
|
+
if (!actor)
|
|
535
|
+
throw new SynomemError('AGENT_NOT_FOUND', `Unknown ${ref.kind}: ${ref.id}`);
|
|
536
|
+
if (requireActive && actor.status !== 'active')
|
|
537
|
+
throw new SynomemError('MUTATION_FORBIDDEN', 'Inactive actors cannot receive new work.');
|
|
538
|
+
return actor;
|
|
539
|
+
}
|
|
540
|
+
boundRef() {
|
|
541
|
+
if (this.actor.kind === 'system')
|
|
542
|
+
throw new SynomemError('MUTATION_FORBIDDEN', 'A human or agent identity is required.');
|
|
543
|
+
return { kind: this.actor.kind, id: this.actor.id };
|
|
544
|
+
}
|
|
545
|
+
canManage(ref) {
|
|
546
|
+
return (sameActor(ref, this.actor) ||
|
|
547
|
+
this.administrative ||
|
|
548
|
+
(ref.kind === 'agent' && overseesAgent(this.actor, this.authority, ref.id)));
|
|
549
|
+
}
|
|
550
|
+
intervention(ref, reason) {
|
|
551
|
+
if (this.actor.kind !== 'human' || sameActor(this.actor, ref) || !this.canManage(ref))
|
|
552
|
+
return {};
|
|
553
|
+
const basis = this.authority.mode === 'local-owner'
|
|
554
|
+
? 'local_owner'
|
|
555
|
+
: this.authority.organizationAdministrator
|
|
556
|
+
? 'organization_admin'
|
|
557
|
+
: this.authority.workspaceAdministrator
|
|
558
|
+
? 'workspace_admin'
|
|
559
|
+
: 'operator';
|
|
560
|
+
return { intervention: { basis, ...(reason ? { reason } : {}) } };
|
|
561
|
+
}
|
|
562
|
+
async syncActor(ref) {
|
|
563
|
+
if (ref.kind === 'agent') {
|
|
564
|
+
const context = this.mutationContext.getStore();
|
|
565
|
+
if (context)
|
|
566
|
+
context.projections.add(ref.id);
|
|
567
|
+
else
|
|
568
|
+
await this.projectionWriter.syncAgent(ref.id);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
async normalizeMutation(operation, input) {
|
|
572
|
+
const schemas = {
|
|
573
|
+
'kudos.give': giveKudosSchema,
|
|
574
|
+
'replies.create': replyCreateSchema,
|
|
575
|
+
'replies.delete': replyDeleteSchema,
|
|
576
|
+
'reactions.set': reactionSetSchema,
|
|
577
|
+
'memos.send': sendMemoSchema,
|
|
578
|
+
'notes.create': createNoteSchema,
|
|
579
|
+
'notes.revise': reviseNoteSchema,
|
|
580
|
+
'posts.create': createPostSchema,
|
|
581
|
+
'posts.update': updatePostSchema,
|
|
582
|
+
'tasks.create': createTaskSchema,
|
|
583
|
+
'tasks.update': updateTaskSchema,
|
|
584
|
+
'tasks.overrideDecision': overrideTaskDecisionSchema,
|
|
585
|
+
'todos.create': createTodoSchema,
|
|
586
|
+
'todos.update': updateTodoSchema,
|
|
587
|
+
};
|
|
588
|
+
const value = this.validate(() => schemas[operation]?.parse(operation === 'kudos.give'
|
|
589
|
+
? {
|
|
590
|
+
...input,
|
|
591
|
+
visibility: input.visibility ?? this.repository.config.defaultVisibility,
|
|
592
|
+
}
|
|
593
|
+
: input) ?? input);
|
|
594
|
+
const normalized = { ...value };
|
|
595
|
+
delete normalized.idempotencyKey;
|
|
596
|
+
for (const field of ['recipient', 'owner', 'assignee']) {
|
|
597
|
+
const supplied = normalized[field];
|
|
598
|
+
if (supplied) {
|
|
599
|
+
const actor = await this.resolveActor(supplied);
|
|
600
|
+
normalized[field] = { kind: actor.kind, id: actor.id };
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
for (const field of [
|
|
604
|
+
'title',
|
|
605
|
+
'body',
|
|
606
|
+
'subject',
|
|
607
|
+
'reason',
|
|
608
|
+
'response',
|
|
609
|
+
'note',
|
|
610
|
+
'details',
|
|
611
|
+
'description',
|
|
612
|
+
])
|
|
613
|
+
if (typeof normalized[field] === 'string')
|
|
614
|
+
normalized[field] = normalized[field].trim();
|
|
615
|
+
for (const field of ['tags', 'topicIds'])
|
|
616
|
+
if (field in normalized ||
|
|
617
|
+
operation.endsWith('.create') ||
|
|
618
|
+
['kudos.give', 'memos.send'].includes(operation))
|
|
619
|
+
normalized[field] = [...new Set((normalized[field] ?? []))].sort();
|
|
620
|
+
if (['kudos.give', 'memos.send', 'tasks.create'].includes(operation))
|
|
621
|
+
normalized.visibility ??= this.repository.config.defaultVisibility;
|
|
622
|
+
if (['notes.create', 'todos.create'].includes(operation))
|
|
623
|
+
normalized.owner ??= this.boundRef();
|
|
624
|
+
if (operation === 'tasks.create')
|
|
625
|
+
normalized.assignee ??= this.boundRef();
|
|
626
|
+
if (['tasks.create', 'todos.create'].includes(operation))
|
|
627
|
+
normalized.priority ??= 3;
|
|
628
|
+
if (['replies.create', 'posts.create', 'posts.update'].includes(operation)) {
|
|
629
|
+
if (operation === 'replies.create')
|
|
630
|
+
normalized.parentId ??= null;
|
|
631
|
+
if (operation === 'posts.update' && normalized.mentions === undefined)
|
|
632
|
+
return normalized;
|
|
633
|
+
const mentions = [];
|
|
634
|
+
for (const supplied of (normalized.mentions ?? [])) {
|
|
635
|
+
let actor;
|
|
636
|
+
try {
|
|
637
|
+
actor = await this.resolveActor(supplied, true);
|
|
638
|
+
}
|
|
639
|
+
catch (error) {
|
|
640
|
+
if (error instanceof SynomemError &&
|
|
641
|
+
['AGENT_NOT_FOUND', 'INVALID_INPUT'].includes(error.code))
|
|
642
|
+
throw new SynomemError('INVALID_INPUT', 'Mention targets must be active and accessible.');
|
|
643
|
+
throw error;
|
|
644
|
+
}
|
|
645
|
+
if (!mentions.some((ref) => sameActor(ref, actor)))
|
|
646
|
+
mentions.push({ kind: actor.kind, id: actor.id });
|
|
647
|
+
}
|
|
648
|
+
normalized.mentions = mentions.sort((a, b) => `${a.kind}:${a.id}`.localeCompare(`${b.kind}:${b.id}`));
|
|
649
|
+
}
|
|
650
|
+
return normalized;
|
|
651
|
+
}
|
|
652
|
+
async authorizeReceipt(operation, result) {
|
|
653
|
+
if (operation.startsWith('replies.')) {
|
|
654
|
+
const reply = await this.getReply(result.id);
|
|
655
|
+
if (operation === 'replies.delete' &&
|
|
656
|
+
!sameActor(reply.author, this.actor) &&
|
|
657
|
+
!this.canModerateRoot(await this.getItem(reply.rootId)))
|
|
658
|
+
throw new SynomemError('MUTATION_FORBIDDEN', 'Moderation authority was removed.');
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
if (operation === 'reactions.set') {
|
|
662
|
+
await this.reactionRoot(result.targetId);
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
const candidate = result;
|
|
666
|
+
const id = candidate.record?.event?.id ?? candidate.event?.id;
|
|
667
|
+
if (!id)
|
|
668
|
+
throw new SynomemError('IDEMPOTENCY_EXPIRED', 'The cached mutation result cannot be reconstructed.');
|
|
669
|
+
const record = await this.getItem(id);
|
|
670
|
+
const event = record.event;
|
|
671
|
+
if (operation.startsWith('notes.'))
|
|
672
|
+
this.assertNoteOwner(record);
|
|
673
|
+
else if (operation.startsWith('todos.'))
|
|
674
|
+
this.assertTodoOwner(record);
|
|
675
|
+
else if (operation.startsWith('tasks.') && operation !== 'tasks.create') {
|
|
676
|
+
if (['tasks.accept', 'tasks.reject'].includes(operation))
|
|
677
|
+
this.assertTaskAssignee(record);
|
|
678
|
+
else
|
|
679
|
+
this.assertTaskParticipant(record);
|
|
680
|
+
if (operation === 'tasks.overrideDecision' &&
|
|
681
|
+
(this.actor.kind !== 'human' ||
|
|
682
|
+
!(this.administrative ||
|
|
683
|
+
(record.event.assignee.kind === 'agent' &&
|
|
684
|
+
overseesAgent(this.actor, this.authority, record.event.assignee.id)) ||
|
|
685
|
+
(event.actor.kind === 'agent' &&
|
|
686
|
+
overseesAgent(this.actor, this.authority, event.actor.id)))))
|
|
687
|
+
throw new SynomemError('MUTATION_FORBIDDEN', 'Only a human overseer may override a task.');
|
|
688
|
+
}
|
|
689
|
+
else if (operation.startsWith('posts.') &&
|
|
690
|
+
['posts.update', 'posts.archive'].includes(operation))
|
|
691
|
+
this.assertPostAuthor(record);
|
|
692
|
+
else if (operation === 'memos.read')
|
|
693
|
+
this.assertRecipient(record.event.recipient, 'mark this memo read');
|
|
694
|
+
else if (operation === 'memos.archive')
|
|
695
|
+
this.assertRecipient(record.event.recipient, 'archive this memo', true);
|
|
696
|
+
else if (operation === 'kudos.acknowledge' &&
|
|
697
|
+
!sameActor(this.actor, record.event.recipient))
|
|
698
|
+
throw new SynomemError('ACKNOWLEDGMENT_FORBIDDEN', 'Only the recipient may acknowledge kudos.');
|
|
699
|
+
else if (operation === 'kudos.revoke' && !this.canManage(event.actor))
|
|
700
|
+
throw new SynomemError('REVOCATION_FORBIDDEN', 'Only the author or an authorized overseer may revoke kudos.');
|
|
701
|
+
for (const field of ['recipient', 'owner', 'assignee'])
|
|
702
|
+
if (field in event) {
|
|
703
|
+
const ref = event[field];
|
|
704
|
+
await this.syncActor(ref);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
personalWrite(operation) {
|
|
708
|
+
this.checkAbort();
|
|
709
|
+
if (!this.writeAllowed)
|
|
710
|
+
throw new SynomemError('READ_ONLY', 'This binding cannot change personal state.');
|
|
711
|
+
return this.repository.transaction(async () => {
|
|
712
|
+
await this.resolveActor(this.boundRef(), true);
|
|
713
|
+
return operation();
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
async mutate(operation, input, action) {
|
|
717
|
+
this.checkAbort();
|
|
718
|
+
if (!this.writeAllowed)
|
|
719
|
+
throw new SynomemError('READ_ONLY', 'This binding cannot write records.');
|
|
720
|
+
const rawKey = input.idempotencyKey;
|
|
721
|
+
if (rawKey !== undefined &&
|
|
722
|
+
(typeof rawKey !== 'string' || !rawKey.trim() || rawKey.trim().length > 200))
|
|
723
|
+
throw new SynomemError('INVALID_INPUT', 'Idempotency keys must contain 1–200 characters.');
|
|
724
|
+
const key = typeof rawKey === 'string' ? rawKey.trim() : undefined;
|
|
725
|
+
const state = { ...(key ? { key } : {}), projections: new Set() };
|
|
726
|
+
const result = await this.mutationContext.run(state, () => this.repository.transaction(async () => {
|
|
727
|
+
if (this.actor.kind !== 'system')
|
|
728
|
+
await this.resolveActor(this.boundRef(), true);
|
|
729
|
+
const normalized = await this.normalizeMutation(operation, input);
|
|
730
|
+
const requestHash = cursorFilter({ operation, request: normalized });
|
|
731
|
+
const keyHash = key ? cursorFilter(key) : undefined;
|
|
732
|
+
const prior = keyHash
|
|
733
|
+
? await this.repository.getMutationReceipt(this.actor.kind, this.actor.id, keyHash)
|
|
734
|
+
: undefined;
|
|
735
|
+
if (prior) {
|
|
736
|
+
if (prior.operation !== operation || prior.requestHash !== requestHash)
|
|
737
|
+
throw new SynomemError('IDEMPOTENCY_CONFLICT', 'This key was already used for a different request.');
|
|
738
|
+
if (!prior.resultJson)
|
|
739
|
+
throw new SynomemError('IDEMPOTENCY_EXPIRED', 'The original response expired. This request will not execute again.');
|
|
740
|
+
const response = JSON.parse(prior.resultJson);
|
|
741
|
+
await this.authorizeReceipt(operation, response);
|
|
742
|
+
if (operation === 'replies.create' || operation === 'replies.delete') {
|
|
743
|
+
const current = await this.getReply(response.id);
|
|
744
|
+
if (current.deleted)
|
|
745
|
+
return { ...response, body: undefined, mentions: undefined, deleted: true };
|
|
746
|
+
}
|
|
747
|
+
if (response && typeof response === 'object' && 'created' in response)
|
|
748
|
+
return { ...response, created: false, deduplicated: true };
|
|
749
|
+
return response;
|
|
750
|
+
}
|
|
751
|
+
if (key &&
|
|
752
|
+
(await this.repository.getEventByIdempotency(this.actor.kind, this.actor.id, key)))
|
|
753
|
+
throw new SynomemError('IDEMPOTENCY_EXPIRED', 'The original request receipt is unavailable. This key will not execute again.');
|
|
754
|
+
const lifecycleOperations = new Set([
|
|
755
|
+
'kudos.acknowledge',
|
|
756
|
+
'kudos.revoke',
|
|
757
|
+
'memos.read',
|
|
758
|
+
'memos.archive',
|
|
759
|
+
'posts.archive',
|
|
760
|
+
'posts.acknowledge',
|
|
761
|
+
'posts.withdrawAcknowledgment',
|
|
762
|
+
'notes.archive',
|
|
763
|
+
'tasks.accept',
|
|
764
|
+
'tasks.reject',
|
|
765
|
+
'tasks.complete',
|
|
766
|
+
'tasks.reopen',
|
|
767
|
+
'tasks.cancel',
|
|
768
|
+
'todos.complete',
|
|
769
|
+
'todos.reopen',
|
|
770
|
+
'todos.cancel',
|
|
771
|
+
'todos.archive',
|
|
772
|
+
]);
|
|
773
|
+
if (lifecycleOperations.has(operation)) {
|
|
774
|
+
const supplied = input.expectedVersion;
|
|
775
|
+
if (!Number.isSafeInteger(supplied) || supplied < 1)
|
|
776
|
+
throw new SynomemError('INVALID_INPUT', 'expectedVersion must be the current positive lifecycleVersion.');
|
|
777
|
+
const rootId = normalized[(operation.startsWith('kudos.')
|
|
778
|
+
? 'kudos'
|
|
779
|
+
: operation.split('.')[0].replace(/s$/, '')) + 'Id'];
|
|
780
|
+
const current = await this.getItem(rootId);
|
|
781
|
+
await this.authorizeReceipt(operation, current);
|
|
782
|
+
if (current.lifecycleVersion !== supplied)
|
|
783
|
+
throw new SynomemError('REVISION_CONFLICT', 'The record changed. Refresh before applying this action.');
|
|
784
|
+
}
|
|
785
|
+
const response = await action();
|
|
786
|
+
if (keyHash) {
|
|
787
|
+
const resultJson = JSON.stringify(response);
|
|
788
|
+
if (Buffer.byteLength(resultJson) > 262144)
|
|
789
|
+
throw new SynomemError('INVALID_INPUT', 'The mutation response exceeds the receipt budget.');
|
|
790
|
+
const eventId = (key
|
|
791
|
+
? await this.repository.getEventByIdempotency(this.actor.kind, this.actor.id, key)
|
|
792
|
+
: undefined)?.id;
|
|
793
|
+
await this.repository.insertMutationReceipt(this.actor.kind, this.actor.id, keyHash, {
|
|
794
|
+
operation,
|
|
795
|
+
requestHash,
|
|
796
|
+
resultJson,
|
|
797
|
+
...(eventId ? { eventId } : {}),
|
|
798
|
+
createdAt: this.now(),
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
return response;
|
|
802
|
+
}));
|
|
803
|
+
for (const id of state.projections)
|
|
804
|
+
await this.projectionWriter.syncAgent(id);
|
|
805
|
+
return result;
|
|
806
|
+
}
|
|
192
807
|
now() {
|
|
193
808
|
return this.clock().toISOString();
|
|
194
809
|
}
|
|
@@ -197,7 +812,10 @@ export class SynomemCore {
|
|
|
197
812
|
}
|
|
198
813
|
eventBase(aggregateId, aggregateVersion, id = this.nextId()) {
|
|
199
814
|
return {
|
|
200
|
-
schemaVersion:
|
|
815
|
+
schemaVersion: 2,
|
|
816
|
+
...(this.mutationContext.getStore()?.key
|
|
817
|
+
? { idempotencyKey: this.mutationContext.getStore().key }
|
|
818
|
+
: {}),
|
|
201
819
|
id,
|
|
202
820
|
workspaceId: this.repository.config.workspaceId,
|
|
203
821
|
aggregateId,
|
|
@@ -576,13 +1194,13 @@ export class SynomemCore {
|
|
|
576
1194
|
visibility: input.visibility ?? this.repository.config.defaultVisibility,
|
|
577
1195
|
}));
|
|
578
1196
|
await this.assertTopicsExist(parsed.topicIds);
|
|
579
|
-
const recipient = await this.
|
|
1197
|
+
const recipient = await this.resolveActor(parsed.recipient, true);
|
|
580
1198
|
if (!recipient) {
|
|
581
|
-
throw new SynomemError('AGENT_NOT_FOUND', `Unknown recipient: ${parsed.
|
|
1199
|
+
throw new SynomemError('AGENT_NOT_FOUND', `Unknown recipient: ${parsed.recipient?.id}`);
|
|
582
1200
|
}
|
|
583
1201
|
if (!this.repository.config.allowSelfAwards &&
|
|
584
1202
|
this.actor.kind !== 'human' &&
|
|
585
|
-
this.actor
|
|
1203
|
+
sameActor(this.actor, recipient)) {
|
|
586
1204
|
throw new SynomemError('SELF_AWARD_FORBIDDEN', 'Non-human actors cannot award kudos to a matching agent identity.');
|
|
587
1205
|
}
|
|
588
1206
|
const outcome = await this.repository.transaction(async () => {
|
|
@@ -593,7 +1211,7 @@ export class SynomemCore {
|
|
|
593
1211
|
const event = {
|
|
594
1212
|
...this.eventBase(id, 1, id),
|
|
595
1213
|
type: 'kudos.given',
|
|
596
|
-
|
|
1214
|
+
recipient: { kind: recipient.kind, id: recipient.id },
|
|
597
1215
|
recipientDisplayName: recipient.displayName,
|
|
598
1216
|
title: parsed.title,
|
|
599
1217
|
reason: parsed.reason,
|
|
@@ -609,7 +1227,7 @@ export class SynomemCore {
|
|
|
609
1227
|
return { event, created: true };
|
|
610
1228
|
});
|
|
611
1229
|
if (outcome.created)
|
|
612
|
-
await this.
|
|
1230
|
+
await this.syncActor(recipient);
|
|
613
1231
|
const record = await this.getKudosRecord(outcome.event.id);
|
|
614
1232
|
return { record, created: outcome.created, deduplicated: !outcome.created };
|
|
615
1233
|
}
|
|
@@ -618,7 +1236,7 @@ export class SynomemCore {
|
|
|
618
1236
|
const record = recordsFromEvents(await this.repository.getReadableSynomemEvents(id))[0];
|
|
619
1237
|
if (!record)
|
|
620
1238
|
throw new SynomemError('KUDOS_NOT_FOUND', `Unknown kudos: ${id}`);
|
|
621
|
-
return record;
|
|
1239
|
+
return this.decorateRecord(record);
|
|
622
1240
|
}
|
|
623
1241
|
async getKudos(id) {
|
|
624
1242
|
this.checkAbort();
|
|
@@ -627,15 +1245,15 @@ export class SynomemCore {
|
|
|
627
1245
|
async listKudos(input) {
|
|
628
1246
|
this.checkAbort();
|
|
629
1247
|
const filters = this.validate(() => listInputSchema.parse(input));
|
|
630
|
-
const recipient = filters.
|
|
631
|
-
? await this.
|
|
1248
|
+
const recipient = filters.recipient?.id
|
|
1249
|
+
? await this.resolveActor(filters.recipient)
|
|
632
1250
|
: undefined;
|
|
633
|
-
if (filters.
|
|
634
|
-
throw new SynomemError('AGENT_NOT_FOUND', `Unknown agent: ${filters.
|
|
1251
|
+
if (filters.recipient?.id && !recipient) {
|
|
1252
|
+
throw new SynomemError('AGENT_NOT_FOUND', `Unknown agent: ${filters.recipient?.id}`);
|
|
635
1253
|
}
|
|
636
1254
|
return await this.repository.listKudosSummaries({
|
|
637
1255
|
...filters,
|
|
638
|
-
...(recipient ? {
|
|
1256
|
+
...(recipient ? { recipient: { kind: recipient.kind, id: recipient.id } } : {}),
|
|
639
1257
|
}, this.actor);
|
|
640
1258
|
}
|
|
641
1259
|
async listKudosChanges(input) {
|
|
@@ -654,19 +1272,19 @@ export class SynomemCore {
|
|
|
654
1272
|
return record;
|
|
655
1273
|
if (record.revocation)
|
|
656
1274
|
throw new SynomemError('INVALID_INPUT', 'Revoked kudos cannot be acknowledged.');
|
|
657
|
-
const isRecipient = this.actor
|
|
658
|
-
if (!
|
|
659
|
-
throw new SynomemError('ACKNOWLEDGMENT_FORBIDDEN', 'Only the recipient
|
|
1275
|
+
const isRecipient = sameActor(this.actor, record.event.recipient);
|
|
1276
|
+
if (!isRecipient) {
|
|
1277
|
+
throw new SynomemError('ACKNOWLEDGMENT_FORBIDDEN', 'Only the recipient may acknowledge kudos.');
|
|
660
1278
|
}
|
|
661
1279
|
const event = {
|
|
662
1280
|
...this.eventBase(record.event.id, 2),
|
|
663
1281
|
type: 'kudos.acknowledged',
|
|
664
1282
|
kudosId: record.event.id,
|
|
665
|
-
|
|
1283
|
+
recipient: record.event.recipient,
|
|
666
1284
|
...(input.note ? { note: input.note.trim() } : {}),
|
|
667
1285
|
};
|
|
668
1286
|
await this.repository.transaction(() => this.repository.insertEvent(event));
|
|
669
|
-
await this.
|
|
1287
|
+
await this.syncActor(record.event.recipient);
|
|
670
1288
|
return await this.getKudosRecord(input.kudosId);
|
|
671
1289
|
}
|
|
672
1290
|
async revokeKudos(input) {
|
|
@@ -684,18 +1302,19 @@ export class SynomemCore {
|
|
|
684
1302
|
throw new SynomemError('REVOCATION_FORBIDDEN', 'Only an administrator may request an administrative revocation.');
|
|
685
1303
|
}
|
|
686
1304
|
const administrative = this.administrative && !isOriginalActor;
|
|
687
|
-
if (!
|
|
688
|
-
throw new SynomemError('REVOCATION_FORBIDDEN', 'Only the
|
|
1305
|
+
if (!this.canManage(record.event.actor)) {
|
|
1306
|
+
throw new SynomemError('REVOCATION_FORBIDDEN', 'Only the author or an authorized overseer may revoke kudos.');
|
|
689
1307
|
}
|
|
690
1308
|
const event = {
|
|
691
1309
|
...this.eventBase(record.event.id, record.acknowledgment ? 3 : 2),
|
|
1310
|
+
...this.intervention(record.event.actor, reason),
|
|
692
1311
|
type: 'kudos.revoked',
|
|
693
1312
|
kudosId: record.event.id,
|
|
694
1313
|
reason,
|
|
695
1314
|
mode: administrative && !isOriginalActor ? 'administrative' : 'actor-requested',
|
|
696
1315
|
};
|
|
697
1316
|
await this.repository.transaction(() => this.repository.insertEvent(event));
|
|
698
|
-
await this.
|
|
1317
|
+
await this.syncActor(record.event.recipient);
|
|
699
1318
|
return await this.getKudosRecord(input.kudosId);
|
|
700
1319
|
}
|
|
701
1320
|
async priorMutation(idempotencyKey, expectedType) {
|
|
@@ -708,13 +1327,13 @@ export class SynomemCore {
|
|
|
708
1327
|
return prior;
|
|
709
1328
|
}
|
|
710
1329
|
canViewItem(summary) {
|
|
1330
|
+
if (summary.kind === 'note' || summary.kind === 'todo')
|
|
1331
|
+
return !!summary.owner && this.canManage(summary.owner);
|
|
711
1332
|
return (this.administrative ||
|
|
712
1333
|
summary.visibility !== 'private' ||
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
summary.ownerAgentId === this.actor.id ||
|
|
717
|
-
summary.assigneeAgentId === this.actor.id)));
|
|
1334
|
+
[summary.actor, summary.recipient, summary.assignee].some((ref) => !!ref &&
|
|
1335
|
+
(sameActor(ref, this.actor) ||
|
|
1336
|
+
(ref.kind === 'agent' && overseesAgent(this.actor, this.authority, ref.id)))));
|
|
718
1337
|
}
|
|
719
1338
|
async requireVisibleItem(id, kind) {
|
|
720
1339
|
const summary = await this.repository.getItemSummary(id);
|
|
@@ -738,7 +1357,7 @@ export class SynomemCore {
|
|
|
738
1357
|
const record = memoRecordsFromEvents(await this.repository.getReadableItemEvents(id))[0];
|
|
739
1358
|
if (!record)
|
|
740
1359
|
throw new SynomemError('MEMO_NOT_FOUND', `Unknown memo: ${id}`);
|
|
741
|
-
return record;
|
|
1360
|
+
return this.decorateRecord(record);
|
|
742
1361
|
}
|
|
743
1362
|
async getMemo(id) {
|
|
744
1363
|
this.checkAbort();
|
|
@@ -749,9 +1368,9 @@ export class SynomemCore {
|
|
|
749
1368
|
await this.repository.assertEventCompatibility();
|
|
750
1369
|
const parsed = this.validate(() => sendMemoSchema.parse(input));
|
|
751
1370
|
await this.assertTopicsExist(parsed.topicIds);
|
|
752
|
-
const recipient = await this.
|
|
1371
|
+
const recipient = await this.resolveActor(parsed.recipient, true);
|
|
753
1372
|
if (!recipient)
|
|
754
|
-
throw new SynomemError('AGENT_NOT_FOUND', `Unknown recipient: ${parsed.
|
|
1373
|
+
throw new SynomemError('AGENT_NOT_FOUND', `Unknown recipient: ${parsed.recipient?.id}`);
|
|
755
1374
|
const outcome = await this.repository.transaction(async () => {
|
|
756
1375
|
const prior = await this.priorMutation(parsed.idempotencyKey, 'memo.sent');
|
|
757
1376
|
if (prior?.type === 'memo.sent')
|
|
@@ -760,7 +1379,7 @@ export class SynomemCore {
|
|
|
760
1379
|
const event = {
|
|
761
1380
|
...this.eventBase(id, 1, id),
|
|
762
1381
|
type: 'memo.sent',
|
|
763
|
-
|
|
1382
|
+
recipient: { kind: recipient.kind, id: recipient.id },
|
|
764
1383
|
recipientDisplayName: recipient.displayName,
|
|
765
1384
|
subject: parsed.subject,
|
|
766
1385
|
body: parsed.body,
|
|
@@ -775,22 +1394,20 @@ export class SynomemCore {
|
|
|
775
1394
|
return { id, created: true };
|
|
776
1395
|
});
|
|
777
1396
|
if (outcome.created)
|
|
778
|
-
await this.
|
|
1397
|
+
await this.syncActor(recipient);
|
|
779
1398
|
return {
|
|
780
1399
|
record: await this.getMemoRecord(outcome.id),
|
|
781
1400
|
created: outcome.created,
|
|
782
1401
|
deduplicated: !outcome.created,
|
|
783
1402
|
};
|
|
784
1403
|
}
|
|
785
|
-
assertRecipient(
|
|
786
|
-
if (!this.
|
|
787
|
-
|
|
788
|
-
throw new SynomemError('MUTATION_FORBIDDEN', `Only the recipient agent or a human administrator may ${operation}.`);
|
|
789
|
-
}
|
|
1404
|
+
assertRecipient(recipient, operation, oversight = false) {
|
|
1405
|
+
if (!(oversight ? this.canManage(recipient) : sameActor(this.actor, recipient)))
|
|
1406
|
+
throw new SynomemError('MUTATION_FORBIDDEN', `Only the recipient may ${operation}.`);
|
|
790
1407
|
}
|
|
791
1408
|
async readMemo(input) {
|
|
792
1409
|
const record = await this.getMemoRecord(input.memoId);
|
|
793
|
-
this.assertRecipient(record.event.
|
|
1410
|
+
this.assertRecipient(record.event.recipient, 'mark this memo read');
|
|
794
1411
|
if (record.read)
|
|
795
1412
|
return record;
|
|
796
1413
|
await this.repository.transaction(async () => {
|
|
@@ -801,17 +1418,17 @@ export class SynomemCore {
|
|
|
801
1418
|
...this.eventBase(record.event.id, await this.repository.nextAggregateVersion(record.event.id)),
|
|
802
1419
|
type: 'memo.read',
|
|
803
1420
|
memoId: record.event.id,
|
|
804
|
-
|
|
1421
|
+
recipient: record.event.recipient,
|
|
805
1422
|
...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
|
|
806
1423
|
};
|
|
807
1424
|
await this.repository.insertEvent(event);
|
|
808
1425
|
});
|
|
809
|
-
await this.
|
|
1426
|
+
await this.syncActor(record.event.recipient);
|
|
810
1427
|
return await this.getMemoRecord(input.memoId);
|
|
811
1428
|
}
|
|
812
1429
|
async archiveMemo(input) {
|
|
813
1430
|
const record = await this.getMemoRecord(input.memoId);
|
|
814
|
-
this.assertRecipient(record.event.
|
|
1431
|
+
this.assertRecipient(record.event.recipient, 'archive this memo', true);
|
|
815
1432
|
if (record.archived)
|
|
816
1433
|
return record;
|
|
817
1434
|
await this.repository.transaction(async () => {
|
|
@@ -819,15 +1436,16 @@ export class SynomemCore {
|
|
|
819
1436
|
if (prior)
|
|
820
1437
|
return;
|
|
821
1438
|
const event = {
|
|
1439
|
+
...this.intervention(record.event.recipient),
|
|
822
1440
|
...this.eventBase(record.event.id, await this.repository.nextAggregateVersion(record.event.id)),
|
|
823
1441
|
type: 'memo.archived',
|
|
824
1442
|
memoId: record.event.id,
|
|
825
|
-
|
|
1443
|
+
recipient: record.event.recipient,
|
|
826
1444
|
...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
|
|
827
1445
|
};
|
|
828
1446
|
await this.repository.insertEvent(event);
|
|
829
1447
|
});
|
|
830
|
-
await this.
|
|
1448
|
+
await this.syncActor(record.event.recipient);
|
|
831
1449
|
return await this.getMemoRecord(input.memoId);
|
|
832
1450
|
}
|
|
833
1451
|
async getPostRecord(id) {
|
|
@@ -835,21 +1453,30 @@ export class SynomemCore {
|
|
|
835
1453
|
const record = postRecordsFromEvents(await this.repository.getReadableItemEvents(id))[0];
|
|
836
1454
|
if (!record)
|
|
837
1455
|
throw new SynomemError('ITEM_NOT_FOUND', `Unknown post: ${id}`);
|
|
838
|
-
return record;
|
|
1456
|
+
return this.decorateRecord(record);
|
|
839
1457
|
}
|
|
840
1458
|
async getPost(id) {
|
|
841
1459
|
this.checkAbort();
|
|
842
1460
|
return await this.getPostRecord(id);
|
|
843
1461
|
}
|
|
1462
|
+
async canonicalPostMentions(input) {
|
|
1463
|
+
const mentions = [];
|
|
1464
|
+
for (const reference of input) {
|
|
1465
|
+
const actor = await this.resolveActor(reference, true);
|
|
1466
|
+
const ref = { kind: actor.kind, id: actor.id };
|
|
1467
|
+
if (!mentions.some((mention) => sameActor(mention, ref)))
|
|
1468
|
+
mentions.push(ref);
|
|
1469
|
+
}
|
|
1470
|
+
return mentions.sort((left, right) => `${left.kind}:${left.id}`.localeCompare(`${right.kind}:${right.id}`));
|
|
1471
|
+
}
|
|
844
1472
|
async createPost(input) {
|
|
845
1473
|
this.checkAbort();
|
|
846
1474
|
await this.repository.assertEventCompatibility();
|
|
847
1475
|
const parsed = this.validate(() => createPostSchema.parse(input));
|
|
848
1476
|
await this.assertTopicsExist(parsed.topicIds);
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
await this.requireVisibleItem(parsed.replyTo, 'post');
|
|
1477
|
+
const mentions = parsed.mentions
|
|
1478
|
+
? await this.canonicalPostMentions(parsed.mentions)
|
|
1479
|
+
: undefined;
|
|
853
1480
|
const outcome = await this.repository.transaction(async () => {
|
|
854
1481
|
const prior = await this.priorMutation(parsed.idempotencyKey, 'post.created');
|
|
855
1482
|
if (prior?.type === 'post.created')
|
|
@@ -860,9 +1487,9 @@ export class SynomemCore {
|
|
|
860
1487
|
type: 'post.created',
|
|
861
1488
|
title: parsed.title,
|
|
862
1489
|
body: parsed.body,
|
|
1490
|
+
mentions: mentions ?? [],
|
|
863
1491
|
tags: [...new Set(parsed.tags ?? [])].sort(),
|
|
864
1492
|
topicIds: [...new Set(parsed.topicIds ?? [])].sort(),
|
|
865
|
-
...(parsed.replyTo ? { replyTo: parsed.replyTo } : {}),
|
|
866
1493
|
...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
|
|
867
1494
|
...(parsed.source ? { source: parsed.source } : {}),
|
|
868
1495
|
...(parsed.metadata ? { metadata: parsed.metadata } : {}),
|
|
@@ -878,7 +1505,9 @@ export class SynomemCore {
|
|
|
878
1505
|
}
|
|
879
1506
|
/** Only the author edits a post. Everyone else responds to it. */
|
|
880
1507
|
assertPostAuthor(record) {
|
|
881
|
-
if (this.administrative
|
|
1508
|
+
if (this.administrative ||
|
|
1509
|
+
(record.event.actor.kind === 'agent' &&
|
|
1510
|
+
overseesAgent(this.actor, this.authority, record.event.actor.id)))
|
|
882
1511
|
return;
|
|
883
1512
|
if (record.event.actor.id !== this.actor.id || record.event.actor.kind !== this.actor.kind) {
|
|
884
1513
|
throw new SynomemError('MUTATION_FORBIDDEN', 'Only the author can change a post.');
|
|
@@ -888,6 +1517,9 @@ export class SynomemCore {
|
|
|
888
1517
|
this.checkAbort();
|
|
889
1518
|
const parsed = this.validate(() => updatePostSchema.parse(input));
|
|
890
1519
|
await this.assertTopicsExist(parsed.topicIds);
|
|
1520
|
+
const mentions = parsed.mentions
|
|
1521
|
+
? await this.canonicalPostMentions(parsed.mentions)
|
|
1522
|
+
: undefined;
|
|
891
1523
|
const record = await this.getPostRecord(parsed.postId);
|
|
892
1524
|
this.assertPostAuthor(record);
|
|
893
1525
|
if (record.status === 'archived') {
|
|
@@ -898,11 +1530,13 @@ export class SynomemCore {
|
|
|
898
1530
|
}
|
|
899
1531
|
await this.repository.transaction(async () => {
|
|
900
1532
|
const event = {
|
|
1533
|
+
...this.intervention(record.event.actor),
|
|
901
1534
|
...this.eventBase(parsed.postId, await this.repository.nextAggregateVersion(parsed.postId)),
|
|
902
1535
|
type: 'post.edited',
|
|
903
1536
|
postId: parsed.postId,
|
|
904
1537
|
title: parsed.title ?? record.title,
|
|
905
1538
|
body: parsed.body ?? record.body,
|
|
1539
|
+
mentions: mentions ?? record.mentions ?? [],
|
|
906
1540
|
tags: [...new Set(parsed.tags ?? record.tags ?? [])].sort(),
|
|
907
1541
|
topicIds: [...new Set(parsed.topicIds ?? record.topicIds ?? [])].sort(),
|
|
908
1542
|
...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
|
|
@@ -918,6 +1552,7 @@ export class SynomemCore {
|
|
|
918
1552
|
if (record.status !== 'archived') {
|
|
919
1553
|
await this.repository.transaction(async () => {
|
|
920
1554
|
const event = {
|
|
1555
|
+
...this.intervention(record.event.actor),
|
|
921
1556
|
...this.eventBase(input.postId, await this.repository.nextAggregateVersion(input.postId)),
|
|
922
1557
|
type: 'post.archived',
|
|
923
1558
|
postId: input.postId,
|
|
@@ -979,7 +1614,7 @@ export class SynomemCore {
|
|
|
979
1614
|
const record = noteRecordsFromEvents(await this.repository.getReadableItemEvents(id))[0];
|
|
980
1615
|
if (!record)
|
|
981
1616
|
throw new SynomemError('NOTE_NOT_FOUND', `Unknown note: ${id}`);
|
|
982
|
-
return record;
|
|
1617
|
+
return this.decorateRecord(record);
|
|
983
1618
|
}
|
|
984
1619
|
async getNote(id) {
|
|
985
1620
|
this.checkAbort();
|
|
@@ -990,15 +1625,9 @@ export class SynomemCore {
|
|
|
990
1625
|
await this.repository.assertEventCompatibility();
|
|
991
1626
|
const parsed = this.validate(() => createNoteSchema.parse(input));
|
|
992
1627
|
await this.assertTopicsExist(parsed.topicIds);
|
|
993
|
-
const
|
|
994
|
-
if (!
|
|
995
|
-
throw new SynomemError('
|
|
996
|
-
const owner = await this.repository.getAgent(ownerId);
|
|
997
|
-
if (!owner)
|
|
998
|
-
throw new SynomemError('AGENT_NOT_FOUND', `Unknown note owner: ${ownerId}`);
|
|
999
|
-
if (!this.administrative && (this.actor.kind !== 'agent' || this.actor.id !== owner.id)) {
|
|
1000
|
-
throw new SynomemError('MUTATION_FORBIDDEN', 'Agents may create notes only for themselves.');
|
|
1001
|
-
}
|
|
1628
|
+
const owner = await this.resolveActor(parsed.owner ?? this.boundRef(), true);
|
|
1629
|
+
if (!this.canManage(owner))
|
|
1630
|
+
throw new SynomemError('MUTATION_FORBIDDEN', 'Only the owner or an authorized overseer may create private memory.');
|
|
1002
1631
|
const outcome = await this.repository.transaction(async () => {
|
|
1003
1632
|
const prior = await this.priorMutation(parsed.idempotencyKey, 'note.created');
|
|
1004
1633
|
if (prior?.type === 'note.created')
|
|
@@ -1006,8 +1635,9 @@ export class SynomemCore {
|
|
|
1006
1635
|
const id = this.nextId();
|
|
1007
1636
|
const event = {
|
|
1008
1637
|
...this.eventBase(id, 1, id),
|
|
1638
|
+
...this.intervention({ kind: owner.kind, id: owner.id }),
|
|
1009
1639
|
type: 'note.created',
|
|
1010
|
-
|
|
1640
|
+
owner: { kind: owner.kind, id: owner.id },
|
|
1011
1641
|
ownerDisplayName: owner.displayName,
|
|
1012
1642
|
title: parsed.title,
|
|
1013
1643
|
body: parsed.body,
|
|
@@ -1022,7 +1652,7 @@ export class SynomemCore {
|
|
|
1022
1652
|
return { id, created: true };
|
|
1023
1653
|
});
|
|
1024
1654
|
if (outcome.created)
|
|
1025
|
-
await this.
|
|
1655
|
+
await this.syncActor(owner);
|
|
1026
1656
|
return {
|
|
1027
1657
|
record: await this.getNoteRecord(outcome.id),
|
|
1028
1658
|
created: outcome.created,
|
|
@@ -1030,10 +1660,8 @@ export class SynomemCore {
|
|
|
1030
1660
|
};
|
|
1031
1661
|
}
|
|
1032
1662
|
assertNoteOwner(record) {
|
|
1033
|
-
if (!this.
|
|
1034
|
-
|
|
1035
|
-
throw new SynomemError('MUTATION_FORBIDDEN', 'Only the note owner or a human administrator may change it.');
|
|
1036
|
-
}
|
|
1663
|
+
if (!this.canManage(record.event.owner))
|
|
1664
|
+
throw new SynomemError('MUTATION_FORBIDDEN', 'Only the owner or an authorized overseer may change private memory.');
|
|
1037
1665
|
}
|
|
1038
1666
|
async reviseNote(input) {
|
|
1039
1667
|
const parsed = this.validate(() => reviseNoteSchema.parse(input));
|
|
@@ -1052,6 +1680,7 @@ export class SynomemCore {
|
|
|
1052
1680
|
parsed.expectedVersion + 1)
|
|
1053
1681
|
throw new SynomemError('REVISION_CONFLICT', 'The note changed before this revision was stored.');
|
|
1054
1682
|
const event = {
|
|
1683
|
+
...this.intervention(record.event.owner),
|
|
1055
1684
|
...this.eventBase(record.event.id, parsed.expectedVersion + 1),
|
|
1056
1685
|
type: 'note.revised',
|
|
1057
1686
|
noteId: record.event.id,
|
|
@@ -1066,7 +1695,7 @@ export class SynomemCore {
|
|
|
1066
1695
|
};
|
|
1067
1696
|
await this.repository.insertEvent(event);
|
|
1068
1697
|
});
|
|
1069
|
-
await this.
|
|
1698
|
+
await this.syncActor(record.event.owner);
|
|
1070
1699
|
return await this.getNoteRecord(parsed.noteId);
|
|
1071
1700
|
}
|
|
1072
1701
|
async archiveNote(input) {
|
|
@@ -1079,6 +1708,7 @@ export class SynomemCore {
|
|
|
1079
1708
|
if (prior)
|
|
1080
1709
|
return;
|
|
1081
1710
|
const event = {
|
|
1711
|
+
...this.intervention(record.event.owner),
|
|
1082
1712
|
...this.eventBase(record.event.id, await this.repository.nextAggregateVersion(record.event.id)),
|
|
1083
1713
|
type: 'note.archived',
|
|
1084
1714
|
noteId: record.event.id,
|
|
@@ -1086,7 +1716,7 @@ export class SynomemCore {
|
|
|
1086
1716
|
};
|
|
1087
1717
|
await this.repository.insertEvent(event);
|
|
1088
1718
|
});
|
|
1089
|
-
await this.
|
|
1719
|
+
await this.syncActor(record.event.owner);
|
|
1090
1720
|
return await this.getNoteRecord(input.noteId);
|
|
1091
1721
|
}
|
|
1092
1722
|
async getTaskRecord(id) {
|
|
@@ -1094,7 +1724,7 @@ export class SynomemCore {
|
|
|
1094
1724
|
const record = taskRecordsFromEvents(await this.repository.getReadableItemEvents(id))[0];
|
|
1095
1725
|
if (!record)
|
|
1096
1726
|
throw new SynomemError('TODO_NOT_FOUND', `Unknown task: ${id}`);
|
|
1097
|
-
return record;
|
|
1727
|
+
return this.decorateRecord(record);
|
|
1098
1728
|
}
|
|
1099
1729
|
async getTask(id) {
|
|
1100
1730
|
this.checkAbort();
|
|
@@ -1105,26 +1735,21 @@ export class SynomemCore {
|
|
|
1105
1735
|
await this.repository.assertEventCompatibility();
|
|
1106
1736
|
const parsed = this.validate(() => createTaskSchema.parse(input));
|
|
1107
1737
|
await this.assertTopicsExist(parsed.topicIds);
|
|
1108
|
-
const
|
|
1109
|
-
if (!assigneeId)
|
|
1110
|
-
throw new SynomemError('INVALID_INPUT', 'A human or system actor must specify assigneeAgentId.');
|
|
1111
|
-
const assignee = await this.repository.getAgent(assigneeId);
|
|
1112
|
-
if (!assignee)
|
|
1113
|
-
throw new SynomemError('AGENT_NOT_FOUND', `Unknown task assignee: ${assigneeId}`);
|
|
1738
|
+
const assignee = await this.resolveActor(parsed.assignee ?? this.boundRef(), true);
|
|
1114
1739
|
if (this.actor.kind === 'agent' &&
|
|
1115
|
-
this.actor
|
|
1740
|
+
!sameActor(this.actor, assignee) &&
|
|
1116
1741
|
!this.repository.config.allowCrossAgentTasks)
|
|
1117
|
-
throw new SynomemError('POLICY_FORBIDDEN', 'Cross-
|
|
1742
|
+
throw new SynomemError('POLICY_FORBIDDEN', 'Cross-actor task assignment is disabled.');
|
|
1118
1743
|
const outcome = await this.repository.transaction(async () => {
|
|
1119
1744
|
const prior = await this.priorMutation(parsed.idempotencyKey, 'task.created');
|
|
1120
1745
|
if (prior?.type === 'task.created')
|
|
1121
1746
|
return { id: prior.id, created: false };
|
|
1122
1747
|
const id = this.nextId();
|
|
1123
|
-
const requiresAcceptance = this.actor
|
|
1748
|
+
const requiresAcceptance = !sameActor(this.actor, assignee);
|
|
1124
1749
|
const event = {
|
|
1125
1750
|
...this.eventBase(id, 1, id),
|
|
1126
1751
|
type: 'task.created',
|
|
1127
|
-
|
|
1752
|
+
assignee: { kind: assignee.kind, id: assignee.id },
|
|
1128
1753
|
assigneeDisplayName: assignee.displayName,
|
|
1129
1754
|
title: parsed.title,
|
|
1130
1755
|
...(parsed.description !== undefined ? { description: parsed.description } : {}),
|
|
@@ -1142,7 +1767,7 @@ export class SynomemCore {
|
|
|
1142
1767
|
return { id, created: true };
|
|
1143
1768
|
});
|
|
1144
1769
|
if (outcome.created)
|
|
1145
|
-
await this.
|
|
1770
|
+
await this.syncActor(assignee);
|
|
1146
1771
|
return {
|
|
1147
1772
|
record: await this.getTaskRecord(outcome.id),
|
|
1148
1773
|
created: outcome.created,
|
|
@@ -1150,16 +1775,12 @@ export class SynomemCore {
|
|
|
1150
1775
|
};
|
|
1151
1776
|
}
|
|
1152
1777
|
assertTaskParticipant(record) {
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
if (!this.administrative && !isCreator && !isAssignee)
|
|
1156
|
-
throw new SynomemError('MUTATION_FORBIDDEN', 'Only the task creator, assignee, or a human administrator may change it.');
|
|
1778
|
+
if (!this.canManage(record.event.assignee) && !this.canManage(record.event.actor))
|
|
1779
|
+
throw new SynomemError('MUTATION_FORBIDDEN', 'Only task participants or authorized overseers may change it.');
|
|
1157
1780
|
}
|
|
1158
1781
|
assertTaskAssignee(record) {
|
|
1159
|
-
if (!this.
|
|
1160
|
-
|
|
1161
|
-
throw new SynomemError('MUTATION_FORBIDDEN', 'Only the assigned agent or a human administrator may accept or reject this task.');
|
|
1162
|
-
}
|
|
1782
|
+
if (!this.canManage(record.event.assignee))
|
|
1783
|
+
throw new SynomemError('MUTATION_FORBIDDEN', 'Only the assignee or an authorized overseer may decide this task.');
|
|
1163
1784
|
}
|
|
1164
1785
|
async updateTask(input) {
|
|
1165
1786
|
const parsed = this.validate(() => updateTaskSchema.parse(input));
|
|
@@ -1179,6 +1800,7 @@ export class SynomemCore {
|
|
|
1179
1800
|
throw new SynomemError('REVISION_CONFLICT', 'The task changed before this update was stored.');
|
|
1180
1801
|
const due = parsed.due === null ? undefined : (parsed.due ?? record.current.due);
|
|
1181
1802
|
const event = {
|
|
1803
|
+
...this.intervention(record.event.assignee),
|
|
1182
1804
|
...this.eventBase(record.event.id, parsed.expectedVersion + 1),
|
|
1183
1805
|
type: 'task.updated',
|
|
1184
1806
|
taskId: record.event.id,
|
|
@@ -1199,9 +1821,41 @@ export class SynomemCore {
|
|
|
1199
1821
|
};
|
|
1200
1822
|
await this.repository.insertEvent(event);
|
|
1201
1823
|
});
|
|
1202
|
-
await this.
|
|
1824
|
+
await this.syncActor(record.event.assignee);
|
|
1203
1825
|
return await this.getTaskRecord(parsed.taskId);
|
|
1204
1826
|
}
|
|
1827
|
+
async overrideTaskDecision(input) {
|
|
1828
|
+
this.checkAbort();
|
|
1829
|
+
const parsed = this.validate(() => overrideTaskDecisionSchema.parse(input));
|
|
1830
|
+
await this.repository.transaction(async () => {
|
|
1831
|
+
const record = await this.getTaskRecord(parsed.taskId);
|
|
1832
|
+
if (this.actor.kind !== 'human' ||
|
|
1833
|
+
!(this.administrative ||
|
|
1834
|
+
(record.event.assignee.kind === 'agent' &&
|
|
1835
|
+
overseesAgent(this.actor, this.authority, record.event.assignee.id)) ||
|
|
1836
|
+
(record.event.actor.kind === 'agent' &&
|
|
1837
|
+
overseesAgent(this.actor, this.authority, record.event.actor.id))))
|
|
1838
|
+
throw new SynomemError('MUTATION_FORBIDDEN', 'Only an authorized human overseer may override a decision.');
|
|
1839
|
+
if (record.current.version !== parsed.expectedVersion)
|
|
1840
|
+
throw new SynomemError('REVISION_CONFLICT', 'The task changed. Reload it before overriding.');
|
|
1841
|
+
if (record.status !== 'open' && record.status !== 'rejected')
|
|
1842
|
+
throw new SynomemError('INVALID_INPUT', 'Only accepted/open or rejected decisions can be overridden.');
|
|
1843
|
+
if (record.status === parsed.nextStatus)
|
|
1844
|
+
throw new SynomemError('INVALID_INPUT', 'The requested decision is already in effect.');
|
|
1845
|
+
const event = {
|
|
1846
|
+
...this.eventBase(parsed.taskId, parsed.expectedVersion + 1),
|
|
1847
|
+
...this.intervention(record.event.assignee, parsed.reason),
|
|
1848
|
+
type: 'task.decision_overridden',
|
|
1849
|
+
taskId: parsed.taskId,
|
|
1850
|
+
previousStatus: record.status,
|
|
1851
|
+
nextStatus: parsed.nextStatus,
|
|
1852
|
+
reason: parsed.reason,
|
|
1853
|
+
...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
|
|
1854
|
+
};
|
|
1855
|
+
await this.repository.insertEvent(event);
|
|
1856
|
+
});
|
|
1857
|
+
return this.getTaskRecord(parsed.taskId);
|
|
1858
|
+
}
|
|
1205
1859
|
async taskTransition(input, type) {
|
|
1206
1860
|
const record = await this.getTaskRecord(input.taskId);
|
|
1207
1861
|
// A rejection must say why. Enforced here as well as in the schema so the
|
|
@@ -1240,6 +1894,7 @@ export class SynomemCore {
|
|
|
1240
1894
|
if (prior)
|
|
1241
1895
|
return;
|
|
1242
1896
|
const base = {
|
|
1897
|
+
...this.intervention(record.event.assignee),
|
|
1243
1898
|
...this.eventBase(record.event.id, await this.repository.nextAggregateVersion(record.event.id)),
|
|
1244
1899
|
taskId: record.event.id,
|
|
1245
1900
|
...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
|
|
@@ -1259,7 +1914,7 @@ export class SynomemCore {
|
|
|
1259
1914
|
: { ...base, type };
|
|
1260
1915
|
await this.repository.insertEvent(event);
|
|
1261
1916
|
});
|
|
1262
|
-
await this.
|
|
1917
|
+
await this.syncActor(record.event.assignee);
|
|
1263
1918
|
return await this.getTaskRecord(input.taskId);
|
|
1264
1919
|
}
|
|
1265
1920
|
completeTask(input) {
|
|
@@ -1289,6 +1944,9 @@ export class SynomemCore {
|
|
|
1289
1944
|
await this.repository.assertEventCompatibility();
|
|
1290
1945
|
const parsed = this.validate(() => createTodoSchema.parse(input));
|
|
1291
1946
|
await this.assertTopicsExist(parsed.topicIds);
|
|
1947
|
+
const owner = await this.resolveActor(parsed.owner ?? this.boundRef(), true);
|
|
1948
|
+
if (!this.canManage(owner))
|
|
1949
|
+
throw new SynomemError('MUTATION_FORBIDDEN', 'Only the owner or an authorized overseer may create private memory.');
|
|
1292
1950
|
const outcome = await this.repository.transaction(async () => {
|
|
1293
1951
|
const prior = await this.priorMutation(parsed.idempotencyKey, 'todo.created');
|
|
1294
1952
|
if (prior?.type === 'todo.created')
|
|
@@ -1296,7 +1954,9 @@ export class SynomemCore {
|
|
|
1296
1954
|
const id = this.nextId();
|
|
1297
1955
|
const event = {
|
|
1298
1956
|
...this.eventBase(id, 1, id),
|
|
1957
|
+
...this.intervention({ kind: owner.kind, id: owner.id }),
|
|
1299
1958
|
type: 'todo.created',
|
|
1959
|
+
owner: { kind: owner.kind, id: owner.id },
|
|
1300
1960
|
title: parsed.title,
|
|
1301
1961
|
...(parsed.details !== undefined ? { details: parsed.details } : {}),
|
|
1302
1962
|
priority: parsed.priority ?? 3,
|
|
@@ -1321,7 +1981,7 @@ export class SynomemCore {
|
|
|
1321
1981
|
if (!record)
|
|
1322
1982
|
throw new SynomemError('ITEM_NOT_FOUND', `Unknown todo: ${id}`);
|
|
1323
1983
|
this.assertTodoOwner(record);
|
|
1324
|
-
return record;
|
|
1984
|
+
return this.decorateRecord(record);
|
|
1325
1985
|
}
|
|
1326
1986
|
/**
|
|
1327
1987
|
* Todos are owner-only for every ordinary actor — the same rule notes
|
|
@@ -1335,10 +1995,8 @@ export class SynomemCore {
|
|
|
1335
1995
|
* on top of a permission that had already been granted.
|
|
1336
1996
|
*/
|
|
1337
1997
|
assertTodoOwner(record) {
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
throw new SynomemError('MUTATION_FORBIDDEN', 'A todo is private to the actor who created it.');
|
|
1341
|
-
}
|
|
1998
|
+
if (!this.canManage(record.event.owner))
|
|
1999
|
+
throw new SynomemError('MUTATION_FORBIDDEN', 'Only the owner or an authorized overseer may change private memory.');
|
|
1342
2000
|
}
|
|
1343
2001
|
async getTodo(id) {
|
|
1344
2002
|
this.checkAbort();
|
|
@@ -1358,6 +2016,7 @@ export class SynomemCore {
|
|
|
1358
2016
|
if (prior)
|
|
1359
2017
|
return;
|
|
1360
2018
|
const event = {
|
|
2019
|
+
...this.intervention(record.event.owner),
|
|
1361
2020
|
...this.eventBase(record.event.id, await this.repository.nextAggregateVersion(record.event.id)),
|
|
1362
2021
|
type: 'todo.updated',
|
|
1363
2022
|
todoId: record.event.id,
|
|
@@ -1377,6 +2036,7 @@ export class SynomemCore {
|
|
|
1377
2036
|
}
|
|
1378
2037
|
async todoTransition(input, type) {
|
|
1379
2038
|
const record = await this.getTodoRecord(input.todoId);
|
|
2039
|
+
this.assertTodoOwner(record);
|
|
1380
2040
|
if (type === 'todo.completed' && record.status !== 'open') {
|
|
1381
2041
|
if (record.status === 'completed')
|
|
1382
2042
|
return record;
|
|
@@ -1431,10 +2091,10 @@ export class SynomemCore {
|
|
|
1431
2091
|
const parsed = this.validate(() => itemListInputSchema.parse(input));
|
|
1432
2092
|
const resolved = {
|
|
1433
2093
|
...parsed,
|
|
1434
|
-
...(parsed.
|
|
1435
|
-
|
|
2094
|
+
...(parsed.participant ? { participant: await this.resolveActor(parsed.participant) } : {}),
|
|
2095
|
+
...(parsed.actorId && parsed.actorKind === 'agent'
|
|
2096
|
+
? { actorId: await this.canonicalAgentId(parsed.actorId) }
|
|
1436
2097
|
: {}),
|
|
1437
|
-
...(parsed.actorId ? { actorId: await this.canonicalAgentId(parsed.actorId) } : {}),
|
|
1438
2098
|
};
|
|
1439
2099
|
return await this.repository.listItemSummaries(resolved, this.actor);
|
|
1440
2100
|
}
|
|
@@ -1473,11 +2133,11 @@ export class SynomemCore {
|
|
|
1473
2133
|
void _cursor;
|
|
1474
2134
|
void _limit;
|
|
1475
2135
|
void _offset;
|
|
1476
|
-
const recipient = filters.
|
|
1477
|
-
? await this.
|
|
2136
|
+
const recipient = filters.recipient?.id
|
|
2137
|
+
? await this.resolveActor(filters.recipient)
|
|
1478
2138
|
: undefined;
|
|
1479
|
-
if (filters.
|
|
1480
|
-
throw new SynomemError('AGENT_NOT_FOUND', `Unknown agent: ${filters.
|
|
2139
|
+
if (filters.recipient?.id && !recipient) {
|
|
2140
|
+
throw new SynomemError('AGENT_NOT_FOUND', `Unknown agent: ${filters.recipient?.id}`);
|
|
1481
2141
|
}
|
|
1482
2142
|
const records = [];
|
|
1483
2143
|
let cursor;
|
|
@@ -1486,7 +2146,7 @@ export class SynomemCore {
|
|
|
1486
2146
|
while (hasMore) {
|
|
1487
2147
|
const page = await this.repository.listKudosSummaries({
|
|
1488
2148
|
...filters,
|
|
1489
|
-
...(recipient ? {
|
|
2149
|
+
...(recipient ? { recipient: { kind: recipient.kind, id: recipient.id } } : {}),
|
|
1490
2150
|
limit: 50,
|
|
1491
2151
|
offset: 0,
|
|
1492
2152
|
...(cursor ? { cursor } : {}),
|
|
@@ -1508,7 +2168,7 @@ export class SynomemCore {
|
|
|
1508
2168
|
byTag: {},
|
|
1509
2169
|
};
|
|
1510
2170
|
for (const record of includedRecords) {
|
|
1511
|
-
stats.byAgent[record.
|
|
2171
|
+
stats.byAgent[record.recipient?.id] = (stats.byAgent[record.recipient?.id] ?? 0) + 1;
|
|
1512
2172
|
const actor = `${record.actor.kind}:${record.actor.id}`;
|
|
1513
2173
|
stats.byActor[actor] = (stats.byActor[actor] ?? 0) + 1;
|
|
1514
2174
|
for (const tag of record.tags)
|
|
@@ -1523,8 +2183,8 @@ export class SynomemCore {
|
|
|
1523
2183
|
* check that silently lags the migration runner reports a healthy database as
|
|
1524
2184
|
* broken.
|
|
1525
2185
|
*/
|
|
1526
|
-
const CURRENT_SCHEMA_VERSION =
|
|
1527
|
-
const EXPECTED_APPLIED_MIGRATIONS = [1, 2, 3, 4, 5, 6, 7, 8];
|
|
2186
|
+
const CURRENT_SCHEMA_VERSION = 9;
|
|
2187
|
+
const EXPECTED_APPLIED_MIGRATIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9];
|
|
1528
2188
|
export class SynomemClient extends SynomemCore {
|
|
1529
2189
|
home;
|
|
1530
2190
|
storage;
|
|
@@ -1533,7 +2193,9 @@ export class SynomemClient extends SynomemCore {
|
|
|
1533
2193
|
const home = resolveHome(options.home);
|
|
1534
2194
|
const storage = new SynomemStorage({
|
|
1535
2195
|
home,
|
|
2196
|
+
...(options.actor ? { actor: options.actor } : {}),
|
|
1536
2197
|
readOnly: options.readOnly ?? false,
|
|
2198
|
+
...(options.authority ? { authority: options.authority } : {}),
|
|
1537
2199
|
...(options.config ? { config: options.config } : {}),
|
|
1538
2200
|
});
|
|
1539
2201
|
const projections = new ProjectionManager(storage);
|
|
@@ -1541,10 +2203,11 @@ export class SynomemClient extends SynomemCore {
|
|
|
1541
2203
|
repository: storage,
|
|
1542
2204
|
projectionWriter: projections,
|
|
1543
2205
|
...(options.actor ? { actor: options.actor } : {}),
|
|
1544
|
-
...(options.
|
|
2206
|
+
...(options.authority ? { authority: options.authority } : {}),
|
|
1545
2207
|
...(options.clock ? { clock: options.clock } : {}),
|
|
1546
2208
|
...(options.idGenerator ? { idGenerator: options.idGenerator } : {}),
|
|
1547
2209
|
...(options.signal ? { signal: options.signal } : {}),
|
|
2210
|
+
canWrite: !options.readOnly,
|
|
1548
2211
|
});
|
|
1549
2212
|
this.home = home;
|
|
1550
2213
|
this.storage = storage;
|
|
@@ -1714,7 +2377,33 @@ export class SynomemClient extends SynomemCore {
|
|
|
1714
2377
|
}
|
|
1715
2378
|
async export(format) {
|
|
1716
2379
|
this.checkAbort();
|
|
1717
|
-
const rows =
|
|
2380
|
+
const rows = [];
|
|
2381
|
+
const access = new Map();
|
|
2382
|
+
for (const row of this.storage.rawEventRows()) {
|
|
2383
|
+
let event;
|
|
2384
|
+
try {
|
|
2385
|
+
event = eventSchema.parse(JSON.parse(row.payload));
|
|
2386
|
+
}
|
|
2387
|
+
catch {
|
|
2388
|
+
if (this.administrative)
|
|
2389
|
+
rows.push(row);
|
|
2390
|
+
continue;
|
|
2391
|
+
}
|
|
2392
|
+
const rootId = 'rootId' in event ? event.rootId : event.aggregateId;
|
|
2393
|
+
if (event.type.startsWith('agent.')) {
|
|
2394
|
+
if (this.administrative)
|
|
2395
|
+
rows.push(row);
|
|
2396
|
+
continue;
|
|
2397
|
+
}
|
|
2398
|
+
if (!access.has(rootId))
|
|
2399
|
+
access.set(rootId, this.storage.canActorReadItem(rootId, this.actor));
|
|
2400
|
+
if (!access.get(rootId))
|
|
2401
|
+
continue;
|
|
2402
|
+
if (event.type === 'reply.created' &&
|
|
2403
|
+
(await this.storage.participation.getReply(event.id))?.deleted)
|
|
2404
|
+
continue;
|
|
2405
|
+
rows.push(row);
|
|
2406
|
+
}
|
|
1718
2407
|
if (format === 'jsonl')
|
|
1719
2408
|
return `${rows.map((row) => row.payload).join('\n')}\n`;
|
|
1720
2409
|
const exported = rows.map((row) => {
|
|
@@ -1728,7 +2417,8 @@ export class SynomemClient extends SynomemCore {
|
|
|
1728
2417
|
if (format === 'json')
|
|
1729
2418
|
return `${JSON.stringify(exported, null, 2)}\n`;
|
|
1730
2419
|
const scan = this.storage.scanEvents();
|
|
1731
|
-
const
|
|
2420
|
+
const allowedIds = new Set(rows.map((row) => row.id));
|
|
2421
|
+
const events = scan.events.filter((event) => allowedIds.has(event.id));
|
|
1732
2422
|
const records = recordsFromEvents(events);
|
|
1733
2423
|
const memos = memoRecordsFromEvents(events);
|
|
1734
2424
|
const notes = noteRecordsFromEvents(events);
|
|
@@ -1756,6 +2446,16 @@ export class SynomemClient extends SynomemCore {
|
|
|
1756
2446
|
this.checkAbort();
|
|
1757
2447
|
return {
|
|
1758
2448
|
backend: 'local',
|
|
2449
|
+
participation: {
|
|
2450
|
+
version: 2,
|
|
2451
|
+
replies: true,
|
|
2452
|
+
reactions: true,
|
|
2453
|
+
personalInbox: true,
|
|
2454
|
+
search: false,
|
|
2455
|
+
canWrite: !this.storage.readOnly,
|
|
2456
|
+
administrator: this.administrative,
|
|
2457
|
+
managedAgentIds: [...this.authority.operatedAgentIds],
|
|
2458
|
+
},
|
|
1759
2459
|
binding: { workspaceId: this.storage.config.workspaceId, actor: this.actor },
|
|
1760
2460
|
administration: {
|
|
1761
2461
|
agentCreationViaMcp: this.storage.config.allowAgentCreationViaMcp,
|
|
@@ -1771,7 +2471,23 @@ export class SynomemClient extends SynomemCore {
|
|
|
1771
2471
|
}
|
|
1772
2472
|
async getCanonicalEvent(id) {
|
|
1773
2473
|
this.checkAbort();
|
|
1774
|
-
|
|
2474
|
+
const event = this.storage.getEvent(id);
|
|
2475
|
+
if (!event)
|
|
2476
|
+
return undefined;
|
|
2477
|
+
if (event.type.startsWith('agent.'))
|
|
2478
|
+
return this.administrative ? event : undefined;
|
|
2479
|
+
const rootId = 'rootId' in event ? event.rootId : event.aggregateId;
|
|
2480
|
+
if (!this.storage.canActorReadItem(rootId, this.actor))
|
|
2481
|
+
throw new SynomemError('POLICY_FORBIDDEN', 'This event is not visible.');
|
|
2482
|
+
if (event.type === 'reply.created' &&
|
|
2483
|
+
(await this.storage.participation.getReply(event.id))?.deleted)
|
|
2484
|
+
return undefined;
|
|
2485
|
+
return event;
|
|
1775
2486
|
}
|
|
1776
2487
|
}
|
|
2488
|
+
export function createLocalOwnerClient(options = {}) {
|
|
2489
|
+
if (options.actor?.kind !== 'human')
|
|
2490
|
+
throw new SynomemError('POLICY_FORBIDDEN', 'Local owner administration requires an explicit human actor.');
|
|
2491
|
+
return new SynomemClient({ ...options, authority: localOwnerAuthority() });
|
|
2492
|
+
}
|
|
1777
2493
|
//# sourceMappingURL=client.js.map
|